samagotchi 0.97.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +10 -2
  4. data/docs/bundle-authoring.md +5 -3
  5. data/docs/cli.md +8 -3
  6. data/docs/hooks.md +59 -0
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +18 -0
  9. data/docs/plugins.md +8 -1
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/answer_refs.rb +16 -9
  12. data/lib/samagotchi/bundle_command.rb +2 -2
  13. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
  15. data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/engine.rb +26 -2
  18. data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
  19. data/lib/samagotchi/hooks.rb +4 -1
  20. data/lib/samagotchi/llm/errors.rb +51 -17
  21. data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
  22. data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
  23. data/lib/samagotchi/output_formatter.rb +8 -2
  24. data/lib/samagotchi/pending_question.rb +7 -3
  25. data/lib/samagotchi/plugin/api.rb +5 -0
  26. data/lib/samagotchi/question_asked.rb +83 -0
  27. data/lib/samagotchi/question_desk.rb +3 -2
  28. data/lib/samagotchi/testing/bundle.rb +18 -1
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +30 -9
  31. data/lib/samagotchi/web/public/app.js +44 -11
  32. data/lib/samagotchi/web/public/index.html +36 -13
  33. data/lib/samagotchi/web/public/info_bar_model.js +0 -2
  34. data/lib/samagotchi/web/public/lists_view.js +32 -8
  35. data/lib/samagotchi/web/public/markdown_place.js +25 -8
  36. data/lib/samagotchi/web/public/question_cards.js +23 -6
  37. data/lib/samagotchi/web/public/session_rename.js +88 -15
  38. data/lib/samagotchi/web/public/session_title.js +6 -0
  39. data/lib/samagotchi/web/public/stage_frame.js +24 -14
  40. data/lib/samagotchi/web/public/stage_model.js +4 -2
  41. data/lib/samagotchi/web/public/stage_slots.js +41 -9
  42. data/lib/samagotchi/web/public/stage_view.js +149 -8
  43. data/lib/samagotchi/web/public/stream_handlers.js +5 -1
  44. data/lib/samagotchi/web/public/turn_model.js +20 -0
  45. data/lib/samagotchi/web/public/turn_view.js +22 -9
  46. metadata +2 -1
@@ -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.
@@ -395,6 +407,8 @@ module Samagotchi
395
407
  # third of the name, in a name at least HINT_FUZZY_MIN long.
396
408
  HINT_MAX_EDITS = 2
397
409
  HINT_FUZZY_MIN = 6
410
+ # The most ids a hint names.
411
+ HINT_SUGGESTIONS = 3
398
412
  # A 404's model it couldn't find: "Model 'X' not found", "model `X`
399
413
  # does not exist", "model X-1 not found" (unquoted, an id-like word,
400
414
  # checked after the match). Bounded quantifiers: a server's long token
@@ -402,6 +416,8 @@ module Samagotchi
402
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
403
417
  # The ids a 404 lists after "Available models:".
404
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
405
421
  # The HTTP status an Anthropic error.type stands for, for an error
406
422
  # object that carries no numeric code (a Messages stream's error event,
407
423
  # an OpenAI error whose code is null or a name).
@@ -528,11 +544,10 @@ module Samagotchi
528
544
 
529
545
  # "did you mean 'ID'?" for a 404 that names the model it couldn't find
530
546
  # and lists the ones it has (oMLX: "Model 'X' not found. Available
531
- # models: a, b, …"): the listed id that is X but for case, the one
532
- # whose last segment (after "/" or "--") is X, else the one id a
533
- # typo away (#typo_distance, whole or its last segment). Read from
534
- # the message past the summary's cut (MESSAGE_LIMIT), within the
535
- # HINT_* bounds; nil when there is no list, nothing close or a tie.
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.
536
551
  def model_hint(message)
537
552
  message = message[0, HINT_SCAN_CHARS]
538
553
  match = MODEL_NOT_FOUND_RE.match(message)
@@ -547,26 +562,45 @@ module Samagotchi
547
562
  .reject { |id| id.empty? || id.length > HINT_ID_MAX }.uniq
548
563
  return nil if ids.empty? || ids.include?(requested)
549
564
 
