samagotchi 0.21.0 → 0.23.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/bin/chi +9 -4
  4. data/docs/architecture.md +3 -2
  5. data/docs/configuration.md +15 -4
  6. data/docs/releasing.md +1 -1
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +11 -5
  9. data/docs/testing.md +6 -0
  10. data/lib/samagotchi/answer_command.rb +11 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  12. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  13. data/lib/samagotchi/bootstrap_command.rb +2 -2
  14. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
  17. data/lib/samagotchi/bridge.rb +12 -9
  18. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  19. data/lib/samagotchi/bridge_client.rb +1 -1
  20. data/lib/samagotchi/bundle_command.rb +39 -29
  21. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  23. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
  25. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  26. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  29. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  31. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/cancellation_controller.rb +1 -1
  38. data/lib/samagotchi/cli/command.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  40. data/lib/samagotchi/client.rb +3 -5
  41. data/lib/samagotchi/commands/registry.rb +2 -2
  42. data/lib/samagotchi/config.rb +38 -14
  43. data/lib/samagotchi/context_note.rb +1 -1
  44. data/lib/samagotchi/context_status.rb +6 -6
  45. data/lib/samagotchi/continue_offer.rb +44 -12
  46. data/lib/samagotchi/debug_log.rb +1 -1
  47. data/lib/samagotchi/desktop/macos.rb +5 -4
  48. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  49. data/lib/samagotchi/engine.rb +49 -19
  50. data/lib/samagotchi/gem_update.rb +2 -1
  51. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  52. data/lib/samagotchi/guardrails/approval.rb +5 -5
  53. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  54. data/lib/samagotchi/guardrails/gate.rb +2 -2
  55. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  56. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  57. data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
  58. data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
  59. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  60. data/lib/samagotchi/hooks/loader.rb +18 -16
  61. data/lib/samagotchi/hooks/registry.rb +10 -2
  62. data/lib/samagotchi/host_registry.rb +1 -3
  63. data/lib/samagotchi/idle_recap.rb +18 -15
  64. data/lib/samagotchi/idle_reminders.rb +6 -3
  65. data/lib/samagotchi/image_store.rb +5 -2
  66. data/lib/samagotchi/kernel_loop.rb +31 -31
  67. data/lib/samagotchi/llm/chat_loop.rb +9 -6
  68. data/lib/samagotchi/llm/errors.rb +33 -2
  69. data/lib/samagotchi/llm/http.rb +12 -12
  70. data/lib/samagotchi/llm/openai_chat.rb +53 -30
  71. data/lib/samagotchi/llm/prompt_cache.rb +83 -0
  72. data/lib/samagotchi/log.rb +2 -2
  73. data/lib/samagotchi/log_subscriber.rb +2 -1
  74. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  75. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  76. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  77. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  78. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  79. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  80. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  81. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  82. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  83. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  84. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  85. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  86. data/lib/samagotchi/memory_bundle.rb +1 -0
  87. data/lib/samagotchi/model_overlay.rb +3 -5
  88. data/lib/samagotchi/model_profile.rb +12 -8
  89. data/lib/samagotchi/note_command.rb +2 -1
  90. data/lib/samagotchi/output_formatter.rb +18 -17
  91. data/lib/samagotchi/owner_lock.rb +2 -1
  92. data/lib/samagotchi/parent_report.rb +24 -7
  93. data/lib/samagotchi/plugin/api.rb +8 -5
  94. data/lib/samagotchi/plugin/context.rb +1 -3
  95. data/lib/samagotchi/plugin/loader.rb +1 -1
  96. data/lib/samagotchi/plugin/service.rb +1 -1
  97. data/lib/samagotchi/plugin/sessions.rb +1 -0
  98. data/lib/samagotchi/plugin_tasks.rb +1 -1
  99. data/lib/samagotchi/prompt.rb +68 -12
  100. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  101. data/lib/samagotchi/question_desk.rb +7 -13
  102. data/lib/samagotchi/recap_setup.rb +1 -1
  103. data/lib/samagotchi/relay_desk.rb +1 -1
  104. data/lib/samagotchi/reminder_store.rb +2 -1
  105. data/lib/samagotchi/reply_wait.rb +12 -2
  106. data/lib/samagotchi/send_command.rb +4 -2
  107. data/lib/samagotchi/session.rb +2 -2
  108. data/lib/samagotchi/session_archive_command.rb +5 -1
  109. data/lib/samagotchi/session_commands.rb +1 -3
  110. data/lib/samagotchi/session_manager.rb +2 -4
  111. data/lib/samagotchi/session_metrics.rb +10 -10
  112. data/lib/samagotchi/session_observer.rb +3 -2
  113. data/lib/samagotchi/session_retention.rb +2 -2
  114. data/lib/samagotchi/sessions_command.rb +14 -5
  115. data/lib/samagotchi/system_prompt.rb +19 -8
  116. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  117. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  118. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
  119. data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
  120. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  121. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  122. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  123. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  124. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  125. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  126. data/lib/samagotchi/terminal_ui.rb +9 -5
  127. data/lib/samagotchi/text_diff.rb +12 -12
  128. data/lib/samagotchi/thinking.rb +6 -2
  129. data/lib/samagotchi/thought_stream_splitter.rb +6 -3
  130. data/lib/samagotchi/tool_activity.rb +1 -0
  131. data/lib/samagotchi/tool_call_parser.rb +13 -14
  132. data/lib/samagotchi/tool_declarations.rb +36 -35
  133. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  134. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  135. data/lib/samagotchi/tools/delegate.rb +1 -1
  136. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  137. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  138. data/lib/samagotchi/tools/edit.rb +14 -13
  139. data/lib/samagotchi/tools/execute.rb +3 -5
  140. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  141. data/lib/samagotchi/tools/memory.rb +21 -22
  142. data/lib/samagotchi/tools/read.rb +7 -7
  143. data/lib/samagotchi/tools/task_create.rb +1 -1
  144. data/lib/samagotchi/tools/task_get.rb +1 -1
  145. data/lib/samagotchi/tools/task_list.rb +1 -1
  146. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  147. data/lib/samagotchi/tools/task_stop.rb +1 -1
  148. data/lib/samagotchi/tools/task_wait.rb +3 -4
  149. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  150. data/lib/samagotchi/tools/write.rb +1 -1
  151. data/lib/samagotchi/turn_flow.rb +8 -8
  152. data/lib/samagotchi/turn_note.rb +5 -1
  153. data/lib/samagotchi/turn_state.rb +22 -0
  154. data/lib/samagotchi/version.rb +1 -1
  155. data/lib/samagotchi/vision_support.rb +2 -2
  156. data/lib/samagotchi/web/app.rb +13 -6
  157. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  158. data/lib/samagotchi/web/qr.rb +7 -1
  159. data/lib/samagotchi/web/server.rb +6 -2
  160. data/lib/samagotchi/worker.rb +10 -2
  161. metadata +21 -20
