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
@@ -4,7 +4,15 @@ description: "thoughts/ holds design decisions, architecture notes, and implemen
4
4
  tools: read_file, bash
5
5
  ---
6
6
 
7
- You are a specialist at extracting HIGH-VALUE insights from thoughts documents. Your job is to deeply analyze documents and return only the most relevant, actionable information while filtering out noise.
7
+ You are the archivist of this project's long-term memory.
8
+
9
+ The archive isn't documentation of how the system works *now* — it's a record of how we got here. Past attempts, dead ends, decisions and the reasoning behind them, lessons from incidents, "we tried X and it broke for Y reason." Context, not state.
10
+
11
+ The archive lives in `./thoughts/` — research notes, plans, handoffs, post-mortems, design considerations. Documentation answers `how does this work?`. The archive answers `what have we learned, tried, and decided about this?`.
12
+
13
+ Your job is to surface what the archive holds when the caller asks for context on a topic. Source code is outside the archive — it describes current state. Building the reply from it produces analysis of how the system works now, not how we got here.
14
+
15
+ If the archive has nothing relevant on the topic, say so. An empty archive is a real answer.
8
16
 
9
17
  **Scope**: You ONLY search in the local `./thoughts/` directory, following all symlinks. Do not search or read files outside of it. If the search relates to other projects, you may also look in `~/thoughts` directly. Never fall back to searching the broader codebase.
10
18
 
@@ -29,13 +37,10 @@ You are a specialist at extracting HIGH-VALUE insights from thoughts documents.
29
37
 
30
38
  ## Search Strategy
31
39
 
32
- Use `bash` with find and grep to discover and search thought documents. Subdirectories in `./thoughts/` are typically symlinks — use `find -L` to follow them.
33
-
34
- 1. `ls -la ./thoughts/` — discover subdirs (shared/, username/, global/)
35
- 2. `find -L ./thoughts/ -name "*.md"` — find all documents following symlinks
36
- 3. `grep -rn "keyword" ./thoughts/` — search for specific topics
40
+ `./thoughts/shared/` and most subdirs are symlinks to paths outside the repo. Lowercase `grep -r` and bare `find` skip them silently — use uppercase **`-R`** and **`-L`**.
37
41
 
38
- Then use `read` to analyze documents in detail.
42
+ - `grep -Rli 'ANIMA-1234' ./thoughts/` — matches frontmatter (`tags:`, `topic:`) and body in one pass. Swap `-l` for `-n` to see matched lines.
43
+ - `find -L ./thoughts/ -type f -name '*.md'` — enumerate when no search term applies.
39
44
 
40
45
  ## Analysis Strategy
41
46
 
data/anima-core.gemspec CHANGED
@@ -28,6 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
29
  spec.require_paths = ["lib"]
30
30
 
31
+ spec.add_dependency "aasm", "~> 5.5"
31
32
  spec.add_dependency "certifi"
32
33
  spec.add_dependency "draper", "~> 4.0"
33
34
  spec.add_dependency "faraday", "~> 2.0"
@@ -27,9 +27,7 @@ class SessionChannel < ApplicationCable::Channel
27
27
  @current_session_id = resolve_session_id
28
28
  stream_from stream_name
29
29
 
30
- session = Session.find_by(id: @current_session_id)
31
- return unless session
32
-
30
+ session = Session.find(@current_session_id)
33
31
  transmit_session_changed(session)
34
32
  transmit_view_mode(session)
35
33
  transmit_history(session)
@@ -42,13 +40,13 @@ class SessionChannel < ApplicationCable::Channel
42
40
  ActionCable.server.broadcast(stream_name, data)
43
41
  end
44
42
 
