samagotchi 0.22.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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/bin/chi +9 -4
  4. data/docs/configuration.md +4 -4
  5. data/docs/releasing.md +1 -1
  6. data/docs/testing.md +6 -0
  7. data/lib/samagotchi/answer_command.rb +4 -2
  8. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  9. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +2 -2
  11. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  12. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
  14. data/lib/samagotchi/bridge.rb +12 -9
  15. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  16. data/lib/samagotchi/bridge_client.rb +1 -1
  17. data/lib/samagotchi/bundle_command.rb +39 -29
  18. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  20. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
  22. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  23. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  26. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  28. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  30. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  31. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  34. data/lib/samagotchi/cancellation_controller.rb +1 -1
  35. data/lib/samagotchi/cli/command.rb +1 -1
  36. data/lib/samagotchi/client.rb +3 -5
  37. data/lib/samagotchi/commands/registry.rb +2 -2
  38. data/lib/samagotchi/config.rb +38 -14
  39. data/lib/samagotchi/context_note.rb +1 -1
  40. data/lib/samagotchi/context_status.rb +6 -6
  41. data/lib/samagotchi/continue_offer.rb +15 -8
  42. data/lib/samagotchi/debug_log.rb +1 -1
  43. data/lib/samagotchi/desktop/macos.rb +5 -4
  44. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  45. data/lib/samagotchi/engine.rb +17 -17
  46. data/lib/samagotchi/gem_update.rb +2 -1
  47. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  48. data/lib/samagotchi/guardrails/approval.rb +5 -5
  49. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  50. data/lib/samagotchi/guardrails/gate.rb +2 -2
  51. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  52. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  53. data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
  54. data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
  55. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  56. data/lib/samagotchi/hooks/loader.rb +18 -16
  57. data/lib/samagotchi/hooks/registry.rb +10 -2
  58. data/lib/samagotchi/host_registry.rb +1 -3
  59. data/lib/samagotchi/idle_recap.rb +18 -15
  60. data/lib/samagotchi/idle_reminders.rb +6 -3
  61. data/lib/samagotchi/image_store.rb +5 -2
  62. data/lib/samagotchi/kernel_loop.rb +31 -31
  63. data/lib/samagotchi/llm/chat_loop.rb +9 -6
  64. data/lib/samagotchi/llm/errors.rb +2 -2
  65. data/lib/samagotchi/llm/http.rb +12 -12
  66. data/lib/samagotchi/llm/openai_chat.rb +39 -28
  67. data/lib/samagotchi/llm/prompt_cache.rb +1 -1
  68. data/lib/samagotchi/log.rb +2 -2
  69. data/lib/samagotchi/log_subscriber.rb +2 -1
  70. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  71. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  72. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  73. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  74. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  75. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  76. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  77. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  78. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  79. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  80. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  81. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  82. data/lib/samagotchi/memory_bundle.rb +1 -0
  83. data/lib/samagotchi/model_overlay.rb +3 -5
  84. data/lib/samagotchi/model_profile.rb +12 -8
  85. data/lib/samagotchi/note_command.rb +2 -1
  86. data/lib/samagotchi/output_formatter.rb +18 -17
  87. data/lib/samagotchi/owner_lock.rb +2 -1
  88. data/lib/samagotchi/plugin/api.rb +8 -5
  89. data/lib/samagotchi/plugin/context.rb +1 -3
  90. data/lib/samagotchi/plugin/loader.rb +1 -1
  91. data/lib/samagotchi/plugin/service.rb +1 -1
  92. data/lib/samagotchi/plugin/sessions.rb +1 -0
  93. data/lib/samagotchi/plugin_tasks.rb +1 -1
  94. data/lib/samagotchi/prompt.rb +68 -12
  95. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  96. data/lib/samagotchi/question_desk.rb +7 -7
  97. data/lib/samagotchi/recap_setup.rb +1 -1
  98. data/lib/samagotchi/relay_desk.rb +1 -1
  99. data/lib/samagotchi/reminder_store.rb +2 -1
  100. data/lib/samagotchi/send_command.rb +4 -2
  101. data/lib/samagotchi/session.rb +2 -2
  102. data/lib/samagotchi/session_archive_command.rb +5 -1
  103. data/lib/samagotchi/session_commands.rb +1 -3
  104. data/lib/samagotchi/session_manager.rb +2 -4
  105. data/lib/samagotchi/session_metrics.rb +10 -10
  106. data/lib/samagotchi/session_observer.rb +3 -2
  107. data/lib/samagotchi/session_retention.rb +2 -2
  108. data/lib/samagotchi/sessions_command.rb +14 -5
  109. data/lib/samagotchi/system_prompt.rb +19 -8
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  111. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  112. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
  113. data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
  114. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  115. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  116. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  117. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  118. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  119. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  120. data/lib/samagotchi/terminal_ui.rb +9 -5
  121. data/lib/samagotchi/text_diff.rb +12 -12
  122. data/lib/samagotchi/thinking.rb +6 -2
  123. data/lib/samagotchi/thought_stream_splitter.rb +6 -3
  124. data/lib/samagotchi/tool_activity.rb +1 -0
  125. data/lib/samagotchi/tool_call_parser.rb +13 -14
  126. data/lib/samagotchi/tool_declarations.rb +36 -35
  127. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  128. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  129. data/lib/samagotchi/tools/delegate.rb +1 -1
  130. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  131. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  132. data/lib/samagotchi/tools/edit.rb +14 -13
  133. data/lib/samagotchi/tools/execute.rb +3 -5
  134. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  135. data/lib/samagotchi/tools/memory.rb +21 -22
  136. data/lib/samagotchi/tools/read.rb +7 -7
  137. data/lib/samagotchi/tools/task_create.rb +1 -1
  138. data/lib/samagotchi/tools/task_get.rb +1 -1
  139. data/lib/samagotchi/tools/task_list.rb +1 -1
  140. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  141. data/lib/samagotchi/tools/task_stop.rb +1 -1
  142. data/lib/samagotchi/tools/task_wait.rb +3 -4
  143. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  144. data/lib/samagotchi/tools/write.rb +1 -1
  145. data/lib/samagotchi/turn_flow.rb +8 -8
  146. data/lib/samagotchi/turn_note.rb +5 -1
  147. data/lib/samagotchi/version.rb +1 -1
  148. data/lib/samagotchi/vision_support.rb +2 -2
  149. data/lib/samagotchi/web/app.rb +13 -6
  150. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  151. data/lib/samagotchi/web/qr.rb +7 -1
  152. data/lib/samagotchi/web/server.rb +6 -2
  153. data/lib/samagotchi/worker.rb +10 -2
  154. metadata +20 -20
