anima-core 1.3.0 → 1.4.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +6 -7
  3. data/README.md +64 -16
  4. data/app/decorators/tool_call_decorator.rb +3 -3
  5. data/app/jobs/agent_request_job.rb +2 -2
  6. data/app/jobs/passive_recall_job.rb +6 -11
  7. data/app/models/concerns/message/broadcasting.rb +1 -0
  8. data/app/models/goal.rb +2 -1
  9. data/app/models/message.rb +0 -13
  10. data/app/models/pending_message.rb +150 -2
  11. data/app/models/session.rb +324 -266
  12. data/bin/inspect-cassette +144 -0
  13. data/bin/release +212 -0
  14. data/bin/with-llms +20 -0
  15. data/config/database.yml +1 -0
  16. data/db/cable_structure.sql +9 -0
  17. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  18. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  19. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  20. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  21. data/db/queue_structure.sql +61 -0
  22. data/db/structure.sql +120 -0
  23. data/lib/agent_loop.rb +42 -13
  24. data/lib/analytical_brain/runner.rb +12 -2
  25. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  26. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  27. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  28. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  29. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  30. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  31. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  32. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  33. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  34. data/lib/anima/cli.rb +41 -13
  35. data/lib/anima/installer.rb +13 -0
  36. data/lib/anima/settings.rb +13 -7
  37. data/lib/anima/version.rb +1 -1
  38. data/lib/events/agent_message.rb +14 -0
  39. data/lib/events/subscribers/persister.rb +2 -1
  40. data/lib/events/subscribers/subagent_message_router.rb +4 -7
  41. data/lib/llm/client.rb +37 -30
  42. data/lib/mneme/compressed_viewport.rb +8 -4
  43. data/lib/mneme/passive_recall.rb +85 -16
  44. data/lib/mneme/runner.rb +15 -4
  45. data/lib/providers/anthropic.rb +112 -7
  46. data/lib/shell_session.rb +185 -2
  47. data/lib/tools/base.rb +0 -1
  48. data/lib/tools/bash.rb +16 -14
  49. data/lib/tools/mark_goal_completed.rb +4 -5
  50. data/lib/tools/registry.rb +6 -1
  51. data/lib/tools/response_truncator.rb +1 -1
  52. data/lib/tools/spawn_specialist.rb +10 -8
  53. data/lib/tools/spawn_subagent.rb +17 -13
  54. data/lib/tools/subagent_prompts.rb +13 -15
  55. data/lib/tui/app.rb +389 -146
  56. data/lib/tui/cable_client.rb +9 -16
  57. data/lib/tui/decorators/base_decorator.rb +24 -4
  58. data/lib/tui/decorators/bash_decorator.rb +1 -1
  59. data/lib/tui/decorators/edit_decorator.rb +4 -2
  60. data/lib/tui/decorators/read_decorator.rb +4 -2
  61. data/lib/tui/decorators/think_decorator.rb +2 -2
  62. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  63. data/lib/tui/decorators/write_decorator.rb +4 -2
  64. data/lib/tui/flash.rb +19 -14
  65. data/lib/tui/formatting.rb +20 -9
  66. data/lib/tui/input_buffer.rb +6 -6
  67. data/lib/tui/message_store.rb +89 -1
  68. data/lib/tui/performance_logger.rb +2 -3
  69. data/lib/tui/screens/chat.rb +56 -60
  70. data/lib/tui/settings.rb +86 -0
  71. data/templates/config.toml +12 -9
  72. data/templates/tui.toml +209 -0
  73. metadata +14 -3
  74. data/config/initializers/fts5_schema_dump.rb +0 -21
  75. data/lib/environment_probe.rb +0 -232
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ec0963d3bb57afc7a12414258c90d645dd200ca489bd7ab34e6ace19ea11927
4
- data.tar.gz: 712d1904e5ddf7b0c244ddeffb2ed993f6265d097eb18000b139cdb08a9bfc4c
3
+ metadata.gz: 283cb2ad728734b96a5badc8b6fc2624c920d3dbefd17412bf5c5f4ae452d6e3
4
+ data.tar.gz: 0ecef454ffd58b1a4c232338e58a4d20fe4b42f4c3d2ecd6aca6dcc446b0daed
5
5
  SHA512:
