samagotchi 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/bin/chi +9 -4
  4. data/docs/architecture.md +3 -2
  5. data/docs/configuration.md +15 -4
  6. data/docs/releasing.md +1 -1
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +11 -5
  9. data/docs/testing.md +6 -0
  10. data/lib/samagotchi/answer_command.rb +11 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  12. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  13. data/lib/samagotchi/bootstrap_command.rb +2 -2
  14. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
  17. data/lib/samagotchi/bridge.rb +12 -9
  18. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  19. data/lib/samagotchi/bridge_client.rb +1 -1
  20. data/lib/samagotchi/bundle_command.rb +39 -29
  21. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  23. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
  25. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  26. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  29. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  31. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/cancellation_controller.rb +1 -1
  38. data/lib/samagotchi/cli/command.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  40. data/lib/samagotchi/client.rb +3 -5
  41. data/lib/samagotchi/commands/registry.rb +2 -2
  42. data/lib/samagotchi/config.rb +38 -14
  43. data/lib/samagotchi/context_note.rb +1 -1
  44. data/lib/samagotchi/context_status.rb +6 -6
  45. data/lib/samagotchi/continue_offer.rb +44 -12
  46. data/lib/samagotchi/debug_log.rb +1 -1
  47. data/lib/samagotchi/desktop/macos.rb +5 -4
  48. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  49. data/lib/samagotchi/engine.rb +49 -19
  50. data/lib/samagotchi/gem_update.rb +2 -1
  51. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  52. data/lib/samagotchi/guardrails/approval.rb +5 -5
  53. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  54. data/lib/samagotchi/guardrails/gate.rb +2 -2
  55. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  56. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  57. data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
  58. data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
  59. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  60. data/lib/samagotchi/hooks/loader.rb +18 -16
  61. data/lib/samagotchi/hooks/registry.rb +10 -2
  62. data/lib/samagotchi/host_registry.rb +1 -3
  63. data/lib/samagotchi/idle_recap.rb +18 -15
  64. data/lib/samagotchi/idle_reminders.rb +6 -3
  65. data/lib/samagotchi/image_store.rb +5 -2
  66. data/lib/samagotchi/kernel_loop.rb +31 -31
  67. data/lib/samagotchi/llm/chat_loop.rb +9 -6
  68. data/lib/samagotchi/llm/errors.rb +33 -2
  69. data/lib/samagotchi/llm/http.rb +12 -12
  70. data/lib/samagotchi/llm/openai_chat.rb +53 -30
  71. data/lib/samagotchi/llm/prompt_cache.rb +83 -0
  72. data/lib/samagotchi/log.rb +2 -2
  73. data/lib/samagotchi/log_subscriber.rb +2 -1
  74. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  75. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  76. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  77. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  78. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  79. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  80. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  81. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  82. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  83. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  84. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  85. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  86. data/lib/samagotchi/memory_bundle.rb +1 -0
  87. data/lib/samagotchi/model_overlay.rb +3 -5
  88. data/lib/samagotchi/model_profile.rb +12 -8
  89. data/lib/samagotchi/note_command.rb +2 -1
  90. data/lib/samagotchi/output_formatter.rb +18 -17
  91. data/lib/samagotchi/owner_lock.rb +2 -1
  92. data/lib/samagotchi/parent_report.rb +24 -7
  93. data/lib/samagotchi/plugin/api.rb +8 -5
  94. data/lib/samagotchi/plugin/context.rb +1 -3
  95. data/lib/samagotchi/plugin/loader.rb +1 -1
  96. data/lib/samagotchi/plugin/service.rb +1 -1
  97. data/lib/samagotchi/plugin/sessions.rb +1 -0
  98. data/lib/samagotchi/plugin_tasks.rb +1 -1
  99. data/lib/samagotchi/prompt.rb +68 -12
  100. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  101. data/lib/samagotchi/question_desk.rb +7 -13
  102. data/lib/samagotchi/recap_setup.rb +1 -1
  103. data/lib/samagotchi/relay_desk.rb +1 -1
  104. data/lib/samagotchi/reminder_store.rb +2 -1
  105. data/lib/samagotchi/reply_wait.rb +12 -2
  106. data/lib/samagotchi/send_command.rb +4 -2
  107. data/lib/samagotchi/session.rb +2 -2
  108. data/lib/samagotchi/session_archive_command.rb +5 -1
  109. data/lib/samagotchi/session_commands.rb +1 -3
  110. data/lib/samagotchi/session_manager.rb +2 -4
  111. data/lib/samagotchi/session_metrics.rb +10 -10
  112. data/lib/samagotchi/session_observer.rb +3 -2
  113. data/lib/samagotchi/session_retention.rb +2 -2
  114. data/lib/samagotchi/sessions_command.rb +14 -5
  115. data/lib/samagotchi/system_prompt.rb +19 -8
  116. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  117. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  118. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
  119. data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
  120. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  121. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  122. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  123. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  124. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  125. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  126. data/lib/samagotchi/terminal_ui.rb +9 -5
  127. data/lib/samagotchi/text_diff.rb +12 -12
  128. data/lib/samagotchi/thinking.rb +6 -2
  129. data/lib/samagotchi/thought_stream_splitter.rb +6 -3
  130. data/lib/samagotchi/tool_activity.rb +1 -0
  131. data/lib/samagotchi/tool_call_parser.rb +13 -14
  132. data/lib/samagotchi/tool_declarations.rb +36 -35
  133. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  134. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  135. data/lib/samagotchi/tools/delegate.rb +1 -1
  136. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  137. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  138. data/lib/samagotchi/tools/edit.rb +14 -13
  139. data/lib/samagotchi/tools/execute.rb +3 -5
  140. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  141. data/lib/samagotchi/tools/memory.rb +21 -22
  142. data/lib/samagotchi/tools/read.rb +7 -7
  143. data/lib/samagotchi/tools/task_create.rb +1 -1
  144. data/lib/samagotchi/tools/task_get.rb +1 -1
  145. data/lib/samagotchi/tools/task_list.rb +1 -1
  146. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  147. data/lib/samagotchi/tools/task_stop.rb +1 -1
  148. data/lib/samagotchi/tools/task_wait.rb +3 -4
  149. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  150. data/lib/samagotchi/tools/write.rb +1 -1
  151. data/lib/samagotchi/turn_flow.rb +8 -8
  152. data/lib/samagotchi/turn_note.rb +5 -1
  153. data/lib/samagotchi/turn_state.rb +22 -0
  154. data/lib/samagotchi/version.rb +1 -1
  155. data/lib/samagotchi/vision_support.rb +2 -2
  156. data/lib/samagotchi/web/app.rb +13 -6
  157. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  158. data/lib/samagotchi/web/qr.rb +7 -1
  159. data/lib/samagotchi/web/server.rb +6 -2
  160. data/lib/samagotchi/worker.rb +10 -2
  161. metadata +21 -20