@@ -65,13 +65,13 @@ module Samagotchi
65
65
  next if basename.empty?
66
66
 
67
67
  meta = ->(key) { raw_meta && (raw_meta[key] || raw_meta[key.to_s]) }
68
- event = meta.(:event).to_s
68
+ event = meta.call(:event).to_s
69
69
  # A hook with no declared event cannot be auto-registered.
70
70
  next if event.empty?
71
71
 
72
72
  event_sym = event.to_sym
73
- on_error = (meta.(:on_error) || "skip").to_s
74
- priority = (meta.(:priority) || 100).to_i
73
+ on_error = (meta.call(:on_error) || "skip").to_s
74
+ priority = (meta.call(:priority) || 100).to_i
75
75
  fail_closed = on_error == "fail_closed" && event_sym == :before_tool_call
76
76
  what = "hook #{basename} (bundle #{bundle_name})"
77
77
  file = hooks_dir && File.join(hooks_dir, basename)
@@ -79,7 +79,7 @@ module Samagotchi
79
79
  failures&.add(what, "the file is missing", required: true) if fail_closed
80
80
  next
81
81
  end
82
- if (mismatch = too_old || sha_mismatch(file, meta.(:sha256), required: fail_closed))
82
+ if (mismatch = too_old || sha_mismatch(file, meta.call(:sha256), required: fail_closed))
83
83
  Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
84
84
  failures&.add(what, mismatch, required: fail_closed)
85
85
  next
@@ -91,12 +91,14 @@ module Samagotchi
91
91
  elsif on_error == "log" then :log
92
92
  else :skip
93
93
  end
94
- label = "#{fail_closed ? "guardrail " : ""}#{basename} (bundle #{bundle_name})"
94
+ label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
95
95
  handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
96
96
  fields: { bundle: bundle_name }) { |event| plugin.call(event) }
97
97
  registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: priority, &handler)
98
98
  loaded += 1
99
- rescue Exception => e
99
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
100
+ raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
101
+
100
102
  Log.error(:hooks, "bundle_hook_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' failed to load: #{e.class}: #{e.message}", bundle: bundle_name, hook: basename.to_s, error: e.class.name)
101
103
  failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed)
102
104
  end
@@ -127,6 +129,7 @@ module Samagotchi
127
129
  klass = Hooks.class_for(basename, ns)
128
130
  instance = Hooks.build_plugin(klass, settings)
129
131
  raise ArgumentError, "plugin #{klass.name.split("::").last} does not respond to #call" unless instance.respond_to?(:call)
132
+
130
133
  instance
131
134
  end
132
135
 
@@ -67,17 +67,15 @@ module Samagotchi
67
67
  definitions = parse_definitions(hooks_config)
68
68
 
69
69
  definitions.each do |defn|
