samagotchi 0.76.0 → 0.77.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/docs/internals/engine.md +1 -1
  4. data/docs/plugins.md +42 -0
  5. data/lib/samagotchi/answer_command.rb +5 -5
  6. data/lib/samagotchi/bridge.rb +3 -3
  7. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  8. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  9. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  10. data/lib/samagotchi/child_reports.rb +1 -1
  11. data/lib/samagotchi/engine.rb +21 -12
  12. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  13. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  14. data/lib/samagotchi/memory_bundle/builder.rb +2 -0
  15. data/lib/samagotchi/message_parts.rb +272 -0
  16. data/lib/samagotchi/parent_report.rb +30 -22
  17. data/lib/samagotchi/pending_question.rb +49 -0
  18. data/lib/samagotchi/question_desk.rb +39 -32
  19. data/lib/samagotchi/relay.rb +8 -9
  20. data/lib/samagotchi/relay_verifier.rb +2 -2
  21. data/lib/samagotchi/relay_watcher.rb +2 -2
  22. data/lib/samagotchi/reply_wait.rb +4 -4
  23. data/lib/samagotchi/row_refs.rb +1 -1
  24. data/lib/samagotchi/session.rb +22 -14
  25. data/lib/samagotchi/session_commands.rb +4 -1
  26. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  27. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  28. data/lib/samagotchi/terminal_ui/formatting.rb +3 -106
  29. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  30. data/lib/samagotchi/terminal_ui.rb +5 -3
  31. data/lib/samagotchi/tool_output_cut.rb +1 -1
  32. data/lib/samagotchi/tool_response.rb +13 -13
  33. data/lib/samagotchi/tool_row_fields.rb +1 -1
  34. data/lib/samagotchi/tool_runner.rb +16 -9
  35. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  36. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  37. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/app.rb +2 -2
  40. data/lib/samagotchi/web/public/app.js +17 -55
  41. data/lib/samagotchi/web/public/ctx.js +12 -1
  42. data/lib/samagotchi/web/public/index.html +2 -1
  43. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  44. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  45. data/lib/samagotchi/web/public/toasts.js +69 -0
  46. metadata +6 -2
  47. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_profile"