@@ -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]),
@@ -186,7 +185,7 @@ module Samagotchi
186
185
  # env+config only
187
186
  Entry.new(key: "default.input", yaml_path: %w[default input], type: :string, default: nil, expose: %i[env config]),
188
187
  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]),
188
+ Entry.new(key: "skip_agent_md", yaml_path: %w[skip_agent_md], type: :bool, default: false, expose: %i[env config])
190
189
  ].freeze
191
190
 
192
191
  # Top-level maps read by their own code, whose entry names and contents
@@ -228,11 +227,13 @@ module Samagotchi
228
227
  # Coercion helpers
229
228
  def coerce(entry, raw)
230
229
  return nil if raw.nil?
230
+
231
231
  # For string, preserve as-is (including trailing spaces like "Please ")
232
232
  if entry.type == :string
233
233
  # A YAML list for a "|"-separated setting (web.annotate_presets).
234
234
  str = raw.is_a?(Array) ? raw.join("|") : raw.to_s
235
235
  return nil if str.empty? && entry.type != :string
236
+
236
237
  return str
237
238
  end
238
239
  str = raw.to_s.strip
@@ -267,7 +268,7 @@ module Samagotchi
267
268
  lowered = str.downcase
268
269
  allowed = entry.enum_values.map(&:downcase)
269
270
  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)
271
+ 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
272
  return entry.default
272
273
  end
273
274
  # return canonical casing from enum_values
@@ -296,6 +297,7 @@ module Samagotchi
296
297
  raw = cli_overrides[entry.key]
297
298
  # cli_overrides may already be coerced; detect by type
298
299
  return [raw, :cli] if already_coerced?(entry, raw)
300
+
299
301
  return [coerce(entry, raw), :cli]