70
- begin
71
- plugin = load_plugin(hooks_dir, defn[:path], settings: defn[:settings])
72
- # Persistent: config hooks must fire on every turn, not be wiped
73
- # by Engine#run_turn's per-turn clear_hooks after turn 1.
74
- registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)",
75
- &wrapped(plugin, defn))
76
- rescue ScriptError, StandardError => e
77
- # ScriptError: a SyntaxError (or LoadError) from `require`.
78
- Log.error(:hooks, "hook_load_failed", echo: "[samagotchi:hooks] hook #{defn[:path]} failed to load: #{e.class}: #{e.message}", hook: defn[:path].to_s, error: e.class.name)
79
- failures&.add("hook #{defn[:path]} (config)", "#{e.class}: #{e.message}", required: defn[:required])
80
- end
70
+ plugin = load_plugin(hooks_dir, defn[:path], settings: defn[:settings])
71
+ # Persistent: config hooks must fire on every turn, not be wiped
72
+ # by Engine#run_turn's per-turn clear_hooks after turn 1.
73
+ registry.register_persistent(defn[:event_type].to_sym, label: "#{defn[:path]} (config)",
74
+ &wrapped(plugin, defn))
75
+ rescue ScriptError, StandardError => e
76
+ # ScriptError: a SyntaxError (or LoadError) from `require`.
77
+ Log.error(:hooks, "hook_load_failed", echo: "[samagotchi:hooks] hook #{defn[:path]} failed to load: #{e.class}: #{e.message}", hook: defn[:path].to_s, error: e.class.name)
78
+ failures&.add("hook #{defn[:path]} (config)", "#{e.class}: #{e.message}", required: defn[:required])
81
79
  end
82
80
 
83
81
  registry
@@ -87,6 +85,7 @@ module Samagotchi
87
85
  # Expand a path that may start with ~.
88
86
  def self.expand_path(path, env = ENV)
89
87
  return path unless path.start_with?("~")
88
+
90
89
  home = env["HOME"] || Dir.home
91
90
  File.join(home, path[1..])
92
91
  end
@@ -97,8 +96,10 @@ module Samagotchi
97
96
  definitions = []
98
97
  hooks_config.each do |event_type, configs|
99
98
  next unless event_type.to_s != "hooks_dir" && configs.is_a?(Array)
99
+
100
100
  configs.each do |cfg|
101
101
  next unless cfg.is_a?(Hash) && cfg["path"]
102
+
102
103
  definitions << {
103
104
  event_type: event_type.to_s,
104
105
  path: cfg["path"],
@@ -123,9 +124,7 @@ module Samagotchi
123
124
 
124
125
  # Check if already loaded (Ruby's require caching handles this)
125
126
  # We cache the instance separately to avoid re-instantiating
126
- unless @plugin_cache
127
- @plugin_cache = {}
128
- end
127
+ @plugin_cache ||= {}
129
128
 
130
129
  @plugin_cache[[full_path, settings]] ||= begin
131
130
  require full_path
@@ -133,6 +132,7 @@ module Samagotchi
133
132
  instance = Hooks.build_plugin(klass, settings)
134
133
  # Validate that the instance responds to #call
135
134
  raise ArgumentError, "Plugin #{klass.name} does not respond to #call" unless instance.respond_to?(:call)
135
+
136
136
  instance
137
137
  end
138
138
  end
@@ -142,9 +142,11 @@ module Samagotchi
142
142
  # on_error: log warns and skip is silent.
143
143
  def self.wrapped(plugin, defn)
144
144
  if defn[:required] && defn[:event_type] == "before_tool_call"
145
- policy, label = :deny, "required hook #{defn[:path]}"
145
+ policy = :deny
146
+ label = "required hook #{defn[:path]}"
146
147
  else
147
- policy, label = (defn[:on_error] == "log" ? :log : :skip), "#{defn[:path]} (config)"
148
+ policy = (defn[:on_error] == "log" ? :log : :skip)
149
+ label = "#{defn[:path]} (config)"
148
150
  end
149
151
  Hooks.wrap(label: label, event: defn[:event_type].to_sym, policy: policy) { |event| plugin.call(event) }
150
152
  end
@@ -13,7 +13,11 @@ module Samagotchi
13
13
  # @param settings [Hash]
14
14
  def self.build_plugin(klass, settings)
15
15
  takes_settings = klass.instance_method(:initialize).arity != 0
16
- takes_settings ? klass.new(settings.is_a?(Hash) ? settings : {}) : klass.new
16
+ if takes_settings
17
+ klass.new(settings.is_a?(Hash) ? settings : {})
18
+ else
19
+ klass.new
20
+ end
17
21
  end
18
22
 
19
23
  # A stream hook (:generation_progress) fires ~once a second: a broken
@@ -62,7 +66,7 @@ module Samagotchi
62
66
  def self.wrap(label:, event:, policy:, log: [:hooks, "hook_failed"], echo: true, fields: {}, &block)
63
67
  log_failure = failure_log_gate(event)
64
68
  lambda do |payload|
65
- block.call(payload)
69
+ yield(payload)
66
70
  rescue Exception => e # rubocop:disable Lint/RescueException -- :deny fails closed on anything
67
71
  raise unless policy == :deny || e.is_a?(StandardError) || e.is_a?(ScriptError)
68
72
 
@@ -130,6 +134,7 @@ module Samagotchi
130
134
  def register(name, &block)
131
135
  raise ArgumentError, "hook name must be a Symbol" unless name.is_a?(Symbol)
132
136
  raise ArgumentError, "hook block is required" unless block_given?
137
+
133
138
  @mutex.synchronize { (@hooks[name] ||= []) << block }
134
139
  end
135
140
 
@@ -144,6 +149,7 @@ module Samagotchi
144
149
  def register_persistent(name, label: nil, &block)
145
150
  raise ArgumentError, "hook name must be a Symbol" unless name.is_a?(Symbol)
146
151
  raise ArgumentError, "hook block is required" unless block_given?
152
+
147
153
  label = label.to_s.strip
148
154
  label = CONFIG_HOOK_LABEL if label.empty?
149
155
  @mutex.synchronize { (@persistent_hooks[name] ||= []) << { label: label, proc: block } }
@@ -160,6 +166,7 @@ module Samagotchi
160
166
  # @return [void]
161
167
  def register_bundle(bundle_name, event_name, hook_name:, priority: 100, &block)
162
168
  raise ArgumentError, "hook block is required" unless block_given?
169
+
163
170
  @mutex.synchronize do
164
171
  (@bundle_hooks[event_name] ||= []) << {
165
172
  bundle: bundle_name,
@@ -180,6 +187,7 @@ module Samagotchi
180
187
  if event_name
181
188
  arr = @bundle_hooks[event_name]
182
189
  return 0 unless arr
190
+
183
191
  before = arr.size
184
192
  @bundle_hooks[event_name] = arr.reject { |h| h[:bundle] == bundle_name }
185
193
  removed = before - @bundle_hooks[event_name].size
@@ -118,9 +118,7 @@ module Samagotchi
118
118
  @model_index = nil # downcased model_id => [host_name] in hosts: order
119
119
  end
120
120
 
121
- def entries
122
- @entries
123
- end
121
+ attr_reader :entries
124
122
 
125
123
  # Loopback, private (RFC 1918, IPv6 unique local) and link-local nets.
126
124
  LOCAL_NETS = %w[127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10
@@ -46,7 +46,7 @@ module Samagotchi
46
46
  module TranscriptFilter
47
47
  # A tool call as the model wrote it inline: gemma, qwen, or the qwen
48
48
  # prompt-literal form. An unterminated gemma call runs to the end.
49
- TOOL_CALL_RE = /<\|tool_call>(?:.*?<tool_call\|>|.*\z)|<tool_call>.*?<\/tool_call>|\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN\]\].*?\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE\]\]/m
49
+ TOOL_CALL_RE = %r{<\|tool_call>(?:.*?<tool_call\|>|.*\z)|<tool_call>.*?</tool_call>|\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN\]\].*?\[\[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE\]\]}m
50
50
  # Each dispatched call's output starts with "[name]"; the kernel loop
