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
@@ -1,274 +0,0 @@
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
- module Web
17
- # What a saved assistant message did, for the web turn view's reload: its
18
- # thinking and its tool calls (name, the params line the live row shows,
19
- # the output from the tool_response messages after it). Two storage
20
- # shapes, best effort:
21
- # * the native loop (a profile, llama.cpp): the raw response as content
22
- # (thinking + tool-call markup, Qwen or Gemma), then one tool_response
23
- # whose content joins every call's output with "\n\n---\n\n";
24
- # * the chat loop (api: openai): the text without thinking, the host's
25
- # reasoning as +thinking+ (older sessions have none), the calls as
26
- # tool_calls ({id, name, arguments}), then one tool_response per call
27
- # (tool_call_id).
28
- # A message that can't be read gives no parts, never an error.
29
- #
30
- # A call's images (a read image, a plugin or MCP tool's) are on its
31
- # tool_response: chat, per call; native, one list for every call, split
32
- # by the saved image_counts (older sessions without them: only a lone
33
- # call gets them).
34
- #
35
- # A call's chi-owned id (ToolIds, saved as tool_ids) is the part's
36
- # tool_id:, and an LLM context edit saved on its output (LLMContextEdit,
37
- # under edits) the part's edit: (.edit_marks), the row's ✂ mark.
38
- #
39
- # A plugin tool's params are what its live row showed (its preview),
40
- # saved with the result as tool_params (native: one per call, chat: one
41
- # per result); its label ("chrome: screenshot") likewise as tool_labels,
42
- # the part's label:, and its row refs as tool_refs (RowRefs, checked
43
- # again here), the part's refs:. The web server has no Engine and runs no plugins, so a
44
- # result without them (older sessions) falls back to the given
45
- # registry: the built-ins', and a plugin tool's arguments as key="value"
46
- # from the parsers' args:, as the live row shows them for a tool
47
- # without a preview.
48
- module MessageParts
49
- JOINER = "\n\n---\n\n"
50
- # A joined output's pieces each start with "[tool]": split there first,
51
- # so an output that holds the joiner itself stays whole.
52
- JOINER_BEFORE_TAG = /\n\n---\n\n(?=\[)/
53
- # The row shows 300 chars and the rest on hover; a read of a big file
54
- # would otherwise ride along with every reload. A longer one keeps its
55
- # head and tail (ToolOutputCut.preview).
56
- OUTPUT_MAX = ToolOutputCut::WEB_PREVIEW_CHARS
57
-
58
- QWEN_THINK = %r{<think>(.*?)</think>}m
59
- QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
60
- GEMMA_THOUGHT = Regexp.new("#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN)}(.*?)" \
61
- "(?:#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE)}|\\z)", Regexp::MULTILINE)
62
-
63
- CallRef = Struct.new(:name, :arguments)
64
-
65
- module_function
66
-
67
- # @param message [Hash] a saved model message (symbol or string keys)
68
- # @param responses [Array<Hash>] the tool_response messages after it
69
- # @param registry [Tools::Registry] the session's tools
70
- # @param cwd [String, nil] the session's working directory (a file
71
- # tool's title is relative to it)
72
- # @param marks [Hash{String => Hash}] the session's edit marks by
73
- # output id (.edit_marks)
74
- # @return [Hash, nil] { thinking:, tools: [{ tool:, params:, title:,
75
- # output:, output_truncated:, images:, tool_id:, edit: }] } with only what it found; nil for nothing
76
- def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil, marks: {})
77
- content = field(message, :content).to_s
78
- calls = field(message, :tool_calls)
79
- tools = if calls.is_a?(Array) && !calls.empty?
80
- native_tools(calls, responses, registry, cwd, marks)
81
- else
82
- markup_tools(content, responses, registry, cwd, marks)
83
- end
84
- parts = {}
85
- thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
86
- parts[:thinking] = thinking unless thinking.empty?
87
- parts[:tools] = tools unless tools.empty?
88
- parts.empty? ? nil : parts
89
- rescue StandardError
90
- nil
91
- end
92
-
93
- def thinking_of(content)
94
- blocks = content.scan(QWEN_THINK).flatten
95
- if blocks.empty? && !content.include?("<think>") && (open = content[QWEN_OPEN_THINK, 1])
96
- blocks = [open]
97
- end
98
- blocks += content.scan(GEMMA_THOUGHT).flatten
99
- blocks.map(&:strip).reject(&:empty?).join("\n\n")
100
- end
101
-
102
- def markup_tools(content, responses, registry, cwd = nil, marks = {})
103
- calls = []
104
- calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
105
- calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
106
- return [] if calls.empty?
107
-
108
- joined = responses.empty? ? nil : responses.map { |r| field(r, :content).to_s }.join(JOINER)
109
- outputs = split_outputs(joined, calls.length)
110
- shown = saved_list(responses, :tool_params, calls.length)
111
- labels = saved_list(responses, :tool_labels, calls.length)
112
- diffs = saved_list(responses, :tool_diffs, calls.length)
113
- refs = saved_list(responses, :tool_refs, calls.length)
114
- ids = saved_list(responses, :tool_ids, calls.length)
115
- images = split_images(responses, calls.length)
116
- calls.each_with_index.map do |call, i|
117
- tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], refs: refs[i],
118
- cwd: cwd, tool_id: ids[i], marks: marks)
119
- end
120
- end
121
-
122
- # A per-call list saved with the joined result(s), or none when its
123
- # length doesn't match the calls.
124
- def saved_list(responses, key, count)
125
- list = responses.flat_map { |r| Array(field(r, key)) }
126
- list.length == count ? list : []
127
- end
128
-
129
- # Each call's images from the joined tool_response(s).
130
- def split_images(responses, count)
131
- images = responses.flat_map { |r| Array(field(r, :images)) }
132
- return [] if images.empty?
133
-
134
- counts = responses.flat_map { |r| Array(field(r, :image_counts)) }
135
- return count == 1 ? [images] : [] unless counts.length == count && counts.sum == images.length
136
-
137
- counts.map { |n| images.shift(n) }
138
- end
139
-
140
- def native_tools(calls, responses, registry, cwd = nil, marks = {})
141
- # By id only for an id that names one response: a server may omit
142
- # it (nil), send "" or repeat it; those pair by position.
143
- ids = responses.map { |r| field(r, :tool_call_id).to_s }
144
- unique = ->(id) { !id.empty? && ids.count(id) == 1 }
145
- by_id = responses.each_with_index.to_h { |r, i| [ids[i], r] }.select { |id, _| unique.call(id) }
146
- calls.each_with_index.map do |raw, i|
147
- ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
148
- call = LLM::NativeToolNormalizer.normalize(ref) || { name: ref.name }
149
- response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
150
- tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
151
- response && field(response, :images), label: field(response, :tool_labels),
152
- diff: field(response, :tool_diffs),
153
- refs: field(response, :tool_refs), cwd: cwd,
154
- tool_id: Array(field(response, :tool_ids)).first, marks: marks)
155
- end
156
- end
157
-
158
- # The ✂ mark of each output an LLM context edit is saved on, by its
159
- # id, from +messages+ (a session's, symbol or string keys): {kind:
160
- # "stale" | "forget", staged: true while a forget waits for the
161
- # turn's end, note: (a stale stub's reason, "superseded by a later
162
- # read"; the first output of a forget_outputs call's, its note), with:
163
- # (the others of that call's: the first one's id), kept: ("12-40, 50",
164
- # a forget's kept lines)}. A forget call's outputs share the note,
165
- # author and stamp (LLMContextView's key); the first in the session's
166
- # order carries the note.
167
- # @return [Hash{String => Hash}]
168
- def edit_marks(messages)
169
- first = {}
170
- Array(messages).each_with_object({}) do |entry, marks|
171
- next unless field(entry, :role).to_s == "tool_response"
172
-
173
- saved = field(entry, :edits)
174
- next unless saved.is_a?(Hash)
175
-
176
- edits = saved.filter_map { |id, hash| LLMContextEdit.from_h(id, hash) }.to_h { |edit| [edit.id, edit] }
177
- order = Array(field(entry, :tool_ids)).map(&:to_s)
178
- edits.sort_by { |id, _| order.index(id) || order.size }.each do |id, edit|
179
- marks[id] = edit_mark(edit, first)
180
- end
181
- end
182
- rescue StandardError
183
- {}
184
- end
185
-
186
- def edit_mark(edit, first)
187
- mark = { kind: edit.kind.to_s }
188
- mark[:staged] = true unless edit.applied?
189
- if edit.kind == :stale
190
- mark[:note] = edit.note[/superseded by .*\z/] || edit.note
191
- else
192
- key = [edit.note, edit.by, edit.staged_at]
193
- if first.key?(key)
194
- mark[:with] = first[key]
195
- else
196
- first[key] = edit.id
197
- mark[:note] = edit.note
198
- end
199
- kept = edit.keep.map { |from, to| from == to ? from.to_s : "#{from}-#{to}" }
200
- mark[:kept] = kept.join(", ") unless kept.empty?
201
- end
202
- mark
203
- end
204
-
205
- # +output+'s ✂ mark (+marks+ by +id+); an applied stale stub says
206
- # what it frees: its output, ~chars/4.
207
- def mark_of(id, marks, output)
208
- mark = marks[id]
209
- return nil unless mark
210
- return mark unless mark[:kind] == "stale" && !mark[:staged] && output
211
-
212
- mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
213
- end
214
-
215
- # +count+ outputs from a joined tool_response: the "[tool]"-tagged
216
- # split when it gives the right count, else the plain one; extra pieces
217
- # stay with the last call, missing ones are nil.
218
- def split_outputs(joined, count)
219
- return [] if joined.nil?
220
- return [joined] if count == 1
221
-
222
- pieces = joined.split(JOINER_BEFORE_TAG, -1)
223
- pieces = joined.split(JOINER, -1) if pieces.length != count
224
- return pieces if pieces.length == count
225
-
226
- head = pieces.first(count - 1)
227
- rest = pieces.drop(count - 1)
228
- head + [rest.empty? ? nil : rest.join(JOINER)]
229
- end
230
-
231
- # +shown+ is the saved params line, when it is a String.
232
- # +diff+ is what an edit/write changed (EditPreview.change), saved as
233
- # tool_diffs. +refs+: a plugin tool's row refs, saved as tool_refs
234
- # (RowRefs.for_display checks them again against +cwd+). +cwd+: the
235
- # session's working directory, for the title.
236
- # +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
237
- def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
238
- tool_id: nil, marks: {})
239
- # A built-in reached through call_tool reloads as that built-in's
240
- # row, with via (its saved params and label are the built-in's).
241
- call = Tools::BuiltinCalls.unwrap_saved(call, output)
242
- name = call[:name].to_s
243
- params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
244
- part = { tool: name, params: params.to_s }
245
- # Its row fields (ToolRowFields: title, called_as, via, view, ref), built from the saved
246
- # call (never saved themselves), so older sessions get them too. The
247
- # saved call is rebuilt by BuiltinCalls, which sets called_as and via again.
248
- part.merge!(ToolRowFields.for(name, call, cwd: cwd))
249
- part[:label] = label if label.is_a?(String) && !label.empty?
250
- unless output.nil?
251
- part[:output] = ToolOutputCut.preview(output, cap: OUTPUT_MAX, tool: name)
252
- part[:output_truncated] = true if output.length > OUTPUT_MAX
253
- end
254
- shots = Array(images).select { |image| image.is_a?(Hash) }
255
- part[:images] = shots.map { |image| ImageStore.symbolize(image).slice(:file, :name, :width, :height) } unless shots.empty?
256
- part[:diff] = diff if diff.is_a?(Hash)
257
- row_refs = RowRefs.for_display(refs, cwd: cwd)
258
- part[:refs] = row_refs unless row_refs.empty?
259
- if tool_id
260
- part[:tool_id] = tool_id.to_s
261
- mark = mark_of(tool_id.to_s, marks, output)
262
- part[:edit] = mark if mark
263
- end
264
- part
265
- end
266
-
267
- def field(hash, key)
268
- return nil unless hash.is_a?(Hash)
269
-
270
- hash.key?(key) ? hash[key] : hash[key.to_s]
271
- end
272
- end
273
- end
274
- end