samagotchi 0.4.0 → 0.5.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +13 -2
  4. data/bin/chi +29 -39
  5. data/docs/cli.md +135 -73
  6. data/docs/configuration.md +15 -20
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +40 -0
  9. data/docs/plugins.md +50 -0
  10. data/docs/releasing.md +9 -6
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +16 -11
  16. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  17. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
  19. data/lib/samagotchi/bundles/system/identity.md +5 -0
  20. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  21. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  22. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  23. data/lib/samagotchi/client.rb +16 -20
  24. data/lib/samagotchi/config.rb +40 -100
  25. data/lib/samagotchi/engine.rb +50 -354
  26. data/lib/samagotchi/kernel_loop.rb +33 -44
  27. data/lib/samagotchi/live_versions.rb +7 -1
  28. data/lib/samagotchi/llm/errors.rb +17 -0
  29. data/lib/samagotchi/llm/http.rb +4 -18
  30. data/lib/samagotchi/llm/openai_chat.rb +17 -0
  31. data/lib/samagotchi/model_profile.rb +4 -10
  32. data/lib/samagotchi/note_command.rb +2 -1
  33. data/lib/samagotchi/reply_wait.rb +48 -4
  34. data/lib/samagotchi/self_report.rb +20 -2
  35. data/lib/samagotchi/send_command.rb +84 -6
  36. data/lib/samagotchi/session.rb +4 -2
  37. data/lib/samagotchi/session_manager.rb +18 -37
  38. data/lib/samagotchi/system_prompt.rb +403 -0
  39. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  41. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  43. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  44. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  45. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  46. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  47. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  48. data/lib/samagotchi/terminal_ui.rb +95 -690
  49. data/lib/samagotchi/thinking.rb +11 -0
  50. data/lib/samagotchi/tool_activity.rb +52 -2
  51. data/lib/samagotchi/tool_runner.rb +3 -0
  52. data/lib/samagotchi/tools/execute.rb +3 -3
  53. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  54. data/lib/samagotchi/tools/read.rb +4 -4
  55. data/lib/samagotchi/update_command.rb +2 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +170 -35
  58. data/lib/samagotchi/web/lan.rb +99 -0
  59. data/lib/samagotchi/web/message_parts.rb +15 -11
  60. data/lib/samagotchi/web/public/activity.js +7 -0
  61. data/lib/samagotchi/web/public/app.js +99 -54
  62. data/lib/samagotchi/web/public/chat_view.js +5 -1
  63. data/lib/samagotchi/web/public/index.html +163 -17
  64. data/lib/samagotchi/web/public/model_pick.js +136 -0
  65. data/lib/samagotchi/web/public/model_picker.js +224 -0
  66. data/lib/samagotchi/web/public/notify.js +10 -0
  67. data/lib/samagotchi/web/public/stage_model.js +110 -0
  68. data/lib/samagotchi/web/public/stage_view.js +580 -0
  69. data/lib/samagotchi/web/public/timing.js +6 -2
  70. data/lib/samagotchi/web/public/turn_events.js +9 -5
  71. data/lib/samagotchi/web/public/turn_model.js +11 -3
  72. data/lib/samagotchi/web/public/turn_view.js +74 -19
  73. data/lib/samagotchi/web/qr.rb +40 -0
  74. data/lib/samagotchi/web/server.rb +101 -11
  75. data/lib/samagotchi/web/token.rb +97 -0
  76. metadata +27 -3
  77. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -83,7 +83,7 @@ module Samagotchi
83
83
 
84
84
  Entry.new(key: "session.retention_days", yaml_path: %w[session retention_days], type: :integer, default: 14, expose: %i[env config cli]),
85
85
  Entry.new(key: "session.max_count", yaml_path: %w[session max_count], type: :integer, default: 500, expose: %i[env config cli]),
86
- Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "running", expose: %i[env config cli]),
86
+ Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "", expose: %i[env config cli]),
87
87
  Entry.new(key: "session.sweep_interval_hours", yaml_path: %w[session sweep_interval_hours], type: :integer, default: 24, expose: %i[env config cli]),
