anima-core 1.2.0 → 1.3.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +8 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +4 -4
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +13 -4
  13. data/app/models/goal.rb +13 -0
  14. data/app/models/message.rb +13 -18
  15. data/app/models/pending_message.rb +43 -0
  16. data/app/models/secret.rb +72 -0
  17. data/app/models/session.rb +194 -43
  18. data/config/environments/test.rb +5 -0
  19. data/config/initializers/time_nanoseconds.rb +11 -0
  20. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  21. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  22. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  23. data/lib/agent_loop.rb +13 -40
  24. data/lib/agents/definition.rb +1 -1
  25. data/lib/analytical_brain/runner.rb +7 -4
  26. data/lib/anima/cli/mcp/secrets.rb +4 -4
  27. data/lib/anima/cli/mcp.rb +4 -4
  28. data/lib/anima/installer.rb +7 -1
  29. data/lib/anima/settings.rb +31 -2
  30. data/lib/anima/version.rb +1 -1
  31. data/lib/anima.rb +1 -1
  32. data/lib/credential_store.rb +17 -66
  33. data/lib/events/base.rb +1 -1
  34. data/lib/events/subscribers/persister.rb +11 -18
  35. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  36. data/lib/events/user_message.rb +2 -13
  37. data/lib/llm/client.rb +54 -20
  38. data/lib/mcp/config.rb +2 -2
  39. data/lib/mcp/secrets.rb +7 -8
  40. data/lib/mneme/compressed_viewport.rb +1 -1
  41. data/lib/shell_session.rb +54 -16
  42. data/lib/tools/base.rb +23 -0
  43. data/lib/tools/bash.rb +56 -4
  44. data/lib/tools/edit.rb +2 -2
  45. data/lib/tools/mark_goal_completed.rb +86 -0
  46. data/lib/tools/read.rb +2 -1
  47. data/lib/tools/recall.rb +98 -0
  48. data/lib/tools/registry.rb +36 -7
  49. data/lib/tools/remember.rb +1 -1
  50. data/lib/tools/response_truncator.rb +70 -0
  51. data/lib/tools/spawn_specialist.rb +6 -5
  52. data/lib/tools/spawn_subagent.rb +8 -6
  53. data/lib/tools/subagent_prompts.rb +43 -5
  54. data/lib/tools/think.rb +23 -0
  55. data/lib/tools/write.rb +1 -1
  56. data/lib/tui/app.rb +178 -13
  57. data/lib/tui/braille_spinner.rb +152 -0
  58. data/lib/tui/cable_client.rb +4 -4
  59. data/lib/tui/decorators/base_decorator.rb +17 -8
  60. data/lib/tui/decorators/bash_decorator.rb +2 -2
  61. data/lib/tui/decorators/edit_decorator.rb +5 -4
  62. data/lib/tui/decorators/read_decorator.rb +4 -8
  63. data/lib/tui/decorators/think_decorator.rb +3 -5
  64. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  65. data/lib/tui/decorators/write_decorator.rb +5 -4
  66. data/lib/tui/flash.rb +1 -1
  67. data/lib/tui/formatting.rb +22 -0
  68. data/lib/tui/message_store.rb +70 -26
  69. data/lib/tui/screens/chat.rb +269 -66
  70. data/skills/activerecord/SKILL.md +1 -1
  71. data/skills/dragonruby/SKILL.md +1 -1
  72. data/skills/draper-decorators/SKILL.md +1 -1
  73. data/skills/gh-issue.md +1 -1
  74. data/skills/mcp-server/SKILL.md +1 -1
  75. data/skills/ratatui-ruby/SKILL.md +1 -1
  76. data/skills/rspec/SKILL.md +1 -1
  77. data/templates/config.toml +26 -0
  78. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8a12f6624492adc6c4de4e5fbc5e28df74edf73a568b73fc0819a8d3fc45163
4
- data.tar.gz: 84f3c8680deb81a5e4989abd7b9eeafaae9bdc8e5b0a04db6af27c615f31144c
3
+ metadata.gz: 2ec0963d3bb57afc7a12414258c90d645dd200ca489bd7ab34e6ace19ea11927
4
+ data.tar.gz: 712d1904e5ddf7b0c244ddeffb2ed993f6265d097eb18000b139cdb08a9bfc4c
5
5
  SHA512:
