samagotchi 0.3.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -8,6 +8,9 @@ require "set"
8
8
  require_relative "log"
9
9
 
10
10
  module Samagotchi
11
+ # Parses the thinking: levels of host and model entries (it needs Config).
12
+ autoload :Thinking, File.expand_path("thinking", __dir__)
13
+
11
14
  # Unified configuration registry implementing the implicit convention:
12
15
  # ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
13
16
  # YAML attr: / nested: {param:} (lower snake, dot = nesting, Option A leaf keeps _)
@@ -22,7 +25,7 @@ module Samagotchi
22
25
  # handled by ConfigFile (same file, below), which reads the *same* parsed YAML
23
26
  # through ConfigFile.read_yaml so the file is parsed once per change.
24
27
  module Config
25
- Entry = Struct.new(:key, :yaml_path, :type, :default, :expose, :enum_values, :aliases, :yaml_aliases, keyword_init: true) do
28
+ Entry = Struct.new(:key, :yaml_path, :type, :default, :expose, :enum_values, :yaml_aliases, keyword_init: true) do
26
29
  def env_key
27
30
  "SAMAGOTCHI_" + yaml_path.map { |p| p.upcase }.join("_")
28
31
  end
@@ -80,7 +83,7 @@ module Samagotchi
80
83
 
81
84
  Entry.new(key: "session.retention_days", yaml_path: %w[session retention_days], type: :integer, default: 14, expose: %i[env config cli]),
82
85
  Entry.new(key: "session.max_count", yaml_path: %w[session max_count], type: :integer, default: 500, expose: %i[env config cli]),
83
- 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]),
84
87
  Entry.new(key: "session.sweep_interval_hours", yaml_path: %w[session sweep_interval_hours], type: :integer, default: 24, expose: %i[env config cli]),
85
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]),
86
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.
@@ -105,9 +108,6 @@ module Samagotchi
105
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]),
106
109
 
107
110
  Entry.new(key: "status.line", yaml_path: %w[status line], type: :string, default: "on", expose: %i[env config cli]),
108
- Entry.new(key: "status.width_mode", yaml_path: %w[status width_mode], type: :string, default: "terminal_cap", expose: %i[env config cli]),
109
- Entry.new(key: "status.max_width", yaml_path: %w[status max_width], type: :integer, default: 160, expose: %i[env config cli]),
110
- Entry.new(key: "status.fixed_width", yaml_path: %w[status fixed_width], type: :integer, default: 120, expose: %i[env config cli]),
111
111
 
112
112
  Entry.new(key: "context.status", yaml_path: %w[context status], type: :bool, default: true, expose: %i[env config cli]),
113
113
  Entry.new(key: "context.window_tokens", yaml_path: %w[context window_tokens], type: :integer, default: nil, expose: %i[env config cli]),
@@ -115,9 +115,12 @@ module Samagotchi
115
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]),
116
116
  Entry.new(key: "context.status_cadence", yaml_path: %w[context status_cadence], type: :integer, default: 0, expose: %i[env config cli]),
117
117
 
118
- Entry.new(key: "thinking.ui", yaml_path: %w[thinking ui], type: :string, default: nil, expose: %i[env config cli]),
119
- Entry.new(key: "thinking.render_interval", yaml_path: %w[thinking render_interval], type: :float, default: 0.08, expose: %i[env config cli]),
120
118
  Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli]),
119
+ # How much models think: off|low|medium|high|default, parsed by Thinking
120
+ # (a :string, since YAML reads an unquoted off as false). The CLI flag
121
+ # is bin/chi's own --thinking; models: and hosts: entries rank below
122
+ # the flag and the env, above config.yml's value.
123
+ Entry.new(key: "thinking.level", yaml_path: %w[thinking level], type: :string, default: "default", expose: %i[env config]),
121
124
 
122
125
  Entry.new(key: "default.n_predict", yaml_path: %w[default n_predict], type: :integer, default: nil, expose: %i[env config cli]),
