anima-core 1.2.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +14 -8
  3. data/README.md +96 -23
  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 +7 -7
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +15 -6
  13. data/app/jobs/passive_recall_job.rb +6 -11
  14. data/app/models/concerns/message/broadcasting.rb +1 -0
  15. data/app/models/goal.rb +14 -0
  16. data/app/models/message.rb +13 -31
  17. data/app/models/pending_message.rb +191 -0
  18. data/app/models/secret.rb +72 -0
  19. data/app/models/session.rb +480 -271
  20. data/bin/inspect-cassette +144 -0
  21. data/bin/release +212 -0
  22. data/bin/with-llms +20 -0
  23. data/config/database.yml +1 -0
  24. data/config/environments/test.rb +5 -0
  25. data/config/initializers/time_nanoseconds.rb +11 -0
  26. data/db/cable_structure.sql +9 -0
  27. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  28. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  29. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  30. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  31. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  32. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  33. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  34. data/db/queue_structure.sql +61 -0
  35. data/db/structure.sql +120 -0
  36. data/lib/agent_loop.rb +53 -51
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +19 -6
  39. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  40. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  42. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  43. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  44. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  45. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  46. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  47. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/cli.rb +41 -13
  51. data/lib/anima/installer.rb +20 -1
  52. data/lib/anima/settings.rb +37 -2
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +1 -1
  55. data/lib/credential_store.rb +17 -66
  56. data/lib/events/agent_message.rb +14 -0
  57. data/lib/events/base.rb +1 -1
  58. data/lib/events/subscribers/persister.rb +12 -18
  59. data/lib/events/subscribers/subagent_message_router.rb +18 -9
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +91 -50
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +9 -5
  65. data/lib/mneme/passive_recall.rb +85 -16
  66. data/lib/mneme/runner.rb +15 -4
  67. data/lib/providers/anthropic.rb +112 -7
  68. data/lib/shell_session.rb +239 -18
  69. data/lib/tools/base.rb +22 -0
  70. data/lib/tools/bash.rb +61 -7
  71. data/lib/tools/edit.rb +2 -2
  72. data/lib/tools/mark_goal_completed.rb +85 -0
  73. data/lib/tools/read.rb +2 -1
  74. data/lib/tools/recall.rb +98 -0
  75. data/lib/tools/registry.rb +41 -7
  76. data/lib/tools/remember.rb +1 -1
  77. data/lib/tools/response_truncator.rb +70 -0
  78. data/lib/tools/spawn_specialist.rb +11 -8
  79. data/lib/tools/spawn_subagent.rb +19 -13
  80. data/lib/tools/subagent_prompts.rb +41 -5
  81. data/lib/tools/think.rb +23 -0
  82. data/lib/tools/write.rb +1 -1
  83. data/lib/tui/app.rb +545 -137
  84. data/lib/tui/braille_spinner.rb +152 -0
  85. data/lib/tui/cable_client.rb +13 -20
  86. data/lib/tui/decorators/base_decorator.rb +40 -11
  87. data/lib/tui/decorators/bash_decorator.rb +3 -3
  88. data/lib/tui/decorators/edit_decorator.rb +7 -4
  89. data/lib/tui/decorators/read_decorator.rb +6 -8
  90. data/lib/tui/decorators/think_decorator.rb +4 -6
  91. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  92. data/lib/tui/decorators/write_decorator.rb +7 -4
  93. data/lib/tui/flash.rb +19 -14
  94. data/lib/tui/formatting.rb +33 -0
  95. data/lib/tui/input_buffer.rb +6 -6
  96. data/lib/tui/message_store.rb +159 -27
  97. data/lib/tui/performance_logger.rb +2 -3
  98. data/lib/tui/screens/chat.rb +302 -103
  99. data/lib/tui/settings.rb +86 -0
  100. data/skills/activerecord/SKILL.md +1 -1
  101. data/skills/dragonruby/SKILL.md +1 -1
  102. data/skills/draper-decorators/SKILL.md +1 -1
  103. data/skills/gh-issue.md +1 -1
  104. data/skills/mcp-server/SKILL.md +1 -1
  105. data/skills/ratatui-ruby/SKILL.md +1 -1
  106. data/skills/rspec/SKILL.md +1 -1
  107. data/templates/config.toml +30 -1
  108. data/templates/tui.toml +209 -0
  109. metadata +24 -3
  110. data/config/initializers/fts5_schema_dump.rb +0 -21
  111. data/lib/environment_probe.rb +0 -232
