samagotchi 0.65.0 → 0.67.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.
Files changed (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +50 -28
  6. data/docs/cli.md +14 -2
  7. data/docs/configuration.md +101 -16
  8. data/docs/context.md +39 -2
  9. data/docs/desktop.md +90 -22
  10. data/docs/guardrails.md +8 -5
  11. data/docs/hooks.md +2 -2
  12. data/docs/internals/anthropic.md +156 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bundles.md +2 -0
  15. data/docs/internals/engine.md +75 -30
  16. data/docs/internals/gemma4-contract.md +1 -1
  17. data/docs/internals/llm-context-bench.md +5 -4
  18. data/docs/internals/prompt-caching.md +36 -13
  19. data/docs/memory.md +1 -1
  20. data/docs/plugins.md +289 -25
  21. data/docs/releasing.md +1 -1
  22. data/docs/sessions.md +16 -6
  23. data/docs/sub-agent.md +6 -1
  24. data/docs/testing.md +17 -3
  25. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  26. data/lib/samagotchi/bootstrap_command.rb +13 -3
  27. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  28. data/lib/samagotchi/bridge.rb +6 -3
  29. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
  31. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  33. data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
  34. data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
  35. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
  36. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  37. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
  38. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  39. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  40. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  44. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  45. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  46. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  47. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  48. data/lib/samagotchi/cache_confirm.rb +9 -6
  49. data/lib/samagotchi/commands/registry.rb +25 -8
  50. data/lib/samagotchi/config.rb +23 -8
  51. data/lib/samagotchi/context_absorber.rb +66 -13
  52. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  53. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  54. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  55. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  56. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  57. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  58. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  59. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  60. data/lib/samagotchi/desktop/macos.rb +84 -13
  61. data/lib/samagotchi/desktop_command.rb +14 -3
  62. data/lib/samagotchi/engine.rb +120 -107
  63. data/lib/samagotchi/engine_peers.rb +3 -0
  64. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  65. data/lib/samagotchi/guardrails/approval.rb +1 -1
  66. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  67. data/lib/samagotchi/guardrails/rules.rb +1 -1
  68. data/lib/samagotchi/guardrails/targets.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +151 -0
  70. data/lib/samagotchi/hooks/registry.rb +2 -2
  71. data/lib/samagotchi/host_keys.rb +147 -0
  72. data/lib/samagotchi/kernel_loop.rb +9 -1
  73. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  74. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  75. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  76. data/lib/samagotchi/llm/api_key.rb +23 -7
  77. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  78. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  79. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  80. data/lib/samagotchi/llm_context_override.rb +1 -0
  81. data/lib/samagotchi/peer_sources.rb +58 -0
  82. data/lib/samagotchi/plugin/api.rb +138 -156
  83. data/lib/samagotchi/plugin/context.rb +22 -1
  84. data/lib/samagotchi/plugin/loader.rb +5 -2
  85. data/lib/samagotchi/plugin/peer.rb +394 -0
  86. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  87. data/lib/samagotchi/plugin_tasks.rb +2 -2
  88. data/lib/samagotchi/row_refs.rb +1 -1
  89. data/lib/samagotchi/self_report.rb +5 -3
  90. data/lib/samagotchi/send_command.rb +41 -15
  91. data/lib/samagotchi/session.rb +18 -1
  92. data/lib/samagotchi/session_commands.rb +16 -1
  93. data/lib/samagotchi/session_continue.rb +58 -15
  94. data/lib/samagotchi/session_inbox.rb +0 -13
  95. data/lib/samagotchi/session_manager.rb +4 -1
  96. data/lib/samagotchi/session_setup.rb +14 -7
  97. data/lib/samagotchi/system_prompt.rb +16 -1
  98. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  99. data/lib/samagotchi/terminal_ui.rb +18 -2
  100. data/lib/samagotchi/tool_call_parser.rb +1 -1
  101. data/lib/samagotchi/tool_runner.rb +13 -1
  102. data/lib/samagotchi/toolkit.rb +246 -0
  103. data/lib/samagotchi/toolkit_binding.rb +138 -0
  104. data/lib/samagotchi/toolkit_command.rb +117 -0
  105. data/lib/samagotchi/tools/context_read.rb +14 -0
  106. data/lib/samagotchi/tools/execute.rb +12 -12
  107. data/lib/samagotchi/tools/registry.rb +43 -6
  108. data/lib/samagotchi/tools/task_create.rb +6 -6
  109. data/lib/samagotchi/tools/task_get.rb +13 -13
  110. data/lib/samagotchi/tools/task_list.rb +6 -6
  111. data/lib/samagotchi/tools/task_record.rb +52 -0
  112. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  113. data/lib/samagotchi/tools/task_stop.rb +6 -6
  114. data/lib/samagotchi/tools/task_wait.rb +12 -13
  115. data/lib/samagotchi/tools/tool_index.rb +454 -0
  116. data/lib/samagotchi/update_command.rb +9 -6
  117. data/lib/samagotchi/version.rb +1 -1
  118. data/lib/samagotchi/web/app.rb +28 -9
  119. data/lib/samagotchi/web/capabilities.rb +1 -1
  120. data/lib/samagotchi/web/public/activity.js +2 -2
  121. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  122. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  123. data/lib/samagotchi/web/public/app.js +52 -43
  124. data/lib/samagotchi/web/public/chains.js +1 -1
  125. data/lib/samagotchi/web/public/commands.js +6 -4
  126. data/lib/samagotchi/web/public/composer_size.js +2 -2
  127. data/lib/samagotchi/web/public/copy.js +1 -1
  128. data/lib/samagotchi/web/public/ctx.js +1 -1
  129. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  130. data/lib/samagotchi/web/public/families_view.js +1 -1
  131. data/lib/samagotchi/web/public/format.js +11 -7
  132. data/lib/samagotchi/web/public/images.js +25 -2
  133. data/lib/samagotchi/web/public/index.html +9 -0
  134. data/lib/samagotchi/web/public/info_bar.js +5 -3
  135. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  136. data/lib/samagotchi/web/public/notify.js +6 -4
  137. data/lib/samagotchi/web/public/question_card.js +1 -1
  138. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  139. data/lib/samagotchi/web/public/session_state.js +17 -0
  140. data/lib/samagotchi/web/public/session_title.js +2 -3
  141. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  142. data/lib/samagotchi/web/public/stage_model.js +2 -2
  143. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  144. data/lib/samagotchi/web/public/strip.js +3 -3
  145. data/lib/samagotchi/web/public/task_stop.js +3 -2
  146. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  147. data/lib/samagotchi/web/public/turn_events.js +1 -1
  148. data/lib/samagotchi/web/public/turn_html.js +2 -2
  149. data/lib/samagotchi/web/public/turn_model.js +2 -2
  150. data/lib/samagotchi/web/public/turn_view.js +1 -1
  151. data/lib/samagotchi/web/server.rb +0 -4
  152. data/lib/samagotchi/worker.rb +13 -8
  153. data/lib/samagotchi/worker_inbound.rb +36 -4
  154. data/lib/samagotchi/worker_wakes.rb +67 -7
  155. metadata +23 -1
@@ -11,6 +11,7 @@ require_relative "../version"
11
11
  require_relative "../self_report"
12
12
  require_relative "../installed_gem"
13
13
  require_relative "../config"
14
+ require_relative "../host_keys"
14
15
  require_relative "../redacted_inspect"
15
16
 
16
17
  module Samagotchi
@@ -48,6 +49,9 @@ module Samagotchi
48
49
  # top of it (MacOS.broadcast_timeout).
49
50
  BROADCAST_TRIAGE_DEADLINE = 20
50
51
  BROADCAST_MARGIN = 10
52
+ # The agent terminals the helper pastes into (AgentTerminal.swift):
53
+ # one launch-file section each, in this order.
54
+ TERMINALS = %w[kitty tmux].freeze
51
55
 
52
56
  # Runs a command without a shell; [output, success].
53
57
  class Runner
@@ -69,10 +73,36 @@ module Samagotchi
69
73
  { "listen_on" => listen_on, "binary" => get.call("kitty.binary").to_s, "agents" => agents }
70
74
  end
71
75
 
76
+ # The tmux: settings, or nil while tmux.enabled is off. The binary is
77
+ # tmux.binary, else the tmux on +env+'s PATH ("" when there is none:
78
+ # see #warnings); the socket dir is where tmux puts `-L` sockets
79
+ # ($TMUX_TMPDIR when it is a directory, else /tmp, then tmux-<uid>).
80
+ # Both are frozen at bake time, like the rest of the launch file.
81
+ def self.tmux_settings(get = Config.method(:get), env: ENV)
82
+ return nil unless get.call("tmux.enabled")
83
+
84
+ binary = get.call("tmux.binary").to_s.strip
85
+ binary = path_tmux(env) if binary.empty?
86
+ tmpdir = env["TMUX_TMPDIR"].to_s
87
+ tmpdir = "/tmp" if tmpdir.empty? || !File.directory?(tmpdir)
88
+ agents = get.call("tmux.agents").to_s.split("|").map(&:strip).reject(&:empty?)
89
+ { "binary" => binary, "socket_dir" => File.join(tmpdir, "tmux-#{Process.uid}"), "agents" => agents }
90
+ end
91
+
92
+ def self.path_tmux(env)
93
+ env["PATH"].to_s.split(File::PATH_SEPARATOR).reject(&:empty?).map { File.join(_1, "tmux") }
94
+ .find { File.file?(_1) && File.executable?(_1) }.to_s
95
+ end
96
+
97
+ # Each switched-on terminal's settings by name ("kitty" => …).
98
+ def self.terminal_settings(get = Config.method(:get), env: ENV)
99
+ { "kitty" => kitty_settings(get), "tmux" => tmux_settings(get, env: env) }.compact
100
+ end
101
+
72
102
  # The helper as chi desktop and chi update make it: the settings it
73
103
  # bakes into the launch file read from the config.
74
104
  def self.from_config(register: true)
75
- new(register: register, kitty: kitty_settings, broadcast_timeout: broadcast_timeout)
105
+ new(register: register, terminals: terminal_settings, broadcast_timeout: broadcast_timeout)
76
106
  end
77
107
 
78
108
  # Seconds the helper gives `chi broadcast` before it stops it: the
@@ -86,7 +116,7 @@ module Samagotchi
86
116
 
87
117
  def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
88
118
  source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
89
- arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil,
119
+ arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, terminals: {},
90
120
  broadcast_timeout: nil)
