samagotchi 0.66.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/docs/architecture.md +50 -28
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +71 -29
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +17 -3
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/engine.rb +96 -191
  51. data/lib/samagotchi/engine_peers.rb +3 -0
  52. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  53. data/lib/samagotchi/guardrails/approval.rb +1 -1
  54. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  55. data/lib/samagotchi/guardrails/rules.rb +1 -1
  56. data/lib/samagotchi/guardrails/targets.rb +1 -1
  57. data/lib/samagotchi/hook_routes.rb +151 -0
  58. data/lib/samagotchi/hooks/registry.rb +2 -2
  59. data/lib/samagotchi/host_keys.rb +147 -0
  60. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  61. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  62. data/lib/samagotchi/llm/api_key.rb +23 -7
  63. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  64. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  65. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  66. data/lib/samagotchi/llm_context_override.rb +1 -0
  67. data/lib/samagotchi/peer_sources.rb +58 -0
  68. data/lib/samagotchi/plugin/api.rb +64 -155
  69. data/lib/samagotchi/plugin/loader.rb +4 -2
  70. data/lib/samagotchi/plugin/peer.rb +394 -0
  71. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  72. data/lib/samagotchi/plugin_tasks.rb +2 -2
  73. data/lib/samagotchi/row_refs.rb +1 -1
  74. data/lib/samagotchi/self_report.rb +5 -3
  75. data/lib/samagotchi/send_command.rb +21 -11
  76. data/lib/samagotchi/session_continue.rb +58 -15
  77. data/lib/samagotchi/session_inbox.rb +0 -13
  78. data/lib/samagotchi/tool_call_parser.rb +1 -1
  79. data/lib/samagotchi/toolkit_binding.rb +138 -0
  80. data/lib/samagotchi/tools/context_read.rb +14 -0
  81. data/lib/samagotchi/tools/execute.rb +12 -12
  82. data/lib/samagotchi/tools/registry.rb +1 -1
  83. data/lib/samagotchi/tools/task_create.rb +6 -6
  84. data/lib/samagotchi/tools/task_get.rb +13 -13
  85. data/lib/samagotchi/tools/task_list.rb +6 -6
  86. data/lib/samagotchi/tools/task_record.rb +52 -0
  87. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  88. data/lib/samagotchi/tools/task_stop.rb +6 -6
  89. data/lib/samagotchi/tools/task_wait.rb +12 -13
  90. data/lib/samagotchi/tools/tool_index.rb +454 -0
  91. data/lib/samagotchi/update_command.rb +9 -6
  92. data/lib/samagotchi/version.rb +1 -1
  93. data/lib/samagotchi/web/app.rb +16 -9
  94. data/lib/samagotchi/web/capabilities.rb +1 -1
  95. data/lib/samagotchi/web/public/activity.js +2 -2
  96. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  97. data/lib/samagotchi/web/public/app.js +31 -36
  98. data/lib/samagotchi/web/public/chains.js +1 -1
  99. data/lib/samagotchi/web/public/commands.js +2 -2
  100. data/lib/samagotchi/web/public/composer_size.js +2 -2
  101. data/lib/samagotchi/web/public/copy.js +1 -1
  102. data/lib/samagotchi/web/public/ctx.js +1 -1
  103. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  104. data/lib/samagotchi/web/public/families_view.js +1 -1
  105. data/lib/samagotchi/web/public/format.js +11 -7
  106. data/lib/samagotchi/web/public/images.js +25 -2
  107. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  108. data/lib/samagotchi/web/public/notify.js +6 -4
  109. data/lib/samagotchi/web/public/question_card.js +1 -1
  110. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  111. data/lib/samagotchi/web/public/session_state.js +14 -0
  112. data/lib/samagotchi/web/public/session_title.js +2 -3
  113. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  114. data/lib/samagotchi/web/public/stage_model.js +2 -2
  115. data/lib/samagotchi/web/public/strip.js +3 -3
  116. data/lib/samagotchi/web/public/task_stop.js +3 -2
  117. data/lib/samagotchi/web/public/turn_events.js +1 -1
  118. data/lib/samagotchi/web/public/turn_html.js +2 -2
  119. data/lib/samagotchi/web/public/turn_model.js +2 -2
  120. data/lib/samagotchi/web/public/turn_view.js +1 -1
  121. data/lib/samagotchi/web/server.rb +0 -4
  122. data/lib/samagotchi/worker.rb +11 -8
  123. data/lib/samagotchi/worker_inbound.rb +36 -4
  124. data/lib/samagotchi/worker_wakes.rb +67 -7
  125. metadata +14 -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,7 +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"
