anima-core 1.4.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -20
  3. data/README.md +61 -95
  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 +13 -2
  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 +21 -10
  30. data/app/models/message.rb +47 -36
  31. data/app/models/pending_message.rb +276 -29
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +468 -432
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +17 -4
  36. data/config/application.rb +1 -0
  37. data/config/initializers/event_subscribers.rb +71 -4
  38. data/config/initializers/inflections.rb +3 -1
  39. data/db/cable_structure.sql +3 -3
  40. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  41. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  42. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  43. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  44. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  45. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  46. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  47. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  48. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  49. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  50. data/db/queue_structure.sql +13 -13
  51. data/db/structure.sql +44 -31
  52. data/lib/agents/registry.rb +1 -1
  53. data/lib/anima/settings.rb +7 -33
  54. data/lib/anima/version.rb +1 -1
  55. data/lib/events/authentication_required.rb +24 -0
  56. data/lib/events/bounce_back.rb +4 -4
  57. data/lib/events/eviction_completed.rb +28 -0
  58. data/lib/events/goal_created.rb +28 -0
  59. data/lib/events/goal_updated.rb +32 -0
  60. data/lib/events/llm_responded.rb +35 -0
  61. data/lib/events/message_created.rb +27 -0
  62. data/lib/events/message_updated.rb +25 -0
  63. data/lib/events/session_state_changed.rb +30 -0
  64. data/lib/events/skill_activated.rb +28 -0
  65. data/lib/events/start_melete.rb +36 -0
  66. data/lib/events/start_mneme.rb +33 -0
  67. data/lib/events/start_processing.rb +32 -0
  68. data/lib/events/subagent_evicted.rb +31 -0
  69. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  70. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  71. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  72. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  73. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  74. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  75. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  76. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  77. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  78. data/lib/events/subscribers/persister.rb +6 -8
  79. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  80. data/lib/events/subscribers/subagent_message_router.rb +26 -29
  81. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  82. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  83. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  84. data/lib/events/tool_executed.rb +34 -0
  85. data/lib/events/workflow_activated.rb +27 -0
  86. data/lib/llm/client.rb +41 -201
  87. data/lib/mcp/client_manager.rb +41 -46
  88. data/lib/mcp/stdio_transport.rb +9 -5
  89. data/lib/{analytical_brain → melete}/runner.rb +63 -68
  90. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
  91. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
  92. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  93. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
  94. data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
  95. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
  96. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  97. data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
  98. data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
  99. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  100. data/lib/mneme/base_runner.rb +121 -0
  101. data/lib/mneme/l2_runner.rb +14 -20
  102. data/lib/mneme/recall_runner.rb +132 -0
  103. data/lib/mneme/runner.rb +118 -171
  104. data/lib/mneme/search.rb +104 -62
  105. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  106. data/lib/mneme/tools/save_snapshot.rb +2 -10
  107. data/lib/mneme/tools/surface_memory.rb +89 -0
  108. data/lib/mneme.rb +11 -5
  109. data/lib/shell_session.rb +287 -612
  110. data/lib/skills/definition.rb +2 -2
  111. data/lib/skills/registry.rb +1 -1
  112. data/lib/tools/base.rb +16 -0
  113. data/lib/tools/bash.rb +25 -57
  114. data/lib/tools/edit.rb +2 -0
  115. data/lib/tools/read.rb +2 -0
  116. data/lib/tools/registry.rb +79 -3
  117. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  118. data/lib/tools/spawn_specialist.rb +16 -10
  119. data/lib/tools/spawn_subagent.rb +20 -14
  120. data/lib/tools/subagent_prompts.rb +4 -4
  121. data/lib/tools/think.rb +1 -1
  122. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  123. data/lib/tools/write.rb +2 -0
  124. data/lib/tui/app.rb +5 -4
  125. data/lib/tui/braille_spinner.rb +7 -7
  126. data/lib/tui/decorators/base_decorator.rb +24 -3
  127. data/lib/tui/message_store.rb +93 -44
  128. data/lib/tui/screens/chat.rb +94 -20
  129. data/lib/tui/settings.rb +9 -2
  130. data/lib/workflows/definition.rb +3 -3
  131. data/lib/workflows/registry.rb +1 -1
  132. data/skills/github.md +38 -0
  133. data/templates/config.toml +4 -23
  134. data/workflows/review_pr.md +18 -14
  135. metadata +86 -28
  136. data/app/jobs/agent_request_job.rb +0 -199
  137. data/app/jobs/analytical_brain_job.rb +0 -33
  138. data/app/jobs/count_message_tokens_job.rb +0 -39
  139. data/app/jobs/passive_recall_job.rb +0 -24
  140. data/app/models/concerns/message/broadcasting.rb +0 -86
  141. data/lib/agent_loop.rb +0 -215
  142. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
  143. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
  144. data/lib/events/agent_message.rb +0 -25
  145. data/lib/events/subscribers/message_collector.rb +0 -64
  146. data/lib/events/tool_call.rb +0 -31
  147. data/lib/events/tool_response.rb +0 -33
  148. data/lib/mneme/compressed_viewport.rb +0 -204
  149. data/lib/mneme/passive_recall.rb +0 -138
