samagotchi 0.22.0 → 0.24.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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/bin/chi +9 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +13 -4
  6. data/docs/hooks.md +20 -6
  7. data/docs/plugins.md +1 -1
  8. data/docs/releasing.md +1 -1
  9. data/docs/sub-agent.md +4 -3
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +4 -2
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  13. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  14. data/lib/samagotchi/bootstrap_command.rb +2 -2
  15. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  16. data/lib/samagotchi/bridge/card_store.rb +4 -3
  17. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -3
  19. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +19 -9
  21. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  22. data/lib/samagotchi/bridge_client.rb +1 -1
  23. data/lib/samagotchi/bundle_command.rb +39 -29
  24. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  26. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/check-in/plugin.rb +4 -1
  28. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  29. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  32. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  34. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  36. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  37. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  38. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  39. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  40. data/lib/samagotchi/cancellation_controller.rb +1 -1
  41. data/lib/samagotchi/cli/command.rb +1 -1
  42. data/lib/samagotchi/client.rb +3 -5
  43. data/lib/samagotchi/commands/registry.rb +2 -2
  44. data/lib/samagotchi/config.rb +42 -14
  45. data/lib/samagotchi/context_note.rb +1 -1
  46. data/lib/samagotchi/context_status.rb +6 -6
  47. data/lib/samagotchi/continue_offer.rb +15 -8
  48. data/lib/samagotchi/debug_log.rb +1 -1
  49. data/lib/samagotchi/desktop/macos.rb +5 -4
  50. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  51. data/lib/samagotchi/engine.rb +97 -18
  52. data/lib/samagotchi/gem_update.rb +2 -1
  53. data/lib/samagotchi/generation_phase.rb +83 -0
  54. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  55. data/lib/samagotchi/guardrails/approval.rb +5 -5
  56. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  57. data/lib/samagotchi/guardrails/gate.rb +2 -2
  58. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  59. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  60. data/lib/samagotchi/guardrails/shell_lex.rb +113 -5
  61. data/lib/samagotchi/guardrails/shell_paths.rb +3 -3
  62. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  63. data/lib/samagotchi/hooks/loader.rb +18 -16
  64. data/lib/samagotchi/hooks/registry.rb +10 -2
  65. data/lib/samagotchi/host_registry.rb +1 -3
  66. data/lib/samagotchi/idle_recap.rb +22 -17
  67. data/lib/samagotchi/idle_reminders.rb +6 -3
  68. data/lib/samagotchi/image_store.rb +5 -2
  69. data/lib/samagotchi/kernel_loop.rb +50 -40
  70. data/lib/samagotchi/llm/chat_loop.rb +29 -16
  71. data/lib/samagotchi/llm/errors.rb +2 -2
  72. data/lib/samagotchi/llm/http.rb +12 -12
  73. data/lib/samagotchi/llm/openai_chat.rb +42 -28
  74. data/lib/samagotchi/llm/prompt_cache.rb +1 -1
  75. data/lib/samagotchi/log.rb +2 -2
  76. data/lib/samagotchi/log_subscriber.rb +5 -3
  77. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  78. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  79. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  80. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  81. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  82. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  83. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  84. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  85. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  86. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  87. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  88. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  89. data/lib/samagotchi/memory_bundle.rb +1 -0
  90. data/lib/samagotchi/model_overlay.rb +3 -5
  91. data/lib/samagotchi/model_profile.rb +12 -8
  92. data/lib/samagotchi/note_command.rb +2 -1
  93. data/lib/samagotchi/output_formatter.rb +18 -17
  94. data/lib/samagotchi/owner_lock.rb +2 -1
  95. data/lib/samagotchi/plugin/api.rb +8 -5
  96. data/lib/samagotchi/plugin/context.rb +1 -3
  97. data/lib/samagotchi/plugin/loader.rb +1 -1
  98. data/lib/samagotchi/plugin/service.rb +1 -1
  99. data/lib/samagotchi/plugin/sessions.rb +1 -0
  100. data/lib/samagotchi/plugin_tasks.rb +1 -1
  101. data/lib/samagotchi/prompt.rb +72 -13
  102. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  103. data/lib/samagotchi/question_desk.rb +7 -7
  104. data/lib/samagotchi/recap_setup.rb +1 -1
  105. data/lib/samagotchi/relay_desk.rb +1 -1
  106. data/lib/samagotchi/reminder_store.rb +2 -1
  107. data/lib/samagotchi/send_command.rb +4 -2
  108. data/lib/samagotchi/session.rb +2 -2
  109. data/lib/samagotchi/session_archive_command.rb +5 -1
  110. data/lib/samagotchi/session_commands.rb +1 -3
  111. data/lib/samagotchi/session_manager.rb +2 -4
  112. data/lib/samagotchi/session_metrics.rb +10 -10
  113. data/lib/samagotchi/session_observer.rb +3 -2
  114. data/lib/samagotchi/session_retention.rb +2 -2
  115. data/lib/samagotchi/sessions_command.rb +14 -5
  116. data/lib/samagotchi/steer.rb +103 -8
  117. data/lib/samagotchi/system_prompt.rb +19 -8
  118. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  119. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  120. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  121. data/lib/samagotchi/terminal_ui/formatting.rb +19 -10
  122. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  123. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  124. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  125. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  126. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  127. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  128. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  129. data/lib/samagotchi/terminal_ui.rb +14 -5
  130. data/lib/samagotchi/text_diff.rb +12 -12
  131. data/lib/samagotchi/thinking.rb +6 -2
  132. data/lib/samagotchi/thought_stream_splitter.rb +15 -6
  133. data/lib/samagotchi/tool_activity.rb +1 -0
  134. data/lib/samagotchi/tool_call_parser.rb +13 -14
  135. data/lib/samagotchi/tool_declarations.rb +36 -35
  136. data/lib/samagotchi/tool_runner.rb +7 -0
  137. data/lib/samagotchi/tool_view.rb +40 -0
  138. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  139. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  140. data/lib/samagotchi/tools/delegate.rb +1 -1
  141. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  142. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  143. data/lib/samagotchi/tools/edit.rb +14 -13
  144. data/lib/samagotchi/tools/execute.rb +3 -5
  145. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  146. data/lib/samagotchi/tools/memory.rb +21 -22
  147. data/lib/samagotchi/tools/read.rb +7 -7
  148. data/lib/samagotchi/tools/task_create.rb +1 -1
  149. data/lib/samagotchi/tools/task_get.rb +1 -1
  150. data/lib/samagotchi/tools/task_list.rb +1 -1
  151. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  152. data/lib/samagotchi/tools/task_stop.rb +1 -1
  153. data/lib/samagotchi/tools/task_wait.rb +3 -4
  154. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  155. data/lib/samagotchi/tools/write.rb +1 -1
  156. data/lib/samagotchi/turn_flow.rb +8 -8
  157. data/lib/samagotchi/turn_note.rb +5 -1
  158. data/lib/samagotchi/version.rb +1 -1
  159. data/lib/samagotchi/vision_support.rb +2 -2
  160. data/lib/samagotchi/waiting_steer.rb +64 -0
  161. data/lib/samagotchi/web/app.rb +13 -6
  162. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  163. data/lib/samagotchi/web/message_parts.rb +5 -0
  164. data/lib/samagotchi/web/public/activity.js +5 -0
  165. data/lib/samagotchi/web/public/app.js +2 -1
  166. data/lib/samagotchi/web/public/command_view.js +28 -0
  167. data/lib/samagotchi/web/public/copy.js +7 -0
  168. data/lib/samagotchi/web/public/index.html +8 -0
  169. data/lib/samagotchi/web/public/stage_model.js +11 -4
  170. data/lib/samagotchi/web/public/stage_view.js +5 -2
  171. data/lib/samagotchi/web/public/turn_events.js +15 -1
  172. data/lib/samagotchi/web/public/turn_view.js +15 -6
  173. data/lib/samagotchi/web/qr.rb +7 -1
  174. data/lib/samagotchi/web/server.rb +6 -2
  175. data/lib/samagotchi/worker.rb +14 -3
  176. metadata +24 -20
