samagotchi 0.96.0 → 0.98.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/bin/chi +3 -0
  4. data/docs/architecture.md +12 -2
  5. data/docs/bundle-authoring.md +7 -4
  6. data/docs/cli.md +13 -4
  7. data/docs/configuration.md +13 -0
  8. data/docs/hooks.md +59 -0
  9. data/docs/internals/background-tasks.md +1 -1
  10. data/docs/internals/bundles.md +2 -2
  11. data/docs/internals/engine.md +21 -1
  12. data/docs/memory.md +43 -3
  13. data/docs/plugins.md +8 -1
  14. data/docs/sessions.md +2 -2
  15. data/lib/samagotchi/answer_refs.rb +16 -9
  16. data/lib/samagotchi/bundle_command/check.rb +5 -3
  17. data/lib/samagotchi/bundle_command/try/run.rb +19 -4
  18. data/lib/samagotchi/bundle_command.rb +2 -2
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/engine.rb +75 -40
  24. data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
  25. data/lib/samagotchi/hooks.rb +4 -1
  26. data/lib/samagotchi/llm/errors.rb +135 -3
  27. data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
  28. data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
  29. data/lib/samagotchi/memory_bundle/profile.rb +14 -4
  30. data/lib/samagotchi/memory_command.rb +237 -0
  31. data/lib/samagotchi/output_formatter.rb +8 -2
  32. data/lib/samagotchi/pending_question.rb +7 -3
  33. data/lib/samagotchi/plugin/api.rb +12 -3
  34. data/lib/samagotchi/question_asked.rb +83 -0
  35. data/lib/samagotchi/question_desk.rb +3 -2
  36. data/lib/samagotchi/send_command.rb +11 -11
  37. data/lib/samagotchi/session_inbox.rb +29 -0
  38. data/lib/samagotchi/session_manager.rb +11 -0
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
  40. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  41. data/lib/samagotchi/terminal_ui.rb +1 -0
  42. data/lib/samagotchi/testing/bundle.rb +18 -1
  43. data/lib/samagotchi/tools/memory.rb +1 -1
  44. data/lib/samagotchi/turn_checkpoint.rb +6 -0
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +30 -9
  47. data/lib/samagotchi/web/public/app.js +44 -11
  48. data/lib/samagotchi/web/public/index.html +36 -13
  49. data/lib/samagotchi/web/public/info_bar_model.js +0 -2
  50. data/lib/samagotchi/web/public/lists_view.js +32 -8
  51. data/lib/samagotchi/web/public/markdown_place.js +25 -8
  52. data/lib/samagotchi/web/public/question_cards.js +23 -6
  53. data/lib/samagotchi/web/public/session_rename.js +88 -15
  54. data/lib/samagotchi/web/public/session_title.js +6 -0
  55. data/lib/samagotchi/web/public/stage_frame.js +24 -14
  56. data/lib/samagotchi/web/public/stage_model.js +4 -2
  57. data/lib/samagotchi/web/public/stage_slots.js +41 -9
  58. data/lib/samagotchi/web/public/stage_view.js +149 -8
  59. data/lib/samagotchi/web/public/stream_handlers.js +5 -1
  60. data/lib/samagotchi/web/public/turn_model.js +20 -0
  61. data/lib/samagotchi/web/public/turn_view.js +22 -9
  62. data/lib/samagotchi/worker.rb +19 -5
  63. data/lib/samagotchi/worker_idle_exit.rb +16 -8
  64. metadata +3 -1
@@ -51,8 +51,9 @@ module Samagotchi
51
51
 
52
52
  LIB_DIR = File.expand_path("../..", __dir__)
53
53
  TRUSTED = %w[reviewed trusted].freeze
54
- # A call of the plugin API, or a name SINCE lists, in the source.
55
- API_CALL = /\b(?:chi|ctx)\.[a-z_]+\b|\bfallback_for:|\bSamagotchi::AtomicFile\b/
54
+ # A call of the plugin API (with one member after it: ctx.sessions.children),
55
+ # or a name SINCE lists, in the source.
56
+ API_CALL = /\b(?:chi|ctx)\.[a-z_]+(?:\.[a-z_]+)?\b|\bfallback_for:|\bSamagotchi::AtomicFile\b/
56
57
  # A requirement's lower bound: >= X, > X, ~> X or = X.
57
58
  LOWER_BOUND = /\A(?:>=|>|~>|=)?\s*(\d+(?:\.\d+)*)\z/
58
59
 
@@ -213,7 +214,8 @@ module Samagotchi
213
214
  floor = lower_bound(manifest.requires_chi)
214
215
  return unless floor && File.file?(path(manifest.plugin[:file]))
215
216
 