6
- metadata.gz: 3b70800258af5296bf243e7b0c020efd6f7736e3f1f58734352e022ca8ec7a15599ffce938fc1adc511bc37a7141472845dcead21b6512cdc09dfceae8b04b87
7
- data.tar.gz: e70536705ac4d1cc468a951079799edd8677cae3c167251c60cccfbb51f79b0e2e7f896b5420f4972c8a59ee3d377ae0fd284d3603db441bd45313a1030f60ca
6
+ metadata.gz: 69115665f072f86b590222cdf4c6ec3ca75be2287fea4d1b50b2361525cb31b75e561b6f6e0ae0e375dfa7c5717b6d549237202f76a7dd4a64826c19a26311fe
7
+ data.tar.gz: b7b35426013e036bc18c5bd8906f26cadd360be8246ef213018deea41cac52e04747a321c6aaf5835dc9f04ff6c19f04bd344de97c08227591bf6563e03abab1
data/.reek.yml CHANGED
@@ -13,8 +13,8 @@ detectors:
13
13
  NilCheck:
14
14
  exclude:
15
15
  - "Anima::Settings#get"
16
+ - "TUI::Settings#get"
16
17
  # Rescue blocks naturally reference the error object more than self.
17
- # EnvironmentProbe assembles output from local data structures — not envy.
18
18
  # Brain transcript builds from event collection — the method's entire purpose.
19
19
  # ConfigMigrator text processing methods naturally reference local line arrays.
20
20
  # ToolDecorator subclasses operate on the tool result — that's the pattern.
@@ -22,7 +22,6 @@ detectors:
22
22
  FeatureEnvy:
23
23
  exclude:
24
24
  - "AnalyticalBrainJob#perform"
25
- - "EnvironmentProbe"
26
25
  - "AnalyticalBrain::Runner#build_messages"
27
26
  - "Anima::ConfigMigrator"
28
27
  - "WebGetToolDecorator"
@@ -68,13 +67,13 @@ detectors:
68
67
  - "WebGetToolDecorator#text_html"
69
68
  # Session model is the core domain object — methods grow naturally.
70
69
  # Mcp CLI accumulates subcommand helpers across add/remove/list/secrets.
71
- # EnvironmentProbe probes multiple orthogonal facets (OS, Git, project files).
72
- # Each facet needs its own detection + formatting methods.
70
+ # ShellSession probes multiple orthogonal facets (CWD, Git, project files)
71
+ # and manages PTY lifecycle methods grow with responsibilities.
73
72
  TooManyMethods:
74
73
  exclude:
75
74
  - "Session"
76
75
  - "Anima::CLI::Mcp"
77
- - "EnvironmentProbe"
76
+ - "ShellSession"
78
77
  # Runner composes system prompt from modular sections — methods grow with responsibilities.
79
78
  - "AnalyticalBrain::Runner"
80
79
  # Decorators branch on tool type across 4 render modes — inherent to the pattern.
@@ -86,11 +85,11 @@ detectors:
86
85
  # Runner checks session type to compose responsibilities — the core dispatch.
87
86
  - "AnalyticalBrain::Runner"
88
87
  # EventDecorator holds shared rendering constants (icons, markers, dispatch maps).
89
- # Event model holds domain type constants (TYPES, CONTEXT_TYPES, SPAWN_TOOLS, etc.).
88
+ # Message holds domain type constants (TYPES, CONTEXT_TYPES, LLM_TYPES, etc.).
90
89
  TooManyConstants:
91
90
  exclude:
92
91
  - "EventDecorator"
93
- - "Event"
92
+ - "Message"
94
93
  # encode_utf8 is descriptive — the digit triggers a false positive.
95
94
  UncommunicativeMethodName:
96
95
  exclude:
data/README.md CHANGED
@@ -14,7 +14,7 @@ Anima is different. It's built on the premise that if you want an agent — a re
14
14
 
15
15
  **Memory that works like memory.** Other systems bolt on memory as an afterthought — filing cabinets the agent has to consciously open mid-task. It never does; the truck is already moving. Anima's memory department ([Mneme](#semantic-memory-mneme)) runs as a third brain process on the event bus. It summarizes what's about to leave the viewport. It compresses short-term into long-term, like biological memory consolidating during sleep. It pins critical moments to active goals so exact instructions survive where summaries would lose nuance. And it recalls — automatically, passively — surfacing relevant older memories right after the soul, right before the present. The agent doesn't decide to remember. It just remembers.