123
126
  Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli]),
@@ -128,6 +131,10 @@ module Samagotchi
128
131
  # Asks again in the same turn after an empty answer (EmptyAnswerRetry);
129
132
  # 0 = off, capped at 3. No CLI flag: workers get no CLI args.
130
133
  Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
134
+ # What `chi update` touches; its --no-gem/--no-bundles/--no-desktop turn one off for a run.
135
+ Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
136
+ Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
137
+ Entry.new(key: "update.desktop", yaml_path: %w[update desktop], type: :bool, default: true, expose: %i[env config]),
131
138
 
132
139
  Entry.new(key: "read.truncate_at_bytes", yaml_path: %w[read truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
133
140
  Entry.new(key: "read.preview_bytes", yaml_path: %w[read preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
@@ -141,8 +148,8 @@ module Samagotchi
141
148
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
142
149
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
143
150
  Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli]),
144
- # 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.
145
- 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]),
146
153
  # Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
147
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]),
148
155
 
@@ -162,33 +169,24 @@ module Samagotchi
162
169
  # Maps of named entries and the keys an entry may hold
163
170
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
164
171
  MAP_ENTRY_KEYS = {
165
- "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling enabled].freeze,
166
- "models" => %w[profile vision sampling].freeze
172
+ "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled].freeze,
173
+ "models" => %w[profile vision sampling thinking].freeze
167
174
  }.freeze
168
175
  # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
169
176
  SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
170
- # Removed settings that warn on their own (Engine.warn_removed_backend_setting).
171
- REMOVED_KEYS = %w[backend].freeze
172
177
 
173
178
  # Fast lookup maps
174
179
  BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
175
- BY_ENV = ENTRIES.each_with_object({}) do |e, h|
176
- h[e.env_key] = e
177
- Array(e.aliases).each { |a| h[a] = e }
178
- end.freeze
179
- BY_CLI = ENTRIES.each_with_object({}) { |e, h| h[e.cli_flag] = e if e.cli_exposed? }.freeze
180
180
 
181
181
  class << self
182
- def find_by_key(key)
183
- BY_KEY[key.to_s]
184
- end
185
-
186
- def find_by_env(env_key)
187
- BY_ENV[env_key.to_s]
182
+ # The +candidates+ within edit distance 2 of +probe+, closest first
183
+ # (the "did you mean" of an unknown key or host).
184
+ def near_names(probe, candidates)
185
+ candidates.uniq.map { |c| [levenshtein(probe, c), c] }.select { |d, _| d <= 2 }.sort.map(&:last)
188
186
  end
189
187
 
190
- def find_by_cli(flag)
191
- BY_CLI[flag.to_s]
188
+ def find_by_key(key)
189
+ BY_KEY[key.to_s]
192
190
  end
193
191
 
194
192
  def all_entries
@@ -306,26 +304,10 @@ module Samagotchi
306
304
  end
307
305
 
308
306
  # Lookup yaml_path in nested hash, accepting snake leaf, kebab alias and
309
- # entry-specific yaml_aliases, then legacy flat UPPER keys (e.g.,
310
- # SAMAGOTCHI_DEFAULT_MODEL) for transition: the nested key wins when a
311
- # file has both (ConfigFile.load_global_env! warns about that).
307
+ # entry-specific yaml_aliases.
312
308
  def lookup_yaml(data, yaml_path)
313
309
  entry = ENTRIES.find { |e| e.yaml_path == yaml_path }
314
- nested = lookup_nested(data, yaml_path, entry)
315
- return nested unless nested.nil?
316
-
317
- lookup_legacy(data, entry)
318
- end
319
-
320
- # The legacy flat key's value for +entry+, or nil.
321
- def lookup_legacy(data, entry)
322
- return nil unless entry && data.is_a?(Hash)
323
-
324
- [entry.env_key, *Array(entry.aliases)].each do |key|
325
- return data[key] if data.key?(key)
326
- return data[key.to_sym] if data.key?(key.to_sym)
327
- end
328
- nil
310
+ lookup_nested(data, yaml_path, entry)
329
311
  end
