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
@@ -80,7 +80,7 @@ module Samagotchi
80
80
  # A line that is one word: a slash and a name, no spaces, no second
81
81
  # slash. `/modle` is one; `/foo bar` and `/usr/bin/env` are not (they
82
82
  # are prompts).
83
- UNKNOWN_COMMAND_WORD = /\A\/[A-Za-z][A-Za-z0-9_-]*\z/
83
+ UNKNOWN_COMMAND_WORD = %r{\A/[A-Za-z][A-Za-z0-9_-]*\z}
84
84
 
85
85
  # Whether +line+ is a command word no command answers: a typo like
86
86
  # `/modle`, which a UI must not send to the model as a prompt.
@@ -100,7 +100,7 @@ module Samagotchi
100
100
 
101
101
  text = line.to_s.strip
102
102
  close = DidYouMean::SpellChecker.new(dictionary: @entries.map(&:name)).correct(text).first
103
- "Unknown command #{text}. #{close ? "Did you mean #{close}? " : ""}/help lists the commands."
103
+ "Unknown command #{text}. #{"Did you mean #{close}? " if close}/help lists the commands."
104
104
  end
105
105
 
106
106
  # @return [Entry, nil] the first entry the UI runs itself (local) that
@@ -4,7 +4,6 @@ require "yaml"
4
4
  require "json"
5
5
  require "fileutils"
6
6
  require "uri"
7
- require "set"
8
7
  require_relative "atomic_file"
9
8
  require_relative "config_text_edit"
10
9
  require_relative "log"
@@ -56,8 +55,8 @@ module Samagotchi
56
55
  end
57
56
 
58
57
  # Section names: lower alnum only, no _ or -.
59
- SECTION_RE = /\A[a-z0-9]+\z/.freeze
60
- LEAF_RE = /\A[a-z0-9_]+\z/.freeze
58
+ SECTION_RE = /\A[a-z0-9]+\z/
59
+ LEAF_RE = /\A[a-z0-9_]+\z/
61
60
 
62
61
  ENTRIES = [
63
62
  # universal – env+config+cli
@@ -126,7 +125,7 @@ module Samagotchi
126
125
  Entry.new(key: "context.status", yaml_path: %w[context status], type: :bool, default: true, expose: %i[env config cli]),
127
126
  Entry.new(key: "context.window_tokens", yaml_path: %w[context window_tokens], type: :integer, default: nil, expose: %i[env config cli]),
128
127
  Entry.new(key: "context.chars_per_token", yaml_path: %w[context chars_per_token], type: :float, default: 4.0, expose: %i[env config cli]),
129
- Entry.new(key: "context.status_thresholds", yaml_path: %w[context status_thresholds],type: :string, default: "20,40,60,80", expose: %i[env config cli]),
128
+ Entry.new(key: "context.status_thresholds", yaml_path: %w[context status_thresholds], type: :string, default: "20,40,60,80", expose: %i[env config cli]),
130
129
  Entry.new(key: "context.status_cadence", yaml_path: %w[context status_cadence], type: :integer, default: 0, expose: %i[env config cli]),
131
130
 
132
131
  Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli]),
@@ -136,7 +135,7 @@ module Samagotchi
136
135
  # the flag and the env, above config.yml's value.
137
136
  Entry.new(key: "thinking.level", yaml_path: %w[thinking level], type: :string, default: "default", expose: %i[env config]),
138
137
 
139
- Entry.new(key: "default.n_predict", yaml_path: %w[default n_predict], type: :integer, default: nil, expose: %i[env config cli]),
138
+ Entry.new(key: "default.max_tokens", yaml_path: %w[default max_tokens], type: :integer, default: nil, expose: %i[env config cli]),
140
139
  Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli]),
141
140
 
142
141
  Entry.new(key: "retry.max", yaml_path: %w[retry max], type: :integer, default: 5, expose: %i[env config cli]),
@@ -145,6 +144,10 @@ module Samagotchi
145
144
  # Asks again in the same turn after an empty answer (EmptyAnswerRetry);
146
145
  # 0 = off, capped at 3. No CLI flag: workers get no CLI args.
147
146
  Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