34
- require_relative "toolkit"
36
+ require_relative "toolkit_binding"
35
37
  require_relative "answer_tail"
36
38
  require_relative "archive_store"
37
39
  require_relative "session_observer"
@@ -44,6 +46,7 @@ require_relative "recap_setup"
44
46
  require_relative "idle_reminders"
45
47
  require_relative "idle_scheduler"
46
48
  require_relative "hooks"
49
+ require_relative "hook_routes"
47
50
  require_relative "guardrails"
48
51
  require_relative "reminder_store"
49
52
  require_relative "reminder_queue"
@@ -146,9 +149,13 @@ module Samagotchi
146
149
  begin
147
150
  require_relative "memory_bundle/system_bundle"
148
151
  MemoryBundle::SystemBundle.ensure!
149
- rescue StandardError
150
- nil
152
+ rescue StandardError => e
153
+ Log.warn(:memory, "system_bundle_ensure_failed", error: e.class.name, msg: e.message.to_s[0, 200])
151
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
152
159
  # Load hooks from config (plugins) and create the registry; what fails
153
160
  # to load is announced, and a required guardrail's failure denies
154
161
  # every tool call. Rules load now too, so their errors are announced.
@@ -185,9 +192,15 @@ module Samagotchi
185
192
  # plugins' tool sets from chi.replace_tools until the turn thread
186
193
  # applies them (#apply_staged_tools!). Built before the plugins load.
187
194
  @plugin_tasks = build_plugin_tasks
188
- # The toolkits plugins declare (chi.toolkit), beside the built-in and
189
- # config ones (#toolkit_kits).
190
- @plugin_kits = []
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
191
204
  @lifecycle_mutex = Mutex.new
192
205
  @shut_down = false
193
206
  load_plugins if plugins
@@ -451,7 +464,8 @@ module Samagotchi
451
464
 
452
465
  log_dropped_steers(steers, "answered")
453
466
  []
454
- rescue StandardError
467
+ rescue StandardError => e
468
+ Log.warn(:turn, "take_steers_failed", error: e.class.name, msg: e.message.to_s[0, 200])
455
469
  []
456
470
  end
457
471
  private :take_steers
@@ -552,7 +566,8 @@ module Samagotchi
552
566
  return saved.dup if !@prompt_builder.prompt_notes.nil? || !saved.empty? || @session.messages.to_a.any?
553
567
 
554
568
  model_notes.map { |note| PromptNote.from_note(note) }
555
- rescue StandardError
569
+ rescue StandardError => e
570
+ Log.warn(:model, "prompt_notes_failed", error: e.class.name, msg: e.message.to_s[0, 200])
556
571
  []
557
572
  end
558
573
 
@@ -617,6 +632,9 @@ module Samagotchi
617
632
  # built-ins, and the ones bundle plugins add
618
633
  attr_reader :command_registry
619
634
 
635
+ # @return [PeerSources] the sources the peer bundles declared
636
+ attr_reader :peer_sources
637
+
620
638
  # The model id sent for +full_ref+ (its alias applied, the host prefix off).
621
639
  def bare_model_name(full_ref)
622
640
  @host_registry.bare_name(full_ref)
@@ -1494,7 +1512,8 @@ module Samagotchi
1494
1512
 
