samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -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
@@ -116,9 +119,13 @@ module Samagotchi
116
119
  Entry.new(key: "context.status_cadence", yaml_path: %w[context status_cadence], type: :integer, default: 0, expose: %i[env config cli]),
117
120
 
118
121
  Entry.new(key: "thinking.ui", yaml_path: %w[thinking ui], type: :string, default: nil, expose: %i[env config cli]),
119
- Entry.new(key: "thinking.preview_lines", yaml_path: %w[thinking preview_lines], type: :integer, default: 1, expose: %i[env config cli]),
120
122
  Entry.new(key: "thinking.render_interval", yaml_path: %w[thinking render_interval], type: :float, default: 0.08, expose: %i[env config cli]),
121
123
  Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli]),
124
+ # How much models think: off|low|medium|high|default, parsed by Thinking
125
+ # (a :string, since YAML reads an unquoted off as false). The CLI flag
126
+ # is bin/chi's own --thinking; models: and hosts: entries rank below
127
+ # the flag and the env, above config.yml's value.
128
+ Entry.new(key: "thinking.level", yaml_path: %w[thinking level], type: :string, default: "default", expose: %i[env config]),
122
129
 
123
130
  Entry.new(key: "default.n_predict", yaml_path: %w[default n_predict], type: :integer, default: nil, expose: %i[env config cli]),
124
131
  Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli]),
@@ -126,6 +133,13 @@ module Samagotchi
126
133
  Entry.new(key: "retry.max", yaml_path: %w[retry max], type: :integer, default: 5, expose: %i[env config cli]),
127
134
  Entry.new(key: "retry.base_delay", yaml_path: %w[retry base_delay], type: :float, default: 0.5, expose: %i[env config cli]),
128
135
  Entry.new(key: "retry.max_delay", yaml_path: %w[retry max_delay], type: :float, default: 8.0, expose: %i[env config cli]),
136
+ # Asks again in the same turn after an empty answer (EmptyAnswerRetry);
137
+ # 0 = off, capped at 3. No CLI flag: workers get no CLI args.
138
+ Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
139
+ # What `chi update` touches; its --no-gem/--no-bundles/--no-desktop turn one off for a run.
140
+ Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
141
+ Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
142
+ Entry.new(key: "update.desktop", yaml_path: %w[update desktop], type: :bool, default: true, expose: %i[env config]),
129
143
 
130
144
  Entry.new(key: "read.truncate_at_bytes", yaml_path: %w[read truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
131
145
  Entry.new(key: "read.preview_bytes", yaml_path: %w[read preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
@@ -141,6 +155,8 @@ module Samagotchi
141
155
  Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli]),
142
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.
143
157
  Entry.new(key: "web.turn_view", yaml_path: %w[web turn_view], type: :bool, default: true, expose: %i[env config cli]),
158
+ # Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
159
+ 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]),
144
160
 
145
161
  Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli]),
146
162
  Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli]),
@@ -149,18 +165,34 @@ module Samagotchi
149
165
  Entry.new(key: "default.input", yaml_path: %w[default input], type: :string, default: nil, expose: %i[env config]),
150
166
  Entry.new(key: "history.file", yaml_path: %w[history file], type: :string, default: nil, expose: %i[env config]),
151
167
  Entry.new(key: "skip_agent_md", yaml_path: %w[skip_agent_md], type: :bool, default: false, expose: %i[env config]),
152
- Entry.new(key: "bridge.enable", yaml_path: %w[bridge enable], type: :bool, default: false, expose: %i[env config]),
153
168
  ].freeze
154
169
 
170
+ # Top-level maps read by their own code, whose entry names and contents
171
+ # are the user's: ConfigFile.model_aliases / preloaded_memories,
172
+ # Hooks::Loader, Engine#read_bundle_settings.
173
+ FREE_FORM_MAPS = %w[model_aliases hooks bundles memories].freeze
174
+ # Maps of named entries and the keys an entry may hold
175
+ # (ConfigFile.hosts_config, ConfigFile.model_settings).
176
+ MAP_ENTRY_KEYS = {
177
+ "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled].freeze,
178
+ "models" => %w[profile vision sampling thinking].freeze
179
+ }.freeze
180
+ # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
181
+ 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
+
155
185
  # Fast lookup maps
