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
@@ -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.
@@ -75,7 +75,7 @@ module Samagotchi
75
75
  return "" unless diff.is_a?(Hash)
76
76
 
77
77
  get = ->(key) { diff[key] || diff[key.to_s] }
78
- " #{paint("+#{get.(:added).to_i}", 32)} #{paint("\u2212#{get.(:removed).to_i}", 31)}"
78
+ " #{paint("+#{get.call(:added).to_i}", 32)} #{paint("\u2212#{get.call(:removed).to_i}", 31)}"
79
79
  end
80
80
 
81
81
  # Prompt labels by the sender's client_id prefix (turn_events.js
@@ -113,7 +113,7 @@ module Samagotchi
113
113
  params = part[:params].to_s.strip
114
114
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
115
115
  status = part[:status].to_s
116
- "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
116
+ "#{paint("tool>", 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
117
117
  "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
118
118
  end
119
119
 
@@ -233,8 +233,8 @@ module Samagotchi
233
233
  value = tps.to_f
234
234
  return "" unless value.positive?
235
235
 
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"
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"
238
238
  end
239
239
 
240
240
  # "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "" for
@@ -297,7 +297,7 @@ module Samagotchi
297
297
  def status_rows(segments, width)
298
298
  return [] if segments.empty? || width <= 0
299
299
 
300
- row = cap_preview_text("status> #{segments.join(' | ')}", width)
300
+ row = cap_preview_text("status> #{segments.join(" | ")}", width)
301
301
  [color_output? ? paint(row, 90) : row]
302
302
  end
303
303
 
@@ -336,7 +336,7 @@ module Samagotchi
336
336
 
337
337
  visible = names.first(limit)
338
338
  suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
339
- "#{label}: #{visible.join(', ')}#{suffix}"
339
+ "#{label}: #{visible.join(", ")}#{suffix}"
340
340
  end
341
341
 
342
342
  def cap_preview_text(text, width)
@@ -411,8 +411,8 @@ module Samagotchi
411
411
  return nil unless last
412
412
 
413
413
  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]
414
+ text = "#{speed_text(last, source)} out"
415
+ text << ", #{speed_text(tokens[:last_prefill_tps], "server")} prompt" if tokens[:last_prefill_tps]
416
416
  text << " (last, #{source})"
417
417
  text << ", avg #{speed_text(tokens[:avg_decode_tps], source)}" if tokens[:avg_decode_tps]
418
418
  text
@@ -428,7 +428,7 @@ module Samagotchi
428
428
  return "#{seconds.round}s" if seconds < 60
429
429
 
430
430
  total_seconds = seconds.round
431
- "#{total_seconds / 60}m #{format('%02d', total_seconds % 60)}s"
431
+ "#{total_seconds / 60}m #{format("%02d", total_seconds % 60)}s"
432
432
  end
433
433
 
434
434
  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
@@ -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)
@@ -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