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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +51 -0
  4. data/README.md +63 -29
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +30 -11
  7. data/app/decorators/tool_call_decorator.rb +32 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +93 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +4 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +402 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/bin/jobs +5 -0
  22. data/config/initializers/event_subscribers.rb +12 -3
  23. data/config/initializers/fts5_schema_dump.rb +21 -0
  24. data/config/queue.yml +0 -1
  25. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  26. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  27. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  28. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  29. data/lib/agent_loop.rb +63 -20
  30. data/lib/analytical_brain/runner.rb +158 -65
  31. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  32. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  33. data/lib/anima/cli.rb +32 -9
  34. data/lib/anima/installer.rb +11 -24
  35. data/lib/anima/settings.rb +59 -0
  36. data/lib/anima/spinner.rb +75 -0
  37. data/lib/anima/version.rb +1 -1
  38. data/lib/environment_probe.rb +4 -4
  39. data/lib/events/bounce_back.rb +37 -0
  40. data/lib/events/subscribers/persister.rb +19 -0
  41. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  42. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  43. data/lib/events/tool_call.rb +5 -3
  44. data/lib/llm/client.rb +19 -9
  45. data/lib/mneme/compressed_viewport.rb +200 -0
  46. data/lib/mneme/l2_runner.rb +138 -0
  47. data/lib/mneme/passive_recall.rb +69 -0
  48. data/lib/mneme/runner.rb +254 -0
  49. data/lib/mneme/search.rb +150 -0
  50. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  51. data/lib/mneme/tools/everything_ok.rb +24 -0
  52. data/lib/mneme/tools/save_snapshot.rb +68 -0
  53. data/lib/mneme.rb +29 -0
  54. data/lib/providers/anthropic.rb +57 -13
  55. data/lib/shell_session.rb +194 -63
  56. data/lib/tasks/fts5.rake +6 -0
  57. data/lib/tools/base.rb +2 -1
  58. data/lib/tools/bash.rb +4 -2
  59. data/lib/tools/registry.rb +22 -3
  60. data/lib/tools/remember.rb +179 -0
  61. data/lib/tools/request_feature.rb +3 -1
  62. data/lib/tools/spawn_specialist.rb +21 -9
  63. data/lib/tools/spawn_subagent.rb +22 -11
  64. data/lib/tools/subagent_prompts.rb +20 -3
  65. data/lib/tools/web_get.rb +21 -10
  66. data/lib/tui/app.rb +222 -125
  67. data/lib/tui/decorators/base_decorator.rb +165 -0
  68. data/lib/tui/decorators/bash_decorator.rb +20 -0
  69. data/lib/tui/decorators/edit_decorator.rb +19 -0
  70. data/lib/tui/decorators/read_decorator.rb +24 -0
  71. data/lib/tui/decorators/think_decorator.rb +36 -0
  72. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  73. data/lib/tui/decorators/write_decorator.rb +19 -0
  74. data/lib/tui/flash.rb +139 -0
  75. data/lib/tui/formatting.rb +28 -0
  76. data/lib/tui/height_map.rb +93 -0
  77. data/lib/tui/message_store.rb +97 -8
  78. data/lib/tui/performance_logger.rb +90 -0
  79. data/lib/tui/screens/chat.rb +358 -133
  80. data/templates/config.toml +47 -0
  81. data/templates/soul.md +1 -1
  82. metadata +83 -4
  83. data/CHANGELOG.md +0 -80
  84. data/Gemfile +0 -17
  85. 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 "timeout"
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"} unless @alive
42
- execute_in_pty(command)
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 { shutdown }
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(:shutdown) }
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
- {"TERM" => "dumb"},
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 = Anima::Settings.command_timeout
235
+ timeout ||= Anima::Settings.command_timeout
236
+ deadline = monotonic_now + timeout
175
237
 
176
- Timeout.timeout(timeout) do
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
- stdout, exit_code = read_until_marker(marker)
182
- update_pwd
183
- stderr = drain_stderr
240
+ stdout, exit_code = read_until_marker(marker, deadline: deadline)
184
241
 
185
- {
186
- stdout: truncate(stdout),
187
- stderr: truncate(stderr),
188
- exit_code: exit_code
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
- rescue Timeout::Error
192
- recover_from_timeout
193
- {error: "Command timed out after #{timeout} seconds"}
194
- rescue Errno::EIO
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
- def read_until_marker(marker)
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 = @pty_stdout.gets
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 || -1]
293
+ [lines.join("\n"), exit_code]
223
294
  end
224
295
 
225
- def consume_until(marker)
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 = @pty_stdout.gets
228
- break if line.nil?
229
- break if line.chomp.delete("\r").include?(marker)
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
- Timeout.timeout(3) { consume_until(marker) }
241
- rescue Timeout::Error, Errno::EIO, IOError
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 shutdown
287
- return unless @alive
288
- @alive = false
401
+ def monotonic_now
402
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
403
+ end
289
404
 
290
- begin
291
- pgid = Process.getpgid(@pid)
292
- Process.kill("TERM", -pgid)
293
- rescue Errno::ESRCH, Errno::EPERM
294
- # Process group already gone
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
- begin
318
- # Non-blocking reap with SIGKILL fallback if process doesn't exit in time
319
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
320
- loop do
321
- _, status = Process.wait2(@pid, Process::WNOHANG)
322
- break if status
323
- if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
324
- Process.kill("KILL", @pid)
325
- Process.wait(@pid)
326
- break
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
- sleep 0.05
458
+ rescue Errno::ECHILD, Errno::ESRCH
459
+ # Already reaped
329
460
  end
330
- rescue Errno::ECHILD, Errno::ESRCH
331
- # Already reaped
461
+
462
+ @pid = nil
332
463
  end
333
464
  end
334
465
  end
@@ -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])
@@ -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
- @tools.values.map(&:schema)
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, _status = Open3.capture2("git", "remote", "get-url", "origin")
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