88
88
  Entry.new(key: "session.idle_exit_minutes", yaml_path: %w[session idle_exit_minutes], type: :float, default: 30.0, expose: %i[env config cli]),
89
89
  # Plain `chi` runs like `chi --shared` (bin/chi, LaunchMode); false, or --no-shared per run, keeps the plain REPL. No CLI flag: that would duplicate --shared.
@@ -108,9 +108,6 @@ module Samagotchi
108
108
  Entry.new(key: "log.level", yaml_path: %w[log level], type: :enum, default: "info", expose: %i[env config cli], enum_values: %w[debug info warn error]),
109
109
 
110
110
  Entry.new(key: "status.line", yaml_path: %w[status line], type: :string, default: "on", expose: %i[env config cli]),
111
- Entry.new(key: "status.width_mode", yaml_path: %w[status width_mode], type: :string, default: "terminal_cap", expose: %i[env config cli]),
112
- Entry.new(key: "status.max_width", yaml_path: %w[status max_width], type: :integer, default: 160, expose: %i[env config cli]),
113
- Entry.new(key: "status.fixed_width", yaml_path: %w[status fixed_width], type: :integer, default: 120, expose: %i[env config cli]),
114
111
 
115
112
  Entry.new(key: "context.status", yaml_path: %w[context status], type: :bool, default: true, expose: %i[env config cli]),
116
113
  Entry.new(key: "context.window_tokens", yaml_path: %w[context window_tokens], type: :integer, default: nil, expose: %i[env config cli]),
@@ -118,8 +115,6 @@ module Samagotchi
118
115
  Entry.new(key: "context.status_thresholds", yaml_path: %w[context status_thresholds],type: :string, default: "20,40,60,80", expose: %i[env config cli]),
119
116
  Entry.new(key: "context.status_cadence", yaml_path: %w[context status_cadence], type: :integer, default: 0, expose: %i[env config cli]),
120
117
 
121
- Entry.new(key: "thinking.ui", yaml_path: %w[thinking ui], type: :string, default: nil, expose: %i[env config cli]),
122
- Entry.new(key: "thinking.render_interval", yaml_path: %w[thinking render_interval], type: :float, default: 0.08, expose: %i[env config cli]),
123
118
  Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli]),
124
119
  # How much models think: off|low|medium|high|default, parsed by Thinking
125
120
  # (a :string, since YAML reads an unquoted off as false). The CLI flag
@@ -153,8 +148,8 @@ module Samagotchi
153
148
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
154
149
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
155
150
  Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli]),
156
- # The page shows each turn as one block of generations (the live one at the bottom); false brings back the row of bubbles; ?view=turn|chat overrides it per page load.
157
- Entry.new(key: "web.turn_view", yaml_path: %w[web turn_view], type: :bool, default: true, expose: %i[env config cli]),
151
+ # The page's view of a turn: turn (one block of generations, the live one at the bottom), stage (the running turn pinned above the composer) or chat (a row of bubbles); ?view= overrides it per page load.
152
+ Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "turn", expose: %i[env config cli], enum_values: %w[turn stage chat]),
158
153
  # Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
159
154
  Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli]),
160
155
 
@@ -179,12 +174,9 @@ module Samagotchi
179
174
  }.freeze
180
175
  # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
181
176
  SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
182
- # Removed settings that warn on their own (Engine.warn_removed_backend_setting).
183
- REMOVED_KEYS = %w[backend].freeze
184
177
 
185
178
  # Fast lookup maps
186
179
  BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
187
- BY_ENV = ENTRIES.each_with_object({}) { |e, h| h[e.env_key] = e }.freeze
188
180
 
189
181
  class << self
190
182
  # The +candidates+ within edit distance 2 of +probe+, closest first
@@ -197,10 +189,6 @@ module Samagotchi
197
189
  BY_KEY[key.to_s]
198
190
  end
199
191
 
200
- def find_by_env(env_key)
201
- BY_ENV[env_key.to_s]
202
- end
203
-
204
192
  def all_entries
205
193
  ENTRIES
206
194
  end
@@ -316,26 +304,10 @@ module Samagotchi
316
304
  end
