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
@@ -15,7 +15,7 @@ module Samagotchi
15
15
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
16
16
  status = activity[:status].to_s
17
17
  elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
18
- "#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color(status))}#{elapsed_suffix}"
18
+ "#{paint("tool>", 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color(status))}#{elapsed_suffix}"
19
19
  end
20
20
 
21
21
  # Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
@@ -45,6 +45,15 @@ module Samagotchi
45
45
  paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
46
46
  end
47
47
 
48
+ # "↪ cut in for your message": a message for the running turn cut a
49
+ # generation that was only thinking (Engine#cut_for_steer); an empty
50
+ # or unknown source is the user's.
51
+ STEER_CUT_FOR = { "chi_send" => "a message sent with chi send", "parent_agent" => "the parent agent's message" }.freeze
52
+
53
+ def format_steer_cut_line(event)
54
+ paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
55
+ end
56
+
48
57
  # "no answer: the model returned nothing (after 1 retry)", dim like the
49
58
  # retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
50
59
  def format_empty_answer_line(retries)
@@ -75,7 +84,7 @@ module Samagotchi
75
84
  return "" unless diff.is_a?(Hash)
76
85
 
77
86
  get = ->(key) { diff[key] || diff[key.to_s] }
78
- " #{paint("+#{get.(:added).to_i}", 32)} #{paint("\u2212#{get.(:removed).to_i}", 31)}"
87
+ " #{paint("+#{get.call(:added).to_i}", 32)} #{paint("\u2212#{get.call(:removed).to_i}", 31)}"
79
88
  end
80
89
 
81
90
  # Prompt labels by the sender's client_id prefix (turn_events.js
@@ -113,7 +122,7 @@ module Samagotchi
113
122
  params = part[:params].to_s.strip
114
123
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
115
124
  status = part[:status].to_s
116
- "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
125
+ "#{paint("tool>", 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
117
126
  "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
118
127
  end
119
128
 
@@ -233,8 +242,8 @@ module Samagotchi
233
242
  value = tps.to_f
234
243
  return "" unless value.positive?
235
244
 
236
- number = value >= 1000 ? "#{format('%.1f', (value / 100).round / 10.0)}k" : value.round.to_s
237
- "#{'~' if source.to_s == 'estimate'}#{number} tok/s"
245
+ number = value >= 1000 ? "#{format("%.1f", (value / 100).round / 10.0)}k" : value.round.to_s
246
+ "#{"~" if source.to_s == "estimate"}#{number} tok/s"
238
247
  end
239
248
 
240
249
  # "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "" for
@@ -297,7 +306,7 @@ module Samagotchi
297
306
  def status_rows(segments, width)
298
307
  return [] if segments.empty? || width <= 0
299
308
 
300
- row = cap_preview_text("status> #{segments.join(' | ')}", width)
309
+ row = cap_preview_text("status> #{segments.join(" | ")}", width)
301
310
  [color_output? ? paint(row, 90) : row]
302
311
  end
303
312
 
@@ -336,7 +345,7 @@ module Samagotchi
336
345
 
337
346
  visible = names.first(limit)
338
347
  suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
339
- "#{label}: #{visible.join(', ')}#{suffix}"
348
+ "#{label}: #{visible.join(", ")}#{suffix}"
340
349
  end
341
350
 
342
351
  def cap_preview_text(text, width)
@@ -411,8 +420,8 @@ module Samagotchi
411
420
  return nil unless last
412
421
 
413
422
  source = tokens[:tps_source].to_s
414
- text = +"#{speed_text(last, source)} out"
415
- text << ", #{speed_text(tokens[:last_prefill_tps], 'server')} prompt" if tokens[:last_prefill_tps]
423
+ text = "#{speed_text(last, source)} out"
424
+ text << ", #{speed_text(tokens[:last_prefill_tps], "server")} prompt" if tokens[:last_prefill_tps]
416
425
  text << " (last, #{source})"
417
426
  text << ", avg #{speed_text(tokens[:avg_decode_tps], source)}" if tokens[:avg_decode_tps]
418
427
  text
@@ -428,7 +437,7 @@ module Samagotchi
428
437
  return "#{seconds.round}s" if seconds < 60
429
438
 
430
439
  total_seconds = seconds.round