16
16
 
17
- **Sub-agents that already know everything.** When Anima spawns a sub-agent, it inherits the parent's full event stream every file read, every decision, every user message. No "let me summarize what I know." Lossless context. Zero wasted tool calls on rediscovery.
17
+ **Sub-agents that know who they are.** When Anima spawns a sub-agent, it starts clean identity, task, and nothing else. No inherited conversation history means the sub-agent works on its task, not the parent's trajectory. Context flows through explicit messages, not leaked assistant turns.
18
18
 
19
19
  **A soul the agent writes itself.** Anima's first session is birth. The agent wakes up, explores its world, meets its human, and writes its own identity. Not a personality description in a config file — a living document the agent authors and evolves. Always in context, always its own.
20
20
 
@@ -63,7 +63,7 @@ Anima (Ruby, Rails 8.1 headless)
63
63
  ├── Skills — domain knowledge bundles (Markdown, user-extensible)
64
64
  ├── Workflows — operational recipes for multi-step tasks
65
65
  ├── MCP — external tool integration (Model Context Protocol)
66
- ├── Sub-agents — autonomous child sessions with lossless context inheritance
66
+ ├── Sub-agents — autonomous child sessions with isolated context
67
67
  ├── Mneme — memory department (summarization, compression, pinning, recall)
68
68
 
69
69
  │ Designed:
@@ -129,7 +129,8 @@ State directory (`~/.anima/`):
129
129
  ```
130
130
  ~/.anima/
131
131
  ├── soul.md # Agent's self-authored identity (always in context)
132
- ├── config.toml # Main settings (hot-reloadable)
132
+ ├── config.toml # Brain settings (hot-reloadable)
133
+ ├── tui.toml # TUI settings (hot-reloadable)
133
134
  ├── mcp.toml # MCP server configuration
134
135
  ├── config/
135
136
  │ └── credentials/ # Rails encrypted credentials (includes AR encryption keys)
@@ -141,7 +142,7 @@ State directory (`~/.anima/`):
141
142
  └── tmp/
142
143
  ```
143
144
 
144
- Updates: `anima update` — upgrades the gem, merges new config settings into your existing `config.toml` without overwriting customized values, and restarts the systemd service if it's running. Use `anima update --migrate-only` to skip the gem upgrade and only add missing config keys.
145
+ Updates: `anima update` — upgrades the gem, merges new config settings into both `config.toml` and `tui.toml` without overwriting customized values, and restarts the systemd service if it's running. Use `anima update --migrate-only` to skip the gem upgrade and only add missing config keys.
145
146
 
146
147
  ### Authentication Setup
147
148
 