300
302
  end
301
303
  if cli_overrides.key?(entry.cli_flag)
@@ -323,7 +325,7 @@ module Samagotchi
323
325
 
324
326
  def already_coerced?(entry, val)
325
327
  case entry.type
326
- when :bool then val == true || val == false
328
+ when :bool then [true, false].include?(val)
327
329
  when :integer then val.is_a?(Integer)
328
330
  when :float then val.is_a?(Float) || val.is_a?(Integer)
329
331
  when :enum then entry.enum_values.include?(val)
@@ -343,6 +345,7 @@ module Samagotchi
343
345
  cur = data
344
346
  yaml_path.each_with_index do |seg, idx|
345
347
  return nil unless cur.is_a?(Hash)
348
+
346
349
  last = idx == yaml_path.size - 1
347
350
  if last
348
351
  candidates = [seg, seg.to_sym]
@@ -361,6 +364,7 @@ module Samagotchi
361
364
  # also try kebab alias for section (should not exist per spec, but be lenient)
362
365
  nxt ||= cur[seg.tr("_", "-")] || cur[seg.tr("_", "-").to_sym]
363
366
  return nil if nxt.nil?
367
+
364
368
  cur = nxt
365
369
  end
366
370
  end
@@ -404,6 +408,13 @@ module Samagotchi
404
408
  get_with_origin(key).first
405
409
  end
406
410
 
411
+ # default.max_tokens: the most tokens one generation may produce, or
412
+ # nil when unset or not positive (the server's own limit).
413
+ def max_tokens
414
+ value = get("default.max_tokens").to_i
415
+ value if value.positive?
416
+ end
417
+
407
418
  # A timeout setting (server.open_timeout, server.read_timeout) in
408
419
  # seconds, +given+ (a caller's own value) first; one that isn't
409
420
  # positive (0, a word) is the key's default, on every kind of host.
@@ -419,6 +430,7 @@ module Samagotchi
419
430
  def get_with_origin(key)
420
431
  entry = find_by_key(key)
421
432
  raise ArgumentError, "unknown config key: #{key}" unless entry
433
+
422
434
  # Live resolve so ENV changes (as in specs) are reflected without explicit reload
423
435
  # Use current ENV and file (via ConfigFile's cached reader), plus any CLI
424
436
  # overrides captured via reload!
@@ -546,7 +558,7 @@ module Samagotchi
546
558
  candidates = candidates.uniq
547
559
  near = near_names(probe, candidates)
548
560
  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 ')}?)"
561
+ hint = close.empty? ? "" : " (did you mean #{close.map { |c| "'#{prefix}#{c}'" }.join(" or ")}?)"
550
562
  "config: unknown key '#{key}'#{hint}"
551
563
  end
552
564
 
@@ -582,6 +594,7 @@ module Samagotchi
582
594
  # parsed Hash or nil (missing file / parse error / non-mapping top level).
583
595
  def read_yaml(env: ENV, path: global_path(env: env))
584
596
  return nil if path.nil?
597
+
585
598
  cache = @yaml_cache ||= {}
586
599
  unless File.file?(path)
587
600
  cache.delete(path)
@@ -593,7 +606,7 @@ module Samagotchi
593
606
  return hit[1] if hit && hit[0] == key
594
607
 
595
608
  data = begin
596
- YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
609
+ YAML.safe_load_file(path, permitted_classes: [], aliases: false)
597
610
  rescue StandardError
598
611
  nil
599
612
  end
@@ -631,7 +644,7 @@ module Samagotchi
631
644
  # else warns once and counts as unset.
632
645
  def bool_flag(value, where, key)
633
646
  return nil if value.nil?
634
- return value if value == true || value == false
647
+ return value if [true, false].include?(value)
635
648
 
636
649
  text = value.to_s.strip.downcase
637
650
  return true if text == "true"
@@ -653,6 +666,7 @@ module Samagotchi
653
666
  # nil. Reserved keys warn once and are dropped. nil when nothing is left.
654
667
  def sampling_map(value, where)
655
668
  return nil if value.nil?
669
+
656
670
  unless value.is_a?(Hash)
657
671
  warn_once "Warning: #{where}: sampling must be a map of request parameters; ignored"
658
672
  return nil
@@ -661,6 +675,7 @@ module Samagotchi
661
675
  result = value.each_with_object({}) do |(raw_key, raw_value), acc|