156
186
  BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
157
- BY_ENV = ENTRIES.each_with_object({}) do |e, h|
158
- h[e.env_key] = e
159
- Array(e.aliases).each { |a| h[a] = e }
160
- end.freeze
161
- BY_CLI = ENTRIES.each_with_object({}) { |e, h| h[e.cli_flag] = e if e.cli_exposed? }.freeze
187
+ BY_ENV = ENTRIES.each_with_object({}) { |e, h| h[e.env_key] = e }.freeze
162
188
 
163
189
  class << self
190
+ # The +candidates+ within edit distance 2 of +probe+, closest first
191
+ # (the "did you mean" of an unknown key or host).
192
+ def near_names(probe, candidates)
193
+ candidates.uniq.map { |c| [levenshtein(probe, c), c] }.select { |d, _| d <= 2 }.sort.map(&:last)
194
+ end
195
+
164
196
  def find_by_key(key)
165
197
  BY_KEY[key.to_s]
166
198
  end
@@ -169,10 +201,6 @@ module Samagotchi
169
201
  BY_ENV[env_key.to_s]
170
202
  end
171
203
 
172
- def find_by_cli(flag)
173
- BY_CLI[flag.to_s]
174
- end
175
-
176
204
  def all_entries
177
205
  ENTRIES
178
206
  end
@@ -186,7 +214,8 @@ module Samagotchi
186
214
  return nil if raw.nil?
187
215
  # For string, preserve as-is (including trailing spaces like "Please ")
188
216
  if entry.type == :string
189
- str = raw.to_s
217
+ # A YAML list for a "|"-separated setting (web.annotate_presets).
218
+ str = raw.is_a?(Array) ? raw.join("|") : raw.to_s
190
219
  return nil if str.empty? && entry.type != :string
191
220
  return str
192
221
  end
@@ -286,21 +315,31 @@ module Samagotchi
286
315
  end
287
316
  end
288
317
 
289
- # Lookup yaml_path in nested hash, accepting snake leaf, kebab alias,
290
- # entry-specific yaml_aliases, and legacy flat UPPER keys (e.g.,
291
- # SAMAGOTCHI_DEFAULT_MODEL) for transition.
318
+ # 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).
292
322
  def lookup_yaml(data, yaml_path)
293
323
  entry = ENTRIES.find { |e| e.yaml_path == yaml_path }
294
- # Legacy flat top-level fallback (transition): e.g., file contains SAMAGOTCHI_DEFAULT_MODEL
295
- if entry && data.is_a?(Hash)
296
- if data.key?(entry.env_key)
297
- return data[entry.env_key]
298
- end
299
- Array(entry.aliases).each do |a|
300
- return data[a] if data.key?(a)
301
- return data[a.to_sym] if data.key?(a.to_sym)
302
- end
303
- end
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
339
+ end
340
+
341
+ # The nested key's value for +yaml_path+, or nil.
342
+ def lookup_nested(data, yaml_path, entry = nil)
304
343
  cur = data
305
344
  yaml_path.each_with_index do |seg, idx|
306
345
  return nil unless cur.is_a?(Hash)
@@ -363,6 +402,16 @@ module Samagotchi
363
402
  get_with_origin(key).first
364
403
  end
365
404
 
405
+ # A timeout setting (server.open_timeout, server.read_timeout) in
406
+ # seconds, +given+ (a caller's own value) first; one that isn't
407
+ # positive (0, a word) is the key's default, on every kind of host.
408
+ def positive_seconds(key, given = nil)
409
+ value = (given.nil? ? get(key) : given).to_i
410
+ value.positive? ? value : find_by_key(key).default
411
+ rescue StandardError
412
+ find_by_key(key)&.default
413
+ end
414
+
366
415
  # [value, origin] for the live value #get returns; origin is one of
367
416
  # :cli, :env, :file, :default.
368
417
  def get_with_origin(key)
@@ -380,26 +429,98 @@ module Samagotchi
380
429
  reload!(cli_overrides: overrides)
381
430
  end
382
431
 