data/db/structure.sql ADDED
@@ -0,0 +1,120 @@
1
+ CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
2
+ CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
3
+ CREATE TABLE IF NOT EXISTS "goals" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "session_id" integer NOT NULL, "parent_goal_id" integer, "description" text NOT NULL, "status" varchar DEFAULT 'active' NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "completed_at" datetime(6), "evicted_at" datetime(6), CONSTRAINT "fk_rails_874b7534ae"
4
+ FOREIGN KEY ("session_id")
5
+ REFERENCES "sessions" ("id")
6
+ , CONSTRAINT "fk_rails_feeb9df31e"
7
+ FOREIGN KEY ("parent_goal_id")
8
+ REFERENCES "goals" ("id")
9
+ );
10
+ CREATE INDEX "index_goals_on_session_id" ON "goals" ("session_id");
11
+ CREATE INDEX "index_goals_on_parent_goal_id" ON "goals" ("parent_goal_id");
12
+ CREATE INDEX "index_goals_on_session_id_and_status" ON "goals" ("session_id", "status");
13
+ CREATE TABLE IF NOT EXISTS "messages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "session_id" integer NOT NULL, "message_type" varchar NOT NULL, "payload" json DEFAULT '{}' NOT NULL, "timestamp" integer(8) NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "token_count" integer DEFAULT 0 NOT NULL, "tool_use_id" varchar, "status" varchar, "api_metrics" json, CONSTRAINT "fk_rails_1ee2a92df0"
14
+ FOREIGN KEY ("session_id")
15
+ REFERENCES "sessions" ("id")
16
+ );
17
+ CREATE INDEX "index_messages_on_session_id_and_status" ON "messages" ("session_id", "status");
18
+ CREATE INDEX "index_messages_on_tool_use_id" ON "messages" ("tool_use_id");
19
+ CREATE INDEX "index_messages_on_session_id" ON "messages" ("session_id");
20
+ CREATE INDEX "index_messages_on_message_type" ON "messages" ("message_type");
21
+ CREATE INDEX "index_messages_on_session_id_and_message_type" ON "messages" ("session_id", "message_type");
22
+ CREATE TABLE IF NOT EXISTS "pinned_messages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message_id" integer NOT NULL, "display_text" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_4a5f237c43"
23
+ FOREIGN KEY ("message_id")
24
+ REFERENCES "messages" ("id")
25
+ );
26
+ CREATE UNIQUE INDEX "index_pinned_messages_on_message_id" ON "pinned_messages" ("message_id");
27
+ CREATE TABLE IF NOT EXISTS "goal_pinned_messages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "goal_id" integer NOT NULL, "pinned_message_id" integer NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_fb51bfeebe"
28
+ FOREIGN KEY ("pinned_message_id")
29
+ REFERENCES "pinned_messages" ("id")
30
+ , CONSTRAINT "fk_rails_689fd4bf8a"
31
+ FOREIGN KEY ("goal_id")
32
+ REFERENCES "goals" ("id")
33
+ );
34
+ CREATE INDEX "index_goal_pinned_messages_on_goal_id" ON "goal_pinned_messages" ("goal_id");
35
+ CREATE INDEX "index_goal_pinned_messages_on_pinned_message_id" ON "goal_pinned_messages" ("pinned_message_id");
36
+ CREATE UNIQUE INDEX "index_goal_pinned_messages_on_goal_id_and_pinned_message_id" ON "goal_pinned_messages" ("goal_id", "pinned_message_id");
37
+ CREATE TABLE IF NOT EXISTS "snapshots" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "session_id" integer NOT NULL, "text" text NOT NULL, "from_message_id" integer NOT NULL, "to_message_id" integer NOT NULL, "level" integer DEFAULT 1 NOT NULL, "token_count" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_eb2ad51db9"
38
+ FOREIGN KEY ("session_id")
39
+ REFERENCES "sessions" ("id")
40
+ );
41
+ CREATE INDEX "index_snapshots_on_session_id" ON "snapshots" ("session_id");
42
+ CREATE INDEX "index_snapshots_on_session_id_and_level" ON "snapshots" ("session_id", "level");
43
+ CREATE INDEX "index_snapshots_on_session_and_event_range" ON "snapshots" ("session_id", "from_message_id", "to_message_id");
44
+ CREATE VIRTUAL TABLE messages_fts USING fts5(
45
+ searchable_text,
46
+ content='',
47
+ contentless_delete=1,
48
+ tokenize='porter unicode61'
49
+ )
50
+ /* messages_fts(searchable_text) */;
51
+ CREATE TABLE IF NOT EXISTS 'messages_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
52
+ CREATE TABLE IF NOT EXISTS 'messages_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
53
+ CREATE TABLE IF NOT EXISTS 'messages_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB, origin INTEGER);
54
+ CREATE TABLE IF NOT EXISTS 'messages_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
55
+ CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages
56
+ WHEN NEW.message_type IN ('user_message', 'agent_message', 'system_message')
57
+ OR (NEW.message_type = 'tool_call' AND json_extract(NEW.payload, '$.tool_name') = 'think')
58
+ BEGIN
59
+ INSERT INTO messages_fts(rowid, searchable_text)
60
+ VALUES (
61
+ NEW.id,
62
+ CASE
63
+ WHEN NEW.message_type IN ('user_message', 'agent_message', 'system_message')
64
+ THEN json_extract(NEW.payload, '$.content')
65
+ WHEN NEW.message_type = 'tool_call'
66
+ THEN json_extract(NEW.payload, '$.tool_input.thoughts')
67
+ END
68
+ );
69
+ END;
70
+ CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages
71
+ WHEN OLD.message_type IN ('user_message', 'agent_message', 'system_message')
72
+ OR (OLD.message_type = 'tool_call' AND json_extract(OLD.payload, '$.tool_name') = 'think')
73
+ BEGIN
74
+ DELETE FROM messages_fts WHERE rowid = OLD.id;
75
+ END;
76
+ CREATE TABLE IF NOT EXISTS "secrets" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "namespace" varchar NOT NULL, "key" varchar NOT NULL, "value" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
77
+ CREATE UNIQUE INDEX "index_secrets_on_namespace_and_key" ON "secrets" ("namespace", "key");
78
+ CREATE INDEX "index_goals_on_evicted_at" ON "goals" ("evicted_at");
79
+ CREATE TABLE IF NOT EXISTS "pending_messages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "session_id" integer NOT NULL, "content" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "source_type" varchar DEFAULT 'user' NOT NULL, "source_name" varchar, CONSTRAINT "fk_rails_007242365b"
80
+ FOREIGN KEY ("session_id")
81
+ REFERENCES "sessions" ("id")
82
+ );
83
+ CREATE INDEX "index_pending_messages_on_session_id" ON "pending_messages" ("session_id");
84
+ CREATE TABLE IF NOT EXISTS "sessions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "view_mode" varchar DEFAULT 'basic' NOT NULL, "processing" boolean DEFAULT FALSE NOT NULL, "parent_session_id" integer, "prompt" text, "granted_tools" text, "name" varchar, "viewport_message_ids" json DEFAULT '[]' NOT NULL, "active_skills" json DEFAULT '[]' NOT NULL, "active_workflow" varchar, "interrupt_requested" boolean DEFAULT FALSE NOT NULL, "mneme_boundary_message_id" integer, "mneme_snapshot_first_message_id" integer, "mneme_snapshot_last_message_id" integer, "initial_cwd" varchar, CONSTRAINT "fk_rails_045409ac27"
85
+ FOREIGN KEY ("parent_session_id")
86
+ REFERENCES "sessions" ("id")
87
+ );
88
+ CREATE INDEX "index_sessions_on_parent_session_id" ON "sessions" ("parent_session_id");
89
+ INSERT INTO "schema_migrations" (version) VALUES
90
+ ('20260403080031'),
91
+ ('20260401210935'),
92
+ ('20260401180000'),
93
+ ('20260330120000'),
94
+ ('20260329120000'),
95
+ ('20260328152142'),
96
+ ('20260328100000'),
97
+ ('20260326180000'),
98
+ ('20260321140100'),
99
+ ('20260321140000'),
100
+ ('20260321120000'),
101
+ ('20260321080000'),
102
+ ('20260316094817'),
103
+ ('20260315191105'),
104
+ ('20260315144837'),
105
+ ('20260315140843'),
106
+ ('20260315100000'),
107
+ ('20260314150000'),
108
+ ('20260314140000'),
109
+ ('20260314112417'),
110
+ ('20260314075248'),
111
+ ('20260313020000'),
112
+ ('20260313010000'),
113
+ ('20260312170000'),
114
+ ('20260308160000'),
115
+ ('20260308150000'),
116
+ ('20260308140000'),
117
+ ('20260308130000'),
118
+ ('20260308124203'),
119
+ ('20260308124202');
120
+
data/lib/agent_loop.rb CHANGED
@@ -1,27 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "shellwords"
4
+
3
5
  # Orchestrates the LLM agent loop: accepts user input, runs the tool-use
