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
@@ -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
@@ -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
 
@@ -557,8 +557,8 @@ module Samagotchi
557
557
 
558
558
  # Run the block with the event log held: no event is numbered or
559
559
  # delivered meanwhile, and events the block emits keep their order.
560
- def synchronize_events(&block)
561
- @session_observer.synchronize(&block)
560
+ def synchronize_events(&)
561
+ @session_observer.synchronize(&)
562
562
  end
563
563
 
564
564
  # Add a context note to the session's conversation, between turns: a
@@ -638,8 +638,8 @@ module Samagotchi
638
638
  # Start an anytime command on its own thread (D8), one #shutdown waits
639
639
  # for, so its command_ran is announced before the process leaves.
640
640
  # @return [Thread]
641
- def spawn_anytime(&block)
642
- thread = Thread.new(&block)
641
+ def spawn_anytime(&)
642
+ thread = Thread.new(&)
643
643
  @lifecycle_mutex.synchronize do
644
644
  @anytime_threads.select!(&:alive?)
645
645
  @anytime_threads << thread
@@ -656,9 +656,9 @@ module Samagotchi
656
656
 
657
657
  # Add a plugin's init task (Plugin::Api#init at commit); it starts with
658
658
  # #start_init_tasks!.
659
- 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, &)
660
660
  @plugin_tasks.add(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
661
- quiet: quiet, timeout: timeout, failed: failed, &block)
661
+ quiet: quiet, timeout: timeout, failed: failed, &)
662
662
  end
663
663
 
664
664
  # Start the init tasks not started yet, each on its own thread
@@ -836,8 +836,8 @@ module Samagotchi
836
836
  # @param name [Symbol] one of the hook names (see Hooks module)
837
837
  # @param block [Proc] receives an event hash (may mutate in place)
838
838
  # @return [void]
839
- def register_hook(name, &block)
840
- @hooks.register(name, &block)
839
+ def register_hook(name, &)
840
+ @hooks.register(name, &)
841
841
  end
842
842
 
843
843
  # Unregister a previously registered hook.
@@ -1369,9 +1369,7 @@ module Samagotchi
1369
1369
  end
1370
1370
 
1371
1371
  # @return [Session] current session (Engine owns create/resume)
1372
- def session
1373
- @session
1374
- end
1372
+ attr_reader :session
1375
1373
 
1376
1374
  # The kernel's Tools::Peers, following the current session. cancelled?
1377
1375
  # is the running turn's cancel, for a tool that waits (delegate_result):
@@ -1424,9 +1422,7 @@ module Samagotchi
1424
1422
  end
1425
1423
 
1426
1424
  # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
1427
- def recap
1428
- @recap
1429
- end
1425
+ attr_reader :recap
1430
1426
 
1431
1427
  # Write the recap now, before the session is left (IdleRecap#write_now).
1432
1428
  # @return [String, nil] the recap written, nil when none was (recap off,
@@ -2128,6 +2124,7 @@ module Samagotchi
2128
2124
  config_path = Samagotchi::ConfigFile.global_path
2129
2125
  data = Samagotchi::ConfigFile.read_yaml(path: config_path)
2130
2126
  return Hooks::Loader.load(data, failures: @guardrail_failures) if data.is_a?(Hash)
2127
+
2131
2128
  Hooks::Registry.new
2132
2129
  end
2133
2130
 
@@ -2150,11 +2147,15 @@ module Samagotchi
2150
2147
  Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: data[:hooks], registry: @hooks,
2151
2148
  failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {},
2152
2149
  requires_chi: data[:requires_chi])
2153
- 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
+
2154
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)
2155
2154
  end
2156
2155
  end
2157
- 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
+
2158
2159
  Log.error(:hooks, "bundles_load_failed", echo: "[samagotchi:hooks] failed to load bundle hooks: #{e.class}: #{e.message}", error: e.class.name)
2159
2160
  end
2160
2161
 
@@ -2278,7 +2279,6 @@ module Samagotchi
2278
2279
  end
2279
2280
  private :bundle_settings
2280
2281
 
2281
-
2282
2282
  # The session's current model as a recap target: its host's OpenAI API
2283
2283
  # (native llama.cpp hosts serve /v1/chat/completions too), key variable
2284
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
@@ -72,8 +72,8 @@ module Samagotchi
72
72
  list.find do |e|
73
73
  case e["scope"]