383
- # Validation for top-level sections
432
+ # Problems with the keys of a parsed config.yml, one message per key the
433
+ # code doesn't read (with a "did you mean" when a known key is close).
434
+ # Every config-exposed entry in ENTRIES is known as written; names under
435
+ # 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!.
384
437
  def validate_yaml_sections(data)
385
438
  return [] unless data.is_a?(Hash)
386
- errors = []
387
- # Legacy flat UPPER keys are handled separately — don't flag them here
388
- legacy_keys = BY_ENV.keys
389
- data.each_key do |k|
390
- next if %w[hosts hooks model_aliases models guardrails bundles].include?(k.to_s)
391
- next if legacy_keys.include?(k.to_s)
392
- # Sections are top-level keys that map to hashes (e.g., default, recap)
393
- # If key contains _ or -, suggest dotted form
394
- if k.to_s.include?("_")
395
- errors << "top-level key '#{k}' contains '_' — use nested form '#{k.to_s.tr('_', '.')}' (e.g., default.model)"
439
+
440
+ data.flat_map do |key, value|
441
+ key = key.to_s
442
+ if find_by_env(key)&.config_exposed? || FREE_FORM_MAPS.include?(key) || REMOVED_KEYS.include?(key)
443
+ []
444
+ elsif MAP_ENTRY_KEYS.key?(key)
445
+ map_entry_problems(key, value)
446
+ elsif value.is_a?(Hash) && (known_sections.include?(key) || ENTRIES.any? { |e| e.yaml_path.size > 1 && e.section == key })
447
+ value.keys.filter_map { |leaf| key_problem("#{key}.#{leaf}") }
448
+ elsif known_sections.include?(key)
449
+ # An all-commented section is nil; recap: false turns recaps off.
450
+ value.nil? || (key == "recap" && value == false) ? [] : ["config: '#{key}' must be a mapping of settings; ignored"]
451
+ else
452
+ [key_problem(key)].compact
396
453
  end
397
- # Check section name shape if its value is a Hash
398
- if data[k].is_a?(Hash) && k.to_s.match?(/[_-]/)
399
- errors << "section '#{k}' must match #{SECTION_RE.inspect} (no _ or -)"
454
+ end
455
+ end
456
+
457
+ # Dotted keys config.yml may set: config-exposed entries (snake and kebab
458
+ # leaf, yaml_aliases), the extra section keys and the map names.
459
+ def known_config_keys
460
+ @known_config_keys ||= ENTRIES.select(&:config_exposed?).flat_map do |entry|
461
+ *section, leaf = entry.yaml_path
462
+ [leaf, leaf.tr("_", "-"), *Array(entry.yaml_aliases)].map { |l| [*section, l].join(".") }
463
+ end.to_set.merge(SECTION_EXTRA_KEYS.flat_map { |s, leaves| leaves.map { |l| "#{s}.#{l}" } })
464
+ .merge(FREE_FORM_MAPS).merge(MAP_ENTRY_KEYS.keys).freeze
465
+ end
466
+
467
+ private
468
+
469
+ def known_sections
470
+ @known_sections ||= known_config_keys.filter_map { |k| k.split(".").first if k.include?(".") }.to_set.freeze
471
+ end
472
+
473
+ # nil when +dotted+ is a key the file may hold.
474
+ def key_problem(dotted)
475
+ return nil if known_config_keys.include?(dotted)
476
+
477
+ entry = find_by_key(dotted) || find_by_env(dotted)
478
+ if entry
479
+ where = entry.cli_exposed? ? "#{entry.env_key} or #{entry.cli_flag}" : entry.env_key
480
+ return "config: '#{dotted}' can't be set in config.yml; use #{where}"
481
+ end
482
+
483
+ canonical = ENTRIES.select(&:config_exposed?).map(&:key) + known_sections.to_a +
484
+ 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.
486
+ probe = dotted.start_with?("SAMAGOTCHI_") ? dotted.delete_prefix("SAMAGOTCHI_").downcase : dotted
487
+ unknown_key_message(dotted, probe, canonical)
488
+ end
489
+
490
+ def map_entry_problems(map, value)
491
+ return [] unless value.is_a?(Hash)
492
+
493
+ allowed = MAP_ENTRY_KEYS.fetch(map)
494
+ value.flat_map do |name, entry|
495
+ next [] unless entry.is_a?(Hash)
496
+
497
+ (entry.keys.map(&:to_s) - allowed).map do |k|
498
+ unknown_key_message("#{map}.#{name}.#{k}", k, allowed, prefix: "#{map}.#{name}.")
499
+ end
500
+ end
501
+ end
502
+
503
+ # "config: unknown key 'x'", with a "did you mean" naming the candidates
504
+ # within edit distance 2 of +probe+ (closest first) and the ones that
505
+ # share its last segment, at most three.
506
+ def unknown_key_message(key, probe, candidates, prefix: "")
507
+ candidates = candidates.uniq
508
+ near = near_names(probe, candidates)
509
+ close = (near + candidates.select { |c| c.split(".").last == probe.split(".").last }).uniq.first(3)
510
+ hint = close.empty? ? "" : " (did you mean #{close.map { |c| "'#{prefix}#{c}'" }.join(' or ')}?)"
511
+ "config: unknown key '#{key}'#{hint}"
512
+ end
513
+
514
+ def levenshtein(a, b)
515
+ prev = (0..b.size).to_a
516
+ a.each_char.with_index(1) do |ca, i|
517
+ cur = [i]
518
+ b.each_char.with_index(1) do |cb, j|
519
+ cur << [prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca == cb ? 0 : 1)].min
400
520
  end