@@ -25,7 +25,7 @@ module Samagotchi
25
25
  result = text.to_s
26
26
  while (open_pos = result.index(open))
27
27
  close_pos = result.index(close, open_pos + open.length)
28
- result = result[0...open_pos] + (close_pos ? result[close_pos + close.length..] : "")
28
+ result = result[0...open_pos] + (close_pos ? result[(close_pos + close.length)..] : "")
29
29
  end
30
30
  result.strip
31
31
  end
@@ -37,7 +37,7 @@ module Samagotchi
37
37
  class Gemma
38
38
  # Digits after the first character: plugin tools may have them
39
39
  # (Plugin::Api::TOOL_NAME).
40
- TOOL_CALL_BODY_RE = /\Acall:([a-z_][a-z0-9_]{0,49})\{/
40
+ TOOL_CALL_BODY_RE = /\Acall:([a-z_][a-z0-9_]{0,49})\{/
41
41
  THOUGHT_CHANNEL_OPEN = ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN
42
42
  THOUGHT_CHANNEL_CLOSE = ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE
43
43
  CONTROL_TOKEN_START = "<|"
@@ -102,7 +102,7 @@ module Samagotchi
102
102
  while (open_pos = result.index(THOUGHT_CHANNEL_OPEN))
103
103
  close_pos = result.index(THOUGHT_CHANNEL_CLOSE, open_pos)
104
104
  result = if close_pos
105
- result[0...open_pos] + result[close_pos + THOUGHT_CHANNEL_CLOSE.length..]
105
+ result[0...open_pos] + result[(close_pos + THOUGHT_CHANNEL_CLOSE.length)..]
106
106
  else
107
107
  result[0...open_pos]
108
108
  end
@@ -128,11 +128,10 @@ module Samagotchi
128
128
  pos = start
129
129
  while (p = text.index(CONTROL_TOKEN_START, pos))
130
130
  # Skip over a <|"|> token entirely
131
- if text[p, @string_delim.length] == @string_delim
132
- pos = p + @string_delim.length
133
- else
134
- return p
135
- end
131
+ return p unless text[p, @string_delim.length] == @string_delim
132
+
133
+ pos = p + @string_delim.length
134
+
136
135
  end
137
136
  nil
138
137
  end
@@ -213,7 +212,7 @@ module Samagotchi
213
212
  e = params_raw.index(@string_delim, s + @string_delim.length)
214
213
  break unless e
215
214
 
216
- arr << params_raw[s + @string_delim.length...e]
215
+ arr << params_raw[(s + @string_delim.length)...e]
217
216
  pos = e + @string_delim.length
218
217
  end
219
218
  unless arr.empty?
@@ -334,13 +333,13 @@ module Samagotchi
334
333
 
335
334
  def strip_thought(text)
336
335
  # Remove all complete <think>...</think> blocks with the newlines after them
337
- result = text.gsub(/<think>.*?<\/think>\n*/m, '')
336
+ result = text.gsub(%r{<think>.*?</think>\n*}m, "")
338
337
  # Remove any stray opening tags
339
- result = result.gsub(/<think>.*?(?=\n|$)/m, '')
338
+ result = result.gsub(/<think>.*?(?=\n|$)/m, "")
340
339
  # Remove any orphaned closing tags (and the whitespace around them)
341
340
  # The answer's own blank lines stay: they are its markdown paragraphs
342
341
  # and lists (a chat host's answer is saved as stripped here).
343
- result.gsub(/^\s*<\/think>\s*/m, '')
342
+ result.gsub(%r{^\s*</think>\s*}m, "")
344
343
  end
345
344
 
346
345
  def strip_tool_calls(text)
@@ -365,11 +364,11 @@ module Samagotchi
365
364
  def qwen_params(body)
366
365
  params = {}
367
366
 
368
- body.scan(/<parameter=(\w+)>(.*?)<\/parameter>/m) do |key, value|
367
+ body.scan(%r{<parameter=(\w+)>(.*?)</parameter>}m) do |key, value|
369
368
  params[key.to_s.downcase] = trim_tag_newline(value)
370
369
  end
371
370
 
372
- body.scan(/<arg_key>(.*?)<\/arg_key>\s*<arg_value>(.*?)<\/arg_value>/m) do |key, value|
371
+ body.scan(%r{<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>}m) do |key, value|
373
372
  params[key.to_s.downcase.strip] = trim_tag_newline(value)
374
373
  end
375
374
 
@@ -71,7 +71,7 @@ module Samagotchi
71
71
  description: "Content to write to the file"
72
72
  }
73
73
  },