51
51
  # joins one step's outputs into a single tool_response with this
52
52
  # separator, while the chat loop writes one message per call.
@@ -215,7 +215,7 @@ module Samagotchi
215
215
  system = format(SYSTEM, range) + (previous ? UPDATE + format(UPDATE_LENGTH, range) : "") + tools
216
216
  user = +""
217
217
  user << "Earlier recap:\n#{previous}\n\n" if previous
218
- user << "Transcript#{previous ? ' since the earlier recap' : ''} (the system prompt and tool " \
218
+ user << "Transcript#{" since the earlier recap" if previous} (the system prompt and tool " \
219
219
  "internals were removed; only user turns and assistant prose remain):\n---\n#{body}\n---\n"
220
220
  user << "#{OFFER_LINE}\n" if offer
221
221
  user << "Write the recap now."
@@ -225,7 +225,7 @@ module Samagotchi
225
225
  # "2-4 sentences", or "3 sentences" for [3, 3] ("1 sentence" for [1, 1])
226
226
  def sentences_text((min, max), adjective = "")
227
227
  count = min == max ? min.to_s : "#{min}-#{max}"
228
- "#{count} #{adjective}#{max == 1 ? 'sentence' : 'sentences'}"
228
+ "#{count} #{adjective}#{max == 1 ? "sentence" : "sentences"}"
229
229
  end
230
230
 
231
231
  # The tail of +body+ when it is over MAX_NEW_CHARS, from a paragraph
@@ -243,7 +243,7 @@ module Samagotchi
243
243
  def tools_used(names)
244
244
  return "" if names.empty?
245
245
 
246
- " (#{names.tally.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(', ')})"
246
+ " (#{names.tally.map { |name, n| n > 1 ? "#{name} x#{n}" : name }.join(", ")})"
247
247
  end
248
248
  end
249
249
 
@@ -395,6 +395,17 @@ module Samagotchi
395
395
  @engine.activity_seq > @last_fire_activity_seq
396
396
  end
397
397
 
398
+ # SHA1 of one message's role and text: detects a rewrite a count alone
399
+ # misses. Model text is taken without its thinking, which is dropped from
400
+ # older model messages when the next turn starts.
401
+ def self.digest(message)
402
+ return nil unless message.is_a?(Hash)
403
+
404
+ text = message["content"].to_s
405
+ text = TranscriptFilter.strip_thought(text) if %w[model assistant].include?(message["role"])
406
+ Digest::SHA1.hexdigest("#{message["role"]}\0#{text}")
407
+ end
408
+
398
409
  private