147
+ # Seconds a generation must have streamed only thinking before a message
148
+ # for the running turn (the user's, chi send's, a parent agent's) cuts
149
+ # it (Engine#cut_for_steer); 0 = never. No CLI flag: workers get no CLI args.
150
+ Entry.new(key: "steer.cut_after", yaml_path: %w[steer cut_after], type: :integer, default: 20, expose: %i[env config]),
148
151
  # A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
149
152
  # --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
150
153
  Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
@@ -186,7 +189,7 @@ module Samagotchi
186
189
  # env+config only
187
190
  Entry.new(key: "default.input", yaml_path: %w[default input], type: :string, default: nil, expose: %i[env config]),
188
191
  Entry.new(key: "history.file", yaml_path: %w[history file], type: :string, default: nil, expose: %i[env config]),
189
- Entry.new(key: "skip_agent_md", yaml_path: %w[skip_agent_md], type: :bool, default: false, expose: %i[env config]),
192
+ Entry.new(key: "skip_agent_md", yaml_path: %w[skip_agent_md], type: :bool, default: false, expose: %i[env config])
190
193
  ].freeze
191
194
 
192
195
  # Top-level maps read by their own code, whose entry names and contents
@@ -228,11 +231,13 @@ module Samagotchi
228
231
  # Coercion helpers
229
232
  def coerce(entry, raw)
230
233
  return nil if raw.nil?
234
+
231
235
  # For string, preserve as-is (including trailing spaces like "Please ")
232
236
  if entry.type == :string
233
237
  # A YAML list for a "|"-separated setting (web.annotate_presets).
234
238
  str = raw.is_a?(Array) ? raw.join("|") : raw.to_s
235
239
  return nil if str.empty? && entry.type != :string
240
+
236
241
  return str
237
242
  end
238
243
  str = raw.to_s.strip
@@ -267,7 +272,7 @@ module Samagotchi
267
272
  lowered = str.downcase
268
273
  allowed = entry.enum_values.map(&:downcase)
269
274
  unless allowed.include?(lowered)
270
- Log.warn(:config, "invalid_value", echo: "Warning: invalid value for #{entry.key}: #{raw.inspect} (allowed: #{entry.enum_values.join(', ')}) — using default", key: entry.key)
275
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid value for #{entry.key}: #{raw.inspect} (allowed: #{entry.enum_values.join(", ")}) — using default", key: entry.key)
271
276
  return entry.default
272
277
  end
273
278
  # return canonical casing from enum_values
@@ -296,6 +301,7 @@ module Samagotchi
296
301
  raw = cli_overrides[entry.key]
297
302
  # cli_overrides may already be coerced; detect by type
298
303
  return [raw, :cli] if already_coerced?(entry, raw)
304
+
299
305
  return [coerce(entry, raw), :cli]
300
306
  end
301
307
  if cli_overrides.key?(entry.cli_flag)
@@ -323,7 +329,7 @@ module Samagotchi
323
329
 
324
330
  def already_coerced?(entry, val)
325
331
  case entry.type
326
- when :bool then val == true || val == false
332
+ when :bool then [true, false].include?(val)
327
333
  when :integer then val.is_a?(Integer)
328
334
  when :float then val.is_a?(Float) || val.is_a?(Integer)
329
335
  when :enum then entry.enum_values.include?(val)
@@ -343,6 +349,7 @@ module Samagotchi
343
349
  cur = data
344
350
  yaml_path.each_with_index do |seg, idx|
345
351
  return nil unless cur.is_a?(Hash)
352
+
346
353
  last = idx == yaml_path.size - 1
347
354
  if last
348
355
  candidates = [seg, seg.to_sym]
@@ -361,6 +368,7 @@ module Samagotchi
361
368
  # also try kebab alias for section (should not exist per spec, but be lenient)
362
369
  nxt ||= cur[seg.tr("_", "-")] || cur[seg.tr("_", "-").to_sym]
363
370
  return nil if nxt.nil?
371
+
364
372
  cur = nxt
365
373
  end
366
374
  end
@@ -404,6 +412,13 @@ module Samagotchi
404
412
  get_with_origin(key).first
405
413
  end
406
414
 