@@ -178,7 +179,7 @@ Plus dynamic tools from configured MCP servers, namespaced as `server_name__tool
178
179
 
179
180
  ### Sub-Agents
180
181
 
181
- Sub-agents aren't processes — they're sessions on the same event bus. When a sub-agent spawns, its viewport assembles from two scopes: its own events (prioritized) and the parent's events (filling remaining budget). No context serialization, no summary prompts the sub-agent sees the parent's raw event stream and already knows everything the parent knows. Lossless inheritance by architecture, not by prompting.
182
+ Sub-agents aren't processes — they're sessions on the same event bus. When a sub-agent spawns, it starts with a clean context: a system prompt (identity + communication instructions), a Goal from the task description, and a single user message containing the task auto-pinned so it survives viewport eviction. No parent conversation history. Sub-agents inherit the parent shell's working directory at spawn time and use a separate model and token budget (configurable via `subagent_model` and `subagent_token_budget`).
182
183
 
183
184
  Two types:
184
185
 
@@ -194,24 +195,25 @@ Two types:
194
195
 
195
196
  **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.
196
197
 
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
+ Each sub-agent is spawned with a single **Goal** from its task description and a pinned user message containing the task text. 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
 
199
200
  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.
200
201
 
201
202
  ### Skills
202
203
 
203
- Domain knowledge bundles loaded from Markdown files. Skills provide specialized expertise that the analytical brain activates and deactivates based on conversation context.
204
+ Domain knowledge bundles loaded from Markdown files. Skills provide specialized expertise that the analytical brain activates based on conversation context. Skill content enters the conversation as phantom tool_use/tool_result pairs through the `PendingMessage` promotion flow — the same mechanism used for sub-agent messages. This keeps the system prompt stable for prompt caching while skills flow through the sliding window like regular messages.
204
205
 
205
206
  - **Built-in skills:** ActiveRecord, Draper decorators, DragonRuby, MCP server, RatatuiRuby, RSpec, GitHub issues
206
207
  - **User skills:** Drop `.md` files into `~/.anima/skills/` to add custom knowledge
207
208
  - **Override:** User skills with the same name replace built-in ones
208
209
  - **Format:** Flat files (`skill-name.md`) or directories (`skill-name/SKILL.md` with `examples/` and `references/`)
210
+ - **Viewport deduplication:** The brain's skill catalog excludes skills already visible in the viewport, preventing redundant activation
209
211
 
210
212
  Active skills are displayed in the TUI HUD panel (toggle with `C-a → h`).
211
213
 
212
214
  ### Workflows
213
215
 
214
- Operational recipes that describe multi-step tasks. Unlike skills (domain knowledge), workflows describe WHAT to do. The analytical brain activates a workflow when it recognizes a matching task, converts the prose into tracked goals, and deactivates it when done.
216
+ Operational recipes that describe multi-step tasks. Unlike skills (domain knowledge), workflows describe WHAT to do. The analytical brain activates a workflow when it recognizes a matching task, converts the prose into tracked goals, and deactivates it when done. Like skills, workflow content enters the conversation as phantom tool pairs through the same `PendingMessage` flow.
215
217
 
216
218
  - **Built-in workflows:** `feature`, `commit`, `create_plan`, `implement_plan`, `review_pr`, `create_note`, `research_codebase`, `decompose_ticket`, and more
217
219
  - **User workflows:** Drop `.md` files into `~/.anima/workflows/` to add custom workflows
@@ -286,7 +288,9 @@ Goals form a two-level hierarchy (root goals with sub-goals) and are displayed i
286
288
 
287
289
  ### Configuration
288
290
 
289
- All tunable values are exposed through `~/.anima/config.toml` with hot-reload (no restart needed):
291
+ Brain and TUI have separate config files — both hot-reloadable (no restart needed).
292
+
293
+ **Brain settings** (`~/.anima/config.toml`):
290
294
 
291
295
  ```toml
292
296
  [llm]
@@ -294,15 +298,14 @@ model = "claude-opus-4-6"
294
298
  fast_model = "claude-haiku-4-5"
295
299
  max_tokens = 8192
296
300
  max_tool_rounds = 250
297
- token_budget = 190_000
301
+ token_budget = 120_000
302
+ subagent_model = "claude-sonnet-4-6"
303
+ subagent_token_budget = 90_000
298
304
 
299
305
  [timeouts]
300
306
  api = 300
301
307
  command = 30
302
308
 
303
- [goals]
304
- completed_decay_messages = 5
305
-
306
309
  [analytical_brain]
307
310
  max_tokens = 4096
308
311
  blocking_on_user_message = true
@@ -313,6 +316,27 @@ default_view_mode = "basic"
313
316
  name_generation_interval = 30