74
- required: ["path", "content"]
74
+ required: %w[path content]
75
75
  }
76
76
  },
77
77
  {
@@ -101,7 +101,7 @@ module Samagotchi
101
101
  description: "Optional end line (1-based, inclusive) for range mode. Must be provided with start_line."
102
102
  }
103
103
  },
104
- required: ["path", "new_text"]
104
+ required: %w[path new_text]
105
105
  }
106
106
  },
107
107
  {
@@ -148,7 +148,7 @@ module Samagotchi
148
148
  description: "Set true to save this entry as a model-specific overlay for the current model only (<name>.<model>.md); it is auto-appended when the entry is read under that model and never listed in the index."
149
149
  }
150
150
  },
151
- required: ["name", "content", "scope"]
151
+ required: %w[name content scope]
152
152
  }
153
153
  },
154
154
  {
@@ -268,7 +268,7 @@ module Samagotchi
268
268
  description: "How often to remind (1-1440 minutes, i.e. up to 1 day)"
269
269
  }
270
270
  },
271
- required: ["name", "description", "interval_minutes"]
271
+ required: %w[name description interval_minutes]
272
272
  }
273
273
  },
274
274
  {
@@ -321,7 +321,7 @@ module Samagotchi
321
321
  description: "The note: what the other session should know (up to 16 KiB)"
322
322
  }
323
323
  },