4
+ require_relative "tool_call_parser"
5
+ require_relative "tool_activity"
6
+ require_relative "tool_row_fields"
7
+ require_relative "llm/native_tool_normalizer"
8
+ require_relative "tools/builtins"
9
+ require_relative "image_store"
10
+ require_relative "llm_context_edit"
11
+ require_relative "token_usage"
12
+ require_relative "row_refs"
13
+ require_relative "tool_output_cut"
14
+
15
+ module Samagotchi
16
+ # What a saved assistant message did, for a turn view's reload: its
17
+ # thinking and its tool calls (name, the params line the live row shows,
18
+ # the output from the tool_response messages after it). Two storage
19
+ # shapes, best effort:
20
+ # * the native loop (a profile, llama.cpp): the raw response as content
21
+ # (thinking + tool-call markup, Qwen or Gemma), then one tool_response
22
+ # whose content joins every call's output with "\n\n---\n\n";
23
+ # * the chat loop (api: openai): the text without thinking, the host's
24
+ # reasoning as +thinking+ (older sessions have none), the calls as
25
+ # tool_calls ({id, name, arguments}), then one tool_response per call
26
+ # (tool_call_id).
27
+ # A message that can't be read gives no parts, never an error.
28
+ #
29
+ # A call's images (a read image, a plugin or MCP tool's) are on its
30
+ # tool_response: chat, per call; native, one list for every call, split
31
+ # by the saved image_counts (older sessions without them: only a lone
32
+ # call gets them).
33
+ #
34
+ # A call's chi-owned id (ToolIds, saved as tool_ids) is the part's
35
+ # tool_id:, and an LLM context edit saved on its output (LLMContextEdit,
36
+ # under edits) the part's edit: (.edit_marks), the row's ✂ mark.
37
+ #
38
+ # A plugin tool's params are what its live row showed (its preview),
39
+ # saved with the result as tool_params (native: one per call, chat: one
40
+ # per result); its label ("chrome: screenshot") likewise as tool_labels,
41
+ # the part's label:, and its row refs as tool_refs (RowRefs, checked
42
+ # again here), the part's refs:. The web server has no Engine and runs no plugins, so a
43
+ # result without them (older sessions) falls back to the given
44
+ # registry: the built-ins', and a plugin tool's arguments as key="value"
45
+ # from the parsers' args:, as the live row shows them for a tool
46
+ # without a preview.
47
+ module MessageParts
48
+ JOINER = "\n\n---\n\n"
49
+ # A joined output's pieces each start with "[tool]": split there first,
50
+ # so an output that holds the joiner itself stays whole.
51
+ JOINER_BEFORE_TAG = /\n\n---\n\n(?=\[)/
52
+ # The row shows 300 chars and the rest on hover; a read of a big file
53
+ # would otherwise ride along with every reload. A longer one keeps its
54
+ # head and tail (ToolOutputCut.preview).
55
+ OUTPUT_MAX = ToolOutputCut::WEB_PREVIEW_CHARS
56
+
57
+ QWEN_THINK = %r{<think>(.*?)</think>}m
58
+ QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
59
+ GEMMA_THOUGHT = Regexp.new("#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN)}(.*?)" \
60
+ "(?:#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE)}|\\z)", Regexp::MULTILINE)
61
+
62
+ CallRef = Struct.new(:name, :arguments)
63
+
64
+ module_function
65
+
66
+ # @param message [Hash] a saved model message (symbol or string keys)
67
+ # @param responses [Array<Hash>] the tool_response messages after it
68
+ # @param registry [Tools::Registry] the session's tools
69
+ # @param cwd [String, nil] the session's working directory (a file
70
+ # tool's title is relative to it)
71
+ # @param marks [Hash{String => Hash}] the session's edit marks by
72
+ # output id (.edit_marks)
73
+ # @return [Hash, nil] { thinking:, tools: [{ tool:, params:, title:,
74
+ # output:, output_truncated:, images:, tool_id:, edit: }] } with only what it found; nil for nothing
75
+ def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil, marks: {})
76
+ content = field(message, :content).to_s
77
+ calls = field(message, :tool_calls)
78
+ tools = if calls.is_a?(Array) && !calls.empty?
79
+ native_tools(calls, responses, registry, cwd, marks)
80
+ else
81
+ markup_tools(content, responses, registry, cwd, marks)
82
+ end
83
+ parts = {}
84
+ thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
85
+ parts[:thinking] = thinking unless thinking.empty?
86
+ parts[:tools] = tools unless tools.empty?
87
+ parts.empty? ? nil : parts
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ def thinking_of(content)
93
+ blocks = content.scan(QWEN_THINK).flatten
94
+ if blocks.empty? && !content.include?("<think>") && (open = content[QWEN_OPEN_THINK, 1])
95
+ blocks = [open]
96
+ end
97
+ blocks += content.scan(GEMMA_THOUGHT).flatten
98
+ blocks.map(&:strip).reject(&:empty?).join("\n\n")
99
+ end
100
+
101
+ def markup_tools(content, responses, registry, cwd = nil, marks = {})
102
+ calls = []
103
+ calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
104
+ calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
105
+ return [] if calls.empty?
106
+
107
+ joined = responses.empty? ? nil : responses.map { |r| field(r, :content).to_s }.join(JOINER)
108
+ outputs = split_outputs(joined, calls.length)
109
+ shown = saved_list(responses, :tool_params, calls.length)
110
+ labels = saved_list(responses, :tool_labels, calls.length)
111
+ diffs = saved_list(responses, :tool_diffs, calls.length)
112
+ refs = saved_list(responses, :tool_refs, calls.length)
113
+ ids = saved_list(responses, :tool_ids, calls.length)
114
+ images = split_images(responses, calls.length)
115
+ calls.each_with_index.map do |call, i|
116
+ tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], refs: refs[i],
117
+ cwd: cwd, tool_id: ids[i], marks: marks)
118
+ end
119
+ end
120
+
121
+ # A per-call list saved with the joined result(s), or none when its
122
+ # length doesn't match the calls.
123
+ def saved_list(responses, key, count)
124
+ list = responses.flat_map { |r| Array(field(r, key)) }
125
+ list.length == count ? list : []
126
+ end
127
+
128
+ # Each call's images from the joined tool_response(s).
129
+ def split_images(responses, count)
130
+ images = responses.flat_map { |r| Array(field(r, :images)) }
131
+ return [] if images.empty?
132
+
133
+ counts = responses.flat_map { |r| Array(field(r, :image_counts)) }
134
+ return count == 1 ? [images] : [] unless counts.length == count && counts.sum == images.length
135
+
136
+ counts.map { |n| images.shift(n) }
137
+ end
138
+
139
+ def native_tools(calls, responses, registry, cwd = nil, marks = {})
140
+ # By id only for an id that names one response: a server may omit
141
+ # it (nil), send "" or repeat it; those pair by position.
142
+ ids = responses.map { |r| field(r, :tool_call_id).to_s }
143
+ unique = ->(id) { !id.empty? && ids.count(id) == 1 }
144
+ by_id = responses.each_with_index.to_h { |r, i| [ids[i], r] }.select { |id, _| unique.call(id) }
145
+ calls.each_with_index.map do |raw, i|
146
+ ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
147
+ call = LLM::NativeToolNormalizer.normalize(ref) || { name: ref.name }
148
+ response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
149
+ tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
150
+ response && field(response, :images), label: field(response, :tool_labels),
151
+ diff: field(response, :tool_diffs),
152
+ refs: field(response, :tool_refs), cwd: cwd,
153
+ tool_id: Array(field(response, :tool_ids)).first, marks: marks)
154
+ end
155
+ end
156
+
157
+ # The ✂ mark of each output an LLM context edit is saved on, by its
158
+ # id, from +messages+ (a session's, symbol or string keys): {kind:
159
+ # "stale" | "forget", staged: true while a forget waits for the
160
+ # turn's end, note: (a stale stub's reason, "superseded by a later
161
+ # read"; the first output of a forget_outputs call's, its note), with:
162
+ # (the others of that call's: the first one's id), kept: ("12-40, 50",
163
+ # a forget's kept lines)}. A forget call's outputs share the note,
164
+ # author and stamp (LLMContextView's key); the first in the session's
165
+ # order carries the note.
166
+ # @return [Hash{String => Hash}]
167
+ def edit_marks(messages)
168
+ first = {}
169
+ Array(messages).each_with_object({}) do |entry, marks|
170
+ next unless field(entry, :role).to_s == "tool_response"
171
+
172
+ saved = field(entry, :edits)
173
+ next unless saved.is_a?(Hash)
174
+
175
+ edits = saved.filter_map { |id, hash| LLMContextEdit.from_h(id, hash) }.to_h { |edit| [edit.id, edit] }
176
+ order = Array(field(entry, :tool_ids)).map(&:to_s)
177
+ edits.sort_by { |id, _| order.index(id) || order.size }.each do |id, edit|
178
+ marks[id] = edit_mark(edit, first)
179
+ end
180
+ end
181
+ rescue StandardError
182
+ {}
183
+ end
184
+
185
+ def edit_mark(edit, first)
186
+ mark = { kind: edit.kind.to_s }
187
+ mark[:staged] = true unless edit.applied?
188
+ if edit.kind == :stale
189
+ mark[:note] = edit.note[/superseded by .*\z/] || edit.note
190
+ else
191
+ key = [edit.note, edit.by, edit.staged_at]
192
+ if first.key?(key)
193
+ mark[:with] = first[key]
194
+ else
195
+ first[key] = edit.id
196
+ mark[:note] = edit.note
197
+ end
198
+ kept = edit.keep.map { |from, to| from == to ? from.to_s : "#{from}-#{to}" }
199
+ mark[:kept] = kept.join(", ") unless kept.empty?
200
+ end
201
+ mark
202
+ end
203
+
204
+ # +output+'s ✂ mark (+marks+ by +id+); an applied stale stub says
205
+ # what it frees: its output, ~chars/4.
206
+ def mark_of(id, marks, output)
207
+ mark = marks[id]
208
+ return nil unless mark
209
+ return mark unless mark[:kind] == "stale" && !mark[:staged] && output
210
+
211
+ mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
212
+ end
213
+
214
+ # +count+ outputs from a joined tool_response: the "[tool]"-tagged
215
+ # split when it gives the right count, else the plain one; extra pieces
216
+ # stay with the last call, missing ones are nil.
217
+ def split_outputs(joined, count)
218
+ return [] if joined.nil?
219
+ return [joined] if count == 1
220
+
221
+ pieces = joined.split(JOINER_BEFORE_TAG, -1)
222
+ pieces = joined.split(JOINER, -1) if pieces.length != count
223
+ return pieces if pieces.length == count
224
+
225
+ head = pieces.first(count - 1)
226
+ rest = pieces.drop(count - 1)
227
+ head + [rest.empty? ? nil : rest.join(JOINER)]
228
+ end
229
+
230
+ # +shown+ is the saved params line, when it is a String.
231
+ # +diff+ is what an edit/write changed (EditPreview.change), saved as
232
+ # tool_diffs. +refs+: a plugin tool's row refs, saved as tool_refs
233
+ # (RowRefs.for_display checks them again against +cwd+). +cwd+: the
234
+ # session's working directory, for the title.
235
+ # +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
236
+ def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
237
+ tool_id: nil, marks: {})
238
+ # A built-in reached through call_tool reloads as that built-in's
239
+ # row, with via (its saved params and label are the built-in's).
240
+ call = Tools::BuiltinCalls.unwrap_saved(call, output)
241
+ name = call[:name].to_s
242
+ params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
243
+ part = { tool: name, params: params.to_s }
244
+ # Its row fields (ToolRowFields: title, called_as, via, view, ref), built from the saved
245
+ # call (never saved themselves), so older sessions get them too. The
246
+ # saved call is rebuilt by BuiltinCalls, which sets called_as and via again.
247
+ part.merge!(ToolRowFields.for(name, call, cwd: cwd))
248
+ part[:label] = label if label.is_a?(String) && !label.empty?
249
+ unless output.nil?
250
+ part[:output] = ToolOutputCut.preview(output, cap: OUTPUT_MAX, tool: name)
251
+ part[:output_truncated] = true if output.length > OUTPUT_MAX
252
+ end
253
+ shots = Array(images).select { |image| image.is_a?(Hash) }
254
+ part[:images] = shots.map { |image| ImageStore.symbolize(image).slice(:file, :name, :width, :height) } unless shots.empty?
255
+ part[:diff] = diff if diff.is_a?(Hash)
256
+ row_refs = RowRefs.for_display(refs, cwd: cwd)
257
+ part[:refs] = row_refs unless row_refs.empty?
258
+ if tool_id
259
+ part[:tool_id] = tool_id.to_s
260
+ mark = mark_of(tool_id.to_s, marks, output)
261
+ part[:edit] = mark if mark
262
+ end
263
+ part
264
+ end
265
+
266
+ def field(hash, key)
267
+ return nil unless hash.is_a?(Hash)
268
+
269
+ hash.key?(key) ? hash[key] : hash[key.to_s]
270
+ end
271
+ end
272
+ end
@@ -4,6 +4,7 @@ require "delegate"
4
4
  require "json"