314
317
  ```
315
318
 
319
+ **TUI settings** (`~/.anima/tui.toml`):
320
+
321
+ ```toml
322
+ [connection]
323
+ default_host = "localhost:42134" # Override per-launch with --host
324
+
325
+ [chat]
326
+ scroll_step = 1
327
+ viewport_back_buffer = 3
328
+
329
+ [theme]
330
+ rate_limit_warning = 70 # Yellow at 70%
331
+ rate_limit_critical = 90 # Red at 90%
332
+ user_message_bg = 22 # 256-color: dark green
333
+ assistant_message_bg = 17 # 256-color: dark navy
334
+ scrollbar_thumb = "cyan"
335
+ border_focused = "yellow"
336
+ ```
337
+
338
+ The TUI is a standalone client with zero Rails dependency. Its settings cover connection tuning, scroll behavior, terminal watchdog, theme colors, and performance logging. See `~/.anima/tui.toml` for all available options.
339
+
316
340
  ## Design
317
341
 
318
342
  ### Three Layers (mirroring biology)
@@ -341,7 +365,7 @@ Events flow through two channels:
341
365
  1. **In-process** — Rails Structured Event Reporter (local subscribers like Persister)
342
366
  2. **Over the wire** — Action Cable WebSocket (`Event::Broadcasting` callbacks push to connected TUI clients)
343
367
 
344
- Events fire, subscribers react, state updates. The system prompt — soul, active skills, active workflow, current goals — is assembled fresh for each LLM call from live state, not from the event stream. The agent's identity (soul.md) and capabilities (skills, workflows) are always current, never stale.
368
+ Events fire, subscribers react, state updates. The system prompt — soul and current goals — is assembled fresh for each LLM call from live state, not from the event stream. Skills and workflows flow through the message stream as phantom tool pairs, keeping the system prompt stable for prompt caching. The agent's identity (soul.md) is always current, never stale.
345
369
 
346
370
  ### Context as Viewport, Not Tape
347
371
 
@@ -351,7 +375,7 @@ The viewport is a live query, not a log. It walks events newest-first until the
351
375
 
352
376
  This means sessions are endless. No compaction. No lossy rewriting. The model always operates in fresh, high-quality context. The [dumb zone](https://github.com/humanlayer/advanced-context-engineering-for-coding-agents/blob/main/ace-fca.md) never arrives. Meanwhile, Mneme runs as a background department — summarizing evicted events into persistent snapshots so past context is preserved, not destroyed.
353
377
 
354
- Sub-agent viewports compose from two event scopes — their own events (prioritized) and parent events (filling remaining budget). Same mechanism, no special handling. The bus is the architecture.
378
+ Sub-agent viewports use the same mechanism — their own events only, no parent context inheritance. The parent provides context through the task description, and the sub-agent builds its own conversation from a clean slate.
355
379
 
356
380
  ### Brain as Microservices on a Shared Event Bus
357
381
 
@@ -408,6 +432,30 @@ The right-side HUD panel shows session state at a glance: session name, goals (w
408
432
 
409
433
  **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
434
 
435
+ **Token Economy HUD**: A fixed panel at the bottom of the HUD displays API economics extracted from every Anthropic response:
436
+
437
+ ```
438
+ ╭ 📊 Token Economy ────────────────────╮
439
+ │ 5h ░░░░░░░░ 1% ➞3h42m │
440
+ │ 7d ▓▓▓▓▓▓▓▓ 98% │
441
+ │ ⚡ ▓▓▓▓▓▓░░ 69% │
442
+ │ 💾 6.3K tokens │
443
+ │ ⠛⣿⣷⣶⣿⣿⣿⣿⣷⣶⣿⣿⣿ │
444
+ │ 🟢 Verbose │
445
+ ╰──────────────────────────────────────╯
446
+ ```
447
+
448
+ | Row | Description |
449
+ |-----|-------------|
450
+ | `5h` | 5-hour rate limit utilization with progress bar and reset countdown |
451
+ | `7d` | 7-day rate limit utilization with progress bar |
452
+ | `⚡` | Cache hit rate — percentage of input tokens served from cache |
453
+ | `💾` | Cumulative tokens saved by cache hits |
454
+ | `⠛⣿` | Braille sparkline — per-call cache hit history (2 calls per character); drops signal cache busts |
455
+ | `🟢` | Connection status and current view mode |
456
+
457
+ Progress bars are color-coded: green (< 70%), yellow (70-89%), red (>= 90%) for rate limits; inverted for cache hits (green >= 70%, red < 30%). All data comes from Anthropic API response headers and usage objects, broadcast as message metadata via ActionCable.
458
+
411
459
  When content exceeds the panel height, the HUD scrolls. Three input methods:
412
460
 
413
461
  | Input | Action |
@@ -609,7 +657,7 @@ This single example demonstrates every core principle:
609
657
  - Mneme memory department (eviction-triggered summarization, persistent snapshots, goal-scoped event pinning, associative recall)
610
658
  - 12 built-in tools + MCP integration (HTTP + stdio transports)
611
659
  - 7 built-in skills + 13 built-in workflows (user-extensible)
612
- - Sub-agents with lossless context inheritance (5 specialists + generic)
660
+ - Sub-agents with isolated context (5 specialists + generic)
613
661
  - Client-server architecture with WebSocket transport + graceful reconnection
614
662
  - Collapsible HUD panel with goals, skills, workflow, and sub-agent tracking
615
663
  - Three TUI view modes (Basic / Verbose / Debug)
@@ -105,10 +105,10 @@ class ToolCallDecorator < MessageDecorator
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
107
  # separate lines, matching how read_file tool responses display file content.
108
- # @param input [Hash] tool input hash with "file_path" and "content" keys
108
+ # @param input [Hash] tool input hash with "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)
111
- path = input.dig("file_path").to_s
111
+ path = input.dig("path").to_s
112
112
  content = input.dig("content").to_s
113
113
  return Toon.encode(input) if content.empty?
114
114
 
@@ -126,7 +126,7 @@ class ToolCallDecorator < MessageDecorator
126
126
  when "web_get"
127
127
  "GET #{input&.dig("url")}"
128
128
  when "read_file", "edit_file", "write_file"
129
- input&.dig("file_path").to_s
129
+ input&.dig("path").to_s
130
130
  else
131
131
  truncate_lines(Toon.encode(input), max_lines: 2)
132
132
  end
@@ -66,10 +66,10 @@ class AgentRequestJob < ApplicationJob
66
66
  agent_loop.run
67
67
  end
68
68
 
69
- # Process any pending messages queued while we were busy.
69
+ # Process any pending messages that arrived after the last tool round.
70
70
  loop do
71
71
  promoted = session.promote_pending_messages!
72
- break if promoted == 0
72
+ break if promoted[:texts].empty? && promoted[:pairs].empty?
73
73
  agent_loop.run
74
74
  end
75
75
 
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Runs passive recall after goal updates — searches message history for
4
- # context relevant to active goals and caches results on the session
5
- # for viewport injection.
4
+ # context relevant to active goals and injects phantom tool_call/tool_response
5
+ # pairs into the session's message stream.
6
6
  #
7
- # Idempotent: multiple enqueues for the same session safely overwrite
8
- # each other's results last one wins.
7
+ # Phantom pairs ride the conveyor belt like regular messages, getting
8
+ # cached, evicted, and compressed by Mneme naturally.
9
9
  #
10
10
  # @example
11
11
  # PassiveRecallJob.perform_later(session.id)
@@ -17,13 +17,8 @@ class PassiveRecallJob < ApplicationJob
17
17
  # @param session_id [Integer]
18
18
  def perform(session_id)
19
19
  session = Session.find(session_id)
20
- results = Mneme::PassiveRecall.new(session).call
20
+ count = Mneme::PassiveRecall.new(session).call
21
21
 
22
- if results.any?
23
- session.update_column(:recalled_message_ids, results.map(&:message_id))
24
- Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
25
- elsif session.recalled_message_ids.present?
26
- session.update_column(:recalled_message_ids, [])
27
- end
22
+ Mneme.logger.info("session=#{session_id} — passive recall injected #{count} phantom pairs") if count > 0
28
23
  end
29
24
  end
@@ -68,6 +68,7 @@ module Message::Broadcasting
68
68
  mode = session.view_mode
69
69
  decorator = MessageDecorator.for(self)
70
70
  broadcast_payload = payload.merge("id" => id, "action" => action)
71
+ broadcast_payload["api_metrics"] = api_metrics if api_metrics.present?
71
72
 
72
73
  if decorator
73
74
  broadcast_payload["rendered"] = {mode => decorator.render(mode)}
data/app/models/goal.rb CHANGED
@@ -31,7 +31,8 @@ class Goal < ApplicationRecord
31
31
  scope :not_evicted, -> { where(evicted_at: nil) }
32
32
 
33
33
  # @!method self.evictable
34
- # Completed goals pending evictionvisible to the brain for age-based review.
34
+ # Completed goals not yet evicted their phantom pairs remain in the
35
+ # sliding window until Mneme compresses them during the eviction cycle.
35
36
  # @return [ActiveRecord::Relation]
36
37
  scope :evictable, -> { completed.where(evicted_at: nil) }
37
38
 
@@ -28,8 +28,6 @@ class Message < ApplicationRecord
28
28
  CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
29
29
  CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
30
30
  THINK_TOOL = "think"
31
- SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
32
-
33
31
  # Message types that require a tool_use_id to pair call with response.
34
32
  TOOL_TYPES = %w[tool_call tool_response].freeze
35
33
 
@@ -71,17 +69,6 @@ class Message < ApplicationRecord
71
69
  # @return [ActiveRecord::Relation]
72
70
  scope :context_messages, -> { where(message_type: CONTEXT_TYPES) }
73
71
 
74
- # @!method self.excluding_spawn_messages
75
- # Excludes spawn_subagent/spawn_specialist tool_call and tool_response messages.
76
- # Used when building parent context for sub-agents — spawn messages cause role
77
- # confusion because the sub-agent sees sibling spawn results and mistakes
78
- # itself for the parent.
79
- # @return [ActiveRecord::Relation]
80
- scope :excluding_spawn_messages, -> {
81
- where.not("message_type IN (?) AND json_extract(payload, '$.tool_name') IN (?)",
82
- TOOL_TYPES, SPAWN_TOOLS)
83
- }
84
-
85
72
  # Maps message_type to the Anthropic Messages API role.
86
73
  # @return [String] "user" or "assistant"
87
74
  def api_role
@@ -1,27 +1,175 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # A user message waiting to enter a session's conversation history.
3
+ # A message waiting to enter a session's conversation history.
4
4
  # Pending messages live in their own table — they are NOT part of the
5
5
  # message stream and have no database ID that could interleave with
6
6
  # tool_call/tool_response pairs.
7
7
  #
8
- # Created when a user sends a message while the session is processing.
8
+ # Created when a message arrives while the session is processing.
9
9
  # Promoted to a real {Message} (delete + create in transaction) when
10
10
  # the current agent loop completes, giving the new message an ID that
11
11
  # naturally follows the tool batch.
12
12
  #
13
+ # Each pending message knows its source (+source_type+, +source_name+)
14
+ # and how to serialize itself for the LLM conversation via {#to_llm_messages}.
15
+ # Non-user messages (sub-agent results, recalled skills, workflows, recall,
16
+ # goal events) become synthetic tool_use/tool_result pairs so the LLM sees
17
+ # "a tool I invoked returned a result" rather than "a user wrote me."
18
+ #
13
19
  # @see Session#enqueue_user_message
14
20
  # @see Session#promote_pending_messages!
15
21
  class PendingMessage < ApplicationRecord
22
+ # Synthetic tool names used in tool_use/tool_result pairs injected into
23
+ # the parent LLM conversation when non-user messages are promoted.
24
+ # These tools don't exist in the agent's registry — the agent sees
25
+ # them as its own past actions (phantom tool calls).
26
+ SUBAGENT_TOOL = "subagent_message"
27
+ RECALL_SKILL_TOOL = "recall_skill"
28
+ RECALL_WORKFLOW_TOOL = "recall_workflow"
29
+ RECALL_MEMORY_TOOL = "recall_memory"
30
+ RECALL_GOAL_TOOL = "recall_goal"
31
+
32
+ # Source types that produce phantom tool_use/tool_result pairs on promotion.
33
+ # User messages produce plain text blocks instead.
34
+ PHANTOM_PAIR_TYPES = %w[subagent skill workflow recall goal].freeze
35
+
36
+ # Maps each phantom pair source type to its synthetic tool name.
37
+ PHANTOM_TOOL_NAMES = {
38
+ "subagent" => SUBAGENT_TOOL,
39
+ "skill" => RECALL_SKILL_TOOL,
40
+ "workflow" => RECALL_WORKFLOW_TOOL,
41
+ "recall" => RECALL_MEMORY_TOOL,
42
+ "goal" => RECALL_GOAL_TOOL
43
+ }.freeze
44
+
45
+ # Maps each phantom pair source type to a lambda building its tool input.
46
+ PHANTOM_TOOL_INPUTS = {
47
+ "subagent" => ->(name) { {from: name} },
48
+ "skill" => ->(name) { {skill: name} },
49
+ "workflow" => ->(name) { {workflow: name} },
50
+ "recall" => ->(name) { {message_id: name.to_i} },
51
+ "goal" => ->(name) { {goal_id: name.to_i} }
52
+ }.freeze
53
+
16
54
  belongs_to :session
17
55
 
18
56
  validates :content, presence: true
57
+ validates :source_type, inclusion: {in: %w[user subagent skill workflow recall goal]}
58
+ validates :source_name, presence: true, unless: :user?
19
59
 
20
60
  after_create_commit :broadcast_created
21
61
  after_destroy_commit :broadcast_removed
22
62
 
63
+ # @return [Boolean] true when this is a plain user message
64
+ def user?
65
+ source_type == "user"
66
+ end
67
+
68
+ # @return [Boolean] true when this message originated from a sub-agent
69
+ def subagent?
70
+ source_type == "subagent"
71
+ end
72
+
73
+ # @return [Boolean] true when this message carries recalled skill content
74
+ def skill?
75
+ source_type == "skill"
76
+ end
77
+
78
+ # @return [Boolean] true when this message carries recalled workflow content
79
+ def workflow?
80
+ source_type == "workflow"
81
+ end
82
+
83
+ # @return [Boolean] true when this message is an associative recall phantom pair
84
+ def recall?
85
+ source_type == "recall"
86
+ end
87
+
88
+ # @return [Boolean] true when this message carries a goal event
89
+ def goal?
90
+ source_type == "goal"
91
+ end
92
+
93
+ # @return [Boolean] true when promotion produces phantom tool_use/tool_result pairs
94
+ def phantom_pair?
95
+ source_type.in?(PHANTOM_PAIR_TYPES)
96
+ end
97
+
98
+ # Phantom tool name for DB persistence and LLM injection.
99
+ # Each phantom pair source type maps to a synthetic tool name.
100
+ #
101
+ # @return [String] phantom tool name
102
+ def phantom_tool_name
103
+ PHANTOM_TOOL_NAMES.fetch(source_type)
104
+ end
105
+
106
+ # Phantom tool input hash for DB persistence and LLM injection.
107
+ #
108
+ # @return [Hash] tool input hash
109
+ def phantom_tool_input
110
+ PHANTOM_TOOL_INPUTS.fetch(source_type).call(source_name)
111
+ end
112
+
113
+ # Content formatted for display and history persistence.
114
+ # Sub-agent messages include an attribution prefix. Skill/workflow
115
+ # messages include a recall label. User messages pass through unchanged.
116
+ #
117
+ # @return [String]
118
+ def display_content
119
+ case source_type
120
+ when "subagent"
121
+ format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content)
122
+ when "skill"
123
+ "[recalled skill: #{source_name}]\n#{content}"
124
+ when "workflow"
125
+ "[recalled workflow: #{source_name}]\n#{content}"
126
+ when "goal"
127
+ "[goal #{source_name}]\n#{content}"
128
+ else
129
+ content
130
+ end
131
+ end
132
+
133
+ # Builds LLM message hashes for this pending message.
134
+ #
135
+ # Phantom pair types become synthetic tool_use/tool_result pairs so the
136
+ # LLM sees them as its own past invocations. User messages return plain
137
+ # content for injection as text blocks within the current tool_results turn.
138
+ #
139
+ # @return [Array<Hash>] synthetic tool pair for phantom pair types
140
+ # @return [String] raw content for user messages
141
+ def to_llm_messages
142
+ return content unless phantom_pair?
143
+
144
+ build_phantom_pair(phantom_tool_name, phantom_tool_input)
145
+ end
146
+
23
147
  private
24
148
 
149
+ # Builds a phantom tool_use/tool_result message pair.
150
+ # Follows the same format for all non-user source types — the only
151
+ # difference is the tool name and input hash.
152
+ #
153
+ # Phantom pairs keep the system prompt stable for prompt caching (#395).
154
+ # Instead of injecting skills/workflows into the system prompt (which
155
+ # busts the cache on every change), they flow through the sliding window
156
+ # as messages the LLM "recalls" via phantom tool invocations.
157
+ #
158
+ # @param tool_name [String] phantom tool name (not in the agent's registry)
159
+ # @param input [Hash] tool input hash
160
+ # @return [Array<Hash>] two-element array: assistant tool_use + user tool_result
161
+ def build_phantom_pair(tool_name, input)
162
+ tool_use_id = "#{tool_name}_#{id}"
163
+ [
164
+ {role: "assistant", content: [
165
+ {type: "tool_use", id: tool_use_id, name: tool_name, input: input}
166
+ ]},
167
+ {role: "user", content: [
168
+ {type: "tool_result", tool_use_id: tool_use_id, content: content}
169
+ ]}
170
+ ]
171
+ end
172
+
25
173
  # Broadcasts a pending message appearance so TUI clients render the
26
174
  # dimmed indicator immediately.
27
175
  def broadcast_created