662
676
  key = raw_key.to_s.strip
663
677
  next if key.empty?
678
+
664
679
  if SAMPLING_RESERVED_KEYS.include?(key)
665
680
  warn_once "Warning: #{where}: sampling.#{key} is set by chi; ignored"
666
681
  next
@@ -687,6 +702,7 @@ module Samagotchi
687
702
  def clear_yaml_cache!(path = nil)
688
703
  cache = @yaml_cache
689
704
  return if cache.nil?
705
+
690
706
  if path
691
707
  cache.delete(path)
692
708
  else
@@ -734,6 +750,7 @@ module Samagotchi
734
750
  raw_hosts.each do |raw_name, raw_cfg|
735
751
  name = raw_name.to_s.strip
736
752
  next if name.empty?
753
+
737
754
  unless name.match?(HOST_NAME_RE)
738
755
  warn_once "Warning: ignoring hosts entry '#{name}': must match /[a-z0-9][a-z0-9._-]*/i"
739
756
  next
@@ -795,7 +812,7 @@ module Samagotchi
795
812
  next
796
813
  end
797
814
  port_val = port.to_s.strip.empty? ? 8080 : port.to_i
798
- if port_val <= 0 || port_val > 65535
815
+ if port_val <= 0 || port_val > 65_535
799
816
  warn_once "Warning: ignoring hosts entry '#{name}': invalid port"
800
817
  next
801
818
  end
@@ -835,7 +852,7 @@ module Samagotchi
835
852
  default_host = Samagotchi::Config.resolve("server.host", **opts).to_s.strip
836
853
  default_host = "localhost" if default_host.empty?
837
854
  default_port = Samagotchi::Config.resolve("server.port", **opts).to_i
838
- default_port = 8080 if default_port <= 0 || default_port > 65535
855
+ default_port = 8080 if default_port <= 0 || default_port > 65_535
839
856
  transport_sym = Samagotchi::Config.resolve_with_origin("server.transport", **opts).then do |value, origin|
840
857
  origin == :default ? nil : value.to_sym
841
858
  end
@@ -877,7 +894,11 @@ module Samagotchi
877
894
 
878
895
  # enabled: false (or "false", any case) in a hosts entry.
879
896
  def host_disabled?(cfg)
880
- enabled = cfg.key?("enabled") ? cfg["enabled"] : (cfg.key?(:enabled) ? cfg[:enabled] : true)
897
+ enabled = if cfg.key?("enabled")
898
+ cfg["enabled"]
899
+ else
900
+ (cfg.key?(:enabled) ? cfg[:enabled] : true)
901
+ end
881
902
  enabled == false || enabled.to_s.strip.downcase == "false"
882
903
  end
883
904
  private_class_method :host_disabled?
@@ -959,6 +980,7 @@ module Samagotchi
959
980
  # Only serialize if non-default or explicitly configured hosts:
960
981
  # include when hosts file exists with hosts: section or when workers need propagation
961
982
  return nil if hosts.nil? || hosts.empty?
983
+
962
984
  # Serialize to JSON with string keys
963
985
  simple = hosts.transform_values do |v|
964
986
  # A url entry travels as its url (host/port come from it); the API
@@ -990,6 +1012,7 @@ module Samagotchi
990
1012
  def bundle_settings(env: ENV, path: global_path(env: env))
991
1013
  raw = section("bundles", env: env, path: path)
992
1014
  return {} if raw.nil?
1015
+
993
1016
  unless raw.is_a?(Hash)
994
1017
  Log.warn(:hooks, "bundles_section_invalid", echo: "[samagotchi:hooks] config.yml bundles: must be a mapping of bundle name to settings; ignored")
995
1018
  return {}
@@ -1025,6 +1048,7 @@ module Samagotchi
1025
1048
  raw.each_with_object({}) do |(k, v), result|
1026
1049
  key = k.to_s.strip
1027
1050
  next if key.empty?
1051
+
1028
1052
  val = v.to_s.strip
1029
1053
  next if val.empty?
1030
1054
 
@@ -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
@@ -41,6 +42,9 @@ module Samagotchi
41
42
  # A question was posted since the last close (it may have been
42
43
  # answered or superseded meanwhile).
43
44
  @asked = false
45
+ # The text a Continue answer came with ({text:, source:}): it steers
46
+ # the continue turn (#run_continue_turn).
47
+ @continue_steer = nil
44
48
  end