91
121
  home = env["HOME"] || Dir.home
92
122
  @app_dir = app_dir || File.join(home, "Applications")
@@ -100,7 +130,7 @@ module Samagotchi
100
130
  @arch = arch
101
131
  @sources_dir = sources_dir
102
132
  @register = register
103
- @kitty = kitty
133
+ @terminals = terminals
104
134
  @broadcast_timeout = broadcast_timeout
105
135
  end
106
136
 
@@ -114,13 +144,13 @@ module Samagotchi
114
144
  # What the helper runs: absolute ruby + chi (no shims, which need the
115
145
  # shell's PATH), and the allowlisted env. chi is an installed gem's
116
146
  # RubyGems wrapper (it activates the gem and outlives upgrades and
117
- # `gem cleanup`), else this checkout's bin/chi. The kitty section only
118
- # when kitty.listen_on is set; the broadcast timeout when given.
147
+ # `gem cleanup`), else this checkout's bin/chi. A section per terminal
148
+ # switched on (TERMINALS order); the broadcast timeout when given.
119
149
  def launch_config
120
150
  env = { "LANG" => LANG }
121
151
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
122
152
  config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
123
- config["kitty"] = @kitty if @kitty
153
+ TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
124
154
  config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
125
155
  config
126
156
  end
@@ -145,11 +175,12 @@ module Samagotchi
145
175
  end