330
312
 
331
313
  # The nested key's value for +yaml_path+, or nil.
@@ -364,21 +346,11 @@ module Samagotchi
364
346
  end
365
347
  end
366
348
 
367
- # Convenience: load file_data from path + snapshot
368
- def load_snapshot(path: nil, env: ENV, cli_overrides: {})
369
- path ||= Samagotchi::ConfigFile.global_path(env: env) rescue nil
370
- file_data = Samagotchi::ConfigFile.read_yaml(env: env, path: path) if path
371
- snapshot(file_data: file_data, env: env, cli_overrides: cli_overrides)
372
- end
373
-
374
- # In-memory store for current process (populated after CLI parse)
375
- def store
376
- @store ||= load_snapshot
377
- end
378
-
379
- 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: {})
380
353
  @cli_overrides = cli_overrides.dup
381
- @store = load_snapshot(path: path, env: env, cli_overrides: cli_overrides)
382
354
  ensure
383
355
  # The log resolves its file and level from here.
384
356
  Samagotchi::Log.invalidate! if defined?(Samagotchi::Log)
@@ -388,10 +360,32 @@ module Samagotchi
388
360
  @cli_overrides ||= {}
389
361
  end
390
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
+
391
375
  def get(key)
392
376
  get_with_origin(key).first
393
377
  end
394
378
 
379
+ # A timeout setting (server.open_timeout, server.read_timeout) in
380
+ # seconds, +given+ (a caller's own value) first; one that isn't
381
+ # positive (0, a word) is the key's default, on every kind of host.
382
+ def positive_seconds(key, given = nil)
383
+ value = (given.nil? ? get(key) : given).to_i
384
+ value.positive? ? value : find_by_key(key).default
385
+ rescue StandardError
386
+ find_by_key(key)&.default
387
+ end
388
+
395
389
  # [value, origin] for the live value #get returns; origin is one of
396
390
  # :cli, :env, :file, :default.
397
391
  def get_with_origin(key)
@@ -413,13 +407,13 @@ module Samagotchi
413
407
  # code doesn't read (with a "did you mean" when a known key is close).
414
408
  # Every config-exposed entry in ENTRIES is known as written; names under
415
409
  # the maps are the user's, and host/model entries may hold the keys
416
- # their readers take. Legacy flat keys warn in ConfigFile.load_global_env!.
410
+ # their readers take.
417
411
  def validate_yaml_sections(data)
418
412
  return [] unless data.is_a?(Hash)
419
413
 
420
414
  data.flat_map do |key, value|
421
415
  key = key.to_s
422
- if find_by_env(key)&.config_exposed? || FREE_FORM_MAPS.include?(key) || REMOVED_KEYS.include?(key)
416
+ if FREE_FORM_MAPS.include?(key)
423
417
  []
424
418
  elsif MAP_ENTRY_KEYS.key?(key)
425
419
  map_entry_problems(key, value)
@@ -454,7 +448,7 @@ module Samagotchi
454
448
  def key_problem(dotted)
455
449
  return nil if known_config_keys.include?(dotted)
456
450
 
457
- entry = find_by_key(dotted) || find_by_env(dotted)
451
+ entry = find_by_key(dotted)
458
452
  if entry
459
453
  where = entry.cli_exposed? ? "#{entry.env_key} or #{entry.cli_flag}" : entry.env_key
460
454
  return "config: '#{dotted}' can't be set in config.yml; use #{where}"
@@ -462,7 +456,7 @@ module Samagotchi
462
456
 
463
457
  canonical = ENTRIES.select(&:config_exposed?).map(&:key) + known_sections.to_a +
464
458
  SECTION_EXTRA_KEYS.flat_map { |s, leaves| leaves.map { |l| "#{s}.#{l}" } } + FREE_FORM_MAPS + MAP_ENTRY_KEYS.keys