1495
1513
  def save_quietly(session)
1496
1514
  session.save(state_dir: session_state_dir)
1497
- 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])
1498
1517
  nil
1499
1518
  end
1500
1519
  private :save_quietly
@@ -1532,122 +1551,59 @@ module Samagotchi
1532
1551
  end
1533
1552
  private :store_answer_display
1534
1553
 
1535
- # ── The hook runtime (Hooks::Runtime) ─────────────────────────────────────
1536
-
1537
- # The three things a hook can do beyond reading its event. Each gets the
1538
- # hook's label (event[:hook]) from the registry.
1539
- def hook_runtime
1540
- Hooks::Runtime.new(
1541
- notify: ->(text:, level:, hook:, fallback_for: nil) { hook_notify(text, level, hook, fallback_for: fallback_for) },
1542
- ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
1543
- stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
1544
- steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) },
1545
- stop_generation: ->(reason:, hook:) { hook_stop_generation(reason, hook) }
1546
- )
1547
- 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
1548
1559
  private :hook_runtime
1549
1560
 
1550
- # What a steer is attributed to: the bundle of a bundle hook's label
1551
- # ("<file> (bundle <name>)"), the label of any other.
1552
- def hook_source(hook)
1553
- hook.to_s[/\(bundle (.+)\)\z/, 1] || hook.to_s
1554
- end
1561
+ def hook_source(hook) = @hook_routes.source(hook)
1555
1562
  private :hook_source
1556
1563
 
1557
1564
  # What a notice may stand in for (hook_notify's fallback_for:).
1558
- NOTICE_FALLBACKS = %i[display editor].freeze
1565
+ NOTICE_FALLBACKS = HookRoutes::NOTICE_FALLBACKS
1559
1566
 
1560
- # One line to the user (:hook_notice). During a turn it is a turn
1561
- # event: the turn's sink (the REPL) and the observers (bridge, log).
1562
- # Outside one (a plugin's command at the prompt) it is announced with
1563
- # between_turns: true, which every UI shows as cards are shown.
1564
- #
1565
- # +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
1566
- # :display, the answer's display (AnswerDisplay), for a UI that doesn't
1567
- # render its links; :editor, a ref the viewer could open in its editor
1568
- # (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
1569
- # does leaves the line out; the others, the log among them, show it. An
1570
- # unknown value is dropped, so the line shows everywhere.
1571
- # +extra+: more fields for the UIs (a toolkit change's summary).
1567
+ # One line to the user (:hook_notice), HookRoutes#notify.
1572
1568
  def hook_notify(text, level, hook, fallback_for: nil, extra: {})
1573
- level = (level || :info).to_sym
1574
- level = :info unless %i[info warn].include?(level)
1575
- notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
1576
- fallback_for: notice_fallback(fallback_for, hook) }.compact.merge(extra)
1577
- return @extension_load.hold(notice) && nil if @extension_load.loading?
1578
-
1579
- in_turn, sink = @turn_state.in_turn_sink
1580
- if anytime_thread?
1581
- announce_anytime(notice.merge(between_turns: true))
1582
- elsif current_init_task
1583
- announce(notice.merge(between_turns: true))
1584
- elsif in_turn
1585
- emit_event(sink, notice)
1586
- else
1587
- announce_or_hold(notice.merge(between_turns: true))
1588
- end
1589
- nil
1569
+ @hook_routes.notify(text, level, hook, fallback_for: fallback_for, extra: extra)
1590
1570
  end
1591
1571
  private :hook_notify
1592
1572
 