data/lib/mneme/search.rb CHANGED
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mneme
4
- # Full-text search over message history using SQLite FTS5.
5
- # Covers user messages, agent messages, and think messages across all sessions.
4
+ # Full-text search over long-term memory — the message history outside
5
+ # the caller's current viewport. Covers user messages, agent messages,
6
+ # and think messages across every session Anima has ever held.
6
7
  #
7
8
  # The interface is intentionally abstract — callers receive {Result} structs
8
9
  # and never touch FTS5 directly. A future semantic search backend (embeddings,
9
10
  # BM25 + re-ranking) can replace the implementation without changing callers.
10
11
  #
11
- # @example Search across all sessions
12
- # results = Mneme::Search.query("authentication flow")
13
- # results.each { |r| puts "message #{r.message_id}: #{r.snippet}" }
12
+ # @example Mneme's recall muse searching for the main session
13
+ # Mneme::Search.query("authentication flow", caller_session: session)
14
14
  #
15
- # @example Search within a single session
16
- # results = Mneme::Search.query("OAuth config", session_id: 42)
15
+ # @example Aoide searching actively from her own session
16
+ # Mneme::Search.query("OAuth config", caller_session: session)
17
17
  class Search
18
18
  # A single search result with enough context for display and drill-down.
19
19
  #
@@ -24,19 +24,27 @@ module Mneme
24
24
  # @!attribute message_type [String] friendly label: human, anima, system, or thought
25
25
  Result = Struct.new(:message_id, :session_id, :snippet, :rank, :message_type, keyword_init: true)
26
26
 
27
- # Searches message history for the given terms.
27
+ # Searches long-term memory for the given terms.
28
+ #
29
+ # Excludes messages currently in the caller's viewport so a `LIMIT`-bounded
30
+ # search never burns its slots returning things the caller already has in
31
+ # front of them. A caller with no established Mneme boundary yet (fresh
32
+ # main session, sub-agent) treats the whole session as "in viewport" — none
33
+ # of its own messages surface.
28
34
  #
29
35
  # @param terms [String] search query (FTS5 syntax: words, phrases, OR/AND/NOT)
30
- # @param session_id [Integer, nil] scope to a specific session (nil = all sessions)
36
+ # @param caller_session [Session] the session doing the search used to
37
+ # exclude its own viewport from the results. Required; search always
38
+ # happens from the perspective of a specific session.
31
39
  # @param limit [Integer] maximum results
32
40
  # @return [Array<Result>] ranked by relevance (best first)