431
- "#{total_seconds / 60}m #{format('%02d', total_seconds % 60)}s"
440
+ "#{total_seconds / 60}m #{format("%02d", total_seconds % 60)}s"
432
441
  end
433
442
 
434
443
  def paint(text, code)
@@ -98,8 +98,8 @@ module Samagotchi
98
98
 
99
99
  begin
100
100
  buf = Reline.line_buffer.to_s
101
- unless buf.empty?
102
- return [] unless buf.lstrip.start_with?("/")
101
+ if !buf.empty? && !buf.lstrip.start_with?("/")
102
+ return []
103
103
  end
104
104
  rescue StandardError
105
105
  nil
@@ -39,7 +39,7 @@ module Samagotchi
39
39
  end
40
40
 
41
41
  def alive? = @thread&.alive? || false
42
-
42
+
43
43
  # Drop the open read and start it again with the current prompt.
44
44
  # @param keep_text [Boolean] with what is typed there now
45
45
  # @param prefill [String, nil] with this text instead
@@ -48,7 +48,7 @@ module Samagotchi
48
48
  @prefill = text unless text.to_s.empty?
49
49
  @thread&.raise(Reprompt)
50
50
  end
51
-
51
+
52
52
  # Type +text+ into the next read (from the sink, between two reads).
53
53
  def prefill_next(text)
54
54
  @prefill = text
@@ -57,7 +57,7 @@ module Samagotchi
57
57
  # @return [String, nil] what the open read holds
58
58
  def typed_text
59
59
  return nil unless $stdin.tty?
60
-
60
+
61
61
  Reline.line_buffer.to_s
62
62
  rescue StandardError
63
63
  nil
@@ -29,6 +29,7 @@ module Samagotchi
29
29
  def call(event)
30
30
  case event[:type]
31
31
  when :empty_answer_retry then line(format_empty_retry_line(event))
32
+ when :steer_cut then line(format_steer_cut_line(event))
32
33
  when :generation_retrying then line(format_generation_retry_line(event))
33
34
  when :turn_completed, :turn_canceled then @turn_over = true
34
35
  when :hook_notice
@@ -33,29 +33,29 @@ module Samagotchi
33
33
  # @param pending [Hash] Engine#pending_question (symbol or string keys)
34
34
  def initialize(pending)
35
35
  field = ->(key) { pending[key] || pending[key.to_s] }
36
- approval = field.(:approval)
36
+ approval = field.call(:approval)
37
37
  @preview = approval.is_a?(Hash) ? (approval[:preview] || approval["preview"]) : nil
38
- @id = field.(:id).to_s
39
- @question = field.(:question).to_s
38
+ @id = field.call(:id).to_s
39
+ @question = field.call(:question).to_s
40
40
  # A delegate's approval relayed here: the delegate's own text is the
41
41
  # question, who asks a dim line (#note).
42
- relay = field.(:relay)
42
+ relay = field.call(:relay)
43
43
  if relay.is_a?(Hash)
44
44
  get = ->(key) { relay[key] || relay[key.to_s] }
45
- chain = Array(get.(:chain)).map(&:to_s)
46
- chain = [get.(:child_id).to_s[0, 8]] if chain.empty?
45
+ chain = Array(get.call(:chain)).map(&:to_s)
46
+ chain = [get.call(:child_id).to_s[0, 8]] if chain.empty?
47
47
  @delegate = chain.reverse.join(" → ")
48
- @delegate_task = get.(:task).to_s
49
- @question = get.(:asked).to_s unless get.(:asked).to_s.strip.empty?
48
+ @delegate_task = get.call(:task).to_s
49
+ @question = get.call(:asked).to_s unless get.call(:asked).to_s.strip.empty?
50
50
  end
