class Ai
Constants
- ACTIVE_REQUESTS
- ACTIVE_REQUESTS_MUTEX
- AGENT_MODEL
- AUTONOMOUS_CHAT_INSTRUCTION
- AgentResult
-
CHAT_API_URL = “api.openai.com/v1/chat/completions” DEFAULT_MODEL = “gpt-4o-mini”
def self.build_request_body(prompt, model: DEFAULT_MODEL) JSON.generate( model: model, messages: [{ role: “user”, content: prompt.to_s }] ) end
def self.parse_response(json) data = JSON.parse(json) if data return data.dig(“error”, “message”) || “ChatGPT request failed” end
content = data.dig(“choices”, 0, “message”, “content”) content = content.to_s.strip content.empty? ? “No response” : content rescue JSON::ParserError “ChatGPT request failed” end
def self.ask_openai(prompt, fetch:, api_key: ENV) text = prompt.to_s.strip return “Please provide a question” if text.empty?
return “OpenAI API key not configured (set OPENAI_API_KEY)” if api_key.nil? || api_key.to_s.strip.empty?
json = fetch.call(text, api_key) parse_response(json) rescue StandardError “ChatGPT request failed” end
- CHANNEL_LOG_KEY
- CHANNEL_LOG_MUTEX
- CHAT_HISTORY
- CHAT_HISTORY_KEY
- CONTEXT_PATTERN
- COOLDOWN_OPTIONS_KEY
- COOLDOWN_SET_PATTERN
- COOLDOWN_SHOW_PATTERN
- EMPTY_PROMPT_QUESTION
- FEATURE_SUGGESTIONS
- FORGET_PATTERN
- GET_MODEL_PATTERN
- HISTORY_PATTERN
- IDLE_REPLY_INTROS
- INFO_PATTERN
- IRC_CTRL
- IRC_RESET
- LOCAL_NETWORK
- MAX_CHANNEL_LOG
- MAX_CHAT_CONTEXT
- MAX_CHAT_HISTORY
- MAX_CONTEXT_LINE_CHARS
- MAX_EMPTY_PROMPT_CONTEXT
- MAX_USER_MESSAGES
- MODEL_PRESETS_PATTERN
- NO_HISTORY_IDLE_REPLY
- REQUEST_ID_MUTEX
- SET_MODEL_PATTERN
- STOP_WORDS
- STYLE_DURATION
- STYLE_META
- STYLE_MODEL
- STYLE_MODEL_TAG
- STYLE_TOKENS
- STYLE_USAGE_API
- STYLE_USAGE_AUTO
- STYLE_USAGE_TOTAL
- TIMELESS_QUERY_PATTERN
- TLDR_INSTRUCTION
- TLDR_PATTERN
- TWO_DAYS_SEC
- USER_MESSAGES_KEY
- USER_MESSAGES_PLUGIN
- WEB_LOOKUP_SIGNAL_PATTERN
- WEB_OFF_PATTERN
- WEB_ON_PATTERN
- WEB_OPTIONS_KEY
- WEB_QUERY_STOP_WORDS
- WEB_SHOW_PATTERN
Public Class Methods
Source
# File plugins/ai.rb, line 836 def self.active_request_count ACTIVE_REQUESTS_MUTEX.synchronize { ACTIVE_REQUESTS.size } end
Source
# File plugins/ai.rb, line 756 def self.answer_card?(text) text.to_s.include?("\n") end
Source
# File plugins/ai.rb, line 1692 def self.append_channel_log(network:, channel:, nick:, text:, at: Time.now, store: RabbotDb, from_bot: false, is_command: false) AiChannelLog.append_entry( network: network, channel: channel, nick: nick, text: text, at: at, store: store, from_bot: from_bot, is_command: is_command ) end
Source
# File plugins/ai.rb, line 1813 def self.append_chat_history(channel, asker:, question:, answer:, network: nil, store: RabbotDb) channel = channel.to_s return if channel.empty? entry = { asker: asker.to_s.strip, question: sanitize_log_text(question), answer: sanitize_log_text(answer) } return if entry[:asker].empty? || entry[:question].empty? || entry[:answer].empty? CHANNEL_LOG_MUTEX.synchronize do CHAT_HISTORY[channel] ||= [] CHAT_HISTORY[channel] << entry CHAT_HISTORY[channel] = CHAT_HISTORY[channel].last(MAX_CHAT_HISTORY) end persist_chat_history_entry( network: network, channel: channel, entry: entry, store: store ) end
Source
# File plugins/ai.rb, line 1666 def self.append_log(channel, nick:, text:, network: nil, at: Time.now, store: RabbotDb, from_bot: false, is_command: false) channel = channel.to_s return if channel.empty? entry = { nick: nick.to_s.strip, text: sanitize_log_text(text), at: store.iso_time(at), from_bot: from_bot, is_command: is_command } return if entry[:nick].empty? || entry[:text].empty? append_channel_log( network: resolve_network(network), channel: channel, nick: entry[:nick], text: entry[:text], at: at, store: store, from_bot: from_bot, is_command: is_command ) end
Source
# File plugins/ai.rb, line 2069 def self.append_user_message(network:, channel:, nick:, text:, at: Time.now, store: RabbotDb) network = store.normalize_network(network) channel = store.normalize_channel(channel) nick = store.normalize_nick(nick) text = sanitize_log_text(text) return if nick.empty? || text.empty? entries = Array( AiPluginStorage.get_json( key: USER_MESSAGES_KEY, network: network, channel: channel, nick: nick, default: [], store: store ) ) entries << { "text" => text, "at" => store.iso_time(at) } entries = entries.last(MAX_USER_MESSAGES) AiPluginStorage.set_json( key: USER_MESSAGES_KEY, value: entries, network: network, channel: channel, nick: nick, store: store ) end
Source
# File plugins/ai.rb, line 2185 def self.ask(prompt, network: nil, channel: nil, yaml_default: nil, model: nil, runner: method(:default_runner), usage_fetch: method(:default_usage_fetch)) model = resolve_model(network: network, channel: channel, yaml_default: yaml_default, model: model) text = prompt.to_s.strip return "Please provide a question" if text.empty? return AiGuard.rejection_message if AiGuard.system_request?(text) result = run_agent(text, model: model, runner: runner) return result.text unless result.success? usage_percents = usage_fetch.call format_answer( result.text, model: result.model, total_tokens: result.total_tokens, duration_s: result.duration_s, usage_percents: usage_percents ) rescue StandardError "AI request failed" end
Source
# File plugins/ai.rb, line 234 def self.build_agent_prompt(question:, context_lines:, chat_history_lines:, asker:, channel:, empty_prompt_channel_lines: [], episode_context_lines: [], profile: nil, web_context_lines: [], reference_context_lines: [], link_context_lines: []) base = AiAgent.build_agent_prompt( question: question, context_lines: context_lines, chat_history_lines: chat_history_lines, empty_prompt_channel_lines: empty_prompt_channel_lines, episode_context_lines: episode_context_lines, asker: asker, channel: channel, profile: profile ) base = inject_web_context(base, web_context_lines, asker: asker, question: question) base = inject_reference_context(base, reference_context_lines, asker: asker, question: question) inject_link_context(base, link_context_lines, asker: asker, question: question) end
Source
# File plugins/ai.rb, line 977 def self.build_channel_what_is_plan(text, asker:, channel:, network: nil, channel_obj: nil, user: nil, channels: nil, yaml_default: nil, models_runner: method(:default_models_runner)) question = parse_what_is_question(text) return nil unless question build_execute_plan( question, asker: asker, channel: channel, network: network, channel_obj: channel_obj, user: user, channels: channels, yaml_default: yaml_default, models_runner: models_runner ) end
Source
# File plugins/ai.rb, line 996 def self.build_channel_where_is_plan(text, asker:, channel:, network: nil, channel_obj: nil, user: nil, channels: nil, yaml_default: nil, models_runner: method(:default_models_runner)) question = parse_where_is_question(text) return nil unless question build_execute_plan( question, asker: asker, channel: channel, network: network, channel_obj: channel_obj, user: user, channels: channels, yaml_default: yaml_default, models_runner: models_runner ) end
Source
# File plugins/ai.rb, line 1991 def self.build_chat_history_lines(channel, question, history: CHAT_HISTORY, network: nil, store: RabbotDb) channel = channel.to_s return [] if channel.empty? entries = if network chat_history_snapshot(channel, network: network, store: store) else history[channel] || [] end select_chat_history_entries(entries, question).flat_map { |entry| format_chat_history_exchange(entry) } end
Source
# File plugins/ai.rb, line 1942 def self.build_context_lines(channel, question, network: nil, log: nil, store: RabbotDb, asker: nil) channel = channel.to_s return [] if channel.empty? entries = if log log[channel] || [] else channel_log_db_entries(network: resolve_network(network), channel: channel, store: store) end entries = AiContext.regular_context_channel_entries(entries) select_context_entries(entries, question, asker: asker).map { |entry| format_context_line(entry) } end
Source
# File plugins/ai.rb, line 1774 def self.build_empty_prompt_channel_lines(network:, channel:, store: RabbotDb) select_empty_prompt_channel_entries( channel_log_entries(network: network, channel: channel, store: store) ).map { |entry| format_channel_context_line(entry) } end
Source
# File plugins/ai.rb, line 1607 def self.build_episode_context_lines(episode) return [] unless episode.is_a?(Hash) lines = ["Title: #{episode['title']}"] code = AthfEpisodeIndex.episode_code(episode) lines << "Code: #{code}" if code lines << "Overall: ##{episode['overall']}" if episode["overall"] lines << "Air date: #{episode['air_date']}" if episode["air_date"] summary = AthfEpisodeIndex.summary(episode["extract"]) lines << "Summary: #{summary}" unless summary.empty? url = episode["url"].to_s.strip lines << "Source: #{url}" unless url.empty? lines end
Source
# File plugins/ai.rb, line 1075 def self.build_execute_plan(question, asker:, channel:, network: nil, channel_obj: nil, user: nil, channels: nil, yaml_default: nil, models_runner: method(:default_models_runner)) models_cmd = models_command?(question) if models_cmd return { immediate_reply: list_models( base: models_cmd[:base], version: models_cmd[:version], models_runner: models_runner ), async: false } end model_cmd = model_command?(question) if model_cmd case model_cmd[:action] when :presets return { immediate_reply: format_model_presets_reply, async: false } when :set if channel_obj && user && network && !model_set_allowed?(channel: channel_obj, user: user, network: network, nick: asker) return { immediate_reply: model_set_denied_message, async: false } end requested = model_cmd[:model] resolved = resolve_model_alias(requested) preset = AiModelPresets.canonical_name(requested) if AiModelPresets.preset?(requested) persist_model!( network: network, channel: channel, model: resolved, channels: channels ) return { immediate_reply: format_model_set_reply(model: resolved, preset: preset), async: false } when :get return { immediate_reply: format_model_show_reply( network: network, channel: channel, yaml_default: yaml_default ), async: false } end end web_cmd = web_command?(question) if web_cmd case web_cmd[:action] when :show return { immediate_reply: format_web_status_reply(network: network, channel: channel), async: false } when :on set_web_enabled!(network: network, channel: channel, enabled: true) return { immediate_reply: format_web_toggle_reply(enabled: true), async: false } when :off if web_cmd[:question].nil? set_web_enabled!(network: network, channel: channel, enabled: false) return { immediate_reply: format_web_toggle_reply(enabled: false), async: false } end end end if history_command?(question) if channel_obj && user && network && !AiAccess.user_allowed?(channel: channel_obj, user: user, network: network, nick: asker, irc: true) return { immediate_reply: AiAccess.denial_message, async: false } end return { immediate_reply: format_history_reply(channel: channel, network: network), async: false } end if info_command?(question) if channel_obj && user && network && !AiAccess.user_allowed?(channel: channel_obj, user: user, network: network, nick: asker, irc: true) return { immediate_reply: AiAccess.denial_message, async: false } end return { immediate_reply: format_info_reply, async: false } end if tldr_command?(question) if channel_obj && user && network && !AiAccess.user_allowed?(channel: channel_obj, user: user, network: network, nick: asker, irc: true) return { immediate_reply: AiAccess.denial_message, async: false } end entry = last_chat_history_entry(channel, network: network) unless entry return { immediate_reply: format_tldr_no_history_reply, async: false } end if network && channel && asker cooldown = resolve_cooldown_sec(network: network, channel: channel) if AiRateLimit.rate_limited?( network: network, channel: channel, nick: asker, cooldown_sec: cooldown ) return { immediate_reply: AiRateLimit.cooldown_message(cooldown_sec: cooldown), async: false } end end return { immediate_reply: nil, async: true, channel: channel, asker: asker, network: network, tldr: true, tldr_entry: entry } end forget_cmd = forget_command?(question) if forget_cmd if channel_obj && user && network && !AiAccess.user_allowed?(channel: channel_obj, user: user, network: network, nick: asker, irc: true) return { immediate_reply: AiAccess.denial_message, async: false } end target_nick = forget_cmd[:target_nick] || asker if forget_admin_action?(forget_cmd) && channel_obj && user && network && !forget_allowed?(channel: channel_obj, user: user, network: network, nick: asker) return { immediate_reply: forget_denied_message, async: false } end case forget_cmd[:action] when :history clear_chat_history!(network: network, channel: channel) return { immediate_reply: format_forget_reply(action: :history, nick: asker), async: false } when :messages clear_user_messages!(network: network, channel: channel, nick: target_nick) return { immediate_reply: format_forget_reply(action: :messages, nick: target_nick), async: false } end end context_cmd = context_command?(question) if context_cmd if channel_obj && user && network && !AiAccess.user_allowed?(channel: channel_obj, user: user, network: network, nick: asker, irc: true) return { immediate_reply: AiAccess.denial_message, async: false } end return { immediate_reply: format_context_reply( channel: channel, network: network, question: context_cmd[:question], asker: asker ), async: false } end cooldown_cmd = cooldown_command?(question) if cooldown_cmd case cooldown_cmd[:action] when :show return { immediate_reply: format_cooldown_show_reply(network: network, channel: channel), async: false } when :set if channel_obj && user && network && !cooldown_set_allowed?(channel: channel_obj, user: user, network: network, nick: asker) return { immediate_reply: cooldown_set_denied_message, async: false } end persist_cooldown!( network: network, channel: channel, seconds: cooldown_cmd[:seconds], channels: channels ) return { immediate_reply: format_cooldown_set_reply(seconds: cooldown_cmd[:seconds]), async: false } end end if channel_obj && user && network && !AiAccess.user_allowed?(channel: channel_obj, user: user, network: network, nick: asker, irc: true) return { immediate_reply: AiAccess.denial_message, async: false } end async_question = question.to_s skip_web = false if web_cmd && web_cmd[:action] == :off && web_cmd[:question] async_question = web_cmd[:question] skip_web = true end if network && channel && asker cooldown = resolve_cooldown_sec(network: network, channel: channel) if AiRateLimit.rate_limited?( network: network, channel: channel, nick: asker, cooldown_sec: cooldown ) return { immediate_reply: AiRateLimit.cooldown_message(cooldown_sec: cooldown), async: false } end end stripped = async_question.strip if empty_prompt?(stripped) if channel_has_recent_messages?(network: network, channel: channel) return { immediate_reply: nil, async: true, channel: channel, asker: asker, network: network, empty_prompt: true, question: stripped, skip_web: skip_web } end return { immediate_reply: idle_reply(channel: channel), async: false } end if AiGuard.system_request?(async_question) return { immediate_reply: AiGuard.rejection_message, async: false } end { immediate_reply: nil, async: true, channel: channel, asker: asker, network: network, question: async_question, skip_web: skip_web } end
Source
# File plugins/ai.rb, line 356 def self.build_link_context_lines(question, fetch: AiUrlContext.method(:default_fetch)) AiUrlContext.build_context_lines(question, fetch: fetch) end
Source
# File plugins/ai.rb, line 369 def self.build_reference_context_lines(question, channel_users: nil, geo_search: Geocoder.method(:search), web_fetch: method(:default_web_fetch), wiki_fetch: AiWikiContext.default_wiki_fetch) return [] unless AiLocationContext.where_is_question?(question) lines = AiLocationContext.build_context_lines( question, channel_users: channel_users, search: geo_search, web_fetch: ->(query) { fetch_web_lookup(query, fetch: web_fetch) } ) lines.concat( AiWikiContext.build_context_lines(question, fetch: wiki_fetch) ) lines end
Source
# File plugins/ai.rb, line 418 def self.build_tldr_prompt(entry) [ TLDR_INSTRUCTION, "", "#{entry[:asker]} asked: #{entry[:question]}", "Rabbot answered: #{entry[:answer]}", "", "TL;DR:" ].join("\n") end
Source
# File plugins/ai.rb, line 2133 def self.build_user_message_lines(network:, channel:, nick:, store: RabbotDb) collect_user_message_entries(network: network, channel: channel, nick: nick, store: store).map do |entry| format_user_message_line(text: entry[:text], at: entry[:at]) end end
Source
# File plugins/ai.rb, line 343 def self.build_web_context_lines(question, fetch: method(:default_web_fetch), network: nil, channel: nil) return [] unless web_lookup_enabled?(network: network, channel: channel) return [] unless needs_web_lookup?(question) lookup = fetch_web_lookup(question, fetch: fetch) return [] unless lookup[:found] [ "Query: #{lookup[:query]}", "Result: #{lookup[:text]}" ] end
Source
# File plugins/ai.rb, line 308 def self.build_web_search_query(question) text = question.to_s.strip.squeeze(" ") return "" if text.empty? version_tokens = text.scan(/\b(?:v)?\d+(?:\.\d+){1,3}\b/i).map(&:downcase).uniq version_parts = version_tokens.flat_map { |token| token.gsub(/\Av/i, "").split(".") } tokens = normalize_text(text).split.reject do |word| STOP_WORDS.include?(word) || WEB_QUERY_STOP_WORDS.include?(word) || word.length < 2 || version_parts.include?(word) end (version_tokens + tokens).uniq.join(" ") end
Source
# File plugins/ai.rb, line 2153 def self.capture_recipe_from_answer!(network:, channel:, nick:, text:, store: RabbotDb, received_at: nil) AiRecipeCapture.capture_from_answer!( network: network, channel: channel, nick: nick, text: text, store: store, received_at: received_at ) end
Source
# File plugins/ai.rb, line 1759 def self.channel_has_recent_messages?(network:, channel:, store: RabbotDb) channel_log_entries(network: network, channel: channel, store: store).any? end
Source
# File plugins/ai.rb, line 1710 def self.channel_log_db_entries(network:, channel:, store: RabbotDb) network = store.normalize_network(network) channel = store.normalize_channel(channel) return [] if channel.empty? Array( AiPluginStorage.get_json( key: CHANNEL_LOG_KEY, network: network, channel: channel, default: [], store: store ) ).map { |entry| normalize_channel_log_entry(entry) } end
Source
# File plugins/ai.rb, line 1743 def self.channel_log_db_snapshot(network:, channel:, store: RabbotDb) channel_log_db_entries(network: network, channel: channel, store: store).map do |entry| { nick: entry[:nick], text: entry[:text] } end end
Source
# File plugins/ai.rb, line 1752 def self.channel_log_entries(network:, channel:, store: RabbotDb) channel = channel.to_s return [] if channel.empty? channel_log_db_entries(network: resolve_network(network), channel: channel, store: store) end
Source
# File plugins/ai.rb, line 1706 def self.channel_log_snapshot(channel, network: nil, store: RabbotDb) channel_log_db_snapshot(network: resolve_network(network), channel: channel, store: store) end
Source
# File plugins/ai.rb, line 961 def self.channel_what_is_trigger?(text) AiWhatIsTrigger.channel_trigger?(text) end
Source
# File plugins/ai.rb, line 969 def self.channel_where_is_trigger?(text) AiWhereIsTrigger.channel_trigger?(text) end
Source
# File plugins/ai.rb, line 1870 def self.chat_history_db_entries(network:, channel:, store: RabbotDb) network = store.normalize_network(resolve_network(network)) channel = store.normalize_channel(channel) return [] if channel.empty? Array( AiPluginStorage.get_json( key: CHAT_HISTORY_KEY, network: network, channel: channel, default: [], store: store ) ).map do |entry| { asker: entry["asker"].to_s, question: entry["question"].to_s, answer: entry["answer"].to_s } end end
Source
# File plugins/ai.rb, line 1892 def self.chat_history_snapshot(channel, network: nil, store: RabbotDb) channel = channel.to_s if network return chat_history_db_entries(network: network, channel: channel, store: store) end CHANNEL_LOG_MUTEX.synchronize do (CHAT_HISTORY[channel] || []).map(&:dup) end end
Source
# File plugins/ai.rb, line 822 def self.clear_active_requests! ACTIVE_REQUESTS_MUTEX.synchronize { ACTIVE_REQUESTS.clear } end
Source
# File plugins/ai.rb, line 816 def self.clear_channel_log! CHANNEL_LOG_MUTEX.synchronize do CHAT_HISTORY.clear end end
Source
# File plugins/ai.rb, line 506 def self.clear_chat_history!(network:, channel:, store: RabbotDb) channel_key = channel.to_s CHANNEL_LOG_MUTEX.synchronize do CHAT_HISTORY.delete(channel_key) end return if network.nil? || network.to_s.strip.empty? network = store.normalize_network(network) channel = store.normalize_channel(channel) return if channel.empty? AiPluginStorage.set_json( key: CHAT_HISTORY_KEY, value: [], network: network, channel: channel, store: store ) end
Source
# File plugins/ai.rb, line 490 def self.clear_user_messages!(network:, channel:, nick:, store: RabbotDb) network = store.normalize_network(network) channel = store.normalize_channel(channel) nick = store.normalize_nick(nick) return if nick.empty? AiPluginStorage.set_json( key: USER_MESSAGES_KEY, value: [], network: network, channel: channel, nick: nick, store: store ) end
Source
# File plugins/ai.rb, line 2062 def self.collect_user_message_entries(network:, channel:, nick:, store: RabbotDb) messages = user_messages_snapshot(network: network, channel: channel, nick: nick, store: store) return messages if messages.any? user_messages_from_channel_log(network: network, channel: channel, nick: nick, store: store) end
Source
# File plugins/ai.rb, line 544 def self.context_command?(question) text = question.to_s.strip match = text.match(CONTEXT_PATTERN) return nil unless match { question: match[1].to_s.strip } end
Source
# File plugins/ai.rb, line 632 def self.cooldown_admin_command?(question) cmd = cooldown_command?(question) cmd && cmd[:action] == :set end
Source
# File plugins/ai.rb, line 610 def self.cooldown_command?(question) text = question.to_s.strip return { action: :show } if text.match?(COOLDOWN_SHOW_PATTERN) if (match = text.match(COOLDOWN_SET_PATTERN)) return { action: :set, seconds: match[1].to_i } end nil end
Source
# File plugins/ai.rb, line 624 def self.cooldown_set_allowed?(channel:, user:, network:, nick:) model_set_allowed?(channel: channel, user: user, network: network, nick: nick) end
Source
# File plugins/ai.rb, line 620 def self.cooldown_set_denied_message "Only channel ops and registered admins can set the !ai cooldown." end
Source
# File plugins/ai.rb, line 628 def self.cooldown_set_responder?(bot) BotIdentity.owner_bot?(bot) end
Source
# File plugins/ai.rb, line 1028 def self.default_models_runner CursorAgentRunner.capture3("cursor-agent", "models", lane: :chat) end
Source
# File plugins/ai.rb, line 795 def self.default_runner(prompt, model) AiAgent.default_runner(prompt, model) end
Source
# File plugins/ai.rb, line 1353 def self.default_usage_fetch fetch_usage_percents end
Source
# File plugins/ai.rb, line 322 def self.default_web_fetch lambda do |query| url = "#{Duck::DDG_HTML_URL}?q=#{CGI.escape(query.to_s)}" RabbotHttp.fetch(url, user_agent: "Mozilla/5.0 (compatible; Rabbot/1.0)") end end
Source
# File plugins/ai.rb, line 1044 def self.empty_prompt?(question) question.to_s.strip.empty? end
Source
# File plugins/ai.rb, line 1326 def self.fetch_usage_percents(token: Cursorusage.session_token, fetch: Cursorusage.method(:default_fetch), cache_ttl: Cursorusage::DEFAULT_CACHE_TTL_SEC, now: Time.now, use_cache: true) token = token.to_s.strip return nil if token.empty? response = Cursorusage.fetch_usage_summary( token: token, fetch: fetch, cache_ttl: cache_ttl, now: now, use_cache: use_cache ) return nil if response[:status] == 401 summary = Cursorusage.parse_usage_summary(response[:body]) return nil unless summary[:success] { auto_percent_used: summary[:auto_percent_used], api_percent_used: summary[:api_percent_used], total_percent_used: summary[:total_percent_used], billing_cycle_end: summary[:billing_cycle_end] } rescue StandardError nil end
Source
# File plugins/ai.rb, line 329 def self.fetch_web_lookup(question, fetch: method(:default_web_fetch)) query = build_web_search_query(question) return { found: false, query: query, text: nil } if query.empty? result = Duck.search(query, fetch: fetch) if result == "No results found" return { found: false, query: query, text: nil } end { found: true, query: query, text: result } rescue StandardError { found: false, query: query, text: nil } end
Source
# File plugins/ai.rb, line 2007 def self.filter_entries_by_nick(entries, nick) entries.select { |entry| nick_matches?(entry[:nick] || entry["nick"], nick) } end
Source
# File plugins/ai.rb, line 1923 def self.follow_up_question?(question) AiContext.follow_up_question?(question) end
Source
# File plugins/ai.rb, line 476 def self.forget_admin_action?(cmd) cmd[:action] == :history || !cmd[:target_nick].nil? end
Source
# File plugins/ai.rb, line 480 def self.forget_allowed?(channel:, user:, network:, nick:) RabbotDb.user_allowed_at_level?( network: network, channel: channel.name, nick: nick, is_op: channel.opped?(user), minimum: RabbotDb::LEVEL_ADMIN ) end
Source
# File plugins/ai.rb, line 453 def self.forget_command?(question) text = question.to_s.strip match = text.match(FORGET_PATTERN) return nil unless match remainder = match[1].to_s.strip return { action: :messages, target_nick: nil } if remainder.empty? down = remainder.downcase return { action: :history } if down == "history" if (parts = remainder.match(/\Amessages(?:\s+(.+))?\z/i)) nick = parts[1].to_s.strip return { action: :messages, target_nick: nick.empty? ? nil : nick } end nil end
Source
# File plugins/ai.rb, line 472 def self.forget_denied_message "Only channel ops and registered admins can clear !ai history or other users' messages." end
Source
# File plugins/ai.rb, line 2164 def self.format_answer(text, model:, total_tokens:, duration_s:, usage_percents: nil, now: Time.now, question: nil, web_used: false, recipe_saved: false, geo_used: false, wiki_used: false, link_used: false) body = text.to_s.strip body = wrap_answer_card(body, question: question) if answer_card?(body) result = AiSummary.format_answer( body, model: model, total_tokens: total_tokens, duration_s: duration_s, usage_percents: usage_percents, now: now ) result = append_web_footer_segment(result) if web_used if geo_used || wiki_used || link_used result = append_enrichment_footer_segment(result, geo: geo_used, wiki: wiki_used, link: link_used) end result = AiRecipeCapture.append_recipe_saved_hint(result) if recipe_saved result end
Source
# File plugins/ai.rb, line 1763 def self.format_channel_context_line(entry) nick = (entry[:nick] || entry["nick"]).to_s text = (entry[:text] || entry["text"]).to_s at = entry[:at] || entry["at"] "[#{format_message_timestamp(at)}] #{nick}: #{truncate_context_line(text)}" end
Source
# File plugins/ai.rb, line 1955 def self.format_chat_history_exchange(entry) AiChatHistory.format_exchange(entry, max_chars: MAX_CONTEXT_LINE_CHARS) end
Source
# File plugins/ai.rb, line 1934 def self.format_context_line(entry) "#{entry[:nick]}: #{truncate_context_line(entry[:text])}" end
Source
# File plugins/ai.rb, line 561 def self.format_context_reply(channel:, network:, question:, asker: nil) q = question.to_s.strip return format_context_usage_reply if q.empty? context_lines = build_context_lines(channel, q, network: network, asker: asker) chat_lines = build_chat_history_lines(channel, q, network: network) web_needed = needs_web_lookup?(q) web_query = build_web_search_query(q) if web_needed lines = [ IrcFormat.report_header(tag: "AI", title: "Context preview", style: STYLE_MODEL_TAG) ] if context_lines.empty? && chat_lines.empty? lines << "No channel log or !ai history matched." else context_lines.each { |line| lines << "IRC: #{line}" } chat_lines.each { |line| lines << "History: #{line}" } end if web_needed lines << "Web: would search — #{web_query}" end link_planned = link_fetch_planned?(q) if link_planned url = AiUrlContext.extract_url(q) lines << "Link: would fetch — #{AiUrlContext.resolve_fetch_url(url)}" end parts = [] parts << "#{context_lines.length} channel" unless context_lines.empty? parts << "#{chat_lines.length / 2} exchange#{'s' unless chat_lines.length <= 2}" unless chat_lines.empty? parts << "web" if web_needed parts << "link" if link_planned parts = ["no matches"] if parts.empty? lines << IrcFormat.report_footer("context", parts.join(" · ")) lines.join("\n") end
Source
# File plugins/ai.rb, line 552 def self.format_context_usage_reply lines = [ IrcFormat.report_header(tag: "AI", title: "Context preview", style: STYLE_MODEL_TAG), "Usage: !ai context <question>", IrcFormat.report_footer("context", "no agent call") ] lines.join("\n") end
Source
# File plugins/ai.rb, line 664 def self.format_cooldown_set_reply(seconds:) lines = [ IrcFormat.report_header(tag: "AI", title: "Cooldown updated", style: STYLE_MODEL_TAG), "Per-user cooldown is now #{seconds}s.", IrcFormat.report_footer("cooldown", "persisted") ] lines.join("\n") end
Source
# File plugins/ai.rb, line 654 def self.format_cooldown_show_reply(network:, channel:) seconds = resolve_cooldown_sec(network: network, channel: channel) lines = [ IrcFormat.report_header(tag: "AI", title: "Rate limit", style: STYLE_MODEL_TAG), "Cooldown is #{seconds}s per user.", IrcFormat.report_footer("cooldown", "#{seconds}s", channel.to_s) ] lines.join("\n") end
Source
# File plugins/ai.rb, line 1371 def self.format_cycle_remaining(cycle_end, now: Time.now) return "" if cycle_end.to_s.strip.empty? end_time = Time.parse(cycle_end).utc remaining = end_time - now.utc return "0m" if remaining <= 0 return "#{ (remaining / 86_400).floor }d" if remaining >= TWO_DAYS_SEC return "#{ (remaining / 3600).floor }h" if remaining >= 3600 minutes = [(remaining / 60).floor, 1].max "#{minutes}m" rescue ArgumentError, TypeError "" end
Source
# File plugins/ai.rb, line 526 def self.format_forget_reply(action:, nick:) case action when :history lines = [ IrcFormat.report_header(tag: "AI", title: "History cleared", style: STYLE_MODEL_TAG), "Recent !ai exchanges removed for this channel.", IrcFormat.report_footer("forget", "history") ] else lines = [ IrcFormat.report_header(tag: "AI", title: "Messages cleared", style: STYLE_MODEL_TAG), "Stored messages removed for #{nick}.", IrcFormat.report_footer("forget", "messages") ] end lines.join("\n") end
Source
# File plugins/ai.rb, line 739 def self.format_history_reply(channel:, network: nil, store: RabbotDb) entries = chat_history_snapshot(channel, network: network, store: store) return NO_HISTORY_IDLE_REPLY if entries.empty? lines = [ IrcFormat.report_header(tag: "AI", title: "Recent exchanges", style: STYLE_MODEL_TAG) ] entries.each do |entry| asker = entry[:asker] question = truncate_context_line(entry[:question]) answer = truncate_context_line(entry[:answer]) lines << "#{asker} — #{question} — #{answer}" end lines << IrcFormat.report_footer("#{entries.length} exchanges", channel.to_s) lines.join("\n") end
Source
# File plugins/ai.rb, line 1054 def self.format_idle_history_reply(entry, intro:) asker = entry[:asker] question = truncate_context_line(entry[:question]) answer = truncate_context_line(entry[:answer]) "#{intro} #{asker} asked \"#{question}\" — #{answer}" end
Source
# File plugins/ai.rb, line 442 def self.format_info_reply lines = [ IrcFormat.report_header(tag: "AI", title: "Feature ideas", style: STYLE_MODEL_TAG) ] FEATURE_SUGGESTIONS.each_with_index do |item, index| lines << "#{index + 1}. #{item[:title]} — #{item[:summary]}" end lines << IrcFormat.report_footer("info only", "5 ideas") lines.join("\n") end
Source
# File plugins/ai.rb, line 2122 def self.format_message_timestamp(at) time = at.is_a?(Time) ? at : RabbotDb.parse_time(at) return "?" unless time time.utc.strftime("%Y-%m-%d %H:%M UTC") end
Source
# File plugins/ai.rb, line 934 def self.format_model_presets_reply lines = [ IrcFormat.report_header(tag: "AI", title: "Model presets", style: STYLE_MODEL_TAG) ] AiModelPresets.listing.each { |line| lines << line } lines << IrcFormat.report_footer("presets", "#{AiModelPresets.names.length} options", "!ai model <preset>") lines.join("\n") end
Source
# File plugins/ai.rb, line 924 def self.format_model_set_reply(model:, preset: nil) lines = [ IrcFormat.report_header(tag: "AI", title: "Model updated", style: STYLE_MODEL_TAG), model.to_s ] lines << "via preset: #{preset}" if preset lines << IrcFormat.report_footer("model", "persisted for this bot") lines.join("\n") end
Source
# File plugins/ai.rb, line 943 def self.format_model_show_reply(network:, channel:, yaml_default: nil) model = resolve_model( network: network, channel: channel, yaml_default: yaml_default ) lines = [ IrcFormat.report_header(tag: "AI", title: "Current model", style: STYLE_MODEL_TAG), model.to_s, IrcFormat.report_footer("model", "per bot") ] lines.join("\n") end
Source
# File plugins/ai.rb, line 1396 def self.format_stats_percentages(usage_percents) AiSummary.format_stats_percentages(usage_percents) end
Source
# File plugins/ai.rb, line 429 def self.format_tldr_no_history_reply lines = [ IrcFormat.report_header(tag: "AI", title: "TLDR", style: STYLE_MODEL_TAG), "No recent !ai exchange to summarize in this channel.", IrcFormat.report_footer("tldr", "no history") ] lines.join("\n") end
Source
# File plugins/ai.rb, line 1400 def self.format_tldr_reply(text, model:, total_tokens:, duration_s:, usage_percents: nil, now: Time.now, recipe_saved: false) body_lines = text.to_s.strip.split("\n").map(&:strip).reject(&:empty?) header = IrcFormat.report_header(tag: "AI", title: "TLDR", style: STYLE_MODEL_TAG) lines = [header] + body_lines result = AiSummary.format_answer( lines.join("\n"), model: model, total_tokens: total_tokens, duration_s: duration_s, usage_percents: usage_percents, now: now ) recipe_saved ? AiRecipeCapture.append_recipe_saved_hint(result) : result end
Source
# File plugins/ai.rb, line 1357 def self.format_usage_percent(value, style, label: nil) if label format("#{style}#{label}%d%%#{IRC_RESET}", value.to_i) else AiSummary.format_usage_percent(value, style) end end
Source
# File plugins/ai.rb, line 2129 def self.format_user_message_line(text:, at:) "[#{format_message_timestamp(at)}] #{truncate_context_line(text)}" end
Source
# File plugins/ai.rb, line 706 def self.format_web_status_reply(network:, channel:) enabled = web_lookup_enabled?(network: network, channel: channel) state = enabled ? "on" : "off" lines = [ IrcFormat.report_header(tag: "AI", title: "Web lookup", style: STYLE_MODEL_TAG), "Channel web search is #{state}.", IrcFormat.report_footer("web", state, channel.to_s) ] lines.join("\n") end
Source
# File plugins/ai.rb, line 717 def self.format_web_toggle_reply(enabled:) state = enabled ? "on" : "off" lines = [ IrcFormat.report_header(tag: "AI", title: "Web lookup updated", style: STYLE_MODEL_TAG), "Channel web search is now #{state}.", IrcFormat.report_footer("web", state) ] lines.join("\n") end
Source
# File plugins/ai.rb, line 1630 def self.generate_autonomous_message(channel:, network:, nick:, profile: nil, episode: nil, topic: nil, runner: method(:default_runner), model: nil, yaml_default: nil, store: RabbotDb) question = AUTONOMOUS_CHAT_INSTRUCTION topic_text = topic.to_s.strip question = "#{question} Optional topic to weave in: #{topic_text}." unless topic_text.empty? return nil if AiGuard.system_request?(question) episode_context_lines = build_episode_context_lines(episode) empty_prompt_channel_lines = build_empty_prompt_channel_lines(network: network, channel: channel, store: store) agent_prompt = build_agent_prompt( question: question, context_lines: [], chat_history_lines: [], empty_prompt_channel_lines: empty_prompt_channel_lines, episode_context_lines: episode_context_lines, asker: nick, channel: channel, profile: profile ) result = run_agent( agent_prompt, model: model, network: network, channel: channel, yaml_default: yaml_default, runner: runner ) return nil unless result.success? ChatModeSession.sanitize_output(result.text) rescue StandardError nil end
Source
# File plugins/ai.rb, line 406 def self.history_command?(question) question.to_s.strip.match?(HISTORY_PATTERN) end
Source
# File plugins/ai.rb, line 1061 def self.idle_reply(channel:, random: Random.new, history: CHAT_HISTORY) entries = if history.equal?(CHAT_HISTORY) chat_history_snapshot(channel) else (history[channel.to_s] || []).map(&:dup) end entry = pick_history_entry(entries, random: random) return NO_HISTORY_IDLE_REPLY if entry.nil? intro = IDLE_REPLY_INTROS[random.rand(IDLE_REPLY_INTROS.length)] format_idle_history_reply(entry, intro: intro) end
Source
# File plugins/ai.rb, line 438 def self.info_command?(question) question.to_s.strip.match?(INFO_PATTERN) end
Source
# File plugins/ai.rb, line 283 def self.inject_link_context(prompt, link_context_lines, asker:, question:) lines = Array(link_context_lines).map(&:to_s).reject(&:empty?) return prompt if lines.empty? section = [ "Linked page content (use if relevant; may be outdated):", *lines, "" ].join("\n") marker = "Question from #{asker}: #{question.to_s.strip}" return "#{prompt}\n\n#{section}" unless prompt.include?(marker) prompt.sub(marker, "#{section}#{marker}") end
Source
# File plugins/ai.rb, line 253 def self.inject_reference_context(prompt, reference_context_lines, asker:, question:) lines = Array(reference_context_lines).map(&:to_s).reject(&:empty?) return prompt if lines.empty? section = [ "Reference data (use if relevant; may be outdated):", *lines, "" ].join("\n") marker = "Question from #{asker}: #{question.to_s.strip}" return "#{prompt}\n\n#{section}" unless prompt.include?(marker) prompt.sub(marker, "#{section}#{marker}") end
Source
# File plugins/ai.rb, line 268 def self.inject_web_context(prompt, web_context_lines, asker:, question:) lines = Array(web_context_lines).map(&:to_s).reject(&:empty?) return prompt if lines.empty? section = [ "Web search results (use if relevant; may be outdated):", *lines, "" ].join("\n") marker = "Question from #{asker}: #{question.to_s.strip}" return "#{prompt}\n\n#{section}" unless prompt.include?(marker) prompt.sub(marker, "#{section}#{marker}") end
Source
# File plugins/ai.rb, line 181 def self.irc_color(fg, bg = nil) AiSummary.irc_color(fg, bg) end
Source
# File plugins/ai.rb, line 414 def self.last_chat_history_entry(channel, network: nil, store: RabbotDb) chat_history_snapshot(channel, network: network, store: store).last end
Source
# File plugins/ai.rb, line 360 def self.link_fetch_planned?(question) url = AiUrlContext.extract_url(question) return false unless url return false unless AiUrlContext.safe_url?(url) return false if AiUrlContext.skip_url?(url) true end
Source
# File plugins/ai.rb, line 1032 def self.list_models(base: nil, version: nil, models_runner: method(:default_models_runner)) stdout, stderr, status = models_runner.call text = stdout.to_s.strip text = stderr.to_s.strip if text.empty? return "Unable to list models." unless status.success? && !text.empty? catalog = AiModelCatalog.load(text) AiModelCatalog.format_listing(catalog, base: base, version: version) rescue StandardError "Unable to list models." end
Source
# File plugins/ai.rb, line 866 def self.list_models_responder?(bot) BotIdentity.owner_bot?(bot) end
Source
# File plugins/ai.rb, line 1792 def self.log_bot_channel_reply(message:, text:, bot:) return unless bot && !bot.is_a?(Class) return unless bot.respond_to?(:nick) && bot.nick return unless bot.respond_to?(:config) && bot.config&.respond_to?(:server) channel = log_channel_from_reply(message) return unless channel return if AiChannelLog.agent_answer_card?(text) reply_text = sanitize_log_text(AiChannelLog.plain_log_text(text)) return if reply_text.empty? append_log( channel.name, nick: bot.nick, text: reply_text, network: bot.config.server, from_bot: true ) end
Source
# File plugins/ai.rb, line 1780 def self.log_channel_from_reply(message) if message.respond_to?(:name) && message.respond_to?(:send) && !message.respond_to?(:user) return message end if message.respond_to?(:channel) && message.channel return message.channel end nil end
Source
# File plugins/ai.rb, line 874 def self.model_command?(question) text = question.to_s.strip return { action: :presets } if text.match?(MODEL_PRESETS_PATTERN) if (match = text.match(SET_MODEL_PATTERN)) return { action: :set, model: match[1] } end return { action: :get } if text.match?(GET_MODEL_PATTERN) nil end
Source
# File plugins/ai.rb, line 889 def self.model_set_allowed?(channel:, user:, network:, nick:) RabbotDb.user_allowed_at_level?( network: network, channel: channel.name, nick: nick, is_op: channel.opped?(user), minimum: RabbotDb::LEVEL_ADMIN ) end
Source
# File plugins/ai.rb, line 899 def self.model_set_denied_message "Only channel ops and registered admins can set the AI model." end
Source
# File plugins/ai.rb, line 903 def self.model_set_responder?(bot) BotIdentity.owner_bot?(bot) end
Source
# File plugins/ai.rb, line 870 def self.models_command?(question) AiModelCatalog.models_command?(question) end
Source
# File plugins/ai.rb, line 298 def self.needs_web_lookup?(question) text = question.to_s.strip return false if text.empty? return false if AiGuard.system_request?(text) return false if text.match?(TIMELESS_QUERY_PATTERN) return false if follow_up_question?(text) && !text.match?(WEB_LOOKUP_SIGNAL_PATTERN) text.match?(WEB_LOOKUP_SIGNAL_PATTERN) end
Source
# File plugins/ai.rb, line 844 def self.next_request_id REQUEST_ID_MUTEX.synchronize do @request_id_counter += 1 end end
Source
# File plugins/ai.rb, line 957 def self.nick_candidates(bot) ([bot.nick, bot.config.nick] + Array(bot.config.nicks)).compact.map(&:to_s).reject(&:empty?).uniq end
Source
# File plugins/ai.rb, line 2003 def self.nick_matches?(entry_nick, nick) entry_nick.to_s.casecmp?(nick.to_s) end
Source
# File plugins/ai.rb, line 1726 def self.normalize_channel_log_entry(entry) AiChannelLog.normalize_entry(entry) end
Source
# File plugins/ai.rb, line 1907 def self.normalize_text(text) sanitize_log_text(text).downcase.gsub(/[^a-z0-9\s]/, " ").squeeze(" ").strip end
Source
# File plugins/ai.rb, line 791 def self.parse_agent_json(stdout, stderr, status, model: nil) AiAgent.parse_agent_json(stdout, stderr, status, model: model || AGENT_MODEL) end
Source
# File plugins/ai.rb, line 1015 def self.parse_nick_ai_prompt(message, bot:) text = message.to_s.lstrip return nil if text.empty? nick_candidates(bot).each do |nick| if (match = text.match(/\A!#{Regexp.escape(nick)}\s+ai(?:\s+(.+))?\z/i)) return match[1].to_s.strip end end nil end
Source
# File plugins/ai.rb, line 965 def self.parse_what_is_question(text) AiWhatIsTrigger.parse_question(text) end
Source
# File plugins/ai.rb, line 973 def self.parse_where_is_question(text) AiWhereIsTrigger.parse_question(text) end
Source
# File plugins/ai.rb, line 1838 def self.persist_chat_history_entry(network:, channel:, entry:, store: RabbotDb) return if network.nil? || network.to_s.strip.empty? network = store.normalize_network(network) channel = store.normalize_channel(channel) return if network.empty? || channel.empty? entries = Array( AiPluginStorage.get_json( key: CHAT_HISTORY_KEY, network: network, channel: channel, default: [], store: store ) ) entries << { "asker" => entry[:asker], "question" => entry[:question], "answer" => entry[:answer] } entries = entries.last(MAX_CHAT_HISTORY) AiPluginStorage.set_json( key: CHAT_HISTORY_KEY, value: entries, network: network, channel: channel, store: store ) end
Source
# File plugins/ai.rb, line 637 def self.persist_cooldown!(network:, channel:, seconds:, channels: nil) seconds = seconds.to_i return nil unless seconds.positive? targets = Array(channels).map(&:to_s).reject(&:empty?) targets = [channel.to_s] if targets.empty? targets.each do |target_channel| BotOptions.set( network: network, channel: target_channel, key: COOLDOWN_OPTIONS_KEY, value: seconds.to_s ) end seconds end
Source
# File plugins/ai.rb, line 907 def self.persist_model!(network:, channel:, model:, channels: nil) model = model.to_s.strip return nil if model.empty? targets = Array(channels).map(&:to_s).reject(&:empty?) targets = [channel.to_s] if targets.empty? targets.each do |target_channel| BotOptions.set( network: network, channel: target_channel, key: AiModel::KEY, value: model ) end model end
Source
# File plugins/ai.rb, line 1048 def self.pick_history_entry(entries, random: Random) return nil if entries.nil? || entries.empty? entries[random.rand(entries.length)] end
Source
# File plugins/ai.rb, line 1472 def self.process_request(question:, channel:, asker:, network: nil, runner: method(:default_runner), usage_fetch: method(:default_usage_fetch), model: nil, profile: nil, yaml_default: nil, web_fetch: method(:default_web_fetch), skip_web: false, url_fetch: AiUrlContext.method(:default_fetch), bot_nick: nil, store: RabbotDb, channel_users: nil, geo_search: Geocoder.method(:search), wiki_fetch: AiWikiContext.default_wiki_fetch) model = resolve_model(network: network, channel: channel, yaml_default: yaml_default, model: model) empty_request = empty_prompt?(question) question = EMPTY_PROMPT_QUESTION if empty_request if AiGuard.system_request?(question) return { success: false, reply: AiGuard.rejection_message } end context_lines = if empty_request [] else build_context_lines(channel, question, network: network, asker: asker) end chat_history_lines = build_chat_history_lines(channel, question, network: network) empty_prompt_channel_lines = if empty_request build_empty_prompt_channel_lines(network: network, channel: channel) else [] end web_context_lines = if empty_request || skip_web [] else build_web_context_lines( question, fetch: web_fetch, network: network, channel: channel ) end web_used = web_context_lines.any? link_context_lines = if empty_request [] else build_link_context_lines(question, fetch: url_fetch) end link_used = link_context_lines.any? reference_context_lines = if empty_request [] else build_reference_context_lines( question, channel_users: channel_users, geo_search: geo_search, web_fetch: web_fetch, wiki_fetch: wiki_fetch ) end enrichment = reference_context_used?( question, channel_users: channel_users, geo_search: geo_search, web_fetch: web_fetch, wiki_fetch: wiki_fetch ) agent_prompt = build_agent_prompt( question: question, context_lines: context_lines, chat_history_lines: chat_history_lines, empty_prompt_channel_lines: empty_prompt_channel_lines, asker: asker, channel: channel, profile: profile, web_context_lines: web_context_lines, reference_context_lines: reference_context_lines, link_context_lines: link_context_lines ) result = run_agent(agent_prompt, model: model, runner: runner) unless result.success? return { success: false, reply: result.text } end filtered_text = AiGuard.filter_output(result.text) filtered_text = strip_redundant_nick_prefix(filtered_text, nick: asker) recipe_capture = if network && channel capture_recipe_from_answer!( network: network, channel: channel, nick: bot_nick || asker, text: filtered_text, store: store ) else { captured: false, recipe: nil } end append_chat_history( channel, asker: asker, question: question, answer: filtered_text, network: network, store: store ) record_bot_answer_to_channel_log!( network: network, channel: channel, bot_nick: bot_nick, text: filtered_text, store: store ) usage_percents = usage_fetch.call reply = format_answer( filtered_text, model: result.model, total_tokens: result.total_tokens, duration_s: result.duration_s, usage_percents: usage_percents, question: empty_request ? nil : question, web_used: web_used, recipe_saved: recipe_capture[:captured], geo_used: enrichment[:geo], wiki_used: enrichment[:wiki], link_used: link_used ) if network && channel && asker AiRateLimit.record_request(network: network, channel: channel, nick: asker) end { success: true, reply: reply } end
Source
# File plugins/ai.rb, line 1416 def self.process_tldr_request(entry:, channel:, asker:, network: nil, runner: method(:default_runner), usage_fetch: method(:default_usage_fetch), model: nil, yaml_default: nil, bot_nick: nil, store: RabbotDb) prompt = build_tldr_prompt(entry) model = resolve_model(network: network, channel: channel, yaml_default: yaml_default, model: model) result = run_agent(prompt, model: model, runner: runner) unless result.success? return { success: false, reply: result.text } end filtered_text = AiGuard.filter_output(result.text) filtered_text = strip_redundant_nick_prefix(filtered_text, nick: asker) recipe_capture = capture_recipe_from_answer!( network: network, channel: channel, nick: bot_nick || asker, text: filtered_text, store: store ) append_chat_history( channel, asker: asker, question: "tldr", answer: filtered_text, network: network, store: store ) record_bot_answer_to_channel_log!( network: network, channel: channel, bot_nick: bot_nick, text: filtered_text, store: store ) usage_percents = usage_fetch.call reply = format_tldr_reply( filtered_text, model: result.model, total_tokens: result.total_tokens, duration_s: result.duration_s, usage_percents: usage_percents, recipe_saved: recipe_capture[:captured] ) if network && channel && asker AiRateLimit.record_request(network: network, channel: channel, nick: asker) end { success: true, reply: reply } end
Source
# File plugins/ai.rb, line 1911 def self.query_tokens(question) AiContext.query_tokens(question, stop_words: STOP_WORDS) end
Source
# File plugins/ai.rb, line 2149 def self.recipe_output?(text) AiRecipeCapture.recipe_output?(text) end
Source
# File plugins/ai.rb, line 1730 def self.record_bot_answer_to_channel_log!(network:, channel:, bot_nick:, text:, store: RabbotDb) return if network.nil? || network.to_s.strip.empty? return if bot_nick.to_s.strip.empty? AiChannelLog.record_bot_answer!( network: network, channel: channel, nick: bot_nick, text: text, store: store ) end
Source
# File plugins/ai.rb, line 387 def self.reference_context_used?(question, channel_users: nil, geo_search: Geocoder.method(:search), web_fetch: method(:default_web_fetch), wiki_fetch: AiWikiContext.default_wiki_fetch) return { geo: false, wiki: false } unless AiLocationContext.where_is_question?(question) location_lines = AiLocationContext.build_context_lines( question, channel_users: channel_users, search: geo_search, web_fetch: ->(query) { fetch_web_lookup(query, fetch: web_fetch) } ) wiki_lines = AiWikiContext.build_context_lines(question, fetch: wiki_fetch) { geo: location_lines.any?, wiki: wiki_lines.any? } end
Source
# File plugins/ai.rb, line 826 def self.register_active_request!(id = nil) id = next_request_id if id.nil? ACTIVE_REQUESTS_MUTEX.synchronize { ACTIVE_REQUESTS[id] = true } id end
Source
# File plugins/ai.rb, line 1919 def self.relevance_score(text, tokens) AiContext.relevance_score(text, tokens) end
Source
# File plugins/ai.rb, line 850 def self.request_preview(question) text = question.to_s.strip return text if text.length <= 80 "#{text[0, 77]}..." end
Source
# File plugins/ai.rb, line 597 def self.resolve_cooldown_sec(network:, channel:) return AiRateLimit::DEFAULT_COOLDOWN_SEC if network.to_s.strip.empty? || channel.to_s.strip.empty? value = BotOptions.get( network: network, channel: channel, key: COOLDOWN_OPTIONS_KEY, default: AiRateLimit::DEFAULT_COOLDOWN_SEC.to_s ) sec = value.to_i sec.positive? ? sec : AiRateLimit::DEFAULT_COOLDOWN_SEC end
Source
# File plugins/ai.rb, line 1622 def self.resolve_episode(search, store: RabbotDb) query = search.to_s.strip return nil if query.empty? episodes = AthfEpisodeIndex.episodes(store: store) AthfEpisodeIndex.find(query, episodes: episodes) end
Source
# File plugins/ai.rb, line 799 def self.resolve_model(network:, channel:, yaml_default: nil, model: nil) return model if model AiModel.resolve( network: network, channel: channel, yaml_default: yaml_default, fallback: AGENT_MODEL ) end
Source
# File plugins/ai.rb, line 885 def self.resolve_model_alias(model) AiModelPresets.resolve(model) end
Source
# File plugins/ai.rb, line 230 def self.resolve_network(network) network || LOCAL_NETWORK end
Source
# File plugins/ai.rb, line 810 def self.run_agent(prompt, model: nil, network: nil, channel: nil, yaml_default: nil, runner: method(:default_runner)) model = resolve_model(network: network, channel: channel, yaml_default: yaml_default, model: model) AiAgent.run_agent(prompt, model: model, runner: runner) end
Source
# File plugins/ai.rb, line 1903 def self.sanitize_log_text(text) text.to_s.gsub(/[\x00-\x1f]/, " ").squeeze(" ").strip end
Source
# File plugins/ai.rb, line 1959 def self.select_chat_history_entries(entries, question) list = Array(entries).map do |entry| { asker: (entry[:asker] || entry["asker"]).to_s, question: (entry[:question] || entry["question"]).to_s, answer: (entry[:answer] || entry["answer"]).to_s } end return [] if list.empty? tokens = query_tokens(question) selected = [] last = list.last if tokens.any? relevant = list.select { |entry| AiChatHistory.exchange_relevance_score(entry, tokens).positive? } if relevant.any? selected = if last && !relevant.include?(last) [last] else relevant.last(MAX_CHAT_CONTEXT) end end end if follow_up_question?(question) || selected.empty? selected = AiChatHistory.merge_entries(selected, [last]) end selected.last(MAX_CHAT_CONTEXT) end
Source
# File plugins/ai.rb, line 1938 def self.select_context_entries(entries, question, asker: nil) AiContext.select_context_entries(entries, question, asker: asker, stop_words: STOP_WORDS) end
Source
# File plugins/ai.rb, line 1770 def self.select_empty_prompt_channel_entries(entries, limit: MAX_EMPTY_PROMPT_CONTEXT) Array(entries).last(limit) end
Source
# File plugins/ai.rb, line 697 def self.set_web_enabled!(network:, channel:, enabled:) BotOptions.set( network: network, channel: channel, key: WEB_OPTIONS_KEY, value: enabled ? "on" : "off" ) end
Source
# File plugins/ai.rb, line 2352 def self.skip_cursorusage_compact_reply?(message) CursorusageCompactSkip.skip?(message) end
Source
# File plugins/ai.rb, line 1386 def self.strip_redundant_nick_prefix(text, nick:) n = nick.to_s.strip return text.to_s if n.empty? lines = text.to_s.split("\n", 2) pattern = /\A#{Regexp.escape(n)}\s*[:,-]\s*/i lines[0] = lines[0].sub(pattern, "") lines.join("\n").strip end
Source
# File plugins/ai.rb, line 410 def self.tldr_command?(question) question.to_s.strip.match?(TLDR_PATTERN) end
Source
# File plugins/ai.rb, line 1915 def self.token_match?(line_token, query_token) AiContext.token_match?(line_token, query_token) end
Source
# File plugins/ai.rb, line 185 def self.token_style_colors AiSummary.token_style_colors end
Source
# File plugins/ai.rb, line 1927 def self.truncate_context_line(text) line = text.to_s.strip return line if line.length <= MAX_CONTEXT_LINE_CHARS "#{line[0, MAX_CONTEXT_LINE_CHARS - 3]}..." end
Source
# File plugins/ai.rb, line 832 def self.unregister_active_request!(id) ACTIVE_REQUESTS_MUTEX.synchronize { ACTIVE_REQUESTS.delete(id) } end
Source
# File plugins/ai.rb, line 1365 def self.usage_percent_separator AiSummary.usage_percent_separator end
Source
# File plugins/ai.rb, line 189 def self.usage_percent_style_colors(kind) AiSummary.usage_percent_style_colors(kind) end
Source
# File plugins/ai.rb, line 2056 def self.user_has_recent_messages?(network:, channel:, nick:, store: RabbotDb) return true if user_messages_snapshot(network: network, channel: channel, nick: nick, store: store).any? user_messages_from_channel_log(network: network, channel: channel, nick: nick, store: store).any? end
Source
# File plugins/ai.rb, line 2011 def self.user_messages_from_channel_log(network:, channel:, nick:, store: RabbotDb) channel = channel.to_s nick = nick.to_s.strip return [] if channel.empty? || nick.empty? if network network = store.normalize_network(network) channel = store.normalize_channel(channel) db_entries = Array( AiPluginStorage.get_json( key: CHANNEL_LOG_KEY, network: network, channel: channel, default: [], store: store ) ) filtered = filter_entries_by_nick(db_entries, nick) return filtered.map do |entry| { text: entry["text"].to_s, at: entry["at"].to_s } end if filtered.any? end resolved = store.normalize_network(resolve_network(network)) channel_key = store.normalize_channel(channel) db_entries = Array( AiPluginStorage.get_json( key: CHANNEL_LOG_KEY, network: resolved, channel: channel_key, default: [], store: store ) ) filter_entries_by_nick(db_entries, nick).map do |entry| { text: entry["text"].to_s, at: entry["at"].to_s } end end
Source
# File plugins/ai.rb, line 2099 def self.user_messages_snapshot(network:, channel:, nick:, store: RabbotDb) network = store.normalize_network(network) channel = store.normalize_channel(channel) nick = store.normalize_nick(nick) return [] if nick.empty? Array( AiPluginStorage.get_json( key: USER_MESSAGES_KEY, network: network, channel: channel, nick: nick, default: [], store: store ) ).map do |entry| { text: entry["text"].to_s, at: entry["at"].to_s } end end
Source
# File plugins/ai.rb, line 731 def self.web_admin_command?(question) cmd = web_command?(question) return false unless cmd return false if cmd[:action] == :off && cmd[:question] true end
Source
# File plugins/ai.rb, line 673 def self.web_command?(question) text = question.to_s.strip return { action: :show } if text.match?(WEB_SHOW_PATTERN) return { action: :on } if text.match?(WEB_ON_PATTERN) if (match = text.match(WEB_OFF_PATTERN)) remainder = match[1].to_s.strip return { action: :off, question: remainder.empty? ? nil : remainder } end nil end
Source
# File plugins/ai.rb, line 685 def self.web_lookup_enabled?(network:, channel:) return true if network.to_s.strip.empty? || channel.to_s.strip.empty? value = BotOptions.get( network: network, channel: channel, key: WEB_OPTIONS_KEY, default: "on" ) value.to_s.strip.downcase != "off" end
Source
# File plugins/ai.rb, line 727 def self.web_set_responder?(bot) BotIdentity.owner_bot?(bot) end
Source
# File plugins/ai.rb, line 760 def self.wrap_answer_card(text, question: nil) preview = request_preview(question || text.to_s.lines.first.to_s) header = IrcFormat.report_header(tag: "AI", title: preview, style: STYLE_MODEL_TAG) body_lines = text.to_s.strip.split("\n").map(&:strip).reject(&:empty?) ([header] + body_lines).join("\n") end
Public Instance Methods
Source
# File plugins/ai.rb, line 2234 def ask(prompt, model: nil, runner: self.class.method(:default_runner)) self.class.ask( prompt, network: bot.config.server, channel: nil, yaml_default: bot.config.shared[:ai_model], model: model, runner: runner ) end
Source
# File plugins/ai.rb, line 2287 def execute(m, prompt) if self.class.models_command?(prompt) && !self.class.list_models_responder?(bot) return end if self.class.model_command?(prompt) && !self.class.model_set_responder?(bot) return end if self.class.web_admin_command?(prompt) && !self.class.web_set_responder?(bot) return end if self.class.cooldown_admin_command?(prompt) && !self.class.cooldown_set_responder?(bot) return end plan = self.class.build_execute_plan( prompt.to_s, asker: m.user.nick, channel: m.channel&.name, network: bot.config.server, channel_obj: m.channel, user: m.user, channels: bot.config.channels, yaml_default: bot.config.shared[:ai_model] ) if plan[:immediate_reply] themed_flood_safe_reply(m, plan[:immediate_reply]) return end return unless plan[:async] request_id = self.class.register_active_request! AsyncJob.run(label: "ai") do outcome = if plan[:tldr] self.class.process_tldr_request( entry: plan[:tldr_entry], channel: plan[:channel], asker: plan[:asker], network: plan[:network], yaml_default: bot.config.shared[:ai_model], bot_nick: bot.nick ) else self.class.process_request( question: plan[:question] || prompt, channel: plan[:channel], asker: plan[:asker], network: plan[:network], profile: bot.config.shared[:character_profile], yaml_default: bot.config.shared[:ai_model], skip_web: plan[:skip_web] == true, bot_nick: bot.nick, channel_users: m.channel&.users ) end safe_reply(m, outcome[:reply]) rescue StandardError safe_reply(m, "AI request failed") ensure self.class.unregister_active_request!(request_id) end end
Source
# File plugins/ai.rb, line 2208 def record_message(m) return unless m.channel return if m.action? return if m.user.nick == bot.nick text = self.class.sanitize_log_text(m.message) return if text.empty? is_command = text.start_with?("!") self.class.append_log( m.channel.name, nick: m.user.nick, text: text, network: bot.config.server, is_command: is_command ) return if is_command self.class.append_user_message( network: bot.config.server, channel: m.channel.name, nick: m.user.nick, text: text ) end
Source
# File plugins/ai.rb, line 2256 def try_nick_ai_command(m) return unless m.channel prompt = self.class.parse_nick_ai_prompt(m.message, bot: bot) return if prompt.nil? execute(m, prompt) end
def fetch_completion(prompt, api_key) uri = URI.parse(CHAT_API_URL) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true request = Net::HTTP::Post.new(uri) request = “Bearer #{api_key}” request = “application/json” request.body = self.class.build_request_body(prompt) http.request(request).body end
Source
# File plugins/ai.rb, line 2265 def try_what_is_channel_question(m) return unless m.channel return if m.action? return if m.user.nick == bot.nick text = self.class.sanitize_log_text(m.message) return unless self.class.channel_what_is_trigger?(text) execute(m, self.class.parse_what_is_question(text)) end
Source
# File plugins/ai.rb, line 2276 def try_where_is_channel_question(m) return unless m.channel return if m.action? return if m.user.nick == bot.nick text = self.class.sanitize_log_text(m.message) return unless self.class.channel_where_is_trigger?(text) execute(m, self.class.parse_where_is_question(text)) end