4
6
  # cycle via {LLM::Client}, and emits events through {Events::Bus}.
5
7
  #
6
8
  # Extracted from {TUI::Screens::Chat} so the same agent logic can run from
7
9
  # the TUI, a background job, or an Action Cable channel.
8
10
  #
9
- # @note Not thread-safe. Callers must serialize concurrent calls to {#process}
10
- # (e.g. TUI uses a loading flag, future callers should use session-level locks).
11
+ # @note Not thread-safe. Callers must serialize concurrent access
12
+ # (e.g. {AgentRequestJob} uses session-level processing locks).
11
13
  #
12
14
  # @example Basic usage
13
15
  # loop = AgentLoop.new(session: session)
14
- # loop.process("What files are in the current directory?")
16
+ # loop.run
15
17
  # loop.finalize
16
18
  #
17
19
  # @example With dependency injection (testing)
18
20
  # loop = AgentLoop.new(session: session, client: mock_client, registry: mock_registry)
19
- # loop.process("hello")
20
- #
21
- # @example Background job usage (retry-safe)
22
- # loop = AgentLoop.new(session: session)
23
- # loop.run # processes persisted session messages without emitting UserMessage
24
- # loop.finalize
21
+ # loop.run
25
22
  class AgentLoop
26
23
  # @return [Session] the conversation session this loop operates on