399
410
 
400
411
  # A failing check writes the recap without the offer line.
@@ -431,9 +442,11 @@ module Samagotchi
431
442
  # Nothing new said (only notes, tool traffic, or no messages at all):
432
443
  # the recap still stands, so no request.
433
444
  return :nothing_new if transcript.strip.empty?
445
+
434
446
  prompt = RecapPrompt.build(transcript, tool_names: TranscriptFilter.tool_names(fresh), previous: previous&.dig(:text),
435
447
  sentences: @sentences, offer: offer_open?)
436
448
  return :nothing_new if prompt.nil?
449
+
437
450
  asked = target
438
451
  @in_flight = { thread: spawn_summarize(client_for(asked), prompt), generation: gen, deadline: @clock.call + @timeout,
439
452
  covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked[:label] }
@@ -459,6 +472,7 @@ module Samagotchi
459
472
  @in_flight = nil
460
473
  recap = safe_value(job[:thread])
461
474
  return if recap.nil? || recap.to_s.strip.empty?
475
+
462
476
  # An IdleClient::Summary names the model that answered; a plain String
463
477
  # (a stubbed client) doesn't, and the asked label stands in.
464
478
  served = recap.model if recap.respond_to?(:model)
@@ -503,17 +517,6 @@ module Samagotchi
503
517
  saved
504
518
  end
505
519
 
506
- # SHA1 of one message's role and text: detects a rewrite a count alone
507
- # misses. Model text is taken without its thinking, which is dropped from
508
- # older model messages when the next turn starts.
509
- def self.digest(message)
510
- return nil unless message.is_a?(Hash)
511
-
512
- text = message["content"].to_s
513
- text = TranscriptFilter.strip_thought(text) if %w[model assistant].include?(message["role"])
514
- Digest::SHA1.hexdigest("#{message['role']}\0#{text}")
515
- end
516
-
517
520
  def bump_generation
518
521
  @mutex.synchronize { @generation += 1 }
519
522
  end
@@ -46,6 +46,7 @@ module Samagotchi
46
46
  def should_check?
47
47
  return false if @engine.turn_running?
48
48
  return false unless last_idle_seconds >= @inactivity
49
+
49
50
  true
50
51
  end
51
52
 
@@ -57,15 +58,17 @@ module Samagotchi
57
58
 
58
59
  def check_due_reminders
59
60
  return unless @queue
61
+
60
62
  due_names = @queue.due.map { |r| r[:name] }
61
63
  return if due_names.empty?
64
+
62
65
  # Signal the engine to create a synthetic turn via callback.
63
66
  # The callback is responsible for triggering a turn (e.g. SessionManager
64
67
  # writes a file, TerminalUI queues input). The engine's run_turn or
65
68
  # REPL injection point then picks up and delivers the reminders.
66
- if @auto_turn_callback
67
- @auto_turn_callback.call(due_names)
68
- end
69
+ return unless @auto_turn_callback
70
+
71
+ @auto_turn_callback.call(due_names)
69
72
  end
70
73
  end
71
74
  end
@@ -100,7 +100,8 @@ module Samagotchi
100
100
  # Downscales and converts images with whatever tool the machine has:
101
101
  # macOS sips first, then ImageMagick. `none` when neither is installed.
102
102
  class ImageResizer
103
- Error = Class.new(StandardError)
103
+ class Error < StandardError
104
+ end
104
105
 
105
106
  attr_reader :tool
106
107
 
@@ -191,7 +192,8 @@ module Samagotchi
191
192
  # Messages hold small refs to those files; the base64 is built only when
192
193
  # a request is sent.
193
194
  module ImageStore
194
- Error = Class.new(StandardError)
195
+ class Error < StandardError
196
+ end
195
197
 
196
198
  DIR = "images"
197
199
  REF_RE = %r{\Aimages/[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z}
@@ -234,6 +236,7 @@ module Samagotchi
234
236
 
235
237
  name = (name || (path && File.basename(path)) || "image").to_s
236
238
  raise Error, "#{name} is too large (over 50 MB)" if bytes.bytesize > MAX_SOURCE_BYTES
239
+
237
240
  info = ImageHeader.read(bytes)
238
241
  raise Error, "#{name} is not an image chi can send (png, jpeg, gif, webp)" unless info
239
242
 
@@ -134,8 +134,7 @@ module Samagotchi
134
134
  # @return [Tools::Registry] the tools #dispatch runs. The Engine sets
135
135
  # its own on a kernel it is given, as with hooks.
136
136
  attr_accessor :tools
137
- attr_accessor :client
138
- attr_accessor :model_key
137
+ attr_accessor :client, :model_key
139
138
  # @return [String, nil] the key whose overlay memory_read takes when
140
139
  # #model_key has none (#sync_model_key!)
141
140
  attr_reader :model_key_fallback
@@ -210,6 +209,18 @@ module Samagotchi
210
209
  @model_key_fallback = fallback
211
210
  end
212
211
 
212
+ # Resolve the per-output character cap for the emitted tool call events.
213
+ #
214
+ # Precedence: an explicit override wins, then max_tool_output_chars
215
+ # (Config). A non-positive value falls back to
216
+ # DEFAULT_MAX_TOOL_OUTPUT_CHARS (there is intentionally no "unlimited" — live UIs get a
217
+ # bounded `output:` plus a truthful `output_truncated:` flag).
218
+ # Class-level so the chat loop resolves it the same way.
219
+ def self.resolve_output_char_cap(override)
220
+ parsed = (override || Samagotchi::Config.get("max_tool_output_chars")).to_i
221
+ parsed.positive? ? parsed : DEFAULT_MAX_TOOL_OUTPUT_CHARS
222
+ end
223
+
213
224
  private
214
225
 
215
226
  # ── One turn (#run) ────────────────────────────────────────────────────────
@@ -223,7 +234,7 @@ module Samagotchi
223
234
  :cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, keyword_init: true)