74
74
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
75
- when "repo" then here.(e) && e["key"] == key
76
- when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.(e)
75
+ when "repo" then here.call(e) && e["key"] == key
76
+ when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.call(e)
77
77
  end
78
78
  end
79
79
  end
@@ -120,8 +120,8 @@ module Samagotchi
120
120
  end
121
121
 
122
122
  # Registry#fire_each rescues a raising hook itself.
123
- def fire_each(name, event, &after_each)
124
- @hooks_lookup.call&.fire_each(name, event, &after_each)
123
+ def fire_each(name, event, &)
124
+ @hooks_lookup.call&.fire_each(name, event, &)
125
125
  end
126
126
  end
127
127
  end
@@ -167,7 +167,7 @@ module Samagotchi
167
167
  program = nil
168
168
  until rest.empty?
169
169
  arg = rest.shift
170
- if arg == "-F" || arg == "-v" then rest.shift
170
+ if ["-F", "-v"].include?(arg) then rest.shift
171
171
  elsif arg.match?(/\A-[Fv]./m) then next
172
172
  elsif arg == "--" then program = rest.shift
173
173
  elsif arg.start_with?("-") then return false
@@ -296,14 +296,16 @@ module Samagotchi
296
296
  delim = @s.getch
297
297
  return false if delim.nil? || delim.match?(/[\s\\]/)
298
298
 
299
- delimited(delim) && delimited(delim) && @s.skip(/[gpiImM\d]*/) && true
299
+ # Two calls, two fields: each one advances the scanner (pattern, then replacement).
300
+ delimited(delim) && delimited(delim) && @s.skip(/[gpiImM\d]*/) && true # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
300
301
  end
301
302
 
302
303
  def transliterate
303
304
  delim = @s.getch
304
305
  return false if delim.nil? || delim.match?(/[\s\\]/)
305
306
 
306
- delimited(delim) && delimited(delim)
307
+ # Source set, then target set: each call advances the scanner.
308
+ delimited(delim) && delimited(delim) # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
307
309
  end
308
310
 
309
311
  # Up to the next unescaped +delim+ (consumed); false at the end.
@@ -123,7 +123,7 @@ module Samagotchi
123
123
  def expand(arg, dir)
124
124
  return nil if arg.include?(ShellLex::SUBST)
125
125
 
126
- arg = arg.sub(/\A(~|\$HOME|\$\{HOME\})(?=\/|\z)/) { @home }
126
+ arg = arg.sub(%r{\A(~|\$HOME|\$\{HOME\})(?=/|\z)}) { @home }
127
127
  return nil if arg.include?("$") || arg.start_with?("~")
128
128
  return File.expand_path(arg) if arg.start_with?("/")
129
129
 
@@ -142,12 +142,12 @@ module Samagotchi
142
142
  # >, >>, 2>&1, &>, <, <<: kept in the word (the walk drops them).
143
143
  def redirection
144
144
  special(@s[@i])
145
- add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.match?(/>\z/)
145
+ add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.end_with?(">")
146
146
  end
147
147
 
148
148
  def operator_or_char(char)
149
149
  op = OPERATORS.find { |o| @s[@i, o.size] == o }
150
- return special(char) if char == "{" || char == "}"
150
+ return special(char) if ["{", "}"].include?(char)
151
151
  return add(char, 1) unless op
152
152
 
153
153
  if op == "&" && @word.match?(/\d*>\z/)
@@ -107,7 +107,7 @@ module Samagotchi
107
107
  def candidate(word)
108
108
  word = word.sub(REDIRECT_PREFIX, "")
109
109
  word = word.split("=", 2).last if word.match?(/\A(--?[\w-]+|[A-Za-z_]\w*)=/)
110
- return nil if word.empty? || word == "-" || word.match?(/\A-[^\/]/)
110
+ return nil if word.empty? || word == "-" || word.match?(%r{\A-[^/]})
111
111
 
112
112
  word
113
113
  end
@@ -124,7 +124,7 @@ module Samagotchi
124
124
  end
125
125
 
126
126
  def expand(word)
127
- word = word.sub(/\A~(?=\/|\z)/) { @home }
127
+ word = word.sub(%r{\A~(?=/|\z)}) { @home }
128
128
  word = word.sub(EXPANSION) { value(Regexp.last_match(1) || Regexp.last_match(2)) || "$" }
129
129
  return nil if word.include?("$") || word.start_with?("~")
130
130