27
24
  attr_reader :session
@@ -30,38 +27,17 @@ class AgentLoop
30
27
  # @param shell_session [ShellSession, nil] injectable persistent shell;
31
28
  # created automatically if not provided
32
29
  # @param client [LLM::Client, nil] injectable LLM client;
33
- # created lazily on first {#process} call if not provided
30
+ # created lazily on first {#run} call if not provided
34
31
  # @param registry [Tools::Registry, nil] injectable tool registry;
35
- # built lazily on first {#process} call if not provided
32
+ # built lazily on first {#run} call if not provided
36
33
  def initialize(session:, shell_session: nil, client: nil, registry: nil)
37
34
  @session = session
38
35
  @shell_session = shell_session || ShellSession.new(session_id: session.id)
36
+ restore_initial_cwd
39
37
  @client = client
40
38
  @registry = registry
41
39
  end
42
40
 
43
- # Runs the agent loop for a single user input.
44
- #
45
- # Persists the user message directly (the global Persister skips
46
- # non-pending user messages because {AgentRequestJob} owns their
47
- # lifecycle). Then emits a bus notification and delegates to {#run}.
48
- # On error emits {Events::AgentMessage} with the error text.
49
- #
50
- # @param input [String] raw user input
51
- # @return [String, nil] the agent's response text, or nil for blank input
52
- def process(input)
53
- text = input.to_s.strip
54
- return if text.empty?
55
-
56
- persist_user_message(text)
57
- Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
58
- run
59
- rescue => error
60
- error_message = "#{error.class}: #{error.message}"
61
- Events::Bus.emit(Events::AgentMessage.new(content: error_message, session_id: @session.id))
62
- error_message
63
- end
64
-
65
41
  # Makes the first LLM API call to verify delivery. Called inside the
66
42
  # Bounce Back transaction — if this raises, the user event rolls back.
67
43
  #
@@ -71,7 +47,7 @@ class AgentLoop
71
47
  # @return [void]
72
48
  # @raise [Providers::Anthropic::Error] on any LLM delivery failure
73
49
  def deliver!
74
- @client ||= LLM::Client.new
50
+ @client ||= build_client
75
51
  @registry ||= build_tool_registry
76
52
 
77
53
  messages = @session.messages_for_llm
@@ -82,6 +58,7 @@ class AgentLoop
82
58
  messages: messages,
83
59
  max_tokens: @client.max_tokens,