224
235
  # One request: the prompt and its images as sent, the images' token
225
236
  # estimate, and the window it was measured against.
226
- Request = Struct.new(:prompt, :images, :image_tokens, :window, keyword_init: true)
237
+ Request = Struct.new(:prompt, :images, :image_tokens, :window, :prefill, keyword_init: true)
227
238
  # One generation: the response (nil when cut), the cut's detail, this
228
239
  # generation's own server counts, the model the server named, the
229
240
  # thinking it streamed, and why it stopped (the transport's finish
@@ -254,7 +265,7 @@ module Samagotchi
254
265
  generation = generate(turn, request)
255
266
  return after_cut(turn, generation) if generation.cut
256
267
 
257
- turn.conversation << { role: "model", content: turn.prefill + generation.response.to_s }
268
+ turn.conversation << { role: "model", content: request.prefill + generation.response.to_s }
258
269
  # Profile-specific parse (incl. Qwen unterminated-block recovery); the
259
270
  # returned fragment (non-nil only for Qwen) is fed back on the next
260
271
  # iteration if the model opened a tool-call block it did not close.
@@ -279,7 +290,10 @@ module Samagotchi
279
290
  turn.conversation << line
280
291
  prompt, images = format_prompt(turn)
281
292
  end
282
- Request.new(prompt: prompt, images: images, image_tokens: image_tokens, window: window)
293
+ # The prefill the prompt ended with (none where Gemma's turn goes on
294
+ # after a tool response): the model message keeps it.
295
+ prefill = Prompt.prefill_for(turn.conversation, @profile, turn.prefill)
296
+ Request.new(prompt: prompt, images: images, image_tokens: image_tokens, window: window, prefill: prefill)
283
297
  end
284
298
 
285
299
  def format_prompt(turn)
@@ -379,7 +393,7 @@ module Samagotchi
379
393
  # after the cut is a plain cancel. Returns :next or the turn's result.
380
394
  def after_cut(turn, generation)
381
395
  cut = generation.cut
382
- raise Client::RequestCancelled.new(turn.cancel_controller.reason) if turn.cancel_controller.cancelled?
396
+ raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller.cancelled?
383
397
 
384
398
  if turn.empty_retry.left?
385
399
  return :next if inject_pending_input!(turn)
@@ -493,7 +507,6 @@ module Samagotchi
493
507
  nil
494
508
  end
495
509
 
496
-
497
510
  # ── Hook dispatch helper ───────────────────────────────────────────────────
498
511
 
499
512
  # Fire a named hook on the registry (if present).
@@ -505,6 +518,7 @@ module Samagotchi
505
518
 
506
519
  def fire_hook(name, event)
507
520
  return unless @hooks
521
+
508
522
  @hooks.fire(name, event)
509
523
  rescue StandardError
510
524
  # A failing hook must not break the turn.
@@ -517,34 +531,22 @@ module Samagotchi
517
531
 
518
532
  # ── Output char cap resolution ─────────────────────────────────────────────
519
533
 
520
- # Resolve the per-output character cap for the emitted tool call events.
521
- #
522
- # Precedence: an explicit override wins, then max_tool_output_chars
523
- # (Config). A non-positive value falls back to
524
- # DEFAULT_MAX_TOOL_OUTPUT_CHARS (there is intentionally no "unlimited" — live UIs get a
525
- # bounded `output:` plus a truthful `output_truncated:` flag).
526
- # Class-level so the chat loop resolves it the same way.
527
- def self.resolve_output_char_cap(override)
528
- parsed = (override || Samagotchi::Config.get("max_tool_output_chars")).to_i
529
- parsed.positive? ? parsed : DEFAULT_MAX_TOOL_OUTPUT_CHARS
530
- end
531
-
532
534
  def resolve_output_char_cap(override)
533
535
  self.class.resolve_output_char_cap(override)
534
536
  end
535
537
 
536
538
  # One request, cancelled by +generation_controller+ (the turn's child)
537
539
  # when there is one.
