anima-core 1.0.1 → 1.0.2
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/.reek.yml +14 -0
- data/CHANGELOG.md +1 -0
- data/README.md +159 -107
- data/app/channels/session_channel.rb +15 -0
- data/app/decorators/agent_message_decorator.rb +6 -0
- data/app/decorators/event_decorator.rb +41 -7
- data/app/decorators/tool_call_decorator.rb +59 -2
- data/app/decorators/tool_response_decorator.rb +24 -2
- data/app/decorators/user_message_decorator.rb +6 -0
- data/app/jobs/agent_request_job.rb +8 -0
- data/db/migrate/20260316094817_add_interrupt_requested_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +8 -2
- data/lib/analytical_brain/runner.rb +1 -19
- data/lib/anima/cli.rb +32 -0
- data/lib/anima/config_migrator.rb +205 -0
- data/lib/anima/installer.rb +2 -118
- data/lib/anima/settings.rb +1 -1
- data/lib/anima/version.rb +1 -1
- data/lib/llm/client.rb +83 -6
- data/lib/tools/think.rb +57 -0
- data/lib/tui/app.rb +8 -2
- data/lib/tui/cable_client.rb +8 -0
- data/lib/tui/screens/chat.rb +40 -0
- data/templates/config.toml +116 -0
- metadata +5 -1
|
@@ -5,21 +5,37 @@
|
|
|
5
5
|
# aggregated tool counter instead. Verbose mode returns tool name
|
|
6
6
|
# and a formatted preview of the input arguments. Debug mode shows
|
|
7
7
|
# full untruncated input as pretty-printed JSON with tool_use_id.
|
|
8
|
+
#
|
|
9
|
+
# Think tool calls are special: "aloud" thoughts are shown in all
|
|
10
|
+
# view modes (with a thought bubble), while "inner" thoughts are
|
|
11
|
+
# visible only in verbose and debug modes.
|
|
8
12
|
class ToolCallDecorator < EventDecorator
|
|
9
|
-
|
|
13
|
+
THINK_TOOL = "think"
|
|
14
|
+
|
|
15
|
+
# In basic mode, only "aloud" think calls are visible.
|
|
16
|
+
# All other tool calls are hidden (represented by the tool counter).
|
|
17
|
+
#
|
|
18
|
+
# @return [Hash, nil] structured think data for aloud thoughts, nil otherwise
|
|
10
19
|
def render_basic
|
|
11
|
-
|
|
20
|
+
return unless think?
|
|
21
|
+
return unless aloud?
|
|
22
|
+
|
|
23
|
+
{role: :think, content: thoughts, visibility: "aloud"}
|
|
12
24
|
end
|
|
13
25
|
|
|
14
26
|
# @return [Hash] structured tool call data
|
|
15
27
|
# `{role: :tool_call, tool: String, input: String, timestamp: Integer|nil}`
|
|
16
28
|
def render_verbose
|
|
29
|
+
return render_think_verbose if think?
|
|
30
|
+
|
|
17
31
|
{role: :tool_call, tool: payload["tool_name"], input: format_input, timestamp: timestamp}
|
|
18
32
|
end
|
|
19
33
|
|
|
20
34
|
# @return [Hash] full tool call data with untruncated input and tool_use_id
|
|
21
35
|
# `{role: :tool_call, tool: String, input: String, tool_use_id: String|nil, timestamp: Integer|nil}`
|
|
22
36
|
def render_debug
|
|
37
|
+
return render_think_debug if think?
|
|
38
|
+
|
|
23
39
|
{
|
|
24
40
|
role: :tool_call,
|
|
25
41
|
tool: payload["tool_name"],
|
|
@@ -29,8 +45,49 @@ class ToolCallDecorator < EventDecorator
|
|
|
29
45
|
}
|
|
30
46
|
end
|
|
31
47
|
|
|
48
|
+
# Think calls get full text — the agent's reasoning IS the signal.
|
|
49
|
+
# Other tool calls show tool name + params (compact JSON).
|
|
50
|
+
# @return [String] transcript line for the analytical brain
|
|
51
|
+
def render_brain
|
|
52
|
+
if think?
|
|
53
|
+
"Think: #{thoughts}"
|
|
54
|
+
else
|
|
55
|
+
"Tool call: #{payload["tool_name"]}(#{tool_input.to_json})"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
32
59
|
private
|
|
33
60
|
|
|
61
|
+
def think?
|
|
62
|
+
payload["tool_name"] == THINK_TOOL
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def aloud?
|
|
66
|
+
tool_input.dig("visibility") == "aloud"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def thoughts
|
|
70
|
+
tool_input.dig("thoughts").to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def tool_input
|
|
74
|
+
payload["tool_input"] || {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def visibility
|
|
78
|
+
tool_input.dig("visibility") || "inner"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Hash] think event for verbose mode — both inner and aloud visible
|
|
82
|
+
def render_think_verbose
|
|
83
|
+
{role: :think, content: thoughts, visibility: visibility, timestamp: timestamp}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Hash] think event for debug mode — full metadata
|
|
87
|
+
def render_think_debug
|
|
88
|
+
{role: :think, content: thoughts, visibility: visibility, tool_use_id: payload["tool_use_id"], timestamp: timestamp}
|
|
89
|
+
end
|
|
90
|
+
|
|
34
91
|
# Formats tool input for display, with tool-specific formatting for
|
|
35
92
|
# known tools and generic JSON fallback for others.
|
|
36
93
|
# @return [String] formatted input preview
|
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
# aggregated tool counter instead. Verbose mode returns truncated
|
|
6
6
|
# output with a success/failure indicator. Debug mode shows full
|
|
7
7
|
# untruncated output with tool_use_id and estimated token count.
|
|
8
|
+
#
|
|
9
|
+
# Think tool responses ("OK") are hidden in basic and verbose modes
|
|
10
|
+
# because the value is in the tool_call (the thoughts), not the response.
|
|
8
11
|
class ToolResponseDecorator < EventDecorator
|
|
12
|
+
THINK_TOOL = "think"
|
|
13
|
+
|
|
9
14
|
# @return [nil] tool responses are hidden in basic mode
|
|
10
15
|
def render_basic
|
|
11
16
|
nil
|
|
12
17
|
end
|
|
13
18
|
|
|
14
|
-
#
|
|
15
|
-
#
|
|
19
|
+
# Think responses are hidden in verbose mode — the "OK" adds no information.
|
|
20
|
+
# @return [Hash, nil] structured tool response data, nil for think responses
|
|
16
21
|
def render_verbose
|
|
22
|
+
return if think?
|
|
23
|
+
|
|
17
24
|
{
|
|
18
25
|
role: :tool_response,
|
|
19
26
|
content: truncate_lines(content, max_lines: 3),
|
|
@@ -34,4 +41,19 @@ class ToolResponseDecorator < EventDecorator
|
|
|
34
41
|
timestamp: timestamp
|
|
35
42
|
}.merge(token_info)
|
|
36
43
|
end
|
|
44
|
+
|
|
45
|
+
# Think responses ("OK") are noise — excluded from the brain's transcript.
|
|
46
|
+
# Other tool responses are compressed to success/failure indicators only.
|
|
47
|
+
# @return [String, nil] ✅ or ❌ indicator, nil for think responses
|
|
48
|
+
def render_brain
|
|
49
|
+
return if think?
|
|
50
|
+
|
|
51
|
+
(payload["success"] != false) ? "\u2705" : "\u274C"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def think?
|
|
57
|
+
payload["tool_name"] == THINK_TOOL
|
|
58
|
+
end
|
|
37
59
|
end
|
|
@@ -26,6 +26,12 @@ class UserMessageDecorator < EventDecorator
|
|
|
26
26
|
render_verbose.merge(token_info)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# @return [String] user message for the analytical brain, middle-truncated
|
|
30
|
+
# if very long (preserves intent at start and conclusion at end)
|
|
31
|
+
def render_brain
|
|
32
|
+
"User: #{truncate_middle(content)}"
|
|
33
|
+
end
|
|
34
|
+
|
|
29
35
|
private
|
|
30
36
|
|
|
31
37
|
# @return [Boolean] true when this message is queued but not yet sent to LLM
|
|
@@ -64,6 +64,7 @@ class AgentRequestJob < ApplicationJob
|
|
|
64
64
|
session.schedule_analytical_brain!
|
|
65
65
|
ensure
|
|
66
66
|
release_processing(session_id)
|
|
67
|
+
clear_interrupt(session_id)
|
|
67
68
|
agent_loop&.finalize
|
|
68
69
|
end
|
|
69
70
|
|
|
@@ -96,6 +97,13 @@ class AgentRequestJob < ApplicationJob
|
|
|
96
97
|
Session.where(id: session_id).update_all(processing: false)
|
|
97
98
|
end
|
|
98
99
|
|
|
100
|
+
# Safety-net clearing of the interrupt flag. The primary clear happens in
|
|
101
|
+
# {LLM::Client#clear_interrupt!} after handling the interrupt; this ensures
|
|
102
|
+
# the flag is reset even if the job crashes before reaching that code path.
|
|
103
|
+
def clear_interrupt(session_id)
|
|
104
|
+
Session.where(id: session_id, interrupt_requested: true).update_all(interrupt_requested: false)
|
|
105
|
+
end
|
|
106
|
+
|
|
99
107
|
# Emits a system message before each retry so the user sees
|
|
100
108
|
# "retrying..." instead of nothing.
|
|
101
109
|
def retry_job(options = {})
|
data/lib/agent_loop.rb
CHANGED
|
@@ -65,7 +65,11 @@ class AgentLoop
|
|
|
65
65
|
# propagate — designed for callers like {AgentRequestJob} that handle
|
|
66
66
|
# retries and need errors to bubble up.
|
|
67
67
|
#
|
|
68
|
-
#
|
|
68
|
+
# When the user interrupts, +chat_with_tools+ returns nil. Tool results
|
|
69
|
+
# are already persisted; no agent message is emitted so the conversation
|
|
70
|
+
# ends at the interrupted tool result.
|
|
71
|
+
#
|
|
72
|
+
# @return [String, nil] the agent's response text, or nil when interrupted
|
|
69
73
|
# @raise [Providers::Anthropic::TransientError] on retryable network/server errors
|
|
70
74
|
# @raise [Providers::Anthropic::AuthenticationError] on auth failures
|
|
71
75
|
def run
|
|
@@ -82,6 +86,8 @@ class AgentLoop
|
|
|
82
86
|
options[:system] = prompt if prompt
|
|
83
87
|
|
|
84
88
|
response = @client.chat_with_tools(messages, registry: @registry, session_id: @session.id, **options)
|
|
89
|
+
return unless response
|
|
90
|
+
|
|
85
91
|
Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
|
|
86
92
|
response
|
|
87
93
|
end
|
|
@@ -94,7 +100,7 @@ class AgentLoop
|
|
|
94
100
|
|
|
95
101
|
# Tool classes available to all sessions by default.
|
|
96
102
|
# @return [Array<Class<Tools::Base>>]
|
|
97
|
-
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet].freeze
|
|
103
|
+
STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think].freeze
|
|
98
104
|
|
|
99
105
|
# Name-to-class mapping for tool restriction validation and registry building.
|
|
100
106
|
# @return [Hash{String => Class<Tools::Base>}]
|
|
@@ -129,7 +129,7 @@ module AnalyticalBrain
|
|
|
129
129
|
events = recent_events
|
|
130
130
|
return [] if events.empty?
|
|
131
131
|
|
|
132
|
-
transcript = events.filter_map { |event|
|
|
132
|
+
transcript = events.filter_map { |event| EventDecorator.for(event)&.render("brain") }.join("\n")
|
|
133
133
|
content = <<~MSG.strip
|
|
134
134
|
The main session is working on this:
|
|
135
135
|
```
|
|
@@ -151,24 +151,6 @@ module AnalyticalBrain
|
|
|
151
151
|
.reverse
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
-
# Formats a single event for the analytical brain's transcript.
|
|
155
|
-
# User/agent messages get 500 chars to preserve conversation context;
|
|
156
|
-
# tool responses get 200 chars to reduce noise from verbose outputs.
|
|
157
|
-
#
|
|
158
|
-
# @param event [Event]
|
|
159
|
-
# @return [String, nil] formatted line, or nil for unhandled event types
|
|
160
|
-
def format_event(event)
|
|
161
|
-
payload = event.payload
|
|
162
|
-
summary = payload["content"].to_s.truncate(500)
|
|
163
|
-
|
|
164
|
-
case event.event_type
|
|
165
|
-
when "user_message" then "User: #{summary}"
|
|
166
|
-
when "agent_message" then "Assistant: #{summary}"
|
|
167
|
-
when "tool_call" then "Tool call: #{payload["tool_name"]}"
|
|
168
|
-
when "tool_response" then "Tool result: #{summary.truncate(200)}"
|
|
169
|
-
end
|
|
170
|
-
end
|
|
171
|
-
|
|
172
154
|
# Builds the system prompt with current session state, skills catalog,
|
|
173
155
|
# and currently active skills.
|
|
174
156
|
#
|
data/lib/anima/cli.rb
CHANGED
|
@@ -20,6 +20,38 @@ module Anima
|
|
|
20
20
|
Installer.new.run
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
desc "update", "Upgrade gem and migrate config"
|
|
24
|
+
option :migrate_only, type: :boolean, default: false, desc: "Skip gem upgrade, only migrate config"
|
|
25
|
+
def update
|
|
26
|
+
unless options[:migrate_only]
|
|
27
|
+
say "Upgrading anima-core gem..."
|
|
28
|
+
unless system("gem", "update", "anima-core")
|
|
29
|
+
say "Gem update failed.", :red
|
|
30
|
+
exit 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Re-exec with the updated gem so migration uses the new template.
|
|
34
|
+
exec(File.join(Gem.bindir, "anima"), "update", "--migrate-only")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
say "Migrating configuration..."
|
|
38
|
+
require_relative "config_migrator"
|
|
39
|
+
result = Anima::ConfigMigrator.new.run
|
|
40
|
+
|
|
41
|
+
case result.status
|
|
42
|
+
when :not_found
|
|
43
|
+
say "Config file not found. Run 'anima install' first.", :red
|
|
44
|
+
exit 1
|
|
45
|
+
when :up_to_date
|
|
46
|
+
say "Config is already up to date."
|
|
47
|
+
when :updated
|
|
48
|
+
result.additions.each do |addition|
|
|
49
|
+
say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
|
|
50
|
+
end
|
|
51
|
+
say "Config updated. Changes take effect immediately — no restart needed."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
23
55
|
# Start the Anima brain server (Puma + Solid Queue) via Foreman.
|
|
24
56
|
# Environment precedence: -e flag > RAILS_ENV env var > "development".
|
|
25
57
|
# Requires prior installation (~/.anima must exist).
|
|
@@ -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
|
|
data/lib/anima/settings.rb
CHANGED
|
@@ -191,7 +191,7 @@ module Anima
|
|
|
191
191
|
value = config.dig(section, key)
|
|
192
192
|
if value.nil?
|
|
193
193
|
raise MissingSettingError,
|
|
194
|
-
"[#{section}] #{key} is not set in #{config_path}. Run `anima
|
|
194
|
+
"[#{section}] #{key} is not set in #{config_path}. Run `anima update` to add missing settings."
|
|
195
195
|
end
|
|
196
196
|
value
|
|
197
197
|
end
|
data/lib/anima/version.rb
CHANGED