521
+ prev = cur
401
522
  end
402
- errors
523
+ prev.last
403
524
  end
404
525
  end
405
526
  end
@@ -467,6 +588,43 @@ module Samagotchi
467
588
  nil
468
589
  end
469
590
 
591
+ # Request keys chi sets itself; a `sampling:` map can't override them.
592
+ # (Length caps would need their own semantics.)
593
+ SAMPLING_RESERVED_KEYS = %w[model messages prompt stream stream_options tools tool_choice stop n_predict
594
+ max_tokens n parallel_tool_calls response_format cache_prompt].freeze
595
+
596
+ # A `sampling:` setting (hosts entry or models: entry): request parameters
597
+ # passed through to the provider as written (keys symbolized, nested maps
598
+ # too, so e.g. chat_template_kwargs works). A null value means "don't
599
+ # send it" (drops chi's own temperature default). Not a map: warns once,
600
+ # nil. Reserved keys warn once and are dropped. nil when nothing is left.
601
+ def sampling_map(value, where)
602
+ return nil if value.nil?
603
+ unless value.is_a?(Hash)
604
+ warn_once "Warning: #{where}: sampling must be a map of request parameters; ignored"
605
+ return nil
606
+ end
607
+
608
+ result = value.each_with_object({}) do |(raw_key, raw_value), acc|
609
+ key = raw_key.to_s.strip
610
+ next if key.empty?
611
+ if SAMPLING_RESERVED_KEYS.include?(key)
612
+ warn_once "Warning: #{where}: sampling.#{key} is set by chi; ignored"
613
+ next
614
+ end
615
+ acc[key.to_sym] = deep_symbolize(raw_value)
616
+ end
617
+ result.empty? ? nil : result.freeze
618
+ end
619
+
620
+ def deep_symbolize(value)
621
+ case value
622
+ when Hash then value.to_h { |k, v| [k.to_s.to_sym, deep_symbolize(v)] }.freeze
623
+ when Array then value.map { |v| deep_symbolize(v) }.freeze
624
+ else value
625
+ end
626
+ end
627
+
470
628
  # Forget the warnings already printed (specs).
471
629
  def reset_warnings!
472
630
  @warned = nil
@@ -492,11 +650,18 @@ module Samagotchi
492
650
  existed = File.file?(path)
493
651
  raw = read_yaml(env: env, path: path) || {}
494
652
  if existed
495
- Samagotchi::Config.validate_yaml_sections(raw).each { |w| Log.warn(:config, "invalid_section", echo: "Warning: #{w}") }
496
- # Warn on legacy UPPER keys
653
+ 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.
497
656
  raw.each_key do |k|
498
- if k.to_s.match?(/\A[A-Z_]{2,}\z/) && k.to_s.start_with?("SAMAGOTCHI_")
499
- Log.warn(:config, "legacy_key", echo: "Warning: config key '#{k}' is legacy UPPER — use '#{k.to_s.downcase.sub(/^samagotchi_/, '').tr('_', '.')}' (e.g., default.model)", key: k.to_s)
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)
500
665
  end