5
5
 
6
6
  require_relative "cli/exit"
7
+ require_relative "pending_question"
7
8
 
8
9
  module Samagotchi
9
10
  # How a wait's end (ReplyWait::Result) reads to whoever runs chi as a
@@ -136,23 +137,24 @@ module Samagotchi
136
137
  JSON.generate({ status: "error", session_id: session_id, detail: detail.to_s })
137
138
  end
138
139
 
139
- # A pending question (Session#pending_question) as a parent sees it.
140
+ # A pending question (Session#pending_question, a PendingQuestion) as a
141
+ # parent sees it.
140
142
  # kind is "question" for the model's own (the desk stores none),
141
143
  # "hook", "approval" or "continue" (the step-limit question, with the
142
144
  # turn's limit).
143
145
  # @return [Hash]
144
146
  def question(pending)
145
- pending ||= {}
146
- approval = pending[:approval].is_a?(Hash) ? symbolize(pending[:approval]) : nil
147
+ pending ||= PendingQuestion.new
148
+ approval = pending.approval.is_a?(Hash) ? symbolize(pending.approval) : nil
147
149
  {
148
- id: pending[:id].to_s,
149
- kind: (pending[:kind] || "question").to_s,
150
- header: blank(pending[:header]),
151
- text: pending[:question].to_s,
152
- options: Array(pending[:options]).map(&:to_s),
153
- multi_select: !!pending[:multi_select],
154
- allow_freeform: !!pending[:allow_freeform],
155
- limit: pending[:limit],
150
+ id: pending.id.to_s,
151
+ kind: (pending.kind || "question").to_s,
152
+ header: blank(pending.header),
153
+ text: pending.question.to_s,
154
+ options: Array(pending.options).map(&:to_s),
155
+ multi_select: !!pending.multi_select,
156
+ allow_freeform: !!pending.allow_freeform,
157
+ limit: pending.limit,
156
158
  approval: approval&.slice(*APPROVAL_KEYS)
157
159
  }.compact