538
- def request_generation(prompt, generation_controller:, cancel_controller:, **options)
539
- @client.complete(prompt, **complete_kwargs(cancel_controller: generation_controller || cancel_controller, **options))
540
+ def request_generation(prompt, generation_controller:, cancel_controller:, **)
541
+ @client.complete(prompt, **complete_kwargs(cancel_controller: generation_controller || cancel_controller, **))
540
542
  end
541
543
 
542
544
  # Yields the turn controller's child for one generation (nil without a
543
545
  # controller).
544
- def with_generation(cancel_controller, &block)
546
+ def with_generation(cancel_controller, &)
545
547
  return yield(nil) unless cancel_controller
546
548
 
547
- cancel_controller.generation(&block)
549
+ cancel_controller.generation(&)
548
550
  end
549
551
 
550
552
  # The generation was cut (stop_generation) and the turn goes on.
@@ -561,19 +563,14 @@ module Samagotchi
561
563
  kwargs[:on_retry] = on_retry if on_retry
562
564
  kwargs[:cancel_controller] = cancel_controller if cancel_controller
563
565
  kwargs[:stop] = @profile.stop_sequences
564
- n_predict = completion_n_predict
565
- kwargs[:n_predict] = n_predict if n_predict
566
+ max_tokens = Samagotchi::Config.max_tokens
567
+ kwargs[:n_predict] = max_tokens if max_tokens
566
568
  resolved_model_name = completion_model_name(model_name)
567
569
  kwargs[:model] = resolved_model_name if resolved_model_name
568
570
  kwargs[:sampling] = sampling if sampling && !sampling.empty?
569
571
  kwargs
570
572
  end
571
573
 
572
- def completion_n_predict
573
- value = Samagotchi::Config.get("default.n_predict").to_i
574
- value if value.positive?
575
- end
576
-
577
574
  def completion_model_name(override = nil)
578
575
  ModelProfile.required_model_name(override)
579
576
  end
@@ -635,6 +632,7 @@ module Samagotchi
635
632
  @parser_profile = @profile
636
633
  @parser
637
634
  end
635
+
638
636
  private
639
637
 
640
638
  # The generation's thinking: what the stream split into the thinking
@@ -682,6 +680,7 @@ module Samagotchi
682
680
  end
683
681
 
684
682
  public
683
+
685
684
  # Public entry point for executing an ALREADY-NORMALIZED internal tool call
686
685
  # (the {name:, content:, path:, scope:, …} shape).
687
686
  #
@@ -696,6 +695,7 @@ module Samagotchi
696
695
  end
697
696
 
698
697
  private
698
+
699
699
  def dispatch(call)
700
700
  entry = @tools[call[:name]]
701
701
  unless entry
@@ -723,7 +723,7 @@ module Samagotchi
723
723
  dispatched[:image_only] = true if result.respond_to?(:image_only?) && result.image_only?
724
724
  end
725
725
  dispatched
726
- rescue => e
726
+ rescue StandardError => e
727
727
  dump_log("tool_error", e.message, tool: call[:name], error: e.class.name)
728
728
  result = "Error: #{e.message}"
729
729
  {
@@ -155,12 +155,15 @@ module Samagotchi
155
155
  (@thinking_refused ||= Set.new) << model
156
156
  end
157
157
 
158
- # One generation's options: the thinking fields under the sampling
159
- # (a sampling key wins, chat_template_kwargs merges per sub-key), the
160
- # empty-answer retry's temperature on top, then every null dropped at
161
- # any depth (a sampling null means "don't send it").
158
+ # One generation's options: default.max_tokens as max_tokens (the
159
+ # adapter's own default, OpenRouter's, otherwise), the thinking fields
160
+ # under the sampling (a sampling key wins, chat_template_kwargs merges
161
+ # per sub-key), the empty-answer retry's temperature on top, then every
162
+ # null dropped at any depth (a sampling null means "don't send it").
162
163
  def request_options(retry_generation: false, model: nil)
164
+ max_tokens = Samagotchi::Config.max_tokens
163
165
  options = deep_merge(thinking_fields(model), sampling)
166
+ options = { max_tokens: max_tokens }.merge(options) if max_tokens
164
167
  options = EmptyAnswerRetry.sampling(options) if retry_generation
165
168
  deep_compact(options)
166
169
  end
@@ -449,10 +452,10 @@ module Samagotchi
449
452
 
450
453
  # The request runs under the generation's own controller (the turn
451
454
  # controller's child), so a plugin's stop_generation cuts it alone.
452
- def with_generation(&block)
455
+ def with_generation(&)
453
456
  return yield(nil) unless @cancel_controller
454
457
 
455
- @cancel_controller.generation(&block)
458
+ @cancel_controller.generation(&)
456
459
  end
457
460
 
458
461
  # The empty response of a cut generation: its spinners close as any
@@ -125,12 +125,12 @@ module Samagotchi
125
125
 
126
126
  # @param hint [String, nil] what to try, added to the summary
127
127
  def initialize(message = nil, context_overflow: false, tools_unsupported: false, reasoning_refused: false, hint: nil,
128
- **options)
128
+ **)
129
129
  @context_overflow = context_overflow