415
+ # default.max_tokens: the most tokens one generation may produce, or
416
+ # nil when unset or not positive (the server's own limit).
417
+ def max_tokens
418
+ value = get("default.max_tokens").to_i
419
+ value if value.positive?
420
+ end
421
+
407
422
  # A timeout setting (server.open_timeout, server.read_timeout) in
408
423
  # seconds, +given+ (a caller's own value) first; one that isn't
409
424
  # positive (0, a word) is the key's default, on every kind of host.
@@ -419,6 +434,7 @@ module Samagotchi
419
434
  def get_with_origin(key)
420
435
  entry = find_by_key(key)
421
436
  raise ArgumentError, "unknown config key: #{key}" unless entry
437
+
422
438
  # Live resolve so ENV changes (as in specs) are reflected without explicit reload
423
439
  # Use current ENV and file (via ConfigFile's cached reader), plus any CLI
424
440
  # overrides captured via reload!
@@ -546,7 +562,7 @@ module Samagotchi
546
562
  candidates = candidates.uniq
547
563
  near = near_names(probe, candidates)
548
564
  close = (near + candidates.select { |c| c.split(".").last == probe.split(".").last }).uniq.first(3)
549
- hint = close.empty? ? "" : " (did you mean #{close.map { |c| "'#{prefix}#{c}'" }.join(' or ')}?)"
565
+ hint = close.empty? ? "" : " (did you mean #{close.map { |c| "'#{prefix}#{c}'" }.join(" or ")}?)"
550
566
  "config: unknown key '#{key}'#{hint}"
551
567
  end
552
568
 
@@ -582,6 +598,7 @@ module Samagotchi
582
598
  # parsed Hash or nil (missing file / parse error / non-mapping top level).
583
599
  def read_yaml(env: ENV, path: global_path(env: env))
584
600
  return nil if path.nil?
601
+
585
602
  cache = @yaml_cache ||= {}
586
603
  unless File.file?(path)
587
604
  cache.delete(path)
@@ -593,7 +610,7 @@ module Samagotchi
593
610
  return hit[1] if hit && hit[0] == key
594
611
 
595
612
  data = begin
596
- YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
613
+ YAML.safe_load_file(path, permitted_classes: [], aliases: false)
597
614
  rescue StandardError
598
615
  nil
599
616
  end
@@ -631,7 +648,7 @@ module Samagotchi
631
648
  # else warns once and counts as unset.
632
649
  def bool_flag(value, where, key)
633
650
  return nil if value.nil?
634
- return value if value == true || value == false
651
+ return value if [true, false].include?(value)
635
652
 
636
653
  text = value.to_s.strip.downcase
637
654
  return true if text == "true"
@@ -653,6 +670,7 @@ module Samagotchi
653
670
  # nil. Reserved keys warn once and are dropped. nil when nothing is left.
654
671
  def sampling_map(value, where)
655
672
  return nil if value.nil?
673
+
656
674
  unless value.is_a?(Hash)
657
675
  warn_once "Warning: #{where}: sampling must be a map of request parameters; ignored"
658
676
  return nil
@@ -661,6 +679,7 @@ module Samagotchi
661
679
  result = value.each_with_object({}) do |(raw_key, raw_value), acc|
662
680
  key = raw_key.to_s.strip
663
681
  next if key.empty?
682
+
664
683
  if SAMPLING_RESERVED_KEYS.include?(key)
665
684
  warn_once "Warning: #{where}: sampling.#{key} is set by chi; ignored"
666
685
  next
@@ -687,6 +706,7 @@ module Samagotchi
687
706
  def clear_yaml_cache!(path = nil)
688
707
  cache = @yaml_cache
689
708
  return if cache.nil?
709
+
690
710
  if path
691
711
  cache.delete(path)
692
712
  else
@@ -734,6 +754,7 @@ module Samagotchi
734
754
  raw_hosts.each do |raw_name, raw_cfg|
735
755
  name = raw_name.to_s.strip
736
756
  next if name.empty?
757
+
737
758
  unless name.match?(HOST_NAME_RE)
738
759
  warn_once "Warning: ignoring hosts entry '#{name}': must match /[a-z0-9][a-z0-9._-]*/i"
739
760
  next
@@ -795,7 +816,7 @@ module Samagotchi
795
816
  next
796
817
  end