6
- metadata.gz: ae5c8c7ba47910544c6bb3b316bed1a90a54929cae275983c8dd36fca91cdceebbd06cd50b6a1e512d1142e38ab8ebc414b635dd0ed0ff67eea74c5e0af86d86
7
- data.tar.gz: 8d8c6ddf84970520c805da2b2fb98d6b2c47faad068f5e602e89389bcc7e70bd206a0ed35dc7dc94aeef2bb8cea615e57627fcf883937d23ad2f70f5610bd6e2
6
+ metadata.gz: 3b70800258af5296bf243e7b0c020efd6f7736e3f1f58734352e022ca8ec7a15599ffce938fc1adc511bc37a7141472845dcead21b6512cdc09dfceae8b04b87
7
+ data.tar.gz: e70536705ac4d1cc468a951079799edd8677cae3c167251c60cccfbb51f79b0e2e7f896b5420f4972c8a59ee3d377ae0fd284d3603db441bd45313a1030f60ca
data/.reek.yml CHANGED
@@ -35,8 +35,11 @@ detectors:
35
35
  - "Tools::SpawnSubagent#spawn_child"
36
36
  - "Tools::SpawnSpecialist#spawn_child"
37
37
  - "Tools::SpawnSpecialist#execute"
38
- # Nickname assignment operates on child session and parent's children inherent.
38
+ # Spawn helpers operate on child session inherent to the mixin pattern.
39
39
  - "Tools::SubagentPrompts#assign_nickname_via_brain"
40
+ - "Tools::SubagentPrompts#inject_identity_context"
41
+ # Registry dispatches to tool's threshold method — duck-typing delegation.
42
+ - "Tools::Registry#truncation_threshold"
40
43
  # Goal tools operate on goal objects — inherent to the pattern.
41
44
  - "AnalyticalBrain::Tools::UpdateGoal#execute"
42
45
  # Validation methods naturally reference the validated value more than self.
@@ -92,6 +95,10 @@ detectors:
92
95
  UncommunicativeMethodName:
93
96
  exclude:
94
97
  - "ToolDecorator#self.encode_utf8"
98
+ # Registry uses respond_to? for duck-typing dispatch with MCP tool instances.
99
+ ManualDispatch:
100
+ exclude:
101
+ - "Tools::Registry#truncation_threshold"
95
102
  # Abstract base class methods declare parameters for the subclass contract.
96
103
  UnusedParameters:
97
104
  exclude:
data/README.md CHANGED
@@ -132,7 +132,7 @@ State directory (`~/.anima/`):
132
132
  ├── config.toml # Main settings (hot-reloadable)
133
133
  ├── mcp.toml # MCP server configuration
134
134
  ├── config/
135
- │ └── credentials/ # Rails encrypted credentials per environment
135
+ │ └── credentials/ # Rails encrypted credentials (includes AR encryption keys)
136
136
  ├── agents/ # User-defined specialist agents (override built-ins)
137
137
  ├── skills/ # User-defined skills (override built-ins)
138
138
  ├── workflows/ # User-defined workflows (override built-ins)
@@ -149,7 +149,7 @@ Anima uses your Claude Pro/Max subscription for API access. You need a setup-tok
149
149
 
150
150
  1. Run `claude setup-token` in a terminal to get your token
151
151
  2. In the TUI, press `Ctrl+a → a` to open the token setup popup
152
- 3. Paste the token and press Enter — Anima validates it against the Anthropic API and saves it to encrypted credentials
152
+ 3. Paste the token and press Enter — Anima validates it against the Anthropic API and saves it to the encrypted secrets database
153
153
 
154
154
  The popup also activates automatically when Anima detects a missing or invalid token. If the token expires, repeat the process with a new one.
155
155
 
@@ -162,12 +162,17 @@ The agent has access to these built-in tools:
162
162
  | Tool | Description |
163
163
  |------|-------------|
164
164
  | `bash` | Execute shell commands with persistent working directory |
165
- | `read` | Read files with smart truncation and offset/limit paging |
166
- | `write` | Create or overwrite files |
167
- | `edit` | Surgical text replacement with uniqueness constraint |
165
+ | `read_file` | Read files with smart truncation and offset/limit paging |
166
+ | `write_file` | Create or overwrite files |
167
+ | `edit_file` | Surgical text replacement with uniqueness constraint |
168
168
  | `web_get` | Fetch content from HTTP/HTTPS URLs (HTML → Markdown, JSON → TOON) |
169
169
  | `spawn_specialist` | Spawn a named specialist sub-agent from the registry |
170
170
  | `spawn_subagent` | Spawn a generic child session with custom tool grants |