51
- self.relayed_to = field.(:relayed_to)
52
- @options = Array(field.(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
53
- header = field.(:header).to_s.strip
51
+ self.relayed_to = field.call(:relayed_to)
52
+ @options = Array(field.call(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
53
+ header = field.call(:header).to_s.strip
54
54
  @header = header.empty? ? nil : header
55
- @multi = !!field.(:multi_select)
56
- @free = !!field.(:allow_freeform)
57
- @approval = field.(:kind).to_s == "approval"
58
- @continue = field.(:kind).to_s == "continue"
55
+ @multi = !!field.call(:multi_select)
56
+ @free = !!field.call(:allow_freeform)
57
+ @approval = field.call(:kind).to_s == "approval"
58
+ @continue = field.call(:kind).to_s == "continue"
59
59
  end
60
60
 
61
61
  def multi? = @multi
@@ -121,19 +121,19 @@ module Samagotchi
121
121
  return [] unless preview.is_a?(Hash)
122
122
 
123
123
  get = ->(key) { preview[key] || preview[key.to_s] }
124
- return [paint.("this edit would fail: #{get.(:error)}", 33)] if get.(:error)
125
- return [paint.("diff not shown: #{get.(:skipped)}", 2)] if get.(:skipped)
124
+ return [paint.call("this edit would fail: #{get.call(:error)}", 33)] if get.call(:error)
125
+ return [paint.call("diff not shown: #{get.call(:skipped)}", 2)] if get.call(:skipped)
126
126
 
127
- lines = get.(:text).to_s.split("\n")
127
+ lines = get.call(:text).to_s.split("\n")
128
128
  return [] if lines.empty?
129
129
 
130
130
  # TextDiff's own cut note ("… N more lines") counts toward the rest.
131
131
  cut = lines.last.to_s[/\A… (\d+) more lines\z/, 1]
132
132
  lines.pop if cut
133
133
  hidden = [lines.size - PREVIEW_LINES, 0].max + cut.to_i
134
- shown = lines.first(PREVIEW_LINES).map { |line| paint.(line, diff_code(line)) }
135
- shown.unshift(paint.("new file", 2)) if get.(:new_file)
136
- shown << paint.("… #{hidden} more lines (full diff on the web)", 2) if hidden.positive?
134
+ shown = lines.first(PREVIEW_LINES).map { |line| paint.call(line, diff_code(line)) }
135
+ shown.unshift(paint.call("new file", 2)) if get.call(:new_file)
136
+ shown << paint.call("… #{hidden} more lines (full diff on the web)", 2) if hidden.positive?
137
137
  shown
138
138
  end
139
139
 
@@ -142,7 +142,7 @@ module Samagotchi
142
142
  # @param outcome [String] the answer (#answer_text) or what closed it
143
143
  def summary(outcome, paint: ->(text, _code) { text })
144
144
  who = @delegate ? "#{@delegate}: " : ""
145
- "#{paint.("#{mark}#{who}#{question.lines.first.to_s.chomp}", approval? ? 33 : 94)} → #{outcome}"
145
+ "#{paint.call("#{mark}#{who}#{question.lines.first.to_s.chomp}", approval? ? 33 : 94)} → #{outcome}"
146
146
  end
147
147
 
148
148
  # @param answer [Answer] an accepted one
@@ -166,7 +166,7 @@ module Samagotchi
166
166
  raw = raw.to_s.strip
167
167
  # "1,3; my text": the first ';' separates the selection from freeform text.
168
168
  sel_part, free_part = raw.include?(";") ? raw.split(";", 2).map(&:strip) : [raw, nil]
169
- free_part = nil if free_part&.empty?
169
+ free_part = nil if free_part && free_part.empty?
170
170
  # Models forget to flag freeform: accept it anyway, with a note.
171
171
  note = free_part && !free? ? "(note: freeform not flagged but accepting '#{free_part}')" : nil
172
172
 
@@ -31,7 +31,7 @@ module Samagotchi
31
31
 
32
32
  # The line a continue offer leaves in the scrollback.
33
33
  def self.continue_summary(outcome, paint: ->(text, _code) { text })
34
- "#{paint.("? #{CONTINUE_QUESTION}", 33)} → #{outcome}"
34
+ "#{paint.call("? #{CONTINUE_QUESTION}", 33)} → #{outcome}"
35
35
  end
36
36
 
37
37
  # @param question [String] the first row's text, wrapped when there is room
@@ -77,14 +77,14 @@ module Samagotchi
77
77
 
78
78
  def layout(width, hint:, details:, header:, folded:)
79
79
  rows = []
80
- rows << @paint.(@header, 1) if header && @header
81
- rows << @paint.(@note, 2) if header && @note
80
+ rows << @paint.call(@header, 1) if header && @header
81
+ rows << @paint.call(@note, 2) if header && @note
82
82
  question_rows = wrap(@question, width, first: @mark, indent: " " * @mark.size)
83
83
  question_rows = question_rows.first(1) unless details
84
- rows.concat(question_rows.map { |row| @paint.(row, @question_code) })
85
- rows.concat(@details.map { |row| @paint.(row, @question_code) }) if details
86
- rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.(" #{o.text}", 92) })
87
- rows << @paint.(" [#{@hint}]", 90) if hint && @hint
84
+ rows.concat(question_rows.map { |row| @paint.call(row, @question_code) })
85
+ rows.concat(@details.map { |row| @paint.call(row, @question_code) }) if details
86
+ rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.call(" #{o.text}", 92) })
87
+ rows << @paint.call(" [#{@hint}]", 90) if hint && @hint
88
88
  rows