146
176
 
147
177
  # The launch file names another chi version or path than this one, or
148
- # other kitty settings or broadcast timeout: refresh_launch_file fixes
149
- # that without a rebuild.
178
+ # other terminal settings or broadcast timeout: refresh_launch_file
179
+ # fixes that without a rebuild.
150
180
  def launch_outdated?
151
181
  launch = read_launch
152
- launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty ||
182
+ launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] ||
183
+ TERMINALS.map { launch[_1] } != TERMINALS.map { @terminals[_1] } ||
153
184
  launch["broadcast_timeout"] != @broadcast_timeout
154
185
  end
155
186
 
@@ -163,10 +194,41 @@ module Samagotchi
163
194
  end
164
195
 
165
196
  def warnings
166
- return [] unless File.file?(File.join(@source_dir, ".git"))
197
+ worktree = if File.file?(File.join(@source_dir, ".git"))
198
+ ["chi runs from a linked git worktree (#{@source_dir}); the helper stops working once it is removed. " \
199
+ "Install from the main checkout or a gem install."]
200
+ end
201
+ Array(worktree) + terminal_warnings + host_key_warnings
202
+ end
167
203
 
168
- ["chi runs from a linked git worktree (#{@source_dir}); the helper stops working once it is removed. " \
169
- "Install from the main checkout or a gem install."]
204
+ # What a terminal section can't do as baked (chi update shows these
205
+ # too: they need no Keychain lookup).
206
+ def terminal_warnings
207
+ tmux = @terminals["tmux"]
208
+ return [] unless tmux && tmux["binary"].to_s.empty?
209
+
210
+ ["tmux.enabled but no tmux found on PATH: set tmux.binary"]
211
+ end
212
+
213
+ # Each configured host key (api_key_env:) and whether the helper's chi
214
+ # gets it: the helper's bare env has no keys, so only the Keychain's
215
+ # count (HostKeys). :keychain, :shell_only (set in this shell, not in
216
+ # the Keychain) or :unset. Hosts come from the config the helper's chi
217
+ # reads: the launch file's XDG_CONFIG_HOME when baked; disabled hosts
218
+ # are left out. @return [Array<Hash>] name:, hosts:, state:
219
+ def host_keys
220
+ baked = read_launch["env"]
221
+ dir = baked["XDG_CONFIG_HOME"] if baked.is_a?(Hash)
222
+ # Spelled out: Paths.config_home's ~ is this process's HOME, not @env's.
223
+ config_env = { "XDG_CONFIG_HOME" => dir.to_s.empty? ? File.join(@env["HOME"] || Dir.home, ".config") : dir }
224
+ hosts = ConfigFile.hosts_config(env: config_env).values.select(&:api_key_env).group_by(&:api_key_env)
225
+ hosts.map do |name, entries|
226
+ state = if HostKeys.from_keychain(name, env: @env) then :keychain
227
+ elsif !@env[name].to_s.strip.empty? then :shell_only
228
+ else :unset
229
+ end
230
+ { name: name, hosts: entries.map(&:name), state: state }
231
+ end
170
232
  end
171
233
 
172
234
  def info_plist
@@ -248,7 +310,8 @@ module Samagotchi
248
310
  stale: stale?,
249
311
  baked_dirs: env.select { |name, _| name.start_with?("XDG_") },
250
312
  service: dump.include?(BUNDLE_ID),
251
- running: running
313
+ running: running,
314
+ host_keys: host_keys
252
315
  )
253
316
  end
254
317
 
@@ -311,6 +374,14 @@ module Samagotchi
311
374
  @arch ||= RbConfig::CONFIG["host_cpu"] == "arm64" ? "arm64" : `uname -m`.strip
