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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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, :
|
|
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({})
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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, "
|
|
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
|
-
|
|
499
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
57
|
+
return path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|