84
60
  tools: @registry.schemas,
61
+ include_metrics: true,
85
62
  **options
86
63
  )
87
64
  end
@@ -100,7 +77,7 @@ class AgentLoop
100
77
  # @raise [Providers::Anthropic::TransientError] on retryable network/server errors
101
78
  # @raise [Providers::Anthropic::AuthenticationError] on auth failures
102
79
  def run
103
- @client ||= LLM::Client.new
80
+ @client ||= build_client
104
81
  @registry ||= build_tool_registry
105
82
 
106
83
  messages = @session.messages_for_llm
@@ -109,14 +86,20 @@ class AgentLoop
109
86
  first_resp = @first_response
110
87
  @first_response = nil
111
88
 
112
- response = @client.chat_with_tools(
89
+ between_rounds = -> { @session.promote_pending_messages! }
90
+
91
+ result = @client.chat_with_tools(
113
92
  messages, registry: @registry, session_id: @session.id,
114
- first_response: first_resp, **options
93
+ first_response: first_resp, between_rounds: between_rounds, **options
115
94
  )
116
- return unless response
117
-
118
- Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
119
- response
95
+ return unless result
96
+
97
+ Events::Bus.emit(Events::AgentMessage.new(
98
+ content: result[:text],
99
+ session_id: @session.id,
100
+ api_metrics: result[:api_metrics]
101
+ ))
102
+ result[:text]
120
103
  end
121
104
 
122
105
  # Clean up the underlying {ShellSession} PTY and resources.
@@ -127,7 +110,7 @@ class AgentLoop
127
110
 
128
111
  # Tool classes available to all sessions by default.
129
112
  # @return [Array<Class<Tools::Base>>]
130
- STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember].freeze
113
+ STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember, Tools::Recall].freeze
131
114
 
132
115
  # Tools that bypass {Session#granted_tools} filtering.
133
116
  # The agent's reasoning depends on these regardless of task scope.
@@ -140,20 +123,37 @@ class AgentLoop
140
123
 
141
124
  private
142
125
 
143
- # @see Session#create_user_message
144
- def persist_user_message(content)
145
- @session.create_user_message(content)
126
+ # Restores the working directory inherited from the parent session.
127
+ # Sub-agents store the parent's CWD at spawn time so their shell starts
128
+ # in the same directory the parent was working in.
129
+ # @return [void]
130
+ def restore_initial_cwd
131
+ cwd = @session.initial_cwd
132
+ return unless cwd.present? && File.directory?(cwd)
133
+
134
+ @shell_session.run("cd #{Shellwords.shellescape(cwd)}")
146
135
  end
147
136
 
148
- # Assembles LLM options (system prompt, environment context).
137
+ # Builds the LLM client with the appropriate model for this session type.
138
+ # Sub-agents use a separate (typically cheaper) model from Settings.
139
+ # @return [LLM::Client]
140
+ def build_client
141
+ if @session.sub_agent?
142
+ LLM::Client.new(model: Anima::Settings.subagent_model)
143
+ else
144
+ LLM::Client.new
145
+ end
146
+ end
147
+
148
+ # Assembles LLM options (system prompt).
149
+ # Broadcasts the full debug context (system prompt + tool schemas)
150
+ # to debug-mode TUI clients on every LLM request.
149
151
  # @return [Hash] options for {LLM::Client#chat_with_tools}
150
152
  def build_llm_options
151
153
  options = {}
152
- unless @session.sub_agent?
153
- env_context = EnvironmentProbe.to_prompt(@shell_session.pwd)
154
- end
155
- prompt = @session.system_prompt(environment_context: env_context)
154
+ prompt = @session.system_prompt
156
155
  options[:system] = prompt if prompt
156
+ @session.broadcast_debug_context(system: prompt, tools: @registry&.schemas)
157
157
  options
158
158
  end
159
159
 
@@ -172,7 +172,9 @@ class AgentLoop
172
172
 
173
173
  granted_standard_tools.each { |tool| registry.register(tool) }
174
174
 
175
- unless @session.sub_agent?
175
+ if @session.sub_agent?
176
+ registry.register(Tools::MarkGoalCompleted)
177
+ else
176
178
  registry.register(Tools::SpawnSubagent)
177
179
  registry.register(Tools::SpawnSpecialist)
178
180
  registry.register(Tools::OpenIssue)