1593
- # A known fallback_for (a String is symbolized), else nil; an unknown
1594
- # one is logged once at debug.
1595
- def notice_fallback(value, hook)
1596
- return if value.nil?
1597
-
1598
- fallback = value.to_s.to_sym
1599
- return fallback if NOTICE_FALLBACKS.include?(fallback)
1600
-
1601
- @unknown_fallbacks ||= Set.new
1602
- Log.debug(:hooks, "notice_unknown_fallback", hook: hook.to_s, value: value.to_s) if @unknown_fallbacks.add?(fallback)
1603
- nil
1604
- end
1605
- private :notice_fallback
1606
-
1607
- # A guardrails line to the user after load (:guardrail_warning, as the
1608
- # load failures show): a running turn's event, else announced.
1609
- def guardrail_notify(message)
1610
- event = { type: :guardrail_warning, message: message.to_s }
1611
- in_turn, sink = @turn_state.in_turn_sink
1612
- in_turn ? emit_event(sink, event) : announce_or_hold(event)
1613
- nil
1614
- end
1573
+ def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
1615
1574
  private :guardrail_notify
1616
1575
 
1617
- # A question through the question flow (REPL sync handler, attached TUI,
1618
- # web), single-select, kind "hook". A --non-interactive run has no one
1619
- # to ask: nil at once. Anything but an answer (a sync handler's text, no
1620
- # answer, cancelled) is nil too.
1621
1576
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}
1622
1577
  def hook_ask_user(question, options, header, allow_freeform, hook)
1623
- @question_desk.ask_for_hook(question, options, header, allow_freeform, hook)
1578
+ @hook_routes.ask_user(question, options, header, allow_freeform, hook)
1624
1579
  end
1625
1580
  private :hook_ask_user
1626
1581
 
1627
- # Cancel the running turn (reason :hook), after a notice that says why.
1628
- # The gate denies the rest of a tool batch once the controller is
1629
- # cancelled; the next request ends the turn as :turn_canceled.
1630
1582
  # @return [Boolean] true when a running turn was cancelled now
1631
- def hook_stop_turn(reason, hook)
1632
- ctrl = active_cancel_controller
1633
- return false unless ctrl && !ctrl.cancelled?
1634
-
1635
- hook_notify("stopped the turn: #{reason}", :warn, hook)
1636
- ctrl.cancel!(:hook, { by: hook_source(hook), reason: reason.to_s })
1637
- end
1583
+ def hook_stop_turn(reason, hook) = @hook_routes.stop_turn(reason, hook)
1638
1584
  private :hook_stop_turn
1639
1585
 
1640
- # Cut the streaming generation (reason :hook); the turn goes on and asks
1641
- # the model again (the loops' cut path). No notice: the plugin posts its
1642
- # own. The bundle and +reason+ go to the log and the retry's nudge.
1643
1586
  # @return [Boolean] true when a streaming generation was cut now
1644
- def hook_stop_generation(reason, hook)
1645
- ctrl = active_cancel_controller
1646
- return false unless ctrl
1587
+ def hook_stop_generation(reason, hook) = @hook_routes.stop_generation(reason, hook)
1588
+ private :hook_stop_generation
1647
1589
 
1648
- 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
+ )
1649
1605
  end
1650
- private :hook_stop_generation
1606
+ private :build_hook_routes
1651
1607
 
1652
1608
  # ── Ask-user-question (structured qualification) ──────────────────────────
1653
1609
 
@@ -2228,8 +2184,8 @@ module Samagotchi
2228
2184
  # Persist deduped used memories onto the session for Web + reload.
2229
2185
  begin
2230
2186
  session.used_memory_names = used_memory_names
2231
- rescue StandardError
2232
- nil
2187
+ rescue StandardError => e
2188
+ Log.warn(:memory, "used_memories_store_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2233
2189
  end
2234
2190
  # Notify live observers of the updated memory list (so yellow bar refreshes
2235
2191
  # even without a tool_call event if the preload was the only addition).
@@ -2238,8 +2194,8 @@ module Samagotchi
2238
2194
 
2239
2195
  begin
2240
2196
  emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
2241
- rescue StandardError
2242
- nil
2197
+ rescue StandardError => e
2198
+ Log.warn(:memory, "used_memories_emit_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2243
2199
  end
2244
2200
  end
2245
2201
  private :publish_used_memories
@@ -2676,12 +2632,16 @@ module Samagotchi
2676
2632
  add_init_task(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
2677
2633
  quiet: quiet, timeout: timeout, failed: failed, &block)
2678
2634
  },
2679
- toolkits: @plugin_kits
2635
+ toolkits: @toolkit_binding.plugin_kits,
2636
+ tool_index: @tool_index,
2637
+ peer_sources: @peer_sources
2680
2638
  )
