anima-core 1.0.2 → 1.1.1
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 +51 -0
- data/README.md +63 -29
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +30 -11
- data/app/decorators/tool_call_decorator.rb +32 -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 +93 -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 +4 -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 +402 -6
- data/app/models/snapshot.rb +76 -0
- data/bin/jobs +5 -0
- data/config/initializers/event_subscribers.rb +12 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/config/queue.yml +0 -1
- 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 +32 -9
- data/lib/anima/installer.rb +11 -24
- data/lib/anima/settings.rb +59 -0
- data/lib/anima/spinner.rb +75 -0
- data/lib/anima/version.rb +1 -1
- data/lib/environment_probe.rb +4 -4
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/persister.rb +19 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/events/tool_call.rb +5 -3
- data/lib/llm/client.rb +19 -9
- 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 +194 -63
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/base.rb +2 -1
- data/lib/tools/bash.rb +4 -2
- data/lib/tools/registry.rb +22 -3
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/request_feature.rb +3 -1
- 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 +21 -10
- 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 +97 -8
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +47 -0
- data/templates/soul.md +1 -1
- metadata +83 -4
- data/CHANGELOG.md +0 -80
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
data/lib/shell_session.rb
CHANGED
|
@@ -3,12 +3,20 @@
|
|
|
3
3
|
require "io/console"
|
|
4
4
|
require "pty"
|
|
5
5
|
require "securerandom"
|
|
6
|
-
require "
|
|
6
|
+
require "shellwords"
|
|
7
7
|
|
|
8
8
|
# Persistent shell session backed by a PTY with FIFO-based stderr separation.
|
|
9
9
|
# Commands share working directory, environment variables, and shell history
|
|
10
10
|
# within a conversation. Multiple tools share the same session.
|
|
11
11
|
#
|
|
12
|
+
# Auto-recovers from timeouts and crashes: if the shell dies, the next command
|
|
13
|
+
# transparently respawns a fresh shell and restores the working directory.
|
|
14
|
+
#
|
|
15
|
+
# Uses IO.select-based deadlines instead of Timeout.timeout for all PTY reads.
|
|
16
|
+
# Timeout.timeout is unsafe with PTY I/O — it uses Thread.raise which can
|
|
17
|
+
# corrupt mutex state, leave resources inconsistent, and cause exceptions
|
|
18
|
+
# to fire outside handler blocks when nested.
|
|
19
|
+
#
|
|
12
20
|
# @example
|
|
13
21
|
# session = ShellSession.new(session_id: 42)
|
|
14
22
|
# session.run("cd /tmp")
|
|
@@ -25,27 +33,39 @@ class ShellSession
|
|
|
25
33
|
@mutex = Mutex.new
|
|
26
34
|
@fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
|
|
27
35
|
@alive = false
|
|
36
|
+
@finalized = false
|
|
28
37
|
@pwd = nil
|
|
38
|
+
@read_buffer = +""
|
|
29
39
|
self.class.cleanup_orphans
|
|
30
40
|
start
|
|
31
41
|
self.class.register(self)
|
|
32
42
|
end
|
|
33
43
|
|
|
34
|
-
# Execute a command in the persistent shell.
|
|
44
|
+
# Execute a command in the persistent shell. Respawns the shell
|
|
45
|
+
# automatically if the previous session died (timeout, crash, etc.).
|
|
35
46
|
#
|
|
36
47
|
# @param command [String] bash command to execute
|
|
48
|
+
# @param timeout [Integer, nil] per-call timeout in seconds; overrides
|
|
49
|
+
# Settings.command_timeout when provided
|
|
37
50
|
# @return [Hash] with :stdout, :stderr, :exit_code keys on success
|
|
38
51
|
# @return [Hash] with :error key on failure
|
|
39
|
-
def run(command)
|
|
52
|
+
def run(command, timeout: nil)
|
|
40
53
|
@mutex.synchronize do
|
|
41
|
-
return {error: "Shell session is not running"}
|
|
42
|
-
|
|
54
|
+
return {error: "Shell session is not running"} if @finalized
|
|
55
|
+
restart unless @alive
|
|
56
|
+
execute_in_pty(command, timeout: timeout)
|
|
43
57
|
end
|
|
58
|
+
rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
|
|
59
|
+
{error: "#{error.class}: #{error.message}"}
|
|
44
60
|
end
|
|
45
61
|
|
|
46
|
-
# Clean up PTY, FIFO, and child process.
|
|
62
|
+
# Clean up PTY, FIFO, and child process. Permanent — the session
|
|
63
|
+
# will not auto-respawn after this call.
|
|
47
64
|
def finalize
|
|
48
|
-
@mutex.synchronize
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@finalized = true
|
|
67
|
+
teardown
|
|
68
|
+
end
|
|
49
69
|
self.class.unregister(self)
|
|
50
70
|
end
|
|
51
71
|
|
|
@@ -73,7 +93,7 @@ class ShellSession
|
|
|
73
93
|
# Finalize all live sessions. Called automatically via at_exit.
|
|
74
94
|
def cleanup_all
|
|
75
95
|
@sessions_mutex.synchronize do
|
|
76
|
-
@sessions.each { |session| session.send(:
|
|
96
|
+
@sessions.each { |session| session.send(:teardown) }
|
|
77
97
|
@sessions.clear
|
|
78
98
|
end
|
|
79
99
|
end
|
|
@@ -116,15 +136,54 @@ class ShellSession
|
|
|
116
136
|
@alive = true
|
|
117
137
|
end
|
|
118
138
|
|
|
139
|
+
# Shuts down the current shell and spawns a fresh one, restoring the
|
|
140
|
+
# previous working directory. Called automatically when @alive is false.
|
|
141
|
+
def restart
|
|
142
|
+
saved_pwd = @pwd
|
|
143
|
+
teardown
|
|
144
|
+
@fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
|
|
145
|
+
start
|
|
146
|
+
restore_working_directory(saved_pwd)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Restores the shell's working directory after a respawn.
|
|
150
|
+
# Skips silently if the directory no longer exists.
|
|
151
|
+
#
|
|
152
|
+
# @param saved_pwd [String, nil] directory path to restore
|
|
153
|
+
# @return [void]
|
|
154
|
+
def restore_working_directory(saved_pwd)
|
|
155
|
+
return unless saved_pwd && File.directory?(saved_pwd)
|
|
156
|
+
execute_in_pty("cd #{Shellwords.shellescape(saved_pwd)}")
|
|
157
|
+
end
|
|
158
|
+
|
|
119
159
|
def create_fifo
|
|
120
|
-
File.mkfifo(@fifo_path)
|
|
160
|
+
File.mkfifo(@fifo_path, 0o600)
|
|
121
161
|
rescue Errno::EEXIST
|
|
122
162
|
# FIFO already exists — reuse it
|
|
123
163
|
end
|
|
124
164
|
|
|
165
|
+
# Env vars that prevent interactive pagers and credential prompts from
|
|
166
|
+
# hanging the PTY. We need a PTY (not pipes) for pwd tracking via /proc
|
|
167
|
+
# and signal handling, but this makes programs think they're on a terminal
|
|
168
|
+
# and launch pagers. No single switch disables all pagers — each tool has
|
|
169
|
+
# its own env var — so we set a comprehensive list plus LESS flags as a
|
|
170
|
+
# safety net for direct `less` invocations.
|
|
171
|
+
SHELL_ENV = {
|
|
172
|
+
"TERM" => "dumb",
|
|
173
|
+
"PAGER" => "cat", # Default pager for most Unix tools
|
|
174
|
+
"LESS" => "-eFRX", # Safety net: make less auto-exit at EOF, no screen clear
|
|
175
|
+
"GIT_PAGER" => "cat", # Git checks this before PAGER
|
|
176
|
+
"MANPAGER" => "cat", # man pages
|
|
177
|
+
"SYSTEMD_PAGER" => "", # journalctl, systemctl (empty = disable)
|
|
178
|
+
"BAT_PAGER" => "cat", # bat (cat alternative)
|
|
179
|
+
"AWS_PAGER" => "", # AWS CLI v2 (empty = disable)
|
|
180
|
+
"PSQL_PAGER" => "cat", # PostgreSQL psql
|
|
181
|
+
"GIT_TERMINAL_PROMPT" => "0" # Fail immediately instead of prompting for credentials
|
|
182
|
+
}.freeze
|
|
183
|
+
|
|
125
184
|
def spawn_shell
|
|
126
185
|
@pty_stdout, @pty_stdin, @pid = PTY.spawn(
|
|
127
|
-
|
|
186
|
+
SHELL_ENV,
|
|
128
187
|
"bash", "--norc", "--noprofile"
|
|
129
188
|
)
|
|
130
189
|
# Disable terminal echo via termios before bash can echo our commands.
|
|
@@ -165,45 +224,57 @@ class ShellSession
|
|
|
165
224
|
@pty_stdin.puts "PS1=''"
|
|
166
225
|
@pty_stdin.puts "exec 2>#{@fifo_path}"
|
|
167
226
|
@pty_stdin.puts "echo '#{marker}'"
|
|
168
|
-
consume_until(marker)
|
|
227
|
+
unless consume_until(marker, deadline: monotonic_now + 10)
|
|
228
|
+
raise IOError, "Shell initialization timed out"
|
|
229
|
+
end
|
|
169
230
|
end
|
|
170
231
|
|
|
171
|
-
def execute_in_pty(command)
|
|
232
|
+
def execute_in_pty(command, timeout: nil)
|
|
172
233
|
clear_stderr
|
|
173
234
|
marker = "__ANIMA_#{SecureRandom.hex(8)}__"
|
|
174
|
-
timeout
|
|
235
|
+
timeout ||= Anima::Settings.command_timeout
|
|
236
|
+
deadline = monotonic_now + timeout
|
|
175
237
|
|
|
176
|
-
|
|
177
|
-
# All on one line: run command, capture exit code, ensure newline
|
|
178
|
-
# before marker so output without trailing newline doesn't merge.
|
|
179
|
-
@pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
|
|
238
|
+
@pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
|
|
180
239
|
|
|
181
|
-
|
|
182
|
-
update_pwd
|
|
183
|
-
stderr = drain_stderr
|
|
240
|
+
stdout, exit_code = read_until_marker(marker, deadline: deadline)
|
|
184
241
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
242
|
+
if exit_code.nil?
|
|
243
|
+
recover_from_timeout
|
|
244
|
+
stderr = drain_stderr
|
|
245
|
+
parts = ["Command timed out after #{timeout} seconds."]
|
|
246
|
+
parts << "Partial stdout:\n#{truncate(stdout)}" unless stdout.empty?
|
|
247
|
+
parts << "stderr:\n#{truncate(stderr)}" unless stderr.empty?
|
|
248
|
+
return {error: parts.join("\n\n")}
|
|
190
249
|
end
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
250
|
+
|
|
251
|
+
update_pwd
|
|
252
|
+
stderr = drain_stderr
|
|
253
|
+
|
|
254
|
+
{
|
|
255
|
+
stdout: truncate(stdout),
|
|
256
|
+
stderr: truncate(stderr),
|
|
257
|
+
exit_code: exit_code
|
|
258
|
+
}
|
|
259
|
+
rescue Errno::EIO, IOError
|
|
195
260
|
@alive = false
|
|
196
261
|
{error: "Shell session terminated unexpectedly"}
|
|
197
|
-
rescue => error
|
|
262
|
+
rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
|
|
198
263
|
{error: "#{error.class}: #{error.message}"}
|
|
199
264
|
end
|
|
200
265
|
|
|
201
|
-
|
|
266
|
+
# Reads lines from the PTY until the marker appears.
|
|
267
|
+
#
|
|
268
|
+
# @param marker [String] unique marker to detect command completion
|
|
269
|
+
# @param deadline [Float] monotonic clock deadline
|
|
270
|
+
# @return [Array(String, Integer)] stdout and exit code on success
|
|
271
|
+
# @return [Array(String, nil)] partial stdout and nil exit code on timeout
|
|
272
|
+
def read_until_marker(marker, deadline:)
|
|
202
273
|
lines = []
|
|
203
274
|
exit_code = nil
|
|
204
275
|
|
|
205
276
|
loop do
|
|
206
|
-
line =
|
|
277
|
+
line = gets_with_deadline(deadline)
|
|
207
278
|
break if line.nil?
|
|
208
279
|
|
|
209
280
|
line = line.chomp.delete("\r")
|
|
@@ -219,26 +290,70 @@ class ShellSession
|
|
|
219
290
|
# Strip trailing empty line added by our separator echo
|
|
220
291
|
lines.pop if lines.last == ""
|
|
221
292
|
|
|
222
|
-
[lines.join("\n"), exit_code
|
|
293
|
+
[lines.join("\n"), exit_code]
|
|
223
294
|
end
|
|
224
295
|
|
|
225
|
-
|
|
296
|
+
# Reads and discards PTY output until the marker appears or deadline expires.
|
|
297
|
+
#
|
|
298
|
+
# @param marker [String] unique marker to wait for
|
|
299
|
+
# @param deadline [Float] monotonic clock deadline
|
|
300
|
+
# @return [Boolean] true if marker was found, false if deadline expired
|
|
301
|
+
# @raise [Errno::EIO] when the PTY child process has exited
|
|
302
|
+
# @raise [IOError] when the PTY file descriptor is closed
|
|
303
|
+
def consume_until(marker, deadline:)
|
|
226
304
|
loop do
|
|
227
|
-
line =
|
|
228
|
-
|
|
229
|
-
|
|
305
|
+
line = gets_with_deadline(deadline)
|
|
306
|
+
return false if line.nil?
|
|
307
|
+
return true if line.chomp.delete("\r").include?(marker)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Reads a single line from the PTY, respecting a deadline.
|
|
312
|
+
# Caller must hold @mutex — @read_buffer is not independently synchronized.
|
|
313
|
+
#
|
|
314
|
+
# Uses IO.select for safe, non-interruptive timeout handling instead of
|
|
315
|
+
# Timeout.timeout (which uses Thread.raise that can corrupt mutex state
|
|
316
|
+
# and leave resources inconsistent).
|
|
317
|
+
#
|
|
318
|
+
# @param deadline [Float] monotonic clock deadline
|
|
319
|
+
# @return [String] line including trailing newline
|
|
320
|
+
# @return [nil] if deadline expired
|
|
321
|
+
# @raise [Errno::EIO] when the PTY child process exits (Linux)
|
|
322
|
+
# @raise [IOError] when the PTY file descriptor is closed
|
|
323
|
+
def gets_with_deadline(deadline)
|
|
324
|
+
loop do
|
|
325
|
+
if (idx = @read_buffer.index("\n"))
|
|
326
|
+
return @read_buffer.slice!(0..idx)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
remaining = deadline - monotonic_now
|
|
330
|
+
return nil if remaining <= 0
|
|
331
|
+
|
|
332
|
+
ready = IO.select([@pty_stdout], nil, nil, remaining)
|
|
333
|
+
return nil unless ready
|
|
334
|
+
|
|
335
|
+
begin
|
|
336
|
+
@read_buffer << @pty_stdout.read_nonblock(4096)
|
|
337
|
+
rescue IO::WaitReadable
|
|
338
|
+
# Spurious wakeup from IO.select — retry
|
|
339
|
+
end
|
|
230
340
|
end
|
|
231
341
|
end
|
|
232
342
|
|
|
233
343
|
# Sends Ctrl+C to interrupt the running command and drains leftover output.
|
|
234
|
-
# If recovery fails, marks the session as dead.
|
|
344
|
+
# If recovery fails, marks the session as dead (will be respawned on next run).
|
|
345
|
+
#
|
|
346
|
+
# @return [void]
|
|
347
|
+
# @raise [Errno::EIO] when the PTY child process has exited
|
|
348
|
+
# @raise [IOError] when the PTY file descriptor is closed
|
|
235
349
|
def recover_from_timeout
|
|
236
350
|
@pty_stdin.write("\x03")
|
|
237
351
|
sleep 0.1
|
|
238
352
|
marker = "__ANIMA_RECOVER_#{SecureRandom.hex(8)}__"
|
|
239
353
|
@pty_stdin.puts "echo '#{marker}'"
|
|
240
|
-
|
|
241
|
-
|
|
354
|
+
recovered = consume_until(marker, deadline: monotonic_now + 3)
|
|
355
|
+
@alive = false unless recovered
|
|
356
|
+
rescue Errno::EIO, IOError
|
|
242
357
|
@alive = false
|
|
243
358
|
end
|
|
244
359
|
|
|
@@ -283,15 +398,26 @@ class ShellSession
|
|
|
283
398
|
"\n\n[Truncated: output exceeded #{max_bytes} bytes]"
|
|
284
399
|
end
|
|
285
400
|
|
|
286
|
-
def
|
|
287
|
-
|
|
288
|
-
|
|
401
|
+
def monotonic_now
|
|
402
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
403
|
+
end
|
|
289
404
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
405
|
+
# Unconditionally cleans up all shell resources (PTY, FIFO, child process).
|
|
406
|
+
# Does NOT short-circuit when @alive is already false — this ensures leaked
|
|
407
|
+
# processes are reaped even after failed recovery marked the session dead.
|
|
408
|
+
#
|
|
409
|
+
# @return [void]
|
|
410
|
+
def teardown
|
|
411
|
+
@alive = false
|
|
412
|
+
@read_buffer = +""
|
|
413
|
+
|
|
414
|
+
if @pid
|
|
415
|
+
begin
|
|
416
|
+
pgid = Process.getpgid(@pid)
|
|
417
|
+
Process.kill("TERM", -pgid)
|
|
418
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
419
|
+
# Process group already gone
|
|
420
|
+
end
|
|
295
421
|
end
|
|
296
422
|
|
|
297
423
|
begin
|
|
@@ -307,28 +433,33 @@ class ShellSession
|
|
|
307
433
|
end
|
|
308
434
|
|
|
309
435
|
begin
|
|
436
|
+
@stderr_thread&.join(1)
|
|
310
437
|
@stderr_thread&.kill
|
|
311
438
|
rescue ThreadError
|
|
312
439
|
# Thread already dead
|
|
313
440
|
end
|
|
314
441
|
|
|
315
|
-
File.delete(@fifo_path) if File.exist?(@fifo_path)
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
442
|
+
File.delete(@fifo_path) if @fifo_path && File.exist?(@fifo_path)
|
|
443
|
+
|
|
444
|
+
if @pid
|
|
445
|
+
begin
|
|
446
|
+
# Non-blocking reap with SIGKILL fallback if process doesn't exit in time
|
|
447
|
+
deadline = monotonic_now + 2
|
|
448
|
+
loop do
|
|
449
|
+
_, status = Process.wait2(@pid, Process::WNOHANG)
|
|
450
|
+
break if status
|
|
451
|
+
if monotonic_now > deadline
|
|
452
|
+
Process.kill("KILL", @pid)
|
|
453
|
+
Process.wait(@pid)
|
|
454
|
+
break
|
|
455
|
+
end
|
|
456
|
+
sleep 0.05
|
|
327
457
|
end
|
|
328
|
-
|
|
458
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
459
|
+
# Already reaped
|
|
329
460
|
end
|
|
330
|
-
|
|
331
|
-
|
|
461
|
+
|
|
462
|
+
@pid = nil
|
|
332
463
|
end
|
|
333
464
|
end
|
|
334
465
|
end
|
data/lib/tasks/fts5.rake
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# No custom Rake tasks needed — FTS5 virtual tables are handled by:
|
|
4
|
+
# - Migration: creates/drops the FTS5 table and triggers
|
|
5
|
+
# - Initializer (config/initializers/fts5_schema_dump.rb): skips virtual
|
|
6
|
+
# tables during schema dump since they can't be expressed in Ruby DSL
|
data/lib/tools/base.rb
CHANGED
|
@@ -49,7 +49,8 @@ module Tools
|
|
|
49
49
|
def initialize(**) = nil
|
|
50
50
|
|
|
51
51
|
# Execute the tool with the given input.
|
|
52
|
-
# @param input [Hash] parsed input matching {.input_schema}
|
|
52
|
+
# @param input [Hash] parsed input matching {.input_schema}. May include
|
|
53
|
+
# a "timeout" key (seconds) for tools that support per-call timeout overrides.
|
|
53
54
|
# @return [String, Hash] result content; Hash with :error key signals failure
|
|
54
55
|
def execute(input)
|
|
55
56
|
raise NotImplementedError, "#{self.class} must implement #execute"
|
data/lib/tools/bash.rb
CHANGED
|
@@ -27,14 +27,16 @@ module Tools
|
|
|
27
27
|
@shell_session = shell_session
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
|
|
30
|
+
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
|
|
31
|
+
# Supports optional "timeout" key (seconds) to override the global
|
|
32
|
+
# command_timeout setting for long-running operations.
|
|
31
33
|
# @return [String] formatted output with stdout, stderr, and exit code
|
|
32
34
|
# @return [Hash] with :error key on failure
|
|
33
35
|
def execute(input)
|
|
34
36
|
command = input["command"].to_s
|
|
35
37
|
return {error: "Command cannot be blank"} if command.strip.empty?
|
|
36
38
|
|
|
37
|
-
result = @shell_session.run(command)
|
|
39
|
+
result = @shell_session.run(command, timeout: input["timeout"])
|
|
38
40
|
return result if result.key?(:error)
|
|
39
41
|
|
|
40
42
|
format_result(result[:stdout], result[:stderr], result[:exit_code])
|
data/lib/tools/registry.rb
CHANGED
|
@@ -30,16 +30,21 @@ module Tools
|
|
|
30
30
|
@tools[tool.tool_name] = tool
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# @return [Array<Hash>] schema array for the Anthropic tools API parameter
|
|
33
|
+
# @return [Array<Hash>] schema array for the Anthropic tools API parameter.
|
|
34
|
+
# Each schema includes an optional `timeout` parameter (seconds) injected
|
|
35
|
+
# by the registry. The agent can override the default per call for
|
|
36
|
+
# long-running operations.
|
|
34
37
|
def schemas
|
|
35
|
-
|
|
38
|
+
default = Anima::Settings.tool_timeout
|
|
39
|
+
@tools.values.map { |tool| inject_timeout(tool.schema, default) }
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
# Execute a tool by name. Classes are instantiated with the registry's
|
|
39
43
|
# context; instances are called directly.
|
|
40
44
|
#
|
|
41
45
|
# @param name [String] registered tool name
|
|
42
|
-
# @param input [Hash] tool input parameters
|
|
46
|
+
# @param input [Hash] tool input parameters (may include "timeout" for
|
|
47
|
+
# tools that support per-call timeout overrides)
|
|
43
48
|
# @return [String, Hash] tool execution result
|
|
44
49
|
# @raise [UnknownToolError] if no tool is registered with the given name
|
|
45
50
|
def execute(name, input)
|
|
@@ -58,5 +63,19 @@ module Tools
|
|
|
58
63
|
def any?
|
|
59
64
|
@tools.any?
|
|
60
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Injects an optional `timeout` parameter into the tool's input schema.
|
|
70
|
+
def inject_timeout(schema, default)
|
|
71
|
+
s = schema.deep_dup
|
|
72
|
+
s[:input_schema] ||= {type: "object", properties: {}}
|
|
73
|
+
s[:input_schema][:properties] ||= {}
|
|
74
|
+
s[:input_schema][:properties]["timeout"] = {
|
|
75
|
+
type: "integer",
|
|
76
|
+
description: "Max execution seconds (default: #{default}). Increase for long-running operations."
|
|
77
|
+
}
|
|
78
|
+
s
|
|
79
|
+
end
|
|
61
80
|
end
|
|
62
81
|
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tools
|
|
4
|
+
# Fractal-resolution zoom into event history. Returns a window centered
|
|
5
|
+
# on a target event with full detail at the center and compressed context
|
|
6
|
+
# at the edges — sharp fovea, blurry periphery.
|
|
7
|
+
#
|
|
8
|
+
# Output structure:
|
|
9
|
+
# [Previous snapshots — compressed context before]
|
|
10
|
+
# [Events N-M — full detail, tool_responses compressed to checkmarks]
|
|
11
|
+
# [Following snapshots — compressed context after]
|
|
12
|
+
#
|
|
13
|
+
# The agent discovers target events via FTS5 search results embedded in
|
|
14
|
+
# viewport recall snippets. This tool drills down into the full context.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# remember(event_id: 42)
|
|
18
|
+
class Remember < Base
|
|
19
|
+
# Events around the target to include at full resolution.
|
|
20
|
+
# ±10 events provides sharp foveal detail while keeping output readable.
|
|
21
|
+
CONTEXT_WINDOW = 20
|
|
22
|
+
|
|
23
|
+
ROLE_LABELS = {
|
|
24
|
+
"user_message" => "User",
|
|
25
|
+
"agent_message" => "Assistant",
|
|
26
|
+
"system_message" => "System"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def self.tool_name = "remember"
|
|
30
|
+
|
|
31
|
+
def self.description
|
|
32
|
+
"Recall the full context around a past event. " \
|
|
33
|
+
"Returns a fractal-resolution window: high detail at the center " \
|
|
34
|
+
"(the target event and its neighbors), compressed context at the edges " \
|
|
35
|
+
"(snapshots before and after). Use this when search results surface " \
|
|
36
|
+
"a relevant event and you need the surrounding conversation."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.input_schema
|
|
40
|
+
{
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
event_id: {
|
|
44
|
+
type: "integer",
|
|
45
|
+
description: "The event ID to zoom into (from search results or recall snippets)"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
required: ["event_id"]
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(session:, **)
|
|
53
|
+
@session = session
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param input [Hash] with "event_id"
|
|
57
|
+
# @return [String] fractal-resolution window around the target event
|
|
58
|
+
def execute(input)
|
|
59
|
+
event_id = input["event_id"].to_i
|
|
60
|
+
target = Event.find_by(id: event_id)
|
|
61
|
+
return {error: "Event #{event_id} not found"} unless target
|
|
62
|
+
|
|
63
|
+
build_fractal_window(target)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Assembles the three-zone fractal window.
|
|
69
|
+
#
|
|
70
|
+
# @param target [Event] the center event
|
|
71
|
+
# @return [String] formatted fractal window
|
|
72
|
+
def build_fractal_window(target)
|
|
73
|
+
target_session = target.session
|
|
74
|
+
center_events = fetch_center_events(target, target_session)
|
|
75
|
+
first_center_id = center_events.first&.id
|
|
76
|
+
last_center_id = center_events.last&.id
|
|
77
|
+
|
|
78
|
+
sections = build_sections(
|
|
79
|
+
target_session: target_session,
|
|
80
|
+
center_events: center_events,
|
|
81
|
+
target_id: target.id,
|
|
82
|
+
first_center_id: first_center_id,
|
|
83
|
+
last_center_id: last_center_id
|
|
84
|
+
)
|
|
85
|
+
sections.join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Builds ordered sections: header, before snapshots, center, after snapshots.
|
|
89
|
+
def build_sections(target_session:, center_events:, target_id:, first_center_id:, last_center_id:)
|
|
90
|
+
sections = [session_header(target_session)]
|
|
91
|
+
|
|
92
|
+
append_snapshot_sections(sections, target_session.snapshots
|
|
93
|
+
.where("to_event_id < ?", first_center_id)
|
|
94
|
+
.chronological.last(3), label: "PREVIOUS CONTEXT")
|
|
95
|
+
|
|
96
|
+
sections << "── FULL CONTEXT (events #{first_center_id}..#{last_center_id}) ──"
|
|
97
|
+
center_events.each { |event| sections << render_center_event(event, target_id) }
|
|
98
|
+
|
|
99
|
+
append_snapshot_sections(sections, target_session.snapshots
|
|
100
|
+
.where("from_event_id > ?", last_center_id)
|
|
101
|
+
.chronological.first(3), label: "FOLLOWING CONTEXT")
|
|
102
|
+
|
|
103
|
+
sections
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def session_header(target_session)
|
|
107
|
+
label = target_session.name || "Session ##{target_session.id}"
|
|
108
|
+
"── recalled from: #{label} ──"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Appends snapshot sections if any exist.
|
|
112
|
+
def append_snapshot_sections(sections, snapshots, label:)
|
|
113
|
+
return if snapshots.empty?
|
|
114
|
+
|
|
115
|
+
sections << "── #{label} (compressed) ──"
|
|
116
|
+
snapshots.each { |snapshot| sections << format_snapshot(snapshot) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Fetches conversation events around the target within a fixed window.
|
|
120
|
+
#
|
|
121
|
+
# @return [Array<Event>] chronologically ordered
|
|
122
|
+
def fetch_center_events(target, target_session)
|
|
123
|
+
half = CONTEXT_WINDOW / 2
|
|
124
|
+
scope = target_session.events.context_events.deliverable
|
|
125
|
+
target_id = target.id
|
|
126
|
+
|
|
127
|
+
before = scope.where("id <= ?", target_id).reorder(id: :desc).limit(half + 1).to_a.reverse
|
|
128
|
+
after = scope.where("id > ?", target_id).reorder(id: :asc).limit(half).to_a
|
|
129
|
+
|
|
130
|
+
before + after
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Renders a center event at full resolution.
|
|
134
|
+
# Conversation events show full content. Tool calls show name + input.
|
|
135
|
+
# Tool responses compressed to status indicator.
|
|
136
|
+
#
|
|
137
|
+
# @param event [Event]
|
|
138
|
+
# @param target_id [Integer] the event being zoomed into (marked with arrow)
|
|
139
|
+
# @return [String]
|
|
140
|
+
def render_center_event(event, target_id)
|
|
141
|
+
marker = (event.id == target_id) ? "→" : " "
|
|
142
|
+
prefix = "#{marker} event #{event.id}"
|
|
143
|
+
|
|
144
|
+
"#{prefix} #{format_event_content(event)}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Formats event content based on type.
|
|
148
|
+
def format_event_content(event)
|
|
149
|
+
data = event.payload
|
|
150
|
+
content = data["content"]
|
|
151
|
+
|
|
152
|
+
if ROLE_LABELS.key?(event.event_type)
|
|
153
|
+
"#{ROLE_LABELS[event.event_type]}: #{content}"
|
|
154
|
+
elsif event.event_type == "tool_call"
|
|
155
|
+
format_tool_call(data)
|
|
156
|
+
elsif event.event_type == "tool_response"
|
|
157
|
+
status = content.to_s.start_with?("Error") ? "error" : "ok"
|
|
158
|
+
"ToolResult: [#{status}] #{data["tool_use_id"]}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def format_tool_call(data)
|
|
163
|
+
if data["tool_name"] == Event::THINK_TOOL
|
|
164
|
+
"Think: #{data.dig("tool_input", "thoughts")}"
|
|
165
|
+
else
|
|
166
|
+
"Tool: #{data["tool_name"]}(#{data["tool_input"].to_json.truncate(200)})"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Formats a snapshot as compressed context.
|
|
171
|
+
#
|
|
172
|
+
# @param snapshot [Snapshot]
|
|
173
|
+
# @return [String]
|
|
174
|
+
def format_snapshot(snapshot)
|
|
175
|
+
level = (snapshot.level == 2) ? "L2" : "L1"
|
|
176
|
+
"[#{level} snapshot, events #{snapshot.from_event_id}..#{snapshot.to_event_id}]\n#{snapshot.text}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -71,7 +71,9 @@ module Tools
|
|
|
71
71
|
|
|
72
72
|
# @return [String, nil] owner/repo parsed from +git remote get-url origin+
|
|
73
73
|
def git_remote_repo
|
|
74
|
-
url,
|
|
74
|
+
url, status = Open3.capture2("git", "remote", "get-url", "origin", err: File::NULL)
|
|
75
|
+
return unless status.success?
|
|
76
|
+
|
|
75
77
|
parse_owner_repo(url.strip)
|
|
76
78
|
rescue Errno::ENOENT
|
|
77
79
|
nil
|