samagotchi 0.75.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +20 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +15 -4
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +34 -0
  13. data/docs/plugins.md +43 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +3 -1
  16. data/lib/samagotchi/answer_command.rb +5 -5
  17. data/lib/samagotchi/bridge.rb +3 -3
  18. data/lib/samagotchi/bundle_command.rb +52 -39
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  21. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  26. data/lib/samagotchi/child_reports.rb +1 -1
  27. data/lib/samagotchi/desktop/macos.rb +1 -2
  28. data/lib/samagotchi/effective_model.rb +5 -0
  29. data/lib/samagotchi/engine.rb +105 -16
  30. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  31. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  32. data/lib/samagotchi/kernel_loop.rb +13 -10
  33. data/lib/samagotchi/llm_context_override.rb +8 -6
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  35. data/lib/samagotchi/memory_bundle/builder.rb +13 -14
  36. data/lib/samagotchi/memory_bundle/installer.rb +122 -5
  37. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  38. data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
  39. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  40. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  42. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  43. data/lib/samagotchi/message_parts.rb +272 -0
  44. data/lib/samagotchi/model_price.rb +4 -3
  45. data/lib/samagotchi/parent_report.rb +30 -22
  46. data/lib/samagotchi/paths.rb +15 -1
  47. data/lib/samagotchi/pending_question.rb +49 -0
  48. data/lib/samagotchi/question_desk.rb +39 -32
  49. data/lib/samagotchi/relay.rb +8 -9
  50. data/lib/samagotchi/relay_verifier.rb +2 -2
  51. data/lib/samagotchi/relay_watcher.rb +2 -2
  52. data/lib/samagotchi/reply_wait.rb +4 -4
  53. data/lib/samagotchi/row_refs.rb +1 -1
  54. data/lib/samagotchi/session.rb +22 -14
  55. data/lib/samagotchi/session_commands.rb +5 -0
  56. data/lib/samagotchi/session_manager.rb +1 -2
  57. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  58. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  59. data/lib/samagotchi/session_metrics.rb +118 -83
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  61. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  62. data/lib/samagotchi/terminal_ui/formatting.rb +24 -10
  63. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  64. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  65. data/lib/samagotchi/terminal_ui.rb +5 -3
  66. data/lib/samagotchi/tool_output_cut.rb +1 -1
  67. data/lib/samagotchi/tool_response.rb +13 -13
  68. data/lib/samagotchi/tool_row_fields.rb +1 -1
  69. data/lib/samagotchi/tool_runner.rb +16 -9
  70. data/lib/samagotchi/toolkit.rb +9 -8
  71. data/lib/samagotchi/toolkit_command.rb +2 -2
  72. data/lib/samagotchi/tools/builtins.rb +1 -5
  73. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  74. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  75. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  76. data/lib/samagotchi/version.rb +1 -1
  77. data/lib/samagotchi/web/app.rb +5 -2
  78. data/lib/samagotchi/web/public/app.js +21 -58
  79. data/lib/samagotchi/web/public/chains.js +1 -1
  80. data/lib/samagotchi/web/public/composer.js +9 -5
  81. data/lib/samagotchi/web/public/ctx.js +84 -11
  82. data/lib/samagotchi/web/public/index.html +32 -36
  83. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  84. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  85. data/lib/samagotchi/web/public/question_cards.js +1 -1
  86. data/lib/samagotchi/web/public/session_state.js +3 -0
  87. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  88. data/lib/samagotchi/web/public/stream_handlers.js +38 -3
  89. data/lib/samagotchi/web/public/timing.js +11 -2
  90. data/lib/samagotchi/web/public/toasts.js +69 -0
  91. metadata +8 -2
  92. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -141,7 +141,8 @@ module Samagotchi
141
141
  name: BUNDLE_NAME,
142
142
  scope: SCOPE,
143
143
  force: false,
144
- strict: true
144
+ strict: true,
145
+ shipped_dir: File.dirname(GEM_BUNDLE_DIR)
145
146
  )
146
147
  installer.run
147
148
  result(:installed, to: to, warnings: installer.warnings)
@@ -182,7 +183,8 @@ module Samagotchi
182
183
  end
183
184
 
184
185
  def upgrade_installer
185
- Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true)
186
+ Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true,
187
+ shipped_dir: File.dirname(GEM_BUNDLE_DIR))
186
188
  end
187
189
  private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present,
188
190
  :upgrade_installer, :result, :conflicting
@@ -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
@@ -37,9 +37,10 @@ module Samagotchi
37
37
  # USD for one generation, OpenAI usage semantics: +prompt_tokens+ holds
38
38
  # the cached reads and the cache writes (OpenRouter keeps that for
39
39
  # Anthropic models), and reasoning is inside +completion_tokens+.
40
- # llama.cpp's native timings.prompt_n leaves its cache_n out, so the
41
- # uncached part is clamped at 0: an undercount, harmless because priced
42
- # models are remote.
40
+ # llama.cpp's native stream ends with tokens_evaluated, the whole prompt
41
+ # (SessionMetrics keeps the largest count, so not timings.prompt_n,
42
+ # which leaves cache_n out); the uncached part is clamped at 0 for a
43
+ # report that has prompt_n alone.
43
44
  def cost(prompt_tokens:, cached_tokens:, cache_write_tokens:, completion_tokens:)
44
45
  uncached = (prompt_tokens - cached_tokens - cache_write_tokens).clamp(0..)
45
46
  ((uncached * input) + (cached_tokens * cache_read) + (cache_write_tokens * cache_write) +
@@ -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"
@@ -28,7 +28,21 @@ module Samagotchi
28
28
  # @return [String] $XDG_CONFIG_HOME (absolute), else ~/.config
29
29
  def config_home(env: ENV)
30
30
  xdg = env.fetch("XDG_CONFIG_HOME", "").to_s.strip
31
- xdg.empty? ? File.expand_path("~/.config") : File.absolute_path(xdg)
31
+ xdg.empty? ? File.join(Dir.home, ".config") : File.absolute_path(xdg)
32
+ end
33
+
34
+ # The set XDG dirs, made absolute. +set+ is :nonblank (unset or blank
35
+ # values omitted) or :key (present keys included, even when blank).
36
+ # @return [Hash{String => String}]
37
+ def absolute_xdg(env: ENV, set: :nonblank)
38
+ included = case set
39
+ when :nonblank then ->(key) { !env.fetch(key, "").to_s.strip.empty? }
40
+ when :key then ->(key) { env.key?(key) }
41
+ else raise ArgumentError, "set must be :nonblank or :key"
42
+ end
43
+ { "XDG_STATE_HOME" => :state_home, "XDG_CONFIG_HOME" => :config_home }.each_with_object({}) do |(key, home), dirs|
44
+ dirs[key] = public_send(home, env: env) if included.call(key)
45
+ end
32
46
  end
33
47
 
34
48
  # +path+ as chi shows it: ~ for the home folder (~/a for one in it),
@@ -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