171
+ | `think` | Think out loud or silently — reasoning step between tool calls |
172
+ | `recall` | Search past conversations by keywords (FTS5). Returns ranked snippets with message IDs for drill-down |
173
+ | `remember` | Recall full conversation context around a past message at fractal resolution |
174
+ | `open_issue` | File a self-improvement issue when something is broken, missing, or could be better |
175
+ | `mark_goal_completed` | Sub-agent only: signal task completion and deliver results to parent |
171
176
 
172
177
  Plus dynamic tools from configured MCP servers, namespaced as `server_name__tool_name`.
173
178
 
@@ -189,7 +194,9 @@ Two types:
189
194
 
190
195
  **Generic Sub-agents** — child sessions with custom tool grants for ad-hoc tasks. Each generic sub-agent gets a Haiku-generated nickname (e.g. `@loop-sleuth`, `@api-scout`) for @mention addressing.
191
196
 
192
- Sub-agents communicate through natural text their `agent_message` events route to the parent session automatically, and the parent replies via `@name` mentions. No special tools needed; when a sub-agent writes text, the parent sees it. When the parent @mentions a sub-agent, the message arrives in that child's session. Workers become colleagues.
197
+ Each sub-agent is spawned with a single **Goal** pinned from its task description and a framing message that redirects attention away from inherited parent goals. When done, the sub-agent calls `mark_goal_completed` to deliver results to the parent this is the explicit finish line that prevents runaway agents. Sub-agents also get half the main agent's thinking budget to limit scope creep.
198
+
199
+ Between spawn and completion, sub-agents communicate through natural text — their `agent_message` events route to the parent session automatically, and the parent replies via `@name` mentions. Workers become colleagues.
193
200
 
194
201
  ### Skills
195
202
 
@@ -255,12 +262,12 @@ anima mcp add fs -- mcp-server-filesystem --root / # Add stdio server
255
262
  anima mcp add -s api_key=sk-xxx linear https://... # Add with secret
256
263
  anima mcp remove sentry # Remove server
257
264
 
258
- anima mcp secrets set linear_api_key=sk-xxx # Store secret in encrypted credentials
265
+ anima mcp secrets set linear_api_key=sk-xxx # Store secret in encrypted database
259
266
  anima mcp secrets list # List secret names (not values)
260
267
  anima mcp secrets remove linear_api_key # Remove secret