216
- calls = File.read(path(manifest.plugin[:file])).scan(API_CALL).uniq.select { |call| Plugin::Api::SINCE.key?(call) }
217
+ calls = File.read(path(manifest.plugin[:file])).scan(API_CALL).flat_map { |call| [call, call.split(".").first(2).join(".")] }
218
+ .uniq.select { |call| Plugin::Api::SINCE.key?(call) }
217
219
  call = calls.max_by { |c| Gem::Version.new(Plugin::Api::SINCE[c]) } or return
218
220
  version = Plugin::Api::SINCE[call]
219
221
  return unless Gem::Version.new(version) > floor
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "rbconfig"
5
+ require_relative "../../cli/exit"
5
6
  require_relative "../../process_group"
6
7
 
7
8
  module Samagotchi
@@ -54,7 +55,7 @@ module Samagotchi
54
55
  end
55
56
  trap_state[:pid] = nil
56
57
  out, err = finish_readers(readers, streams)
57
- code = timed_out ? CLI::Exit::FAILED : process_exit_status(status)
58
+ code = timed_out ? CLI::Exit::FAILED : exit_code(status, trap_state)
58
59
  Result.new(exit_status: code, stdout: out, stderr: err, session_id: err[/^Session: (\S+)/, 1],
59
60
  timed_out: timed_out)
60
61
  end
@@ -91,13 +92,16 @@ module Samagotchi
91
92
  end
92
93
 
93
94
  def trap_forwarding
94
- state = { pid: nil, wait: nil, pending: nil }
95
+ state = { pid: nil, wait: nil, pending: nil, interrupted: false }
95
96
  old = {}
96
97
  %w[INT TERM].each do |signal|
97
98
  old[signal] = Signal.trap(signal) do
98
99
  if state[:pid]
99
100
  # Not once its waiter reaped it: that group may be gone.
100
- ProcessGroup.signal(state[:pid], signal) if state[:wait]&.alive?
101
+ if state[:wait]&.alive?
102
+ state[:interrupted] ||= signal == "INT"
103
+ ProcessGroup.signal(state[:pid], signal)
104
+ end
101
105
  else
102
106
  state[:pending] ||= signal
103
107
  end
@@ -111,7 +115,10 @@ module Samagotchi
111
115
  state[:pid] = pid
112
116
  pending = state[:pending]
113
117
  state[:pending] = nil
114
- ProcessGroup.signal(pid, pending) if pending
118
+ return unless pending
119
+
120
+ state[:interrupted] ||= pending == "INT"
121
+ ProcessGroup.signal(pid, pending)
115
122
  end
116
123
 
117
124
  def restore_traps(old)
@@ -171,6 +178,14 @@ module Samagotchi
171
178
  readers.each(&:join)
172
179
  end
173
180
 
181
+ # A Ctrl-C forwarded to the child is an interrupted run, however it
182
+ # ended: a Ruby still booting (before chi's INT handler) exits 1.
183
+ def exit_code(status, trap_state)
184
+ return CLI::Exit::INTERRUPTED if trap_state[:interrupted] && !status.success?
185
+
186
+ process_exit_status(status)
187
+ end
188
+
174
189
  def process_exit_status(status)
175
190
  status.exitstatus || (128 + status.termsig.to_i)
176
191
  end
@@ -476,7 +476,7 @@ module Samagotchi
476
476
  hooks.each do |hk, hook|
477
477
  hook_path = File.join(Samagotchi::MemoryBundle::Provenance.new(name: name).hooks_dir, hk)
478
478
  exists = File.exist?(hook_path) ? "ok" : "missing"
479
- @stdout.puts " #{hk}: event=#{hook.event} on_error=#{hook.on_error} priority=#{hook.priority} [#{exists}]"
479
+ @stdout.puts " #{hk}: #{hook.events_field} on_error=#{hook.on_error} priority=#{hook.priority} [#{exists}]"
480
480
  end
481
481
  @stdout.puts " requires_chi: #{st[:provenance].requires_chi}" if st[:hooks_requires_failure]
482
482
  @stdout.puts " not loaded: #{st[:hooks_requires_failure]}" if st[:hooks_requires_failure]
@@ -658,7 +658,7 @@ module Samagotchi
658
658
  end
659
659
 
660
660
  def hook_metadata_line(hook)
661
- "--- metadata: event=#{hook.event} on_error=#{hook.on_error} priority=#{hook.priority} sha256=#{hook.sha256}"
661
+ "--- metadata: #{hook.events_field} on_error=#{hook.on_error} priority=#{hook.priority} sha256=#{hook.sha256}"
662
662
  end
663
663
 
664
664
  def build(rest)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.7.0
