anima-core 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +16 -5
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. data/lib/mneme/passive_recall.rb +0 -69
@@ -1,232 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "etc"
4
- require "open3"
5
- require "timeout"
6
- require "json"
7
- require "pathname"
8
- require "uri"
9
-
10
- # Probes the shell environment and assembles a lightweight metadata block
11
- # for injection into the system prompt. Gives the agent awareness of its
12
- # working directory, OS, Git status, and nearby project files — without
13
- # loading any file content.
14
- #
15
- # @example
16
- # EnvironmentProbe.to_prompt("/home/user/projects/my-app")
17
- # # => "## Environment\n\nOS: Arch Linux (pacman, yay)\n..."
18
- class EnvironmentProbe
19
- # Assembles the environment context block for a given working directory.
20
- #
21
- # @param pwd [String, nil] current working directory
22
- # @return [String, nil] Markdown-formatted environment block, or nil when pwd is unknown
23
- def self.to_prompt(pwd)
24
- new(pwd).to_prompt
25
- end
26
-
27
- # @param pwd [String, nil] current working directory
28
- def initialize(pwd)
29
- @pwd = pwd
30
- end
31
-
32
- # @return [String, nil] Markdown-formatted environment block
33
- def to_prompt
34
- return unless @pwd
35
-
36
- sections = [os_section, working_directory_section, project_files_section].compact
37
- return if sections.empty?
38
-
39
- "## Environment\n\n#{sections.join("\n\n")}"
40
- end
41
-
42
- private
43
-
44
- # @return [String] OS name with package manager hint
45
- def os_section
46
- sysname = Etc.uname[:sysname]
47
- "OS: #{format_os(sysname)}"
48
- end
49
-
50
- # @param sysname [String] kernel name from uname (e.g. "Linux", "Darwin")
51
- # @return [String] human-readable OS description
52
- def format_os(sysname)
53
- case sysname
54
- when "Linux"
55
- distro = detect_linux_distro || "Linux"
56
- pkg = detect_package_manager
57
- pkg ? "#{distro} (#{pkg})" : distro
58
- when "Darwin"
59
- "macOS (Homebrew)"
60
- else
61
- sysname
62
- end
63
- end
64
-
65
- # Reads PRETTY_NAME from /etc/os-release.
66
- #
67
- # @return [String, nil] distro name, or nil on non-Linux / missing file
68
- def detect_linux_distro
69
- return unless File.exist?("/etc/os-release")
70
-
71
- File.foreach("/etc/os-release") do |line|
72
- if line.start_with?("PRETTY_NAME=")
73
- return line.split("=", 2).last.strip.delete('"')
74
- end
75
- end
76
- nil
77
- end
78
-
79
- # Returns the primary package manager(s) for the current system.
80
- # Arch-based systems list both pacman and yay when present;
81
- # other families return the first match.
82
- #
83
- # @return [String, nil] comma-separated package manager names
84
- def detect_package_manager
85
- managers = []
86
- managers << "pacman" if File.exist?("/usr/bin/pacman")
87
- managers << "yay" if File.exist?("/usr/bin/yay")
88
- return managers.join(", ") if managers.any?
89
-
90
- return "apt" if File.exist?("/usr/bin/apt")
91
- return "dnf" if File.exist?("/usr/bin/dnf")
92
- return "Homebrew" if File.exist?("/opt/homebrew/bin/brew") || File.exist?("/usr/local/bin/brew")
93
-
94
- nil
95
- end
96
-
97
- # @return [String] CWD line plus optional Git metadata
98
- def working_directory_section
99
- lines = ["CWD: #{@pwd}"]
100
- append_git_lines(lines)
101
- lines.join("\n")
102
- end
103
-
104
- # Appends Git metadata lines (remote, branch, PR) to the output array.
105
- #
106
- # @param lines [Array<String>] accumulator for output lines
107
- # @return [void]
108
- def append_git_lines(lines)
109
- git = detect_git
110
- return unless git
111
-
112
- remote = git[:remote]
113
- branch = git[:branch]
114
- pr_number = git[:pr_number]
115
-
116
- lines << "Git: #{git[:repo_name]} (#{remote})" if remote
117
- lines << "Branch: #{branch}" if branch
118
- lines << "PR: ##{pr_number} (#{git[:pr_state]})" if pr_number
119
- end
120
-
121
- # Detects Git repo metadata: remote, branch, and open PR.
122
- #
123
- # @return [Hash{Symbol => String}, nil] keys: :remote, :repo_name, :branch,
124
- # and optionally :pr_number (Integer) and :pr_state (String); nil when not in a repo
125
- def detect_git
126
- _, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree", err: File::NULL)
127
- return unless status.success?
128
-
129
- info = {}
130
- detect_git_remote(info)
131
- detect_git_branch(info)
132
- info
133
- rescue Errno::ENOENT
134
- nil
135
- end
136
-
137
- # Populates :remote and :repo_name on the info hash.
138
- def detect_git_remote(info)
139
- remote, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin", err: File::NULL)
140
- remote = remote.strip
141
- return unless remote.present?
142
-
143
- info[:remote] = remote
144
- info[:repo_name] = extract_repo_name(remote)
145
- end
146
-
147
- # Populates :branch, :pr_number, and :pr_state on the info hash.
148
- def detect_git_branch(info)
149
- branch, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD", err: File::NULL)
150
- branch = branch.strip
151
- return unless branch.present?
152
-
153
- info[:branch] = branch
154
- pr = detect_pr(branch)
155
- info.merge!(pr) if pr
156
- end
157
-
158
- # Extracts owner/repo from a Git remote URL.
159
- #
160
- # @param remote_url [String] SSH or HTTPS remote URL
161
- # @return [String] "owner/repo" path, or the raw URL when parsing fails
162
- def extract_repo_name(remote_url)
163
- path = if remote_url.match?(%r{\A\w+://})
164
- URI.parse(remote_url).path
165
- else
166
- # SSH format: git@host:owner/repo.git
167
- remote_url.split(":").last
168
- end
169
- path.delete_prefix("/").delete_suffix(".git")
170
- rescue URI::InvalidURIError
171
- remote_url
172
- end
173
-
174
- # Queries GitHub for an open PR on the given branch via the gh CLI.
175
- #
176
- # @param branch [String] branch name
177
- # @return [Hash, nil] with :pr_number and :pr_state, or nil
178
- # @note Returns nil on timeout, missing gh CLI, or JSON parse errors
179
- def detect_pr(branch)
180
- Timeout.timeout(Anima::Settings.web_request_timeout) do
181
- output, status = Open3.capture2(
182
- "gh", "pr", "list", "--head", branch,
183
- "--json", "number,state", "--limit", "1",
184
- chdir: @pwd, err: File::NULL
185
- )
186
- return unless status.success?
187
-
188
- pr = JSON.parse(output).first
189
- return unless pr
190
-
191
- {pr_number: pr["number"], pr_state: pr["state"].downcase}
192
- end
193
- rescue Timeout::Error, Errno::ENOENT, JSON::ParserError
194
- nil
195
- end
196
-
197
- # Scans for well-known project files up to a configurable depth.
198
- #
199
- # @return [String, nil] project files section, or nil when none found
200
- def project_files_section
201
- found = scan_project_files
202
- return if found.empty?
203
-
204
- header = "Project files that may contain useful context:"
205
- entries = found.map { |path| "- #{path}" }
206
- [header, *entries, "Use read_file to examine these when needed."].join("\n")
207
- end
208
-
209
- # Scans the working directory for whitelisted filenames.
210
- #
211
- # @return [Array<String>] sorted relative paths
212
- def scan_project_files
213
- base = Pathname.new(@pwd)
214
-
215
- glob_patterns.flat_map { |pattern| Dir.glob(pattern) }
216
- .map { |full_path| Pathname.new(full_path).relative_path_from(base).to_s }
217
- .sort
218
- .uniq
219
- end
220
-
221
- # Builds glob patterns for each whitelisted filename at each depth level.
222
- #
223
- # @return [Array<String>] glob patterns
224
- def glob_patterns
225
- whitelist = Anima::Settings.project_files_whitelist
226
- max_depth = Anima::Settings.project_files_max_depth
227
-
228
- whitelist.product((0..max_depth).to_a).map do |filename, depth|
229
- File.join(@pwd, Array.new(depth, "*"), filename)
230
- end
231
- end
232
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Events
4
- class AgentMessage < Base
5
- TYPE = "agent_message"
6
-
7
- def type
8
- TYPE
9
- end
10
- end
11
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Events
4
- module Subscribers
5
- # Collects chat-displayable events in-memory for the current session.
6
- # Provides the message list that the TUI renders and the LLM client consumes.
7
- #
8
- # Only user_message and agent_message events are collected — system_message,
9
- # tool_call, and tool_response are internal and not part of the chat display.
10
- #
11
- # @example
12
- # collector = Events::Subscribers::MessageCollector.new
13
- # Events::Bus.subscribe(collector)
14
- # collector.messages # => [{role: "user", content: "hi"}, ...]
15
- class MessageCollector
16
- include Events::Subscriber
17
-
18
- DISPLAYABLE_TYPES = %w[user_message agent_message].freeze
19
-
20
- # Maps event types to LLM-compatible role identifiers
21
- ROLE_MAP = {
22
- "user_message" => "user",
23
- "agent_message" => "assistant"
24
- }.freeze
25
-
26
- def initialize
27
- @messages = []
28
- @mutex = Mutex.new
29
- end
30
-
31
- # @return [Array<Hash>] thread-safe copy of collected messages
32
- def messages
33
- @mutex.synchronize { @messages.dup }
34
- end
35
-
36
- # Receives a Rails.event notification hash.
37
- # @param event [Hash] with :payload containing :type and :content keys
38
- def emit(event)
39
- type = event.dig(:payload, :type)
40
- return unless DISPLAYABLE_TYPES.include?(type)
41
-
42
- content = event.dig(:payload, :content)
43
- return if content.nil?
44
-
45
- @mutex.synchronize do
46
- @messages << {
47
- role: ROLE_MAP.fetch(type),
48
- content: content
49
- }
50
- end
51
- end
52
-
53
- # Directly push a pre-built message hash (used for loading persisted events).
54
- # @param message [Hash] with :role and :content keys
55
- def messages_push(message)
56
- @mutex.synchronize { @messages << message }
57
- end
58
-
59
- def clear
60
- @mutex.synchronize { @messages = [] }
61
- end
62
- end
63
- end
64
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Events
4
- class ToolCall < Base
5
- TYPE = "tool_call"
6
-
7
- attr_reader :tool_name, :tool_input, :tool_use_id, :timeout
8
-
9
- # @param content [String] human-readable description of the tool call
10
- # @param tool_name [String] registered tool name (e.g. "web_get")
11
- # @param tool_input [Hash] arguments passed to the tool
12
- # @param tool_use_id [String] Anthropic-assigned ID for correlating call/result
13
- # @param timeout [Integer] maximum seconds before the call is considered orphaned
14
- # @param session_id [String, nil] optional session identifier
15
- def initialize(content:, tool_name:, tool_input: {}, tool_use_id: nil, timeout: nil, session_id: nil)
16
- super(content: content, session_id: session_id)
17
- @tool_name = tool_name
18
- @tool_input = tool_input
19
- @tool_use_id = tool_use_id
20
- @timeout = timeout
21
- end
22
-
23
- def type
24
- TYPE
25
- end
26
-
27
- def to_h
28
- super.merge(tool_name: tool_name, tool_input: tool_input, tool_use_id: tool_use_id, timeout: timeout)
29
- end
30
- end
31
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Events
4
- class ToolResponse < Base
5
- TYPE = "tool_response"
6
-
7
- attr_reader :tool_name, :success, :tool_use_id
8
-
9
- # @param content [String] tool execution output
10
- # @param tool_name [String] registered tool name
11
- # @param success [Boolean] whether the tool executed successfully
12
- # @param tool_use_id [String, nil] Anthropic-assigned ID for correlating call/result
13
- # @param session_id [String, nil] optional session identifier
14
- def initialize(content:, tool_name:, success: true, tool_use_id: nil, session_id: nil)
15
- super(content: content, session_id: session_id)
16
- @tool_name = tool_name
17
- @success = success
18
- @tool_use_id = tool_use_id
19
- end
20
-
21
- def type
22
- TYPE
23
- end
24
-
25
- def success?
26
- @success
27
- end
28
-
29
- def to_h
30
- super.merge(tool_name: tool_name, success: success, tool_use_id: tool_use_id)
31
- end
32
- end
33
- end
@@ -1,200 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mneme
4
- # Builds a compressed viewport for Mneme's LLM context. Mneme sees
5
- # conversation (user/agent messages and think events) but not mechanical
6
- # execution (tool calls and responses). Tool calls are compressed to
7
- # aggregate counters like `[4 tools called]`.
8
- #
9
- # The viewport is split into three zones separated by delimiters:
10
- # - **Eviction zone** — messages about to leave the viewport (upper third)
11
- # - **Middle zone** — messages in the middle of the viewport
12
- # - **Recent zone** — the most recent messages (lower third)
13
- #
14
- # Zone boundaries are calculated WITH tool call tokens (they affect
15
- # position), then tool calls are removed and replaced with counters.
16
- #
17
- # @example
18
- # viewport = Mneme::CompressedViewport.new(session, token_budget: 60_000)
19
- # viewport.render #=> "── EVICTION ZONE ──\nmessage 42 User: ..."
20
- class CompressedViewport
21
- ZONE_DELIMITERS = {
22
- eviction: "── EVICTION ZONE (upper third) ──",
23
- middle: "── MIDDLE ZONE ──",
24
- recent: "── RECENT ZONE (lower third) ──"
25
- }.freeze
26
-
27
- # @param session [Session] the session to build viewport for
28
- # @param token_budget [Integer] total tokens available for Mneme's viewport
29
- # @param from_message_id [Integer, nil] start from this message ID (inclusive);
30
- # when nil, uses the session's full viewport
31
- def initialize(session, token_budget:, from_message_id: nil)
32
- @session = session
33
- @token_budget = token_budget
34
- @from_message_id = from_message_id
35
- end
36
-
37
- # Renders the compressed viewport as a string ready for Mneme's LLM context.
38
- #
39
- # @return [String] compressed viewport with zone delimiters
40
- def render
41
- return "" if messages.empty?
42
-
43
- zones = split_into_zones(messages)
44
- render_zones(zones)
45
- end
46
-
47
- # @return [Array<Message>] the raw messages selected for this viewport
48
- def messages
49
- @messages ||= fetch_messages
50
- end
51
-
52
- private
53
-
54
- # Fetches messages within token budget, starting from from_message_id.
55
- # Selects newest-first until budget exhausted, returns chronological.
56
- # Caches per-message token costs in @message_costs for reuse by split_into_zones.
57
- #
58
- # @return [Array<Message>]
59
- def fetch_messages
60
- scope = @session.messages.context_messages
61
-
62
- if @from_message_id
63
- scope = scope.where("id >= ?", @from_message_id)
64
- end
65
-
66
- selected = []
67
- @message_costs = {}
68
- remaining = @token_budget
69
-
70
- scope.reorder(id: :desc).each do |message|
71
- cost = message_token_cost(message)
72
- break if cost > remaining && selected.any?
73
-
74
- selected << message
75
- @message_costs[message.id] = cost
76
- remaining -= cost
77
- end
78
-
79
- selected.reverse
80
- end
81
-
82
- # Splits messages into three zones by token count.
83
- # Zone boundaries are calculated including ALL messages (tool calls count
84
- # toward position), but zone assignment uses cumulative tokens.
85
- #
86
- # @return [Hash{Symbol => Array<Message>}] :eviction, :middle, :recent
87
- def split_into_zones(messages)
88
- costs = messages.map { |message| [message, @message_costs[message.id] || message_token_cost(message)] }
89
- zone_size = costs.sum(&:last) / 3.0
90
-
91
- result = {eviction: [], middle: [], recent: []}
92
- cumulative = 0
93
-
94
- costs.each do |message, cost|
95
- cumulative += cost
96
- result[zone_for_cumulative(cumulative, zone_size)] << message
97
- end
98
-
99
- result
100
- end
101
-
102
- # Renders zones with delimiters, compressing tool calls into counters.
103
- #
104
- # @param zones [Hash{Symbol => Array<Message>}]
105
- # @return [String]
106
- def render_zones(zones)
107
- %i[eviction middle recent].flat_map { |name|
108
- [ZONE_DELIMITERS[name], render_zone(zones[name])]
109
- }.join("\n")
110
- end
111
-
112
- # Determines which zone an event belongs to based on cumulative token position.
113
- #
114
- # @param cumulative [Numeric] cumulative token count including this event
115
- # @param zone_size [Float] token count per zone (total / 3)
116
- # @return [Symbol] :eviction, :middle, or :recent
117
- def zone_for_cumulative(cumulative, zone_size)
118
- if cumulative <= zone_size
119
- :eviction
120
- elsif cumulative <= zone_size * 2
121
- :middle
122
- else
123
- :recent
124
- end
125
- end
126
-
127
- # Renders a single zone: conversation messages as full text, consecutive
128
- # tool calls/responses compressed into `[N tools called]` counters.
129
- # tool_response messages are intentionally silent — they affect zone boundaries
130
- # via token cost but are not rendered; only tool_call messages increment the counter.
131
- #
132
- # @param zone_messages [Array<Message>]
133
- # @return [String]
134
- def render_zone(zone_messages)
135
- lines = []
136
- tool_count = 0
137
-
138
- zone_messages.each do |message|
139
- if conversation_message?(message) || think_message?(message)
140
- lines << flush_tool_count(tool_count)
141
- tool_count = 0
142
- lines << render_message_line(message)
143
- elsif message.message_type == "tool_call"
144
- tool_count += 1
145
- end
146
- end
147
-
148
- lines << flush_tool_count(tool_count)
149
- lines.compact.join("\n")
150
- end
151
-
152
- # @return [Boolean] true if message is a user/agent/system message
153
- def conversation_message?(message)
154
- message.message_type.in?(Message::CONVERSATION_TYPES)
155
- end
156
-
157
- # Think messages are tool_call messages with tool_name == "think".
158
- # They carry the agent's reasoning and are treated as conversation.
159
- #
160
- # @return [Boolean]
161
- def think_message?(message)
162
- message.message_type == "tool_call" && message.payload["tool_name"] == Message::THINK_TOOL
163
- end
164
-
165
- ROLE_LABELS = {
166
- "user_message" => "User",
167
- "agent_message" => "Assistant",
168
- "system_message" => "System"
169
- }.freeze
170
-
171
- # Renders a single message as a transcript line.
172
- #
173
- # @param message [Message]
174
- # @return [String]
175
- def render_message_line(message)
176
- prefix = "message #{message.id}"
177
- data = message.payload
178
- if think_message?(message)
179
- "#{prefix} Think: #{data.dig("tool_input", "thoughts")}"
180
- else
181
- "#{prefix} #{ROLE_LABELS.fetch(message.message_type)}: #{data["content"]}"
182
- end
183
- end
184
-
185
- # Returns a tool count string if any tools were called, nil otherwise.
186
- #
187
- # @param count [Integer] number of tool calls to flush
188
- # @return [String, nil]
189
- def flush_tool_count(count)
190
- return if count == 0
191
- "[#{count} #{(count == 1) ? "tool" : "tools"} called]"
192
- end
193
-
194
- # @return [Integer] token cost using cached count or heuristic
195
- def message_token_cost(message)
196
- cached = message.token_count
197
- (cached > 0) ? cached : message.estimate_tokens
198
- end
199
- end
200
- end
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mneme
4
- # Passive recall — automatic memory surfacing triggered by Goal updates.
5
- # When goals are created or updated, searches event history for related
6
- # context and caches the results on the session for viewport injection.
7
- #
8
- # The agent never calls a tool; relevant memories appear automatically
9
- # in the viewport between snapshots and the sliding window. This mirrors
10
- # recognition memory in humans — context surfaces without conscious effort.
11
- #
12
- # @example Trigger after a goal update
13
- # Mneme::PassiveRecall.new(session).call
14
- class PassiveRecall
15
- # @param session [Session] the session whose goals drive recall
16
- def initialize(session)
17
- @session = session
18
- end
19
-
20
- # Searches event history using active goal descriptions as queries.
21
- # Returns recall results suitable for viewport injection.
22
- #
23
- # @return [Array<Mneme::Search::Result>] deduplicated, relevance-sorted
24
- def call
25
- goals = @session.goals.active.root.includes(:sub_goals)
26
- return [] if goals.empty?
27
-
28
- search_terms = build_search_terms(goals)
29
- return [] if search_terms.blank?
30
-
31
- results = Mneme::Search.query(search_terms, limit: Anima::Settings.recall_max_results)
32
-
33
- # Exclude events from the current session's viewport — no point recalling
34
- # what the agent already sees.
35
- viewport_ids = @session.viewport_message_ids.to_set
36
- results.reject { |result| viewport_ids.include?(result.message_id) }
37
- end
38
-
39
- private
40
-
41
- STOP_WORDS = Set.new(%w[
42
- a an the is are was were be been being do does did
43
- have has had in on at to for of and or but not with
44
- this that it its by from as up out if about into
45
- fix add create update remove implement check set get
46
- ]).freeze
47
-
48
- # Extracts meaningful keywords from active goals and joins with OR.
49
- # Stop words and generic verbs are stripped — they're too common to
50
- # produce useful recall results.
51
- #
52
- # @param goals [ActiveRecord::Relation<Goal>]
53
- # @return [String] FTS5 OR-joined keywords
54
- def build_search_terms(goals)
55
- descriptions = goals.flat_map { |goal|
56
- [goal.description] + goal.sub_goals.reject(&:completed?).map(&:description)
57
- }
58
-
59
- words = descriptions.join(" ")
60
- .gsub(/[^a-zA-Z0-9\s-]/, "")
61
- .downcase
62
- .split
63
- .uniq
64
- .reject { |word| STOP_WORDS.include?(word) || word.length < 3 }
65
-
66
- words.join(" OR ").truncate(500)
67
- end
68
- end
69
- end