312
375
  end
313
376
 
377
+ def host_key_warnings
378
+ host_keys.reject { |key| key[:state] == :keychain }.map do |key|
379
+ where = key[:state] == :shell_only ? "is set in this shell only" : "is unset"
380
+ "#{key[:name]} #{where}: the helper's new sessions and model list won't reach host " \
381
+ "#{key[:hosts].join(", ")} until it is in the Keychain (#{HostKeys.store_command(key[:name])})"
382
+ end
383
+ end
384
+
314
385
  def read_launch
315
386
  launch = JSON.parse(File.read(launch_path))
316
387
  launch.is_a?(Hash) ? launch : {}
@@ -3,6 +3,7 @@
3
3
  require_relative "cli/command"
4
4
  require_relative "cli/flags"
5
5
  require_relative "desktop"
6
+ require_relative "host_keys"
6
7
 
7
8
  module Samagotchi
8
9
  # `chi desktop install|upgrade|uninstall|status`: the native helper that
@@ -121,12 +122,22 @@ module Samagotchi
121
122
  dirs = s[:baked_dirs].empty? ? "(defaults)" : s[:baked_dirs].map { |name, value| "#{name}=#{value}" }.join(" ")
122
123
  service = s[:service] ? "registered" : "not registered (try chi desktop upgrade, or log out and back in)"
123
124
  running = s[:running] ? "running" : "not running (open -g \"#{s[:app_path]}\")"