3
+ version: 0.7.1
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.72.0"
7
+ requires_chi: ">= 0.98.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -4,8 +4,8 @@
4
4
  require "open3"
5
5
  require "samagotchi/ref"
6
6
 
7
- # An after_turn hook that links the source refs the model's answer
8
- # mentions — a JIRA ticket, a GitHub issue, an internal wiki page. In the
7
+ # An after_turn (and question_asked) hook that links the source refs the
8
+ # model's answer mentions — a JIRA ticket, a GitHub issue, an internal wiki page. In the
9
9
  # web, each ref in the answer becomes a link (`[JIRA-123](https://…)`,
10
10
  # through event[:present]: display only, the model's text stays as it was,
11
11
  # and it survives a reload). Every UI also gets one line right after the
@@ -58,6 +58,14 @@ require "samagotchi/ref"
58
58
  # action, "Open in <name>", the ref's URL. A URL that isn't http(s) stays a
59
59
  # markdown link only. The web's ref menu shows the action.
60
60
  #
61
+ # The hook is on :question_asked too (the manifest's events:, chi newer than
62
+ # 0.97.0): while a question of the model's (or an approval of its call)
63
+ # waits, the refs in the reply that asks it, and in the question's text and
64
+ # options, are refs on that reply, the same as an answer's. The web pairs
65
+ # them in the stage's asking slot, on the question's card and on that step
66
+ # in the history (a ref only in the question's text pairs there alone). No
67
+ # links in the text and no note line for it: the turn's end has those.
68
+ #
61
69
  # The note is not stored in the conversation: it is an event, replayed by a
62
70
  # UI with the session's cards (a reload keeps it, after the worker stopped
63
71
  # too: cards.json). The links are stored as the answer's display. A ref in
@@ -120,10 +128,20 @@ class SourceLinks
120
128
  end
121
129
 
122
130
  def call(event)
123
- return unless event.is_a?(Hash) && event[:type] == :after_turn
124
- return unless event[:status].to_s == "completed"
131
+ return unless event.is_a?(Hash)
125
132
  return if @sources.empty?
126
133
 
134
+ case event[:type]
135
+ when :after_turn then after_turn(event)
136
+ when :question_asked then question_asked(event)
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def after_turn(event)
143
+ return unless event[:status].to_s == "completed"
144
+
127
145
  text = last_model_text(event[:messages])
128
146
  return if text.nil? || text.empty?
129
147
 
@@ -141,7 +159,23 @@ class SourceLinks
141
159
  event[:notify]&.call(line(found), level: :info, fallback_for: covered ? :display : nil)
142
160
  end
143
161
 
144
- private
162
+ # A question of the model's (or an approval of its call) is about to
163
+ # open: the refs in the reply that asks it and in the question's own text
164
+ # and options, each scanned on its own (a code span never runs from one
165
+ # into the next), as refs on that reply. No present (a mid-turn text has
166
+ # no display) and no note line (the after-turn one stays the terminals').
167
+ def question_asked(event)
168
+ refs = event[:refs]
169
+ return unless refs
170
+
171
+ question = event[:question].is_a?(Hash) ? event[:question] : {}
172
+ field = ->(key) { question.key?(key) ? question[key] : question[key.to_s] }
173
+ parts = [event[:text], field.call(:question), *Array(field.call(:options))]
174
+ parts.each do |part|
175
+ text = part.is_a?(String) ? part : ""
176
+ add_refs(refs, occurrences(text[0, MAX_SCAN])) unless text.empty?
177
+ end
178
+ end
145
179
 
146
180
  # Links the refs in the answer as shown: the model's text unless an
147
181
  # earlier hook changed it (then its offsets differ, so it is scanned
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.5.1
3
+ version: 0.6.0
4
4
  scope: system
5
- description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
5
+ description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, refs in a waiting question's reply, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.61.0"
7
+ requires_chi: ">= 0.98.0"
8
8
  files:
9
9
  source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
10
10
  hooks:
11
11
  source_links.rb:
12
- sha256: sha256:c41249ec0bbb708ec7827e1d0f66689dfbbc415c1da5fe33f5d3a8ec8e5eaa2f
13
- event: after_turn
12
+ sha256: sha256:84c0bef064fb1ea76749a61e758eba612662aaf0f05f18a18ab4228497078fa2
13
+ events: [after_turn, question_asked]
14
14
  on_error: log
15
15
  priority: 90
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.96.0
3
+ version: 0.98.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -77,6 +77,7 @@ require_relative "answer_display"
77
77
  require_relative "answer_refs"
78
78
  require_relative "edit_preview"
79
79
  require_relative "question_desk"
80
+ require_relative "question_asked"
80
81
  require_relative "child_reports"
81
82
  require_relative "relay_desk"