797
818
  port_val = port.to_s.strip.empty? ? 8080 : port.to_i
798
- if port_val <= 0 || port_val > 65535
819
+ if port_val <= 0 || port_val > 65_535
799
820
  warn_once "Warning: ignoring hosts entry '#{name}': invalid port"
800
821
  next
801
822
  end
@@ -835,7 +856,7 @@ module Samagotchi
835
856
  default_host = Samagotchi::Config.resolve("server.host", **opts).to_s.strip
836
857
  default_host = "localhost" if default_host.empty?
837
858
  default_port = Samagotchi::Config.resolve("server.port", **opts).to_i
838
- default_port = 8080 if default_port <= 0 || default_port > 65535
859
+ default_port = 8080 if default_port <= 0 || default_port > 65_535
839
860
  transport_sym = Samagotchi::Config.resolve_with_origin("server.transport", **opts).then do |value, origin|
840
861
  origin == :default ? nil : value.to_sym
841
862
  end
@@ -877,7 +898,11 @@ module Samagotchi
877
898
 
878
899
  # enabled: false (or "false", any case) in a hosts entry.
879
900
  def host_disabled?(cfg)
880
- enabled = cfg.key?("enabled") ? cfg["enabled"] : (cfg.key?(:enabled) ? cfg[:enabled] : true)
901
+ enabled = if cfg.key?("enabled")
902
+ cfg["enabled"]
903
+ else
904
+ (cfg.key?(:enabled) ? cfg[:enabled] : true)
905
+ end
881
906
  enabled == false || enabled.to_s.strip.downcase == "false"
882
907
  end
883
908
  private_class_method :host_disabled?
@@ -959,6 +984,7 @@ module Samagotchi
959
984
  # Only serialize if non-default or explicitly configured hosts:
960
985
  # include when hosts file exists with hosts: section or when workers need propagation
961
986
  return nil if hosts.nil? || hosts.empty?
987
+
962
988
  # Serialize to JSON with string keys
963
989
  simple = hosts.transform_values do |v|
964
990
  # A url entry travels as its url (host/port come from it); the API
@@ -990,6 +1016,7 @@ module Samagotchi
990
1016
  def bundle_settings(env: ENV, path: global_path(env: env))
991
1017
  raw = section("bundles", env: env, path: path)
992
1018
  return {} if raw.nil?
1019
+
993
1020
  unless raw.is_a?(Hash)
994
1021
  Log.warn(:hooks, "bundles_section_invalid", echo: "[samagotchi:hooks] config.yml bundles: must be a mapping of bundle name to settings; ignored")
995
1022
  return {}
@@ -1025,6 +1052,7 @@ module Samagotchi
1025
1052
  raw.each_with_object({}) do |(k, v), result|
1026
1053
  key = k.to_s.strip
1027
1054
  next if key.empty?
1055
+
1028
1056
  val = v.to_s.strip
1029
1057
  next if val.empty?
1030
1058
 
@@ -42,7 +42,7 @@ module Samagotchi
42
42
 
43
43
  # The note's own text, without its frame.
44
44
  def text_of(message)