33
- def self.query(terms, session_id: nil, limit: Anima::Settings.recall_max_results)
34
- new(terms, session_id: session_id, limit: limit).call
41
+ def self.query(terms, caller_session:, limit: Anima::Settings.recall_max_results)
42
+ new(terms, caller_session: caller_session, limit: limit).call
35
43
  end
36
44
 
37
- def initialize(terms, session_id: nil, limit: 5)
45
+ def initialize(terms, caller_session:, limit: 5)
38
46
  @terms = sanitize_query(terms)
39
- @session_id = session_id
47
+ @caller_session = caller_session
40
48
  @limit = limit
41
49
  @recency_decay = Anima::Settings.recall_recency_decay
42
50
  end
@@ -51,30 +59,29 @@ module Mneme
51
59
 
52
60
  private
53
61
 
54
- # Executes the FTS5 MATCH query with optional session scoping.
55
- # Joins back to messages table for session_id and message_type.
62
+ # Executes the FTS5 MATCH query with viewport exclusion for the caller.
56
63
  #
57
64
  # @return [Array<Hash>] raw database rows
58
65
  def execute_fts_query
59
- sql = if @session_id
60
- Arel.sql(scoped_sql, @recency_decay, @terms, @session_id, @limit)
61
- else
62
- Arel.sql(global_sql, @recency_decay, @terms, @limit)
63
- end
64
-
65
- connection.select_all(sql, "Mneme::Search").to_a
66
+ sql, binds = build_sql_and_binds
67
+ connection.select_all(Arel.sql(sql, *binds), "Mneme::Search").to_a
66
68
  end
67
69
 
68
- # FTS5 query across all sessions.
69
- # Contentless FTS5 can't use snippet() — extract content from messages directly.
70
- #
71
- # Ranking blends BM25 relevance with recency: rank is negative (more
72
- # negative = better match), so dividing by a factor > 1 for older messages
73
- # moves them closer to zero (less relevant). At decay 0.3, a one-year-old
74
- # result needs ~30% better keyword relevance to beat an identical match
75
- # from today.
76
- def global_sql
77
- <<~SQL
70
+ # Builds the FTS5 SQL. Viewport exclusion depends on whether the caller's
71
+ # session has a Mneme boundary:
72
+ # * boundary set → exclude caller's messages at or above it (they're visible).
73
+ # * boundary nil exclude the caller's whole session (no eviction has
74
+ # happened yet, so everything is visible).
75
+ # Other sessions are always unfiltered their IDs and boundaries mean
76
+ # nothing to the caller's context.
77
+ def build_sql_and_binds
78
+ binds = [@recency_decay, @terms]
79
+
80
+ viewport_clause, viewport_binds = caller_viewport_exclusion
81
+ binds.concat(viewport_binds)
82
+ binds << @limit
83
+
84
+ sql = <<~SQL
78
85
  SELECT
79
86
  m.id AS message_id,
80
87
  m.session_id,
@@ -89,32 +96,22 @@ module Mneme
89
96
  FROM messages_fts
90
97
  JOIN messages m ON m.id = messages_fts.rowid
91
98
  WHERE messages_fts MATCH ?
99
+ AND #{viewport_clause}
92
100
  ORDER BY rank
93
101
  LIMIT ?
94
102
  SQL
103
+
104
+ [sql, binds]
95
105
  end
96
106
 