465
- # A legacy-looking flat key compares by its nested form.
459
+ # A flat SAMAGOTCHI_* key (the env name) compares by its nested form.
466
460
  probe = dotted.start_with?("SAMAGOTCHI_") ? dotted.delete_prefix("SAMAGOTCHI_").downcase : dotted
467
461
  unknown_key_message(dotted, probe, canonical)
468
462
  end
@@ -485,7 +479,7 @@ module Samagotchi
485
479
  # share its last segment, at most three.
486
480
  def unknown_key_message(key, probe, candidates, prefix: "")
487
481
  candidates = candidates.uniq
488
- near = candidates.map { |c| [levenshtein(probe, c), c] }.select { |d, _| d <= 2 }.sort.map(&:last)
482
+ near = near_names(probe, candidates)
489
483
  close = (near + candidates.select { |c| c.split(".").last == probe.split(".").last }).uniq.first(3)
490
484
  hint = close.empty? ? "" : " (did you mean #{close.map { |c| "'#{prefix}#{c}'" }.join(' or ')}?)"
491
485
  "config: unknown key '#{key}'#{hint}"
@@ -513,7 +507,6 @@ module Samagotchi
513
507
  XDG_CONFIG_HOME_ENV = "XDG_CONFIG_HOME"
514
508
  CONFIG_DIR = "samagotchi"
515
509
  CONFIG_FILE = "config.yml"
516
- DEFAULT_MODEL_KEY = "SAMAGOTCHI_DEFAULT_MODEL"
517
510
  MODEL_ALIASES_KEY = "model_aliases"
518
511
  MODELS_KEY = "models"
519
512
 
@@ -621,46 +614,18 @@ module Samagotchi
621
614
  end
622
615
  end
623
616
 
624
- # New unified loader: delegates to Samagotchi::Config registry.
625
- # Breaking: YAML now expects lower snake dotted paths (default.model)
626
- # instead of UPPER scalar keys. For compatibility, UPPER keys are warned
627
- # and ignored — env wins over file as before, now via registry precedence
628
- # (CLI > ENV > file > default). Returns true if file existed.
629
- 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))
630
623
  existed = File.file?(path)
631
624
  raw = read_yaml(env: env, path: path) || {}
632
625
  if existed
633
626
  Samagotchi::Config.validate_yaml_sections(raw).each { |w| Log.warn(:config, "config_key", echo: "Warning: #{w}") }
634
- # Warn on legacy UPPER keys; the nested key wins when both are set.
635
- # Any other flat key is unknown to validate_yaml_sections.
636
- raw.each_key do |k|
637
- entry = Samagotchi::Config.find_by_env(k)
638
- next unless entry&.config_exposed?
639
-
640
- if !Samagotchi::Config.lookup_nested(raw, entry.yaml_path, entry).nil?
641
- nested = entry.yaml_path.join(".")
642
- Log.warn(:config, "legacy_key", echo: "Warning: config: both '#{k}' and '#{nested}' are set; using '#{nested}', remove the flat key", key: k.to_s)
643
- else
644
- Log.warn(:config, "legacy_key", echo: "Warning: config key '#{k}' is legacy UPPER — use '#{entry.yaml_path.join('.')}'", key: k.to_s)
645
- end
646
- end
647
- end
648
- # For process-wide access, prime the Config store (so Config.get works)
649
- Samagotchi::Config.reload!(path: path, env: env, cli_overrides: {})
650
- # Keep ENV in sync for any code still reading ENV directly (transition).
651
- # Only for keys that were actually present in file (not defaults) to
652
- # avoid polluting ENV with defaults.
653
- Samagotchi::Config.all_entries.each do |entry|
654
- next unless entry.env_exposed?
655
- # check if file actually contained this key (including legacy flat)
656
- file_val = Samagotchi::Config.lookup_yaml(raw, entry.yaml_path)
657
- file_val ||= raw[entry.env_key] if raw.key?(entry.env_key)
658
- Array(entry.aliases).each { |a| file_val ||= raw[a] if raw.key?(a) }
659
- next if file_val.nil?
660
- val = Samagotchi::Config.store[entry.key]
661
- next if val.nil?
662
- env[entry.env_key] = val.to_s unless env.key?(entry.env_key)
663
627
  end
