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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +3 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +34 -12
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. 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 parsing raw model output into internal tool
27
- # calls ({name:, content:, path:, scope:, ...}) and stripping per-profile
28
- # thought blocks. KernelLoop delegates here so it no longer carries dual
29
- # Gemma/Qwen parse code paths; the ModelProfile remains the source of truth
30
- # for call/thought delimiter tokens.
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 << native_call(name, params_raw.strip)
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
- # Map native {key: "value"} params to the internal call hash.
158
- # Uses well-known named params for each tool; falls back to params_raw if
159
- # no recognised param is present.
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 and params_raw still
164
- # contains the "key:" prefix. strip_param_prefix removes it so the actual
165
- # command/path value is passed to the tool rather than the raw fragment.
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. strip_gemma_delimiters is applied to all fallback values so that
169
- # <|"|>value<|"|> is cleaned to just "value" before being dispatched.
170
- def native_call(name, params_raw)
171
- params = extract_native_params(params_raw)
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
- case name
174
- when Tools::Execute::NAME
175
- content = params["command"] ||
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 << qwen_call_to_internal(name, params)
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(call[:content], path: call[:path], start_line: call[:start_line], end_line: call[:end_line])
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
- # XML syntax (used by the model):
11
- # <tool name="edit" path="path/to/file">
12
- # <old>exact text to replace</old>
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
- def self.call(content, path:, start_line: nil, end_line: nil)
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(content, path: path, start_line: start_line, end_line: end_line)
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: tags first; range: file not found first).
39
- def self.apply(content, path:, start_line: nil, end_line: nil, read: ->(p) { File.read(p) })
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(content, path: path, start_line: start_line, end_line: end_line, read: read)
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 = extract_tag(content, "old")
47
- new_text = extract_tag(content, "new")
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(content, path:, start_line:, end_line:, read:)
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 = extract_tag(content, "new")
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