2681
2639
  # What plugins show while they load (an MCP server that didn't
2682
2640
  # start) waits for the first turn, beside the load warnings: no UI
2683
2641
  # is there yet, and the Engine isn't built.
2684
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)
2685
2645
  end
2686
2646
 
2687
2647
  # Keep a plugin's new tool set (chi.replace_tools, from any thread)
@@ -2699,100 +2659,42 @@ module Samagotchi
2699
2659
  end
2700
2660
  public :apply_staged_tools!
2701
2661
 
2702
- # The kits a session's toolkit resolves against: the built-ins, config's
2703
- # toolkit.kits, the plugins' (Toolkit.kits). Config's are read once per
2704
- # process, like the plugins': an edit to toolkit.kits never changes a
2705
- # live session's tools mid-session (a new worker reads it).
2706
- # @param errors [Array<String>] gets a line per config kit left out
2707
- # @return [Hash{String => Toolkit::Kit}]
2708
- def toolkit_kits(errors = [])
2709
- @config_kits = Toolkit.config_kits unless defined?(@config_kits)
2710
- Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
2711
- end
2712
-
2713
- # The session's toolkit resolved (Toolkit::Resolved): what its next
2714
- # turn declares. A plain full toolkit is nil, the Registry's "every tool".
2715
- def resolved_toolkit(session, errors = [])
2716
- resolved = Toolkit.of(session&.toolkit).resolve(toolkit_kits(errors))
2717
- resolved.everything? ? nil : resolved
2718
- end
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
2719
2668
 
2720
2669
  # The tools this session registered (Tools::Registry), declared or not.
2721
2670
  attr_reader :tools
2722
2671
 
2723
- # +toolkit+ (the session's by default) as its next turn would declare
2724
- # it (/toolkit, the UIs' chip).
2725
- # @return [Toolkit::Explained]
2726
- def toolkit_explained(toolkit = nil)
2727
- toolkit ||= Toolkit.of(@session&.toolkit)
2728
- resolved = toolkit.resolve(toolkit_kits)
2729
- filter = resolved.everything? ? nil : resolved
2730
- Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: @tools.entries(toolkit: filter),
2731
- left_out: @tools.left_out_names(toolkit: filter), pending: filter != @tools.toolkit)
2732
- end
2733
-
2734
- # The session's toolkit for the UIs (the snapshot, command_ran): nil on
2735
- # full, or with no session.
2736
- def toolkit_summary
2737
- @session && toolkit_explained.summary
2738
- end
2739
2672
  public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
2740
2673
 
2741
- # A plugin's ctx.use_toolkit: put the session on toolkit +name+, saved,
2742
- # when it has no model reply yet and no turn runs (D3: no confirm from
2743
- # plugins), and tell the user as /toolkit would; it applies at the next
2744
- # turn's start.
2674
+ # A plugin's ctx.use_toolkit (ToolkitBinding#use).
2745
2675
  # @return [Boolean] whether it was staged
2746
2676
  # @raise [ArgumentError] an unknown toolkit
2747
- def use_toolkit(name)
2748
- raise ArgumentError, "no toolkit named #{name}" unless toolkit_kits.key?(name)
2749
- return false if @session.nil? || turn_running? || history?
2750
-
2751
- toolkit = Toolkit.new(name: name)
2752
- return true if @session.toolkit == toolkit
2753
-
2754
- @session.toolkit = toolkit
2755
- @session.save(state_dir: session_state_dir)
2756
- hook_notify("toolkit: #{name} from the next turn's start", :info, "toolkit", extra: { toolkit: toolkit_summary })
2757
- true
2758
- end
2677
+ def use_toolkit(name) = @toolkit_binding.use(name)
2759
2678
  public :use_toolkit
