anima-core 1.0.1 → 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 +61 -0
- data/README.md +202 -116
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +44 -10
- data/app/decorators/agent_message_decorator.rb +6 -0
- data/app/decorators/event_decorator.rb +41 -7
- data/app/decorators/tool_call_decorator.rb +66 -5
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +35 -5
- data/app/decorators/user_message_decorator.rb +6 -0
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +95 -20
- 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/20260316094817_add_interrupt_requested_to_sessions.rb +5 -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 +67 -18
- data/lib/analytical_brain/runner.rb +159 -84
- 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 +34 -1
- data/lib/anima/config_migrator.rb +205 -0
- data/lib/anima/installer.rb +13 -130
- data/lib/anima/settings.rb +42 -1
- 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 +99 -14
- 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/think.rb +57 -0
- data/lib/tools/web_get.rb +15 -6
- data/lib/tui/app.rb +230 -127
- data/lib/tui/cable_client.rb +8 -0
- 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 +374 -109
- data/templates/config.toml +156 -0
- metadata +87 -4
- data/CHANGELOG.md +0 -79
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "toml-rb"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Anima
|
|
7
|
+
# Merges new default settings into an existing config.toml without
|
|
8
|
+
# overwriting user-customized values.
|
|
9
|
+
#
|
|
10
|
+
# Preserves the user's formatting and comments by extracting text blocks
|
|
11
|
+
# from the template and appending them to the config file. Missing entire
|
|
12
|
+
# sections are appended with their separator comments; missing keys within
|
|
13
|
+
# existing sections are inserted at the end of the section.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# result = ConfigMigrator.new.run
|
|
17
|
+
# result.status #=> :updated
|
|
18
|
+
# result.additions #=> [#<Addition section="paths" key="soul" value="/home/...">]
|
|
19
|
+
class ConfigMigrator
|
|
20
|
+
ANIMA_HOME = File.expand_path("~/.anima")
|
|
21
|
+
TEMPLATE_PATH = File.expand_path("../../templates/config.toml", __dir__).freeze
|
|
22
|
+
|
|
23
|
+
# A single config key that was added during migration.
|
|
24
|
+
# @!attribute [r] section [String] TOML section name
|
|
25
|
+
# @!attribute [r] key [String] key name within the section
|
|
26
|
+
# @!attribute [r] value [Object] default value from the template
|
|
27
|
+
Addition = Data.define(:section, :key, :value)
|
|
28
|
+
|
|
29
|
+
# Outcome of a migration run.
|
|
30
|
+
# @!attribute [r] status [Symbol] :not_found, :up_to_date, or :updated
|
|
31
|
+
# @!attribute [r] additions [Array<Addition>] keys that were added
|
|
32
|
+
Result = Data.define(:status, :additions)
|
|
33
|
+
|
|
34
|
+
# Section separator pattern used in the template (e.g. "# ─── LLM ───...").
|
|
35
|
+
SEPARATOR_PATTERN = /^# ─── /
|
|
36
|
+
|
|
37
|
+
# @param config_path [String] path to the user's config.toml
|
|
38
|
+
# @param template_path [String] path to the default config template
|
|
39
|
+
# @param anima_home [String] expanded path to ~/.anima (for template interpolation)
|
|
40
|
+
def initialize(config_path: File.join(ANIMA_HOME, "config.toml"),
|
|
41
|
+
template_path: TEMPLATE_PATH,
|
|
42
|
+
anima_home: ANIMA_HOME)
|
|
43
|
+
@config_path = Pathname.new(config_path)
|
|
44
|
+
@template_path = Pathname.new(template_path)
|
|
45
|
+
@anima_home = anima_home
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Merge missing settings from the template into the user's config.
|
|
49
|
+
#
|
|
50
|
+
# @return [Result] status (:not_found, :up_to_date, :updated) and additions list
|
|
51
|
+
def run
|
|
52
|
+
return Result.new(status: :not_found, additions: []) unless @config_path.exist?
|
|
53
|
+
|
|
54
|
+
template_text = resolve_template
|
|
55
|
+
template_config = TomlRB.parse(template_text)
|
|
56
|
+
user_config = TomlRB.load_file(@config_path.to_s)
|
|
57
|
+
|
|
58
|
+
additions = find_additions(user_config, template_config)
|
|
59
|
+
return Result.new(status: :up_to_date, additions: []) if additions.empty?
|
|
60
|
+
|
|
61
|
+
apply_additions(additions, template_text)
|
|
62
|
+
Result.new(status: :updated, additions: additions)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Replace template placeholders with actual paths.
|
|
68
|
+
def resolve_template
|
|
69
|
+
File.read(@template_path.to_s).gsub("{{ANIMA_HOME}}") { @anima_home }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Compare user config against template defaults.
|
|
73
|
+
# Returns additions for keys present in template but absent from user config.
|
|
74
|
+
def find_additions(user, template)
|
|
75
|
+
template.flat_map do |section, keys|
|
|
76
|
+
missing_keys_in_section(user, section, keys)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def missing_keys_in_section(user, section, keys)
|
|
81
|
+
keys.filter_map do |key, value|
|
|
82
|
+
next if user.key?(section) && user[section].key?(key)
|
|
83
|
+
|
|
84
|
+
Addition.new(section: section, key: key, value: value)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Write missing settings into the user's config file, preserving existing content.
|
|
89
|
+
def apply_additions(additions, template_text)
|
|
90
|
+
user_text = @config_path.read
|
|
91
|
+
template_blocks = parse_section_blocks(template_text)
|
|
92
|
+
|
|
93
|
+
missing_sections, missing_keys = additions.partition do |addition|
|
|
94
|
+
!user_text.match?(/^\[#{Regexp.escape(addition.section)}\]/)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
user_text = append_missing_sections(user_text, missing_sections, template_blocks)
|
|
98
|
+
user_text = insert_missing_keys(user_text, missing_keys, template_text)
|
|
99
|
+
|
|
100
|
+
@config_path.write(user_text)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def append_missing_sections(user_text, missing_sections, template_blocks)
|
|
104
|
+
missing_sections.map(&:section).uniq.each do |section|
|
|
105
|
+
block = template_blocks[section]
|
|
106
|
+
next unless block
|
|
107
|
+
|
|
108
|
+
user_text = "#{user_text.rstrip}\n\n#{block.rstrip}\n"
|
|
109
|
+
end
|
|
110
|
+
user_text
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def insert_missing_keys(user_text, missing_keys, template_text)
|
|
114
|
+
missing_keys.each do |addition|
|
|
115
|
+
section = addition.section
|
|
116
|
+
key_block = extract_key_block(template_text, section, addition.key)
|
|
117
|
+
next unless key_block
|
|
118
|
+
|
|
119
|
+
user_text = insert_key_in_section(user_text, section, key_block)
|
|
120
|
+
end
|
|
121
|
+
user_text
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Split template into section blocks keyed by TOML section name.
|
|
125
|
+
# Each block spans from its separator comment to the next separator (exclusive).
|
|
126
|
+
def parse_section_blocks(template_text)
|
|
127
|
+
lines = template_text.lines
|
|
128
|
+
separator_indices = lines.each_index.select { |idx| lines[idx].match?(SEPARATOR_PATTERN) }
|
|
129
|
+
block_ranges = build_block_ranges(separator_indices, lines.length)
|
|
130
|
+
|
|
131
|
+
block_ranges.each_with_object({}) do |(start_idx, end_idx), blocks|
|
|
132
|
+
block_lines = lines[start_idx..end_idx]
|
|
133
|
+
section_name = extract_section_name(block_lines)
|
|
134
|
+
blocks[section_name] = block_lines.join if section_name
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Find the TOML section name (e.g. "llm") within a block of lines.
|
|
139
|
+
def extract_section_name(block_lines)
|
|
140
|
+
header = block_lines.find { |line| line.match?(/^\[\w+\]/) }
|
|
141
|
+
header&.match(/^\[(\w+)\]/)&.[](1)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Build [start, end] pairs from separator indices.
|
|
145
|
+
def build_block_ranges(separator_indices, total_lines)
|
|
146
|
+
separator_indices.each_with_index.map do |start_idx, position|
|
|
147
|
+
next_pos = position + 1
|
|
148
|
+
end_idx = (next_pos < separator_indices.length) ? separator_indices[next_pos] - 1 : total_lines - 1
|
|
149
|
+
[start_idx, end_idx]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Extract a single key and its preceding comment lines from the template.
|
|
154
|
+
def extract_key_block(template_text, section, key)
|
|
155
|
+
lines = template_text.lines
|
|
156
|
+
in_section = false
|
|
157
|
+
|
|
158
|
+
lines.each_with_index do |line, line_idx|
|
|
159
|
+
if line.match?(/^\[#{Regexp.escape(section)}\]/)
|
|
160
|
+
in_section = true
|
|
161
|
+
elsif line.match?(/^\[/) && in_section
|
|
162
|
+
break
|
|
163
|
+
elsif in_section && line.match?(/^#{Regexp.escape(key)}\s*=/)
|
|
164
|
+
return build_key_block(lines, line_idx)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Walk backward from a key line to collect preceding comment lines.
|
|
171
|
+
def build_key_block(lines, key_idx)
|
|
172
|
+
comment_start = key_idx
|
|
173
|
+
scan_idx = key_idx - 1
|
|
174
|
+
while scan_idx >= 0 && lines[scan_idx].match?(/^#/)
|
|
175
|
+
comment_start = scan_idx
|
|
176
|
+
scan_idx -= 1
|
|
177
|
+
end
|
|
178
|
+
"\n#{lines[comment_start..key_idx].join}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Insert a key block at the end of an existing section
|
|
182
|
+
# (before the next separator comment or EOF).
|
|
183
|
+
def insert_key_in_section(user_text, section, key_block)
|
|
184
|
+
lines = user_text.lines
|
|
185
|
+
insert_at = find_section_end(lines, section)
|
|
186
|
+
insert_at -= 1 while insert_at > 0 && lines[insert_at - 1].strip.empty?
|
|
187
|
+
|
|
188
|
+
lines.insert(insert_at, key_block)
|
|
189
|
+
lines.join
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Find the line index where a section ends (next separator or section header).
|
|
193
|
+
def find_section_end(lines, section)
|
|
194
|
+
in_section = false
|
|
195
|
+
lines.each_with_index do |line, line_idx|
|
|
196
|
+
if line.match?(/^\[#{Regexp.escape(section)}\]/)
|
|
197
|
+
in_section = true
|
|
198
|
+
elsif in_section && (line.match?(SEPARATOR_PATTERN) || line.match?(/^\[/))
|
|
199
|
+
return line_idx
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
lines.length
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
data/lib/anima/installer.rb
CHANGED
|
@@ -75,124 +75,8 @@ module Anima
|
|
|
75
75
|
config_path = anima_home.join("config.toml")
|
|
76
76
|
return if config_path.exist?
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
#
|
|
81
|
-
# Edit settings below to customize Anima's behavior.
|
|
82
|
-
# Changes take effect immediately — no restart needed.
|
|
83
|
-
|
|
84
|
-
# ─── LLM ───────────────────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
[llm]
|
|
87
|
-
|
|
88
|
-
# Primary model for conversations.
|
|
89
|
-
model = "claude-sonnet-4-20250514"
|
|
90
|
-
|
|
91
|
-
# Lightweight model for fast tasks (e.g. session naming).
|
|
92
|
-
fast_model = "claude-haiku-4-5"
|
|
93
|
-
|
|
94
|
-
# Maximum tokens per LLM response.
|
|
95
|
-
max_tokens = 8192
|
|
96
|
-
|
|
97
|
-
# Maximum consecutive tool execution rounds per request.
|
|
98
|
-
max_tool_rounds = 25
|
|
99
|
-
|
|
100
|
-
# Context window budget — tokens reserved for conversation history.
|
|
101
|
-
# Set this based on your model's context window minus system prompt.
|
|
102
|
-
token_budget = 190_000
|
|
103
|
-
|
|
104
|
-
# ─── Timeouts (seconds) ─────────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
[timeouts]
|
|
107
|
-
|
|
108
|
-
# LLM API request timeout.
|
|
109
|
-
api = 30
|
|
110
|
-
|
|
111
|
-
# Shell command execution timeout.
|
|
112
|
-
command = 30
|
|
113
|
-
|
|
114
|
-
# MCP server response timeout.
|
|
115
|
-
mcp_response = 60
|
|
116
|
-
|
|
117
|
-
# Web fetch request timeout.
|
|
118
|
-
web_request = 10
|
|
119
|
-
|
|
120
|
-
# ─── Shell ──────────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
[shell]
|
|
123
|
-
|
|
124
|
-
# Maximum bytes of command output before truncation.
|
|
125
|
-
max_output_bytes = 100_000
|
|
126
|
-
|
|
127
|
-
# ─── Tools ──────────────────────────────────────────────────────
|
|
128
|
-
|
|
129
|
-
[tools]
|
|
130
|
-
|
|
131
|
-
# Maximum file size for read/edit operations (bytes).
|
|
132
|
-
max_file_size = 10_485_760
|
|
133
|
-
|
|
134
|
-
# Maximum lines returned by the read tool.
|
|
135
|
-
max_read_lines = 2_000
|
|
136
|
-
|
|
137
|
-
# Maximum bytes returned by the read tool.
|
|
138
|
-
max_read_bytes = 50_000
|
|
139
|
-
|
|
140
|
-
# Maximum bytes from web GET responses.
|
|
141
|
-
max_web_response_bytes = 100_000
|
|
142
|
-
|
|
143
|
-
# ─── Environment ──────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
[environment]
|
|
146
|
-
|
|
147
|
-
# Files to scan for in the working directory (at root and up to project_files_max_depth subdirectories deep).
|
|
148
|
-
project_files = ["CLAUDE.md", "AGENTS.md", "README.md", "CONTRIBUTING.md"]
|
|
149
|
-
|
|
150
|
-
# Maximum directory depth for project file scanning.
|
|
151
|
-
project_files_max_depth = 3
|
|
152
|
-
|
|
153
|
-
# ─── GitHub ─────────────────────────────────────────────────────
|
|
154
|
-
|
|
155
|
-
[github]
|
|
156
|
-
|
|
157
|
-
# Repository for agent feature requests (owner/repo format).
|
|
158
|
-
# Falls back to parsing git remote origin when unset.
|
|
159
|
-
repo = "hoblin/anima"
|
|
160
|
-
|
|
161
|
-
# Label applied to agent-created feature request issues.
|
|
162
|
-
label = "anima-wants"
|
|
163
|
-
|
|
164
|
-
# ─── Paths ─────────────────────────────────────────────────────
|
|
165
|
-
|
|
166
|
-
[paths]
|
|
167
|
-
|
|
168
|
-
# The agent's self-authored identity file.
|
|
169
|
-
soul = "#{anima_home.join("soul.md")}"
|
|
170
|
-
|
|
171
|
-
# ─── Session ────────────────────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
[session]
|
|
174
|
-
|
|
175
|
-
# Regenerate session name every N messages.
|
|
176
|
-
name_generation_interval = 30
|
|
177
|
-
|
|
178
|
-
# ─── Analytical Brain ─────────────────────────────────────────
|
|
179
|
-
|
|
180
|
-
[analytical_brain]
|
|
181
|
-
|
|
182
|
-
# Maximum tokens per analytical brain response.
|
|
183
|
-
# Must accommodate multiple tool calls (rename + goals + skills + ready).
|
|
184
|
-
max_tokens = 4096
|
|
185
|
-
|
|
186
|
-
# Run the analytical brain synchronously before the main agent on user messages.
|
|
187
|
-
# Ensures activated skills are available for the current response.
|
|
188
|
-
blocking_on_user_message = true
|
|
189
|
-
|
|
190
|
-
# Run the analytical brain asynchronously after the main agent completes.
|
|
191
|
-
blocking_on_agent_message = false
|
|
192
|
-
|
|
193
|
-
# Number of recent events to include in the analytical brain's context window.
|
|
194
|
-
event_window = 20
|
|
195
|
-
TOML
|
|
78
|
+
template = File.read(File.join(TEMPLATE_DIR, "config.toml"))
|
|
79
|
+
config_path.write(template.gsub("{{ANIMA_HOME}}") { anima_home.to_s })
|
|
196
80
|
say " created #{config_path}"
|
|
197
81
|
end
|
|
198
82
|
|
|
@@ -231,19 +115,22 @@ module Anima
|
|
|
231
115
|
|
|
232
116
|
next if key_path.exist? && content_path.exist?
|
|
233
117
|
|
|
118
|
+
content_str = content_path.to_s
|
|
119
|
+
key_str = key_path.to_s
|
|
120
|
+
|
|
234
121
|
key = ActiveSupport::EncryptedFile.generate_key
|
|
235
122
|
key_path.write(key)
|
|
236
|
-
File.chmod(0o600,
|
|
123
|
+
File.chmod(0o600, key_str)
|
|
237
124
|
|
|
238
125
|
config = ActiveSupport::EncryptedConfiguration.new(
|
|
239
|
-
config_path:
|
|
240
|
-
key_path:
|
|
126
|
+
config_path: content_str,
|
|
127
|
+
key_path: key_str,
|
|
241
128
|
env_key: "RAILS_MASTER_KEY",
|
|
242
129
|
raise_if_missing_key: true
|
|
243
130
|
)
|
|
244
131
|
|
|
245
132
|
config.write("secret_key_base: #{SecureRandom.hex(64)}\n")
|
|
246
|
-
File.chmod(0o600,
|
|
133
|
+
File.chmod(0o600, content_str)
|
|
247
134
|
say " created credentials for #{env}"
|
|
248
135
|
end
|
|
249
136
|
end
|
|
@@ -269,16 +156,12 @@ module Anima
|
|
|
269
156
|
WantedBy=default.target
|
|
270
157
|
UNIT
|
|
271
158
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
else
|
|
276
|
-
service_path.write(unit_content)
|
|
277
|
-
say " updated #{service_path}"
|
|
278
|
-
end
|
|
159
|
+
already_exists = service_path.exist?
|
|
160
|
+
if already_exists && service_path.read == unit_content
|
|
161
|
+
say " anima.service unchanged"
|
|
279
162
|
else
|
|
280
163
|
service_path.write(unit_content)
|
|
281
|
-
say " created #{service_path}"
|
|
164
|
+
say " #{already_exists ? "updated" : "created"} #{service_path}"
|
|
282
165
|
end
|
|
283
166
|
|
|
284
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.
|
|
@@ -191,7 +232,7 @@ module Anima
|
|
|
191
232
|
value = config.dig(section, key)
|
|
192
233
|
if value.nil?
|
|
193
234
|
raise MissingSettingError,
|
|
194
|
-
"[#{section}] #{key} is not set in #{config_path}. Run `anima
|
|
235
|
+
"[#{section}] #{key} is not set in #{config_path}. Run `anima update` to add missing settings."
|
|
195
236
|
end
|
|
196
237
|
value
|
|
197
238
|
end
|
data/lib/anima/version.rb
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Transient failure event emitted when LLM delivery fails inside the
|
|
5
|
+
# Bounce Back transaction. The user event record is rolled back, and
|
|
6
|
+
# this event notifies clients to remove the phantom message and
|
|
7
|
+
# restore the text to the input field.
|
|
8
|
+
#
|
|
9
|
+
# Not persisted — not included in {Event::TYPES}.
|
|
10
|
+
class BounceBack < Base
|
|
11
|
+
TYPE = "bounce_back"
|
|
12
|
+
|
|
13
|
+
# @return [String] human-readable error description
|
|
14
|
+
attr_reader :error
|
|
15
|
+
|
|
16
|
+
# @return [Integer, nil] database ID of the rolled-back event (for client-side removal)
|
|
17
|
+
attr_reader :event_id
|
|
18
|
+
|
|
19
|
+
# @param content [String] original user message text to restore to input
|
|
20
|
+
# @param error [String] error description for the flash message
|
|
21
|
+
# @param session_id [Integer] session the message was intended for
|
|
22
|
+
# @param event_id [Integer, nil] ID of the event that was broadcast optimistically
|
|
23
|
+
def initialize(content:, error:, session_id:, event_id: nil)
|
|
24
|
+
super(content: content, session_id: session_id)
|
|
25
|
+
@error = error
|
|
26
|
+
@event_id = event_id
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def type
|
|
30
|
+
TYPE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
super.merge(error: error, event_id: event_id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Reacts to non-pending {Events::UserMessage} emissions by scheduling
|
|
6
|
+
# {AgentRequestJob}. This is the event-driven bridge between the
|
|
7
|
+
# channel (which emits the intent) and the job (which persists and
|
|
8
|
+
# delivers the message).
|
|
9
|
+
#
|
|
10
|
+
# Pending messages are skipped — they are picked up by the running
|
|
11
|
+
# agent loop after it finishes the current turn.
|
|
12
|
+
class AgentDispatcher
|
|
13
|
+
include Events::Subscriber
|
|
14
|
+
|
|
15
|
+
# @param event [Hash] Rails.event notification hash
|
|
16
|
+
def emit(event)
|
|
17
|
+
payload = event[:payload]
|
|
18
|
+
return unless payload.is_a?(Hash)
|
|
19
|
+
return unless payload[:type] == "user_message"
|
|
20
|
+
return if payload[:status] == Event::PENDING_STATUS
|
|
21
|
+
|
|
22
|
+
session_id = payload[:session_id]
|
|
23
|
+
return unless session_id
|
|
24
|
+
|
|
25
|
+
AgentRequestJob.perform_later(session_id, content: payload[:content])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -27,6 +27,12 @@ module Events
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# Receives a Rails.event notification hash and persists it.
|
|
30
|
+
#
|
|
31
|
+
# Skips non-pending user messages — those are persisted by
|
|
32
|
+
# {AgentRequestJob} inside a transaction with LLM delivery
|
|
33
|
+
# (Bounce Back, #236). Also skips event types not in {Event::TYPES}
|
|
34
|
+
# (transient events like {Events::BounceBack}).
|
|
35
|
+
#
|
|
30
36
|
# @param event [Hash] with :payload containing event data
|
|
31
37
|
def emit(event)
|
|
32
38
|
payload = event[:payload]
|
|
@@ -34,6 +40,8 @@ module Events
|
|
|
34
40
|
|
|
35
41
|
event_type = payload[:type]
|
|
36
42
|
return if event_type.nil?
|
|
43
|
+
return unless Event::TYPES.include?(event_type)
|
|
44
|
+
return if persisted_by_job?(event_type, payload)
|
|
37
45
|
|
|
38
46
|
target_session = @session || Session.find_by(id: payload[:session_id])
|
|
39
47
|
return unless target_session
|
|
@@ -52,6 +60,15 @@ module Events
|
|
|
52
60
|
def session=(new_session)
|
|
53
61
|
@mutex.synchronize { @session = new_session }
|
|
54
62
|
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Non-pending user messages are persisted by {AgentRequestJob} inside
|
|
67
|
+
# a transaction with LLM delivery. Pending messages are still
|
|
68
|
+
# auto-persisted here because they queue while the session is busy.
|
|
69
|
+
def persisted_by_job?(event_type, payload)
|
|
70
|
+
event_type == "user_message" && payload[:status] != Event::PENDING_STATUS
|
|
71
|
+
end
|
|
55
72
|
end
|
|
56
73
|
end
|
|
57
74
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Routes text messages between parent and child sessions, enabling
|
|
6
|
+
# bidirectional @mention communication.
|
|
7
|
+
#
|
|
8
|
+
# **Child → Parent:** When a sub-agent emits an {Events::AgentMessage},
|
|
9
|
+
# the router persists a {Events::UserMessage} in the parent session
|
|
10
|
+
# with attribution prefix, then wakes the parent via {AgentRequestJob}.
|
|
11
|
+
#
|
|
12
|
+
# **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
|
|
13
|
+
# containing `@name` mentions, the router persists the message in each
|
|
14
|
+
# matching child session and wakes them via {AgentRequestJob}.
|
|
15
|
+
#
|
|
16
|
+
# Both directions use direct persistence + job enqueue (same pattern as
|
|
17
|
+
# {Tools::SpawnSubagent#spawn_child}) to avoid conflicts with the global
|
|
18
|
+
# {Persister} which skips non-pending user messages.
|
|
19
|
+
#
|
|
20
|
+
# This replaces the +return_result+ tool — sub-agents communicate
|
|
21
|
+
# through natural text messages instead of structured tool calls.
|
|
22
|
+
class SubagentMessageRouter
|
|
23
|
+
include Events::Subscriber
|
|
24
|
+
|
|
25
|
+
# Attribution prefix format for messages routed from child to parent.
|
|
26
|
+
# @example "[sub-agent @loop-sleuth]: Here's what I found..."
|
|
27
|
+
ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
|
|
28
|
+
|
|
29
|
+
# Regex to extract @mention names from parent agent messages.
|
|
30
|
+
MENTION_PATTERN = /@(\w[\w-]*)/
|
|
31
|
+
|
|
32
|
+
# Routes agent text messages between parent and child sessions.
|
|
33
|
+
#
|
|
34
|
+
# For sub-agent sessions: forwards to parent with attribution prefix.
|
|
35
|
+
# For parent sessions: scans for @mentions and routes to matching children.
|
|
36
|
+
#
|
|
37
|
+
# @param event [Hash] Rails.event notification hash with +:payload+ containing
|
|
38
|
+
# an +agent_message+ event (type, session_id, content)
|
|
39
|
+
# @return [void]
|
|
40
|
+
def emit(event)
|
|
41
|
+
payload = event[:payload]
|
|
42
|
+
return unless payload.is_a?(Hash)
|
|
43
|
+
return unless payload[:type] == "agent_message"
|
|
44
|
+
|
|
45
|
+
session_id = payload[:session_id]
|
|
46
|
+
return unless session_id
|
|
47
|
+
|
|
48
|
+
content = payload[:content].to_s
|
|
49
|
+
return if content.empty?
|
|
50
|
+
|
|
51
|
+
session = Session.find_by(id: session_id)
|
|
52
|
+
return unless session
|
|
53
|
+
|
|
54
|
+
if session.sub_agent?
|
|
55
|
+
route_to_parent(session, content)
|
|
56
|
+
else
|
|
57
|
+
route_mentions_to_children(session, content)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Forwards a sub-agent's text message to its parent session.
|
|
64
|
+
# Persists directly and enqueues a job so the parent agent wakes
|
|
65
|
+
# up to process the message.
|
|
66
|
+
#
|
|
67
|
+
# @param child [Session] the sub-agent session
|
|
68
|
+
# @param content [String] the sub-agent's message text
|
|
69
|
+
def route_to_parent(child, content)
|
|
70
|
+
parent = child.parent_session
|
|
71
|
+
return unless parent
|
|
72
|
+
|
|
73
|
+
name = child.name || "agent-#{child.id}"
|
|
74
|
+
attributed = format(ATTRIBUTION_FORMAT, name, content)
|
|
75
|
+
|
|
76
|
+
parent.create_user_event(attributed)
|
|
77
|
+
AgentRequestJob.perform_later(parent.id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Scans a parent agent's message for @mentions and routes the message
|
|
81
|
+
# to each mentioned child session.
|
|
82
|
+
#
|
|
83
|
+
# @param parent [Session] the parent session
|
|
84
|
+
# @param content [String] the parent agent's message text
|
|
85
|
+
def route_mentions_to_children(parent, content)
|
|
86
|
+
mentioned_names = content.scan(MENTION_PATTERN).flatten.uniq
|
|
87
|
+
return if mentioned_names.empty?
|
|
88
|
+
|
|
89
|
+
active_children = parent.child_sessions.where.not(name: nil).index_by(&:name)
|
|
90
|
+
return if active_children.empty?
|
|
91
|
+
|
|
92
|
+
mentioned_names.each do |name|
|
|
93
|
+
child = active_children[name]
|
|
94
|
+
next unless child
|
|
95
|
+
|
|
96
|
+
child.create_user_event(content)
|
|
97
|
+
AgentRequestJob.perform_later(child.id)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Bridges transient (non-persisted) events to ActionCable so clients
|
|
6
|
+
# receive them over WebSocket. Persisted events reach clients via
|
|
7
|
+
# {Event::Broadcasting} callbacks; this subscriber handles events
|
|
8
|
+
# that never touch the database.
|
|
9
|
+
#
|
|
10
|
+
# @example Registering at boot
|
|
11
|
+
# Events::Bus.subscribe(Events::Subscribers::TransientBroadcaster.new)
|
|
12
|
+
class TransientBroadcaster
|
|
13
|
+
include Events::Subscriber
|
|
14
|
+
|
|
15
|
+
# Event types that are broadcast without persistence.
|
|
16
|
+
TRANSIENT_TYPES = [Events::BounceBack::TYPE].freeze
|
|
17
|
+
|
|
18
|
+
# @param event [Hash] Rails.event notification hash
|
|
19
|
+
def emit(event)
|
|
20
|
+
payload = event[:payload]
|
|
21
|
+
return unless payload.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
event_type = payload[:type]
|
|
24
|
+
return unless TRANSIENT_TYPES.include?(event_type)
|
|
25
|
+
|
|
26
|
+
session_id = payload[:session_id]
|
|
27
|
+
return unless session_id
|
|
28
|
+
|
|
29
|
+
ActionCable.server.broadcast(
|
|
30
|
+
"session_#{session_id}",
|
|
31
|
+
payload.transform_keys(&:to_s)
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|