324
- required: ["session", "text"]
324
+ required: %w[session text]
325
325
  }
326
326
  },
327
327
  {
@@ -399,30 +399,15 @@ module Samagotchi
399
399
  description: "Allow freeform/Other text alongside selection. Default false."
400
400
  }
401
401
  },
402
- required: ["question", "options"]
402
+ required: %w[question options]
403
403
  }
404
404
  }
405
405
  ].freeze
406
406
 
407
- # Where Gemma's declaration text says something the schema doesn't: extra
408
- # description text, and explicit required:false on optional parameters.
407
+ # Where Gemma's declaration text says something the schema doesn't:
408
+ # extra description text.
409
409
  GEMMA_PARAM_OVERRIDES = {
410
410
  "edit" => { new_text: { description: "Replacement text (required)" } },
411
- "task_wait" => {
412
- timeout: { required: false },
413
- tail_lines: { required: false },
414
- done_pattern: { required: false }
415
- },
416
- "delegate" => {
417
- model: { required: false },
418
- session: { required: false },
419
- wait: { required: false },
420
- timeout: { required: false }
421
- },
422
- "delegate_result" => {
423
- session: { required: false },
424
- timeout: { required: false }
425
- },
426
411
  "ask_user_question" => {
427
412
  options: { description: "2-8 answer options as strings (labels). Single/multi selection via multi_select flag." }
428
413
  }
@@ -440,26 +425,42 @@ module Samagotchi
440
425
 
441
426
  module_function
442
427
 
443
- # Gemma 4 <|tool>declaration:NAME{…}<tool|> blocks for every tool, one per line group.
428
+ # Gemma 4 <|tool>declaration:NAME{…}<tool|> blocks for every tool, back
429
+ # to back, as Gemma 4's chat template declares tools.
444
430
  # @param schemas [Array<Hash>] a Tools::Registry's schemas
445
431
  def gemma_declarations(schemas = TOOL_SCHEMAS)
446
- schemas.map { |schema| gemma_declaration(schema) }.join("\n")
432
+ schemas.map { |schema| gemma_declaration(schema) }.join
447
433
  end
448
434
 
435
+ # One declaration in the template's shape: properties sorted by name,
436
+ # types upper-cased, the required names as a list.
449
437
  def gemma_declaration(schema)
450
438
  q = GEMMA_QUOTE
451
- required = Array(schema[:parameters][:required])
439
+ parameters = schema[:parameters] || {}
452
440
  overrides = GEMMA_PARAM_OVERRIDES.fetch(schema[:name], {})
453
- lines = schema[:parameters][:properties].map do |name, param|
454
- override = overrides.fetch(name, {})
455
- description = override.fetch(:description, param[:description])
456
- line = " #{name}:{type:#{q}#{param[:type]}#{q}, description:#{q}#{description}#{q}"
457
- req = override.fetch(:required, required.include?(name.to_s) ? true : nil)
458
- line += ", required:#{req}" unless req.nil?
459
- "#{line}}"
441
+ properties = (parameters[:properties] || {}).to_h do |name, param|
442
+ [name.to_s, param.merge(overrides.fetch(name.to_sym, {}))]
460
443
  end
461
- params = lines.empty? ? " parameters:{}" : " parameters:{\n#{lines.join(",\n")}\n }"
462
- "<|tool>declaration:#{schema[:name]}{\n description:#{q}#{schema[:description]}#{q},\n#{params}\n}<tool|>"
444
+ required = Array(parameters[:required]).map(&:to_s)
445
+ fields = []
446
+ fields << "properties:{#{gemma_properties(properties)}}" unless properties.empty?
447
+ fields << "required:[#{required.map { |name| "#{q}#{name}#{q}" }.join(",")}]" unless required.empty?
448
+ fields << "type:#{q}#{(parameters[:type] || "object").to_s.upcase}#{q}"
449
+ "<|tool>declaration:#{schema[:name]}{description:#{q}#{schema[:description]}#{q},parameters:{#{fields.join(",")}}}<tool|>"
450
+ end
451
+
452
+ def gemma_properties(properties)
453
+ q = GEMMA_QUOTE
454
+ properties.sort.map do |name, param|
455
+ fields = []
456
+ fields << "description:#{q}#{param[:description]}#{q}" unless param[:description].to_s.empty?
457
+ items = param[:items]
458
+ if param[:type].to_s.casecmp?("array") && items.is_a?(Hash) && !items.empty?
459
+ fields << "items:{#{items.sort_by { |key, _| key.to_s }.map { |key, value| "#{key}:#{q}#{value.to_s.upcase}#{q}" if key.to_s == "type" }.compact.join(",")}}"
460
+ end
461
+ fields << "type:#{q}#{param[:type].to_s.upcase}#{q}"
462
+ "#{name}:{#{fields.join(",")}}"
463
+ end.join(",")
463
464
  end
464
465
 
465
466
  # The schemas for the chat path's tools: array: +schemas+ with
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tool_activity"
4
+ require_relative "tool_view"
4
5
  require_relative "guardrails"
5
6
  require_relative "vision_context"
6
7
  require_relative "log"
@@ -55,6 +56,9 @@ module Samagotchi
55
56
  # The worker runs in its session's working directory.
56
57
  title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
57
58
  started[:title] = title if title
59
+ # The full command for a richer UI (params stays cut at 80).
60
+ view = ToolView.for(call[:name], call)&.to_h
61
+ started[:view] = view if view
58
62
  emit(on_stream_event, started)
59
63
 
60
64
  # The ask comes after tool_call_started: the UI shows the tool line,
@@ -95,6 +99,9 @@ module Samagotchi
95
99
  completed[:images] = images if images&.any?
96
100
  completed[:diff] = diff if diff
97
101
  completed[:waited_ms] = waited_ms if waited_ms
102
+ # Also here: a UI that missed the start (a replay gap) builds its row
103
+ # from this event.
104
+ completed[:view] = view if view
98
105
  emit(on_stream_event, completed)
99
106
  # After the completed event: a hook's card (check-in, skills) prints
100
107
  # under the call's tool row, not before it.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tools/execute"
4
+ require_relative "tools/task_create"
5
+
6
+ module Samagotchi
7
+ # What a richer UI shows of a tool call beyond its one-line params: the
8
+ # full command of an execute / task_create (whitespace kept), and the
9
+ # call's own cwd: argument. Built by the server on every path (the live
10
+ # tool_call_started, the bridge's snapshot and replay, a reload from the
11
+ # saved call), so the UIs never parse the call themselves. params and its
12
+ # 80-char cut stay as they are (the guardrails, "ran as:", the TUI).
13
+ #
14
+ # +truncated+: the command was longer than COMMAND_LIMIT and is cut to it;
15
+ # +chars+ is then its full length.
16
+ ToolView = Data.define(:command, :cwd, :truncated, :chars) do
17
+ def to_h = super.compact.reject { |_key, value| value == false }
18
+ end
19
+
20
+ class ToolView
21
+ # The longest command a view carries (the spike's longest was 3,799).
22
+ COMMAND_LIMIT = 8_000
23
+
24
+ TOOLS = [Tools::Execute::NAME, Tools::TaskCreate::NAME].freeze
25
+
26
+ # @return [ToolView, nil] nil for a tool without a view and for an
27
+ # empty command
28
+ def self.for(tool_name, call)
29
+ return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
30
+
31
+ command = call[:content].to_s
32
+ return nil if command.strip.empty?
33
+
34
+ cwd = call[:cwd].to_s.strip
35
+ truncated = command.length > COMMAND_LIMIT
36
+ new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
37
+ truncated: truncated, chars: truncated ? command.length : nil)
38
+ end
39
+ end
40
+ end
@@ -56,7 +56,7 @@ module Samagotchi
56
56
 