124
- @stdout.puts(format_rows([["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch],
125
- ["state dirs", dirs], ["service", service], ["hotkey", hotkey(s[:hotkey])],
126
- ["process", running], ["at login", login(s[:login])]]))
125
+ rows = [["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch], ["state dirs", dirs],
126
+ ["service", service], ["hotkey", hotkey(s[:hotkey])], ["process", running], ["at login", login(s[:login])]]
127
+ rows << ["host keys", host_keys(s[:host_keys])] unless Array(s[:host_keys]).empty?
128
+ @stdout.puts(format_rows(rows))
127
129
  0
128
130
  end
129
131
 
132
+ # `VAR keychain · VAR this shell only (security …)`: what the helper's chi gets.
133
+ def host_keys(keys)
134
+ keys.map do |key|
135
+ next "#{key[:name]} keychain" if key[:state] == :keychain
136
+
137
+ "#{key[:name]} #{key[:state] == :shell_only ? "this shell only" : "unset"} (#{HostKeys.store_command(key[:name])})"
138
+ end.join(" · ")
139
+ end
140
+
130
141
  def hotkey(state)
131
142
  return "unknown (the app writes it when it starts)" unless state
132
143
 
@@ -19,6 +19,8 @@ require_relative "context_window"
19
19
  require_relative "kernel_loop"
20
20
  require_relative "tools/builtins"
21
21
  require_relative "tools/task_runtime"
22
+ require_relative "tools/tool_index"
23
+ require_relative "peer_sources"
22
24
  require_relative "session_commands"
23
25
  require_relative "plugin/loader"
24
26
  require_relative "extension_load"
@@ -31,6 +33,7 @@ require_relative "llm/backend"
31
33
  require_relative "llm/openai_chat"
32
34
  require_relative "llm/turn_settings"
33
35
  require_relative "session"
36
+ require_relative "toolkit_binding"
34
37
  require_relative "answer_tail"
35
38
  require_relative "archive_store"
36
39
  require_relative "session_observer"
@@ -43,6 +46,7 @@ require_relative "recap_setup"
43
46
  require_relative "idle_reminders"
44
47
  require_relative "idle_scheduler"
45
48
  require_relative "hooks"
49
+ require_relative "hook_routes"
46
50
  require_relative "guardrails"
47
51
  require_relative "reminder_store"
48
52
  require_relative "reminder_queue"
@@ -145,9 +149,13 @@ module Samagotchi
145
149
  begin
146
150
  require_relative "memory_bundle/system_bundle"
147
151
  MemoryBundle::SystemBundle.ensure!
148
- rescue StandardError
149
- nil
152
+ rescue StandardError => e
153
+ Log.warn(:memory, "system_bundle_ensure_failed", error: e.class.name, msg: e.message.to_s[0, 200])
150
154
  end
155
+ # What hooks and plugins do beyond reading their event (a notice, a
156
+ # question, a stop, a steer), routed to the UIs. Built before anything
157
+ # that may notify.
158
+ @hook_routes = build_hook_routes
151
159
  # Load hooks from config (plugins) and create the registry; what fails
152
160
  # to load is announced, and a required guardrail's failure denies
153
161
  # every tool call. Rules load now too, so their errors are announced.
@@ -184,6 +192,15 @@ module Samagotchi
184
192
  # plugins' tool sets from chi.replace_tools until the turn thread
185
193
  # applies them (#apply_staged_tools!). Built before the plugins load.
186
194
  @plugin_tasks = build_plugin_tasks
195
+ # The session's toolkit: the kits (the plugins' too, chi.toolkit),
196
+ # what its next turn declares, the turn-start apply.
197
+ @toolkit_binding = build_toolkit_binding
198
+ # The tools plugins publish without declaring (chi.tool_source), and
199
+ # the search and call tools that reach them (Tools::ToolIndex).
200
+ @tool_index = Tools::ToolIndex.new
201
+ # The context sources the peer bundles declare as a peer agent's
202
+ # answers (chi.peer_source); the worker's ContextAbsorber asks it.
203
+ @peer_sources = PeerSources.new
187
204
  @lifecycle_mutex = Mutex.new
188
205
  @shut_down = false
189
206
  load_plugins if plugins
@@ -447,7 +464,8 @@ module Samagotchi
447
464
 
448
465
  log_dropped_steers(steers, "answered")
449
466
  []
450
- rescue StandardError
467
+ rescue StandardError => e
468
+ Log.warn(:turn, "take_steers_failed", error: e.class.name, msg: e.message.to_s[0, 200])
451
469
  []
452
470
  end
453
471
  private :take_steers
@@ -548,7 +566,8 @@ module Samagotchi
548
566
  return saved.dup if !@prompt_builder.prompt_notes.nil? || !saved.empty? || @session.messages.to_a.any?
549
567
 
550
568
  model_notes.map { |note| PromptNote.from_note(note) }
551
- rescue StandardError
569
+ rescue StandardError => e
570
+ Log.warn(:model, "prompt_notes_failed", error: e.class.name, msg: e.message.to_s[0, 200])
552
571
  []
553
572
  end
554
573
 
@@ -613,6 +632,9 @@ module Samagotchi
613
632
  # built-ins, and the ones bundle plugins add
614
633
  attr_reader :command_registry
615
634
 
635
+ # @return [PeerSources] the sources the peer bundles declared
636
+ attr_reader :peer_sources
637
+
616
638
  # The model id sent for +full_ref+ (its alias applied, the host prefix off).
617
639
  def bare_model_name(full_ref)
618
640
  @host_registry.bare_name(full_ref)
@@ -1295,6 +1317,7 @@ module Samagotchi
1295
1317
  llm_context: llm_context_summary,
1296
1318
  thinking: thinking_explained&.summary,
1297
1319
  reasoning_history: reasoning_history_explained&.summary,
1320
+ toolkit: toolkit_summary,
1298
1321
  recap_enabled: !@recap.nil?,
1299
1322
  recap_min_user_turns: @recap&.min_user_turns,
1300
1323
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1489,7 +1512,8 @@ module Samagotchi
1489
1512
 
1490
1513
  def save_quietly(session)
1491
1514
  session.save(state_dir: session_state_dir)
1492
- rescue StandardError
1515
+ rescue StandardError => e
1516
+ Log.warn(:turn, "turn_end_save_failed", error: e.class.name, msg: e.message.to_s[0, 200])
1493
1517
  nil
1494
1518
  end
1495
1519
  private :save_quietly
@@ -1527,121 +1551,59 @@ module Samagotchi
1527
1551
  end
1528
1552
  private :store_answer_display
1529
1553
 
1530
- # ── The hook runtime (Hooks::Runtime) ─────────────────────────────────────
1531
-
1532
- # The three things a hook can do beyond reading its event. Each gets the
1533
- # hook's label (event[:hook]) from the registry.
1534
- def hook_runtime
1535
- Hooks::Runtime.new(
1536
- notify: ->(text:, level:, hook:, fallback_for: nil) { hook_notify(text, level, hook, fallback_for: fallback_for) },
1537
- ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
1538
- stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
1539
- steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) },
1540
- stop_generation: ->(reason:, hook:) { hook_stop_generation(reason, hook) }
1541
- )
1542
- end
1554
+ # ── The hook runtime (HookRoutes) ───────────────────────────────────────
1555
+
1556
+ # What a hook (Hooks::Runtime) and a plugin (Plugin::Host) can do beyond
1557
+ # reading its event, routed to the UIs by HookRoutes; these forward.
1558
+ def hook_runtime = @hook_routes.runtime
1543
1559
  private :hook_runtime
1544
1560
 
1545
- # What a steer is attributed to: the bundle of a bundle hook's label
1546
- # ("<file> (bundle <name>)"), the label of any other.
1547
- def hook_source(hook)
1548
- hook.to_s[/\(bundle (.+)\)\z/, 1] || hook.to_s
1549
- end
1561
+ def hook_source(hook) = @hook_routes.source(hook)
1550
1562
  private :hook_source
1551
1563
 
1552
1564
  # What a notice may stand in for (hook_notify's fallback_for:).
1553
- NOTICE_FALLBACKS = %i[display editor].freeze
1554
-
1555
- # One line to the user (:hook_notice). During a turn it is a turn
1556
- # event: the turn's sink (the REPL) and the observers (bridge, log).
1557
- # Outside one (a plugin's command at the prompt) it is announced with
1558
- # between_turns: true, which every UI shows as cards are shown.
1559
- #
1560
- # +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
1561
- # :display, the answer's display (AnswerDisplay), for a UI that doesn't
1562
- # render its links; :editor, a ref the viewer could open in its editor
1563
- # (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
1564
- # does leaves the line out; the others, the log among them, show it. An
1565
- # unknown value is dropped, so the line shows everywhere.
1566
- def hook_notify(text, level, hook, fallback_for: nil)
1567
- level = (level || :info).to_sym
1568
- level = :info unless %i[info warn].include?(level)
1569
- notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
1570
- fallback_for: notice_fallback(fallback_for, hook) }.compact
1571
- return @extension_load.hold(notice) && nil if @extension_load.loading?
1565
+ NOTICE_FALLBACKS = HookRoutes::NOTICE_FALLBACKS
1572
1566
 