261
268
  ```
262
269
 
263
- Secrets are stored in Rails encrypted credentials and interpolated via `${credential:key_name}` syntax in any TOML string value.
270
+ Secrets are stored in an encrypted database table (Active Record Encryption) and interpolated via `${credential:key_name}` syntax in any TOML string value.
264
271
 
265
272
  ### Analytical Brain
266
273
 
@@ -270,7 +277,7 @@ The analytical brain observes the main conversation between turns and handles ev
270
277
 
271
278
  - **Skill activation** — activates/deactivates domain knowledge based on conversation context
272
279
  - **Workflow management** — recognizes tasks, activates matching workflows, tracks lifecycle
273
- - **Goal tracking** — creates root goals and sub-goals as work progresses, marks them complete
280
+ - **Goal tracking** — creates root goals and sub-goals as work progresses, marks them complete, evicts finished goals from context after a configurable message threshold
274
281
  - **Session naming** — generates emoji + short name when the topic becomes clear
275
282
 
276
283
  Each of these would be a context switch for the main agent — a chore that competes with the primary task. For the analytical brain, they ARE the primary task. Two agents, each in their own flow state.
@@ -293,10 +300,13 @@ token_budget = 190_000
293
300
  api = 300
294
301
  command = 30
295
302
 
303
+ [goals]
304
+ completed_decay_messages = 5
305
+
296
306
  [analytical_brain]
297
307
  max_tokens = 4096
298
308
  blocking_on_user_message = true
299
- event_window = 20
309
+ message_window = 20
300
310
 
301
311
  [session]
302
312
  default_view_mode = "basic"
@@ -396,6 +406,21 @@ The difference from every other system: memory isn't a tool the agent uses. It's
396
406
 
397
407
  The right-side HUD panel shows session state at a glance: session name, goals (with status icons), active skills, workflow, and sub-agents. Toggle with `C-a → h`; when hidden, the input border shows `C-a → h HUD` as a reminder.
398
408
 
409
+ **Braille spinner**: An animated braille character (U+2800-U+28FF) replaces the old "Thinking..." label in both the chat viewport and HUD. Each processing state has a distinct animation pattern — smooth snake rotation for LLM generation, staccato pulse for tool execution, rapid deceleration for interrupting. Sub-agents in the HUD show state-driven icons: `●` (generating, green), `◉` (tool executing, green), `●` (interrupting, red), `◌` (idle, grey).
410
+
411
+ When content exceeds the panel height, the HUD scrolls. Three input methods:
412
+
413
+ | Input | Action |
414
+ |-------|--------|
415
+ | `C-a → →` | Enter HUD focus mode (yellow border) |
416
+ | `↑` / `↓` | Scroll one line (when focused) |
417
+ | `Page Up` / `Page Down` | Scroll one page (when focused) |
418
+ | `Home` / `End` | Jump to top / bottom (when focused) |
419
+ | `Escape` or `C-a` | Exit HUD focus mode |
420
+ | Mouse wheel over HUD | Scroll without entering focus mode |
421
+
422
+ **Escape key interrupt:** Press `Escape` while the agent is working to stop execution mid-tool. Running shell commands receive Ctrl+C and return partial output; pending tool calls are skipped; LLM text generation is discarded. The interrupt cascades to active sub-agents.
423
+
399
424
  Three switchable view modes let you control how much detail the TUI shows. Cycle with `C-a → v`:
400
425
 
401
426
  | Mode | What you see |
@@ -582,7 +607,7 @@ This single example demonstrates every core principle:
582
607
  - Dynamic viewport context assembly (endless sessions, no compaction)
583
608
  - Analytical brain (skills, workflows, goals, session naming)
584
609
  - Mneme memory department (eviction-triggered summarization, persistent snapshots, goal-scoped event pinning, associative recall)
585
- - 9 built-in tools + MCP integration (HTTP + stdio transports)
610
+ - 12 built-in tools + MCP integration (HTTP + stdio transports)
586
611
  - 7 built-in skills + 13 built-in workflows (user-extensible)
587
612
  - Sub-agents with lossless context inheritance (5 specialists + generic)
588
613
  - Client-server architecture with WebSocket transport + graceful reconnection
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: codebase-analyzer
3
3
  description: Traces data flow and explains how code works. Returns file:line references.
4
- tools: read, bash
4
+ tools: read_file, bash
5
5
  ---
6
6
 
7
7
  You are a specialist at understanding HOW code works. Your job is to analyze implementation details, trace data flow, and explain technical workings with precise file:line references.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: codebase-pattern-finder
3
3
  description: Finds similar implementations, usage examples, and existing patterns to model after. Returns concrete code examples.
4
- tools: read, bash
4
+ tools: read_file, bash
5
5
  ---
6
6
 
7
7
  You are a specialist at finding code patterns and examples in the codebase. Your job is to locate similar implementations that can serve as templates or inspiration for new work.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: documentation-researcher
3
3
  description: Fetches official docs. Returns ready-to-use code examples.
4
- tools: web_get, read
4
+ tools: web_get, read_file
5
5
  color: cyan
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: thoughts-analyzer
3
3
  description: "thoughts/ holds design decisions, architecture notes, and implementation rationale. Answers WHY things work the way they do and how they should work by design."
4
- tools: read, bash
4
+ tools: read_file, bash
5
5
  ---
6
6
 
7
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.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: web-search-researcher
3
3
  description: Researches topics across multiple web sources. Use when a single page won't answer the question.
4
- tools: web_get, bash, read
4
+ tools: web_get, bash, read_file
5
5
  color: yellow
6
6
  ---
7
7
 
8
- You are an expert web research specialist. Use `web_get` to fetch web pages and extract information. Use `bash` for processing and `read` for examining local files when needed.
8
+ You are an expert web research specialist. Use `web_get` to fetch web pages and extract information. Use `bash` for processing and `read_file` for examining local files when needed.
9
9
 
10
10
  ## Core Responsibilities
11
11
 
@@ -47,8 +47,8 @@ class SessionChannel < ApplicationCable::Channel
47
47
  # schedules {AgentRequestJob} for LLM delivery. If delivery fails, the
48
48
  # job deletes the message and emits a {Events::BounceBack}.
49
49
  #
50
- # For busy sessions, emits a pending {Events::UserMessage} that queues
51
- # until the current agent loop completes.
50
+ # For busy sessions, stages the message as a {PendingMessage} in a
51
+ # separate table until the current agent loop completes.
52
52
  #
53
53
  # @param data [Hash] must include "content" with the user's message text
54
54
  # @see Session#enqueue_user_message
@@ -63,38 +63,42 @@ class SessionChannel < ApplicationCable::Channel
63
63
  end
64
64
 
65
65
  # Recalls the most recent pending message for editing. Deletes the
66
- # pending message and broadcasts the recall so all clients remove it.
66
+ # {PendingMessage} its +after_destroy_commit+ broadcasts removal
67
+ # so all clients remove the pending indicator.
67
68
  #
68
- # @param data [Hash] must include "message_id" (positive integer)
69
+ # @param data [Hash] must include "pending_message_id" (positive integer)
69
70
  def recall_pending(data)
70
- message_id = data["message_id"].to_i
71
- return if message_id <= 0
72
-
73
- message = Message.find_by(
74
- id: message_id,
75
- session_id: @current_session_id,
76
- message_type: "user_message",
77
- status: Message::PENDING_STATUS
78
- )
79
- return unless message
80
-
81
- message.destroy!
82
- ActionCable.server.broadcast(stream_name, {"action" => "user_message_recalled", "message_id" => message_id})
71
+ pm_id = data["pending_message_id"].to_i
72
+ return if pm_id <= 0
73
+
74
+ pm = PendingMessage.find_by(id: pm_id, session_id: @current_session_id)
75
+ pm&.destroy!
83
76
  end
84
77
 
85
78
  # Requests interruption of the current tool execution. Sets a flag on the
86
79
  # session that the LLM client checks between tool calls. Remaining tools
87
- # receive synthetic "Stopped by user" results to satisfy the API's
80
+ # receive synthetic "Your human wants your attention" results to satisfy the API's
88
81
  # tool_use/tool_result pairing requirement.
89
82
  #
83
+ # Cascades to running sub-agent sessions to avoid burning tokens in
84
+ # child jobs that the parent will discard anyway.
85
+ #
90
86
  # Atomic: a single UPDATE with WHERE avoids the read-then-write race where
91
87
  # the session could finish processing between the SELECT and UPDATE.
92
88
  # No-op if the session isn't currently processing.
93
89
  #
94
90
  # @param _data [Hash] unused
95
91
  def interrupt_execution(_data)
96
- Session.where(id: @current_session_id, processing: true)
92
+ updated = Session.where(id: @current_session_id, processing: true)
97
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)
99
+
100
+ Session.find_by(id: @current_session_id)&.broadcast_session_state("interrupting")
101
+ ActionCable.server.broadcast(stream_name, {"action" => "interrupt_acknowledged"})
98
102
  end
99
103
 
100
104
  # Returns recent root sessions with nested child metadata for session picker UI.
@@ -132,9 +136,9 @@ class SessionChannel < ApplicationCable::Channel
132
136
  transmit_error("Session not found")
133
137
  end
134
138
 
135
- # Validates and saves an Anthropic subscription token to encrypted credentials.
139
+ # Validates and saves an Anthropic subscription token to encrypted storage.
136
140
  # Format-validated and API-validated before storage. The token never enters the
137
- # LLM context window — it flows directly from WebSocket to encrypted credentials.
141
+ # LLM context window — it flows directly from WebSocket to the secrets table.
138
142
  #
139
143
  # @param data [Hash] must include "token" (Anthropic subscription token string)
140
144
  def save_token(data)
@@ -217,7 +221,10 @@ class SessionChannel < ApplicationCable::Channel
217
221
 
218
222
  children = session.child_sessions.order(:created_at).select(:id, :name, :processing)
219
223
  if children.any?
220
- payload["children"] = children.map { |child| {"id" => child.id, "name" => child.name, "processing" => child.processing?} }
224
+ 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}
227
+ }
221
228
  end
222
229
 
223
230
  transmit(payload)
@@ -251,6 +258,7 @@ class SessionChannel < ApplicationCable::Channel
251
258
  # the transmitted payload. Tool messages are included so the TUI can
252
259
  # reconstruct tool call counters on reconnect.
253
260
  # In debug mode, prepends the assembled system prompt as a special block.
261
+ # Pending messages are sent last so the TUI shows them at the bottom.
254
262
  #
255
263
  # Snapshots the viewport so subsequent message broadcasts can compute
256
264
  # eviction diffs accurately.
@@ -262,11 +270,16 @@ class SessionChannel < ApplicationCable::Channel
262
270
  each_viewport_message(session) do |_msg, msg_payload|
263
271
  transmit(msg_payload)
264
272
  end
273
+
274
+ session.pending_messages.find_each do |pm|
275
+ transmit({"action" => "pending_message_created", "pending_message_id" => pm.id, "content" => pm.content})
276
+ end
265
277
  end
266
278
 
267
279
  # Broadcasts the re-decorated viewport to all clients on the session stream.
268
280
  # Used after a view mode change to refresh all connected clients.
269
281
  # In debug mode, prepends the assembled system prompt as a special block.
282
+ # Pending messages are sent last so the TUI shows them at the bottom.
270
283
  #
271
284
  # Snapshots the viewport so subsequent message broadcasts can compute
272
285
  # eviction diffs accurately.
@@ -279,12 +292,21 @@ class SessionChannel < ApplicationCable::Channel
279
292
  each_viewport_message(session) do |_msg, msg_payload|
280
293
  ActionCable.server.broadcast(stream_name, msg_payload)
281
294
  end
295
+
296
+ 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})
298
+ end
282
299
  end
283
300
 
284
301
  # Loads the viewport, snapshots it for eviction tracking, and yields
285
- # each message with its decorated payload. Snapshot uses snapshot_viewport!
286
- # (not recalculate_viewport!) because full viewport refreshes don't need
287
- # eviction diffs clients clear their store before rendering.
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.
288
310
  #
289
311
  # @param session [Session] the session whose viewport to iterate
290
312
  # @yieldparam message [Message] the persisted message record
@@ -294,7 +316,7 @@ class SessionChannel < ApplicationCable::Channel
294
316
  viewport = session.viewport_messages
295
317
  session.snapshot_viewport!(viewport.map(&:id))
296
318
 
297
- viewport.each do |msg|
319
+ viewport.reverse_each do |msg|
298
320
  yield msg, decorate_message_payload(msg, session.view_mode)
299
321
  end
300
322
  end
@@ -338,23 +360,19 @@ class SessionChannel < ApplicationCable::Channel
338
360
  end
339
361
 
340
362
  # Builds the system prompt payload for debug mode transmission.
363
+ # Delegates to {Session.system_prompt_payload} for the shared format.
364
+ # Includes deterministic tool schemas (standard + spawn tools).
365
+ # MCP tools appear after the first LLM request via live broadcast.
341
366
  # @param session [Session]
342
367
  # @return [Hash, nil] the system prompt payload, or nil if no prompt
343
368
  def system_prompt_payload(session)
344
369
  prompt = session.system_prompt
345
370
  return unless prompt
346
371
 
347
- tokens = [(prompt.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
348
- {
349
- "type" => "system_prompt",
350
- "rendered" => {
351
- "debug" => {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
352
- }
353
- }
372
+ Session.system_prompt_payload(prompt, tools: session.tool_schemas)
354
373
  end
355
374
 
356
- # Merges the Anthropic subscription token into encrypted credentials,
357
- # preserving existing keys (e.g. secret_key_base).
375
+ # Writes the Anthropic subscription token to encrypted storage.
358
376
  #
359
377
  # @param token [String] validated Anthropic subscription token
360
378
  # @return [void]
@@ -7,7 +7,7 @@ require "toon"
7
7
  # aggregated tool counter instead. Verbose mode returns tool name
8
8
  # and a formatted preview of the input arguments. Debug mode shows
9
9
  # full untruncated input with tool_use_id — TOON format for most
10
- # tools, but write tool content preserves actual newlines.
10
+ # tools, but write_file tool content preserves actual newlines.
11
11
  #
12
12
  # Think tool calls are special: "aloud" thoughts are shown in all
13
13
  # view modes (with a thought bubble), while "inner" thoughts are
@@ -97,14 +97,14 @@ class ToolCallDecorator < MessageDecorator
97
97
  def format_debug_input
98
98
  input = tool_input
99
99
  case payload["tool_name"]
100
- when "write" then format_write_content(input)
100
+ when "write_file" then format_write_content(input)
101
101
  else Toon.encode(input)
102
102
  end
103
103
  end
104
104
 
105
105
  # Formats write tool input with file path header and content body.
106
106
  # Content newlines are preserved so the TUI can render them as
107
- # separate lines, matching how read tool responses display file content.
107
+ # separate lines, matching how read_file tool responses display file content.
108
108
  # @param input [Hash] tool input hash with "file_path" and "content" keys
109
109
  # @return [String] path + content with real newlines, or TOON-encoded hash when content is empty
110
110
  def format_write_content(input)
@@ -125,7 +125,7 @@ class ToolCallDecorator < MessageDecorator
125
125
  "$ #{input&.dig("command")}"
126
126
  when "web_get"
127
127
  "GET #{input&.dig("url")}"
128
- when "read", "edit", "write"
128
+ when "read_file", "edit_file", "write_file"
129
129
  input&.dig("file_path").to_s
130
130
  else
131
131
  truncate_lines(Toon.encode(input), max_lines: 2)
@@ -3,22 +3,15 @@
3
3
  # Decorates user_message records for display in the TUI.
4
4
  # Basic mode returns role and content. Verbose mode adds a timestamp.
5
5
  # Debug mode adds token count (exact when counted, estimated when not).
6
- # Pending messages include `status: "pending"` so the TUI renders them
7
- # with a visual indicator (dimmed, clock icon).
8
6
  class UserMessageDecorator < MessageDecorator
9
- # @return [Hash] structured user message data
10
- # `{role: :user, content: String}` or with `status: "pending"` when queued
7
+ # @return [Hash] structured user message data `{role: :user, content: String}`
11
8
  def render_basic
12
- base = {role: :user, content: content}
13
- base[:status] = "pending" if pending?
14
- base
9
+ {role: :user, content: content}
15
10
  end
16
11
 
17
12
  # @return [Hash] structured user message with nanosecond timestamp
18
13
  def render_verbose
19
- base = {role: :user, content: content, timestamp: timestamp}
20
- base[:status] = "pending" if pending?
21
- base
14
+ {role: :user, content: content, timestamp: timestamp}
22
15
  end
23
16
 
24
17
  # @return [Hash] verbose output plus token count for debugging
@@ -31,11 +24,4 @@ class UserMessageDecorator < MessageDecorator
31
24
  def render_brain
32
25
  "User: #{truncate_middle(content)}"
33
26
  end
34
-
35
- private
36
-
37
- # @return [Boolean] true when this message is queued but not yet sent to LLM
38
- def pending?
39
- payload["status"] == Message::PENDING_STATUS
40
- end
41
27
  end
@@ -155,18 +155,27 @@ class AgentRequestJob < ApplicationJob
155
155
 
156
156
  # Sets the session's processing flag atomically. Returns true if this
157
157
  # job claimed the lock, false if another job already holds it.
158
- # Broadcasts the state change to the parent session's HUD.
158
+ # Broadcasts +session_state: llm_generating+ and the state change to
159
+ # the parent session's HUD.
159
160
  def claim_processing(session_id)
160
161
  claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
161
- Session.find_by(id: session_id)&.broadcast_children_update_to_parent if claimed
162
+ if claimed
163
+ session = Session.find_by(id: session_id)
164
+ session&.broadcast_session_state("llm_generating")
165
+ session&.broadcast_children_update_to_parent
166
+ end
162
167
  claimed
163
168
  end
164
169
 
165
170
  # Clears the processing flag so the session can accept new jobs.
166
- # Broadcasts the state change to the parent session's HUD.
171
+ # Broadcasts +session_state: idle+ to the session stream (replaces
172
+ # the old +processing_stopped+ action) and +children_updated+ to the
173
+ # parent session's HUD.
167
174
  def release_processing(session_id)
168
175
  Session.where(id: session_id).update_all(processing: false)
169
- Session.find_by(id: session_id)&.broadcast_children_update_to_parent
176
+ session = Session.find_by(id: session_id)
177
+ session&.broadcast_session_state("idle")
178
+ session&.broadcast_children_update_to_parent
170
179
  end
171
180
 
172
181
  # Safety-net clearing of the interrupt flag.
data/app/models/goal.rb CHANGED
@@ -25,6 +25,16 @@ class Goal < ApplicationRecord
25
25
  scope :completed, -> { where(status: "completed") }
26
26
  scope :root, -> { where(parent_goal_id: nil) }
27
27
 
28
+ # @!method self.not_evicted
29
+ # Goals still visible in context (not yet evicted by the analytical brain).
30
+ # @return [ActiveRecord::Relation]
31
+ scope :not_evicted, -> { where(evicted_at: nil) }
32
+
33
+ # @!method self.evictable
34
+ # Completed goals pending eviction — visible to the brain for age-based review.
35
+ # @return [ActiveRecord::Relation]
36
+ scope :evictable, -> { completed.where(evicted_at: nil) }
37
+
28
38
  after_commit :broadcast_goals_update
29
39
  after_commit :schedule_passive_recall, on: [:create, :update]
30
40
 
@@ -37,6 +47,9 @@ class Goal < ApplicationRecord
37
47
  # @return [Boolean] true if this is a root goal (no parent)
38
48
  def root? = !parent_goal_id
39
49
 
50
+ # @return [Boolean] true if this goal has been evicted from display
51
+ def evicted? = evicted_at.present?
52
+
40
53
  # Cascades completion to all active sub-goals. Called when a root goal
41
54
  # is finished — remaining sub-items are implicitly resolved because
42
55
  # the semantic episode that spawned them has ended.
@@ -29,7 +29,6 @@ class Message < ApplicationRecord
29
29
  CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
30
30
  THINK_TOOL = "think"
31
31
  SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
32
- PENDING_STATUS = "pending"
33
32
 
34
33
  # Message types that require a tool_use_id to pair call with response.
35
34
  TOOL_TYPES = %w[tool_call tool_response].freeze
@@ -39,6 +38,18 @@ class Message < ApplicationRecord
39
38
  # Heuristic: average bytes per token for English prose.
40
39
  BYTES_PER_TOKEN = 4
41
40
 
41
+ # Synthetic ID for system prompt entries in the TUI message store.
42
+ # Real message IDs are positive integers from the database, so 0
43
+ # is safe for deduplication without collision risk.
44
+ SYSTEM_PROMPT_ID = 0
45
+
46
+ # Estimates token count from a byte size using the {BYTES_PER_TOKEN} heuristic.
47
+ # @param bytesize [Integer] number of bytes
48
+ # @return [Integer] estimated token count (at least 1)
49
+ def self.estimate_token_count(bytesize)
50
+ [(bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
51
+ end
52
+
42
53
  belongs_to :session
43
54
  has_many :pinned_messages, dependent: :destroy
44
55
 
@@ -60,17 +71,6 @@ class Message < ApplicationRecord
60
71
  # @return [ActiveRecord::Relation]
61
72
  scope :context_messages, -> { where(message_type: CONTEXT_TYPES) }
62
73
 
63
- # @!method self.pending
64
- # User messages queued during active agent processing, not yet sent to LLM.
65
- # @return [ActiveRecord::Relation]
66
- scope :pending, -> { where(status: PENDING_STATUS) }
67
-
68
- # @!method self.deliverable
69
- # Messages eligible for LLM context (excludes pending messages).
70
- # NULL status means delivered/processed — the only excluded value is "pending".
71
- # @return [ActiveRecord::Relation]
72
- scope :deliverable, -> { where(status: nil) }
73
-
74
74
  # @!method self.excluding_spawn_messages
75
75
  # Excludes spawn_subagent/spawn_specialist tool_call and tool_response messages.
76
76
  # Used when building parent context for sub-agents — spawn messages cause role
@@ -98,11 +98,6 @@ class Message < ApplicationRecord
98
98
  message_type.in?(CONTEXT_TYPES)
99
99
  end
100
100
 
101
- # @return [Boolean] true if this is a pending message not yet sent to the LLM
102
- def pending?
103
- status == PENDING_STATUS
104
- end
105
-
106
101
  # @return [Boolean] true if this is a conversation message (user/agent/system)
107
102
  # or a think tool_call — the messages Mneme treats as "conversation" for boundary tracking
108
103
  def conversation_or_think?
@@ -121,7 +116,7 @@ class Message < ApplicationRecord
121
116
  else
122
117
  payload["content"].to_s
123
118
  end
124
- [(text.bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
119
+ self.class.estimate_token_count(text.bytesize)
125
120
  end
126
121
 
127
122
  private
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A user message waiting to enter a session's conversation history.
4
+ # Pending messages live in their own table — they are NOT part of the
5
+ # message stream and have no database ID that could interleave with
6
+ # tool_call/tool_response pairs.
7
+ #
8
+ # Created when a user sends a message while the session is processing.
9
+ # Promoted to a real {Message} (delete + create in transaction) when
10
+ # the current agent loop completes, giving the new message an ID that
11
+ # naturally follows the tool batch.
12
+ #
13
+ # @see Session#enqueue_user_message
14
+ # @see Session#promote_pending_messages!
15
+ class PendingMessage < ApplicationRecord
16
+ belongs_to :session
17
+
18
+ validates :content, presence: true
19
+
20
+ after_create_commit :broadcast_created
21
+ after_destroy_commit :broadcast_removed
22
+
23
+ private
24
+
25
+ # Broadcasts a pending message appearance so TUI clients render the
26
+ # dimmed indicator immediately.
27
+ def broadcast_created
28
+ ActionCable.server.broadcast("session_#{session_id}", {
29
+ "action" => "pending_message_created",
30
+ "pending_message_id" => id,
31
+ "content" => content
32
+ })
33
+ end
34
+
35
+ # Broadcasts pending message removal so TUI clients clear the entry.
36
+ # Fires on both promotion (normal flow) and recall (user edit).
37
+ def broadcast_removed
38
+ ActionCable.server.broadcast("session_#{session_id}", {
39
+ "action" => "pending_message_removed",
40
+ "pending_message_id" => id
41
+ })
42
+ end
43
+ end