82
83
  require_relative "guardrail_wiring"
@@ -187,49 +188,13 @@ module Samagotchi
187
188
  @question_desk = build_question_desk
188
189
  # Built here, not on first use: the turn thread and the Bridge's read it.
189
190
  @relay_desk = RelayDesk.new
190
- @extension_load = ExtensionLoad.new(hook_failures: @guardrail_failures)
191
- @hooks = @extension_load.hooks
192
- # The tools this session offers (the prompts' declarations and the
193
- # kernel's dispatch): the built-ins, per Engine. execute.description
194
- # off: execute offers no description parameter. +task_finish_notes+:
195
- # a worker's, whose finish wake reports a task left running (the
196
- # Worker's TaskWatch): execute and task_create say so.
197
- @tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false,
198
- policy: Config.get(LLMContextStrategy::POLICY_SETTING),
199
- task_finish_notes: task_finish_notes)
200
- restrict_scratch_tools! if @scratch
201
- # Likewise the slash commands its SessionCommands run.
202
- @command_registry = SessionCommands.register_builtins(Commands::Registry.new)
203
- # Installed bundles' plugins add commands, tools and hooks to these
204
- # (docs/plugins.md); one that fails is announced with the load
205
- # failures, and the rest still load.
206
- # Their services (chi.service), which #shutdown stops, and the anytime
207
- # commands running now (#spawn_anytime), which it waits for.
208
- @services = Plugin::Services.new
209
- @anytime_threads = []
210
- # chi.init tasks (#add_init_task), started by #start_init_tasks!, and
211
- # plugins' tool sets from chi.replace_tools until the turn thread
212
- # applies them (#apply_staged_tools!). Built before the plugins load.
213
- @plugin_tasks = build_plugin_tasks
214
- # The session's toolkit: the kits (the plugins' too, chi.toolkit),
215
- # what its next turn declares, the turn-start apply.
216
- @toolkit_binding = build_toolkit_binding
217
191
  # The notices a turn's thinking level leads to (once per session and host).
218
192
  @thinking_notices = ThinkingNotices.new(session: -> { @session }, profile: -> { self.profile },
219
193
  notify: ->(text, level, source) { hook_notify(text, level, source) })
220
- # The tools plugins publish without declaring (chi.tool_source), and
221
- # the search and call tools that reach them (Tools::ToolIndex).
222
- @tool_index = Tools::ToolIndex.new
223
- # The context sources the peer bundles declare as a peer agent's
224
- # answers (chi.peer_source); the worker's ContextAbsorber asks it.
225
- @peer_sources = PeerSources.new
226
194
  @lifecycle_mutex = Mutex.new
227
195
  @shut_down = false
228
- load_plugins if plugins
229
- # Registered from what the sources published while they loaded, and
230
- # with no plugins too: a toolkit's left-out built-ins are reachable
231
- # either way (declared per toolkit, Tools::Registry).
232
- @tool_index.declare(@tools, failures: @plugin_failures)
196
+ # The built-ins, the commands, and the plugins that add to them.
197
+ build_tools_and_plugins(load: plugins, task_finish_notes: task_finish_notes)
233
198
  guardrail_rules
234
199
  # The Engine owns the reminder store; its kernel (built here, or a
235
200
  # spec's) gets it, so tool calls via KernelLoop and reminder injection
@@ -277,6 +242,52 @@ module Samagotchi
277
242
 
278
243
  # ── initialize's collaborators ──────────────────────────────────────────
279
244
 