1573
- in_turn, sink = @turn_state.in_turn_sink
1574
- if anytime_thread?
1575
- announce_anytime(notice.merge(between_turns: true))
1576
- elsif current_init_task
1577
- announce(notice.merge(between_turns: true))
1578
- elsif in_turn
1579
- emit_event(sink, notice)
1580
- else
1581
- announce_or_hold(notice.merge(between_turns: true))
1582
- end
1583
- nil
1567
+ # One line to the user (:hook_notice), HookRoutes#notify.
1568
+ def hook_notify(text, level, hook, fallback_for: nil, extra: {})
1569
+ @hook_routes.notify(text, level, hook, fallback_for: fallback_for, extra: extra)
1584
1570
  end
1585
1571
  private :hook_notify
1586
1572
 
1587
- # A known fallback_for (a String is symbolized), else nil; an unknown
1588
- # one is logged once at debug.
1589
- def notice_fallback(value, hook)
1590
- return if value.nil?
1591
-
1592
- fallback = value.to_s.to_sym
1593
- return fallback if NOTICE_FALLBACKS.include?(fallback)
1594
-
1595
- @unknown_fallbacks ||= Set.new
1596
- Log.debug(:hooks, "notice_unknown_fallback", hook: hook.to_s, value: value.to_s) if @unknown_fallbacks.add?(fallback)
1597
- nil
1598
- end
1599
- private :notice_fallback
1600
-
1601
- # A guardrails line to the user after load (:guardrail_warning, as the
1602
- # load failures show): a running turn's event, else announced.
1603
- def guardrail_notify(message)
1604
- event = { type: :guardrail_warning, message: message.to_s }
1605
- in_turn, sink = @turn_state.in_turn_sink
1606
- in_turn ? emit_event(sink, event) : announce_or_hold(event)
1607
- nil
1608
- end
1573
+ def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
1609
1574
  private :guardrail_notify
1610
1575
 
1611
- # A question through the question flow (REPL sync handler, attached TUI,
1612
- # web), single-select, kind "hook". A --non-interactive run has no one
1613
- # to ask: nil at once. Anything but an answer (a sync handler's text, no
1614
- # answer, cancelled) is nil too.
1615
1576
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}
1616
1577
  def hook_ask_user(question, options, header, allow_freeform, hook)
1617
- @question_desk.ask_for_hook(question, options, header, allow_freeform, hook)
1578
+ @hook_routes.ask_user(question, options, header, allow_freeform, hook)
1618
1579
  end
1619
1580
  private :hook_ask_user
1620
1581
 
1621
- # Cancel the running turn (reason :hook), after a notice that says why.
1622
- # The gate denies the rest of a tool batch once the controller is
1623
- # cancelled; the next request ends the turn as :turn_canceled.
1624
1582
  # @return [Boolean] true when a running turn was cancelled now
1625
- def hook_stop_turn(reason, hook)
1626
- ctrl = active_cancel_controller
1627
- return false unless ctrl && !ctrl.cancelled?
1628
-
1629
- hook_notify("stopped the turn: #{reason}", :warn, hook)
1630
- ctrl.cancel!(:hook, { by: hook_source(hook), reason: reason.to_s })
1631
- end
1583
+ def hook_stop_turn(reason, hook) = @hook_routes.stop_turn(reason, hook)
1632
1584
  private :hook_stop_turn
1633
1585
 
1634
- # Cut the streaming generation (reason :hook); the turn goes on and asks
1635
- # the model again (the loops' cut path). No notice: the plugin posts its
1636
- # own. The bundle and +reason+ go to the log and the retry's nudge.
1637
1586
  # @return [Boolean] true when a streaming generation was cut now
1638
- def hook_stop_generation(reason, hook)
1639
- ctrl = active_cancel_controller
1640
- return false unless ctrl
1587
+ def hook_stop_generation(reason, hook) = @hook_routes.stop_generation(reason, hook)
1588
+ private :hook_stop_generation
1641
1589
 
1642
- ctrl.cancel_generation!(:hook, { by: hook_source(hook), reason: reason.to_s })
1590
+ # The hook routes (see #initialize for when).
1591
+ def build_hook_routes
1592
+ HookRoutes.new(
1593
+ extension_load: -> { @extension_load },
1594
+ in_turn_sink: -> { @turn_state.in_turn_sink },
1595
+ anytime_thread: -> { anytime_thread? },
1596
+ announce_anytime: ->(event) { announce_anytime(event) },
1597
+ init_task: -> { current_init_task },
1598
+ announce: ->(event) { announce(event) },
1599
+ announce_or_hold: ->(event) { announce_or_hold(event) },
1600
+ emit: ->(sink, event) { emit_event(sink, event) },
1601
+ question_desk: -> { @question_desk },
1602
+ cancel_controller: -> { active_cancel_controller },
1603
+ steer: ->(text, source) { steer(text, source: source) }
1604
+ )
1643
1605
  end