89
89
  end
90
90
 
@@ -97,13 +97,13 @@ module Samagotchi
97
97
  candidate = line.empty? ? " #{item}" : "#{line} · #{item}"
98
98
  if !line.empty? && display_width(candidate) > width
99
99
  rows << line
100
- line = +" #{item}"
100
+ line = " #{item}"
101
101
  else
102
102
  line = +candidate
103
103
  end
104
104
  end
105
105
  rows << line unless line.empty?
106
- rows.map { |row| @paint.(row, 92) }
106
+ rows.map { |row| @paint.call(row, 92) }
107
107
  end
108
108
 
109
109
  # Word wrap at +width+ columns, the first row after +first+, the others
@@ -122,7 +122,7 @@ module Samagotchi
122
122
  while display_width(line.rstrip) > width
123
123
  head, = Reline::Unicode.take_mbchar_range(line, 0, width, padding: false)
124
124
  rows << head
125
- line = +"#{indent}#{line[head.size..]}"
125
+ line = "#{indent}#{line[head.size..]}"
126
126
  end
127
127
  end
128
128
  rows << line.rstrip unless line.strip.empty? && !rows.empty?
@@ -216,7 +216,7 @@ module Samagotchi
216
216
  # The prompt and the text as render_finished writes them: one line per
217
217
  # input line, with a trailing space when a line fills the width exactly.
218
218
  def seam_final_lines
219
- @buffer_of_lines.size.times.map do |i|
219
+ Array.new(@buffer_of_lines.size) do |i|
220
220
  line = Reline::Unicode.strip_non_printing_start_end(prompt_list[i]) + modified_lines[i]
221
221
  split_line_by_width(line, screen_width).last.empty? ? "#{line} " : line
222
222
  end
@@ -43,6 +43,7 @@ module Samagotchi
43
43
  def <<(item)
44
44
  @lock.synchronize do
45
45
  next @answers << item if @answers
46
+
46
47
  taken = @turn && item.first == :line && @turn.call(item.last)
47
48
  # Not now: the line goes back into the prompt.
48
49
  next @reader&.prefill_next(item.last) if taken == :back
@@ -130,8 +131,8 @@ module Samagotchi
130
131
  end
131
132
  end
132
133
 
133
- def with_surface_lock(&block)
134
- @surface.respond_to?(:synchronize) ? @surface.synchronize(&block) : yield
134
+ def with_surface_lock(&)
135
+ @surface.respond_to?(:synchronize) ? @surface.synchronize(&) : yield
135
136
  end
136
137
  end
137
138
  end
@@ -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)
@@ -1002,7 +1007,12 @@ module Samagotchi
1002
1007
  # Steering merges text only: a line with images runs as the next turn.
1003
1008
  return image_line_waits if ImageInput.extract(line).any?
1004
1009
 
1010
+ epoch = @engine.input_epoch
1005
1011
  @pending_input_queue.push(line.strip)
1012
+ # The user's line cuts a generation that has streamed only thinking
1013
+ # for long (steer.cut_after), now or once it has: the model starts the
1014
+ # step again with it.
1015
+ @engine.cut_for_steer(nil, epoch: epoch)
1006
1016
  true
1007
1017
  end
1008
1018
 
@@ -1092,7 +1102,6 @@ module Samagotchi
1092
1102
  ConfigFile.recap_config