158
160
  end
@@ -178,13 +180,18 @@ module Samagotchi
178
180
  "chi send --wait --format json #{session_id} waits until they answer"
179
181
  end
180
182
 
181
- def approval?(question)
182
- question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
183
- end
183
+ # @param question [PendingQuestion, Hash, nil] a pending question, or a
184
+ # Hash with its kind (#question's, Session#waiting_question's)
185
+ def approval?(question) = kind_of(question) == "approval"
184
186
 
185
187
  # The step-limit question (ContinueOffer).
186
- def continue?(question)
187
- question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "continue"
188
+ def continue?(question) = kind_of(question) == "continue"
189
+
190
+ def kind_of(question)
191
+ case question
192
+ when PendingQuestion then question.kind.to_s
193
+ when Hash then (question[:kind] || question["kind"]).to_s
194
+ end
188
195
  end
189
196
 
190
197
  # What a message does to a step-limit question.
@@ -197,13 +204,14 @@ module Samagotchi
197
204
  attach = "chi --attach #{session_id}"
198
205
  case result.status
199
206
  when :waiting_for_answer
200
- if approval?(result.question)
201
- "waiting for an approval: #{first_line(result.question[:question])}; #{DENY_AND_TELL}"
202
- elsif continue?(result.question)
203
- "waiting at the step limit: #{first_line(result.question[:question])}; " \
204
- "answer Continue or Stop: chi answer #{session_id} --question #{result.question[:id]} --option Continue"
207
+ q = question(result.question)
208
+ if approval?(q)
209
+ "waiting for an approval: #{first_line(q[:text])}; #{DENY_AND_TELL}"
210
+ elsif continue?(q)
211
+ "waiting at the step limit: #{first_line(q[:text])}; " \
212
+ "answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
205
213
  else