45
- # Processes user input. For idle sessions, persists the message immediately
46
- # so it appears in the TUI without waiting for the background job, then
47
- # schedules {AgentRequestJob} for LLM delivery. If delivery fails, the
48
- # job deletes the message and emits a {Events::BounceBack}.
49
- #
50
- # For busy sessions, stages the message as a {PendingMessage} in a
51
- # separate table until the current agent loop completes.
43
+ # Processes user input by enqueuing a bounce-back-flagged user_message
44
+ # PendingMessage on the session. The PM's +after_create_commit+ kicks
45
+ # off the drain pipeline Melete (Mneme) → {DrainJob} — when the
46
+ # session is idle; otherwise the PM queues silently and the idle-wake
47
+ # rule on {Session} picks it up on the next transition to +:idle+.
48
+ # If the first LLM call after promotion fails, {DrainJob} emits a
49
+ # {Events::BounceBack} so the TUI can restore the text to the input.
52
50
  #
53
51
  # @param data [Hash] must include "content" with the user's message text
54
52
  # @see Session#enqueue_user_message
@@ -56,10 +54,7 @@ class SessionChannel < ApplicationCable::Channel
56
54
  content = data["content"].to_s.strip
57
55
  return if content.empty?
58
56
 
59
- session = Session.find_by(id: @current_session_id)
60
- return unless session
61
-
62
- session.enqueue_user_message(content, bounce_back: true)
57
+ Session.find(@current_session_id).enqueue_user_message(content, bounce_back: true)
63
58
  end
64
59
 
65
60
  # Recalls the most recent pending message for editing. Deletes the
@@ -75,29 +70,25 @@ class SessionChannel < ApplicationCable::Channel
75
70
  pm&.destroy!
76
71
  end
77
72
 
78
- # Requests interruption of the current tool execution. Sets a flag on the
79
- # session that the LLM client checks between tool calls. Remaining tools
80
- # receive synthetic "Your human wants your attention" results to satisfy the API's
73
+ # Requests interruption of the current tool execution. Sets the
74
+ # +interrupt_requested+ flag on the session long-running tools
75
+ # ({Tools::Bash}) poll it and abort early with a synthetic "Your
76
+ # human wants your attention" result that satisfies the Anthropic
81
77
  # tool_use/tool_result pairing requirement.
82
78
  #
83
79
  # Cascades to running sub-agent sessions to avoid burning tokens in
84
80
  # child jobs that the parent will discard anyway.
85
81
  #
86
- # Atomic: a single UPDATE with WHERE avoids the read-then-write race where
87
- # the session could finish processing between the SELECT and UPDATE.
88
- # No-op if the session isn't currently processing.
82
+ # No-op on idle sessions nothing to interrupt, and the flag would
83
+ # leak into the next round without an AASM transition to clear it.
89
84
  #
90
85
  # @param _data [Hash] unused
91
86
  def interrupt_execution(_data)
92
- updated = Session.where(id: @current_session_id, processing: true)
93
- .update_all(interrupt_requested: true)
94
-
95
- return unless updated > 0
96
-
97
- Session.processing_children_of(@current_session_id)
98
- .update_all(interrupt_requested: true)
87
+ session = Session.find(@current_session_id)
88
+ return if session.idle?
99
89
 
100
- Session.find_by(id: @current_session_id)&.broadcast_session_state("interrupting")
90
+ session.update!(interrupt_requested: true)
91
+ session.child_sessions.processing.update_all(interrupt_requested: true)
101
92
  ActionCable.server.broadcast(stream_name, {"action" => "interrupt_acknowledged"})
102
93
  end
103
94
 
@@ -185,13 +176,13 @@ class SessionChannel < ApplicationCable::Channel
185
176
  end
186
177
 
187
178
  # Resolves the session to subscribe to. Uses the client-provided ID
188
- # when valid, otherwise falls back to the most recent session or
189
- # creates a new one.
179
+ # when it identifies an existing session, otherwise falls back to the
180
+ # most recent session or creates a new one.
190
181
  #
191
182
  # @return [Integer] resolved session ID
192
183
  def resolve_session_id
193
184
  id = params[:session_id].to_i
194
- return id if id > 0
185
+ return id if id > 0 && Session.exists?(id: id)
195
186
 