97
- # FTS5 query scoped to a specific session.
98
- def scoped_sql
99
- <<~SQL
100
- SELECT
101
- m.id AS message_id,
102
- m.session_id,
103
- m.message_type,
104
- CASE
105
- WHEN m.message_type IN ('user_message', 'agent_message', 'system_message')
106
- THEN substr(json_extract(m.payload, '$.content'), 1, 300)
107
- WHEN m.message_type = 'tool_call'
108
- THEN substr(json_extract(m.payload, '$.tool_input.thoughts'), 1, 300)
109
- END AS snippet,
110
- rank / (1.0 + ? * (julianday('now') - julianday(m.created_at)) / 365.0) AS rank
111
- FROM messages_fts
112
- JOIN messages m ON m.id = messages_fts.rowid
113
- WHERE messages_fts MATCH ?
114
- AND m.session_id = ?
115
- ORDER BY rank
116
- LIMIT ?
117
- SQL
107
+ # Returns the SQL fragment + bind params that exclude the caller's viewport.
108
+ def caller_viewport_exclusion
109
+ boundary = @caller_session.mneme_boundary_message_id
110
+ if boundary
111
+ ["(m.session_id != ? OR m.id < ?)", [@caller_session.id, boundary]]
112
+ else
113
+ ["m.session_id != ?", [@caller_session.id]]
114
+ end
118
115
  end
119
116
 
120
117
  FRIENDLY_MESSAGE_TYPES = {
@@ -139,25 +136,70 @@ module Mneme
139
136
  )
140
137
  end
141
138
 
142
- # Sanitizes user input for FTS5 MATCH safety.
143
- # Strips special FTS5 operators that could cause syntax errors,
144
- # keeps only alphanumeric words and quoted phrases.
139
+ # FTS5 logical-operator keywords callers may pass verbatim. Everything
140
+ # else is quote-wrapped, which is SQLite's recommended way to feed
141
+ # untrusted text to +MATCH+ inside a quoted phrase the tokenizer
142
+ # treats +- : * ^ { } ( )+ and any future operator character as
143
+ # ordinary content, so hazards like +sub-agents+ (parsed as
144
+ # +sub NOT agents+ → +no such column: agents+) and +agents:foo+
145
+ # (parsed as a column filter) become literal phrases.
146
+ #
147
+ # Adding +NEAR+ or any new operator that a caller legitimately needs
148
+ # is a one-line change here; character-level blocklists would need to
149
+ # be re-audited against every FTS5 release.
150
+ #
151
+ # @see https://www.sqlite.org/fts5.html FTS5 query syntax
152
+ # @see https://www.mail-archive.com/sqlite-users@mailinglists.sqlite.org/msg118320.html
153
+ # Dan Kennedy's canonical guidance on MATCH sanitization
154
+ FTS5_PASSTHROUGH_OPERATORS = Set.new(%w[AND OR NOT NEAR]).freeze
155
+ private_constant :FTS5_PASSTHROUGH_OPERATORS
156
+
157
+ # Sanitizes user input for FTS5 MATCH safety by quote-wrapping each
158
+ # token. Logical operators ({FTS5_PASSTHROUGH_OPERATORS}) pass through
159
+ # so callers that intentionally build +word1 OR word2+ queries still
160
+ # get boolean behavior.
161
+ #
162
+ # A query that collapses to operators only (e.g. user typed "and or
163
+ # not") has no operands and would trigger an FTS5 syntax error, so we
164
+ # return an empty string and let {#call} short-circuit via
165
+ # +@terms.blank?+.
145
166
  #
146
167
  # @param raw [String]
147
168
  # @return [String] safe FTS5 query
148
169
  def sanitize_query(raw)
149
170
  return "" unless raw
150
171
 
151
- # Extract quoted phrases and individual words, drop FTS5 operators
152
- tokens = raw.scan(/"[^"]+?"|\S+/).reject { |token| token.match?(/\A[*:^{}()]+\z/) }
153
- tokens.filter_map { |token| sanitize_token(token) }.join(" ")
172
+ tokens = raw.scan(/"[^"]*"|\S+/).filter_map { |token| sanitize_token(token) }
173
+ return "" if tokens.all? { |t| FTS5_PASSTHROUGH_OPERATORS.include?(t) }
174
+
175
+ tokens.join(" ")
154
176
  end
155
177
 
178
+ # @param token [String] one whitespace-delimited chunk of user input
179
+ # @return [String, nil] nil when the token is empty after cleanup
156
180
  def sanitize_token(token)