45
- fetch(message, :content).to_s.sub(/\A\[CONTEXT NOTE[^\n]*\n/, "").sub(/\n\[END NOTE\]\z/, "")
45
+ fetch(message, :content).to_s.sub(/\A\[CONTEXT NOTE[^\n]*\n/, "").delete_suffix("\n[END NOTE]")
46
46
  end
47
47
 
48
48
  def fetch(message, key)
@@ -64,7 +64,7 @@ module Samagotchi
64
64
  def self.last_bucket(conversation)
65
65
  message = conversation.reverse.find do |entry|
66
66
  content = entry[:content].to_s
67
- entry[:role] == "system" && (content.start_with?(STATUS_PREFIX) || content.start_with?(LINE_PREFIX))
67
+ entry[:role] == "system" && content.start_with?(STATUS_PREFIX, LINE_PREFIX)
68
68
  end
69
69
  return nil unless message
70
70
 
@@ -221,11 +221,11 @@ module Samagotchi
221
221
  end
222
222
 
223
223
  def emit?(bucket:, iteration_index:)
224
- bucket_changed = if @last_bucket.nil?
225
- bucket != "under#{@thresholds.first}"
226
- else
227
- bucket != @last_bucket
228
- end
224
+ bucket_changed = bucket != if @last_bucket.nil?
225
+ "under#{@thresholds.first}"
226
+ else
227
+ @last_bucket
228
+ end
229
229
  cadence_due = @cadence.positive? && ((iteration_index + 1) % @cadence).zero?
230
230
  bucket_changed || cadence_due
231
231
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "log"
4
5
 
5
6
  module Samagotchi
6
7
  # A session worker's continue offer: what it announces when a turn runs out
@@ -55,9 +56,9 @@ module Samagotchi
55
56
  # (or kept, after a cancelled continue turn) is announced and asked.
56
57
  def after_turn(result, continue: false, no_interrupt: false)
57
58
  outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
58
- return unless outcome == :continue_offered || outcome == :continue_cancelled
59
+ return unless %i[continue_offered continue_cancelled].include?(outcome)
59
60
 
60
- open
61
+ announce_offer
61
62
  end
62
63
 
63
64
  # A prompt (or a reminder turn) taken while a continue is offered
@@ -111,20 +112,26 @@ module Samagotchi
111
112
  # The continue offer was answered yes: resume the conversation without a
112
113
  # user message, with the iteration limit the offer's turn had. A failure
113
114
  # keeps the offer, as the REPL does ("continue prompt preserved"), and
114
- # asks again. The answer's text is carried into the turn as it begins;
115
- # a turn that never began (a raise before it) leaves it to no other.
115
+ # asks again, one before the turn began too (logged, no :turn_failed).
116
+ # The answer's text is carried into the turn as it begins; a turn that
117
+ # never began leaves it to no other.
116
118
  def run_continue_turn(command)
117
119
  offer = @turn_flow.offer
118
120
  close("answered")
119
121
  steer = @continue_steer
120
122
  @continue_steer = nil
121
123
  begin
122
- @engine.steer_next_turn(steer[:text], source: steer[:source]) if steer
123
- @turn_flow.before_continue_turn
124
+ begin
125
+ @engine.steer_next_turn(steer[:text], source: steer[:source]) if steer
126
+ @turn_flow.before_continue_turn
127
+ rescue StandardError => e
128
+ Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
129
+ return announce_offer(offer)
130
+ end
124
131
  @run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
125
132
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
126
133
  if error
127
- open(offer)
134
+ announce_offer(offer)
128
135
  else
129
136
  after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
130
137
  end
@@ -145,7 +152,7 @@ module Samagotchi
145
152
 
146
153
  private
147
154
 
148
- def open(offer = @turn_flow.offer)
155
+ def announce_offer(offer = @turn_flow.offer)
149
156
  @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
150
157
  ask
151
158
  end
@@ -86,7 +86,7 @@ module Samagotchi
86
86
 
87
87
  def rotate
88
88
  File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
89
- return unless lock.flock(File::LOCK_EX | File::LOCK_NB)
89
+ next unless lock.flock(File::LOCK_EX | File::LOCK_NB)
90
90
 
91
91
  stat = File.stat(@path)
92
92
  File.rename(@path, "#{@path}.1") if stat.ino == @ino && stat.size >= @max_bytes
@@ -22,7 +22,8 @@ module Samagotchi
22
22
  # command goes through the runner, and the dirs are injectable, so specs
23
23
  # (and smokes with register: false) never touch the real system.
24
24
  class MacOS
25
- Error = Class.new(StandardError)
25
+ class Error < StandardError
26
+ end
26
27
 
27
28
  APP_NAME = "Chi Helper"
28
29
  BUNDLE_ID = "dev.samagotchi.chi-helper"
@@ -166,9 +167,9 @@ module Samagotchi
166
167
  end
167
168
 
168
169
  # Keeps the login item as it was.
169
- def upgrade(&progress)
170
+ def upgrade(&)
170
171
  was_login = installed? && login == "enabled"
171
- install(force: true, login: was_login, &progress)
172
+ install(force: true, login: was_login, &)
172
173
  end
173
174
 
174
175
  # SMAppService status as the app reports it ("enabled",
@@ -212,7 +213,7 @@ module Samagotchi
212
213
  env = launch["env"].is_a?(Hash) ? launch["env"] : {}
213
214
  dump, = @runner.run([PBS, "-dump"])
214
215
  _, running = @runner.run(["pgrep", "-x", EXECUTABLE])
215
- hotkey = (JSON.parse(File.read(hotkey_path)) rescue nil)
216
+ hotkey = JSON.parse(File.read(hotkey_path)) rescue nil
216
217
  result.merge(
217
218
  app_version: app_version,
218
219
  login: login,
@@ -30,7 +30,7 @@ module Samagotchi
30
30
  # The request options of a retry generation: the turn's sampling, with
31
31
  # TEMPERATURE when it sets none (a configured nil keeps "don't send").
32
32
  def self.sampling(base)
33
- base = base || {}
33
+ base ||= {}
34
34
  base.key?(:temperature) ? base : base.merge(temperature: TEMPERATURE)
35
35
  end
36
36
 
@@ -47,6 +47,8 @@ require_relative "tools/memory"
47
47
  require_relative "muted_memories"
48
48
  require_relative "used_memories"
49
49
  require_relative "turn_state"
50
+ require_relative "generation_phase"
51
+ require_relative "waiting_steer"
50
52
  require_relative "bundle_needs"
51
53
  require_relative "model_overlay"
52
54
  require_relative "served_model"
@@ -103,6 +105,10 @@ module Samagotchi
103
105
  # steers) and the idle layer's activity clock. Built first: a plugin
104
106
  # may steer or ask whether a turn runs while it loads.
105
107
  @turn_state = TurnState.new(clock: -> { monotonic_now })
108
+ # What the running generation streams: whether a steer may cut it.
109
+ @generation_phase = GenerationPhase.new(clock: -> { monotonic_now })
110
+ # A message that may cut it but came too early: it cuts later.
111
+ @waiting_steer = WaitingSteer.new
106
112
  @scratch = scratch
107
113
  @no_interrupt = no_interrupt
108
114
  @chat_backend = nil
@@ -325,6 +331,69 @@ module Samagotchi
325
331
  @turn_state.steer(text, source: source)
326
332
  end
327
333
 
334
+ # A message for the running turn from +source+ (Steer.source_for_client:
335
+ # nil for the user, "chi_send", "parent_agent") cuts the streaming
336
+ # generation when it has streamed only thinking for steer.cut_after
337
+ # seconds (GenerationPhase): the loops' cut path then starts the step
338
+ # again with the message, no nudge, no retry spent. Too early, the
339
+ # message waits (WaitingSteer) and cuts once the thinking passes it
340
+ # (#recheck_waiting_steer, on each thinking chunk), unless a boundary
341
+ # hands it to the model first. A plugin's message never cuts.
342
+ # Call it after the message is queued, with the #input_epoch read
343
+ # before queueing it. Outside any lock; a cut that lands just after the
344
+ # generation ended is harmless (the loop re-asks).
345
+ # @return [Boolean] whether a generation was cut now
346
+ def cut_for_steer(source, epoch: nil)
347
+ return false unless Steer.cuts?(source)
348
+
349
+ after = Config.get("steer.cut_after").to_i
350
+ return false unless after.positive?
351
+
352
+ ctrl = active_cancel_controller
353
+ return false unless ctrl
354
+ return steer_cut!(ctrl, source.to_s) if @generation_phase.cuttable?(after)
355
+
356
+ Log.info(:turn, "steer_cut_waits", source: source.to_s) if @waiting_steer.wait!(source, epoch: epoch)
357
+ false
358
+ rescue StandardError
359
+ false
360
+ end
361
+
362
+ # The drains that took input so far (WaitingSteer#epoch): read it before
363
+ # queueing a message for #cut_for_steer.
364
+ def input_epoch
365
+ @waiting_steer.epoch
366
+ end
367
+
368
+ # A thinking chunk streamed: a waiting message cuts once the thinking
369
+ # passes steer.cut_after.
370
+ def recheck_waiting_steer
371
+ return unless @waiting_steer.waiting?
372
+
373
+ after = Config.get("steer.cut_after").to_i
374
+ return unless after.positive? && @generation_phase.cuttable?(after)
375
+
376
+ source = @waiting_steer.take
377
+ ctrl = active_cancel_controller
378
+ steer_cut!(ctrl, source, waited: true) if source && ctrl
379
+ rescue StandardError
380
+ nil
381
+ end
382
+ private :recheck_waiting_steer
383
+
384
+ def steer_cut!(ctrl, source, waited: false)
385
+ age = @generation_phase.age
386
+ cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
387
+ if cut
388
+ @waiting_steer.clear!
389
+ fields = { source: source, age: age&.round(1) }
390
+ fields[:waited] = true if waited
391
+ Log.info(:turn, "steer_cut", **fields)
392
+ end
393
+ cut
394
+ end
395
+ private :steer_cut!
396
+
328
397
  # Like #steer, for the turn that begins next (a continue turn answered
329
398
  # with a text): it joins that turn at its first boundary. Callable from
330
399
  # any thread; a turn that never begins keeps it for the next one, unless
@@ -344,10 +413,13 @@ module Samagotchi
344
413
  # in a --non-interactive run) and the plugin steers. At the after-answer
345
414
  # boundary the steers are dropped: the model answered, and a nudge would
346
415
  # only restart the turn. The steer part fails on its own, never taking
347
- # the user's lines with it.
416
+ # the user's lines with it. Items are a String (a user line), a
417
+ # Steer::Line (a worker's input line with its sender) or a steer Hash.
348
418
  def turn_drain(pending_input)
349
419
  lambda do |at_answer: false|
350
420
  lines = pending_input ? Array(pending_input.call) : []
421
+ # The input went to the model: a message that waited to cut is in.
422
+ @waiting_steer.delivered! unless lines.empty?
351
423
  lines + take_steers(at_answer)
352
424
  end
353
425
  end
@@ -557,8 +629,8 @@ module Samagotchi
557
629
 
558
630
  # Run the block with the event log held: no event is numbered or
559
631
  # delivered meanwhile, and events the block emits keep their order.
560
- def synchronize_events(&block)
561
- @session_observer.synchronize(&block)
632
+ def synchronize_events(&)
633
+ @session_observer.synchronize(&)
562
634
  end
563
635
 
564
636
  # Add a context note to the session's conversation, between turns: a
@@ -638,8 +710,8 @@ module Samagotchi
638
710
  # Start an anytime command on its own thread (D8), one #shutdown waits
639
711
  # for, so its command_ran is announced before the process leaves.
640
712
  # @return [Thread]
641
- def spawn_anytime(&block)
642
- thread = Thread.new(&block)
713
+ def spawn_anytime(&)
714
+ thread = Thread.new(&)
643
715
  @lifecycle_mutex.synchronize do
644
716
  @anytime_threads.select!(&:alive?)
645
717
  @anytime_threads << thread
@@ -656,9 +728,9 @@ module Samagotchi
656
728
 
657
729
  # Add a plugin's init task (Plugin::Api#init at commit); it starts with
658
730
  # #start_init_tasks!.
659
- def add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &block)
731
+ def add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &)
660
732
  @plugin_tasks.add(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
661
- quiet: quiet, timeout: timeout, failed: failed, &block)
733
+ quiet: quiet, timeout: timeout, failed: failed, &)
662
734
  end