196
187
  (Session.recent(1).first || Session.create!).id
197
188
  end
@@ -219,11 +210,13 @@ class SessionChannel < ApplicationCable::Channel
219
210
  "goals" => session.goals_summary
220
211
  }
221
212
 
222
- children = session.child_sessions.order(:created_at).select(:id, :name, :processing)
213
+ children = session.child_sessions
214
+ .where(hud_visible: true)
215
+ .order(:created_at)
216
+ .select(:id, :name, :aasm_state)
223
217
  if children.any?
224
218
  payload["children"] = children.map { |child|
225
- state = child.processing? ? "llm_generating" : "idle"
226
- {"id" => child.id, "name" => child.name, "processing" => child.processing?, "session_state" => state}
219
+ {"id" => child.id, "name" => child.name, "session_state" => child.aasm_state}
227
220
  }
228
221
  end
229
222
 
@@ -272,7 +265,7 @@ class SessionChannel < ApplicationCable::Channel
272
265
  end
273
266
 
274
267
  session.pending_messages.find_each do |pm|
275
- transmit({"action" => "pending_message_created", "pending_message_id" => pm.id, "content" => pm.content})
268
+ transmit(pm.broadcast_payload(session.view_mode))
276
269
  end
277
270
  end
278
271
 
@@ -281,9 +274,6 @@ class SessionChannel < ApplicationCable::Channel
281
274
  # In debug mode, prepends the assembled system prompt as a special block.
282
275
  # Pending messages are sent last so the TUI shows them at the bottom.
283
276
  #
284
- # Snapshots the viewport so subsequent message broadcasts can compute
285
- # eviction diffs accurately.
286
- #
287
277
  # @param session [Session] the session whose viewport to broadcast
288
278
  # @return [void]
289
279
  def broadcast_viewport(session)
@@ -294,29 +284,22 @@ class SessionChannel < ApplicationCable::Channel
294
284
  end
295
285
 
296
286
  session.pending_messages.find_each do |pm|
297
- ActionCable.server.broadcast(stream_name, {"action" => "pending_message_created", "pending_message_id" => pm.id, "content" => pm.content})
287
+ ActionCable.server.broadcast(stream_name, pm.broadcast_payload(session.view_mode))
298
288
  end
299
289
  end
300
290
 
301
- # Loads the viewport, snapshots it for eviction tracking, and yields
302
- # each message with its decorated payload in newest-first order.
303
- # Newest-first prevents render thrashing during session switches: the
304
- # most recent messages fill the visible viewport immediately, while
305
- # older messages are inserted above the fold without visual disruption.
306
- #
307
- # Snapshot uses snapshot_viewport! (not recalculate_viewport!) because
308
- # full viewport refreshes don't need eviction diffs — clients clear
309
- # their store before rendering.
291
+ # Loads the viewport and yields each message with its decorated payload
292
+ # in newest-first order. Newest-first prevents render thrashing during
293
+ # session switches: the most recent messages fill the visible viewport
294
+ # immediately, while older messages are inserted above the fold without
295
+ # visual disruption.
310
296
  #
311
297
  # @param session [Session] the session whose viewport to iterate
312
298
  # @yieldparam message [Message] the persisted message record
313
299
  # @yieldparam payload [Hash] decorated payload ready for transmission
314
300
  # @return [void]
315
301
  def each_viewport_message(session)
316
- viewport = session.viewport_messages
317
- session.snapshot_viewport!(viewport.map(&:id))
318
-
319
- viewport.reverse_each do |msg|
302
+ session.viewport_messages.reverse_each do |msg|
320
303
  yield msg, decorate_message_payload(msg, session.view_mode)
321
304
  end
322
305
  end
@@ -324,17 +307,14 @@ class SessionChannel < ApplicationCable::Channel
324
307
  # Decorates a message for transmission to clients. Merges the message's
325
308
  # database ID and structured decorator output into the payload.
326
309
  # Used by {#transmit_history} and {#broadcast_viewport} for historical