1644
- private :hook_stop_generation
1606
+ private :build_hook_routes
1645
1607
 
1646
1608
  # ── Ask-user-question (structured qualification) ──────────────────────────
1647
1609
 
@@ -2019,8 +1981,11 @@ module Samagotchi
2019
1981
  # at its first request).
2020
1982
  start_init_tasks!
2021
1983
  await_init_tasks(turn.controller, turn.on_event)
2022
- # Plugins' tool sets that changed since the last turn.
1984
+ # Plugins' tool sets that changed since the last turn, then the
1985
+ # session's toolkit (a /toolkit change since the last turn): what the
1986
+ # prompt built next declares, unchanged until the next turn's start.
2023
1987
  apply_staged_tools!
1988
+ apply_toolkit!(session, turn.on_event)
2024
1989
  # A Stop during the wait above: the turn ends here,
2025
1990
  # before the hooks run or a reminder is used up.
2026
1991
  return false if turn.controller.cancelled?
@@ -2219,8 +2184,8 @@ module Samagotchi
2219
2184
  # Persist deduped used memories onto the session for Web + reload.
2220
2185
  begin
2221
2186
  session.used_memory_names = used_memory_names
2222
- rescue StandardError
2223
- nil
2187
+ rescue StandardError => e
2188
+ Log.warn(:memory, "used_memories_store_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2224
2189
  end
2225
2190
  # Notify live observers of the updated memory list (so yellow bar refreshes
2226
2191
  # even without a tool_call event if the preload was the only addition).
@@ -2229,8 +2194,8 @@ module Samagotchi
2229
2194
 
2230
2195
  begin
2231
2196
  emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
2232
- rescue StandardError
2233
- nil
2197
+ rescue StandardError => e
2198
+ Log.warn(:memory, "used_memories_emit_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2234
2199
  end
2235
2200
  end
2236
2201
  private :publish_used_memories
@@ -2666,12 +2631,17 @@ module Samagotchi
2666
2631
  init: lambda { |bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
2667
2632
  add_init_task(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
2668
2633
  quiet: quiet, timeout: timeout, failed: failed, &block)
2669
- }
2634
+ },
2635
+ toolkits: @toolkit_binding.plugin_kits,
2636
+ tool_index: @tool_index,
2637
+ peer_sources: @peer_sources
2670
2638
  )
2671
2639
  # What plugins show while they load (an MCP server that didn't
2672
2640
  # start) waits for the first turn, beside the load warnings: no UI
2673
2641
  # is there yet, and the Engine isn't built.
2674
2642
  @extension_load.load_plugins(registries, failures: @plugin_failures)
2643
+ # Declared once, from what the sources published while they loaded.
2644
+ @tool_index.declare(@tools, failures: @plugin_failures)
2675
2645
  end
2676
2646
 
2677
2647
  # Keep a plugin's new tool set (chi.replace_tools, from any thread)
@@ -2689,6 +2659,43 @@ module Samagotchi
2689
2659
  end
2690
2660
  public :apply_staged_tools!
2691
2661
 
2662
+ # The session's toolkit (ToolkitBinding): the kits, what its next turn
2663
+ # declares, ctx.use_toolkit and the turn-start apply.
2664
+ def toolkit_kits(errors = []) = @toolkit_binding.kits(errors)
2665
+ def resolved_toolkit(session, errors = []) = @toolkit_binding.resolved(session, errors)
2666
+ def toolkit_explained(toolkit = nil) = @toolkit_binding.explained(toolkit)
2667
+ def toolkit_summary = @toolkit_binding.summary
2668
+
2669
+ # The tools this session registered (Tools::Registry), declared or not.
2670
+ attr_reader :tools
2671
+
2672
+ public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
2673
+
2674
+ # A plugin's ctx.use_toolkit (ToolkitBinding#use).
2675
+ # @return [Boolean] whether it was staged
2676
+ # @raise [ArgumentError] an unknown toolkit
2677
+ def use_toolkit(name) = @toolkit_binding.use(name)
2678
+ public :use_toolkit
2679
+
2680
+ # At a turn's start (ToolkitBinding#apply!).
2681
+ # @return [Boolean] whether the declared tools changed
2682
+ def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
2683
+ private :apply_toolkit!
2684
+
2685
+ # The session's toolkit (see #initialize for when).
2686
+ def build_toolkit_binding
2687
+ ToolkitBinding.new(
2688
+ tools: -> { @tools },
2689
+ session: -> { @session },
2690
+ state_dir: -> { session_state_dir },
2691
+ can_stage: -> { !turn_running? && !history? },
2692
+ reset_prompt: -> { @prompt_builder&.reset! },
2693
+ emit: ->(sink, event) { emit_event(sink, event) },
2694
+ notify: ->(text, level, source, extra:) { hook_notify(text, level, source, extra: extra) }
2695
+ )
2696
+ end
2697
+ private :build_toolkit_binding
2698
+
2692
2699
  # The tools changed (a plugin's chi.tools_changed!): the system prompts,
2693
2700
  # which declare them, are built again on the next turn.
2694
2701
  def tools_changed!