663
735
 
664
736
  # Start the init tasks not started yet, each on its own thread
@@ -836,8 +908,8 @@ module Samagotchi
836
908
  # @param name [Symbol] one of the hook names (see Hooks module)
837
909
  # @param block [Proc] receives an event hash (may mutate in place)
838
910
  # @return [void]
839
- def register_hook(name, &block)
840
- @hooks.register(name, &block)
911
+ def register_hook(name, &)
912
+ @hooks.register(name, &)
841
913
  end
842
914
 
843
915
  # Unregister a previously registered hook.
@@ -1369,9 +1441,7 @@ module Samagotchi
1369
1441
  end
1370
1442
 
1371
1443
  # @return [Session] current session (Engine owns create/resume)
1372
- def session
1373
- @session
1374
- end
1444
+ attr_reader :session
1375
1445
 
1376
1446
  # The kernel's Tools::Peers, following the current session. cancelled?
1377
1447
  # is the running turn's cancel, for a tool that waits (delegate_result):
@@ -1424,9 +1494,7 @@ module Samagotchi
1424
1494
  end
1425
1495
 
1426
1496
  # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
1427
- def recap
1428
- @recap
1429
- end
1497
+ attr_reader :recap
1430
1498
 
1431
1499
  # Write the recap now, before the session is left (IdleRecap#write_now).