206
- "waiting for an answer: #{first_line(result.question&.dig(:question))}; open it: #{attach} or the web"
214
+ "waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
207
215
  end
208
216
  when :no_reply then "#{no_reply_line(result, session_id)}; #{attach} shows it"
209
217
  when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The one open question of a session (QuestionDesk's pending question,
5
+ # Session#pending_question): the model's ask_user_question, a hook's
6
+ # ask_user, a guardrail approval, a relayed delegate approval or the
7
+ # step-limit question (kind "continue").
8
+ #
9
+ # id: the question's uuid; question, options, header, multi_select,
10
+ # allow_freeform: what the UIs show; kind: nil for the model's own,
11
+ # "hook", "approval" or "continue"; hook: the asking hook's name;
12
+ # approval: the approval's facts (Guardrails::Approval.payload); relay: a
13
+ # relayed approval's facts (Relay.card); limit, context: the step-limit
14
+ # question's turn limit and offer context (ContinueOffer); status:
15
+ # "pending" or "cancelled"; created_at: iso8601; relayed_to: the relay
16
+ # mark ({parent_id:, parent_short:, relay_id:}, QuestionDesk#annotate).
17
+ # approval, relay, context and relayed_to stay Hashes as given (string
18
+ # keys inside after a load). A field may be nil (unset).
19
+ #
20
+ # Crosses the process as a Hash: #to_h for an event, a Bridge snapshot or
21
+ # a UI (symbol keys), #to_file for session.json (string keys); both in
22
+ # member order with unset fields left out. .from_file reads either back.
23
+ PendingQuestion = Data.define(:id, :question, :options, :header, :multi_select, :allow_freeform, :kind, :hook,
24
+ :approval, :relay, :limit, :context, :status, :created_at, :relayed_to) do
25
+ def initialize(**fields)
26
+ super(**members.to_h { |m| [m, nil] }, **fields)
27
+ end
28
+
29
+ # A saved or sent record (string or symbol keys, a key we don't know
30
+ # dropped), a PendingQuestion as it is; nil for anything else.
31
+ # @return [PendingQuestion, nil]
32
+ def self.from_file(raw)
33
+ return raw if raw.is_a?(PendingQuestion)
34
+ return nil unless raw.is_a?(Hash)
35
+
36
+ new(**members.to_h { |m| [m, raw.key?(m.to_s) ? raw[m.to_s] : raw[m]] })
37
+ end
38
+
39
+ # The event / snapshot shape: symbol keys, in member order, unset
40
+ # fields left out.
41
+ # @return [Hash{Symbol => Object}]
42
+ def to_h = super.compact
43
+
44
+ # The session file's record: string keys, in member order, unset
45
+ # fields left out.
46
+ # @return [Hash{String => Object}]
47
+ def to_file = to_h.transform_keys(&:to_s)
48
+ end
49
+ end
@@ -7,6 +7,7 @@ require "time"
7
7
 
8
8
  require_relative "client_id"
9
9
  require_relative "log"
10
+ require_relative "pending_question"
10
11
  require_relative "tools/ask_user_question"
11
12
  require_relative "guardrails/parent_approvals"
12
13
  require_relative "guardrails/parent_continue"
@@ -87,6 +88,8 @@ module Samagotchi
87
88
  # A Monitor: re-entered by the specs from the same thread.
88
89
  @lock = Monitor.new
89
90
  @cv = @lock.new_cond
91
+ # The open question (PendingQuestion), nil with none; replaced, never
92
+ # changed in place (#annotate, #cancel make a new one with #with).
90
93
  @pending = nil
91
94
  @answer = nil
92
95
  # Why the open question was cancelled (#cancel), for the sync path.
@@ -99,9 +102,10 @@ module Samagotchi
99
102
  @shelved = nil
100
103
  end
101
104
 
102
- # @return [Hash, nil] current pending question (thread-safe copy)
105
+ # @return [PendingQuestion, nil] the question pending now (a value: it
106
+ # never changes once read)
103
107
  def pending
104
- @lock.synchronize { @pending&.dup }
108
+ @lock.synchronize { @pending }
105
109
  end
106
110
 
107
111
  # Ask the model's question (ask_user_question): the kernel's
@@ -127,8 +131,9 @@ module Samagotchi
127
131
  # Open a question for the UIs and wait for its answer. Emits
128
132
  # :question_requested, persists it to the session, and BLOCKS until
129
133
  # answer / cancel wakes it (or the turn is cancelled).
130
- # The fields go to pending as given (no cleaning), extra keys
131
- # included, so a caller can add its own (kind:, approval:).
134
+ # The fields become the PendingQuestion as given (no cleaning): any of
135
+ # its members (kind:, approval:, …); a key it doesn't have raises
136
+ # ArgumentError.
132
137
  # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
133
138
  # @param watch [#call, nil] asked every WATCH_INTERVAL seconds while the
134
139
  # question waits (outside the lock): a String closes the question with
@@ -155,7 +160,7 @@ module Samagotchi
155
160
  # @param on_superseded_close [#call, nil] called when a question that
156
161
  # superseded it closes (its poster posts it again if it still
157
162
  # stands); nil: posted again as it was
158
- # @return [Hash, nil] the pending question; nil when it waits for another
163
+ # @return [PendingQuestion, nil] the pending question; nil when it waits for another
159
164
  def post(fields, on_answer:, on_superseded_close: nil)
160
165
  standing = { fields: fields, on_answer: on_answer, on_superseded_close: on_superseded_close }
161
166
  shelved = @lock.synchronize do
@@ -179,7 +184,7 @@ module Samagotchi
179
184
  withdrawn = @lock.synchronize do
180
185
  @shelved = nil if id.nil?
181
186
  standing = @standing
182
- next nil unless standing && @pending && @pending[:id] == standing[:id]
187
+ next nil unless standing && @pending && @pending.id == standing[:id]
183
188
  next nil if id && standing[:id].to_s != id.to_s
184
189
 
185
190
  @standing = nil
@@ -195,7 +200,7 @@ module Samagotchi
195
200
 
196
201
  private def ask(fields, watch)
197
202
  pending = publish(fields)
198
- id = pending[:id]
203
+ id = pending.id
199
204
 
200
205
  # If a synchronous UI handler is registered (TUI), invoke it inline on the
201
206
  # SAME thread that called request_question (TerminalUI's REPL thread is the
@@ -204,7 +209,7 @@ module Samagotchi
204
209
  # any future UI that registers a sync handler gets the same guarantee.
205
210
  if @sync_handler
206
211
  begin
207
- sync_res = @sync_handler.call(pending.dup)
212
+ sync_res = @sync_handler.call(pending.to_h)
208
213
  # Handler may have called answer_question or returned a hash/string.
209
214
  # Decided under the lock; saved and announced after it (never emit
210
215
  # holding the question lock: a snapshot holds the event lock and
@@ -298,12 +303,12 @@ module Samagotchi
298
303
  answer = @lock.synchronize do
299
304
  pending = @pending
300
305
  raise NotPending, "no pending question" unless pending
301
- raise NotPending, "id mismatch" unless pending[:id].to_s == id.to_s
306
+ raise NotPending, "id mismatch" unless pending.id.to_s == id.to_s
302
307
  # First responder wins: after the first answer the turn thread clears
303
308
  # @pending in a later lock block, so a second UI's answer can
304
309
  # land in between and must not overwrite the first.
305
310
  raise NotPending, "question already answered" if @answer
306
- raise NotPending, "question #{pending[:status]}" unless pending[:status].to_s == "pending"
311
+ raise NotPending, "question #{pending.status}" unless pending.status.to_s == "pending"
307
312
 
308
313
  answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, parent_setting: parent_setting)
