module RoomReader
Constants
- BEHOLD_PREFIX
- CHANNEL_LOG_KEY
- CHANNEL_LOG_PLUGIN
- GEO_CONTEXT_PATTERN
- IdeaResolution
- MAX_IDEA_CHARS
- MAX_ROOM_NOTE_CHARS
- PLUGIN_BUILD_REQUEST_PATTERN
- PROPOSAL_SLUG_PATTERN
- REFINE_INSTRUCTION
- ROOM_CONTEXT_LINES
- ROOM_READER_ROLES
- SKIP_PREFIX
- SWAYED_BY_ROOM_SUFFIX
- USE_EXISTING_PREFIX
Public Instance Methods
Source
# File lib/room_reader.rb, line 214 def actionable_proposal?(idea) BuildProposalFilter.proposable?(idea) end
Source
# File lib/room_reader.rb, line 261 def ai_refine_idea(idea, context_lines, idea_source:, profile:, runner:, plugins_dir: Help::PLUGINS_DIR) prompt = build_refine_prompt( idea: idea, context_lines: context_lines, idea_source: idea_source, profile: profile, plugins_dir: plugins_dir ) result = runner.call(prompt) return nil unless result.respond_to?(:success?) && result.success? text = result.text.to_s.strip return nil if text.empty? return nil if AiGuard.system_request?(text) text.gsub(/\A[!`"']+|[!`"']+\z/, "").strip[0, MAX_IDEA_CHARS] rescue StandardError nil end
Source
# File lib/room_reader.rb, line 239 def build_refine_prompt(idea:, context_lines:, idea_source:, profile: nil, plugins_dir: PluginCatalog::DEFAULT_PLUGINS_DIR) parts = [] profile_prompt = render_profile_prompt(profile) parts << profile_prompt unless profile_prompt.empty? parts << REFINE_INSTRUCTION parts << existing_plugins_summary(plugins_dir: plugins_dir) parts << "Idea-source user: #{idea_source}" parts << "Proposed idea: #{idea}" parts << "Recent channel chat (oldest to newest):" parts.concat(context_lines) AiGuard.guard_prompt(parts.join("\n")) end
Source
# File lib/room_reader.rb, line 121 def channel_context_lines(network:, channel:, exclude_nicks: [], limit: ROOM_CONTEXT_LINES, store: RabbotDb, channel_obj: nil, idea_sources: []) entries = channel_log_entries(network: network, channel: channel, store: store) filtered = regular_entries(entries).reject do |entry| nick = entry_nick(entry) exclude_nicks.any? { |excluded| nick.casecmp?(excluded.to_s) } end filtered = filtered.select do |entry| context_trusted_nick?( nick: entry_nick(entry), network: network, channel: channel, channel_obj: channel_obj, idea_sources: idea_sources ) end filtered.last(limit).map { |entry| format_context_line(entry) } end
Source
# File lib/room_reader.rb, line 313 def channel_log_entries(network:, channel:, store: RabbotDb) Array( AiPluginStorage.get_json( key: CHANNEL_LOG_KEY, network: network, channel: channel, default: [], store: store ) ) end
Source
# File lib/room_reader.rb, line 141 def context_trusted_nick?(nick:, network:, channel:, channel_obj: nil, idea_sources: []) user = Struct.new(:nick).new(nick) ChannelTrust.trusted_context_nick?( nick: nick, channel: channel_obj || channel, user: user, network: network, idea_sources: idea_sources ) end
Source
# File lib/room_reader.rb, line 329 def entry_nick(entry) (entry["nick"] || entry[:nick]).to_s end
Source
# File lib/room_reader.rb, line 252 def existing_plugins_summary(plugins_dir: PluginCatalog::DEFAULT_PLUGINS_DIR, limit: 40) lines = PluginCatalog.list_entries(plugins_dir: plugins_dir).first(limit).map do |entry| "- !#{entry[:command]} — #{entry[:description]}" end return "" if lines.empty? ["Existing plugins (prefer these over duplicates):", *lines].join("\n") end
Source
# File lib/room_reader.rb, line 233 def existing_usage_message(existing) description = existing[:description] suffix = description.empty? ? "" : " (#{description})" "Already covered by #{existing[:usage]}#{suffix}." end
Source
# File lib/room_reader.rb, line 333 def format_context_line(entry) nick = entry_nick(entry) text = (entry["text"] || entry[:text]).to_s.strip at = entry["at"] || entry[:at] timestamp = format_timestamp(at) "#{timestamp ? "[#{timestamp}] " : ""}#{nick}: #{text}" end
Source
# File lib/room_reader.rb, line 218 def format_plugin_command(idea) prepared = prepare_proposal_text(idea) return nil if prepared.empty? return nil unless actionable_proposal?(prepared) if (parsed = parse_proposal_slug(prepared)) body = prepare_proposal_text(parsed[:body]) return nil if body.empty? "!rabbot plugin #{parsed[:slug]} — #{body}" elsif plugin_build_request?(prepared) "!rabbot plugin #{prepared}" end end
Source
# File lib/room_reader.rb, line 341 def format_timestamp(at) time = at.is_a?(Time) ? at : RabbotDb.parse_time(at) return nil unless time time.utc.strftime("%H:%M") end
Source
# File lib/room_reader.rb, line 176 def geo_context_hint(texts) joined = texts.join(" ") return "" unless joined.match?(GEO_CONTEXT_PATTERN) location = PluginCatalog.extract_location_hint(joined) location ? "room talking #{location}/weather/geo" : "room talking weather/geo" end
Source
# File lib/room_reader.rb, line 198 def parse_proposal_slug(text) match = prepare_proposal_text(text).match(PROPOSAL_SLUG_PATTERN) return nil unless match { slug: match[1].downcase, body: match[2].strip } end
Source
# File lib/room_reader.rb, line 281 def parse_skip_marker(text) match = text.to_s.strip.match(/\A#{SKIP_PREFIX}\s*(.+)\z/i) return nil unless match IdeaResolution.new(action: :skip, message: match[1].strip) end
Source
# File lib/room_reader.rb, line 288 def parse_use_existing_marker(text, plugins_dir: PluginCatalog::DEFAULT_PLUGINS_DIR) match = text.to_s.strip.match(/\A#{USE_EXISTING_PREFIX}([a-z][a-z0-9_]*)(?::(.+))?\z/i) return nil unless match command = match[1].downcase args = match[2].to_s.strip entry = PluginCatalog.list_entries(plugins_dir: plugins_dir).find do |candidate| candidate[:command].to_s.split(/\s+/, 2).first.casecmp?(command) end return nil unless entry existing = { command: command, args: args, usage: args.empty? ? "!#{command}" : "!#{command} #{args}", description: entry[:description].to_s.strip, plugin: entry[:plugin].to_s } IdeaResolution.new( action: :use_existing, existing: existing, message: existing_usage_message(existing) ) end
Source
# File lib/room_reader.rb, line 184 def plugin_build_request?(idea) idea.to_s.match?(PLUGIN_BUILD_REQUEST_PATTERN) end
Source
# File lib/room_reader.rb, line 188 def prepare_proposal_text(text) text.to_s .sub(SWAYED_BY_ROOM_SUFFIX, "") .sub(BEHOLD_PREFIX, "") .gsub(/[`]/, " ") .gsub(/[\x00-\x1f]/, " ") .squeeze(" ") .strip[0, MAX_IDEA_CHARS] end
Source
# File lib/room_reader.rb, line 97 def refine_idea(idea:, network:, channel:, idea_source:, profile: nil, runner: nil, plugins_dir: PluginCatalog::DEFAULT_PLUGINS_DIR, store: RabbotDb, channel_obj: nil, idea_sources: []) resolution = resolve_idea( idea: idea, network: network, channel: channel, idea_source: idea_source, profile: profile, runner: runner, plugins_dir: plugins_dir, store: store, channel_obj: channel_obj, idea_sources: idea_sources ) case resolution.action when :use_existing resolution.message else resolution.idea end end
Source
# File lib/room_reader.rb, line 325 def regular_entries(entries) Array(entries).reject { |entry| entry["is_command"] == true || entry[:is_command] == true } end
Source
# File lib/room_reader.rb, line 352 def render_profile_prompt(profile) case profile when nil "" when CharacterProfile profile.render_prompt else profile.to_s.strip end end
Source
# File lib/room_reader.rb, line 47 def resolve_idea(idea:, network:, channel:, idea_source:, profile: nil, runner: nil, plugins_dir: PluginCatalog::DEFAULT_PLUGINS_DIR, store: RabbotDb, channel_obj: nil, idea_sources: []) cleaned = idea.to_s.strip return IdeaResolution.new(action: :build, idea: cleaned) if cleaned.empty? context_lines = channel_context_lines( network: network, channel: channel, exclude_nicks: [idea_source], store: store, channel_obj: channel_obj, idea_sources: idea_sources ) combined_text = [cleaned, summarize_room_momentum(context_lines)].join(" ") existing = PluginCatalog.existing_command_for(cleaned, plugins_dir: plugins_dir) if existing && plugin_build_request?(cleaned) location = PluginCatalog.extract_location_hint(combined_text) if location && !location.empty? existing = existing.merge( args: location, usage: "!#{existing[:command]} #{location}" ) end return IdeaResolution.new( action: :use_existing, existing: existing, message: existing_usage_message(existing) ) end refined = if context_lines.empty? cleaned elsif runner ai_refine_idea(cleaned, context_lines, idea_source: idea_source, profile: profile, plugins_dir: plugins_dir, runner: runner) || cleaned else cleaned end parsed_existing = parse_use_existing_marker(refined, plugins_dir: plugins_dir) return parsed_existing if parsed_existing parsed_skip = parse_skip_marker(refined) return parsed_skip if parsed_skip IdeaResolution.new(action: :build, idea: refined) end
Source
# File lib/room_reader.rb, line 43 def room_reader_role?(role) ROOM_READER_ROLES.include?(role.to_s) end
Source
# File lib/room_reader.rb, line 348 def strip_context_prefix(line) line.to_s.sub(/\A(?:\[[^\]]+\]\s+)?\w+:\s*/, "").strip end
Source
# File lib/room_reader.rb, line 160 def summarize_room_momentum(context_lines, max_chars: MAX_ROOM_NOTE_CHARS) texts = Array(context_lines).map { |line| strip_context_prefix(line) }.reject(&:empty?) return "" if texts.empty? note = texts.last(3).join("; ")[0, max_chars] geo_hint = geo_context_hint(texts) return note if geo_hint.empty? combined = "#{note} [#{geo_hint}]" combined[0, max_chars] end
Source
# File lib/room_reader.rb, line 152 def sway_with_context(idea, context_lines) room_note = summarize_room_momentum(context_lines) return idea if room_note.empty? combined = "#{idea} — swayed by room chat: #{room_note}" combined[0, MAX_IDEA_CHARS] end
Source
# File lib/room_reader.rb, line 205 def troubleshooting_paste?(text) prepared = prepare_proposal_text(text) return false if prepared.empty? prepared.match?(/(?:^|\s)\d+\.\s/) || prepared.match?(/\b\w[\w\s]{0,40}\s>\s+\w/) || prepared.match?(/\b(?:System Settings|Game Settings|Missing BIOS|Per System)\b/i) end