130
130
  @tools_unsupported = tools_unsupported
131
131
  @reasoning_refused = reasoning_refused
132
132
  @hint = hint
133
- super(message, **options)
133
+ super(message, **)
134
134
  end
135
135
 
136
136
  attr_reader :hint
@@ -138,7 +138,7 @@ module Samagotchi
138
138
  end
139
139
  end
140
140
 
141
- def stream_attempt(uri, request, current, &on_line)
141
+ def stream_attempt(uri, request, current, &)
142
142
  # Time to first token is the answering attempt's, not the retries'.
143
143
  current[:attempt_started_at] = monotonic_now
144
144
  shown = lambda do
@@ -155,11 +155,11 @@ module Samagotchi
155
155
  response.read_body do |chunk|
156
156
  buffer << chunk
157
157
  while (newline_index = buffer.index("\n"))
158
- on_line.call(buffer.slice!(0, newline_index + 1).strip, shown)
158
+ yield(buffer.slice!(0, newline_index + 1).strip, shown)
159
159
  end
160
160
  end
161
161
  # A body that doesn't end in a newline still has a last line.
162
- on_line.call(buffer.strip, shown) unless buffer.strip.empty?
162
+ yield(buffer.strip, shown) unless buffer.strip.empty?
163
163
  end
164
164
  end
165
165
  end
@@ -192,7 +192,7 @@ module Samagotchi
192
192
  cancellable(cancel_controller, current) do
193
193
  attempt.call(current.merge!(attempts: 1))
194
194
  rescue StandardError => e
195
- raise RequestCancelled.new(cancel_controller.reason) if !e.is_a?(RequestCancelled) && cancel_controller&.cancelled?
195
+ raise RequestCancelled, cancel_controller.reason if !e.is_a?(RequestCancelled) && cancel_controller&.cancelled?
196
196
 
197
197
  raise
198
198
  end
@@ -208,7 +208,7 @@ module Samagotchi
208
208
  @api_key&.authorize(request)
209
209
  end
210
210
 
211
- def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
211
+ def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &)
212
212
  options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
213
213
  # Net::HTTP retries an idempotent request once on its own (max_retries
214
214
  # defaults to 1) when the connection drops before an answer: chi's
@@ -218,15 +218,15 @@ module Samagotchi
218
218
  retries = max_retries || 0
219
219
  options[:max_retries] = retries
220
220
  options[:use_ssl] = true if uri.scheme == "https"
221
- Net::HTTP.start(uri.host, uri.port, **options, &block)
221
+ Net::HTTP.start(uri.host, uri.port, **options, &)
222
222
  end
223
223
 
224
224
  # Runs the block (one attempt) until it returns, retrying network
225
225
  # errors. The block gets a hash to put the attempt's Net::HTTP in, so a
226
226
  # cancel can reach its socket.
227
- def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new })
227
+ def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new }, &block)
228
228
  cancellable(cancel_controller, current) do
229
- retrying(cancel_controller, on_retry, on_network_error, current) { |state| yield(state) }
229
+ retrying(cancel_controller, on_retry, on_network_error, current, &block)
230
230
  end
231
231
  end
232
232
 
@@ -236,7 +236,7 @@ module Samagotchi
236
236
  def cancellable(cancel_controller, current)
237
237
  requesting_thread = Thread.current
238
238
  listener_id = cancel_controller&.on_cancel { |reason| abort_request(current, requesting_thread, reason) }
239
- raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
239
+ raise RequestCancelled, cancel_controller.reason if cancel_controller&.cancelled?
240
240
 
241
241
  yield
242
242
  ensure
@@ -257,7 +257,7 @@ module Samagotchi
257
257
  rescue RequestCancelled
258
258
  raise
259
259
  rescue ProviderError => e
260
- raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
260
+ raise RequestCancelled, cancel_controller.reason if cancel_controller&.cancelled?
261
261
 
262
262
  e.attempts = attempts
263
263
  raise if !e.retryable? || current[:streamed]
@@ -267,7 +267,7 @@ module Samagotchi
267
267
 
268
268
  retry_after(e, attempts, delay, on_retry, cancel_controller, current)
269
269
  rescue StandardError => e
270
- raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
270
+ raise RequestCancelled, cancel_controller.reason if cancel_controller&.cancelled?
271
271
  raise first_token_timeout if current[:first_token_expired]
272
272
  raise unless self.class.network_error?(e)
273
273
 
@@ -432,7 +432,7 @@ module Samagotchi
432
432
 
433
433
  remaining = seconds
434
434
  while remaining.positive?
435
- raise RequestCancelled.new(cancel_controller.reason) if cancel_controller.cancelled?
435
+ raise RequestCancelled, cancel_controller.reason if cancel_controller.cancelled?
436
436
 
437
437
  slice = [remaining, WAIT_TICK].min
438
438
  @sleeper.call(slice)