45
49
 
46
50
  attr_writer :queue_command
@@ -52,9 +56,9 @@ module Samagotchi
52
56
  # (or kept, after a cancelled continue turn) is announced and asked.
53
57
  def after_turn(result, continue: false, no_interrupt: false)
54
58
  outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
55
- return unless outcome == :continue_offered || outcome == :continue_cancelled
59
+ return unless %i[continue_offered continue_cancelled].include?(outcome)
56
60
 
57
- open
61
+ announce_offer
58
62
  end
59
63
 
60
64
  # A prompt (or a reminder turn) taken while a continue is offered
@@ -62,6 +66,7 @@ module Samagotchi
62
66
  def drop(origin)
63
67
  return unless awaiting?
64
68
 
69
+ @continue_steer = nil
65
70
  close("dropped")
66
71
  @engine.synchronize_events do
67
72
  @turn_flow.drop_offer!
@@ -95,6 +100,10 @@ module Samagotchi
95
100
  close(resolved ? "answered" : "dropped") unless awaiting?
96
101
  return unless resolved
97
102
 
103
+ # Answered without a turn (a typed /continue no that got there before
104
+ # the answer's /continue yes): the answer's text steers nothing.
105
+ @continue_steer = nil unless result.resume
106
+
98
107
  # An offer answered without a turn ("no") is activity: the recap
99
108
  # written at the offer (it says the turn stopped) gets rewritten.
100
109
  @engine.record_activity unless result.resume
@@ -103,18 +112,32 @@ module Samagotchi
103
112
  # The continue offer was answered yes: resume the conversation without a
104
113
  # user message, with the iteration limit the offer's turn had. A failure
105
114
  # keeps the offer, as the REPL does ("continue prompt preserved"), and
106
- # asks again.
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.
107
118
  def run_continue_turn(command)
108
119
  offer = @turn_flow.offer
109
120
  close("answered")
110
- @turn_flow.before_continue_turn
111
- @run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
112
- max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
113
- if error
114
- open(offer)
115
- else
116
- after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
121
+ steer = @continue_steer
122
+ @continue_steer = nil
123
+ begin
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
131
+ @run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
132
+ max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
133
+ if error
134
+ announce_offer(offer)
135
+ else
136
+ after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
137
+ end
117
138
  end
139
+ ensure
140
+ @engine.drop_carried_steers if steer
118
141
  end
119
142
  end
120
143
 
@@ -129,7 +152,7 @@ module Samagotchi
129
152
 
130
153
  private
131
154
 
132
- def open(offer = @turn_flow.offer)
155
+ def announce_offer(offer = @turn_flow.offer)
133
156
  @engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
134
157
  ask
135
158
  end
@@ -164,9 +187,18 @@ module Samagotchi
164
187
  end
165
188
 
166
189
  # The question's answer as the /continue line a typed answer would be:
167
- # Continue → yes; Stop → no; a text (with Stop, or alone) → no, <text>.
190
+ # Continue → yes, and a text with it steers the continue turn (from the
191
+ # parent agent when the answer says so); Stop → no; a text (with Stop,
192
+ # or alone) → no, <text>.
168
193
  def answered(answer, client_id:)
194
+ # The /continue line takes the text on one line; the steer keeps its
195
+ # line breaks.
169
196
  text = answer[:freeform].to_s.gsub(/\s+/, " ").strip
197
+ @continue_steer = nil
198
+ if Array(answer[:selected]).include?(CONTINUE) && !text.empty?
199
+ @continue_steer = { text: answer[:freeform].to_s.strip,
200
+ source: answer[:by] == "parent_agent" ? "parent_agent" : "user" }
201
+ end
170
202
  line = if Array(answer[:selected]).include?(CONTINUE) then "/continue yes"
171
203
  elsif text.empty? then "/continue no"
172
204
  else "/continue no, #{text}"
@@ -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
 
@@ -325,6 +325,21 @@ module Samagotchi
325
325
  @turn_state.steer(text, source: source)
326
326
  end
327
327
 
