samagotchi 0.3.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +16 -0
  4. data/bin/chi +32 -31
  5. data/docs/cli.md +77 -5
  6. data/docs/configuration.md +104 -2
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +88 -6
  10. data/docs/releasing.md +6 -6
  11. data/docs/sessions.md +19 -17
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +4 -1
  14. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  15. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  16. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  18. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  19. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  20. data/lib/samagotchi/client.rb +9 -6
  21. data/lib/samagotchi/commands/registry.rb +8 -0
  22. data/lib/samagotchi/config.rb +64 -20
  23. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  25. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  26. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  27. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  28. data/lib/samagotchi/desktop/macos.rb +59 -8
  29. data/lib/samagotchi/desktop_command.rb +6 -3
  30. data/lib/samagotchi/edit_preview.rb +82 -0
  31. data/lib/samagotchi/engine.rb +201 -104
  32. data/lib/samagotchi/gem_update.rb +89 -0
  33. data/lib/samagotchi/guardrails/approval.rb +26 -4
  34. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  35. data/lib/samagotchi/host_registry.rb +8 -12
  36. data/lib/samagotchi/idle_client.rb +24 -15
  37. data/lib/samagotchi/idle_reminders.rb +2 -2
  38. data/lib/samagotchi/image_store.rb +10 -6
  39. data/lib/samagotchi/kernel_loop.rb +26 -79
  40. data/lib/samagotchi/live_versions.rb +59 -0
  41. data/lib/samagotchi/llm/api_key.rb +41 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  43. data/lib/samagotchi/llm/errors.rb +21 -7
  44. data/lib/samagotchi/llm/http.rb +15 -4
  45. data/lib/samagotchi/llm/openai_chat.rb +5 -26
  46. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  47. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  49. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  50. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  51. data/lib/samagotchi/model_profile.rb +23 -0
  52. data/lib/samagotchi/prompt.rb +4 -2
  53. data/lib/samagotchi/reminder_store.rb +1 -9
  54. data/lib/samagotchi/self_report.rb +17 -3
  55. data/lib/samagotchi/send_command.rb +107 -12
  56. data/lib/samagotchi/session_commands.rb +38 -8
  57. data/lib/samagotchi/session_manager.rb +1 -16
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
  59. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
  60. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  61. data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  63. data/lib/samagotchi/terminal_ui.rb +62 -248
  64. data/lib/samagotchi/text_diff.rb +181 -0
  65. data/lib/samagotchi/thinking.rb +115 -0
  66. data/lib/samagotchi/tool_runner.rb +34 -1
  67. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  68. data/lib/samagotchi/tools/edit.rb +23 -9
  69. data/lib/samagotchi/tools/write.rb +4 -0
  70. data/lib/samagotchi/turn_flow.rb +12 -2
  71. data/lib/samagotchi/update_command.rb +308 -0
  72. data/lib/samagotchi/update_hint.rb +59 -0
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +6 -4
  75. data/lib/samagotchi/web/app.rb +3 -3
  76. data/lib/samagotchi/web/message_parts.rb +8 -3
  77. data/lib/samagotchi/web/public/activity.js +3 -0
  78. data/lib/samagotchi/web/public/app.js +36 -24
  79. data/lib/samagotchi/web/public/chat_view.js +3 -0
  80. data/lib/samagotchi/web/public/data.js +2 -0
  81. data/lib/samagotchi/web/public/diff_view.js +58 -0
  82. data/lib/samagotchi/web/public/index.html +22 -1
  83. data/lib/samagotchi/web/public/question_card.js +3 -1
  84. data/lib/samagotchi/web/public/turn_events.js +29 -5
  85. data/lib/samagotchi/web/public/turn_view.js +2 -1
  86. data/lib/samagotchi/worker.rb +5 -4
  87. metadata +12 -1