628
+ Samagotchi::Config.reload!(cli_overrides: {})
664
629
  existed
665
630
  end
666
631
 
@@ -726,6 +691,7 @@ module Samagotchi
726
691
  first_token_timeout = raw_cfg.key?("first_token_timeout") ? raw_cfg["first_token_timeout"] : raw_cfg[:first_token_timeout]
727
692
  vision = ConfigFile.vision_flag(raw_cfg.key?("vision") ? raw_cfg["vision"] : raw_cfg[:vision], "hosts entry '#{name}'")
728
693
  sampling = ConfigFile.sampling_map(raw_cfg.key?("sampling") ? raw_cfg["sampling"] : raw_cfg[:sampling], "hosts entry '#{name}'")
694
+ thinking = Thinking.level(raw_cfg.key?("thinking") ? raw_cfg["thinking"] : raw_cfg[:thinking], "hosts entry '#{name}'")
729
695
  unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
730
696
  warn_once "Warning: hosts entry '#{name}': first_token_timeout must be seconds (0 = off); using the default"
731
697
  first_token_timeout = nil
@@ -789,19 +755,20 @@ module Samagotchi
789
755
  api: api_val&.to_sym, original_name: name, scheme: scheme,
790
756
  url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
791
757
  profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
792
- vision: vision, sampling: sampling }
758
+ vision: vision, sampling: sampling, thinking: thinking }
793
759
  end
794
760
  end
795
761
 
796
- # If no hosts defined, synthesize "default" from SAMAGOTCHI_SERVER_HOST/PORT
762
+ # If no hosts defined, synthesize "default" from server.host/port/transport.
797
763
  if normalized.empty?
798
- 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
799
766
  default_host = "localhost" if default_host.empty?
800
- default_port = env.fetch("SAMAGOTCHI_SERVER_PORT", "8080").to_s.strip
801
- default_port = default_port.empty? ? 8080 : default_port.to_i
767
+ default_port = Samagotchi::Config.resolve("server.port", **opts).to_i
802
768
  default_port = 8080 if default_port <= 0 || default_port > 65535
803
- transport_env = env.fetch("SAMAGOTCHI_SERVER_TRANSPORT", "").to_s.strip.downcase
804
- 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
805
772
  normalized["default"] = { name: "default", host: default_host, port: default_port, transport: transport_sym, original_name: "default" }
806
773
  end
807
774
  normalized
@@ -880,6 +847,26 @@ module Samagotchi
880
847
  [nil, value]
881
848
  end
882
849
 
850
+ # Hosted providers' names, which no local model family uses: as a
851
+ # prefix they always mean a host ("openai:gpt-4o"). "mistral" and
852
+ # "deepseek" are left out: Ollama has mistral:7b and deepseek-* tags.
853
+ PROVIDER_HOST_NAMES = %w[openrouter openai anthropic google gemini groq xai together fireworks].freeze
854
+
855
+ # The host a model ref names when that host isn't configured, or nil.
856
+ # A ':' in a model id is often a tag (qwen3:8b, org/model:Q4_K_M), so
857
+ # the prefix counts as a host only when it could be a host name and
858
+ # either the rest looks like a hosted provider's org/model id
859
+ # ("nosuch:anthropic/claude-sonnet-4"; Ollama-style tags never hold a
860
+ # '/') or the prefix is a provider's name (PROVIDER_HOST_NAMES).
861
+ def unknown_host_prefix(raw, hosts:)
862
+ prefix, rest = raw.to_s.strip.split(":", 2)
863
+ return nil if rest.to_s.strip.empty? || !prefix.match?(HOST_NAME_RE)
864
+ return nil unless rest.include?("/") || PROVIDER_HOST_NAMES.include?(prefix.downcase)
865
+
866
+ prefix = prefix.downcase
867
+ hosts.keys.map { |k| k.to_s.downcase }.include?(prefix) ? nil : prefix
868
+ end
869
+
883
870
  def hosts_json_for_env(env: ENV, path: global_path(env: env))