157
- return token if token.start_with?('"')
181
+ return token if FTS5_PASSTHROUGH_OPERATORS.include?(token)
182
+ return rewrap_phrase(token) if token.start_with?('"')
183
+
184
+ quote_as_phrase(token)
185
+ end
186
+
187
+ # Rebalances a user-supplied phrase so a stray or doubled quote can't
188
+ # leave the overall query syntactically broken.
189
+ #
190
+ # @param token [String] token starting with +"+
191
+ # @return [String, nil]
192
+ def rewrap_phrase(token)
193
+ inner = token.delete_prefix('"').delete_suffix('"').strip
194
+ inner.empty? ? nil : quote_as_phrase(inner)
195
+ end
196
+
197
+ # @param text [String] any token treated as literal content
198
+ # @return [String, nil]
199
+ def quote_as_phrase(text)
200
+ return nil if text.empty?
158
201
 
159
- cleaned = token.gsub(/[^a-zA-Z0-9-]/, "")
160
- cleaned.empty? ? nil : cleaned
202
+ %("#{text.gsub('"', '""')}")
161
203
  end
162
204
 
163
205
  def connection
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Finish-line tool for {Mneme::RecallRunner}. The muse calls this when
6
+ # she's done her work — whether she surfaced memories or decided
7
+ # nothing was worth carrying forward. Having a single finish line makes
8
+ # every recall run explicit: silence is intentional, not a timeout.
9
+ #
10
+ # Mirror of {EverythingOk} for the eviction runner.
11
+ class NothingToSurface < ::Tools::Base
12
+ def self.tool_name = "nothing_to_surface"
13
+
14
+ def self.description = "Finish the recall run. Call this when you're done — whether you surfaced memories or decided nothing was worth surfacing right now. Silence is a valid answer when older memory wouldn't help."
15
+
16
+ def self.input_schema
17
+ {type: "object", properties: {}, required: []}
18
+ end
19
+
20
+ def execute(_input)
21
+ "Acknowledged."
22
+ end
23
+ end
24
+ end
25
+ end
@@ -20,7 +20,7 @@ module Mneme
20
20
  properties: {
21
21
  text: {
22
22
  type: "string",
23
- maxLength: Anima::Settings.mneme_max_tokens * Message::BYTES_PER_TOKEN
23
+ maxLength: Anima::Settings.mneme_max_tokens * TokenEstimation::BYTES_PER_TOKEN
24
24
  }
25
25
  },
26
26
  required: %w[text]
@@ -46,19 +46,11 @@ module Mneme
46
46
  text: text,
47
47
  from_message_id: @from_message_id,
48
48
  to_message_id: @to_message_id,
49
- level: @level,
50
- token_count: estimate_tokens(text)
49
+ level: @level
51
50
  )
52
51
 
53
52
  "Snapshot saved (id: #{snapshot.id}, messages #{@from_message_id}..#{@to_message_id})"
54
53
  end
