anima-core 1.0.2 → 1.1.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.
- checksums.yaml +4 -4
- data/.gitattributes +1 -0
- data/.reek.yml +47 -0
- data/README.md +60 -26
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +29 -10
- data/app/decorators/tool_call_decorator.rb +7 -3
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +12 -4
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +90 -23
- data/app/jobs/mneme_job.rb +51 -0
- data/app/jobs/passive_recall_job.rb +29 -0
- data/app/models/concerns/event/broadcasting.rb +18 -0
- data/app/models/event.rb +10 -0
- data/app/models/goal.rb +27 -0
- data/app/models/goal_pinned_event.rb +11 -0
- data/app/models/pinned_event.rb +41 -0
- data/app/models/session.rb +335 -6
- data/app/models/snapshot.rb +76 -0
- data/config/initializers/event_subscribers.rb +14 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
- data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
- data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
- data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
- data/lib/agent_loop.rb +63 -20
- data/lib/analytical_brain/runner.rb +158 -65
- data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
- data/lib/analytical_brain/tools/finish_goal.rb +6 -1
- data/lib/anima/cli.rb +2 -1
- data/lib/anima/installer.rb +11 -12
- data/lib/anima/settings.rb +41 -0
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/agent_dispatcher.rb +29 -0
- data/lib/events/subscribers/persister.rb +17 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/llm/client.rb +16 -8
- data/lib/mneme/compressed_viewport.rb +200 -0
- data/lib/mneme/l2_runner.rb +138 -0
- data/lib/mneme/passive_recall.rb +69 -0
- data/lib/mneme/runner.rb +254 -0
- data/lib/mneme/search.rb +150 -0
- data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
- data/lib/mneme/tools/everything_ok.rb +24 -0
- data/lib/mneme/tools/save_snapshot.rb +68 -0
- data/lib/mneme.rb +29 -0
- data/lib/providers/anthropic.rb +57 -13
- data/lib/shell_session.rb +188 -59
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/spawn_specialist.rb +21 -9
- data/lib/tools/spawn_subagent.rb +22 -11
- data/lib/tools/subagent_prompts.rb +20 -3
- data/lib/tools/web_get.rb +15 -6
- data/lib/tui/app.rb +222 -125
- data/lib/tui/decorators/base_decorator.rb +165 -0
- data/lib/tui/decorators/bash_decorator.rb +20 -0
- data/lib/tui/decorators/edit_decorator.rb +19 -0
- data/lib/tui/decorators/read_decorator.rb +24 -0
- data/lib/tui/decorators/think_decorator.rb +36 -0
- data/lib/tui/decorators/web_get_decorator.rb +19 -0
- data/lib/tui/decorators/write_decorator.rb +19 -0
- data/lib/tui/flash.rb +139 -0
- data/lib/tui/formatting.rb +28 -0
- data/lib/tui/height_map.rb +93 -0
- data/lib/tui/message_store.rb +25 -1
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +40 -0
- metadata +83 -4
- data/CHANGELOG.md +0 -80
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
data/lib/agent_loop.rb
CHANGED
|
@@ -42,7 +42,9 @@ class AgentLoop
|
|
|
42
42
|
|
|
43
43
|
# Runs the agent loop for a single user input.
|
|
44
44
|
#
|
|
45
|
-
#
|
|
45
|
+
# Persists the user event 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}.
|
|
46
48
|
# On error emits {Events::AgentMessage} with the error text.
|
|
47
49
|
#
|
|
48
50
|
# @param input [String] raw user input
|
|
@@ -51,6 +53,7 @@ class AgentLoop
|
|
|
51
53
|
text = input.to_s.strip
|
|
52
54
|
return if text.empty?
|
|
53
55
|
|
|
56
|
+
persist_user_event(text)
|
|
54
57
|
Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
|
|
55
58
|
run
|
|
56
59
|
rescue => error
|
|
@@ -59,15 +62,39 @@ class AgentLoop
|
|
|
59
62
|
error_message
|
|
60
63
|
end
|
|
61
64
|
|
|
65
|
+
# Makes the first LLM API call to verify delivery. Called inside the
|
|
66
|
+
# Bounce Back transaction — if this raises, the user event rolls back.
|
|
67
|
+
#
|
|
68
|
+
# Caches the first response so the subsequent {#run} call can continue
|
|
69
|
+
# from it without duplicating the API call.
|
|
70
|
+
#
|
|
71
|
+
# @return [void]
|
|
72
|
+
# @raise [Providers::Anthropic::Error] on any LLM delivery failure
|
|
73
|
+
def deliver!
|
|
74
|
+
@client ||= LLM::Client.new
|
|
75
|
+
@registry ||= build_tool_registry
|
|
76
|
+
|
|
77
|
+
messages = @session.messages_for_llm
|
|
78
|
+
options = build_llm_options
|
|
79
|
+
|
|
80
|
+
@first_response = @client.provider.create_message(
|
|
81
|
+
model: @client.model,
|
|
82
|
+
messages: messages,
|
|
83
|
+
max_tokens: @client.max_tokens,
|
|
84
|
+
tools: @registry.schemas,
|
|
85
|
+
**options
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
62
89
|
# Runs the LLM tool-use loop on persisted session messages.
|
|
63
90
|
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
91
|
+
# When a cached first response exists (from {#deliver!}), continues
|
|
92
|
+
# from that response without a redundant API call. Otherwise makes
|
|
93
|
+
# a fresh call — used for pending message processing and the standard
|
|
94
|
+
# path.
|
|
67
95
|
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
# ends at the interrupted tool result.
|
|
96
|
+
# Lets errors propagate — designed for callers like {AgentRequestJob}
|
|
97
|
+
# that handle retries and need errors to bubble up.
|
|
71
98
|
#
|
|
72
99
|
# @return [String, nil] the agent's response text, or nil when interrupted
|
|
73
100
|
# @raise [Providers::Anthropic::TransientError] on retryable network/server errors
|
|
@@ -77,15 +104,15 @@ class AgentLoop
|
|
|
77
104
|
@registry ||= build_tool_registry
|
|
78
105
|
|
|
79
106
|
messages = @session.messages_for_llm
|
|
80
|
-
options =
|
|
107
|
+
options = build_llm_options
|
|
81
108
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
end
|
|
85
|
-
prompt = @session.system_prompt(environment_context: env_context)
|
|
86
|
-
options[:system] = prompt if prompt
|
|
109
|
+
first_resp = @first_response
|
|
110
|
+
@first_response = nil
|
|
87
111
|
|
|
88
|
-
response = @client.chat_with_tools(
|
|
112
|
+
response = @client.chat_with_tools(
|
|
113
|
+
messages, registry: @registry, session_id: @session.id,
|
|
114
|
+
first_response: first_resp, **options
|
|
115
|
+
)
|
|
89
116
|
return unless response
|
|
90
117
|
|
|
91
118
|
Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
|
|
@@ -100,7 +127,7 @@ class AgentLoop
|
|
|
100
127
|
|
|
101
128
|
# Tool classes available to all sessions by default.
|
|
102
129
|
# @return [Array<Class<Tools::Base>>]
|
|
103
|
-
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think].freeze
|
|
130
|
+
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember].freeze
|
|
104
131
|
|
|
105
132
|
# Name-to-class mapping for tool restriction validation and registry building.
|
|
106
133
|
# @return [Hash{String => Class<Tools::Base>}]
|
|
@@ -108,10 +135,28 @@ class AgentLoop
|
|
|
108
135
|
|
|
109
136
|
private
|
|
110
137
|
|
|
138
|
+
# @see Session#create_user_event
|
|
139
|
+
def persist_user_event(content)
|
|
140
|
+
@session.create_user_event(content)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Assembles LLM options (system prompt, environment context).
|
|
144
|
+
# @return [Hash] options for {LLM::Client#chat_with_tools}
|
|
145
|
+
def build_llm_options
|
|
146
|
+
options = {}
|
|
147
|
+
unless @session.sub_agent?
|
|
148
|
+
env_context = EnvironmentProbe.to_prompt(@shell_session.pwd)
|
|
149
|
+
end
|
|
150
|
+
prompt = @session.system_prompt(environment_context: env_context)
|
|
151
|
+
options[:system] = prompt if prompt
|
|
152
|
+
options
|
|
153
|
+
end
|
|
154
|
+
|
|
111
155
|
# Builds the tool registry appropriate for this session type.
|
|
112
156
|
# Main sessions get standard tools + spawn_subagent + spawn_specialist.
|
|
113
|
-
# Sub-
|
|
114
|
-
# Sub-
|
|
157
|
+
# Sub-agents get granted standard tools only (no spawning, no nesting).
|
|
158
|
+
# Sub-agent results are delivered through natural text messages routed
|
|
159
|
+
# by {Events::Subscribers::SubagentMessageRouter}.
|
|
115
160
|
# When {Session#granted_tools} is nil, all standard tools are granted.
|
|
116
161
|
# MCP tools from configured servers are registered for all session types.
|
|
117
162
|
#
|
|
@@ -122,9 +167,7 @@ class AgentLoop
|
|
|
122
167
|
|
|
123
168
|
granted_standard_tools.each { |tool| registry.register(tool) }
|
|
124
169
|
|
|
125
|
-
|
|
126
|
-
registry.register(Tools::ReturnResult)
|
|
127
|
-
else
|
|
170
|
+
unless @session.sub_agent?
|
|
128
171
|
registry.register(Tools::SpawnSubagent)
|
|
129
172
|
registry.register(Tools::SpawnSpecialist)
|
|
130
173
|
registry.register(Tools::RequestFeature)
|
|
@@ -2,67 +2,99 @@
|
|
|
2
2
|
|
|
3
3
|
module AnalyticalBrain
|
|
4
4
|
# Orchestrates the analytical brain — a phantom (non-persisted) LLM loop
|
|
5
|
-
# that observes a
|
|
5
|
+
# that observes a session and performs background maintenance via tools.
|
|
6
6
|
#
|
|
7
|
-
# The
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# The brain's capabilities are assembled from independent {Responsibility}
|
|
8
|
+
# modules, each contributing a prompt section and tools. Which modules are
|
|
9
|
+
# active depends on the session type:
|
|
10
|
+
#
|
|
11
|
+
# * **Parent sessions** — session naming, skill/workflow/goal management
|
|
12
|
+
# * **Child sessions** — sub-agent nickname assignment, skill/workflow/goal management
|
|
13
|
+
#
|
|
14
|
+
# Tools mutate the observed session directly (e.g. renaming it, activating
|
|
15
|
+
# skills), but no trace of the brain's reasoning is persisted — events are
|
|
16
|
+
# emitted into a phantom session (session_id: nil).
|
|
11
17
|
#
|
|
12
18
|
# @example
|
|
13
19
|
# AnalyticalBrain::Runner.new(session).call
|
|
14
20
|
class Runner
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
TOOLS = [
|
|
18
|
-
Tools::RenameSession,
|
|
19
|
-
Tools::ActivateSkill,
|
|
20
|
-
Tools::DeactivateSkill,
|
|
21
|
-
Tools::ReadWorkflow,
|
|
22
|
-
Tools::DeactivateWorkflow,
|
|
23
|
-
Tools::SetGoal,
|
|
24
|
-
Tools::UpdateGoal,
|
|
25
|
-
Tools::FinishGoal,
|
|
26
|
-
Tools::EverythingIsReady
|
|
27
|
-
].freeze
|
|
28
|
-
|
|
29
|
-
SYSTEM_PROMPT = <<~PROMPT
|
|
30
|
-
You are a background automation that manages session metadata.
|
|
31
|
-
You MUST ONLY communicate through tool calls — NEVER output text.
|
|
32
|
-
Always finish by calling everything_is_ready.
|
|
21
|
+
# A composable unit of brain capability: a prompt section + its tools.
|
|
22
|
+
Responsibility = Data.define(:prompt, :tools)
|
|
33
23
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
24
|
+
RESPONSIBILITIES = {
|
|
25
|
+
session_naming: Responsibility.new(
|
|
26
|
+
prompt: <<~PROMPT,
|
|
27
|
+
──────────────────────────────
|
|
28
|
+
SESSION NAMING
|
|
29
|
+
──────────────────────────────
|
|
30
|
+
Call rename_session when the topic becomes clear or shifts.
|
|
31
|
+
Format: one emoji + 1-3 descriptive words.
|
|
32
|
+
PROMPT
|
|
33
|
+
tools: [Tools::RenameSession]
|
|
34
|
+
),
|
|
39
35
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
sub_agent_naming: Responsibility.new(
|
|
37
|
+
prompt: <<~PROMPT,
|
|
38
|
+
──────────────────────────────
|
|
39
|
+
SUB-AGENT NAMING
|
|
40
|
+
──────────────────────────────
|
|
41
|
+
Call assign_nickname to give this sub-agent a short, memorable nickname.
|
|
42
|
+
Format: 1-3 lowercase words joined by hyphens (e.g. "loop-sleuth", "api-scout").
|
|
43
|
+
Evocative of the task, fun, easy to type after @.
|
|
44
|
+
Generate EXACTLY ONE nickname. If taken, pick another — no numeric suffixes.
|
|
45
|
+
PROMPT
|
|
46
|
+
tools: [Tools::AssignNickname]
|
|
47
|
+
),
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
skill_management: Responsibility.new(
|
|
50
|
+
prompt: <<~PROMPT,
|
|
51
|
+
──────────────────────────────
|
|
52
|
+
SKILL MANAGEMENT
|
|
53
|
+
──────────────────────────────
|
|
54
|
+
Call activate_skill when the conversation matches a skill's description.
|
|
55
|
+
Call deactivate_skill when the agent moves to a different domain.
|
|
56
|
+
Multiple skills can be active at once.
|
|
57
|
+
PROMPT
|
|
58
|
+
tools: [Tools::ActivateSkill, Tools::DeactivateSkill]
|
|
59
|
+
),
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
workflow_management: Responsibility.new(
|
|
62
|
+
prompt: <<~PROMPT,
|
|
63
|
+
──────────────────────────────
|
|
64
|
+
WORKFLOW MANAGEMENT
|
|
65
|
+
──────────────────────────────
|
|
66
|
+
Call read_workflow when the user starts a multi-step task matching a workflow description.
|
|
67
|
+
Read the returned content and use judgment to create appropriate goals — not a mechanical 1:1 mapping.
|
|
68
|
+
Adapt to context: skip irrelevant steps, add extra steps for unfamiliar areas.
|
|
69
|
+
Call deactivate_workflow when the workflow completes or the user shifts focus.
|
|
70
|
+
Only one workflow can be active at a time — activating a new one replaces the previous.
|
|
71
|
+
PROMPT
|
|
72
|
+
tools: [Tools::ReadWorkflow, Tools::DeactivateWorkflow]
|
|
73
|
+
),
|
|
65
74
|
|
|
75
|
+
goal_tracking: Responsibility.new(
|
|
76
|
+
prompt: <<~PROMPT,
|
|
77
|
+
──────────────────────────────
|
|
78
|
+
GOAL TRACKING
|
|
79
|
+
──────────────────────────────
|
|
80
|
+
Call set_goal to create a root goal when the user starts a multi-step task.
|
|
81
|
+
Call set_goal with parent_goal_id to add sub-goals (TODO items) under it.
|
|
82
|
+
Call update_goal to refine a goal's description as understanding evolves.
|
|
83
|
+
Call finish_goal when the main agent completes work a goal describes.
|
|
84
|
+
Finishing a root goal cascades — all active sub-goals are completed too.
|
|
85
|
+
Never duplicate an existing goal — check the active goals list first.
|
|
86
|
+
PROMPT
|
|
87
|
+
tools: [Tools::SetGoal, Tools::UpdateGoal, Tools::FinishGoal]
|
|
88
|
+
)
|
|
89
|
+
}.freeze
|
|
90
|
+
|
|
91
|
+
BASE_PROMPT = <<~PROMPT
|
|
92
|
+
You are a background automation that manages session metadata.
|
|
93
|
+
You MUST ONLY communicate through tool calls — NEVER output text.
|
|
94
|
+
Always finish by calling everything_is_ready.
|
|
95
|
+
PROMPT
|
|
96
|
+
|
|
97
|
+
COMPLETION_PROMPT = <<~PROMPT
|
|
66
98
|
──────────────────────────────
|
|
67
99
|
COMPLETION
|
|
68
100
|
──────────────────────────────
|
|
@@ -70,7 +102,11 @@ module AnalyticalBrain
|
|
|
70
102
|
If nothing needs changing, call it immediately as your only tool call.
|
|
71
103
|
PROMPT
|
|
72
104
|
|
|
73
|
-
#
|
|
105
|
+
# Which responsibilities activate for each session type.
|
|
106
|
+
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
|
|
108
|
+
|
|
109
|
+
# @param session [Session] the session to observe and maintain
|
|
74
110
|
# @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
|
|
75
111
|
def initialize(session, client: nil)
|
|
76
112
|
@session = session
|
|
@@ -81,9 +117,9 @@ module AnalyticalBrain
|
|
|
81
117
|
)
|
|
82
118
|
end
|
|
83
119
|
|
|
84
|
-
# Runs the analytical brain loop. Builds context from the
|
|
85
|
-
# recent events, calls the LLM with the
|
|
86
|
-
# executes any tool calls against the
|
|
120
|
+
# Runs the analytical brain loop. Builds context from the session's
|
|
121
|
+
# recent events, calls the LLM with the session-appropriate tool set,
|
|
122
|
+
# and executes any tool calls against the session.
|
|
87
123
|
#
|
|
88
124
|
# Events emitted during tool execution are not persisted — the phantom
|
|
89
125
|
# session_id (nil) causes the global Persister to skip them.
|
|
@@ -116,13 +152,21 @@ module AnalyticalBrain
|
|
|
116
152
|
|
|
117
153
|
private
|
|
118
154
|
|
|
155
|
+
# @return [Array<Symbol>] responsibility keys for this session type
|
|
156
|
+
def active_responsibility_keys
|
|
157
|
+
@session.sub_agent? ? CHILD_RESPONSIBILITIES : PARENT_RESPONSIBILITIES
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @return [Array<Responsibility>] active responsibility modules
|
|
161
|
+
def active_responsibilities
|
|
162
|
+
active_responsibility_keys.map { |key| RESPONSIBILITIES.fetch(key) }
|
|
163
|
+
end
|
|
164
|
+
|
|
119
165
|
# Builds a condensed transcript of recent events as a single user message.
|
|
120
|
-
# The
|
|
121
|
-
# just needs to understand "what is the agent doing RIGHT NOW?"
|
|
166
|
+
# The framing differs by session type:
|
|
122
167
|
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
# confuses the main session's user messages with requests directed at it.
|
|
168
|
+
# * **Parent:** "The main session is working on this: [transcript]"
|
|
169
|
+
# * **Child:** "A sub-agent has been spawned with this task: [transcript]"
|
|
126
170
|
#
|
|
127
171
|
# @return [Array<Hash>] single-element messages array, or empty if no events
|
|
128
172
|
def build_messages
|
|
@@ -130,6 +174,15 @@ module AnalyticalBrain
|
|
|
130
174
|
return [] if events.empty?
|
|
131
175
|
|
|
132
176
|
transcript = events.filter_map { |event| EventDecorator.for(event)&.render("brain") }.join("\n")
|
|
177
|
+
|
|
178
|
+
if @session.sub_agent?
|
|
179
|
+
build_child_message(transcript)
|
|
180
|
+
else
|
|
181
|
+
build_parent_message(transcript)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def build_parent_message(transcript)
|
|
133
186
|
content = <<~MSG.strip
|
|
134
187
|
The main session is working on this:
|
|
135
188
|
```
|
|
@@ -141,6 +194,18 @@ module AnalyticalBrain
|
|
|
141
194
|
[{role: "user", content: content}]
|
|
142
195
|
end
|
|
143
196
|
|
|
197
|
+
def build_child_message(transcript)
|
|
198
|
+
content = <<~MSG.strip
|
|
199
|
+
A sub-agent has been spawned with this task:
|
|
200
|
+
```
|
|
201
|
+
#{transcript}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Assign a memorable nickname based on the task, activate relevant skills, then call everything_is_ready.
|
|
205
|
+
MSG
|
|
206
|
+
[{role: "user", content: content}]
|
|
207
|
+
end
|
|
208
|
+
|
|
144
209
|
# @return [Array<Event>] most recent events in chronological order
|
|
145
210
|
def recent_events
|
|
146
211
|
@session.events
|
|
@@ -151,14 +216,16 @@ module AnalyticalBrain
|
|
|
151
216
|
.reverse
|
|
152
217
|
end
|
|
153
218
|
|
|
154
|
-
# Builds the system prompt
|
|
155
|
-
# and currently active skills.
|
|
219
|
+
# Builds the system prompt from active responsibilities + context sections.
|
|
156
220
|
#
|
|
157
221
|
# @return [String]
|
|
158
222
|
def build_system_prompt
|
|
159
223
|
sections = [
|
|
160
|
-
|
|
224
|
+
BASE_PROMPT,
|
|
225
|
+
*active_responsibilities.map(&:prompt),
|
|
226
|
+
COMPLETION_PROMPT,
|
|
161
227
|
session_state_section,
|
|
228
|
+
active_siblings_section,
|
|
162
229
|
skills_catalog_section,
|
|
163
230
|
workflows_catalog_section,
|
|
164
231
|
active_goals_section
|
|
@@ -181,6 +248,27 @@ module AnalyticalBrain
|
|
|
181
248
|
SECTION
|
|
182
249
|
end
|
|
183
250
|
|
|
251
|
+
# Shows sibling nicknames already in use so the brain avoids collisions
|
|
252
|
+
# at prompt level (the tool also validates at execution time).
|
|
253
|
+
#
|
|
254
|
+
# @return [String, nil] sibling names section, or nil for parent sessions
|
|
255
|
+
def active_siblings_section
|
|
256
|
+
return unless @session.sub_agent?
|
|
257
|
+
|
|
258
|
+
siblings = @session.parent_session.child_sessions
|
|
259
|
+
.where.not(id: @session.id)
|
|
260
|
+
.where.not(name: nil)
|
|
261
|
+
.pluck(:name)
|
|
262
|
+
return if siblings.empty?
|
|
263
|
+
|
|
264
|
+
<<~SECTION
|
|
265
|
+
──────────────────────────────
|
|
266
|
+
ACTIVE SIBLINGS
|
|
267
|
+
──────────────────────────────
|
|
268
|
+
These nicknames are already taken: #{siblings.join(", ")}
|
|
269
|
+
SECTION
|
|
270
|
+
end
|
|
271
|
+
|
|
184
272
|
# @return [String] available skills list for the analytical brain
|
|
185
273
|
def skills_catalog_section
|
|
186
274
|
catalog = Skills::Registry.instance.catalog
|
|
@@ -248,11 +336,16 @@ module AnalyticalBrain
|
|
|
248
336
|
# @return [Logger] dev-only analytical brain logger
|
|
249
337
|
def log = AnalyticalBrain.logger
|
|
250
338
|
|
|
251
|
-
# @return [Tools::Registry] registry with
|
|
339
|
+
# @return [Tools::Registry] registry with tools from active responsibilities
|
|
252
340
|
def build_registry
|
|
253
341
|
registry = ::Tools::Registry.new(context: {main_session: @session})
|
|
254
|
-
|
|
342
|
+
active_tools.each { |tool| registry.register(tool) }
|
|
255
343
|
registry
|
|
256
344
|
end
|
|
345
|
+
|
|
346
|
+
# @return [Array<Class<Tools::Base>>] tools from all active responsibilities + completion
|
|
347
|
+
def active_tools
|
|
348
|
+
active_responsibilities.flat_map(&:tools) + [Tools::EverythingIsReady]
|
|
349
|
+
end
|
|
257
350
|
end
|
|
258
351
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticalBrain
|
|
4
|
+
module Tools
|
|
5
|
+
# Assigns a static nickname to a sub-agent session.
|
|
6
|
+
# Operates on the session passed through the registry context.
|
|
7
|
+
#
|
|
8
|
+
# Nicknames must be unique among active siblings — the tool returns
|
|
9
|
+
# an error on collision so the LLM can pick another name naturally,
|
|
10
|
+
# without programmatic suffixes.
|
|
11
|
+
#
|
|
12
|
+
# @see AnalyticalBrain::Runner — invokes this tool for child sessions
|
|
13
|
+
class AssignNickname < ::Tools::Base
|
|
14
|
+
# Lowercase hyphenated words: "loop-sleuth", "api-scout", "test-fixer"
|
|
15
|
+
NICKNAME_PATTERN = /\A[a-z][a-z0-9]*(-[a-z0-9]+)*\z/
|
|
16
|
+
MAX_LENGTH = 30
|
|
17
|
+
|
|
18
|
+
def self.tool_name = "assign_nickname"
|
|
19
|
+
|
|
20
|
+
def self.description = "Assign a short, memorable nickname to this sub-agent. " \
|
|
21
|
+
"The nickname is permanent — it will not change."
|
|
22
|
+
|
|
23
|
+
def self.input_schema
|
|
24
|
+
{
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
nickname: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "1-3 lowercase words joined by hyphens (e.g. 'loop-sleuth', 'api-scout'). " \
|
|
30
|
+
"Evocative of the task, fun, easy to type after @."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
required: %w[nickname]
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param main_session [Session] the sub-agent session to name
|
|
38
|
+
def initialize(main_session:, **)
|
|
39
|
+
@session = main_session
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param input [Hash<String, Object>] with "nickname" key
|
|
43
|
+
# @return [String] confirmation message
|
|
44
|
+
# @return [Hash] with :error key on validation failure
|
|
45
|
+
def execute(input)
|
|
46
|
+
nickname = input["nickname"].to_s.strip.downcase
|
|
47
|
+
|
|
48
|
+
error = validate(nickname)
|
|
49
|
+
return error if error
|
|
50
|
+
|
|
51
|
+
@session.update!(name: nickname)
|
|
52
|
+
"Nickname set to @#{nickname}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validate(nickname)
|
|
58
|
+
return {error: "Nickname cannot be blank"} if nickname.empty?
|
|
59
|
+
return {error: "Invalid format: use 1-3 lowercase words joined by hyphens"} unless nickname.match?(NICKNAME_PATTERN)
|
|
60
|
+
return {error: "Nickname too long (max #{MAX_LENGTH} chars)"} if nickname.length > MAX_LENGTH
|
|
61
|
+
|
|
62
|
+
if sibling_nickname_taken?(nickname)
|
|
63
|
+
{error: "Nickname '#{nickname}' is already taken by a sibling. Choose another."}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sibling_nickname_taken?(nickname)
|
|
68
|
+
return false unless @session.parent_session
|
|
69
|
+
|
|
70
|
+
@session.parent_session.child_sessions
|
|
71
|
+
.where.not(id: @session.id)
|
|
72
|
+
.exists?(name: nickname)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -51,11 +51,16 @@ module AnalyticalBrain
|
|
|
51
51
|
id = goal.id
|
|
52
52
|
return {error: "Goal already completed: #{goal.description} (id: #{id})"} if goal.completed?
|
|
53
53
|
|
|
54
|
+
released = 0
|
|
54
55
|
Goal.transaction do
|
|
55
56
|
goal.update!(status: "completed", completed_at: Time.current)
|
|
56
57
|
goal.cascade_completion! if goal.root?
|
|
58
|
+
released = goal.release_orphaned_pins!
|
|
57
59
|
end
|
|
58
|
-
|
|
60
|
+
|
|
61
|
+
msg = "Goal completed: #{goal.description} (id: #{id})"
|
|
62
|
+
msg += " (released #{released} orphaned pins)" if released > 0
|
|
63
|
+
msg
|
|
59
64
|
end
|
|
60
65
|
end
|
|
61
66
|
end
|
data/lib/anima/cli.rb
CHANGED
|
@@ -78,6 +78,7 @@ module Anima
|
|
|
78
78
|
|
|
79
79
|
desc "tui", "Launch the Anima terminal interface"
|
|
80
80
|
option :host, desc: "Brain server address (default: #{DEFAULT_HOST})"
|
|
81
|
+
option :debug, type: :boolean, default: false, desc: "Enable performance logging to log/tui_performance.log"
|
|
81
82
|
def tui
|
|
82
83
|
require "ratatui_ruby"
|
|
83
84
|
require_relative "../tui/app"
|
|
@@ -89,7 +90,7 @@ module Anima
|
|
|
89
90
|
cable_client = TUI::CableClient.new(host: host)
|
|
90
91
|
cable_client.connect
|
|
91
92
|
|
|
92
|
-
TUI::App.new(cable_client: cable_client).run
|
|
93
|
+
TUI::App.new(cable_client: cable_client, debug: options[:debug]).run
|
|
93
94
|
end
|
|
94
95
|
|
|
95
96
|
desc "version", "Show version"
|
data/lib/anima/installer.rb
CHANGED
|
@@ -115,19 +115,22 @@ module Anima
|
|
|
115
115
|
|
|
116
116
|
next if key_path.exist? && content_path.exist?
|
|
117
117
|
|
|
118
|
+
content_str = content_path.to_s
|
|
119
|
+
key_str = key_path.to_s
|
|
120
|
+
|
|
118
121
|
key = ActiveSupport::EncryptedFile.generate_key
|
|
119
122
|
key_path.write(key)
|
|
120
|
-
File.chmod(0o600,
|
|
123
|
+
File.chmod(0o600, key_str)
|
|
121
124
|
|
|
122
125
|
config = ActiveSupport::EncryptedConfiguration.new(
|
|
123
|
-
config_path:
|
|
124
|
-
key_path:
|
|
126
|
+
config_path: content_str,
|
|
127
|
+
key_path: key_str,
|
|
125
128
|
env_key: "RAILS_MASTER_KEY",
|
|
126
129
|
raise_if_missing_key: true
|
|
127
130
|
)
|
|
128
131
|
|
|
129
132
|
config.write("secret_key_base: #{SecureRandom.hex(64)}\n")
|
|
130
|
-
File.chmod(0o600,
|
|
133
|
+
File.chmod(0o600, content_str)
|
|
131
134
|
say " created credentials for #{env}"
|
|
132
135
|
end
|
|
133
136
|
end
|
|
@@ -153,16 +156,12 @@ module Anima
|
|
|
153
156
|
WantedBy=default.target
|
|
154
157
|
UNIT
|
|
155
158
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
else
|
|
160
|
-
service_path.write(unit_content)
|
|
161
|
-
say " updated #{service_path}"
|
|
162
|
-
end
|
|
159
|
+
already_exists = service_path.exist?
|
|
160
|
+
if already_exists && service_path.read == unit_content
|
|
161
|
+
say " anima.service unchanged"
|
|
163
162
|
else
|
|
164
163
|
service_path.write(unit_content)
|
|
165
|
-
say " created #{service_path}"
|
|
164
|
+
say " #{already_exists ? "updated" : "created"} #{service_path}"
|
|
166
165
|
end
|
|
167
166
|
|
|
168
167
|
system("systemctl", "--user", "daemon-reload", err: File::NULL, out: File::NULL)
|
data/lib/anima/settings.rb
CHANGED
|
@@ -177,6 +177,47 @@ module Anima
|
|
|
177
177
|
# @return [Integer]
|
|
178
178
|
def analytical_brain_event_window = get("analytical_brain", "event_window")
|
|
179
179
|
|
|
180
|
+
# ─── Mneme (Memory Department) ────────────────────────────────
|
|
181
|
+
|
|
182
|
+
# Maximum tokens per Mneme LLM response.
|
|
183
|
+
# @return [Integer]
|
|
184
|
+
def mneme_max_tokens = get("mneme", "max_tokens")
|
|
185
|
+
|
|
186
|
+
# Fraction of the main viewport token budget allocated to Mneme's viewport.
|
|
187
|
+
# @return [Float]
|
|
188
|
+
def mneme_viewport_fraction = get("mneme", "viewport_fraction")
|
|
189
|
+
|
|
190
|
+
# Fraction of the main viewport token budget reserved for L1 snapshots.
|
|
191
|
+
# @return [Float]
|
|
192
|
+
def mneme_l1_budget_fraction = get("mneme", "l1_budget_fraction")
|
|
193
|
+
|
|
194
|
+
# Fraction of the main viewport token budget reserved for L2 snapshots.
|
|
195
|
+
# @return [Float]
|
|
196
|
+
def mneme_l2_budget_fraction = get("mneme", "l2_budget_fraction")
|
|
197
|
+
|
|
198
|
+
# Number of uncovered L1 snapshots that triggers L2 compression.
|
|
199
|
+
# @return [Integer]
|
|
200
|
+
def mneme_l2_snapshot_threshold = get("mneme", "l2_snapshot_threshold")
|
|
201
|
+
|
|
202
|
+
# Fraction of the main viewport token budget reserved for pinned events.
|
|
203
|
+
# Pinned events appear between snapshots and the sliding window.
|
|
204
|
+
# @return [Float]
|
|
205
|
+
def mneme_pinned_budget_fraction = get("mneme", "pinned_budget_fraction")
|
|
206
|
+
|
|
207
|
+
# ─── Recall (Associative Memory) ────────────────────────────
|
|
208
|
+
|
|
209
|
+
# Maximum search results returned per FTS5 query.
|
|
210
|
+
# @return [Integer]
|
|
211
|
+
def recall_max_results = get("recall", "max_results")
|
|
212
|
+
|
|
213
|
+
# Fraction of the main viewport token budget reserved for recalled memories.
|
|
214
|
+
# @return [Float]
|
|
215
|
+
def recall_budget_fraction = get("recall", "budget_fraction")
|
|
216
|
+
|
|
217
|
+
# Maximum tokens per individual recall snippet.
|
|
218
|
+
# @return [Integer]
|
|
219
|
+
def recall_max_snippet_tokens = get("recall", "max_snippet_tokens")
|
|
220
|
+
|
|
180
221
|
private
|
|
181
222
|
|
|
182
223
|
# Reads a setting from the config file.
|
data/lib/anima/version.rb
CHANGED