245
+ # The Engine as the plugins' host: the tools and slash commands this
246
+ # session offers, what plugins add to them (services, init tasks, the
247
+ # toolkit, the tool index, peer sources), then the plugins themselves
248
+ # unless +load+ is false (see #initialize for when).
249
+ def build_tools_and_plugins(load:, task_finish_notes:)
250
+ @extension_load = ExtensionLoad.new(hook_failures: @guardrail_failures)
251
+ @hooks = @extension_load.hooks
252
+ # The tools this session offers (the prompts' declarations and the
253
+ # kernel's dispatch): the built-ins, per Engine. execute.description
254
+ # off: execute offers no description parameter. +task_finish_notes+:
255
+ # a worker's, whose finish wake reports a task left running (the
256
+ # Worker's TaskWatch): execute and task_create say so.
257
+ @tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false,
258
+ policy: Config.get(LLMContextStrategy::POLICY_SETTING),
259
+ task_finish_notes: task_finish_notes)
260
+ restrict_scratch_tools! if @scratch
261
+ # Likewise the slash commands its SessionCommands run.
262
+ @command_registry = SessionCommands.register_builtins(Commands::Registry.new)
263
+ # Installed bundles' plugins add commands, tools and hooks to these
264
+ # (docs/plugins.md); one that fails is announced with the load
265
+ # failures, and the rest still load.
266
+ # Their services (chi.service), which #shutdown stops, and the anytime
267
+ # commands running now (#spawn_anytime), which it waits for.
268
+ @services = Plugin::Services.new
269
+ @anytime_threads = []
270
+ # chi.init tasks (#add_init_task), started by #start_init_tasks!, and
271
+ # plugins' tool sets from chi.replace_tools until the turn thread
272
+ # applies them (#apply_staged_tools!). Built before the plugins load.
273
+ @plugin_tasks = build_plugin_tasks
274
+ # The session's toolkit: the kits (the plugins' too, chi.toolkit),
275
+ # what its next turn declares, the turn-start apply.
276
+ @toolkit_binding = build_toolkit_binding
277
+ # The tools plugins publish without declaring (chi.tool_source), and
278
+ # the search and call tools that reach them (Tools::ToolIndex).
279
+ @tool_index = Tools::ToolIndex.new
280
+ # The context sources the peer bundles declare as a peer agent's
281
+ # answers (chi.peer_source); the worker's ContextAbsorber asks it.
282
+ @peer_sources = PeerSources.new
283
+ load_plugins if load
284
+ # Registered from what the sources published while they loaded, and
285
+ # with no plugins too: a toolkit's left-out built-ins are reachable
286
+ # either way (declared per toolkit, Tools::Registry).
287
+ @tool_index.declare(@tools, failures: @plugin_failures)
288
+ end
289
+ private :build_tools_and_plugins
290
+
280
291
  # The tools a scratch session doesn't offer, and its memory_write.
281
292
  def restrict_scratch_tools!
282
293
  # A scratch session's children would outlive it, and so would a
@@ -354,7 +365,8 @@ module Samagotchi
354
365
  model_key: -> { @model_key },
355
366
  model_name: -> { guardrail_model_name },
356
367
  cancelled: -> { !!active_cancel_controller&.cancelled? },
357
- ask: ->(fields) { open_question(fields) },
368
+ # An approval of the model's call: :question_asked first (QuestionAsked).
369
+ ask: ->(fields) { open_question(question_asked(QuestionAsked::KIND_APPROVAL, fields)) },
358
370
  notify: ->(message) { guardrail_notify(message) }
359
371
  )
360
372
  end
@@ -1260,6 +1272,7 @@ module Samagotchi
1260
1272
  muted_memory_names: @muted_memory_names.dup,
1261
1273
  prompt_notes: prompt_notes.map(&:to_h),
1262
1274
  parent_id: @session&.parent_id,
1275
+ title: @session&.title,
1263
1276
  model_name: effective_model_ref,
1264
1277
  served_model: served_pair[0],
1265
1278
  served_model_for: served_pair[1],
@@ -1565,13 +1578,35 @@ module Samagotchi
1565
1578
 
1566
1579
  # Ask the model's question (ask_user_question): the kernel's
1567
1580
  # question_handler, called on the turn thread with the payload
1568
- # Tools::AskUserQuestion.validate made (QuestionDesk#request).
1581
+ # Tools::AskUserQuestion.validate made (QuestionDesk#request), after
1582
+ # the :question_asked hooks (QuestionAsked) gave the step its refs; a
1583
+ # --non-interactive run has no one to ask, so they don't fire there.
1569
1584
  # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
1570
1585
  # @return [String] normalized answer JSON
1571
1586
  def request_question(payload)
1587
+ payload = question_asked(QuestionAsked::KIND_QUESTION, payload) unless interface == :non_interactive
1572
1588
  @question_desk.request(payload)
1573
1589
  end
1574
1590
 
1591
+ # The :question_asked hooks for a question of the model's about to
1592
+ # open: the fields with the asking step's refs (QuestionAsked.fire).
1593
+ # @return [Hash]
1594
+ def question_asked(kind, fields)
1595
+ QuestionAsked.fire(hooks: @hooks, conversation: live_conversation, kind: kind, fields: fields,
1596
+ cwd: @session&.working_directory, synchronize: ->(&block) { synchronize_events(&block) },
1597
+ visible: ->(content) { question_step_text(content) })
1598
+ end
1599
+ private :question_asked
1600
+
1601
+ # A step's text as the asking slot shows it: the native loop's raw
1602
+ # response without its thoughts and tool-call bodies (a chat step has
1603
+ # neither), then the markup's tokens (OutputFormatter.strip_markup).
1604
+ def question_step_text(content)
1605
+ parser = @kernel.parser
1606
+ OutputFormatter.strip_markup(parser.strip_tool_calls(parser.strip_thought(content)))
1607
+ end
1608
+ private :question_step_text
1609
+
1575
1610
  # Open a question for the UIs and wait for its answer