309
314
  if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
@@ -338,15 +343,15 @@ module Samagotchi
338
343
  def annotate(id, relayed_to:, reason: nil)
339
344
  marked = @lock.synchronize do
340
345
  pending = @pending
341
- next nil unless pending && pending[:id].to_s == id.to_s
342
- next nil if @answer || pending[:status].to_s != "pending"
346
+ next nil unless pending && pending.id.to_s == id.to_s
347
+ next nil if @answer || pending.status.to_s != "pending"
343
348
 
344
- relayed_to ? pending[:relayed_to] = relayed_to : pending.delete(:relayed_to)
349
+ @pending = pending = pending.with(relayed_to: relayed_to)
345
350
  if session
346
- session.pending_question = pending.dup
351
+ session.pending_question = pending
347
352
  begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
348
353
  end
349
- pending[:id]
354
+ pending.id
350
355
  end
351
356
  return false unless marked
352
357
 
@@ -372,16 +377,16 @@ module Samagotchi
372
377
  cancelled_id = @lock.synchronize do
373
378
  pending = @pending
374
379
  next unless pending
375
- next if id && pending[:id].to_s != id.to_s
376
- next if @answer || pending[:status].to_s != "pending"
380
+ next if id && pending.id.to_s != id.to_s
381
+ next if @answer || pending.status.to_s != "pending"
377
382
  # Its poster closes it; nothing waits on a cancel.