57
57
  # Booleans the way models send them (Gemma passes "true"/"false").
58
58
  def self.to_bool(value)
59
- return value if value == true || value == false
59
+ return value if [true, false].include?(value)
60
60
 
61
61
  %w[1 true yes on].include?(value.to_s.strip.downcase)
62
62
  end
@@ -90,6 +90,7 @@ module Samagotchi
90
90
  begin
91
91
  parsed = JSON.parse(s)
92
92
  return parsed.dup if parsed.is_a?(Array)
93
+
93
94
  # If parsed is a String like "a, b", fall through to split
94
95
  if parsed.is_a?(String)
95
96
  s = parsed
@@ -124,8 +125,6 @@ module Samagotchi
124
125
  parts = t.split(/[,;\n]+/).map(&:strip)
125
126
  # Strip surrounding quotes from each part
126
127
  parts.map { |p| p.gsub(/\A["'\s]+|["'\s]+\z/, "").strip }
127
- else
128
- return nil
129
128
  end
130
129
  end
131
130
 
@@ -143,7 +142,7 @@ module Samagotchi
143
142
  s = s.strip
144
143
  return nil if s.empty?
145
144
  return nil if s.match?(/\A[\[\],\s]+\z/)
146
- return nil if s == "]" || s == "["
145
+ return nil if ["]", "["].include?(s)
147
146
 
148
147
  s
149
148
  end
@@ -87,7 +87,7 @@ module Samagotchi
87
87
  # one of its keys must be a property; the rest must all be.
88
88
  unknown = ([path_key].compact + Array(o[:verbatim]) + Array(o[:also]) + o.fetch(:aliases, {}).keys) - properties
89
89
  unknown << content_keys.join("/") unless content_keys.empty? || content_keys.intersect?(properties)
90
- raise ArgumentError, "#{name}: overrides name no schema property: #{unknown.join(', ')}" unless unknown.empty?
90
+ raise ArgumentError, "#{name}: overrides name no schema property: #{unknown.join(", ")}" unless unknown.empty?
91
91
  raise ArgumentError, "#{name}: a property would replace the call's name" if fields.include?("name")
92
92
 
93
93
  Row.new(name: name, content_keys: content_keys, path_key: path_key, fields: fields,
@@ -155,7 +155,7 @@ module Samagotchi
155
155
  # true unless told otherwise; text-based parsers hand strings over.
156
156
  def self.parse_wait(value)
157
157
  return true if value.nil? || value.to_s.strip.empty?
158
- return value if value == true || value == false
158
+ return value if [true, false].include?(value)
159
159
 
160
160
  !%w[false 0 no off].include?(value.to_s.strip.downcase)
161
161
  end
@@ -137,7 +137,7 @@ module Samagotchi
137
137
 
138
138
  # execute: git push origin main
139
139
  def what_text(facts)
140
- facts = facts.is_a?(Hash) ? facts : {}
140
+ facts = {} unless facts.is_a?(Hash)
141
141
  tool = facts[:label] || facts[:tool] || "tool"
142
142
  what = facts[:command] || Array(facts[:paths]).join(", ")
143
143
  what = facts[:args].to_s if what.to_s.empty?
@@ -216,7 +216,6 @@ module Samagotchi
216
216
  preview[:tail]
217
217
  ].join("\n")
218
218
  end
219
-
220
219
  end
221
220
  end
222
221
  end
@@ -27,7 +27,7 @@ module Samagotchi
27
27
  updated, message = result
28
28
  File.write(path, updated)
29
29
  message
30
- rescue => e
30
+ rescue StandardError => e
31
31
  "Error: #{e.message}"
32
32
  end
33
33
 
@@ -49,16 +49,16 @@ module Samagotchi
49
49
  old_text = old_text.to_s
50
50
  new_text = new_text.to_s
51
51
 
52
- source = read.(path)
52
+ source = read.call(path)
53
53
  count = count_occurrences(source, old_text)
54
54
 
55
55
  return "Error: old text not found in #{path}" if count == 0
56
56
  return "Error: old text matches #{count} times in #{path}; make it unique" if count > 1
57
57
 
58
58
  idx = source.index(old_text)
59
- updated = source[0, idx] + new_text + source[idx + old_text.length..]
59
+ updated = source[0, idx] + new_text + source[(idx + old_text.length)..]
60
60
  [updated, "Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"]
61
- rescue => e
61
+ rescue StandardError => e
62
62
  "Error: #{e.message}"
63
63
  end
64
64
 
@@ -77,7 +77,7 @@ module Samagotchi
77
77
  # guessed start would silently overwrite the top of the file.
78
78
  return START_LINE_REQUIRED if start_num.nil?
79
79
 
80
- source = read.(path)
80
+ source = read.call(path)
81
81
  lines = source.lines
82
82
  total_lines = lines.length
83
83
 
@@ -94,12 +94,11 @@ module Samagotchi
94
94
  end_value = nil
95
95
  if end_provided
96
96
  if end_num > total_lines
97
- if OutputGuardrails.env_bool("SAMAGOTCHI_EDIT_ALLOW_OOR_END", default: true)
98
- clamp_note = " (end_line #{end_num} exceeds #{total_lines} lines; clamped to line #{total_lines})"
99
- end_value = total_lines
100
- else
101
- return "Error: range out of bounds for #{path}: file has #{total_lines} lines"
102
- end
97
+ return "Error: range out of bounds for #{path}: file has #{total_lines} lines" unless OutputGuardrails.env_bool("SAMAGOTCHI_EDIT_ALLOW_OOR_END", default: true)
98
+
99
+ clamp_note = " (end_line #{end_num} exceeds #{total_lines} lines; clamped to line #{total_lines})"
100
+ end_value = total_lines
101
+
103
102
  else
104
103
  end_value = end_num
105
104
  end
@@ -107,6 +106,7 @@ module Samagotchi
107
106
  unless OutputGuardrails.env_bool("SAMAGOTCHI_EDIT_END_OPTIONAL", default: true)
108
107
  return "Error: start_line and end_line must both be provided for range edits"
109
108
  end
109
+
110
110
  end_value = total_lines
111
111
  end
112
112
 
@@ -118,13 +118,13 @@ module Samagotchi
118
118
  suffix = lines[end_value..]&.join.to_s
119
119
  # Ensure new_text ends with a newline when a suffix follows so that the
120
120
  # first suffix line isn't concatenated onto the last replacement line.
121
- normalized = (!new_text.empty? && !suffix.empty? && !new_text.end_with?("\n")) ? new_text + "\n" : new_text
121
+ normalized = !new_text.empty? && !suffix.empty? && !new_text.end_with?("\n") ? new_text + "\n" : new_text
122
122
  updated = prefix + normalized + suffix
123
123
 
124
124
  replaced_lines = (end_value - start_num) + 1
125
125
  new_line_count = new_text.lines.length
126
126
  [updated, "Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"]
127
- rescue => e
127
+ rescue StandardError => e
128
128
  "Error: #{e.message}"
129
129
  end
130
130
  private_class_method :apply_range_mode
@@ -136,6 +136,7 @@ module Samagotchi
136
136
  while (i = haystack.index(needle, pos))
137
137
  count += 1
138
138
  break if count > 1 # early exit — we only care whether it's 0, 1, or >1
139
+
139
140
  pos = i + needle.length
140
141
  end
141
142
  count
@@ -67,7 +67,7 @@ module Samagotchi
67
67
  stopped_result("Error: command timed out after #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
68
68
  rescue CommandCancelled => e
69
69
  stopped_result("Error: command stopped by the user after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
70
- rescue => e
70
+ rescue StandardError => e
71
71
  "Error: #{e.message}"
72
72
  end
73
73
 
@@ -186,7 +186,7 @@ module Samagotchi
186
186
  @thread = Thread.new do
187
187
  Thread.current.report_on_exception = false
188
188
  loop { @buffer << io.readpartial(READ_CHUNK_BYTES) }
189
- rescue EOFError, IOError
189
+ rescue IOError # EOFError is one
190
190
  nil
191
191
  end
192
192
  end
@@ -221,9 +221,7 @@ module Samagotchi
221
221
  signal_process(pid, "TERM")
222
222
 
223
223
  deadline = monotonic_time + STOP_GRACE_SEC
224
- while process_alive?(pid) && monotonic_time < deadline
225
- sleep(STOP_POLL_INTERVAL_SEC)
226
- end
224
+ sleep(STOP_POLL_INTERVAL_SEC) while process_alive?(pid) && monotonic_time < deadline
227
225
 
228
226
  signal_process(pid, "KILL") if process_alive?(pid)
229
227
  end
@@ -10,7 +10,7 @@ module Samagotchi
10
10
 
11
11
  def self.name = NAME
12
12
 
13
- def self.call(content, reminder_store: nil)
13
+ def self.call(_content, reminder_store: nil)
14
14
  return "Error: reminder_store not configured" unless reminder_store
15
15
 
16
16
  reminder_store.list
@@ -1,4 +1,3 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require "fileutils"
@@ -46,6 +45,7 @@ module Samagotchi
46
45
  if names.empty?
47
46
  return "Error: no memory names provided"
48
47
  end
48
+
49
49
  bad = names.find { |name| invalid_name?(name) }
50
50
  return invalid_name_error(bad) if bad
51
51
 
@@ -58,34 +58,34 @@ module Samagotchi
58
58
  found = false
59
59
  resolved_scopes.each do |resolved_scope|
60
60
  path = memory_path(name, resolved_scope)
61
- if File.exist?(path)
62
- body = File.read(path, encoding: "UTF-8")
63
- # Append model-specific overlay if key is provided and overlay exists.
64
- [model_key, fallback_model_key].compact.each do |key|
65
- overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
66
- next unless overlay_path && File.exist?(overlay_path)
67
-
68
- body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path, encoding: "UTF-8")
69
- break
70
- end
71
- results << body
72
- found = true
61
+ next unless File.exist?(path)
62
+
63
+ body = File.read(path, encoding: "UTF-8")
64
+ # Append model-specific overlay if key is provided and overlay exists.
65
+ [model_key, fallback_model_key].compact.each do |key|
66
+ overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
67
+ next unless overlay_path && File.exist?(overlay_path)
68
+
69
+ body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path, encoding: "UTF-8")
73
70
  break
74
71
  end
72
+ results << body
73
+ found = true
74
+ break
75
75
  end
76
76
  missing << name unless found
77
77
  end
78
78
 
79
79
  if results.empty? && !missing.empty?
80
- "Error: memory not found: #{missing.join(', ')}"
80
+ "Error: memory not found: #{missing.join(", ")}"
81
81
  elsif missing.empty?
82
82
  results.join(SEPARATOR)
83
83
  else
84
- results.join(SEPARATOR) + SEPARATOR + "Error: memory not found: #{missing.join(', ')}"
84
+ results.join(SEPARATOR) + SEPARATOR + "Error: memory not found: #{missing.join(", ")}"
85
85
  end
86
86
  rescue Errno::ENOENT => e
87
87
  "Error: #{e.message}"
88
- rescue => e
88
+ rescue StandardError => e
89
89
  "Error: #{e.message}"
90
90
  end
91
91
 
@@ -108,7 +108,7 @@ module Samagotchi
108
108
  return nil if value.empty?
109
109
  return value if VALID_SCOPES.include?(value)
110
110
 
111
- raise ArgumentError, "invalid scope '#{scope}', expected one of: #{VALID_SCOPES.join(', ')}"
111
+ raise ArgumentError, "invalid scope '#{scope}', expected one of: #{VALID_SCOPES.join(", ")}"
112
112
  end
113
113
 
114
114
  def self.scoped_index(scope)
@@ -157,6 +157,7 @@ module Samagotchi
157
157
  return MemoryRead.invalid_name_error(entry_name) if MemoryRead.invalid_name?(entry_name)
158
158
  return "Error: scope is required" if scope.to_s.strip.empty?
159
159
  return "Error: content is required" if body.empty?
160
+
160
161
  resolved_scope = MemoryRead.normalize_scope(scope)
161
162
 
162
163
  if current_model_only
@@ -195,14 +196,12 @@ module Samagotchi
195
196
 
196
197
  # The verbatim "write to index.md" behavior (path: "index") must not
197
198
  # trigger upsert logic.
198
- unless entry_name == MEMORY_INDEX
199
- if manage_index(resolved_scope, entry_name, bytes, description)
200
- message += " Index line refreshed automatically."
201
- end
199
+ if !(entry_name == MEMORY_INDEX) && manage_index(resolved_scope, entry_name, bytes, description)
200
+ message += " Index line refreshed automatically."
202
201
  end
203
202
 
204
203
  message
205
- rescue => e
204
+ rescue StandardError => e
206
205
  "Error: #{e.message}"
207
206
  end
208
207
 
@@ -65,7 +65,7 @@ module Samagotchi
65
65
  build_truncated_preview(path: path, file_size: size, preview_bytes: preview_bytes)
66
66
  rescue Errno::ENOENT
67
67
  "Error: file not found: #{path}"
68
- rescue => e
68
+ rescue StandardError => e
69
69
  "Error: #{e.message}"
70
70
  end
71
71
 
@@ -124,6 +124,7 @@ module Samagotchi
124
124
 
125
125
  start_num = parse_positive_line_number(start_line, "start_line")
126
126
  return start_num if start_num.is_a?(String)
127
+
127
128
  # end_line alone reads from the top of the file.
128
129
  start_num ||= 1
129
130
 
@@ -145,12 +146,11 @@ module Samagotchi
145
146
  # the historical hard error instead.
146
147
  clamp_note = ""
147
148
  if end_provided && end_num > total_lines
148
- if OutputGuardrails.env_bool("SAMAGOTCHI_READ_ALLOW_OOR_END", default: true)
149
- clamp_note = "\n[read: end_line #{end_num} exceeds #{total_lines} lines; returning lines #{start_num}-#{total_lines}]"
150
- end_num = total_lines
151
- else
152
- return "Error: range out of bounds for #{path}: file has #{total_lines} lines"
153
- end
149
+ return "Error: range out of bounds for #{path}: file has #{total_lines} lines" unless OutputGuardrails.env_bool("SAMAGOTCHI_READ_ALLOW_OOR_END", default: true)
150
+
151
+ clamp_note = "\n[read: end_line #{end_num} exceeds #{total_lines} lines; returning lines #{start_num}-#{total_lines}]"
152
+ end_num = total_lines
153
+
154
154
  end
155
155
 
156
156
  # A start past EOF is genuinely unusable -> keep a hard error. A slice
@@ -22,7 +22,7 @@ module Samagotchi
22
22
  "output_path: #{record.fetch("output_path")}",
23
23
  "cwd: #{record.fetch("cwd")}"
24
24
  ].join("\n")
25
- rescue => e
25
+ rescue StandardError => e
26
26
  "Error: #{e.message}"
27
27
  end
28
28
  end
@@ -14,7 +14,7 @@ module Samagotchi
14
14
  return error if error
15
15
 
16
16
  format_record(record)
17
- rescue => e
17
+ rescue StandardError => e
18
18
  "Error: #{e.message}"
19
19
  end
20
20
 
@@ -15,7 +15,7 @@ module Samagotchi
15
15
  return "No tasks found." if records.empty?
16
16
 
17
17
  records.map { |record| format_entry(record) }.join("\n\n")
18
- rescue => e
18
+ rescue StandardError => e
19
19
  "Error: #{e.message}"
20
20
  end
21
21