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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -1
- data/README.md +16 -0
- data/bin/chi +32 -31
- data/docs/cli.md +77 -5
- data/docs/configuration.md +104 -2
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +88 -6
- data/docs/releasing.md +6 -6
- data/docs/sessions.md +19 -17
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/client.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +64 -20
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- 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 +112 -9
- 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/engine.rb +201 -104
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +26 -79
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +21 -7
- data/lib/samagotchi/llm/http.rb +15 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -26
- 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 +23 -0
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/self_report.rb +17 -3
- data/lib/samagotchi/send_command.rb +107 -12
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +1 -16
- data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +62 -248
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- 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 +6 -4
- data/lib/samagotchi/web/app.rb +3 -3
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +36 -24
- data/lib/samagotchi/web/public/chat_view.js +3 -0
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +22 -1
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/turn_events.js +29 -5
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/worker.rb +5 -4
- 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
|
-
|
|
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
|
-
-
|
|
6
|
-
-
|
|
7
|
-
- Don't write memories unless the task asks for it
|
|
8
|
-
- Don't
|
|
9
|
-
-
|
|
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
|
+
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:
|
|
9
|
+
config_modification_protocol.md: sha256:f76ee7e2f5a8e699401e3a4c9144643ee64015efd11a266029704bf228be6a95
|
|
10
10
|
memory_guide.md: sha256:6dd1ecfd6a377c4f2cfc2dc299c1ef3835f10d9f313616fd3eb23e5fd3202858
|
|
11
|
-
delegated.md: sha256:
|
|
11
|
+
delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
|
data/lib/samagotchi/client.rb
CHANGED
|
@@ -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
|
|
167
|
-
@read_timeout = (read_timeout
|
|
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
|
|
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
|
|
@@ -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({})
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
|
@@ -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
|
-
|
|
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/>
|