@@ -12,7 +12,7 @@ module Agents
12
12
  # ---
13
13
  # name: codebase-analyzer
14
14
  # description: Analyzes codebase implementation details.
15
- # tools: read, bash
15
+ # tools: read_file, bash
16
16
  # model: claude-sonnet-4-5
17
17
  # ---
18
18
  #
@@ -9,7 +9,9 @@ module AnalyticalBrain
9
9
  # active depends on the session type:
10
10
  #
11
11
  # * **Parent sessions** — session naming, skill/workflow/goal management
12
- # * **Child sessions** — sub-agent nickname assignment, skill/workflow/goal management
12
+ # * **Child sessions** — sub-agent nickname assignment, skill management
13
+ # (goal tracking and workflows disabled — sub-agents manage their sole goal
14
+ # via mark_goal_completed)
13
15
  #
14
16
  # Tools mutate the observed session directly (e.g. renaming it, activating
15
17
  # skills), but no trace of the brain's reasoning is persisted — events are
@@ -51,7 +53,8 @@ module AnalyticalBrain
51
53
  ──────────────────────────────
52
54
  SKILL MANAGEMENT
53
55
  ──────────────────────────────
54
- Activate skills when the conversation enters their domain.
56
+ Activate skills when the conversation signals intent — before the agent acts on it.
57
+ Late activation means the agent works without domain knowledge.
55
58
  Deactivate when the agent moves to a different domain.
56
59
  Multiple skills can be active at once.
57
60
  PROMPT
@@ -104,7 +107,7 @@ module AnalyticalBrain
104
107
 
105
108
  # Which responsibilities activate for each session type.
106
109
  PARENT_RESPONSIBILITIES = %i[session_naming skill_management workflow_management goal_tracking].freeze
107
- CHILD_RESPONSIBILITIES = %i[sub_agent_naming skill_management workflow_management goal_tracking].freeze
110
+ CHILD_RESPONSIBILITIES = %i[sub_agent_naming skill_management].freeze
108
111
 
109
112
  # @param session [Session] the session to observe and maintain
110
113
  # @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
@@ -201,7 +204,7 @@ module AnalyticalBrain
201
204
  #{transcript}