@@ -57,8 +57,8 @@ Legacy flat keys (`SAMAGOTCHI_DEFAULT_MODEL`, `SAMAGOTCHI_N_PREDICT` etc. at top
57
57
  **Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
58
58
 
59
59
  - `model_aliases:` map of alias → model id (`ConfigFile.resolve_model_alias`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid).
60
- - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
- - `models:` map of model id or alias → `{profile, vision, sampling}` (`ConfigFile.model_settings`). `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
60
+ - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
+ - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
63
63
  - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; env `SAMAGOTCHI_GUARDRAILS_ENABLED`), `rules:` (list), `disable:` (list of rule ids, `id` or `bundle:id`, switching off a bundle's or config rule without editing it). A rule takes only `id`, `tool`, `command`, `path`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules load when a session starts: restart the worker/REPL after an edit. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
64
64
 
@@ -1,10 +1,9 @@
1
1
  # Delegated session
2
2
 
3
- A parent session delegated your task and reads only your final reply; the user may be watching or not.
3
+ Another chi session (the parent) gave you this task. It reads only your last message of each turn; the user may be watching, or not.
4
4
 
5
- - Do the task; put everything the parent needs in your last message (findings, paths, commands run, what is unverified). Nothing else of yours reaches it.
6
- - Don't delegate further, don't start other chi sessions, don't send notes to sessions other than the parent.
7
- - Don't write memories unless the task asks for it; put what you learned in the reply instead.
8
- - Don't change config, install bundles or edit managed files.
9
- - Stay in the working directory you were started in.
10
- - If you are blocked, say so in the reply with the exact question; only ask_user_question when the task says the user is watching.
5
+ - Finish the task in this turn if you can. End with one reply the parent can act on without asking back: the result first, then evidence (paths, commands run and what they printed), then anything you could not verify or finish.
6
+ - The parent may send follow-up messages later; each one is a new turn in this same session, with what you did so far.
7
+ - Work in the directory you were started in. Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
8
+ - You can't delegate further. Don't start other chi sessions, and send notes only to the parent.
9
+ - If you are blocked, stop and say so in the reply with the exact question. Use ask_user_question only when the task says the user is watching.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.3.0
3
+ version: 0.4.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:8b594100bee4797b563bd6425dc6f3ff93e1bdfbe9fc5fa1d66b42093c4795e2
8
8
  self_map.md: sha256:67eb6223728a72788fa0d75aa56affea2ca48962de2e445293bc12b15f5f70c0
9
- config_modification_protocol.md: sha256:1d1047f3f99fc9ceb89ef191cbcf3d68bc996addd00d7b6eb567a63b8ff677ea
9
+ config_modification_protocol.md: sha256:f76ee7e2f5a8e699401e3a4c9144643ee64015efd11a266029704bf228be6a95
10
10
  memory_guide.md: sha256:6dd1ecfd6a377c4f2cfc2dc299c1ef3835f10d9f313616fd3eb23e5fd3202858
11
- delegated.md: sha256:8f7965b6165c3526abb0cd39e44a8b73a8d6a26755049bdcd007baf0b0771087
11
+ delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -147,15 +147,17 @@ module Samagotchi
147
147
  # to stream its first text (LLM::HTTP); nil: no limit
148
148
  # @param name [String, nil] the host's config name, for error lines
149
149
  # (default: the transport's label)
150
+ # @param api_key_env [String, nil] the variable holding the host's API
151
+ # key (llama.cpp's --api-key), sent as a bearer token on every request;
152
+ # nil sends no Authorization header
153
+ # @param env [Hash] where the key variable is read
150
154
  def initialize(host: nil, port: nil, open_timeout: nil, read_timeout: nil, transport: nil, sleeper: nil, scheme: nil,
151
- first_token_timeout: nil, name: nil)
155
+ first_token_timeout: nil, name: nil, api_key_env: nil, env: ENV)
152
156
  # Unified config precedence: CLI > ENV > file > default (via Samagotchi::Config)
153
157
  cfg_host = nil; cfg_port = nil; cfg_transport_raw = nil
154
158
  begin
155
159
  cfg_host = Samagotchi::Config.get("server.host")
156
160
  cfg_port = Samagotchi::Config.get("server.port")
157
- cfg_open_timeout = Samagotchi::Config.get("server.open_timeout")
158
- cfg_read_timeout = Samagotchi::Config.get("server.read_timeout")
159
161
  cfg_transport_raw = Samagotchi::Config.get("server.transport")
160
162
  rescue StandardError
161
163
  nil
@@ -163,8 +165,8 @@ module Samagotchi
163
165
  @host = host || cfg_host
164
166
  @port = (port || cfg_port).to_i
165
167
  @scheme = scheme || "http"
166
- @open_timeout = (open_timeout || cfg_open_timeout).to_i
167
- @read_timeout = (read_timeout || cfg_read_timeout).to_i
168
+ @open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout", open_timeout)
169
+ @read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout", read_timeout)
168
170
  transport_fallback = cfg_transport_raw || ENV.fetch(SERVER_TRANSPORT_ENV, DEFAULT_TRANSPORT.to_s)
169
171
  @transport = build_transport(resolve_transport(transport || transport_fallback))
170
172
  @props_cache = {}
@@ -174,7 +176,8 @@ module Samagotchi
174
176
  @host_name = name
175
177
  @label = name.to_s.empty? ? @transport.label : name.to_s
176
178
  @http = LLM::HTTP.new(label: @label, open_timeout: @open_timeout, read_timeout: @read_timeout,
177
- sleeper: sleeper, first_token_timeout: first_token_timeout)
179
+ sleeper: sleeper, first_token_timeout: first_token_timeout,
180
+ api_key: LLM::ApiKey.for(api_key_env, host: @label, env: env))
178
181
  end
179
182
 
180
183
  # Seconds a completion may take to stream its first text, or nil.
@@ -74,6 +74,14 @@ module Samagotchi
74
74
 
75
75
  def command?(line) = !lookup(line).nil?
76
76
 
77
+ # @return [Entry, nil] the first entry the UI runs itself (local) that
78
+ # matches +line+, whatever its uis (a UI answers the others' too:
79
+ # the REPL's /detach note)
80
+ def lookup_local(line)
81
+ text = line.to_s.strip
82
+ @entries.find { |entry| entry.local && entry.match?(text) }
83
+ end
84
+
77
85
  # @return [Array<Entry>] every entry, in registration order
78
86
  def entries = @entries.dup
79
87
 
@@ -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
@@ -118,6 +121,11 @@ module Samagotchi
118
121
  Entry.new(key: "thinking.ui", yaml_path: %w[thinking ui], type: :string, default: nil, expose: %i[env config cli]),
119
122
  Entry.new(key: "thinking.render_interval", yaml_path: %w[thinking render_interval], type: :float, default: 0.08, expose: %i[env config cli]),
120
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]),
121
129
 
122
130
  Entry.new(key: "default.n_predict", yaml_path: %w[default n_predict], type: :integer, default: nil, expose: %i[env config cli]),
123
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]),
@@ -128,6 +136,10 @@ module Samagotchi
128
136
  # Asks again in the same turn after an empty answer (EmptyAnswerRetry);
129
137
  # 0 = off, capped at 3. No CLI flag: workers get no CLI args.
130
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]),
131
143
 
132
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]),
133
145
  Entry.new(key: "read.preview_bytes", yaml_path: %w[read preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
@@ -162,8 +174,8 @@ module Samagotchi
162
174
  # Maps of named entries and the keys an entry may hold
163
175
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
164
176
  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
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
167
179
  }.freeze
168
180
  # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
169
181
  SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
@@ -172,13 +184,15 @@ module Samagotchi
172
184
 
173
185
  # Fast lookup maps
174
186
  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
187
+ BY_ENV = ENTRIES.each_with_object({}) { |e, h| h[e.env_key] = e }.freeze
180
188
 
181
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
+
182
196
  def find_by_key(key)
183
197
  BY_KEY[key.to_s]
184
198
  end
@@ -187,10 +201,6 @@ module Samagotchi
187
201
  BY_ENV[env_key.to_s]
188
202
  end
189
203
 
190
- def find_by_cli(flag)
191
- BY_CLI[flag.to_s]
192
- end
193
-
194
204
  def all_entries
195
205
  ENTRIES
196
206
  end
@@ -321,10 +331,10 @@ module Samagotchi
321
331
  def lookup_legacy(data, entry)
322
332
  return nil unless entry && data.is_a?(Hash)
323
333
 
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
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
+
328
338
  nil
329
339
  end
330
340
 
@@ -392,6 +402,16 @@ module Samagotchi
392
402
  get_with_origin(key).first
393
403
  end
394
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
+
395
415
  # [value, origin] for the live value #get returns; origin is one of
396
416
  # :cli, :env, :file, :default.
397
417
  def get_with_origin(key)
@@ -485,7 +505,7 @@ module Samagotchi
485
505
  # share its last segment, at most three.
486
506
  def unknown_key_message(key, probe, candidates, prefix: "")
487
507
  candidates = candidates.uniq
488
- near = candidates.map { |c| [levenshtein(probe, c), c] }.select { |d, _| d <= 2 }.sort.map(&:last)
508
+ near = near_names(probe, candidates)
489
509
  close = (near + candidates.select { |c| c.split(".").last == probe.split(".").last }).uniq.first(3)
490
510
  hint = close.empty? ? "" : " (did you mean #{close.map { |c| "'#{prefix}#{c}'" }.join(' or ')}?)"
491
511
  "config: unknown key '#{key}'#{hint}"
@@ -652,10 +672,11 @@ module Samagotchi
652
672
  # avoid polluting ENV with defaults.
653
673
  Samagotchi::Config.all_entries.each do |entry|
654
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"
655
677
  # check if file actually contained this key (including legacy flat)
656
678
  file_val = Samagotchi::Config.lookup_yaml(raw, entry.yaml_path)
657
679
  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
680
  next if file_val.nil?
660
681
  val = Samagotchi::Config.store[entry.key]
661
682
  next if val.nil?
@@ -726,6 +747,7 @@ module Samagotchi
726
747
  first_token_timeout = raw_cfg.key?("first_token_timeout") ? raw_cfg["first_token_timeout"] : raw_cfg[:first_token_timeout]
727
748
  vision = ConfigFile.vision_flag(raw_cfg.key?("vision") ? raw_cfg["vision"] : raw_cfg[:vision], "hosts entry '#{name}'")
728
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}'")
729
751
  unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
730
752
  warn_once "Warning: hosts entry '#{name}': first_token_timeout must be seconds (0 = off); using the default"
731
753
  first_token_timeout = nil
@@ -789,7 +811,7 @@ module Samagotchi
789
811
  api: api_val&.to_sym, original_name: name, scheme: scheme,
790
812
  url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
791
813
  profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
792
- vision: vision, sampling: sampling }
814
+ vision: vision, sampling: sampling, thinking: thinking }
793
815
  end
794
816
  end
795
817
 
@@ -880,6 +902,26 @@ module Samagotchi
880
902
  [nil, value]
881
903
  end
882
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
+
883
925
  def hosts_json_for_env(env: ENV, path: global_path(env: env))
884
926
  hosts = hosts_config(env: env, path: path)
885
927
  # Only serialize if non-default or explicitly configured hosts:
@@ -892,7 +934,7 @@ module Samagotchi
892
934
  location = v[:url] ? { "url" => v[:url] } : { "host" => v[:host], "port" => v[:port] }
893
935
  location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
894
936
  "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
895
- "vision" => v[:vision], "sampling" => v[:sampling]).compact
937
+ "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s).compact
896
938
  end
897
939
  JSON.generate(simple)
898
940
  rescue StandardError
@@ -976,6 +1018,8 @@ module Samagotchi
976
1018
  result[key][:vision] = vision unless vision.nil?
977
1019
  sampling = sampling_map(v.key?("sampling") ? v["sampling"] : v[:sampling], "models: #{key}")
978
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
979
1023
  end
980
1024
  rescue StandardError
981
1025
  {}
@@ -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/>