1576
1611
  # (QuestionDesk#open_question: BLOCKS until answered or cancelled).
1577
1612
  # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
@@ -69,9 +69,9 @@ module Samagotchi
69
69
 
70
70
  hook = MemoryBundle::BundleHook.parse(raw_meta)
71
71
  # A hook with no declared event cannot be auto-registered.
72
- next if hook.event.empty?
72
+ next if hook.events.empty?
73
73
 
74
- fail_closed = hook.on_error == "fail_closed" && hook.event == "before_tool_call"
74
+ fail_closed = hook.events.any? { |event| hook.fail_closed?(event) }
75
75
  what = "hook #{basename} (bundle #{bundle_name})"
76
76
  file = hooks_dir && File.join(hooks_dir, basename)
77
77
  unless file && File.exist?(file)
@@ -98,9 +98,10 @@ module Samagotchi
98
98
  loaded
99
99
  end
100
100
 
101
- # Build one hook file and register it in +registry+ under its
102
- # event, priority and error policy (a fail_closed before_tool_call
103
- # one denies when it raises). No missing-file, sha or requires_chi
101
+ # Build one hook file and register it in +registry+ under each of
102
+ # its events (one instance for all: its state is shared), with its
103
+ # priority and error policy (a fail_closed before_tool_call one
104
+ # denies when it raises). No missing-file, sha or requires_chi
104
105
  # check: #load makes them; the test kit loads a bundle's source
105
106
  # this way.
106
107
  # @param hook [MemoryBundle::BundleHook] its manifest entry
@@ -109,17 +110,19 @@ module Samagotchi
109
110
  # @raise [Exception] what loading or building the file raised;
110
111
  # nothing is registered then
111
112
  def load_hook_file(bundle_name, basename, file, hook, registry, settings: {}, namespace: nil)
112
- event_sym = hook.event.to_sym
113
- fail_closed = hook.on_error == "fail_closed" && event_sym == :before_tool_call
114
113
  plugin = instantiate(bundle_name, basename, file, settings: settings, namespace: namespace)
115
- policy = if fail_closed then :deny
116
- elsif hook.on_error == "log" then :log
117
- else :skip
118
- end
119
- label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
120
- handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
121
- fields: { bundle: bundle_name }) { |event| plugin.call(event) }
122
- registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: hook.priority, &handler)
114
+ hook.events.each do |event_name|
115
+ event_sym = event_name.to_sym
116
+ fail_closed = hook.fail_closed?(event_sym)
117
+ policy = if fail_closed then :deny
118
+ elsif hook.on_error == "log" then :log
119
+ else :skip
120
+ end
121
+ label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
122
+ handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
123
+ fields: { bundle: bundle_name }) { |event| plugin.call(event) }
124
+ registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: hook.priority, &handler)
125
+ end
123
126
  nil
124
127
  end
125
128
 
@@ -24,12 +24,15 @@ module Samagotchi
24
24
  # :before_tool_call — before a tool is dispatched: vote with event[:guardrail].deny!/ask!
25
25
  # (or the older event[:blocked]=true + event[:block_reason]); the
26
26
  # event also has context: and targets: (docs/guardrails.md)
27
+ # :question_asked — a question of the model's (ask_user_question) or an approval of its
28
+ # call is about to open: event[:refs] attaches refs to the asking
29
+ # reply (QuestionAsked)
27
30
  # :after_tool_call — after tool execution, before result injection
28
31
  module Hooks
29
32
  # Every event a hook can be registered for, in a turn's order
30
33
  # (docs/hooks.md). Plugin::Api#on takes any symbol: `chi bundle check`
31
34
  # warns on one not here.
32
35
  EVENTS = %i[session_start before_turn before_generation generation_progress after_generation before_tool_call
33
- after_tool_call after_turn session_end].freeze
36
+ question_asked after_tool_call after_turn session_end].freeze
34
37
  end
35
38
  end
@@ -191,12 +191,24 @@ module Samagotchi
191
191
  elsif tools_unsupported?
192
192
  "host #{host} rejected the request: #{tools_detail}; #{TOOLS_HINT}"
193
193
  else
194
- "host #{host} rejected the request: #{detail}#{"; #{hint}" if hint}"
194
+ "host #{host} rejected the request: #{hinted_detail}"
195
195
  end
196
196
  end
197
197
 
198
198
  private
199
199
 