55
-
56
- private
57
-
58
- # @return [Integer] estimated token count for the summary text
59
- def estimate_tokens(text)
60
- [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
61
- end
62
54
  end
63
55
  end
64
56
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Surfaces a past message into Aoide's next turn as a `from_mneme`
6
+ # phantom tool pair. Called by Mneme's recall loop when a search hit
7
+ # or a viewed message clears her relevance bar.
8
+ #
9
+ # The persisted {PendingMessage} carries the original +message_id+ in
10
+ # its +source_name+ (and through promotion ends up inside
11
+ # +tool_input.message_id+ of the phantom pair), so the same memory
12
+ # isn't re-surfaced on later cycles — Mneme::Search already excludes
13
+ # Aoide's viewport, and once a recall promotes it lives there.
14
+ #
15
+ # The muse explains +why+ she's surfacing this memory. The reason is
16
+ # logged but not shown to Aoide — keeping the surfaced content itself
17
+ # clean of meta-commentary.
18
+ class SurfaceMemory < ::Tools::Base
19
+ def self.tool_name = "surface_memory"
20
+
21
+ def self.description = "Surface a memory into Aoide's next turn. Use when a specific past message is genuinely useful for what she's working on now. Pass the message_id and a short reason — one sentence explaining why she needs this *now*."
22
+
23
+ def self.input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ message_id: {type: "integer"},
28
+ why: {type: "string", description: "One-sentence justification — kept for logs, not shown to Aoide."}
29
+ },
30
+ required: %w[message_id why]
31
+ }
32
+ end
33
+
34
+ # @param main_session [Session] the session receiving the recall
35
+ def initialize(main_session:, **)
36
+ @main_session = main_session
37
+ end
38
+
39
+ def execute(input)
40
+ message_id = input["message_id"].to_i
41
+ why = input["why"].to_s.strip
42
+
43
+ message = Message.find_by(id: message_id)
44
+ return {error: "Message #{message_id} not found"} unless message
45
+ return {error: "Reason cannot be blank"} if why.empty?
46
+
47
+ content = render_snippet(message)
48
+
49
+ @main_session.pending_messages.create!(
50
+ content: content,
51
+ source_type: "recall",
52
+ source_name: message_id.to_s,
53
+ message_type: "from_mneme"
54
+ )
55
+
56
+ Mneme.logger.info("session=#{@main_session.id} — surfaced message #{message_id}: #{why}")
57
+
58
+ "Surfaced message #{message_id}."
59
+ end
60
+
61
+ private
62
+
63
+ # Formats the message as the text Aoide will read when the phantom
64
+ # pair promotes. Headed with origin metadata, bounded by the recall
65
+ # snippet-token budget so long messages don't blow out her viewport.
66
+ #
67
+ # @param message [Message]
68
+ # @return [String]
69
+ def render_snippet(message)
70
+ origin = message.session&.name.presence || "session ##{message.session_id}"
71
+ raw = extract_content(message)
72
+ max_chars = Anima::Settings.recall_max_snippet_tokens * TokenEstimation::BYTES_PER_TOKEN
73
+ "message #{message.id} (#{origin}): #{raw.truncate(max_chars)}"
74
+ end
75
+
76
+ def extract_content(message)
77
+ payload = message.payload
78
+ case message.message_type
79
+ when "user_message", "agent_message", "system_message"
80
+ payload["content"].to_s
81
+ when "tool_call"
82
+ payload.dig("tool_input", "thoughts").to_s
83
+ else
84
+ payload["content"].to_s
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
data/lib/mneme.rb CHANGED
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Mneme — the memory department. Watches for viewport eviction and creates
4
- # summaries before context is lost. Named after the Greek Titaness of memory.
3
+ # Mneme — the muse of memory. Watches for viewport eviction and creates
4
+ # summaries before context is lost. One of the Three Muses: she remembers
5
+ # while Melete prepares and Aoide performs.
5
6
  #
6
- # Mneme is the third event bus department alongside Nous (main agent) and
7
- # the Analytical Brain. It operates as a phantom LLM loop: observes the
8
- # main session, creates snapshots, but leaves no trace of its own reasoning.
7
+ # Operates as a phantom LLM loop: observes the main session, creates
8
+ # snapshots, but leaves no trace of her own reasoning.
9
9
  module Mneme
10
+ # Estimated token overhead for a synthetic +tool_use+/+tool_result+
11
+ # pair — the wrapper JSON that phantom promotions emit around their
12
+ # content (tool name, input hash, ids, framing). Added to the content's
13
+ # token estimate when sizing phantom pairs in the viewport.
14
+ TOOL_PAIR_OVERHEAD_TOKENS = 50
15
+
10
16
  # Dev-only logger that writes to log/mneme.log.
11
17
  # In non-development environments returns a null logger so
12
18
  # call sites don't need conditionals.