550
- close = closest_model_id(requested, ids)
551
- close && "did you mean '#{close}'?"
565
+ close = closest_model_ids(requested, ids)
566
+ "did you mean #{close.map { |id| "'#{id}'" }.join(" or ")}?" unless close.empty?
552
567
  end
553
568
 
554
- # The id that is +requested+ but for case, or whose last segment is,
555
- # else the one id a typo away (#typo_distance); nil on a tie.
556
- def closest_model_id(requested, ids)
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)
557
577
  probe = requested.downcase
558
- exact = ids.find { |id| id.downcase == probe } || ids.find { |id| last_segment(id) == probe }
559
- return exact if exact
578
+ exact = ids.select { |id| id.downcase == probe || last_segment(id) == probe }
579
+ return exact.first(HINT_SUGGESTIONS) unless exact.empty?
560
580
 
561
581
  scored = ids.filter_map do |id|
562
582
  distance = [id.downcase, last_segment(id)].uniq.filter_map { |name| typo_distance(probe, name) }.min
563
583
  [distance, id] if distance
564
584
  end
565
- return nil if scored.empty?
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)
591
+ end
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
566
598
 
567
- best = scored.map(&:first).min
568
- closest = scored.select { |distance, _| distance == best }
569
- closest.size == 1 ? closest.first.last : nil
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)
570
604
  end
571
605
 
572
606
  # The edit distance of +a+ and +b+ when it is a typo's: at most
@@ -6,15 +6,22 @@ module Samagotchi
6
6
  module MemoryBundle
7
7
  # One hook's metadata, as a bundle's manifest.yml declares it and its
8
8
  # installed record (manifest.json) keeps it: the file's sha256
9
- # ("sha256:<hex>", "" when none is recorded), the event it registers
10
- # for ("" when none: it isn't loaded), on_error (skip, log or
11
- # fail_closed) and priority. The one place the defaults live.
12
- class BundleHook < Data.define(:sha256, :event, :on_error, :priority)
9
+ # ("sha256:<hex>", "" when none is recorded), the events it registers
10
+ # for (manifest `event:` for one, `events:` a list; none: it isn't
11
+ # loaded), on_error (skip, log or fail_closed) and priority. One
12
+ # instance of the hook serves every event it lists. The one place the
13
+ # defaults live.
14
+ class BundleHook < Data.define(:sha256, :events, :on_error, :priority)
13
15
  DEFAULT_ON_ERROR = "skip"
14
16
  DEFAULT_PRIORITY = 100
15
17
 
