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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +9 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +47 -17
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bootstrap_command.rb +29 -29
  12. data/lib/samagotchi/bridge/card_store.rb +4 -4
  13. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  14. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  16. data/lib/samagotchi/bridge.rb +59 -112
  17. data/lib/samagotchi/bridge_client.rb +11 -28
  18. data/lib/samagotchi/bundle_command.rb +158 -212
  19. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  21. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  22. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  25. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  27. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  32. data/lib/samagotchi/bundles/system/identity.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
  35. data/lib/samagotchi/cli/command.rb +51 -0
  36. data/lib/samagotchi/cli/flags.rb +116 -0
  37. data/lib/samagotchi/config.rb +70 -55
  38. data/lib/samagotchi/config_text_edit.rb +107 -0
  39. data/lib/samagotchi/desktop_command.rb +24 -18
  40. data/lib/samagotchi/edit_preview.rb +2 -2
  41. data/lib/samagotchi/engine.rb +20 -75
  42. data/lib/samagotchi/events.rb +23 -0
  43. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  44. data/lib/samagotchi/guardrails/targets.rb +7 -1
  45. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  46. data/lib/samagotchi/hooks/loader.rb +23 -33
  47. data/lib/samagotchi/hooks/registry.rb +41 -0
  48. data/lib/samagotchi/hooks.rb +0 -3
  49. data/lib/samagotchi/host_registry.rb +15 -0
  50. data/lib/samagotchi/idle_reminders.rb +0 -7
  51. data/lib/samagotchi/image_store.rb +22 -0
  52. data/lib/samagotchi/kernel_loop.rb +12 -16
  53. data/lib/samagotchi/live_versions.rb +2 -13
  54. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  55. data/lib/samagotchi/log_subscriber.rb +5 -1
  56. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  57. data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  59. data/lib/samagotchi/memory_bundle/installer.rb +25 -30
  60. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  61. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  63. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  64. data/lib/samagotchi/memory_paths.rb +10 -0
  65. data/lib/samagotchi/note_command.rb +23 -37
  66. data/lib/samagotchi/owner_lock.rb +14 -3
  67. data/lib/samagotchi/plugin/api.rb +11 -17
  68. data/lib/samagotchi/plugin/context.rb +70 -15
  69. data/lib/samagotchi/plugin/loader.rb +2 -3
  70. data/lib/samagotchi/sampling_settings.rb +3 -5
  71. data/lib/samagotchi/self_report.rb +6 -9
  72. data/lib/samagotchi/send_command.rb +33 -52
  73. data/lib/samagotchi/session.rb +88 -92
  74. data/lib/samagotchi/session_archive_command.rb +8 -14
  75. data/lib/samagotchi/session_commands.rb +3 -21
  76. data/lib/samagotchi/session_delete_command.rb +15 -29
  77. data/lib/samagotchi/session_manager.rb +29 -15
  78. data/lib/samagotchi/session_metrics.rb +2 -1
  79. data/lib/samagotchi/sessions_command.rb +3 -2
  80. data/lib/samagotchi/system_prompt.rb +2 -1
  81. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  83. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  85. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  86. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  87. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  88. data/lib/samagotchi/terminal_ui.rb +156 -234
  89. data/lib/samagotchi/thinking.rb +2 -3
  90. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  91. data/lib/samagotchi/tool_call_parser.rb +56 -292
  92. data/lib/samagotchi/tool_declarations.rb +1 -1
  93. data/lib/samagotchi/tool_runner.rb +8 -0
  94. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  95. data/lib/samagotchi/tools/builtins.rb +2 -1
  96. data/lib/samagotchi/tools/edit.rb +21 -29
  97. data/lib/samagotchi/tools/memory.rb +2 -13
  98. data/lib/samagotchi/tools/send_note.rb +1 -1
  99. data/lib/samagotchi/update_command.rb +32 -22
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/vision_support.rb +4 -5
  102. data/lib/samagotchi/web/app.rb +89 -151
  103. data/lib/samagotchi/web/public/activity.js +1 -11
  104. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  105. data/lib/samagotchi/web/public/app.js +158 -785
  106. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  107. data/lib/samagotchi/web/public/command_list.js +99 -0
  108. data/lib/samagotchi/web/public/data.js +22 -66
  109. data/lib/samagotchi/web/public/format.js +0 -23
  110. data/lib/samagotchi/web/public/index.html +0 -2
  111. data/lib/samagotchi/web/public/model_pick.js +0 -7
  112. data/lib/samagotchi/web/public/notifications.js +169 -0
  113. data/lib/samagotchi/web/public/question_cards.js +352 -0
  114. data/lib/samagotchi/web/public/session_state.js +34 -0
  115. data/lib/samagotchi/web/public/timing.js +1 -1
  116. data/lib/samagotchi/web/public/turn_events.js +11 -2
  117. data/lib/samagotchi/web/public/turn_model.js +0 -14
  118. data/lib/samagotchi/web/session_hub.rb +6 -4
  119. data/lib/samagotchi/web/session_summary.rb +5 -3
  120. data/lib/samagotchi/worker.rb +59 -53
  121. data/lib/samagotchi/worker_sidecar.rb +27 -1
  122. 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 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
@@ -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(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