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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -1
- data/docs/architecture.md +11 -4
- data/docs/cli.md +13 -1
- data/docs/configuration.md +4 -3
- data/docs/guardrails.md +9 -0
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +20 -7
- data/docs/internals/context-telemetry.md +22 -0
- data/docs/internals/engine.md +15 -4
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +34 -0
- data/docs/plugins.md +43 -1
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/answer_command.rb +5 -5
- data/lib/samagotchi/bridge.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +52 -39
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +1 -1
- data/lib/samagotchi/desktop/macos.rb +1 -2
- data/lib/samagotchi/effective_model.rb +5 -0
- data/lib/samagotchi/engine.rb +105 -16
- data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
- data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +13 -10
- data/lib/samagotchi/llm_context_override.rb +8 -6
- data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
- data/lib/samagotchi/memory_bundle/builder.rb +13 -14
- data/lib/samagotchi/memory_bundle/installer.rb +122 -5
- data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
- data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/source.rb +66 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
- data/lib/samagotchi/message_parts.rb +272 -0
- data/lib/samagotchi/model_price.rb +4 -3
- data/lib/samagotchi/parent_report.rb +30 -22
- data/lib/samagotchi/paths.rb +15 -1
- data/lib/samagotchi/pending_question.rb +49 -0
- data/lib/samagotchi/question_desk.rb +39 -32
- data/lib/samagotchi/relay.rb +8 -9
- data/lib/samagotchi/relay_verifier.rb +2 -2
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +4 -4
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/session.rb +22 -14
- data/lib/samagotchi/session_commands.rb +5 -0
- data/lib/samagotchi/session_manager.rb +1 -2
- data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
- data/lib/samagotchi/session_metrics/totals.rb +127 -0
- data/lib/samagotchi/session_metrics.rb +118 -83
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +24 -10
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
- data/lib/samagotchi/terminal_ui.rb +5 -3
- data/lib/samagotchi/tool_output_cut.rb +1 -1
- data/lib/samagotchi/tool_response.rb +13 -13
- data/lib/samagotchi/tool_row_fields.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +16 -9
- data/lib/samagotchi/toolkit.rb +9 -8
- data/lib/samagotchi/toolkit_command.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +1 -5
- data/lib/samagotchi/tools/delegate_relay.rb +7 -7
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/web_fetch.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -2
- data/lib/samagotchi/web/public/app.js +21 -58
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/composer.js +9 -5
- data/lib/samagotchi/web/public/ctx.js +84 -11
- data/lib/samagotchi/web/public/index.html +32 -36
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/markdown_place.js +53 -0
- data/lib/samagotchi/web/public/question_cards.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_slots.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +38 -3
- data/lib/samagotchi/web/public/timing.js +11 -2
- data/lib/samagotchi/web/public/toasts.js +69 -0
- metadata +8 -2
- 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
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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
|
|
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
|
|
147
|
+
pending ||= PendingQuestion.new
|
|
148
|
+
approval = pending.approval.is_a?(Hash) ? symbolize(pending.approval) : nil
|
|
147
149
|
{
|
|
148
|
-
id: pending
|
|
149
|
-
kind: (pending
|
|
150
|
-
header: blank(pending
|
|
151
|
-
text: pending
|
|
152
|
-
options: Array(pending
|
|
153
|
-
multi_select: !!pending
|
|
154
|
-
allow_freeform: !!pending
|
|
155
|
-
limit: pending
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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(
|
|
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"
|
data/lib/samagotchi/paths.rb
CHANGED
|
@@ -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.
|
|
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
|