202
205
  ```
203
206
 
204
- Assign a nickname, activate relevant skills, then call everything_is_ready.
207
+ Assign a nickname and activate relevant skills, then call everything_is_ready.
205
208
  MSG
206
209
  [{role: "user", content: content}]
207
210
  end
@@ -269,9 +272,15 @@ module AnalyticalBrain
269
272
  SECTION
270
273
  end
271
274
 
275
+ # Skills already visible in the viewport are excluded from the catalog
276
+ # so the brain doesn't re-activate them. When a skill evicts from the
277
+ # viewport, it reappears here and the brain can re-inject if relevant.
278
+ #
279
+ # @see Session#skills_in_viewport
272
280
  # @return [String] available skills list for the analytical brain
273
281
  def skills_catalog_section
274
- catalog = Skills::Registry.instance.catalog
282
+ present = @session.skills_in_viewport
283
+ catalog = Skills::Registry.instance.catalog.except(*present)
275
284
  items = if catalog.empty?
276
285
  "None"
277
286
  else
@@ -285,9 +294,13 @@ module AnalyticalBrain
285
294
  SECTION
286
295
  end
287
296
 
297
+ # Workflows already visible in the viewport are excluded from the catalog.
298
+ #
299
+ # @see Session#workflow_in_viewport
288
300
  # @return [String] available workflows list for the analytical brain
289
301
  def workflows_catalog_section
290
- catalog = Workflows::Registry.instance.catalog
302
+ present = @session.workflow_in_viewport
303
+ catalog = Workflows::Registry.instance.catalog.reject { |name, _| name == present }
291
304
  items = if catalog.empty?
292
305
  "None"
293
306
  else
@@ -3,8 +3,8 @@
3
3
  module AnalyticalBrain
4
4
  module Tools
5
5
  # Activates a domain knowledge skill on the main session.
6
- # The skill's content is injected into the main agent's system prompt,
7
- # making the knowledge available for the current and future responses.
6
+ # The skill's content enters the conversation as a phantom
7
+ # tool_use/tool_result pair through the {PendingMessage} promotion flow.
8
8
  class ActivateSkill < ::Tools::Base
9
9
  def self.tool_name = "activate_skill"
10
10
 
@@ -47,7 +47,7 @@ module AnalyticalBrain
47
47
  return error if error
48
48
 
49
49
  @session.update!(name: nickname)
50
- "Nickname set to @#{nickname}"
50
+ "Nickname set to #{nickname}"
51
51
  end
52
52
 
53
53
  private
@@ -3,7 +3,8 @@
3
3
  module AnalyticalBrain
4
4
  module Tools
5
5
  # Deactivates a domain knowledge skill on the main session.
6
- # The skill's content is removed from the main agent's system prompt.
6
+ # The skill's recalled message stays in the conversation and
7
+ # evicts naturally from the sliding window.
7
8
  class DeactivateSkill < ::Tools::Base
8
9
  def self.tool_name = "deactivate_skill"
9
10
 
@@ -3,7 +3,8 @@
3
3
  module AnalyticalBrain
4
4
  module Tools
5
5
  # Deactivates the current workflow on the main session.
6
- # The workflow's content is removed from the main agent's system prompt.
6
+ # The workflow's recalled message stays in the conversation and
7
+ # evicts naturally from the sliding window.
7
8
  class DeactivateWorkflow < ::Tools::Base
8
9
  def self.tool_name = "deactivate_workflow"
9
10
 
@@ -5,6 +5,8 @@ module AnalyticalBrain
5
5
  # Marks a goal as completed on the main session. Sets the status to
6
6
  # "completed" and records the completion timestamp.
7
7
  class FinishGoal < ::Tools::Base
8
+ include GoalMessaging
9
+
8
10
  def self.tool_name = "finish_goal"
9
11
 
10
12
  def self.description = "Mark a goal as completed."
@@ -57,6 +59,7 @@ module AnalyticalBrain
57
59
 
58
60
  msg = "Goal completed: #{desc} (id: #{id})"
59
61
  msg += " (released #{released} orphaned pins)" if released > 0
62
+ enqueue_goal_message(goal, msg)
60
63
  msg
61
64
  end
62
65
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Shared helper for goal tools that enqueue phantom pair messages
6
+ # when the analytical brain creates, updates, or completes a goal.
7
+ #
8
+ # Including classes must set +@main_session+ to the owning {Session}.
9
+ module GoalMessaging
10
+ private
11
+
12
+ # Enqueues a goal event as a {PendingMessage} on the main session.
13
+ # Promoted to a phantom tool_use/tool_result pair so the main agent
14
+ # sees "I recalled this goal event" in its conversation history.
15
+ #
16
+ # @param goal [Goal] the goal that changed
17
+ # @param confirmation [String] human-readable event description
18
+ # @return [PendingMessage]
19
+ def enqueue_goal_message(goal, confirmation)
20
+ @main_session.pending_messages.create!(
21
+ content: confirmation,
22
+ source_type: "goal",
23
+ source_name: goal.id.to_s
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
@@ -4,8 +4,8 @@ module AnalyticalBrain
4
4
  module Tools
5
5
  # Reads and activates a workflow on the main session.
6
6
  # Returns the full workflow content so the brain can create goals from it.
7
- # Also sets the workflow as active on the session, injecting its content
8
- # into the main agent's "Your Expertise" section.
7
+ # The workflow's content enters the conversation as a phantom
8
+ # tool_use/tool_result pair through the {PendingMessage} promotion flow.
9
9
  class ReadWorkflow < ::Tools::Base
10
10
  def self.tool_name = "read_workflow"
11
11
 
@@ -6,6 +6,8 @@ module AnalyticalBrain
6
6
  # objectives (semantic episodes); sub-goals are TODO-style steps within
7
7
  # a root goal. The two-level hierarchy is enforced by the Goal model.
8
8
  class SetGoal < ::Tools::Base
9
+ include GoalMessaging
10
+
9
11
  def self.tool_name = "set_goal"
10
12
 
11
13
  def self.description = "Create a goal or sub-goal."
@@ -40,7 +42,9 @@ module AnalyticalBrain
40
42
  description: description,
41
43
  parent_goal_id: input["parent_goal_id"]
42
44
  )
43
- format_confirmation(goal)
45
+ confirmation = format_confirmation(goal)
46
+ enqueue_goal_message(goal, confirmation)
47
+ confirmation
44
48
  rescue ActiveRecord::RecordInvalid => error
45
49
  {error: error.record.errors.full_messages.join(", ")}
46
50
  end
@@ -13,6 +13,8 @@ module AnalyticalBrain
13
13
  # Completed goals cannot be updated; attempting to do so returns an error
14
14
  # so the brain learns to check status before calling this tool.
15
15
  class UpdateGoal < ::Tools::Base
16
+ include GoalMessaging
17
+
16
18
  def self.tool_name = "update_goal"
17
19
 
18
20
  def self.description = "Refine a goal's wording as understanding evolves."
@@ -49,7 +51,9 @@ module AnalyticalBrain
49
51
  return {error: "Cannot update completed goal: #{goal.description} (id: #{goal_id})"} if goal.completed?
50
52
 
51
53
  goal.update!(description: description)
52
- "Goal updated: #{description} (id: #{goal_id})"
54
+ confirmation = "Goal updated: #{description} (id: #{goal_id})"
55
+ enqueue_goal_message(goal, confirmation)
56
+ confirmation
53
57
  end
54
58
  end
55
59
  end
@@ -5,8 +5,8 @@ require "thor"
5
5
  module Anima
6
6
  class CLI < Thor
7
7
  class Mcp < Thor
8
- # CLI commands for managing MCP secrets stored in Rails encrypted
9
- # credentials. Secrets are referenced in mcp.toml via
8
+ # CLI commands for managing MCP secrets stored in the encrypted
9
+ # secrets table. Secrets are referenced in mcp.toml via
10
10
  # +${credential:key_name}+ syntax.
11
11
  #
12
12
  # @example Store a secret
@@ -22,7 +22,7 @@ module Anima
22
22
  true
23
23
  end
24
24
 
25
- desc "set KEY=VALUE", "Store an MCP secret in encrypted credentials"
25
+ desc "set KEY=VALUE", "Store an MCP secret in encrypted secrets"
26
26
  def set(pair)
27
27
  key, value = pair.split("=", 2)
28
28
  unless value
@@ -50,7 +50,7 @@ module Anima
50
50
  keys.each { |key| say " #{key}" }
51
51
  end
52
52
 
53
- desc "remove KEY", "Remove an MCP secret from encrypted credentials"
53
+ desc "remove KEY", "Remove an MCP secret from encrypted storage"
54
54
  def remove(key)
55
55
  secrets = require_mcp_secrets
56
56
  unless secrets.list.include?(key)
data/lib/anima/cli/mcp.rb CHANGED
@@ -12,7 +12,7 @@ module Anima
12
12
  true
13
13
  end
14
14
 
15
- desc "secrets SUBCOMMAND", "Manage MCP secrets in encrypted credentials"
15
+ desc "secrets SUBCOMMAND", "Manage MCP secrets in encrypted storage"
16
16
  subcommand "secrets", Secrets
17
17
 
18
18
  desc "list", "List configured MCP servers with health status"
@@ -40,14 +40,14 @@ module Anima
40
40
 
41
41
  Use -e KEY=VALUE to set environment variables (stdio servers).
42
42
  Use -H "Header: Value" to set HTTP headers (HTTP servers).
43
- Use -s KEY=VALUE to store a secret in encrypted credentials.
43
+ Use -s KEY=VALUE to store a secret in encrypted storage.
44
44
  DESC
45
45
  option :env, aliases: "-e", type: :string, repeatable: true, banner: "KEY=VALUE",
46
46
  desc: "Environment variables (repeatable)"
47
47
  option :header, aliases: "-H", type: :string, repeatable: true, banner: "Header: Value",
48
48
  desc: "HTTP headers (repeatable)"
49
49
  option :secret, aliases: "-s", type: :string, repeatable: true, banner: "KEY=VALUE",
50
- desc: "Store secret in encrypted credentials (repeatable)"
50
+ desc: "Store secret in encrypted storage (repeatable)"
51
51
  def add(name, *rest)
52
52
  if rest.empty?
53
53
  say "Error: missing server URL or command.", :red
@@ -87,7 +87,7 @@ module Anima
87
87
  ::Mcp::Config.new
88
88
  end
89
89
 
90
- # Stores secrets from -s KEY=VALUE flags in encrypted credentials.
90
+ # Stores secrets from -s KEY=VALUE flags in the encrypted secrets table.
91
91
  def store_secrets(secret_strings)
92
92
  return unless secret_strings&.any?
93
93