501
666
  end
502
667
  end
@@ -507,10 +672,11 @@ module Samagotchi
507
672
  # avoid polluting ENV with defaults.
508
673
  Samagotchi::Config.all_entries.each do |entry|
509
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"
510
677
  # check if file actually contained this key (including legacy flat)
511
678
  file_val = Samagotchi::Config.lookup_yaml(raw, entry.yaml_path)
512
679
  file_val ||= raw[entry.env_key] if raw.key?(entry.env_key)
513
- Array(entry.aliases).each { |a| file_val ||= raw[a] if raw.key?(a) }
514
680
  next if file_val.nil?
515
681
  val = Samagotchi::Config.store[entry.key]
516
682
  next if val.nil?
@@ -580,6 +746,8 @@ module Samagotchi
580
746
  profile = (raw_cfg["profile"] || raw_cfg[:profile]).to_s.strip.downcase
581
747
  first_token_timeout = raw_cfg.key?("first_token_timeout") ? raw_cfg["first_token_timeout"] : raw_cfg[:first_token_timeout]
582
748
  vision = ConfigFile.vision_flag(raw_cfg.key?("vision") ? raw_cfg["vision"] : raw_cfg[:vision], "hosts entry '#{name}'")
749
+ sampling = ConfigFile.sampling_map(raw_cfg.key?("sampling") ? raw_cfg["sampling"] : raw_cfg[:sampling], "hosts entry '#{name}'")
750
+ thinking = Thinking.level(raw_cfg.key?("thinking") ? raw_cfg["thinking"] : raw_cfg[:thinking], "hosts entry '#{name}'")
583
751
  unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
584
752
  warn_once "Warning: hosts entry '#{name}': first_token_timeout must be seconds (0 = off); using the default"
585
753
  first_token_timeout = nil
@@ -643,7 +811,7 @@ module Samagotchi
643
811
  api: api_val&.to_sym, original_name: name, scheme: scheme,
644
812
  url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
645
813
  profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
646
- vision: vision }
814
+ vision: vision, sampling: sampling, thinking: thinking }
647
815
  end
648
816
  end
649
817
 
@@ -734,6 +902,26 @@ module Samagotchi
734
902
  [nil, value]
735
903
  end
736
904
 
905
+ # Hosted providers' names, which no local model family uses: as a
906
+ # prefix they always mean a host ("openai:gpt-4o"). "mistral" and
907
+ # "deepseek" are left out: Ollama has mistral:7b and deepseek-* tags.
908
+ PROVIDER_HOST_NAMES = %w[openrouter openai anthropic google gemini groq xai together fireworks].freeze
909
+
910
+ # The host a model ref names when that host isn't configured, or nil.
911
+ # A ':' in a model id is often a tag (qwen3:8b, org/model:Q4_K_M), so
912
+ # the prefix counts as a host only when it could be a host name and
913
+ # either the rest looks like a hosted provider's org/model id
914
+ # ("nosuch:anthropic/claude-sonnet-4"; Ollama-style tags never hold a
915
+ # '/') or the prefix is a provider's name (PROVIDER_HOST_NAMES).
916
+ def unknown_host_prefix(raw, hosts:)
917
+ prefix, rest = raw.to_s.strip.split(":", 2)
918
+ return nil if rest.to_s.strip.empty? || !prefix.match?(HOST_NAME_RE)
919
+ return nil unless rest.include?("/") || PROVIDER_HOST_NAMES.include?(prefix.downcase)
920
+
921
+ prefix = prefix.downcase
922
+ hosts.keys.map { |k| k.to_s.downcase }.include?(prefix) ? nil : prefix
923
+ end
924
+
737
925
  def hosts_json_for_env(env: ENV, path: global_path(env: env))
738
926
  hosts = hosts_config(env: env, path: path)
739
927
  # Only serialize if non-default or explicitly configured hosts:
@@ -746,7 +934,7 @@ module Samagotchi
746
934
  location = v[:url] ? { "url" => v[:url] } : { "host" => v[:host], "port" => v[:port] }
747
935
  location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
748
936
  "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