317
305
 
318
306
  # Lookup yaml_path in nested hash, accepting snake leaf, kebab alias and
319
- # entry-specific yaml_aliases, then legacy flat UPPER keys (e.g.,
320
- # SAMAGOTCHI_DEFAULT_MODEL) for transition: the nested key wins when a
321
- # file has both (ConfigFile.load_global_env! warns about that).
307
+ # entry-specific yaml_aliases.
322
308
  def lookup_yaml(data, yaml_path)
323
309
  entry = ENTRIES.find { |e| e.yaml_path == yaml_path }
324
- nested = lookup_nested(data, yaml_path, entry)
325
- return nested unless nested.nil?
326
-
327
- lookup_legacy(data, entry)
328
- end
329
-
330
- # The legacy flat key's value for +entry+, or nil.
331
- def lookup_legacy(data, entry)
332
- return nil unless entry && data.is_a?(Hash)
333
-
334
- key = entry.env_key
335
- return data[key] if data.key?(key)
336
- return data[key.to_sym] if data.key?(key.to_sym)
337
-
338
- nil
310
+ lookup_nested(data, yaml_path, entry)
339
311
  end
340
312
 
341
313
  # The nested key's value for +yaml_path+, or nil.
@@ -374,21 +346,11 @@ module Samagotchi
374
346
  end
375
347
  end
376
348
 
377
- # Convenience: load file_data from path + snapshot
378
- def load_snapshot(path: nil, env: ENV, cli_overrides: {})
379
- path ||= Samagotchi::ConfigFile.global_path(env: env) rescue nil
380
- file_data = Samagotchi::ConfigFile.read_yaml(env: env, path: path) if path
381
- snapshot(file_data: file_data, env: env, cli_overrides: cli_overrides)
382
- end
383
-
384
- # In-memory store for current process (populated after CLI parse)
385
- def store
386
- @store ||= load_snapshot
387
- end
388
-
389
- def reload!(path: nil, env: ENV, cli_overrides: {})
349
+ # Set this process's CLI settings (bin/chi, after it parsed the flags).
350
+ # Nothing is cached: #get resolves every call, reading config.yml
351
+ # again when it changed.
352
+ def reload!(cli_overrides: {})
390
353
  @cli_overrides = cli_overrides.dup
391
- @store = load_snapshot(path: path, env: env, cli_overrides: cli_overrides)
392
354
  ensure
393
355
  # The log resolves its file and level from here.
394
356
  Samagotchi::Log.invalidate! if defined?(Samagotchi::Log)
@@ -398,6 +360,18 @@ module Samagotchi
398
360
  @cli_overrides ||= {}
399
361
  end
400
362
 
363
+ # This process's CLI settings as the env a spawned worker takes them
364
+ # from ({"SAMAGOTCHI_..." => "value"}): a worker gets no CLI args, and
365
+ # reads everything else from ENV and config.yml itself.
366
+ def cli_env
367
+ cli_overrides.each_with_object({}) do |(key, value), env|
368
+ entry = find_by_key(key) || ENTRIES.find { |e| e.cli_flag == key.to_s }
369
+ next unless entry&.env_exposed? && !value.nil?
370
+
371
+ env[entry.env_key] = value.to_s
372
+ end
373
+ end
374
+
401
375
  def get(key)
402
376
  get_with_origin(key).first
403
377
  end
@@ -433,13 +407,13 @@ module Samagotchi
433
407
  # code doesn't read (with a "did you mean" when a known key is close).
434
408
  # Every config-exposed entry in ENTRIES is known as written; names under
435
409
  # the maps are the user's, and host/model entries may hold the keys
436
- # their readers take. Legacy flat keys warn in ConfigFile.load_global_env!.
410
+ # their readers take.
437
411
  def validate_yaml_sections(data)
438
412
  return [] unless data.is_a?(Hash)
439
413
 
440
414
  data.flat_map do |key, value|
441
415
  key = key.to_s
442
- if find_by_env(key)&.config_exposed? || FREE_FORM_MAPS.include?(key) || REMOVED_KEYS.include?(key)
416
+ if FREE_FORM_MAPS.include?(key)
443
417
  []