@@ -90,7 +90,7 @@ module Samagotchi
90
90
  # As typed: the Engine applies an alias where it resolves the model.
91
91
  flag_model_name = model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(model_name)
92
92
  @effective_model_name = flag_model_name || @default_model_name
93
- @host_registry = host_registry || Samagotchi::HostRegistry.new
93
+ @host_registry = host_registry || Samagotchi::HostRegistry.new
94
94
  # An injected client (specs) stands in for every host's client.
95
95
  @host_registry.client_override = client if client
96
96
  @client = @host_registry.resolve(@effective_model_name).client
@@ -101,6 +101,7 @@ module Samagotchi
101
101
  if session_id && !Session.exist?(session_id)
102
102
  raise SessionNotFound, "Session not found: #{session_id}"
103
103
  end
104
+
104
105
  claim_session!(session_id) if session_id
105
106
  @resume_session = session_id ? Session.load(session_id) : nil
106
107
  if @resume_session
@@ -146,7 +147,7 @@ module Samagotchi
146
147
  @between_turns = BetweenTurns.new(surface: @surface, view: @view, renderer: @renderer,
147
148
  turn_running: -> { @engine.turn_running? }, quiet: @non_interactive)
148
149
  @render_event = ->(event) { handle_stream_event(event) }
149
- @engine = Engine.new(
150
+ @engine = Engine.new(
150
151
  client: client,
151
152
  host_registry: @host_registry,
152
153
  profile: profile,
@@ -504,6 +505,7 @@ module Samagotchi
504
505
  end
505
506
  end
506
507
  break if input.nil?
508
+
507
509
  local = local_command(input)
508
510
  if local == :exit
509
511
  @delete_on_exit ||= SessionCommands.delete_on_exit?(input)
@@ -705,6 +707,7 @@ module Samagotchi
705
707
  return
706
708
  end
707
709
  return if show_local_command(input)
710
+
708
711
  # A typo of a command (/modle) is not a prompt: say so, send nothing.
709
712
  hint = command_registry.unknown_command_hint(input)
710
713
  return @surface.commit(hint) if hint
@@ -849,6 +852,7 @@ module Samagotchi
849
852
  # #cancel_turn_from_prompt.
850
853
  def poll_input_with_reminder_check(awaiting_continue:)
851
854
  return :due unless @engine.due_reminder_names.empty?
855
+
852
856
  # Specs and pipes: a plain blocking read.
853
857
  unless @repl_input
854
858
  @between_turns.flush_cards
@@ -972,10 +976,10 @@ module Samagotchi
972
976
  # While a turn runs, a line submitted at the open prompt steers it: it
973
977
  # merges at the next iteration boundary (Kernel), and one that comes after
974
978
  # the last runs as the next turn. Reminder turns too.
975
- def with_steering(&block)
979
+ def with_steering(&)
976
980
  return yield unless @repl_input
977
981
 
978
- @repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain }, &block)
982
+ @repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain }, &)
979
983
  end