1432
1500
  # @return [String, nil] the recap written, nil when none was (recap off,
@@ -1887,6 +1955,8 @@ module Samagotchi
1887
1955
  # shared inactivity clock so the idle recap detector (shared with the REPL)
1888
1956
  # treats the just-finished turn as activity and re-arms its window.
1889
1957
  left = @turn_state.finish!
1958
+ @generation_phase.finished!
1959
+ @waiting_steer.clear!
1890
1960
  Client.swap_probe_cancel(probe_cancel_before)
1891
1961
  # The next turn's window and served model are asked again: the cache
1892
1962
  # (this host's, process-wide) is dropped here, at the turn's end,
@@ -2128,6 +2198,7 @@ module Samagotchi
2128
2198
  config_path = Samagotchi::ConfigFile.global_path
2129
2199
  data = Samagotchi::ConfigFile.read_yaml(path: config_path)
2130
2200
  return Hooks::Loader.load(data, failures: @guardrail_failures) if data.is_a?(Hash)
2201
+
2131
2202
  Hooks::Registry.new
2132
2203
  end
2133
2204
 
@@ -2150,11 +2221,15 @@ module Samagotchi
2150
2221
  Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: data[:hooks], registry: @hooks,
2151
2222
  failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {},
2152
2223
  requires_chi: data[:requires_chi])