444
418
  elsif MAP_ENTRY_KEYS.key?(key)
445
419
  map_entry_problems(key, value)
@@ -474,7 +448,7 @@ module Samagotchi
474
448
  def key_problem(dotted)
475
449
  return nil if known_config_keys.include?(dotted)
476
450
 
477
- entry = find_by_key(dotted) || find_by_env(dotted)
451
+ entry = find_by_key(dotted)
478
452
  if entry
479
453
  where = entry.cli_exposed? ? "#{entry.env_key} or #{entry.cli_flag}" : entry.env_key
480
454
  return "config: '#{dotted}' can't be set in config.yml; use #{where}"
@@ -482,7 +456,7 @@ module Samagotchi
482
456
 
483
457
  canonical = ENTRIES.select(&:config_exposed?).map(&:key) + known_sections.to_a +
484
458
  SECTION_EXTRA_KEYS.flat_map { |s, leaves| leaves.map { |l| "#{s}.#{l}" } } + FREE_FORM_MAPS + MAP_ENTRY_KEYS.keys
485
- # A legacy-looking flat key compares by its nested form.
459
+ # A flat SAMAGOTCHI_* key (the env name) compares by its nested form.
486
460
  probe = dotted.start_with?("SAMAGOTCHI_") ? dotted.delete_prefix("SAMAGOTCHI_").downcase : dotted
487
461
  unknown_key_message(dotted, probe, canonical)
488
462
  end
@@ -533,7 +507,6 @@ module Samagotchi
533
507
  XDG_CONFIG_HOME_ENV = "XDG_CONFIG_HOME"
534
508
  CONFIG_DIR = "samagotchi"
535
509
  CONFIG_FILE = "config.yml"
536
- DEFAULT_MODEL_KEY = "SAMAGOTCHI_DEFAULT_MODEL"
537
510
  MODEL_ALIASES_KEY = "model_aliases"
538
511
  MODELS_KEY = "models"
539
512
 
@@ -641,47 +614,18 @@ module Samagotchi
641
614
  end
642
615
  end
643
616
 
644
- # New unified loader: delegates to Samagotchi::Config registry.
645
- # Breaking: YAML now expects lower snake dotted paths (default.model)
646
- # instead of UPPER scalar keys. For compatibility, UPPER keys are warned
647
- # and ignored — env wins over file as before, now via registry precedence
648
- # (CLI > ENV > file > default). Returns true if file existed.
649
- def load_global_env!(env: ENV, path: global_path(env: env))
617
+ # Check config.yml once at start (warnings for keys the code doesn't
618
+ # read) and clear the CLI settings. Nothing is copied into ENV: every
619
+ # Config.get reads the file again when it changed, so a long-lived
620
+ # process (chi web) and the workers it spawns see edits, and a file
621
+ # value reports origin :file. Returns true if the file existed.
622
+ def load!(env: ENV, path: global_path(env: env))
650
623
  existed = File.file?(path)
651
624
  raw = read_yaml(env: env, path: path) || {}
652
625
  if existed
653
626
  Samagotchi::Config.validate_yaml_sections(raw).each { |w| Log.warn(:config, "config_key", echo: "Warning: #{w}") }