200
+ # The detail with the hint, ahead of a model list the detail has (a
201
+ # 404's "Available models: …", cut at the message's limit), else at
202
+ # its end.
203
+ def hinted_detail
204
+ return detail unless hint
205
+
206
+ list_at = detail.index(ProviderErrors::AVAILABLE_MODELS_START_RE)
207
+ return "#{detail}; #{hint}" unless list_at
208
+
209
+ "#{detail[0...list_at].sub(/[\s.;:]+\z/, "")}; #{hint} #{detail[list_at..].lstrip}"
210
+ end
211
+
200
212
  # The detail up to the end of the sentence that says the model takes no
201
213
  # tools: what a provider adds after it (OpenRouter: "Try disabling
202
214
  # "execute"…" and a routing docs link) is advice for its own UI.
@@ -384,6 +396,28 @@ module Samagotchi
384
396
  # Seconds to wait before retrying a CreditsHeld without a Retry-After.
385
397
  CREDITS_SETTLE_DELAY = 20.0
386
398
  RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
399
+ # How much of a server's error message the summary keeps.
400
+ MESSAGE_LIMIT = 500
401
+ # The 404 model hint's bounds (the message is the server's): how much
402
+ # of the message it reads, the longest model id, how many listed ids.
403
+ HINT_SCAN_CHARS = 64 * 1024
404
+ HINT_ID_MAX = 200
405
+ HINT_IDS_MAX = 2000
406
+ # A typo the hint forgives: at most this many edits, fewer than a
407
+ # third of the name, in a name at least HINT_FUZZY_MIN long.
408
+ HINT_MAX_EDITS = 2
409
+ HINT_FUZZY_MIN = 6
410
+ # The most ids a hint names.
411
+ HINT_SUGGESTIONS = 3
412
+ # A 404's model it couldn't find: "Model 'X' not found", "model `X`
413
+ # does not exist", "model X-1 not found" (unquoted, an id-like word,
414
+ # checked after the match). Bounded quantifiers: a server's long token
415
+ # can't make the match quadratic (Ruby 3.3 has no regex memoization).
416
+ MODEL_NOT_FOUND_RE = /model\s+(?:["'`]([^"'`\s]{1,#{HINT_ID_MAX}})["'`]|([^"'`\s]{1,#{HINT_ID_MAX}}))\s+(?:was\s+)?(?:not found|does not exist)/i
417
+ # The ids a 404 lists after "Available models:".
418
+ AVAILABLE_MODELS_RE = /available models?:\s*(.+)\z/im
419
+ # Where that list starts, for the summary to put its hint ahead of it.
420
+ AVAILABLE_MODELS_START_RE = /\s*\bavailable models?:/i
387
421
  # The HTTP status an Anthropic error.type stands for, for an error
388
422
  # object that carries no numeric code (a Messages stream's error event,
389
423
  # an OpenAI error whose code is null or a name).
@@ -426,6 +460,7 @@ module Samagotchi
426
460
  else
427
461
  OutOfCredits.new(text, **options)
428
462
  end
463
+ when 404 then bad_request(text, status, message, options.merge(hint: model_hint(error_message(body, limit: nil))))
429
464
  when 400..499 then bad_request(text, status, message, options)
430
465
  when 500..599
431
466
  ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
@@ -492,7 +527,7 @@ module Samagotchi
492
527
  # upstream failure (OpenRouter) often says only "Provider returned
493
528
  # error" and puts the upstream's words in error.metadata.raw and its
494
529
  # name in metadata.provider_name; both are added when present.
495
- def error_message(body)
530
+ def error_message(body, limit: MESSAGE_LIMIT)
496
531
  parsed = parse_json(body.to_s)
497
532
  message =
498
533
  if parsed.is_a?(Hash)
@@ -502,9 +537,106 @@ module Samagotchi
502
537
  message = body.to_s if message.nil? || message.to_s.strip.empty?
503
538
  message = message.to_s.strip
504
539
  message = with_upstream(message, parsed["error"]["metadata"]) if parsed.is_a?(Hash) && parsed["error"].is_a?(Hash)
505
- message.empty? ? "(empty body)" : message[0, 500]
540
+ return "(empty body)" if message.empty?
541
+
542
+ limit ? message[0, limit] : message
543
+ end
544
+
545
+ # "did you mean 'ID'?" for a 404 that names the model it couldn't find
546
+ # and lists the ones it has (oMLX: "Model 'X' not found. Available
547
+ # models: a, b, …"), naming up to HINT_SUGGESTIONS ids
548
+ # (#closest_model_ids: "did you mean 'A' or 'B'?"). Read from the
549
+ # message past the summary's cut (MESSAGE_LIMIT), within the HINT_*
550
+ # bounds; nil when there is no list or nothing close.
551
+ def model_hint(message)
552
+ message = message[0, HINT_SCAN_CHARS]
553
+ match = MODEL_NOT_FOUND_RE.match(message)
554
+ requested = match && (match[1] || match[2])
555
+ return nil unless requested && (match[1] || requested.match?(%r{[-/.:_\d]}))
556
+
557
+ list = message[AVAILABLE_MODELS_RE, 1]
558
+ return nil unless list
559
+
560
+ ids = list.split(/,\s*|\s+/, HINT_IDS_MAX + 1).first(HINT_IDS_MAX)
561
+ .map { |id| id.delete("[]\"'`").sub(/[.;]+\z/, "") }
562
+ .reject { |id| id.empty? || id.length > HINT_ID_MAX }.uniq
563
+ return nil if ids.empty? || ids.include?(requested)
564
+
565
+ close = closest_model_ids(requested, ids)
566
+ "did you mean #{close.map { |id| "'#{id}'" }.join(" or ")}?" unless close.empty?
567
+ end
568
+
569
+ # The listed ids closest to +requested+, at most HINT_SUGGESTIONS, in
570
+ # the first tier that has any: the ids that are +requested+ but for
571
+ # case, or whose last segment (after "/" or "--") is; else the ids a
572
+ # typo away (#typo_distance, whole or last segment), the nearest
573
+ # only; else the ids that contain +requested+ (HINT_FUZZY_MIN
574
+ # characters or more) or whose last segment it contains, the
575
+ # shortest first.
576
+ def closest_model_ids(requested, ids)
577
+ probe = requested.downcase
578
+ exact = ids.select { |id| id.downcase == probe || last_segment(id) == probe }
579
+ return exact.first(HINT_SUGGESTIONS) unless exact.empty?
580
+
581
+ scored = ids.filter_map do |id|
582
+ distance = [id.downcase, last_segment(id)].uniq.filter_map { |name| typo_distance(probe, name) }.min
583
+ [distance, id] if distance
584
+ end
585
+ unless scored.empty?
586
+ best = scored.map(&:first).min
587
+ return scored.select { |distance, _| distance == best }.map(&:last).first(HINT_SUGGESTIONS)
588
+ end
589
+
590
+ containing_ids(probe, ids)
506
591
  end
507
592
 
593
+ # The ids that contain +probe+ or whose last segment +probe+ contains
594
+ # (either one HINT_FUZZY_MIN characters or more), closest in length
595
+ # first, at most HINT_SUGGESTIONS.
596
+ def containing_ids(probe, ids)
597
+ return [] if probe.length < HINT_FUZZY_MIN
598
+
599
+ ids.select do |id|
600
+ segment = last_segment(id)
601
+ id.downcase.include?(probe) || (segment.length >= HINT_FUZZY_MIN && probe.include?(segment))
602
+ end.each_with_index.sort_by { |id, index| [(id.length - probe.length).abs, index] }
603
+ .first(HINT_SUGGESTIONS).map(&:first)
604
+ end
605
+
606
+ # The edit distance of +a+ and +b+ when it is a typo's: at most
607
+ # HINT_MAX_EDITS and under a third of the shorter one, which has
608
+ # HINT_FUZZY_MIN characters or more; else nil. A banded Levenshtein:
609
+ # linear in the length.
610
+ def typo_distance(a, b)
611
+ shorter = [a.length, b.length].min
612
+ return nil if shorter < HINT_FUZZY_MIN || (a.length - b.length).abs > HINT_MAX_EDITS
613
+
614
+ distance = banded_distance(a, b, HINT_MAX_EDITS)
615
+ distance if distance && distance * 3 < shorter
616
+ end
617
+
618
+ # Levenshtein distance of +a+ and +b+ if at most +limit+, else nil;
619
+ # only cells within +limit+ of the diagonal are computed.
620
+ def banded_distance(a, b, limit)
621
+ far = limit + 1
622
+ prev = Hash.new(far)
623
+ (0..[limit, b.length].min).each { |j| prev[j] = j }
624
+ a.each_char.with_index(1) do |ca, i|
625
+ cur = Hash.new(far)
626
+ cur[0] = i if i <= limit
627
+ ([i - limit, 1].max..[i + limit, b.length].min).each do |j|
628
+ cost = ca == b[j - 1] ? 0 : 1
629
+ cur[j] = [prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost, far].min
630
+ end
631
+ return nil if cur.empty? || cur.values.min > limit
632
+
633
+ prev = cur
634
+ end
635
+ prev[b.length] <= limit ? prev[b.length] : nil
636
+ end
637
+
638
+ def last_segment(id) = id.downcase.split(%r{/|--}).last
639
+
508
640
  # error.metadata.reason of a JSON error body (OpenRouter), or nil.
509
641
  def error_reason(body)
510
642
  parsed = parse_json(body.to_s)