328
+ # Like #steer, for the turn that begins next (a continue turn answered
329
+ # with a text): it joins that turn at its first boundary. Callable from
330
+ # any thread; a turn that never begins keeps it for the next one, unless
331
+ # #drop_carried_steers drops it.
332
+ # @return [Boolean] whether it was queued (false: blank)
333
+ def steer_next_turn(text, source:)
334
+ @turn_state.steer_next_turn(text, source: source)
335
+ end
336
+
337
+ # Drop the #steer_next_turn steers no turn began with (the continue turn
338
+ # failed before it began), logged; a no-op once a turn took them.
339
+ def drop_carried_steers
340
+ log_dropped_steers(@turn_state.drop_carried_steers, "not_begun")
341
+ end
342
+
328
343
  # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
329
344
  # in a --non-interactive run) and the plugin steers. At the after-answer
330
345
  # boundary the steers are dropped: the model answered, and a nudge would
@@ -542,8 +557,8 @@ module Samagotchi
542
557
 
543
558
  # Run the block with the event log held: no event is numbered or
544
559
  # delivered meanwhile, and events the block emits keep their order.
545
- def synchronize_events(&block)
546
- @session_observer.synchronize(&block)
560
+ def synchronize_events(&)
561
+ @session_observer.synchronize(&)
547
562
  end
548
563
 
549
564
  # Add a context note to the session's conversation, between turns: a
@@ -623,8 +638,8 @@ module Samagotchi
623
638
  # Start an anytime command on its own thread (D8), one #shutdown waits
624
639
  # for, so its command_ran is announced before the process leaves.
625
640
  # @return [Thread]
626
- def spawn_anytime(&block)
627
- thread = Thread.new(&block)
641
+ def spawn_anytime(&)
642
+ thread = Thread.new(&)
628
643
  @lifecycle_mutex.synchronize do
629
644
  @anytime_threads.select!(&:alive?)
630
645
  @anytime_threads << thread
@@ -641,9 +656,9 @@ module Samagotchi
641
656
 
642
657
  # Add a plugin's init task (Plugin::Api#init at commit); it starts with
643
658
  # #start_init_tasks!.
644
- def add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &block)
659
+ def add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &)
645
660
  @plugin_tasks.add(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
646
- quiet: quiet, timeout: timeout, failed: failed, &block)
661
+ quiet: quiet, timeout: timeout, failed: failed, &)
647
662
  end
648
663
 
649
664
  # Start the init tasks not started yet, each on its own thread
@@ -821,8 +836,8 @@ module Samagotchi
821
836
  # @param name [Symbol] one of the hook names (see Hooks module)
822
837
  # @param block [Proc] receives an event hash (may mutate in place)
823
838
  # @return [void]
824
- def register_hook(name, &block)
825
- @hooks.register(name, &block)
839
+ def register_hook(name, &)
840
+ @hooks.register(name, &)
826
841
  end
827
842
 
828
843
  # Unregister a previously registered hook.
@@ -1089,7 +1104,8 @@ module Samagotchi
1089
1104
  # @param limit [Integer, nil] the turn ran out of iterations at this
1090
1105
  # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
1091
1106
  # answer the continue offer says so (ReplyWait, ParentReport "limit")
1092
- def record_last_turn(session, outcome, seconds, origin, limit: nil)
1107
+ # @param event [Hash] the turn's end event (#stop_facts)
1108
+ def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1093
1109
  client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1094
1110
  source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
1095
1111
  elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
@@ -1098,9 +1114,23 @@ module Samagotchi
1098
1114
  session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
1099
1115
  "seconds" => seconds.round(1), "origin" => source }
1100
1116
  session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
1117
+ session.last_turn.merge!(stop_facts(event))
1101
1118
  end
1102
1119
  private :record_last_turn
1103
1120
 
1121
+ # Why a turn stopped, from its end event, for a parent agent's wait
1122
+ # (ReplyWait): a provider error's kind and whether a retry may help, a
1123
+ # cancel's reason and the hook that stopped it. Only what is known.
1124
+ def stop_facts(event)
1125
+ case event&.dig(:type)
1126
+ when :turn_failed then { "error_kind" => event[:error_kind]&.to_s, "retryable" => event[:retryable] }.compact
1127
+ when :turn_canceled
1128
+ { "cancel_reason" => event[:cancellation_reason]&.to_s, "stopped_by" => event[:cancelled_by]&.to_s }.compact
1129
+ else {}
1130
+ end
1131
+ end
1132
+ private :stop_facts
1133
+
1104
1134
  # How every turn ends (completed, canceled, failed): the session gets