654
- # Warn on legacy UPPER keys; the nested key wins when both are set.
655
- # Any other flat key is unknown to validate_yaml_sections.
656
- raw.each_key do |k|
657
- entry = Samagotchi::Config.find_by_env(k)
658
- next unless entry&.config_exposed?
659
-
660
- if !Samagotchi::Config.lookup_nested(raw, entry.yaml_path, entry).nil?
661
- nested = entry.yaml_path.join(".")
662
- Log.warn(:config, "legacy_key", echo: "Warning: config: both '#{k}' and '#{nested}' are set; using '#{nested}', remove the flat key", key: k.to_s)
663
- else
664
- Log.warn(:config, "legacy_key", echo: "Warning: config key '#{k}' is legacy UPPER — use '#{entry.yaml_path.join('.')}'", key: k.to_s)
665
- end
666
- end
667
- end
668
- # For process-wide access, prime the Config store (so Config.get works)
669
- Samagotchi::Config.reload!(path: path, env: env, cli_overrides: {})
670
- # Keep ENV in sync for any code still reading ENV directly (transition).
671
- # Only for keys that were actually present in file (not defaults) to
672
- # avoid polluting ENV with defaults.
673
- Samagotchi::Config.all_entries.each do |entry|
674
- next unless entry.env_exposed?
675
- # The env's thinking.level outranks models:/hosts: levels, the file's doesn't (Thinking.resolve).
676
- next if entry.key == "thinking.level"
677
- # check if file actually contained this key (including legacy flat)
678
- file_val = Samagotchi::Config.lookup_yaml(raw, entry.yaml_path)
679
- file_val ||= raw[entry.env_key] if raw.key?(entry.env_key)
680
- next if file_val.nil?
681
- val = Samagotchi::Config.store[entry.key]
682
- next if val.nil?
683
- env[entry.env_key] = val.to_s unless env.key?(entry.env_key)
684
627
  end
628
+ Samagotchi::Config.reload!(cli_overrides: {})
685
629
  existed
686
630
  end
687
631
 
@@ -815,15 +759,16 @@ module Samagotchi
815
759
  end
816
760
  end
817
761
 
818
- # If no hosts defined, synthesize "default" from SAMAGOTCHI_SERVER_HOST/PORT
762
+ # If no hosts defined, synthesize "default" from server.host/port/transport.
819
763
  if normalized.empty?
820
- default_host = env.fetch("SAMAGOTCHI_SERVER_HOST", "localhost").to_s.strip
764
+ opts = { file_data: data, env: env, cli_overrides: Samagotchi::Config.cli_overrides }
765
+ default_host = Samagotchi::Config.resolve("server.host", **opts).to_s.strip
821
766
  default_host = "localhost" if default_host.empty?
822
- default_port = env.fetch("SAMAGOTCHI_SERVER_PORT", "8080").to_s.strip
823
- default_port = default_port.empty? ? 8080 : default_port.to_i
767
+ default_port = Samagotchi::Config.resolve("server.port", **opts).to_i
824
768
  default_port = 8080 if default_port <= 0 || default_port > 65535
825
- transport_env = env.fetch("SAMAGOTCHI_SERVER_TRANSPORT", "").to_s.strip.downcase
826
- transport_sym = VALID_TRANSPORTS_FOR_CONFIG.include?(transport_env) ? transport_env.to_sym : nil
769
+ transport_sym = Samagotchi::Config.resolve_with_origin("server.transport", **opts).then do |value, origin|
770
+ origin == :default ? nil : value.to_sym
771
+ end
827
772
  normalized["default"] = { name: "default", host: default_host, port: default_port, transport: transport_sym, original_name: "default" }
828
773
  end
829
774
  normalized
@@ -959,23 +904,18 @@ module Samagotchi
959
904
 
960
905
  raw_data = read_yaml(env: env, path: path) || {}
961
906
 
962
- # Migrate to new dotted nested form: default.model (Option A)
963
907
  raw_data["default"] ||= {}
964
908
  if raw_data["default"].is_a?(Hash)
965
909
  raw_data["default"]["model"] = resolved
966
910
  else
967
911
  raw_data["default"] = { "model" => resolved }
968
912
  end
969
- # Remove legacy UPPER key if present
970
- raw_data.delete(DEFAULT_MODEL_KEY)
971
913
  FileUtils.mkdir_p(File.dirname(path))
972
914
  tmp = "#{path}.tmp"
973
915
  File.write(tmp, YAML.dump(raw_data))
974
916
  File.rename(tmp, path)
975
917
  clear_yaml_cache!(path)
976
- env[DEFAULT_MODEL_KEY] = resolved
977
- # Also sync new Config store if loaded
978
- Samagotchi::Config.reload!(path: path, env: env, cli_overrides: {})
918
+ Samagotchi::Config.reload!(cli_overrides: Samagotchi::Config.cli_overrides)
979
919
  true
980
920
  end
981
921