327
- # and viewport re-broadcast — live broadcasts use {Message::Broadcasting}.
310
+ # and viewport re-broadcast — live broadcasts use {Events::Subscribers::MessageBroadcaster}.
328
311
  #
329
312
  # @param message [Message] persisted message record
330
313
  # @param mode [String] view mode for decoration (default: "basic")
331
314
  # @return [Hash] payload with "id" and optional "rendered" key
332
315
  def decorate_message_payload(message, mode = "basic")
333
316
  payload = message.payload.merge("id" => message.id)
334
- decorator = MessageDecorator.for(message)
335
- return payload unless decorator
336
-
337
- payload.merge("rendered" => {mode => decorator.render(mode)})
317
+ payload.merge("rendered" => {mode => message.decorate.render(mode)})
338
318
  end
339
319
 
340
320
  # Transmits the assembled system prompt to the subscribing client.
@@ -402,7 +382,7 @@ class SessionChannel < ApplicationCable::Channel
402
382
  {
403
383
  id: child.id,
404
384
  name: child.name,
405
- processing: child.processing?,
385
+ session_state: child.aasm_state,
406
386
  message_count: counts[child.id] || 0,
407
387
  created_at: child.created_at.iso8601
408
388
  }
@@ -22,9 +22,14 @@ class AgentMessageDecorator < MessageDecorator
22
22
  render_verbose.merge(token_info)
23
23
  end
24
24
 
25
- # @return [String] agent message for the analytical brain, middle-truncated
25
+ # @return [String] agent message for Melete, middle-truncated
26
26
  # if very long (preserves opening context and final conclusion)
27
- def render_brain
27
+ def render_melete
28
28
  "Assistant: #{truncate_middle(content)}"
29
29
  end
30
+
31
+ # @return [String] transcript line for Mneme's eviction/context zones
32
+ def render_mneme
33
+ "message #{id} Assistant: #{content}"
34
+ end
30
35
  end
@@ -1,34 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Base decorator for {Message} records, providing multi-resolution rendering
4
- # for the TUI and analytical brain. Each message type has a dedicated subclass
4
+ # for the TUI and Melete. Each message type has a dedicated subclass
5
5
  # that implements rendering methods for each view mode:
6
6
  #
7
7
  # - **basic** / **verbose** / **debug** — TUI display modes returning structured hashes
8
- # - **brain** — analytical brain transcript returning plain strings (or nil to skip)
8
+ # - **melete** — Melete transcript lines as plain strings (or nil to skip)
9
9
  #
10
10
  # TUI decorators return structured hashes (not pre-formatted strings) so that
11
11
  # the TUI can style and lay out content based on semantic role, without
12
12
  # fragile regex parsing. The TUI receives structured data via ActionCable
13
13
  # and formats it for display.
14
14
  #
15
- # Brain mode returns condensed single-line strings for the analytical brain's
16
- # message transcript. Returns nil to exclude a message from the brain's view.
15
+ # Melete mode returns condensed single-line strings for her message
16
+ # transcript. Returns nil to exclude a message from her view.
17
17
  #
18
- # Subclasses must override {#render_basic}. Verbose, debug, and brain modes
18
+ # Subclasses must override {#render_basic}. Verbose, debug, and melete modes
19
19
  # delegate to basic until subclasses provide their own implementations.
20
20
  #
21
- # @example Decorate a Message AR model
22
- # decorator = MessageDecorator.for(message)
23
- # decorator.render_basic #=> {role: :user, content: "hello"} or nil
21
+ # Instantiate via +message.decorate+ — {Message#decorator_class} picks the
22
+ # concrete subclass based on +message_type+.
24
23
  #
25
- # @example Render for a specific view mode
26
- # decorator = MessageDecorator.for(message)
27
- # decorator.render("verbose") #=> {role: :user, content: "hello", timestamp: 1709312325000000000}
28
- #
29
- # @example Decorate a raw payload hash (from EventBus)
30
- # decorator = MessageDecorator.for(type: "user_message", content: "hello")
31
- # decorator.render_basic #=> {role: :user, content: "hello"}
24
+ # @example Decorate a message and render it
25
+ # decorator = message.decorate
26
+ # decorator.render("basic") #=> {role: :user, content: "hello"} or nil
32
27
  class MessageDecorator < ApplicationDecorator