1105
1135
  # what the turn kept, goes idle and records the ending, and the end
1106
1136
  # event is announced, as one step of the event log (a snapshot taken
@@ -1118,7 +1148,7 @@ module Samagotchi
1118
1148
  kept, event = yield(seconds)
1119
1149
  replace_session_messages(turn.session, kept) if kept
1120
1150
  turn.session.status = Session::STATUS_IDLE
1121
- record_last_turn(turn.session, outcome, seconds, turn.origin, limit: exhausted ? turn.limit : nil)
1151
+ record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
1122
1152
  emit_event(turn.on_event, turn.tag(event))
1123
1153
  end
1124
1154
  turn.ended = true
@@ -1339,9 +1369,7 @@ module Samagotchi
1339
1369
  end
1340
1370
 
1341
1371
  # @return [Session] current session (Engine owns create/resume)
1342
- def session
1343
- @session
1344
- end
1372
+ attr_reader :session
1345
1373
 
1346
1374
  # The kernel's Tools::Peers, following the current session. cancelled?
1347
1375
  # is the running turn's cancel, for a tool that waits (delegate_result):
@@ -1394,9 +1422,7 @@ module Samagotchi
1394
1422
  end
1395
1423
 
1396
1424
  # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
1397
- def recap
1398
- @recap
1399
- end
1425
+ attr_reader :recap
1400
1426
 
1401
1427
  # Write the recap now, before the session is left (IdleRecap#write_now).
1402
1428
  # @return [String, nil] the recap written, nil when none was (recap off,
@@ -2098,6 +2124,7 @@ module Samagotchi
2098
2124
  config_path = Samagotchi::ConfigFile.global_path
2099
2125
  data = Samagotchi::ConfigFile.read_yaml(path: config_path)
2100
2126
  return Hooks::Loader.load(data, failures: @guardrail_failures) if data.is_a?(Hash)
2127
+
2101
2128
  Hooks::Registry.new
2102
2129
  end
2103
2130
 
@@ -2120,11 +2147,15 @@ module Samagotchi
2120
2147
  Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: data[:hooks], registry: @hooks,
2121
2148
  failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {},
2122
2149
  requires_chi: data[:requires_chi])
2123
- rescue Exception => e
2150
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
2151
+ raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
2152
+
2124
2153
  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)
2125
2154
  end
2126
2155
  end
2127
- rescue Exception => e
2156
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
2157
+ raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
2158
+
2128
2159
  Log.error(:hooks, "bundles_load_failed", echo: "[samagotchi:hooks] failed to load bundle hooks: #{e.class}: #{e.message}", error: e.class.name)
2129
2160
  end
2130
2161
 
@@ -2248,7 +2279,6 @@ module Samagotchi
2248
2279
  end
2249
2280
  private :bundle_settings
2250
2281
 
2251
-
2252
2282
  # The session's current model as a recap target: its host's OpenAI API
2253
2283
  # (native llama.cpp hosts serve /v1/chat/completions too), key variable
2254
2284
  # and bare model name, as a turn resolves them.
@@ -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.
@@ -159,7 +159,7 @@ module Samagotchi
159
159
  raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
160
160
  end
161
161
 
162
- doc = YAML.safe_load(File.read(path))
162
+ doc = YAML.safe_load_file(path)
163
163
  raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
164
164
 
165
165
  loaded.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
@@ -150,13 +150,13 @@ module Samagotchi
150
150
  return nil unless preview.is_a?(Hash)
151
151
 
152
152
  get = ->(key) { preview[key] || preview[key.to_s] }
153
- return "would fail: #{get.(:error)}" if get.(:error)
154
- return "not shown (#{get.(:skipped)})" if get.(:skipped)
153
+ return "would fail: #{get.call(:error)}" if get.call(:error)
154
+ return "not shown (#{get.call(:skipped)})" if get.call(:skipped)
155
155
 
156
- added = get.(:added).to_i
157
- return "new file, #{added} #{added == 1 ? "line" : "lines"}" if get.(:new_file)
156
+ added = get.call(:added).to_i
157
+ return "new file, #{added} #{added == 1 ? "line" : "lines"}" if get.call(:new_file)
158
158
 
159
- "+#{added} −#{get.(:removed).to_i}"
159
+ "+#{added} −#{get.call(:removed).to_i}"
160
160
  end
161
161
  end
162
162
  end