980
984
 
981
985
  # On the reader thread, from ReplInput: takes a line for the running turn.
@@ -994,6 +998,7 @@ module Samagotchi
994
998
  # A command, never steering text (/archive waits, back in the prompt).
995
999
  return command_during_turn(line) if local || command_registry.command?(line)
996
1000
  return true if line.strip.empty?
1001
+
997
1002
  # A typo of a command (/modle) is not steering text: say so, take it.
998
1003
  if (hint = command_registry.unknown_command_hint(line))
999
1004
  @surface.commit(hint)
@@ -1092,7 +1097,6 @@ module Samagotchi
1092
1097
  ConfigFile.recap_config
1093
1098
  end
1094
1099
 
1095
-
1096
1100
  # Reset the Engine's shared inactivity clock when a prompt opens (a
1097
1101
  # Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
1098
1102
  # single seam the Engine-owned idle recap detector reads, so the idle clock
@@ -46,8 +46,8 @@ module Samagotchi
46
46
  a_mid = a[pre...(a.size - suf)]
47
47
  b_mid = b[pre...(b.size - suf)]
48
48
  middle = myers(a_mid, b_mid) ||
49
- a_mid.each_index.map { |i| [:del, i, nil] } + b_mid.each_index.map { |j| [:add, nil, j] }
50
- middle.each { |t, i, j| ops << [t, i && i + pre, j && j + pre] }
49
+ (a_mid.each_index.map { |i| [:del, i, nil] } + b_mid.each_index.map { |j| [:add, nil, j] })
50
+ middle.each { |t, i, j| ops << [t, i && (i + pre), j && (j + pre)] }
51
51
  (0...suf).each { |s| ops << [:eq, a.size - suf + s, b.size - suf + s] }
52
52
  ops
53
53
  end
@@ -64,13 +64,13 @@ module Samagotchi
64
64
  n = a.size
65
65
  m = b.size
66
66
  off = n + m + 1
67
- v = Array.new(2 * off + 1, 0)
67
+ v = Array.new((2 * off) + 1, 0)
68
68
  trace = []
69
69
  (0..(n + m)).each do |d|
70
70
  return nil if d > max_d
71
71
 
72
72
  # Round d reads k-1 and k+1 for k in -d..d: keep -(d+1)..d+1.
73
- trace << v[off - d - 1, 2 * d + 3]
73
+ trace << v[off - d - 1, (2 * d) + 3]
74
74
  k = -d
75
75
  while k <= d
76
76
  x = if k == -d || (k != d && v[off + k - 1] < v[off + k + 1])
@@ -98,8 +98,8 @@ module Samagotchi
98
98
  vd = trace[d]
99
99
  at = ->(k) { vd[k + d + 1] }
100
100
  k = x - y
101
- prev_k = k == -d || (k != d && at.(k - 1) < at.(k + 1)) ? k + 1 : k - 1
102
- prev_x = at.(prev_k)
101
+ prev_k = k == -d || (k != d && at.call(k - 1) < at.call(k + 1)) ? k + 1 : k - 1
102
+ prev_x = at.call(prev_k)
103
103
  prev_y = prev_x - prev_k
104
104
  while x > prev_x && y > prev_y
105
105
  x -= 1
@@ -108,11 +108,11 @@ module Samagotchi
108
108
  end
109
109
  break if d.zero?
110
110
 
111
- if x == prev_x
112
- ops << [:add, nil, prev_y]
113
- else
114
- ops << [:del, prev_x, nil]
115
- end
111
+ ops << if x == prev_x
112
+ [:add, nil, prev_y]
113
+ else
114
+ [:del, prev_x, nil]
115
+ end
116
116
  x = prev_x
117
117
  y = prev_y
118
118
  end
@@ -144,7 +144,7 @@ module Samagotchi
144
144
  line = t == :add ? b[j] : a[i]
145
145
  mark = { eq: " ", del: "-", add: "+" }[t]
146
146
  # Only the "\n": chomp("\n") would take a CRLF's "\r" too.
147
- lines << mark + (line.end_with?("\n") ? line[0...-1] : line)
147
+ lines << (mark + (line.end_with?("\n") ? line[0...-1] : line))
148
148
  lines << NO_NEWLINE unless line.end_with?("\n")
