samagotchi 0.8.1 → 0.10.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 +89 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +9 -2
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/memory.md +5 -3
- data/docs/plugins.md +47 -17
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/identity.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/targets.rb +7 -1
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/log_subscriber.rb +5 -1
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +25 -30
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +29 -15
- data/lib/samagotchi/session_metrics.rb +2 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +8 -0
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +2 -13
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +13 -1
|
@@ -1,33 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "model_profile"
|
|
4
|
-
require_relative "tools/execute"
|
|
5
|
-
require_relative "tools/read"
|
|
6
|
-
require_relative "tools/write"
|
|
7
|
-
require_relative "tools/memory"
|
|
8
|
-
require_relative "tools/edit"
|
|
9
|
-
require_relative "tools/task_create"
|
|
10
|
-
require_relative "tools/task_get"
|
|
11
|
-
require_relative "tools/task_list"
|
|
12
|
-
require_relative "tools/task_stop"
|
|
13
|
-
require_relative "tools/task_wait"
|
|
14
|
-
require_relative "tools/web_fetch"
|
|
15
|
-
require_relative "tools/register_reminder"
|
|
16
|
-
require_relative "tools/cancel_reminder"
|
|
17
|
-
require_relative "tools/list_reminders"
|
|
18
|
-
require_relative "tools/list_sessions"
|
|
19
|
-
require_relative "tools/send_note"
|
|
20
|
-
require_relative "tools/delegate"
|
|
21
|
-
require_relative "tools/delegate_result"
|
|
22
|
-
require_relative "tools/ask_user_question"
|
|
23
4
|
require_relative "tools/args"
|
|
5
|
+
require_relative "tools/ask_user_question"
|
|
6
|
+
require_relative "tools/builtin_calls"
|
|
24
7
|
|
|
25
8
|
module Samagotchi
|
|
26
|
-
# Per-profile strategy for
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# for call/thought
|
|
9
|
+
# Per-profile strategy for reading tool calls out of raw model output and
|
|
10
|
+
# stripping per-profile thought blocks. #read gives each call as
|
|
11
|
+
# {name:, args:, raw:} (its wire format read, nothing more); #parse builds
|
|
12
|
+
# them into internal calls with Tools::BuiltinCalls, as the chat path
|
|
13
|
+
# does. The ModelProfile remains the source of truth for call/thought
|
|
14
|
+
# delimiter tokens.
|
|
31
15
|
class ToolCallParser
|
|
32
16
|
# @param profile [ModelProfile]
|
|
33
17
|
# @return [Gemma, Qwen]
|
|
@@ -67,8 +51,13 @@ module Samagotchi
|
|
|
67
51
|
|
|
68
52
|
# Parse canonical Gemma 4 tool calls:
|
|
69
53
|
# <|tool_call>call:NAME{params}<tool_call|>
|
|
70
|
-
# Uses String#index for outer delimiters (no backtracking risk).
|
|
71
54
|
def parse(text)
|
|
55
|
+
read(text).map { |call| Tools::BuiltinCalls.build(call[:name], call[:args], raw: call[:raw]) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Each tool call as {name:, args:, raw:}, unbuilt.
|
|
59
|
+
# Uses String#index for outer delimiters (no backtracking risk).
|
|
60
|
+
def read(text)
|
|
72
61
|
results = []
|
|
73
62
|
|
|
74
63
|
pos = 0
|
|
@@ -82,7 +71,7 @@ module Samagotchi
|
|
|
82
71
|
name = m[1]
|
|
83
72
|
params_raw = body[m.end(0)..]
|
|
84
73
|
params_raw = params_raw[0..-2] if params_raw.end_with?("}")
|
|
85
|
-
results <<
|
|
74
|
+
results << read_call(name, params_raw.strip)
|
|
86
75
|
end
|
|
87
76
|
pos = close_pos + @tool_call_close.length
|
|
88
77
|
end
|
|
@@ -154,155 +143,47 @@ module Samagotchi
|
|
|
154
143
|
str.gsub(@string_delim, "")
|
|
155
144
|
end
|
|
156
145
|
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
146
|
+
# One call's {key: "value"} params as args (string keys).
|
|
147
|
+
#
|
|
148
|
+
# A built-in's params come from the flat scan (extract_native_params):
|
|
149
|
+
# strings, quotes unescaped its own way. A tool that isn't built in
|
|
150
|
+
# gets its body read as values (Tools::Args.parse_gemma: numbers,
|
|
151
|
+
# booleans, lists, objects), the flat scan when that fails, and the
|
|
152
|
+
# raw text as content for the unknown-tool error.
|
|
160
153
|
#
|
|
161
154
|
# The model sometimes omits quotes and/or the space after the colon, e.g.
|
|
162
155
|
# {command:ruby -e 'puts 1'} instead of {command: "ruby -e 'puts 1'"}
|
|
163
|
-
# In that case extract_native_params finds nothing
|
|
164
|
-
#
|
|
165
|
-
#
|
|
156
|
+
# In that case extract_native_params finds nothing; the main argument
|
|
157
|
+
# (BuiltinCalls row's fallback) is then the body after a "key:" prefix,
|
|
158
|
+
# or the whole body.
|
|
166
159
|
#
|
|
167
160
|
# Gemma 4 may also use its <|"|> string delimiter token instead of plain
|
|
168
|
-
# quotes
|
|
169
|
-
#
|
|
170
|
-
def
|
|
171
|
-
|
|
161
|
+
# quotes; it is stripped from every value but file text, old/new, env
|
|
162
|
+
# and options.
|
|
163
|
+
def read_call(name, params_raw)
|
|
164
|
+
row = Tools::BuiltinCalls.row(name)
|
|
165
|
+
unless row
|
|
166
|
+
args = Tools::Args.parse_gemma(params_raw, @string_delim) || extract_native_params(params_raw)
|
|
167
|
+
return { name: name, args: args, raw: strip_gemma_delimiters(params_raw) }
|
|
168
|
+
end
|
|
172
169
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
strip_param_prefix(params_raw, "command") ||
|
|
177
|
-
params_raw
|
|
178
|
-
{ name: name, content: strip_gemma_delimiters(content), path: nil, scope: nil }
|
|
179
|
-
when Tools::Read::NAME
|
|
180
|
-
content = params["path"] ||
|
|
181
|
-
strip_param_prefix(params_raw, "path") ||
|
|
182
|
-
params_raw
|
|
183
|
-
{
|
|
184
|
-
name: name,
|
|
185
|
-
content: strip_gemma_delimiters(content),
|
|
186
|
-
path: nil,
|
|
187
|
-
scope: nil,
|
|
188
|
-
start_line: params["start_line"],
|
|
189
|
-
end_line: params["end_line"]
|
|
190
|
-
}
|
|
191
|
-
when Tools::Write::NAME
|
|
192
|
-
{ name: name, content: params["content"] || "", path: params["path"], scope: nil }
|
|
193
|
-
when Tools::MemoryRead::NAME
|
|
194
|
-
content = params["name"] ||
|
|
195
|
-
strip_param_prefix(params_raw, "name") ||
|
|
196
|
-
params_raw
|
|
197
|
-
{ name: name, content: strip_gemma_delimiters(content), path: nil, scope: params["scope"] }
|
|
198
|
-
when Tools::MemoryWrite::NAME
|
|
199
|
-
# The declaration uses "name" (required) and "scope" (required).
|
|
200
|
-
entry_name = params["name"] || ""
|
|
201
|
-
{ name: name, content: params["content"] || "", path: entry_name, scope: params["scope"], description: params["description"] ? strip_gemma_delimiters(params["description"]) : nil,
|
|
202
|
-
current_model_only: params["current_model_only"] }
|
|
203
|
-
when Tools::Edit::NAME
|
|
204
|
-
old_text = params["old_text"] || params["old"] || ""
|
|
205
|
-
new_text = params["new_text"] || params["new"] || ""
|
|
206
|
-
content = "<old>#{old_text}</old><new>#{new_text}</new>"
|
|
207
|
-
{
|
|
208
|
-
name: name,
|
|
209
|
-
content: content,
|
|
210
|
-
path: params["path"],
|
|
211
|
-
scope: nil,
|
|
212
|
-
start_line: params["start_line"],
|
|
213
|
-
end_line: params["end_line"]
|
|
214
|
-
}
|
|
215
|
-
when Tools::TaskCreate::NAME
|
|
216
|
-
command = params["command"] ||
|
|
217
|
-
strip_param_prefix(params_raw, "command") ||
|
|
218
|
-
params_raw
|
|
219
|
-
{
|
|
220
|
-
name: name,
|
|
221
|
-
content: strip_gemma_delimiters(command),
|
|
222
|
-
path: nil,
|
|
223
|
-
scope: nil,
|
|
224
|
-
cwd: params["cwd"],
|
|
225
|
-
env: params["env"]
|
|
226
|
-
}
|
|
227
|
-
when Tools::TaskGet::NAME, Tools::TaskStop::NAME
|
|
228
|
-
task_id = params["id"] ||
|
|
229
|
-
params["task_id"] ||
|
|
230
|
-
strip_param_prefix(params_raw, "id") ||
|
|
231
|
-
strip_param_prefix(params_raw, "task_id") ||
|
|
232
|
-
params_raw
|
|
233
|
-
{
|
|
234
|
-
name: name,
|
|
235
|
-
content: strip_gemma_delimiters(task_id),
|
|
236
|
-
path: nil,
|
|
237
|
-
scope: nil
|
|
238
|
-
}
|
|
239
|
-
when Tools::TaskWait::NAME
|
|
240
|
-
task_id = params["id"] ||
|
|
241
|
-
params["task_id"] ||
|
|
242
|
-
strip_param_prefix(params_raw, "id") ||
|
|
243
|
-
strip_param_prefix(params_raw, "task_id") ||
|
|
244
|
-
params_raw
|
|
245
|
-
{
|
|
246
|
-
name: name,
|
|
247
|
-
content: strip_gemma_delimiters(task_id),
|
|
248
|
-
path: nil,
|
|
249
|
-
scope: nil,
|
|
250
|
-
timeout: params["timeout"],
|
|
251
|
-
tail_lines: params["tail_lines"],
|
|
252
|
-
done_pattern: params["done_pattern"]
|
|
253
|
-
}
|
|
254
|
-
when Tools::TaskList::NAME
|
|
255
|
-
{ name: name, content: "", path: nil, scope: nil }
|
|
256
|
-
when Tools::WebFetch::NAME
|
|
257
|
-
content = params["url"] ||
|
|
258
|
-
strip_param_prefix(params_raw, "url") ||
|
|
259
|
-
params_raw
|
|
260
|
-
{ name: name, content: strip_gemma_delimiters(content), path: nil, scope: nil }
|
|
261
|
-
when Tools::RegisterReminder::NAME
|
|
262
|
-
content = params["name"] || strip_param_prefix(params_raw, "name") || params_raw
|
|
263
|
-
{
|
|
264
|
-
name: name,
|
|
265
|
-
content: strip_gemma_delimiters(content),
|
|
266
|
-
path: nil,
|
|
267
|
-
scope: nil,
|
|
268
|
-
description: params["description"] || "",
|
|
269
|
-
interval_minutes: params["interval_minutes"] || "1"
|
|
270
|
-
}
|
|
271
|
-
when Tools::CancelReminder::NAME
|
|
272
|
-
content = params["name"] || strip_param_prefix(params_raw, "name") || params_raw
|
|
273
|
-
{ name: name, content: strip_gemma_delimiters(content), path: nil, scope: nil }
|
|
274
|
-
when Tools::ListReminders::NAME
|
|
275
|
-
{ name: name, content: "", path: nil, scope: nil }
|
|
276
|
-
when Tools::ListSessions::NAME
|
|
277
|
-
cwd = params["cwd"] || strip_param_prefix(params_raw, "cwd")
|
|
278
|
-
{ name: name, content: "", path: nil, scope: nil, cwd: cwd && strip_gemma_delimiters(cwd) }
|
|
279
|
-
when Tools::SendNote::NAME
|
|
280
|
-
{ name: name, content: strip_gemma_delimiters(params["text"].to_s), path: nil, scope: nil,
|
|
281
|
-
session: strip_gemma_delimiters(params["session"].to_s) }
|
|
282
|
-
when Tools::Delegate::NAME
|
|
283
|
-
task = params["task"] || strip_param_prefix(params_raw, "task") || params_raw
|
|
284
|
-
{ name: name, content: strip_gemma_delimiters(task.to_s), path: nil, scope: nil,
|
|
285
|
-
model: params["model"] && strip_gemma_delimiters(params["model"]),
|
|
286
|
-
session: params["session"] && strip_gemma_delimiters(params["session"]),
|
|
287
|
-
wait: params["wait"], timeout: params["timeout"] }
|
|
288
|
-
when Tools::DelegateResult::NAME
|
|
289
|
-
{ name: name, content: "", path: nil, scope: nil,
|
|
290
|
-
session: params["session"] && strip_gemma_delimiters(params["session"]), timeout: params["timeout"] }
|
|
291
|
-
when Tools::AskUserQuestion::NAME
|
|
292
|
-
{ name: name, content: params["question"] ? strip_gemma_delimiters(params["question"]) : strip_gemma_delimiters(params_raw),
|
|
293
|
-
path: nil, scope: nil,
|
|
294
|
-
question: params["question"] ? strip_gemma_delimiters(params["question"]) : strip_gemma_delimiters(params_raw),
|
|
295
|
-
options: parse_ask_options(params, params_raw),
|
|
296
|
-
header: params["header"] ? strip_gemma_delimiters(params["header"]) : nil,
|
|
297
|
-
multi_select: params["multi_select"],
|
|
298
|
-
allow_freeform: params["allow_freeform"] }
|
|
299
|
-
else
|
|
300
|
-
# A registry (plugin) tool, or an unknown one: its arguments as a
|
|
301
|
-
# Hash on args: (typed by the schema at dispatch), and the raw text
|
|
302
|
-
# as content for the unknown-tool error.
|
|
303
|
-
args = Tools::Args.parse_gemma(params_raw, @string_delim) || params
|
|
304
|
-
{ name: name, content: strip_gemma_delimiters(params_raw), path: nil, scope: nil, args: args }
|
|
170
|
+
params = extract_native_params(params_raw)
|
|
171
|
+
args = params.to_h do |key, value|
|
|
172
|
+
[key, value.is_a?(String) && !row.verbatim_key?(key) ? strip_gemma_delimiters(value) : value]
|
|
305
173
|
end
|
|
174
|
+
fill_main_argument(row, args, params_raw)
|
|
175
|
+
args["options"] = parse_ask_options(params, params_raw) if row.options
|
|
176
|
+
{ name: name, args: args, raw: params_raw }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The row's main argument from a body the scan didn't find it in.
|
|
180
|
+
def fill_main_argument(row, args, params_raw)
|
|
181
|
+
return unless row.fallback
|
|
182
|
+
return if row.fallback_keys.any? { |key| args.key?(key) }
|
|
183
|
+
|
|
184
|
+
value = row.fallback_keys.lazy.map { |key| strip_param_prefix(params_raw, key) }.find(&:itself) unless row.fallback == :raw
|
|
185
|
+
value ||= params_raw unless row.fallback == :prefix
|
|
186
|
+
args[row.fallback_key || row.content_keys.first] = strip_gemma_delimiters(value) if value
|
|
306
187
|
end
|
|
307
188
|
|
|
308
189
|
def parse_ask_options(params, params_raw)
|
|
@@ -424,6 +305,12 @@ module Samagotchi
|
|
|
424
305
|
end
|
|
425
306
|
|
|
426
307
|
def parse(text)
|
|
308
|
+
read(text).map { |call| Tools::BuiltinCalls.build(call[:name], call[:args], raw: call[:raw]) }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Each <tool_call> block as {name:, args:, raw:}: its <parameter=…>
|
|
312
|
+
# (or <arg_key>/<arg_value>) text by lowercased key, unbuilt.
|
|
313
|
+
def read(text)
|
|
427
314
|
results = []
|
|
428
315
|
|
|
429
316
|
pos = 0
|
|
@@ -438,7 +325,7 @@ module Samagotchi
|
|
|
438
325
|
body = text[body_start...close_pos]
|
|
439
326
|
if (name = qwen_function_name(body))
|
|
440
327
|
params = qwen_params(body)
|
|
441
|
-
results <<
|
|
328
|
+
results << { name: name, args: params, raw: params.to_s }
|
|
442
329
|
end
|
|
443
330
|
pos = close_pos + tool_close.length
|
|
444
331
|
end
|
|
@@ -509,129 +396,6 @@ module Samagotchi
|
|
|
509
396
|
|
|
510
397
|
nil
|
|
511
398
|
end
|
|
512
|
-
|
|
513
|
-
def qwen_call_to_internal(name, params)
|
|
514
|
-
case name
|
|
515
|
-
when Tools::Execute::NAME
|
|
516
|
-
{ name: name, content: qwen_param_value(params, "command"), path: nil, scope: nil }
|
|
517
|
-
when Tools::Read::NAME
|
|
518
|
-
{
|
|
519
|
-
name: name,
|
|
520
|
-
content: qwen_param_value(params, "path"),
|
|
521
|
-
path: nil,
|
|
522
|
-
scope: nil,
|
|
523
|
-
start_line: qwen_param_value(params, "start_line"),
|
|
524
|
-
end_line: qwen_param_value(params, "end_line")
|
|
525
|
-
}
|
|
526
|
-
when Tools::Write::NAME
|
|
527
|
-
content = qwen_param_value(params, "content", "text", strip: false)
|
|
528
|
-
{ name: name, content: content, path: qwen_param_value(params, "path"), scope: nil }
|
|
529
|
-
when Tools::MemoryRead::NAME
|
|
530
|
-
memory_name = qwen_param_value(params, "name")
|
|
531
|
-
{ name: name, content: memory_name, path: nil, scope: qwen_param_value(params, "scope") }
|
|
532
|
-
when Tools::MemoryWrite::NAME
|
|
533
|
-
entry_name = qwen_param_value(params, "name")
|
|
534
|
-
content = qwen_param_value(params, "content", "text", "body", "value", strip: false)
|
|
535
|
-
{ name: name, content: content, path: entry_name, scope: qwen_param_value(params, "scope"), description: qwen_param_value(params, "description"),
|
|
536
|
-
current_model_only: qwen_param_value(params, "current_model_only") }
|
|
537
|
-
when Tools::Edit::NAME
|
|
538
|
-
old_text = qwen_param_value(params, "old_text", "old", strip: false)
|
|
539
|
-
new_text = qwen_param_value(params, "new_text", "new", strip: false)
|
|
540
|
-
content = "<old>#{old_text}</old><new>#{new_text}</new>"
|
|
541
|
-
{
|
|
542
|
-
name: name,
|
|
543
|
-
content: content,
|
|
544
|
-
path: qwen_param_value(params, "path"),
|
|
545
|
-
scope: nil,
|
|
546
|
-
start_line: qwen_param_value(params, "start_line"),
|
|
547
|
-
end_line: qwen_param_value(params, "end_line")
|
|
548
|
-
}
|
|
549
|
-
when Tools::TaskCreate::NAME
|
|
550
|
-
{
|
|
551
|
-
name: name,
|
|
552
|
-
content: qwen_param_value(params, "command"),
|
|
553
|
-
path: nil,
|
|
554
|
-
scope: nil,
|
|
555
|
-
cwd: qwen_param_value(params, "cwd"),
|
|
556
|
-
env: qwen_param_value(params, "env", strip: false)
|
|
557
|
-
}
|
|
558
|
-
when Tools::TaskGet::NAME, Tools::TaskStop::NAME
|
|
559
|
-
{
|
|
560
|
-
name: name,
|
|
561
|
-
content: qwen_param_value(params, "id", "task_id"),
|
|
562
|
-
path: nil,
|
|
563
|
-
scope: nil
|
|
564
|
-
}
|
|
565
|
-
when Tools::TaskWait::NAME
|
|
566
|
-
{
|
|
567
|
-
name: name,
|
|
568
|
-
content: qwen_param_value(params, "id", "task_id"),
|
|
569
|
-
path: nil,
|
|
570
|
-
scope: nil,
|
|
571
|
-
timeout: qwen_param_value(params, "timeout"),
|
|
572
|
-
tail_lines: qwen_param_value(params, "tail_lines"),
|
|
573
|
-
done_pattern: qwen_param_value(params, "done_pattern")
|
|
574
|
-
}
|
|
575
|
-
when Tools::TaskList::NAME
|
|
576
|
-
{ name: name, content: "", path: nil, scope: nil }
|
|
577
|
-
when Tools::WebFetch::NAME
|
|
578
|
-
{ name: name, content: qwen_param_value(params, "url"), path: nil, scope: nil }
|
|
579
|
-
when Tools::RegisterReminder::NAME
|
|
580
|
-
{
|
|
581
|
-
name: name,
|
|
582
|
-
content: qwen_param_value(params, "name"),
|
|
583
|
-
path: nil,
|
|
584
|
-
scope: nil,
|
|
585
|
-
description: qwen_param_value(params, "description"),
|
|
586
|
-
interval_minutes: qwen_param_value(params, "interval_minutes")
|
|
587
|
-
}
|
|
588
|
-
when Tools::CancelReminder::NAME
|
|
589
|
-
{ name: name, content: qwen_param_value(params, "name"), path: nil, scope: nil }
|
|
590
|
-
when Tools::ListReminders::NAME
|
|
591
|
-
{ name: name, content: "", path: nil, scope: nil }
|
|
592
|
-
when Tools::ListSessions::NAME
|
|
593
|
-
{ name: name, content: "", path: nil, scope: nil, cwd: qwen_param_value(params, "cwd") }
|
|
594
|
-
when Tools::SendNote::NAME
|
|
595
|
-
{ name: name, content: qwen_param_value(params, "text"), path: nil, scope: nil,
|
|
596
|
-
session: qwen_param_value(params, "session") }
|
|
597
|
-
when Tools::Delegate::NAME
|
|
598
|
-
{ name: name, content: qwen_param_value(params, "task"), path: nil, scope: nil,
|
|
599
|
-
model: qwen_param_value(params, "model"), session: qwen_param_value(params, "session"),
|
|
600
|
-
wait: qwen_param_value(params, "wait"), timeout: qwen_param_value(params, "timeout") }
|
|
601
|
-
when Tools::DelegateResult::NAME
|
|
602
|
-
{ name: name, content: "", path: nil, scope: nil,
|
|
603
|
-
session: qwen_param_value(params, "session"), timeout: qwen_param_value(params, "timeout") }
|
|
604
|
-
when Tools::AskUserQuestion::NAME
|
|
605
|
-
opts_raw = qwen_param_value(params, "options", strip: false)
|
|
606
|
-
opts = qwen_ask_options(opts_raw)
|
|
607
|
-
{
|
|
608
|
-
name: name,
|
|
609
|
-
content: qwen_param_value(params, "question"),
|
|
610
|
-
path: nil, scope: nil,
|
|
611
|
-
question: qwen_param_value(params, "question"),
|
|
612
|
-
options: opts,
|
|
613
|
-
header: qwen_param_value(params, "header"),
|
|
614
|
-
multi_select: qwen_param_value(params, "multi_select"),
|
|
615
|
-
allow_freeform: qwen_param_value(params, "allow_freeform")
|
|
616
|
-
}
|
|
617
|
-
else
|
|
618
|
-
# A registry (plugin) tool, or an unknown one: the parameters as
|
|
619
|
-
# given (text; typed by the schema at dispatch).
|
|
620
|
-
{ name: name, content: params.to_s, path: nil, scope: nil, args: params }
|
|
621
|
-
end
|
|
622
|
-
end
|
|
623
|
-
|
|
624
|
-
def qwen_param_value(params, *keys, strip: true)
|
|
625
|
-
value = keys.lazy.map { |key| params[key] }.find { |candidate| !candidate.nil? }
|
|
626
|
-
return "" if value.nil?
|
|
627
|
-
|
|
628
|
-
strip ? value.to_s.strip : value.to_s
|
|
629
|
-
end
|
|
630
|
-
|
|
631
|
-
def qwen_ask_options(raw)
|
|
632
|
-
norm = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw)
|
|
633
|
-
norm.empty? ? nil : norm
|
|
634
|
-
end
|
|
635
399
|
end
|
|
636
400
|
end
|
|
637
401
|
end
|
|
@@ -123,7 +123,7 @@ module Samagotchi
|
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
name: "memory_write",
|
|
126
|
-
description: "Write or update a memory entry in scoped memories. Scope is required: project or system. Use the `name` parameter for the entry name (use `name`, NOT `path` — the file tools use `path`); each scope's index.md is auto-maintained (one managed line per entry); use name \"index\" to write the index file verbatim. If the user asks to save guidance for the current model only, pass current_model_only: true to save a model-specific overlay.",
|
|
126
|
+
description: "Write or update a memory entry in scoped memories. Scope is required: project or system. Use the `name` parameter for the entry name (use `name`, NOT `path` — the file tools use `path`); each scope's index.md is auto-maintained (one managed line per entry); use name \"index\" to write the index file verbatim. For a small change to an existing memory (a step, a line), `edit` its file (<scope dir>/<name>.md) instead of rewriting it all; its index line is refreshed either way. If the user asks to save guidance for the current model only, pass current_model_only: true to save a model-specific overlay.",
|
|
127
127
|
parameters: {
|
|
128
128
|
type: "object",
|
|
129
129
|
properties: {
|
|
@@ -6,6 +6,7 @@ require_relative "vision_context"
|
|
|
6
6
|
require_relative "log"
|
|
7
7
|
require_relative "edit_preview"
|
|
8
8
|
require_relative "tools/tool_path"
|
|
9
|
+
require_relative "memory_bundle/index_sync"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
# The single per-call path both loops use: the tool_call_started and
|
|
@@ -67,6 +68,7 @@ module Samagotchi
|
|
|
67
68
|
before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
|
|
68
69
|
result = verdict.deny? ? denied(call, verdict) : dispatch(call)
|
|
69
70
|
diff = file_change(call, before)
|
|
71
|
+
refresh_memory_index(call) if diff
|
|
70
72
|
result = approved(result, verdict) if verdict.allow? && verdict.decided_by
|
|
71
73
|
result, images = attach_images(call, result) if result[:images]
|
|
72
74
|
|
|
@@ -117,6 +119,12 @@ module Samagotchi
|
|
|
117
119
|
nil
|
|
118
120
|
end
|
|
119
121
|
|
|
122
|
+
# A write/edit that changed a memory's file refreshes its index line,
|
|
123
|
+
# as memory_write does (MemoryBundle::IndexSync; a no-op elsewhere).
|
|
124
|
+
def refresh_memory_index(call)
|
|
125
|
+
MemoryBundle::IndexSync.refresh(Tools::ToolPath.normalize(call[:path]))
|
|
126
|
+
end
|
|
127
|
+
|
|
120
128
|
# A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
|
|
121
129
|
# binary file). They become "?" here, before the output reaches the
|
|
122
130
|
# conversation, the events and the saved session: JSON.generate raises
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../tool_declarations"
|
|
4
|
+
require_relative "ask_user_question"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Tools
|
|
8
|
+
# The one map from a parsed tool call ({name, args}) to the internal call
|
|
9
|
+
# the built-in handlers, hooks, guardrails and bundles read:
|
|
10
|
+
#
|
|
11
|
+
# { name:, content:, path:, scope:, <each other schema field>: }
|
|
12
|
+
#
|
|
13
|
+
# Each parser (Gemma, Qwen, the chat path) only reads its wire format
|
|
14
|
+
# into string-keyed args; this table says where each argument goes. It is
|
|
15
|
+
# derived from ToolDeclarations::TOOL_SCHEMAS: a property goes to the
|
|
16
|
+
# field of its own name, unless OVERRIDES puts it in content: (the
|
|
17
|
+
# tool's main argument) or path:. A new parameter is a schema property
|
|
18
|
+
# and the handler reading it.
|
|
19
|
+
#
|
|
20
|
+
# A tool that is not built in keeps its arguments whole on args: (typed
|
|
21
|
+
# by its schema at dispatch, Tools::Args) with +raw+ as its content.
|
|
22
|
+
module BuiltinCalls
|
|
23
|
+
# Per tool, what the schema alone doesn't say:
|
|
24
|
+
# content: the property (or ordered list of keys to try) that fills
|
|
25
|
+
# content:; absent, content: is "" (the handlers take a
|
|
26
|
+
# String)
|
|
27
|
+
# path: the property that fills path: (otherwise "path" itself)
|
|
28
|
+
# also: a property that fills content: and keeps its own field
|
|
29
|
+
# aliases: other keys a model uses for a property, tried after it
|
|
30
|
+
# verbatim: values passed as given (file text, old/new, env,
|
|
31
|
+
# options); every other String value is stripped
|
|
32
|
+
# fallback: how Gemma fills the main argument from a body it could
|
|
33
|
+
# not read as key:value pairs (see ToolCallParser::Gemma)
|
|
34
|
+
# options: ask_user_question's options, normalized
|
|
35
|
+
OVERRIDES = {
|
|
36
|
+
"execute" => { content: "command", fallback: :prefix_or_raw },
|
|
37
|
+
"read" => { content: "path", fallback: :prefix_or_raw },
|
|
38
|
+
"write" => { verbatim: %w[content], aliases: { "content" => %w[text] } },
|
|
39
|
+
"memory_read" => { content: "name", fallback: :prefix_or_raw },
|
|
40
|
+
"memory_write" => { path: "name", verbatim: %w[content], aliases: { "content" => %w[text body value] } },
|
|
41
|
+
"edit" => { verbatim: %w[old_text new_text], aliases: { "old_text" => %w[old], "new_text" => %w[new] } },
|
|
42
|
+
"task_create" => { content: "command", verbatim: %w[env], fallback: :prefix_or_raw },
|
|
43
|
+
"task_get" => { content: %w[id task_id], fallback: :prefix_or_raw },
|
|
44
|
+
"task_stop" => { content: %w[id task_id], fallback: :prefix_or_raw },
|
|
45
|
+
"task_wait" => { content: %w[id task_id], fallback: :prefix_or_raw },
|
|
46
|
+
"web_fetch" => { content: "url", fallback: :prefix_or_raw },
|
|
47
|
+
"register_reminder" => { content: "name", fallback: :prefix_or_raw },
|
|
48
|
+
"cancel_reminder" => { content: "name", fallback: :prefix_or_raw },
|
|
49
|
+
"list_sessions" => { fallback: :prefix, fallback_key: "cwd" },
|
|
50
|
+
"send_note" => { content: "text" },
|
|
51
|
+
"delegate" => { content: "task", fallback: :prefix_or_raw },
|
|
52
|
+
"ask_user_question" => { content: "question", also: %w[question], verbatim: %w[options], options: true,
|
|
53
|
+
fallback: :raw }
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# One built-in's mapping, resolved from its schema and OVERRIDES.
|
|
57
|
+
Row = Data.define(:name, :content_keys, :path_key, :fields, :aliases, :verbatim, :fallback, :fallback_key,
|
|
58
|
+
:options) do
|
|
59
|
+
# The keys to try for +key+, in order.
|
|
60
|
+
def keys_for(key) = [key, *aliases.fetch(key, [])]
|
|
61
|
+
|
|
62
|
+
# The keys Gemma's fallback may find as a "key:" prefix.
|
|
63
|
+
def fallback_keys = fallback_key ? [fallback_key] : content_keys.flat_map { |key| keys_for(key) }
|
|
64
|
+
|
|
65
|
+
# Whether a given +key+ (a property or one of its aliases) is
|
|
66
|
+
# passed as given.
|
|
67
|
+
def verbatim_key?(key) = verbatim.any? { |property| keys_for(property).include?(key) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
module_function
|
|
71
|
+
|
|
72
|
+
# @return [Hash{String => Row}]
|
|
73
|
+
def rows
|
|
74
|
+
@rows ||= ToolDeclarations::TOOL_SCHEMAS.to_h { |schema| [schema[:name], row_for(schema)] }.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def row_for(schema)
|
|
78
|
+
name = schema[:name]
|
|
79
|
+
o = OVERRIDES.fetch(name, {})
|
|
80
|
+
properties = schema.dig(:parameters, :properties).keys.map(&:to_s)
|
|
81
|
+
content_keys = Array(o[:content])
|
|
82
|
+
path_key = o[:path] || ("path" if properties.include?("path") && !content_keys.include?("path"))
|
|
83
|
+
taken = content_keys + [path_key].compact - Array(o[:also])
|
|
84
|
+
fields = properties - taken
|
|
85
|
+
# content: may list a model's other spelling (task_wait's "id"), so
|
|
86
|
+
# one of its keys must be a property; the rest must all be.
|
|
87
|
+
unknown = ([path_key].compact + Array(o[:verbatim]) + Array(o[:also]) + o.fetch(:aliases, {}).keys) - properties
|
|
88
|
+
unknown << content_keys.join("/") unless content_keys.empty? || content_keys.intersect?(properties)
|
|
89
|
+
raise ArgumentError, "#{name}: overrides name no schema property: #{unknown.join(', ')}" unless unknown.empty?
|
|
90
|
+
raise ArgumentError, "#{name}: a property would replace the call's name" if fields.include?("name")
|
|
91
|
+
|
|
92
|
+
Row.new(name: name, content_keys: content_keys, path_key: path_key, fields: fields,
|
|
93
|
+
aliases: o.fetch(:aliases, {}), verbatim: Array(o[:verbatim]), fallback: o[:fallback],
|
|
94
|
+
fallback_key: o[:fallback_key], options: o.fetch(:options, false))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Row, nil]
|
|
98
|
+
def row(name) = rows[name.to_s]
|
|
99
|
+
|
|
100
|
+
def builtin?(name) = rows.key?(name.to_s)
|
|
101
|
+
|
|
102
|
+
# The internal call for a parsed one.
|
|
103
|
+
# @param name [String]
|
|
104
|
+
# @param args [Hash] string keys, as the model gave them
|
|
105
|
+
# @param raw [String, nil] the call's text, the content of a tool
|
|
106
|
+
# that isn't built in
|
|
107
|
+
# @return [Hash]
|
|
108
|
+
def build(name, args, raw: nil)
|
|
109
|
+
name = name.to_s
|
|
110
|
+
args = args.is_a?(Hash) ? args.transform_keys(&:to_s) : {}
|
|
111
|
+
row = row(name)
|
|
112
|
+
return passthrough(name, args, raw) unless row
|
|
113
|
+
|
|
114
|
+
call = { name: name, content: "", path: nil, scope: nil }
|
|
115
|
+
call[:content] = content_value(row, args)
|
|
116
|
+
call[:path] = value(row, args, row.path_key) if row.path_key
|
|
117
|
+
row.fields.each { |key| call[key.to_sym] = value(row, args, key) }
|
|
118
|
+
call[:options] = options(call[:options]) if row.options
|
|
119
|
+
call
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def content_value(row, args)
|
|
123
|
+
found = row.content_keys.lazy.map { |key| value(row, args, key) }.find { |v| !v.nil? }
|
|
124
|
+
found.nil? ? "" : found.to_s
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The first given value among +key+ and its aliases, stripped unless
|
|
128
|
+
# verbatim; nil when none is given.
|
|
129
|
+
def value(row, args, key)
|
|
130
|
+
found = row.keys_for(key).lazy.map { |k| args[k] }.find { |v| !v.nil? }
|
|
131
|
+
found.is_a?(String) && !row.verbatim.include?(key) ? found.strip : found
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Normalized when they read as options, else as given (the question
|
|
135
|
+
# card's validate reads them again).
|
|
136
|
+
def options(raw)
|
|
137
|
+
normalized = AskUserQuestion.normalize_options_lenient(raw)
|
|
138
|
+
normalized.empty? ? raw : normalized
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def passthrough(name, args, raw)
|
|
142
|
+
{ name: name, content: raw.nil? ? args.values.join(" ") : raw.to_s, path: nil, scope: nil, args: args }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -66,7 +66,8 @@ module Samagotchi
|
|
|
66
66
|
Read.call(call[:content], start_line: call[:start_line], end_line: call[:end_line])
|
|
67
67
|
end,
|
|
68
68
|
Edit::NAME => lambda do |call, _kctx|
|
|
69
|
-
Edit.call(
|
|
69
|
+
Edit.call(path: call[:path], old_text: call[:old_text], new_text: call[:new_text],
|
|
70
|
+
start_line: call[:start_line], end_line: call[:end_line])
|
|
70
71
|
end,
|
|
71
72
|
TaskCreate::NAME => lambda do |call, kctx|
|
|
72
73
|
next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
|