16
- def initialize(sha256: "", event: "", on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
17
- super
18
+ # @param event [String, nil] one event (manifest `event:`)
19
+ # @param events [Array<String>, String, nil] the events (manifest
20
+ # `events:`); `event:` and `events:` together are joined, blanks
21
+ # and repeats dropped
22
+ def initialize(sha256: "", event: nil, events: nil, on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
23
+ names = [*event, *events].map { |e| e.to_s.strip }.reject(&:empty?).uniq.freeze
24
+ super(sha256: sha256, events: names, on_error: on_error, priority: priority)
18
25
  end
19
26
 
20
27
  # @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
@@ -25,7 +32,7 @@ module Samagotchi
25
32
 
26
33
  h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
27
34
  on_error = h["on_error"].to_s.strip
28
- new(sha256: prefixed(h["sha256"]), event: h["event"].to_s.strip,
35
+ new(sha256: prefixed(h["sha256"]), event: h["event"], events: h["events"],
29
36
  on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
30
37
  priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
31
38
  end
@@ -44,8 +51,20 @@ module Samagotchi
44
51
  # The sha's hex, "" when none is recorded.
45
52
  def hex = sha256.delete_prefix("sha256:")
46
53
 
47
- # The string-keyed form manifest.yml and manifest.json hold.
48
- def to_record = to_h.transform_keys(&:to_s)
54
+ # Whether the hook fails closed: on_error fail_closed applies to its
55
+ # before_tool_call registration only.
56
+ def fail_closed?(event) = on_error == "fail_closed" && event.to_s == "before_tool_call"
57
+
58
+ # How `chi bundle status` / `diff` name its events: `event=x`, or
59
+ # `events=x,y` for more than one.
60
+ def events_field = events.size > 1 ? "events=#{events.join(",")}" : "event=#{events.first}"
61
+
62
+ # The string-keyed form manifest.yml and manifest.json hold: `event`
63
+ # for one event (as an older chi reads it), `events` for more.
64
+ def to_record
65
+ listed = events.size > 1 ? { "events" => events.dup } : { "event" => events.first.to_s }
66
+ { "sha256" => sha256, **listed, "on_error" => on_error, "priority" => priority }
67
+ end
49
68
  end
50
69
  end
51
70
  end
@@ -375,9 +375,26 @@ module Samagotchi
375
375
  next unless k.is_a?(String) && !k.empty?
376
376
  next unless v.is_a?(Hash)
377
377
 
378
+ check_hook_events(k, v)
378
379
  acc[k] = BundleHook.parse(v)
379
380
  end
380
381
  end
382
+
383
+ HOOK_EVENT_NAME = /\A[a-z][a-z0-9_]*\z/
384
+
385
+ # A hook's events: list holds event names (one instance registered
386
+ # for each); it and event: are one or the other.
387
+ def check_hook_events(file, entry)
388
+ return unless entry.key?("events")
389
+
390
+ raw = entry["events"]
391
+ raise ValidationError, "hooks: #{file}: give event: or events:, not both" if entry.key?("event")
392
+ raise ValidationError, "hooks: #{file}: events: must be a list of event names" unless raw.is_a?(Array) && !raw.empty?
393
+
394
+ raw.each do |item|
395
+ raise ValidationError, "hooks: #{file}: events: #{item.inspect} is not an event name" unless item.is_a?(String) && item.strip.match?(HOOK_EVENT_NAME)
396
+ end
397
+ end
381
398
  end
382
399
  end
383
400
  end
@@ -65,6 +65,10 @@ module Samagotchi
65
65
  # Qwen tool_call XML blocks that survive when a tool call is rendered as text
66
66
  # e.g. <tool_call>\n<function=list_reminders>\n</function>\n</tool_call>
67
67
  QWEN_TOOL_CALL_RE = %r{<tool_call>.*?</tool_call>}m
68
+ # Gemma 4's closed tool-call block, <|tool_call>call:NAME{…}<tool_call|>:
69
+ # a native step's call, whose body #strip_markup drops (#strip keeps it:
70
+ # a streamed chunk may hold only one marker).
71
+ GEMMA_TOOL_CALL_RE = /<\|tool_call>.*?<tool_call\|>/m
68
72
 
69
73
  module_function
70
74
 
@@ -85,9 +89,11 @@ module Samagotchi
85
89
 
86
90
  # The same tokens as #strip, but the text keeps its layout (indentation,
87
91
  # runs of spaces); only the blank lines removed blocks leave collapse.
88
- # For showing a whole saved message (the attached TUI's join, the web history).
92
+ # For showing a whole saved message (the attached TUI's join, the web history):
93
+ # a native Gemma step's closed tool-call blocks go with their bodies, as
94
+ # Qwen's do (a step that only calls a tool has no text).
89
95
  def strip_markup(text)
90
- remove_tokens(text).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
96
+ remove_tokens(text.to_s.gsub(GEMMA_TOOL_CALL_RE, "")).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
91
97
  end
92
98
 
93
99
  def remove_tokens(text)
@@ -17,15 +17,19 @@ module Samagotchi
17
17
  # relayed approval's facts (Relay.card); limit, context: the step-limit
18
18
  # question's turn limit and offer context (ContinueOffer); status:
19
19
  # "pending" or "cancelled"; created_at: iso8601; relayed_to: the relay
20
- # mark ({parent_id:, parent_short:, relay_id:}, QuestionDesk#annotate).
21
- # approval, relay, context and relayed_to stay Hashes as given (string
20
+ # mark ({parent_id:, parent_short:, relay_id:}, QuestionDesk#annotate);
21
+ # refs: the refs on the reply that asks ({match:, kind:, target:, …},
22
+ # the :question_asked hooks', QuestionAsked; display only, checked again
23
+ # where a saved one reaches a UI).
24
+ # approval, relay, context, relayed_to and refs stay as given (string
22
25
  # keys inside after a load). A field may be nil (unset).
23
26
  #
24
27
  # Crosses the process as a Hash: #to_h for an event, a Bridge snapshot or
25
28
  # a UI (symbol keys), #to_file for session.json (string keys); both in
26
29
  # member order with unset fields left out. .from_file reads either back.
27
30
  PendingQuestion = Data.define(:id, :question, :options, :header, :multi_select, :allow_freeform, :effects, :user_only,
28
- :kind, :hook, :approval, :relay, :limit, :context, :status, :created_at, :relayed_to) do
31
+ :kind, :hook, :approval, :relay, :limit, :context, :status, :created_at, :relayed_to,
32
+ :refs) do
29
33
  def initialize(**fields)
30
34
  super(**members.to_h { |m| [m, nil] }, **fields)
31
35
  end
@@ -27,6 +27,11 @@ module Samagotchi
27
27
  # one (ctx.sessions.children) is listed when it came later. Keys have
28
28
  # no "?" (API_CALL drops it). A keyword added later (ctx.ask_model's
29
29
  # model: in 0.80.0, ctx.sessions.send's delivery: in 0.64.0) has none.
30
+ # Events aren't listed: chi.on takes any symbol and an older chi never
31
+ # fires one it doesn't know (harmless; chi bundle check warns on it),
32
+ # and a manifest hook's events: list (BundleHook) needs the bundle's
33
+ # requires_chi set by hand (docs/hooks.md, Bundle Hooks): no source
34
+ # scan covers manifest.yml.
30
35
  SINCE = {
31
36
  "ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
32
37
  "Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "answer_refs"
4
+ require_relative "log"
5
+ require_relative "output_formatter"
6
+
7
+ module Samagotchi
8
+ # The :question_asked hook event: a question of the model's own (its
9
+ # ask_user_question, kind "question") or an approval of its tool call
10
+ # (kind "approval") is about to open. Fired from the two Engine seams
11
+ # (Engine#request_question, the guardrail wiring's ask:), never from
12
+ # QuestionDesk, so a relayed delegate approval, a hook's ask_user and the
13
+ # step-limit question never fire it.
14
+ #
15
+ # A hook attaches refs to the reply that asks (event[:refs], after_turn's
16
+ # call shape and checks, AnswerRefs): they are kept as `display_refs` on
17
+ # the step's model message (the live conversation's last model entry,
18
+ # changed in place: the loops and the forget layer hold it by identity),
19
+ # display only, and go on the pending question (PendingQuestion#refs), so
20
+ # a UI pairs them in the asking text, on the card and in the history.
21
+ #
22
+ # The hooks run on the turn thread before the question opens: their time
23
+ # is the question's wait. A raising hook is logged (Registry#fire) and the
24
+ # question opens without its refs; event[:ask_user] is a no-op (nil).
25
+ module QuestionAsked
26
+ EVENT = :question_asked
27
+ KIND_QUESTION = "question"
28
+ KIND_APPROVAL = "approval"
29
+ # Lower than an answer's (AnswerRefs): cards.json holds up to 20 cards.
30
+ MAX_REFS = 50
31
+ MAX_BYTES = 32 * 1024
32
+
33
+ module_function
34
+
35
+ # Fire the event and return the question's fields with the step's refs
36
+ # (+fields+ as given when it has none, or with no subscriber: then
37
+ # nothing is built).
38
+ # @param hooks [Hooks::Registry]
39
+ # @param conversation [Array<Hash>, nil] the running turn's live
40
+ # conversation (Engine#live_conversation)
41
+ # @param kind [String] KIND_QUESTION or KIND_APPROVAL
42
+ # @param fields [Hash] the question's fields (no id yet)
43
+ # @param cwd [String, nil] the session's folder, for file refs
44
+ # @param synchronize [#call, nil] runs the step's change under the
45
+ # event lock (a snapshot reads the conversation under it)
46
+ # @param visible [#call, nil] (content) → the step's text as the asking
47
+ # slot shows it; default OutputFormatter.strip_markup (the Engine's
48
+ # drops the native loop's tool-call bodies first)
49
+ # @return [Hash] the fields to open the question with
50
+ def fire(hooks:, conversation:, kind:, fields:, cwd:, synchronize: nil, visible: nil)
51
+ return fields unless hooks&.any?(EVENT)
52
+
53
+ step = Array(conversation).reverse_each.find { |m| m.is_a?(Hash) && (m[:role] || m["role"]).to_s == "model" }
54
+ refs = AnswerRefs.new(step ? [step] : [], cwd: cwd, max_refs: MAX_REFS, max_bytes: MAX_BYTES)
55
+ visible ||= ->(content) { OutputFormatter.strip_markup(content) }
56
+ event = { type: EVENT, kind: kind, text: step ? visible.call((step[:content] || step["content"]).to_s) : "",
57
+ question: deep_freeze(Marshal.load(Marshal.dump(fields.to_h))),
58
+ # A question from here would wait inside the one opening.
59
+ ask_user: ->(**) {} }
60
+ event[:refs] = refs.adder(event)
61
+ hooks.fire(EVENT, event)
62
+ refs.close!
63
+ list = refs.to_a
64
+ if refs.added?
65
+ store = -> { step[AnswerRefs::KEY] = list }
66
+ synchronize ? synchronize.call(&store) : store.call
67
+ end
68
+ # The step's whole list: a second question in it gets the first's too.
69
+ list.empty? ? fields : fields.merge(refs: list)
70
+ rescue StandardError => e
71
+ Log.warn(:hooks, "question_asked_failed", error: e.class.name, msg: e.message.to_s[0, 200])
72
+ fields
73
+ end
74
+
75
+ def deep_freeze(value)
76
+ case value
77
+ when Hash then value.each_value { |v| deep_freeze(v) }
78
+ when Array then value.each { |v| deep_freeze(v) }
79
+ end
80
+ value.freeze
81
+ end
82
+ end
83
+ end
@@ -120,10 +120,11 @@ module Samagotchi
120
120
  # question_handler, called on the turn thread with the payload
121
121
  # Tools::AskUserQuestion.validate made. Opens it (#open_question) and
122
122
  # returns the answer as JSON for the tool result.
123
- # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
123
+ # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:},
124
+ # and refs: (the asking step's, QuestionAsked) when it has any
124
125
  # @return [String] normalized answer JSON
125
126
  def request(payload)
126
- result = open_question(payload.slice(:question, :options, :header)
127
+ result = open_question(payload.slice(:question, :options, :header, :refs)
127
128
  .merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
128
129
  # Dismissed (the card's dismiss, Esc): an answer of its own, not a
129
130
  # tool failure the model learns to avoid the tool from.
@@ -3,6 +3,7 @@
3
3
  require_relative "../plugin/loader"
4
4
  require_relative "../plugin/tool_spec"
5
5
  require_relative "../hooks"
6
+ require_relative "../question_asked"
6
7
  require_relative "../commands/registry"
7
8
  require_relative "../session_commands"
8
9
  require_relative "../tools/builtins"
@@ -179,7 +180,7 @@ module Samagotchi
179
180
  end
180
181
  @hooks_namespace = Module.new
181
182
  @manifest.hooks.each do |basename, hook|
182
- next if hook.event.empty?
183
+ next if hook.events.empty?
183
184
 
184
185
  Hooks::BundleLoader.load_hook_file(@name, basename, File.join(@dir, "hooks", basename), hook, hooks,
185
186
  settings: @settings, namespace: @hooks_namespace)
@@ -203,6 +204,22 @@ module Samagotchi
203
204
  payload
204
205
  end
205
206
 
207
+ # Fire :question_asked as chi's two seams do (QuestionAsked.fire): a
208
+ # question (+kind+ "question") or an approval ("approval") asked by a
209
+ # step whose text is +text+, with the question's +question+ fields.
210
+ # The hooks' refs are checked as chi checks them (the question caps,
211
+ # file refs under +cwd+, default the session's folder).
212
+ # @return [Array<Hash>] the refs the step and its pending question
213
+ # get, as stored; [] for none
214
+ def question_asked(text, kind: "question", question: {}, cwd: nil)
215
+ step = { role: "model", content: text.to_s }
216
+ fields = failing do
217
+ QuestionAsked.fire(hooks: hooks, conversation: [step], kind: kind.to_s, fields: question.to_h,
218
+ cwd: cwd || @recorder[:cwd])
219
+ end
220
+ Array(fields[:refs])
221
+ end
222
+
206
223
  # Build the before_tool_call payload from the real Gate, without firing
207
224
  # this bundle's hooks.
208
225
  # @param call [Hash] the parsed tool call
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.97.0"
4
+ VERSION = "0.98.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -14,6 +14,7 @@ require_relative "../client_id"
14
14
  require_relative "../delivery"
15
15
  require_relative "../answer_tail"
16
16
  require_relative "../answer_refs"
17
+ require_relative "../question_asked"
17
18
  require_relative "../bridge_client"
18
19
  require_relative "../bridge/bounded_queue"
19
20
  require_relative "../bridge/card_store"
@@ -839,7 +840,7 @@ module Samagotchi
839
840
  plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
840
841
  # Plugins' slow setup still running (chi.init): the page's init row.
841
842
  init_tasks: snapshot_list(turn_snapshot, "init_tasks"),
842
- cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }),
843
+ cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }, cwd: session.working_directory),
843
844
  # The composer's / autocomplete; the built-ins until a worker
844
845
  # names its plugins' too.
845
846
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
@@ -871,13 +872,28 @@ module Samagotchi
871
872
  # dead worker saved can't be answered (its turn is gone), nor a chi
872
873
  # REPL's (it shares nothing with the web).
873
874
  # A question between turns (the step-limit one) is the snapshot's own.
875
+ # Its refs (the asking reply's, QuestionAsked) are checked again.
874
876
  def shown_pending_question(session, turn_snapshot, owner)
875
- if turn_snapshot
876
- current_turn = turn_snapshot["current_turn"]
877
- current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
878
- elsif owner&.worker?
879
- session.pending_question&.to_h
880
- end
877
+ pending = if turn_snapshot
878
+ current_turn = turn_snapshot["current_turn"]
879
+ current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
880
+ elsif owner&.worker?
881
+ session.pending_question&.to_h
882
+ end
883
+ with_question_refs(pending, cwd: session.working_directory)
884
+ end
885
+
886
+ # A pending question (a snapshot's, the session file's or a saved
887
+ # card's) with its refs checked again, under a question's caps
888
+ # (QuestionAsked): a stored file is never trusted, and what isn't
889
+ # valid any more is dropped. The question as it was without refs.
890
+ def with_question_refs(pending, cwd:)
891
+ return pending unless pending.is_a?(Hash)
892
+
893
+ key = [:refs, "refs"].find { |name| pending.key?(name) } or return pending
894
+ refs = Samagotchi::AnswerRefs.for_display(pending[key], cwd: cwd, max_refs: Samagotchi::QuestionAsked::MAX_REFS,
895
+ max_bytes: Samagotchi::QuestionAsked::MAX_BYTES)
896
+ refs.empty? ? pending.except(key) : pending.merge(key => refs)
881
897
  end
882
898
 
883
899
  # The full show's session part: the file's, with what a live worker's
@@ -1031,11 +1047,16 @@ module Samagotchi
1031
1047
  # cards: CardStore#list), else (no live worker) the ones the last
1032
1048
  # worker saved in the session's folder (#saved_cards), a card's body as
1033
1049
  # body_html: rendered markdown when the renderer is on, else the
1034
- # escaped text in a <pre>.
1050
+ # escaped text in a <pre>, and a question card's refs checked again
1051
+ # (#with_question_refs; +cwd+ the session's folder, nil where the
1052
+ # session file isn't read: a file ref then stays only absolute).
1035
1053
  # @return [Array<Hash>]
1036
- def cards_for_display(cards)
1054
+ def cards_for_display(cards, cwd: nil)
1037
1055
  Array(cards).filter_map do |card|
1038
1056
  next unless card.is_a?(Hash)
1057
+ if card["type"].to_s == "question" && card["pending_question"].is_a?(Hash)
1058
+ next card.merge("pending_question" => with_question_refs(card["pending_question"], cwd: cwd))
1059
+ end
1039
1060
  next card unless card["type"].to_s == "card"
1040
1061
 
1041
1062
  card.merge("body_html" => card_body_html(card["body"].to_s))