2760
2679
 
2761
- # Resolve the session's toolkit into the Registry, on the turn thread at
2762
- # a turn's start (before its prompt is built), and build the prompts
2763
- # again when what it declares changed. A name that doesn't resolve (a
2764
- # typo, an uninstalled bundle) declares everything for now and says so
2765
- # once; the saved toolkit stays as it is.
2680
+ # At a turn's start (ToolkitBinding#apply!).
2766
2681
  # @return [Boolean] whether the declared tools changed
2767
- def apply_toolkit!(session, on_event = nil)
2768
- errors = []
2769
- resolved = Toolkit.of(session&.toolkit).resolve(toolkit_kits(errors))
2770
- warn_toolkit(session, resolved, errors, on_event)
2771
- resolved = nil if resolved.everything?
2772
- return false if resolved == @tools.toolkit
2773
-
2774
- @tools.toolkit = resolved
2775
- @prompt_builder&.reset!
2776
- true
2777
- end
2682
+ def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
2778
2683
  private :apply_toolkit!
2779
2684
 
2780
- # The toolkit's load warnings, each once per process: a name that
2781
- # didn't resolve, config kits left out.
2782
- def warn_toolkit(session, resolved, errors, on_event)
2783
- lines = errors.dup
2784
- if resolved.fallback
2785
- lines << "#{Toolkit.of(session&.toolkit).label}: #{resolved.fallback}; this session declares every tool (full) " \
2786
- "until it resolves"
2787
- end
2788
- @toolkit_warned ||= []
2789
- (lines - @toolkit_warned).each do |line|
2790
- @toolkit_warned << line
2791
- Log.warn(:config, "toolkit_unresolved", message: line)
2792
- emit_event(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
2793
- end
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
+ )
2794
2696
  end
2795
- private :warn_toolkit
2697
+ private :build_toolkit_binding
2796
2698
 
2797
2699
  # The tools changed (a plugin's chi.tools_changed!): the system prompts,
2798
2700
  # which declare them, are built again on the next turn.
@@ -2820,7 +2722,7 @@ module Samagotchi
2820
2722
  steer: ->(text, label) { steer(text, source: hook_source(label)) },
2821
2723
  stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
2822
2724
  stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
2823
- toolkit: -> { @session && Toolkit.of(@session.toolkit).name },
2725
+ toolkit: -> { @toolkit_binding.name },
2824
2726
  declared_tools: -> { toolkit_explained.declared.map(&:name) },
2825
2727
  use_toolkit: ->(name) { use_toolkit(name) },
2826
2728
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
@@ -2973,7 +2875,8 @@ module Samagotchi
2973
2875
  def with_generation_report(event)
2974
2876
  report = @metrics.finish_generation
2975
2877
  event.merge(speed: report.speed&.to_h, tokens: report.tokens, served_expected_by: served_expected_by(event[:served_model]))
2976
- rescue StandardError
2878
+ rescue StandardError => e
2879
+ Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2977
2880
  event
2978
2881
  end
2979
2882
  private :with_generation_report
@@ -2990,7 +2893,8 @@ module Samagotchi
2990
2893
  # Capture used memories synchronously in the turn thread.
2991
2894
  read_names = begin
2992
2895
  @used_memories.capture(event, muted: @muted_memory_names)
2993
- 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])
2994
2898
  nil
2995
2899
  end
2996
2900
  # Turn-scoped sink: receives the event hash as the stream handler
@@ -2999,8 +2903,9 @@ module Samagotchi
2999
2903
  if on_event
3000
2904
  begin
3001
2905
  on_event.call(event)
3002
- rescue StandardError
2906
+ rescue StandardError => e
3003
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])
3004
2909
  end
3005
2910
  end
3006
2911