884
871
  hosts = hosts_config(env: env, path: path)
885
872
  # Only serialize if non-default or explicitly configured hosts:
@@ -892,7 +879,7 @@ module Samagotchi
892
879
  location = v[:url] ? { "url" => v[:url] } : { "host" => v[:host], "port" => v[:port] }
893
880
  location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
894
881
  "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
895
- "vision" => v[:vision], "sampling" => v[:sampling]).compact
882
+ "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s).compact
896
883
  end
897
884
  JSON.generate(simple)
898
885
  rescue StandardError
@@ -917,23 +904,18 @@ module Samagotchi
917
904
 
918
905
  raw_data = read_yaml(env: env, path: path) || {}
919
906
 
920
- # Migrate to new dotted nested form: default.model (Option A)
921
907
  raw_data["default"] ||= {}
922
908
  if raw_data["default"].is_a?(Hash)
923
909
  raw_data["default"]["model"] = resolved
924
910
  else
925
911
  raw_data["default"] = { "model" => resolved }
926
912
  end
927
- # Remove legacy UPPER key if present
928
- raw_data.delete(DEFAULT_MODEL_KEY)
929
913
  FileUtils.mkdir_p(File.dirname(path))
930
914
  tmp = "#{path}.tmp"
931
915
  File.write(tmp, YAML.dump(raw_data))
932
916
  File.rename(tmp, path)
933
917
  clear_yaml_cache!(path)
934
- env[DEFAULT_MODEL_KEY] = resolved
935
- # Also sync new Config store if loaded
936
- Samagotchi::Config.reload!(path: path, env: env, cli_overrides: {})
918
+ Samagotchi::Config.reload!(cli_overrides: Samagotchi::Config.cli_overrides)
937
919
  true
938
920
  end
939
921
 
@@ -976,6 +958,8 @@ module Samagotchi
976
958
  result[key][:vision] = vision unless vision.nil?
977
959
  sampling = sampling_map(v.key?("sampling") ? v["sampling"] : v[:sampling], "models: #{key}")
978
960
  result[key][:sampling] = sampling if sampling
961
+ thinking = Thinking.level(v.key?("thinking") ? v["thinking"] : v[:thinking], "models: #{key}")
962
+ result[key][:thinking] = thinking if thinking
979
963
  end
980
964
  rescue StandardError
981
965
  {}
@@ -1,5 +1,6 @@
1
- // Chi Helper: the "Send to chi" Service opens the panel with the selection,
2
- // the hotkey with the clipboard. No Dock icon (LSUIElement); it stays
1
+ // Chi Helper: the "Send to chi" Service opens the panel with the selection
2
+ // (text, or images: files in Finder, a picture in Preview), the hotkey with
3
+ // the clipboard (a screenshot too). No Dock icon (LSUIElement); it stays
3
4
  // running after the first launch. `ChiHelper --login on|off|status` (run
4
5
  // directly by chi desktop) manages the login item and exits.
5
6
  import AppKit