378
- raise NotDismissable, "answer #{Array(pending[:options]).join(" or ")}" if @standing && id
383
+ raise NotDismissable, "answer #{Array(pending.options).join(" or ")}" if @standing && id
379
384
  next if @standing
380
385
 
381
- pending[:status] = "cancelled"
386
+ @pending = pending.with(status: "cancelled")
382
387
  @cancel_reason = reason.to_s
383
388
  @cv.broadcast
384
- pending[:id]
389
+ pending.id
385
390
  end
386
391
  return id.nil? && self.pending.nil? unless cancelled_id
387
392
 
@@ -420,21 +425,23 @@ module Samagotchi
420
425
  # :question_requested to every UI. Observers that stash the event (e.g.
421
426
  # TerminalUI handle_question_event) discard it as stale if a sync
422
427
  # handler answers and clears it first (drain_pending_question?).
423
- # @return [Hash] the pending question
428
+ # The event carries it as a Hash (PendingQuestion#to_h), as every UI
429
+ # and the Bridge's stream read it.
430
+ # @return [PendingQuestion] the pending question
424
431
  # @param standing [Hash, nil] a standing question's record (#post)
425
432
  def publish(fields, standing: nil)
426
- pending = { id: SecureRandom.uuid, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
433
+ pending = PendingQuestion.new(id: SecureRandom.uuid, **fields, status: "pending", created_at: Time.now.iso8601(3))
427
434
  @lock.synchronize do
428
435
  @pending = pending
429
436
  @answer = nil
430
437
  @cancel_reason = nil
431
- @standing = standing&.merge(id: pending[:id])
438
+ @standing = standing&.merge(id: pending.id)
432
439
  end
433
440
  if session
434
- session.pending_question = pending.dup
441
+ session.pending_question = pending
435
442
  begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
436
443
  end
437
- event = { type: :question_requested, pending_question: pending }
444
+ event = { type: :question_requested, pending_question: pending.to_h }
438
445
  event[:standing] = true if standing
439
446
  emit(event)
440
447
  pending
@@ -489,11 +496,11 @@ module Samagotchi
489
496
  # @return [Hash] {id:, selected:, freeform:, selected_indices:, by:}
490
497
  # @raise [ArgumentError, Refused]
491
498
  def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
492
- opts = Array(pending[:options])
499
+ opts = Array(pending.options)
493
500
  invalid = sel.reject { |v| opts.include?(v) }
494
501
  raise ArgumentError, "invalid selection: #{invalid.join(", ")} (valid: #{opts.join(", ")})" unless invalid.empty?
495
- raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending[:multi_select] && sel.size > 1
496
- raise ArgumentError, "selection required" if pending[:multi_select] == false && sel.empty? && fm.nil?
502
+ raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending.multi_select && sel.size > 1
503
+ raise ArgumentError, "selection required" if pending.multi_select == false && sel.empty? && fm.nil?
497
504
 
498
505
  indices = sel.map { |v| opts.index(v) }
499
506
  # The one whose answer settles the approval (Approval.settle, by index).
@@ -501,7 +508,7 @@ module Samagotchi
501
508
  reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
502
509
  raise Refused, reason if reason
503
510
  end
504
- answer = { id: pending[:id].to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
511
+ answer = { id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
505
512
  answer[:by] = "parent_agent" if parent_agent
506
513
  answer
507
514
  end
@@ -513,7 +520,7 @@ module Samagotchi
513
520
  loop do
514
521
  done = @lock.synchronize do
515
522
  next true if @answer || cancel_controller&.cancelled?
516
- next true if @pending.nil? || @pending[:status] != "pending"
523
+ next true if @pending.nil? || @pending.status != "pending"
517
524
 
518
525
  # Wait with timeout to check cancel; 0.2s matches reminder poll
519
526
  @cv.wait([0.2, watch ? next_watch - monotonic : 0.2].min.clamp(0.0, 0.2))
@@ -527,9 +534,9 @@ module Samagotchi
527
534
  next unless reason
528
535
 
529
536
  closed = @lock.synchronize do
530
- next false if @answer || @pending.nil? || @pending[:status] != "pending"
537
+ next false if @answer || @pending.nil? || @pending.status != "pending"
531
538
 
532
- @pending[:status] = "cancelled"
539
+ @pending = @pending.with(status: "cancelled")
533
540
  true
534
541
  end
535
542
  return reason if closed
@@ -18,29 +18,28 @@ module Samagotchi
18
18
  module_function
19
19
 
20
20
  # @param child [Session] the child as loaded (its id, its first prompt)
21
- # @param pending [Hash] the child's pending question (string keys inside
22
- # when it came from a saved session)
21
+ # @param pending [PendingQuestion] the child's pending question (string
22
+ # keys inside its approval and relay when it came from a saved session)
23
23
  # @param relay_id [String] RelayDesk's id
24
24
  # @param more [Integer] other delegates' approvals waiting behind this one
25
25
  # @return [Hash] Engine#open_question fields
26
26
  def card(child, pending, relay_id:, more: 0)
27
- pending = deep_symbolize(pending)
28
- facts = pending[:approval].is_a?(Hash) ? pending[:approval] : {}
27
+ facts = pending.approval.is_a?(Hash) ? deep_symbolize(pending.approval) : {}
29
28
  short = child.id.to_s[0, 8]
30
- inner = pending[:relay].is_a?(Hash) ? pending[:relay] : nil
29
+ inner = pending.relay.is_a?(Hash) ? deep_symbolize(pending.relay) : nil
31
30
  chain = inner ? [*Array(inner[:chain]), short] : [short]
32
- asked = inner ? inner[:asked].to_s : pending[:question].to_s
31
+ asked = inner ? inner[:asked].to_s : pending.question.to_s
33
32
  task = inner ? inner[:task].to_s : task_of(child)
34
33
  who = chain.reverse.join(" → ")
35
34
  {
36
35
  question: question_text(who, task, asked),
37
- options: relabel(Array(pending[:options]), facts[:scopes], who),
36
+ options: relabel(Array(pending.options), facts[:scopes], who),
38
37
  header: header(who, more),
39
38
  multi_select: false,
40
- allow_freeform: pending.key?(:allow_freeform) ? !!pending[:allow_freeform] : true,
39
+ allow_freeform: pending.allow_freeform.nil? || !!pending.allow_freeform,
41
40
  kind: Guardrails::Approval::KIND,
42
41
  approval: facts,
43
- relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending[:id].to_s,
42
+ relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
44
43
  task: task, chain: chain, asked: asked, more: more.to_i }
45
44
  }
46
45
  end