749
- "vision" => v[:vision]).compact
937
+ "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s).compact
750
938
  end
751
939
  JSON.generate(simple)
752
940
  rescue StandardError
@@ -828,11 +1016,27 @@ module Samagotchi
828
1016
  vision = vision_flag(v.key?("vision") ? v["vision"] : v[:vision], "models: #{key}")
829
1017
  result[key] = { profile: profile.empty? ? nil : profile }
830
1018
  result[key][:vision] = vision unless vision.nil?
1019
+ sampling = sampling_map(v.key?("sampling") ? v["sampling"] : v[:sampling], "models: #{key}")
1020
+ result[key][:sampling] = sampling if sampling
1021
+ thinking = Thinking.level(v.key?("thinking") ? v["thinking"] : v[:thinking], "models: #{key}")
1022
+ result[key][:thinking] = thinking if thinking
831
1023
  end
832
1024
  rescue StandardError
833
1025
  {}
834
1026
  end
835
1027
 
1028
+ # The first +names+ (model as typed, alias-resolved, bare, …) whose
1029
+ # models: entry sets +field+: [key, value], or nil. VisionSupport and
1030
+ # SamplingSettings look a model up the same way.
1031
+ def model_setting(names, field, models: nil)
1032
+ models ||= model_settings
1033
+ Array(names).map { |name| name.to_s.strip.downcase }.reject(&:empty?).uniq.each do |key|
1034
+ value = models.dig(key, field)
1035
+ return [key, value] unless value.nil?
1036
+ end
1037
+ nil
1038
+ end
1039
+
836
1040
  def resolve_model_alias(raw, env: ENV, path: global_path(env: env), hosts: nil)
837
1041
  value = raw.to_s.strip
838
1042
  return value if value.empty?
@@ -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
 
@@ -49,18 +49,24 @@ struct LiveSession: Decodable, Identifiable, Equatable {
49
49
  /// Only UUID-shaped ids go into chi's argv, so nothing can turn into a flag.
50
50
  var valid: Bool { UUID(uuidString: id) != nil }
51
51
 
52
- var shortCwd: String {
53
- guard let cwd else { return "" }
52
+ var shortCwd: String { cwd.map(Self.shorten) ?? "" }
53
+
54
+ /// A path with the home folder as "~".
55
+ static func shorten(_ path: String) -> String {
54
56
  let home = NSHomeDirectory()
55
- return cwd.hasPrefix(home) ? "~" + cwd.dropFirst(home.count) : cwd
57
+ return path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path
56
58
  }
57
59
 
58
- /// "just now", "5m ago", "2h ago", "yesterday", "3d ago", or "".
59
- func age(now: Date = Date()) -> String {
60
- guard let updatedAt else { return "" }
60
+ var updated: Date? {
61
+ guard let updatedAt else { return nil }
61
62
  let precise = ISO8601DateFormatter()
62
63
  precise.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
63
- guard let date = precise.date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt) else { return "" }
64
+ return precise.date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt)
65
+ }
66
+
67
+ /// "just now", "5m ago", "2h ago", "yesterday", "3d ago", or "".
68
+ func age(now: Date = Date()) -> String {
69
+ guard let date = updated else { return "" }
64
70
  let seconds = max(0, now.timeIntervalSince(date))
65
71
  switch seconds {
66
72
  case ..<60: return "just now"
@@ -100,7 +106,9 @@ final class ChiRunner {
100
106
  /// Runs `chi <args>`, with stdin written from a background queue and
101
107
  /// stdout/stderr drained as they come (no pipe deadlock near the 16 KiB
102
108
  /// note cap). Calls back on the main queue.
103
- 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) {
104
112
  let config: LaunchConfig
105
113
  do { config = try loadLaunch() } catch let error as ChiError {
106
114
  completion(.failure(error)); return
@@ -155,7 +163,7 @@ final class ChiRunner {
155
163
  try? input.fileHandleForWriting.close()
156
164
  }
157
165
  }
158
- DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
166
+ DispatchQueue.global().asyncAfter(deadline: .now() + (timeout ?? self.timeout)) {
159
167
  if process.isRunning {
160
168
  lock.lock(); timedOut = true; lock.unlock()
161
169
  process.terminate()