149
149
  end
150
150
  end
@@ -23,6 +23,10 @@ module Samagotchi
23
23
  # An empty thought after the Qwen assistant cue: the model answers at once
24
24
  # (what the server's own template does with enable_thinking false).
25
25
  QWEN_EMPTY_THOUGHT = "<think>\n\n</think>\n\n"
26
+ # Gemma 4's empty thought channel after the model cue, as its chat
27
+ # template writes it with enable_thinking false (only where a model turn
28
+ # starts: Prompt.prefill_for).
29
+ GEMMA_EMPTY_THOUGHT = "<|channel>thought\n<channel|>"
26
30
 
27
31
  # What a native prompt gets for a level: the text before the system
28
32
  # prompt, the text after the assistant cue, and whether the level means
@@ -43,7 +47,7 @@ module Samagotchi
43
47
  return text.to_sym if LEVELS.include?(text.to_sym)
44
48
 
45
49
  hint = value == true || %w[true on].include?(text) ? "; `on` isn't one, `default` leaves it to the model" : ""
46
- ConfigFile.warn_once "Warning: #{where}: thinking must be one of #{LEVELS.join(', ')}#{hint}; ignored"
50
+ ConfigFile.warn_once "Warning: #{where}: thinking must be one of #{LEVELS.join(", ")}#{hint}; ignored"
47
51
  nil
48
52
  end
49
53
 
@@ -116,7 +120,7 @@ module Samagotchi
116
120
  off = level == :off
117
121
  honoured = !EFFORTS.include?(level)
118
122
  case profile&.name
119
- when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: "", honoured: honoured)
123
+ when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: off ? GEMMA_EMPTY_THOUGHT : "", honoured: honoured)
120
124
  when "qwen36" then Native.new(system_token: "", prefill: off ? QWEN_EMPTY_THOUGHT : "", honoured: honoured)
121
125
  else Native.new(system_token: "", prefill: "", honoured: level == DEFAULT)
122
126
  end
@@ -74,7 +74,7 @@ module Samagotchi
74
74
  end
75
75
  body = @carry[0...close_pos]
76
76
  route_body(body, thinking)
77
- @carry = @carry[close_pos + @active[:close].length..].to_s
77
+ @carry = @carry[(close_pos + @active[:close].length)..].to_s
78
78
  @active = nil
79
79
  @state = :normal
80
80
  end
@@ -108,6 +108,7 @@ module Samagotchi
108
108
  @blocks.each do |block|
109
109
  pos = @carry.index(block[:open])
110
110
  next if pos.nil?
111
+
111
112
  best = { pos: pos, block: block } if best.nil? || pos < best[:pos]
112
113
  end
113
114
  best
@@ -118,7 +119,7 @@ module Samagotchi
118
119
  def flush_normal(text)
119
120
  partial = @blocks.map { |block| longest_suffix_prefix_of(@carry, block[:open]) }.max.to_i
120
121
  text << @carry[0...(@carry.length - partial)]
121
- @carry = @carry[@carry.length - partial..].to_s
122
+ @carry = @carry[(@carry.length - partial)..].to_s
122
123
  end
123
124
 
124
125
  # In-block state: emit the body seen so far to the active lane, retaining a
@@ -127,12 +128,13 @@ module Samagotchi
127
128
  close = @active[:close]
128
129
  partial = longest_suffix_prefix_of(@carry, close)
129
130
  route_body(@carry[0...(@carry.length - partial)], thinking)
130
- @carry = @carry[@carry.length - partial..].to_s
131
+ @carry = @carry[(@carry.length - partial)..].to_s
131
132
  end
132
133
 
133
134
  # Route a body fragment to the active block's lane (:thinking or :drop).
134
135
  def route_body(body, thinking)
135
136
  return if body.nil? || body.empty?
137
+
136
138
  thinking << body if @active && @active[:lane] == :thinking
137
139
  end
138
140
 
@@ -141,6 +143,7 @@ module Samagotchi
141
143
  def longest_suffix_prefix_of(str, marker)
142
144
  mlen = marker.length
143
145
  return 0 if mlen.zero? || str.empty?
146
+
144
147
  [str.length, mlen].min.downto(1) do |n|
145
148
  return n if str[-n..] == marker[0, n]
146
149
  end
@@ -229,6 +229,7 @@ module Samagotchi
229
229
  def registry_params(entry, call)
230
230
  return nil if entry&.core?
231
231
  return nil if entry.nil? && !call[:args].is_a?(Hash)
232
+
232
233
  if entry&.preview
233
234
  begin
234
235
  return entry.preview.call(call)
@@ -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
@@ -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