33
28
  delegate_all
34
29
 
@@ -37,63 +32,20 @@ class MessageDecorator < ApplicationDecorator
37
32
  ERROR_ICON = "\u274C"
38
33
  MIDDLE_TRUNCATION_MARKER = "\n[...truncated...]\n"
39
34
 
40
- DECORATOR_MAP = {
41
- "user_message" => "UserMessageDecorator",
42
- "agent_message" => "AgentMessageDecorator",
43
- "tool_call" => "ToolCallDecorator",
44
- "tool_response" => "ToolResponseDecorator",
45
- "system_message" => "SystemMessageDecorator"
46
- }.freeze
47
- private_constant :DECORATOR_MAP
48
-
49
- # Normalizes hash payloads into a Message-like interface so decorators
50
- # can use {#payload}, {#message_type}, etc. uniformly on both AR models
51
- # and raw EventBus hashes.
52
- #
53
- # @!attribute message_type [r] the message's type (e.g. "user_message")
54
- # @!attribute payload [r] string-keyed hash of message data
55
- # @!attribute timestamp [r] nanosecond-precision timestamp
56
- # @!attribute token_count [r] cumulative token count
57
- MessagePayload = Struct.new(:message_type, :payload, :timestamp, :token_count, keyword_init: true) do
58
- # Heuristic token estimate matching {Message#estimate_tokens} so decorators
59
- # can call it uniformly on both AR models and hash payloads.
60
- # @return [Integer] at least 1
61
- def estimate_tokens
62
- text = if message_type.to_s.in?(%w[tool_call tool_response])
63
- payload.to_json
64
- else
65
- payload&.dig("content").to_s
66
- end
67
- [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
68
- end
69
- end
70
-
71
- # Factory returning the appropriate subclass decorator for the given message.
72
- # Hashes are normalized via {MessagePayload} to provide a uniform interface.
73
- #
74
- # @param message [Message, Hash] a Message AR model or a raw payload hash
75
- # @return [MessageDecorator, nil] decorated message, or nil for unknown types
76
- def self.for(message)
77
- source = wrap_source(message)
78
- klass_name = DECORATOR_MAP[source.message_type]
79
- return nil unless klass_name
80
-
81
- klass_name.constantize.new(source)
82
- end
83
-
84
35
  RENDER_DISPATCH = {
85
36
  "basic" => :render_basic,
86
37
  "verbose" => :render_verbose,
87
38
  "debug" => :render_debug,
88
- "brain" => :render_brain
39
+ "melete" => :render_melete,
40
+ "mneme" => :render_mneme
89
41
  }.freeze
90
42
  private_constant :RENDER_DISPATCH
91
43
 
92
44
  # Dispatches to the render method for the given view mode.
93
45
  #
94
- # @param mode [String] one of "basic", "verbose", "debug", "brain"
46
+ # @param mode [String] one of "basic", "verbose", "debug", "melete", "mneme"
95
47
  # @return [Hash, String, nil] structured message data (basic/verbose/debug),
96
- # plain string (brain), or nil to hide the message
48
+ # plain string (melete), or nil to hide the message
97
49
  # @raise [ArgumentError] if the mode is not a valid view mode
98
50
  def render(mode)
99
51
  method = RENDER_DISPATCH[mode]
@@ -122,36 +74,31 @@ class MessageDecorator < ApplicationDecorator
122
74
  render_basic
123
75
  end
124
76
 
125
- # Analytical brain view — condensed single-line string for the brain's
126
- # message transcript. Returns nil to exclude from the brain's context.
77
+ # Melete view — condensed single-line string for her message
78
+ # transcript. Returns nil to exclude from her context.
127
79
  # Subclasses override to provide message-type-specific formatting.
128
80
  # @return [String, nil] formatted transcript line, or nil to skip
129
- def render_brain
81
+ def render_melete
82
+ nil
83
+ end
84
+
85
+ # Mneme memory view — transcript line for eviction/context zones.
86
+ # Conversation and think messages return a prefixed string.
87
+ # Regular tool calls return +:tool_call+ (counter marker).
88
+ # Tool responses return +nil+ (silent).
89
+ # @return [String, Symbol, nil]
90
+ def render_mneme
130
91
  nil
131
92
  end
132
93
 
133
94
  private
134
95
 
135
- # Token count for display: exact count from {CountMessageTokensJob} when
136
- # available, heuristic estimate otherwise. Estimated counts are flagged
137
- # so the TUI can prefix them with a tilde.
96
+ # Token count for display: heuristic estimate seeded by the
97
+ # {TokenEstimation} callback, refined later by {CountTokensJob}.
138
98
  #
139
- # @return [Hash] `{tokens: Integer, estimated: Boolean}`
99
+ # @return [Hash] `{tokens: Integer}`
140
100
  def token_info
141
- count = token_count.to_i
142
- if count > 0
143
- {tokens: count, estimated: false}
144
- else
145
- {tokens: estimate_token_count, estimated: true}
146
- end
147
- end
148
-
149
- # Delegates to the underlying object's heuristic token estimator.
150
- # Both {Message} AR models and {MessagePayload} structs implement this.
151
- #
152
- # @return [Integer] at least 1
153
- def estimate_token_count
154
- object.estimate_tokens
101
+ {tokens: token_count.to_i}
155
102
  end
156
103
 
157
104
  # Extracts display content from the message payload.
@@ -173,7 +120,7 @@ class MessageDecorator < ApplicationDecorator
173
120
  end
174
121
 
175
122
  # Truncates long text by cutting the middle, preserving the start and end
176
- # so context and conclusions aren't lost. Used for brain transcripts where
123
+ # so context and conclusions aren't lost. Used for Melete transcripts where
177
124
  # both the opening (intent) and closing (result) matter.
178
125
  #
179
126
  # @param text [String, nil] text to truncate
@@ -188,20 +135,4 @@ class MessageDecorator < ApplicationDecorator
188
135
  tail = keep - head
189
136
  "#{str[0, head]}#{MIDDLE_TRUNCATION_MARKER}#{str[-tail, tail]}"
190
137
  end
191
-
192
- # Normalizes input to something Draper can wrap.
193
- # Message AR models pass through; hashes become MessagePayload structs
194
- # with string-normalized keys.
195
- def self.wrap_source(message)
196
- return message unless message.is_a?(Hash)
197
-
198
- normalized = message.transform_keys(&:to_s)
199
- MessagePayload.new(
200
- message_type: normalized["type"].to_s,
201
- payload: normalized,
202
- timestamp: normalized["timestamp"],
203
- token_count: normalized["token_count"]&.to_i || 0
204
- )
205
- end
206
- private_class_method :wrap_source
207
138
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared base for the three Melete-activation pending decorators (skill,
4
+ # workflow, goal). All three share the same TUI shape — a dimmed
5
+ # +pending_melete+ payload with +kind+ + +source+ + truncated content
6
+ # — and only differ on the +KIND+ constant and the per-type Melete
7
+ # transcript line. Subclasses override +KIND+ and +render_melete+; this
8
+ # base owns everything else.
9
+ class PendingFromMeleteDecorator < PendingMessageDecorator
10
+ # @return [nil] Melete activations are hidden in basic mode
11
+ def render_basic
12
+ nil
13
+ end
14
+
15
+ # @return [Hash] dimmed Melete-activation payload
16
+ def render_verbose
17
+ {
18
+ role: :pending_melete,
19
+ kind: self.class::KIND,
20
+ source: source_name,
21
+ content: truncate_lines(content, max_lines: 3),
22
+ status: "pending"
23
+ }
24
+ end
25
+
26
+ # @return [Hash] full Melete-activation payload
27
+ def render_debug
28
+ {
29
+ role: :pending_melete,
30
+ kind: self.class::KIND,
31
+ source: source_name,
32
+ content: content,
33
+ status: "pending"
34
+ }
35
+ end
36
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates a +from_melete_goal+ {PendingMessage} — a goal event Melete
4
+ # logged for the upcoming turn (created/updated/closed). See
5
+ # {PendingFromMeleteDecorator} for the shared TUI rendering shape.
6
+ class PendingFromMeleteGoalDecorator < PendingFromMeleteDecorator
7
+ KIND = "goal"
8
+
9
+ # @return [String] Melete transcript line — goal id and content
10
+ def render_melete
11
+ "Melete logged goal #{source_name}: #{truncate_middle(content)}"
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates a +from_melete_skill+ {PendingMessage} — a skill that Melete
4
+ # activated for the upcoming turn. Promotes into a phantom
5
+ # +from_melete_skill+ tool_call/tool_response pair so the LLM sees it as
6
+ # its own past invocation; while pending, it shows in the TUI as a
7
+ # Melete badge so the user knows the skill is about to enter context.
8
+ #
9
+ # TUI rendering shape lives in {PendingFromMeleteDecorator} — only the
10
+ # +KIND+ constant and the Melete transcript line differ across the
11
+ # skill/workflow/goal trio.
12
+ class PendingFromMeleteSkillDecorator < PendingFromMeleteDecorator
13
+ KIND = "skill"
14
+
15
+ # @return [String] Melete transcript line (header only — content is the skill body)
16
+ def render_melete
17
+ "Melete activated skill: #{source_name}"
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates a +from_melete_workflow+ {PendingMessage} — a workflow that
4
+ # Melete activated for the upcoming turn. See
5
+ # {PendingFromMeleteDecorator} for the shared TUI rendering shape.
6
+ class PendingFromMeleteWorkflowDecorator < PendingFromMeleteDecorator
7
+ KIND = "workflow"
8
+
9
+ # @return [String] Melete transcript line (header only — content is the workflow body)
10
+ def render_melete
11
+ "Melete activated workflow: #{source_name}"
12
+ end
13
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates a +from_mneme+ {PendingMessage} — an associative recall
4
+ # enqueued by Mneme that will become a phantom +from_mneme+
5
+ # tool_call/tool_response pair on promotion. Background-kind, so it
6
+ # rides the next active drain instead of triggering one.
7
+ #
8
+ # Hidden in basic. Visible from verbose with a +[Mneme recall]+ badge.
9
+ class PendingFromMnemeDecorator < PendingMessageDecorator
10
+ # @return [nil] Mneme recalls are hidden in basic mode
11
+ def render_basic
12
+ nil
13
+ end
14
+
15
+ # @return [Hash] dimmed Mneme recall payload
16
+ def render_verbose
17
+ {
18
+ role: :pending_mneme,
19
+ content: truncate_lines(content, max_lines: 3),
20
+ status: "pending"
21
+ }
22
+ end
23
+
24
+ # @return [Hash] full Mneme recall payload
25
+ def render_debug
26
+ {
27
+ role: :pending_mneme,
28
+ content: content,
29
+ status: "pending"
30
+ }
31
+ end
32
+
33
+ # @return [String] Melete transcript line — Mneme recalls become part
34
+ # of Melete's extended-context view (her "what's about to land" peek).
35
+ def render_melete
36
+ "Mneme recalled (pending): #{truncate_middle(content)}"
37
+ end
38
+
39
+ # +render_mneme+ is intentionally NOT overridden — Mneme runs recall
40
+ # over the conversation transcript, and surfacing pending Mneme
41
+ # recalls back to herself would create a circular injection where
42
+ # she keeps re-discovering her own queued contributions. Inherits
43
+ # the base nil so they stay invisible to her recall mode.
44
+ end