@@ -2715,6 +2722,9 @@ module Samagotchi
2715
2722
  steer: ->(text, label) { steer(text, source: hook_source(label)) },
2716
2723
  stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
2717
2724
  stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
2725
+ toolkit: -> { @toolkit_binding.name },
2726
+ declared_tools: -> { toolkit_explained.declared.map(&:name) },
2727
+ use_toolkit: ->(name) { use_toolkit(name) },
2718
2728
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
2719
2729
  ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
2720
2730
  },
@@ -2865,7 +2875,8 @@ module Samagotchi
2865
2875
  def with_generation_report(event)
2866
2876
  report = @metrics.finish_generation
2867
2877
  event.merge(speed: report.speed&.to_h, tokens: report.tokens, served_expected_by: served_expected_by(event[:served_model]))
2868
- rescue StandardError
2878
+ rescue StandardError => e
2879
+ Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2869
2880
  event
2870
2881
  end
2871
2882
  private :with_generation_report
@@ -2882,7 +2893,8 @@ module Samagotchi
2882
2893
  # Capture used memories synchronously in the turn thread.
2883
2894
  read_names = begin
2884
2895
  @used_memories.capture(event, muted: @muted_memory_names)
2885
- rescue StandardError
2896
+ rescue StandardError => e
2897
+ Log.warn(:memory, "used_memories_capture_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2886
2898
  nil
2887
2899
  end
2888
2900
  # Turn-scoped sink: receives the event hash as the stream handler
@@ -2891,8 +2903,9 @@ module Samagotchi
2891
2903
  if on_event
2892
2904
  begin
2893
2905
  on_event.call(event)
2894
- rescue StandardError
2906
+ rescue StandardError => e
2895
2907
  # Sink errors must not break the kernel loop (same as KernelLoop's own handling)
2908
+ Log.warn(:turn, "sink_failed", event_type: event[:type].to_s, error: e.class.name, msg: e.message.to_s[0, 200])
2896
2909
  end
2897
2910
  end
2898
2911
 
@@ -20,6 +20,9 @@ module Samagotchi
20
20
  def model_ref = engine.effective_model_ref
21
21
  def thinking = engine.thinking_override
22
22
  def reasoning_history = engine.reasoning_history_override
23
+ # The peer's name when +name+ is a source a peer bundle declared
24
+ # (PeerSources), for context_read's header.
25
+ def peer_label(name) = engine.peer_sources&.label_for(name)
23
26
  end
24
27
 
25
28
  # What the approval relay needs from the parent's Engine (Peers#relay):
@@ -228,7 +228,7 @@ module Samagotchi
228
228
 
229
229
  # A plugin tool is asked about by its targets:' label:, else its
230
230
  # label, as its row shows it; a call that acts as another tool (an MCP
231
- # tool behind mcp_call) by that tool's label, else (none registered)
231
+ # tool behind call_tool) by that tool's label, else (none registered)
232
232
  # by its name (Approval.payload).
233
233
  acts_as = verdict.targets&.acts_as
234
234
  label = verdict.targets&.label ||
@@ -38,7 +38,7 @@ module Samagotchi
38
38
  kind: KIND,
39
39
  approval: {
40
40
  tool: targets&.tool || verdict.call[:name].to_s,
41
- # The tool the call acts as (an MCP tool behind mcp_call).
41
+ # The tool the call acts as (an MCP tool behind call_tool).
42
42
  acts_as: targets&.acts_as,
43
43
  label: label,
44
44
  command: targets&.command,
@@ -111,7 +111,7 @@ module Samagotchi
111
111
  # tool + the normalized command, or the sorted paths; for a plugin
112
112
  # tool with neither (an MCP tool), its arguments, so "this call" is
113
113
  # this call. A call that acts as another tool keys as
114
- # `mcp_call>mcp_github_x`: the real name stays in, so it never reuses
114
+ # `call_tool>mcp_github_x`: the real name stays in, so it never reuses
115
115
  # that tool's own approvals (nor they its).
116
116
  def self.key_for(verdict)
117
117
  t = verdict.targets
@@ -12,7 +12,7 @@ module Samagotchi
12
12
  # tool: a tool name, a list, or "shell" (execute + task_create);
13
13
  # a name may be a glob ("mcp_*", "mcp_{git,gh}_*"); it
14
14
  # matches the call's name or the tool a plugin tool's call
15
- # acts as (Targets#acts_as: an MCP tool behind mcp_call)
15
+ # acts as (Targets#acts_as: an MCP tool behind call_tool)
16
16
  # command: a Ruby regex on a shell tool's command
17
17
  # path: "outside_repo", or a glob on the resolved paths (absolute
18
18
  # or "**/…" globs match the absolute path; others the path
@@ -50,7 +50,7 @@ module Samagotchi
50
50
  attr_reader :args
51
51
 
52
52
  # @return [String, nil] the tool a plugin tool's call acts as (its
53
- # targets:' acts_as:, an MCP tool behind mcp_call): a rule's tool:
53
+ # targets:' acts_as:, an MCP tool behind call_tool): a rule's tool:
54
54
  # matches it as well as the call's name. Never a core tool's or
55
55
  # another bundle's (.plugin_targets drops those). #tool stays the
56
56
  # call's name.