2153
- rescue Exception => e
2224
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
2225
+ raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
2226
+
2154
2227
  Log.error(:hooks, "bundle_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' failed to load hooks: #{e.class}: #{e.message}", bundle: bundle_name, error: e.class.name)
2155
2228
  end
2156
2229
  end
2157
- rescue Exception => e
2230
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
2231
+ raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
2232
+
2158
2233
  Log.error(:hooks, "bundles_load_failed", echo: "[samagotchi:hooks] failed to load bundle hooks: #{e.class}: #{e.message}", error: e.class.name)
2159
2234
  end
2160
2235
 
@@ -2278,7 +2353,6 @@ module Samagotchi
2278
2353
  end
2279
2354
  private :bundle_settings
2280
2355
 
2281
-
2282
2356
  # The session's current model as a recap target: its host's OpenAI API
2283
2357
  # (native llama.cpp hosts serve /v1/chat/completions too), key variable
2284
2358
  # and bare model name, as a turn resolves them.
@@ -2331,11 +2405,16 @@ module Samagotchi
2331
2405
  progress = nil
2332
2406
  case event[:type]
2333
2407
  when :generation_started
2408
+ @generation_phase.started!
2334
2409
  watch&.started(event[:iteration])
2335
2410
  when :generation_chunk
2336
2411
  # A chunk without the lanes (no loop of ours sends one) counts as text.
2337
2412
  text = event.key?(:text) ? event[:text] : event[:content]
2338
2413
  progress = { thinking: event[:thinking].to_s, text: text.to_s }
2414
+ @generation_phase.chunk!(**progress, tool_call: event[:tool_call])
2415
+ recheck_waiting_steer unless progress[:thinking].empty?
2416
+ when :generation_retrying then @generation_phase.retrying!
2417
+ when :generation_completed, :generation_cancelled then @generation_phase.finished!
2339
2418
  end
2340
2419
  # The chat loop asked again without the thinking fields: a notice,
2341
2420
  # not an event of its own.
@@ -15,7 +15,8 @@ module Samagotchi
15
15
  # SAMAGOTCHI_UPDATE_GEM_FILE=<path to a .gem> (hidden, for smokes) makes that
16
16
  # file the newest version and installs it instead.
17
17
  class GemUpdate
18
- Error = Class.new(StandardError)
18
+ class Error < StandardError
19
+ end
19
20
 
20
21
  LATEST_URL = URI("https://rubygems.org/api/v1/versions/samagotchi/latest.json")
21
22
  # Not SAMAGOTCHI_UPDATE_GEM: that is the env form of config update.gem.