samagotchi 0.9.0 → 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 +56 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +3 -2
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +34 -12
- 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 +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
- 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/manifest.yml +2 -2
- 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/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/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +1 -27
- 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 +27 -13
- 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/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 +1 -11
- 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 +12 -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
|
|
@@ -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
|
|
@@ -5,24 +5,23 @@ require_relative "tool_path"
|
|
|
5
5
|
|
|
6
6
|
module Samagotchi
|
|
7
7
|
module Tools
|
|
8
|
-
# Replaces an exact block of text in an existing file
|
|
8
|
+
# Replaces an exact block of text in an existing file (old_text →
|
|
9
|
+
# new_text), or a range of lines (start_line..end_line → new_text).
|
|
9
10
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# <new>replacement text</new>
|
|
14
|
-
# </tool>
|
|
15
|
-
#
|
|
16
|
-
# The <old> block must match exactly once in the file. The tool returns an
|
|
17
|
-
# error if the text is not found or appears more than once.
|
|
11
|
+
# old_text must match exactly once in the file. The tool returns an error
|
|
12
|
+
# if the text is not found or appears more than once. new_text is
|
|
13
|
+
# required in both modes; "" deletes.
|
|
18
14
|
class Edit
|
|
19
15
|
NAME = "edit"
|
|
20
16
|
|
|
21
17
|
def self.name = NAME
|
|
22
18
|
|
|
23
|
-
|
|
19
|
+
OLD_TEXT_REQUIRED = "Error: old_text is required (the exact text to replace), or give start_line for a range edit"
|
|
20
|
+
NEW_TEXT_REQUIRED = 'Error: new_text is required (use "" to delete)'
|
|
21
|
+
|
|
22
|
+
def self.call(path:, old_text: nil, new_text: nil, start_line: nil, end_line: nil)
|
|
24
23
|
path = ToolPath.normalize(path)
|
|
25
|
-
result = apply(
|
|
24
|
+
result = apply(path: path, old_text: old_text, new_text: new_text, start_line: start_line, end_line: end_line)
|
|
26
25
|
return result if result.is_a?(String)
|
|
27
26
|
|
|
28
27
|
updated, message = result
|
|
@@ -35,22 +34,21 @@ module Samagotchi
|
|
|
35
34
|
# The edit without the write: [updated, message] or "Error: …", with
|
|
36
35
|
# exactly the strings call returns. +read+ reads the file, so a dry run
|
|
37
36
|
# (EditPreview) can reuse a copy it already has; each mode keeps its own
|
|
38
|
-
# check order (exact:
|
|
39
|
-
def self.apply(
|
|
37
|
+
# check order (exact: the texts first; range: file not found first).
|
|
38
|
+
def self.apply(path:, old_text: nil, new_text: nil, start_line: nil, end_line: nil, read: ->(p) { File.read(p) })
|
|
40
39
|
path = ToolPath.normalize(path)
|
|
41
40
|
|
|
42
41
|
if range_requested?(start_line, end_line)
|
|
43
|
-
return apply_range_mode(
|
|
42
|
+
return apply_range_mode(new_text, path: path, start_line: start_line, end_line: end_line, read: read)
|
|
44
43
|
end
|
|
45
44
|
|
|
46
|
-
old_text
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return "Error: missing <old>...</old> block" if old_text.nil?
|
|
50
|
-
return "Error: missing <new>...</new> block" if new_text.nil?
|
|
51
|
-
return "Error: <old> block is empty" if old_text.empty?
|
|
45
|
+
return OLD_TEXT_REQUIRED if old_text.nil? || old_text.to_s.empty?
|
|
46
|
+
return NEW_TEXT_REQUIRED if new_text.nil?
|
|
52
47
|
return "Error: file not found: #{path}" unless File.exist?(path)
|
|
53
48
|
|
|
49
|
+
old_text = old_text.to_s
|
|
50
|
+
new_text = new_text.to_s
|
|
51
|
+
|
|
54
52
|
source = read.(path)
|
|
55
53
|
count = count_occurrences(source, old_text)
|
|
56
54
|
|
|
@@ -64,11 +62,11 @@ module Samagotchi
|
|
|
64
62
|
"Error: #{e.message}"
|
|
65
63
|
end
|
|
66
64
|
|
|
67
|
-
def self.apply_range_mode(
|
|
65
|
+
def self.apply_range_mode(new_text, path:, start_line:, end_line:, read:)
|
|
68
66
|
return "Error: file not found: #{path}" unless File.exist?(path)
|
|
67
|
+
return NEW_TEXT_REQUIRED if new_text.nil?
|
|
69
68
|
|
|
70
|
-
new_text =
|
|
71
|
-
return "Error: missing <new>...</new> block" if new_text.nil?
|
|
69
|
+
new_text = new_text.to_s
|
|
72
70
|
|
|
73
71
|
start_num = parse_positive_line_number(start_line, "start_line")
|
|
74
72
|
return start_num if start_num.is_a?(String)
|
|
@@ -139,12 +137,6 @@ module Samagotchi
|
|
|
139
137
|
end
|
|
140
138
|
private_class_method :count_occurrences
|
|
141
139
|
|
|
142
|
-
def self.extract_tag(content, tag)
|
|
143
|
-
m = content.match(/<#{tag}>(.*?)<\/#{tag}>/m)
|
|
144
|
-
m ? m[1] : nil
|
|
145
|
-
end
|
|
146
|
-
private_class_method :extract_tag
|
|
147
|
-
|
|
148
140
|
def self.range_requested?(start_line, end_line)
|
|
149
141
|
!blank?(start_line) || !blank?(end_line)
|
|
150
142
|
end
|