@@ -10,11 +11,12 @@ final class ServiceProvider: NSObject {
10
11
 
11
12
  // Info.plist NSServices: NSMessage = sendToChi.
12
13
  @objc func sendToChi(_ pboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString>) {
13
- guard let text = pboard.string(forType: .string) else {
14
- error.pointee = "No text in the selection" as NSString
14
+ let (text, images) = ImageIntake.read(pboard)
15
+ guard !text.isEmpty || !images.isEmpty else {
16
+ error.pointee = "No text or image in the selection" as NSString
15
17
  return
16
18
  }
17
- app?.open(text: text)
19
+ app?.open(text: text, images: images)
18
20
  }
19
21
  }
20
22
 
@@ -28,6 +30,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
28
30
 
29
31
  func applicationDidFinishLaunching(_ notification: Notification) {
30
32
  provider.app = self
33
+ ImageIntake.sweep()
31
34
  NSApp.servicesProvider = provider
32
35
  NSUpdateDynamicServices()
33
36
  hotkey = Hotkey { [weak self] in self?.openFromClipboard() }
@@ -54,12 +57,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
54
57
 
55
58
  /// The hotkey doesn't activate us, so the frontmost app is the source.
56
59
  func openFromClipboard() {
57
- open(text: NSPasteboard.general.string(forType: .string) ?? "")
60
+ let (text, images) = ImageIntake.read(NSPasteboard.general)
61
+ open(text: text, images: images)
58
62
  }
59
63
 
60
- func open(text: String) {
64
+ func open(text: String, images: [PanelImage] = []) {
61
65
  let from = sourceApp
62
- panel.show(text: text, source: from?.localizedName?.lowercased() ?? "desktop", returnTo: from)
66
+ panel.show(text: text, images: images, source: from?.localizedName?.lowercased() ?? "desktop", returnTo: from)
63
67
  }
64
68
  }
65
69
 
@@ -106,7 +106,9 @@ final class ChiRunner {
106
106
  /// Runs `chi <args>`, with stdin written from a background queue and
107
107
  /// stdout/stderr drained as they come (no pipe deadlock near the 16 KiB
108
108
  /// note cap). Calls back on the main queue.
109
- func run(_ args: [String], stdin: Data? = nil, completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
109
+ /// @param timeout this call's limit instead of the runner's
110
+ func run(_ args: [String], stdin: Data? = nil, timeout: TimeInterval? = nil,
111
+ completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
110
112
  let config: LaunchConfig
111
113
  do { config = try loadLaunch() } catch let error as ChiError {
112
114
  completion(.failure(error)); return
@@ -161,7 +163,7 @@ final class ChiRunner {
161
163
  try? input.fileHandleForWriting.close()
162
164
  }
163
165
  }
164
- DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
166
+ DispatchQueue.global().asyncAfter(deadline: .now() + (timeout ?? self.timeout)) {
165
167
  if process.isRunning {
166
168
  lock.lock(); timedOut = true; lock.unlock()
167
169
  process.terminate()
@@ -0,0 +1,113 @@
1
+ // Images for the panel: what a pasteboard (the clipboard, the Service's,
2
+ // a drop) carries, as files `chi send --image` can read. Image files are
3
+ // passed as they are; image data (a ⌃⇧⌘4 screenshot, Preview, Safari's
4
+ // Copy Image) is written to a temp file first, deleted after the send.
5
+ import AppKit
6
+ import UniformTypeIdentifiers
7
+
8
+ /// The Bridge's cap on one turn's images.
9
+ let maxImages = 20
10
+
11
+ struct PanelImage: Identifiable, Equatable {
12
+ let id = UUID()
13
+ let url: URL
14
+ /// Written by the helper (image data), so the helper deletes it.
15
+ let temp: Bool
16
+ let thumbnail: NSImage?
17
+
18
+ var name: String { url.lastPathComponent }
19
+
20
+ init(url: URL, temp: Bool) {
21
+ self.url = url
22
+ self.temp = temp
23
+ thumbnail = NSImage(contentsOf: url)
24
+ }
25
+
26
+ static func == (a: PanelImage, b: PanelImage) -> Bool { a.id == b.id }
27
+ }
28
+
29
+ enum ImageIntake {
30
+ static let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("chi-helper", isDirectory: true)
31
+
32
+ /// A pasteboard's content for the panel, in this order: image files
33
+ /// (Finder ⌘C, a Service on files) with no text; else its text, as before
34
+ /// (an office app's copy carries a picture of the cells too: the text
35
+ /// wins); else image data (png, then tiff). A non-image file is skipped.
36
+ static func read(_ pb: NSPasteboard) -> (text: String, images: [PanelImage]) {
37
+ let files = imageFiles(pb)
38
+ if !files.isEmpty { return ("", files) }
39
+ if let text = pb.string(forType: .string), !text.isEmpty { return (text, []) }
40
+ return ("", imageData(pb))
41
+ }
42
+
43
+ /// A drop adds images only: text dropped on the fields is theirs.
44
+ static func dropped(_ pb: NSPasteboard) -> [PanelImage] {
45
+ let files = imageFiles(pb)
46
+ return files.isEmpty ? imageData(pb) : files
47
+ }
48
+
49
+ static func imageFiles(_ pb: NSPasteboard) -> [PanelImage] {
50
+ let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL] ?? []
51
+ return urls.filter(isImage).prefix(maxImages).map { PanelImage(url: $0, temp: false) }
52
+ }
53
+
54
+ static func isImage(_ url: URL) -> Bool {
55
+ (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .image) == true
56
+ }
57
+
58
+ static func imageData(_ pb: NSPasteboard) -> [PanelImage] {
59
+ for (type, ext) in [(NSPasteboard.PasteboardType.png, "png"), (.tiff, "tiff")] {
60
+ guard let data = pb.data(forType: type) else { continue }
61
+ // A folder per image, so the name chi shows is "clipboard.png".
62
+ let dir = tempDir.appendingPathComponent(UUID().uuidString, isDirectory: true)
63
+ do {
64
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
65
+ let url = dir.appendingPathComponent("clipboard.\(ext)")
66
+ try data.write(to: url)
67
+ return [PanelImage(url: url, temp: true)]
68
+ } catch {
69
+ return []
70
+ }
71
+ }
72
+ return []
73
+ }
74
+
75
+ static func delete(_ images: [PanelImage]) {
76
+ for image in images where image.temp {
77
+ try? FileManager.default.removeItem(at: image.url.deletingLastPathComponent())
78
+ }
79
+ }
80
+
81
+ /// At launch: what a helper that quit mid-send left.
82
+ static func sweep() {
83
+ try? FileManager.default.removeItem(at: tempDir)
84
+ }
85
+ }
86
+
87
+ /// The panel's background, taking dropped images anywhere on the panel.
88
+ final class DropEffectView: NSVisualEffectView {
89
+ var onDrop: (([PanelImage]) -> Void)?
90
+
91
+ override init(frame: NSRect) {
92
+ super.init(frame: frame)
93
+ registerForDraggedTypes([.fileURL, .png, .tiff])
94
+ }
95
+
96
+ required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
97
+
98
+ override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
99
+ carriesImages(sender.draggingPasteboard) ? .copy : []
100
+ }
101
+
102
+ override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
103
+ let images = ImageIntake.dropped(sender.draggingPasteboard)
104
+ guard !images.isEmpty else { return false }
105
+ onDrop?(images)
106
+ return true
107
+ }
108
+
109
+ private func carriesImages(_ pb: NSPasteboard) -> Bool {
110
+ let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL] ?? []
111
+ return urls.contains(where: ImageIntake.isImage) || pb.availableType(from: [.png, .tiff]) != nil
112
+ }
113
+ }
@@ -33,6 +33,12 @@
33
33
  <key>NSSendTypes</key>
34
34
  <array>
35
35
  <string>public.utf8-plain-text</string>
36
+ <string>public.png</string>
37
+ <string>public.tiff</string>
38
+ </array>
39
+ <key>NSSendFileTypes</key>
40
+ <array>
41
+ <string>public.image</string>
36
42
  </array>
37
43
  <key>NSRequiredContext</key>
38
44
  <dict/>