1093
1103
  end
1094
1104
 
1095
-
1096
1105
  # Reset the Engine's shared inactivity clock when a prompt opens (a
1097
1106
  # Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
1098
1107
  # 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
@@ -46,12 +46,16 @@ module Samagotchi
46
46
  @active = nil
47
47
  end
48
48
 
49
- # Feed one raw chunk. Returns the newly-routed deltas for this chunk only.
50
- # @return [Hash{ text: String, thinking: String }]
49
+ # Feed one raw chunk. Returns the newly-routed deltas for this chunk
50
+ # only, and +tool+: whether the chunk opened, streamed into or closed a
51
+ # tool_call block (its bytes are dropped, but a steer must not cut a
52
+ # generation that writes a tool call: Engine#cut_for_steer).
53
+ # @return [Hash{ text: String, thinking: String, tool: Boolean }]
51
54
  def feed(chunk)
52
55
  @carry << chunk.to_s
53
56
  text = +""
54
57
  thinking = +""
58
+ tool = false
55
59
  loop do
56
60
  if @state == :normal
57
61
  hit = earliest_open
@@ -64,9 +68,11 @@ module Samagotchi
64
68
  @carry = @carry[hit[:pos]..]
65
69
  @active = hit[:block]
66
70
  @state = :in_block
71
+ tool ||= @active[:lane] == :drop
67
72
  # Drop the open marker itself; its body follows.
68
73
  @carry = @carry[@active[:open].length..].to_s
69
74
  else
75
+ tool ||= @active[:lane] == :drop && !@carry.empty?
70
76
  close_pos = @carry.index(@active[:close])
71
77
  if close_pos.nil?
72
78
  flush_in_block(thinking)
@@ -74,12 +80,12 @@ module Samagotchi
74
80
  end
75
81
  body = @carry[0...close_pos]
76
82
  route_body(body, thinking)
77
- @carry = @carry[close_pos + @active[:close].length..].to_s
83
+ @carry = @carry[(close_pos + @active[:close].length)..].to_s
78
84
  @active = nil
79
85
  @state = :normal
80
86
  end
81
87
  end
82
- { text: text, thinking: thinking }
88
+ { text: text, thinking: thinking, tool: tool }
83
89
  end
84
90
 
85
91
  # Flush any residual state at end of stream (e.g. an unterminated thinking
@@ -108,6 +114,7 @@ module Samagotchi
108
114
  @blocks.each do |block|
109
115
  pos = @carry.index(block[:open])
110
116
  next if pos.nil?
117
+
111
118
  best = { pos: pos, block: block } if best.nil? || pos < best[:pos]
112
119
  end
113
120
  best
@@ -118,7 +125,7 @@ module Samagotchi
118
125
  def flush_normal(text)
119
126
  partial = @blocks.map { |block| longest_suffix_prefix_of(@carry, block[:open]) }.max.to_i
120
127
  text << @carry[0...(@carry.length - partial)]
121
- @carry = @carry[@carry.length - partial..].to_s
128
+ @carry = @carry[(@carry.length - partial)..].to_s
122
129
  end
123
130
 
124
131
  # In-block state: emit the body seen so far to the active lane, retaining a
@@ -127,12 +134,13 @@ module Samagotchi
127
134
  close = @active[:close]
128
135
  partial = longest_suffix_prefix_of(@carry, close)
129
136
  route_body(@carry[0...(@carry.length - partial)], thinking)
130
- @carry = @carry[@carry.length - partial..].to_s
137
+ @carry = @carry[(@carry.length - partial)..].to_s
131
138
  end
132
139
 
133
140
  # Route a body fragment to the active block's lane (:thinking or :drop).
134
141
  def route_body(body, thinking)
135
142
  return if body.nil? || body.empty?
143
+
136
144
  thinking << body if @active && @active[:lane] == :thinking
137
145
  end
138
146
 
@@ -141,6 +149,7 @@ module Samagotchi
141
149
  def longest_suffix_prefix_of(str, marker)
142
150
  mlen = marker.length
143
151
  return 0 if mlen.zero? || str.empty?
152
+
144
153
  [str.length, mlen].min.downto(1) do |n|
145
154
  return n if str[-n..] == marker[0, n]
146
155
  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)