samagotchi 0.10.1 → 0.12.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 +94 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +23 -4
- data/docs/cli.md +49 -8
- data/docs/configuration.md +44 -11
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bridge.rb +4 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +506 -499
- data/lib/samagotchi/host_registry.rb +76 -92
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +352 -407
- data/lib/samagotchi/llm/chat_loop.rb +95 -125
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +1 -1
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +1 -1
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +23 -4
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/self_report.rb +19 -7
- data/lib/samagotchi/send_command.rb +9 -9
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +15 -17
- data/lib/samagotchi/session_manager.rb +22 -9
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +16 -35
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +10 -7
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +8 -6
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +42 -35
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +6 -6
- data/lib/samagotchi/worker.rb +3 -2
- metadata +13 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: known-names
|
|
3
|
-
version: 0.1.
|
|
3
|
+
version: 0.1.4
|
|
4
4
|
scope: system
|
|
5
5
|
description: Catches a near-miss of a protected name (home folder, login, git name, repo name, names from config) in a tool call and rejects it with the right spelling; mode ask / correct optional
|
|
6
6
|
trust_level: reviewed
|
|
@@ -8,7 +8,7 @@ files:
|
|
|
8
8
|
known_names.md: sha256:d5348b95b6c655017b541910b36e384220da280818160201f6ec9a50e1e4287c
|
|
9
9
|
hooks:
|
|
10
10
|
known_names.rb:
|
|
11
|
-
sha256: sha256:
|
|
11
|
+
sha256: sha256:c0845c9b6d04ec884c4641441d8a0ba3482283f4ee3e00c8b3bf99efb0c635b1
|
|
12
12
|
event: before_tool_call
|
|
13
13
|
on_error: log
|
|
14
14
|
priority: 50
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: skills
|
|
3
|
-
version: 0.1.
|
|
3
|
+
version: 0.1.5
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Skills (skill_<name> memories, the steps of a task done together): /skill save, list, show and diff; older versions kept with a short diff line on every update; a nudge when a followed skill's step fails"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
10
|
-
requires_chi: ">= 0.
|
|
9
|
+
sha256: sha256:9bd637f8fa933b03434e4ccdcbf2547aa011993b8044f2739ee7d50f995d2882
|
|
10
|
+
requires_chi: ">= 0.11.0"
|
|
@@ -173,10 +173,10 @@ class Plugin
|
|
|
173
173
|
nil
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
+
# A failed step is a call whose status (core's, from the full output) is
|
|
177
|
+
# error: a denied call is blocked, a stopped task_wait stopped.
|
|
176
178
|
def step_failed(event, ctx)
|
|
177
|
-
|
|
178
|
-
output = event[:output].to_s
|
|
179
|
-
return if NOT_FAILURES.include?(tool) || guardrail_deny?(tool, output) || !failure?(tool, output)
|
|
179
|
+
return if NOT_FAILURES.include?(event[:tool].to_s) || event[:status] != "error"
|
|
180
180
|
|
|
181
181
|
@failed = true
|
|
182
182
|
return if @nudged || @read.any? { |name| @written.include?(name) }
|
|
@@ -184,26 +184,6 @@ class Plugin
|
|
|
184
184
|
@nudged = ctx.steer(format(NUDGE, @read.join(", ")))
|
|
185
185
|
end
|
|
186
186
|
|
|
187
|
-
# A denied call, by its first line: a legacy veto, a rule's, hook's or
|
|
188
|
-
# core's deny, or the user's (declined, approval cancelled). Core's
|
|
189
|
-
# wording (ToolRunner#denied, Verdict#deny_text).
|
|
190
|
-
def guardrail_deny?(tool, output)
|
|
191
|
-
output.match?(/\A\[#{Regexp.escape(tool)}\] Error: (?:blocked by guardrail: |denied by guardrail \(|[^\n]*? It needed approval \()/)
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
# A tool error ("[x] Error: …" raised, "[x]\nError: …" returned), or an
|
|
195
|
-
# execute that exited non-zero; its "exit: N" line may be cut off by the
|
|
196
|
-
# hook's output cap, and then an Error: line near the top counts.
|
|
197
|
-
def failure?(tool, output)
|
|
198
|
-
return true if output.match?(/\A\[#{Regexp.escape(tool)}\](?: |\n)Error:/)
|
|
199
|
-
return false unless tool == "execute"
|
|
200
|
-
|
|
201
|
-
exit_line = output.match(/^exit: (\d+)(?: \(no output\))?\s*\z/)
|
|
202
|
-
return exit_line[1] != "0" if exit_line
|
|
203
|
-
|
|
204
|
-
output.lines.first(20).any? { |line| line.match?(/\A\s*Error:/i) }
|
|
205
|
-
end
|
|
206
|
-
|
|
207
187
|
def after_turn(ctx)
|
|
208
188
|
changed_elsewhere(ctx)
|
|
209
189
|
return unless @nudge && @failed
|
|
@@ -56,7 +56,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
|
|
|
56
56
|
|
|
57
57
|
**Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
|
|
58
58
|
|
|
59
|
-
- `model_aliases:` map of alias → model id (`
|
|
59
|
+
- `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
|
|
60
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
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 `~`.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.12.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:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
9
|
+
config_modification_protocol.md: sha256:dddd083d6f419674e6e0cc39118ca9785583a557e4950af1a9b028e52e8a3e0c
|
|
10
10
|
memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
|
|
11
11
|
delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
|
data/lib/samagotchi/client.rb
CHANGED
|
@@ -90,6 +90,21 @@ module Samagotchi
|
|
|
90
90
|
openai_compatible? ? payload.dig("choices", 0, "text").to_s : payload.fetch("content", "")
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# Why the stream stopped, from its last payload, as an OpenAI finish
|
|
94
|
+
# reason ("length" for the token cap or a full context, "stop"
|
|
95
|
+
# otherwise); nil for a payload that doesn't say. llama.cpp's
|
|
96
|
+
# /completion names it stop_type ("limit", "eos", "word"); the
|
|
97
|
+
# OpenAI-compatible servers send their own finish_reason.
|
|
98
|
+
def finish_reason_from(payload)
|
|
99
|
+
return nil unless payload.is_a?(Hash)
|
|
100
|
+
return payload.dig("choices", 0, "finish_reason") if openai_compatible?
|
|
101
|
+
|
|
102
|
+
case payload["stop_type"]
|
|
103
|
+
when "limit" then "length"
|
|
104
|
+
when "eos", "word" then "stop"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
93
108
|
# The request's `model` field for this transport (nil = omit the field):
|
|
94
109
|
# - llama.cpp: forward the selector (SAMAGOTCHI_DEFAULT_MODEL) verbatim.
|
|
95
110
|
# - mlx_lm.server: omit `model` entirely (use whatever was loaded via the
|
|
@@ -248,7 +263,12 @@ module Samagotchi
|
|
|
248
263
|
content, payload = parsed_chunk
|
|
249
264
|
shown.call unless content.to_s.empty?
|
|
250
265
|
result << content
|
|
251
|
-
|
|
266
|
+
next unless on_chunk
|
|
267
|
+
|
|
268
|
+
chunk = { content: content, payload: payload }
|
|
269
|
+
finish_reason = @transport.finish_reason_from(payload)
|
|
270
|
+
chunk[:finish_reason] = finish_reason if finish_reason
|
|
271
|
+
on_chunk.call(**chunk)
|
|
252
272
|
end
|
|
253
273
|
result
|
|
254
274
|
rescue LLM::BadRequest => e
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "set"
|
|
|
8
8
|
require_relative "atomic_file"
|
|
9
9
|
require_relative "config_text_edit"
|
|
10
10
|
require_relative "log"
|
|
11
|
+
require_relative "model_ref"
|
|
11
12
|
require_relative "paths"
|
|
12
13
|
|
|
13
14
|
module Samagotchi
|
|
@@ -436,7 +437,44 @@ module Samagotchi
|
|
|
436
437
|
else
|
|
437
438
|
[key_problem(key)].compact
|
|
438
439
|
end
|
|
440
|
+
end + model_ref_problems(data)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# What a model ref in config.yml won't do as written: an alias whose
|
|
444
|
+
# target is another alias (aliases apply once, ModelRef), a ref that
|
|
445
|
+
# starts with "<host>/" (only ':' names a host).
|
|
446
|
+
def model_ref_problems(data)
|
|
447
|
+
hosts = data["hosts"].is_a?(Hash) ? data["hosts"] : {}
|
|
448
|
+
aliases = data["model_aliases"].is_a?(Hash) ? data["model_aliases"] : {}
|
|
449
|
+
default = data["default"]["model"] if data["default"].is_a?(Hash)
|
|
450
|
+
refs = [["default.model", default], *aliases.map { |name, target| ["model_aliases.#{name}", target] }]
|
|
451
|
+
slash = refs.filter_map { |where, ref| host_slash_problem(where, ref, hosts) }
|
|
452
|
+
names = aliases.keys.map { |k| k.to_s.strip.downcase }
|
|
453
|
+
chains = aliases.filter_map do |name, target|
|
|
454
|
+
_, inner = ModelRef.split(target, hosts: hosts)
|
|
455
|
+
next unless names.include?(inner.downcase)
|
|
456
|
+
|
|
457
|
+
"config: model_aliases.#{name} points to the alias '#{inner}'; aliases don't chain, so '#{inner}' is sent as written"
|
|
439
458
|
end
|
|
459
|
+
slash + chains + hosts.keys.filter_map { |name| family_host_problem(name.to_s) }
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Model families whose ids carry a ':' tag on Ollama and friends
|
|
463
|
+
# (qwen3:8b, llama3.1:70b): a host named like one takes those refs.
|
|
464
|
+
MODEL_FAMILY_HOST_RE = /\A(?:qwen|llama|codellama|gemma|mistral|mixtral|deepseek|phi|granite|starcoder|smollm|olmo)[\d.]*\z/i
|
|
465
|
+
|
|
466
|
+
def family_host_problem(name)
|
|
467
|
+
return nil unless name.match?(MODEL_FAMILY_HOST_RE)
|
|
468
|
+
|
|
469
|
+
"config: hosts.#{name} is named like a model family: a ref such as '#{name}:8b' (an Ollama tag) " \
|
|
470
|
+
"goes to host #{name} as '8b'; rename the host if you use such ids"
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def host_slash_problem(where, ref, hosts)
|
|
474
|
+
prefix, rest = ref.to_s.strip.split("/", 2)
|
|
475
|
+
return nil if rest.to_s.empty? || !hosts.keys.map { |k| k.to_s.downcase }.include?(prefix.downcase)
|
|
476
|
+
|
|
477
|
+
"config: #{where} '#{ref}' starts with the host '#{prefix}' and '/': only ':' names a host now (#{prefix}:#{rest})"
|
|
440
478
|
end
|
|
441
479
|
|
|
442
480
|
# Dotted keys config.yml may set: config-exposed entries (snake and kebab
|
|
@@ -831,28 +869,9 @@ module Samagotchi
|
|
|
831
869
|
end
|
|
832
870
|
|
|
833
871
|
# Parse a model string that may be qualified as "host_alias:model"
|
|
834
|
-
# Returns [host_alias_or_nil, bare_model]
|
|
872
|
+
# Returns [host_alias_or_nil, bare_model] (ModelRef.split)
|
|
835
873
|
def parse_host_qualified_model(raw, hosts: nil)
|
|
836
|
-
|
|
837
|
-
return [nil, value] if value.empty?
|
|
838
|
-
# Try split on first ':' or '/' where prefix matches a known host
|
|
839
|
-
hosts_map = hosts || {}
|
|
840
|
-
# Normalize keys downcase
|
|
841
|
-
lowered_keys = hosts_map.keys.map(&:downcase)
|
|
842
|
-
# Check ':' split
|
|
843
|
-
if value.include?(":")
|
|
844
|
-
prefix, rest = value.split(":", 2)
|
|
845
|
-
if lowered_keys.include?(prefix.strip.downcase) && !rest.strip.empty?
|
|
846
|
-
return [prefix.strip.downcase, rest.strip]
|
|
847
|
-
end
|
|
848
|
-
end
|
|
849
|
-
if value.include?("/")
|
|
850
|
-
prefix, rest = value.split("/", 2)
|
|
851
|
-
if lowered_keys.include?(prefix.strip.downcase) && !rest.strip.empty?
|
|
852
|
-
return [prefix.strip.downcase, rest.strip]
|
|
853
|
-
end
|
|
854
|
-
end
|
|
855
|
-
[nil, value]
|
|
874
|
+
ModelRef.split(raw, hosts: hosts)
|
|
856
875
|
end
|
|
857
876
|
|
|
858
877
|
# Hosted providers' names, which no local model family uses: as a
|
|
@@ -989,25 +1008,10 @@ module Samagotchi
|
|
|
989
1008
|
nil
|
|
990
1009
|
end
|
|
991
1010
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
aliases = model_aliases(env: env, path: path)
|
|
997
|
-
# Host-qualified handling: "host:alias" -> "host:resolved"
|
|
998
|
-
# This allows /model small-box:small where "small" is an alias.
|
|
999
|
-
if value.include?(":") || value.include?("/")
|
|
1000
|
-
hosts_map = hosts || hosts_config(env: env, path: path)
|
|
1001
|
-
host, bare = parse_host_qualified_model(value, hosts: hosts_map)
|
|
1002
|
-
if host && !bare.to_s.strip.empty?
|
|
1003
|
-
resolved_bare = aliases.fetch(bare.downcase, bare)
|
|
1004
|
-
# Preserve the separator the user used (: or /)
|
|
1005
|
-
sep = value.downcase.include?("#{host}:") ? ":" : (value.downcase.include?("#{host}/") ? "/" : ":")
|
|
1006
|
-
return "#{host}#{sep}#{resolved_bare}"
|
|
1007
|
-
end
|
|
1008
|
-
end
|
|
1009
|
-
|
|
1010
|
-
aliases.fetch(value.downcase, value)
|
|
1011
|
+
# +raw+ parsed against config.yml's hosts (or +hosts+) and aliases.
|
|
1012
|
+
# @return [ModelRef]
|
|
1013
|
+
def model_ref(raw, env: ENV, path: global_path(env: env), hosts: nil)
|
|
1014
|
+
ModelRef.parse(raw, hosts: hosts || hosts_config(env: env, path: path), aliases: model_aliases(env: env, path: path))
|
|
1011
1015
|
end
|
|
1012
1016
|
|
|
1013
1017
|
# Words /model takes as arguments (clear/default/none/off reset the
|
|
@@ -5,7 +5,7 @@ require_relative "context_usage"
|
|
|
5
5
|
require_relative "context_window"
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
|
-
# One turn's context tracking for
|
|
8
|
+
# One turn's context tracking for either loop: how full the window is
|
|
9
9
|
# before each request (the server's last prompt count plus an estimate for
|
|
10
10
|
# what the turn appended since, else a chars-per-token estimate), the
|
|
11
11
|
# bucket that puts it in, and what comes of that:
|
|
@@ -95,18 +95,18 @@ module Samagotchi
|
|
|
95
95
|
|
|
96
96
|
def enabled? = @enabled
|
|
97
97
|
|
|
98
|
-
# Before a request: estimate
|
|
99
|
-
# request's ContextWindow::Resolved) and update
|
|
100
|
-
# value. A rise into a bucket that asks the model for
|
|
101
|
-
# a line for #take_guidance.
|
|
102
|
-
# +image_tokens+: the images' estimate (their base64 is not in
|
|
98
|
+
# Before a request: estimate the usage of a prompt of +prompt_chars+
|
|
99
|
+
# against +window+ (this request's ContextWindow::Resolved) and update
|
|
100
|
+
# the status line's value. A rise into a bucket that asks the model for
|
|
101
|
+
# a change leaves a line for #take_guidance.
|
|
102
|
+
# +image_tokens+: the images' estimate (their base64 is not in the chars).
|
|
103
103
|
#
|
|
104
104
|
# @return [Hash, nil] the :context_status event's fields (without
|
|
105
105
|
# :type/:iteration) when the emit gate fires, else nil
|
|
106
|
-
def observe(
|
|
106
|
+
def observe(prompt_chars, iteration_index:, window:, image_tokens: 0)
|
|
107
107
|
return nil unless @enabled
|
|
108
108
|
|
|
109
|
-
usage = estimate(
|
|
109
|
+
usage = estimate(prompt_chars, server_usage: @server_usage, window: window, image_tokens: image_tokens, counted: @counted)
|
|
110
110
|
bucket = bucket_for(usage[:estimated_pct])
|
|
111
111
|
# The status line's value, every iteration; the gate below decides
|
|
112
112
|
# only the event and the model's guidance line.
|
|
@@ -135,7 +135,8 @@ module Samagotchi
|
|
|
135
135
|
normalized
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
# After a generation: +usage+ is its own server counts (
|
|
138
|
+
# After a generation: +usage+ is its own server counts ({prompt_tokens:,
|
|
139
|
+
# total_tokens:, context_window_tokens:}; nil: none), for
|
|
139
140
|
# +prompt_chars+/+image_tokens+ as sent. The next estimate adds only
|
|
140
141
|
# what the turn appended since (answer, tool results), and the status
|
|
141
142
|
# line's value counts the answer. Without counts the pre-generation
|
|
@@ -161,21 +162,21 @@ module Samagotchi
|
|
|
161
162
|
# `window` is this request's ContextWindow::Resolved. A window the
|
|
162
163
|
# stream payload reports itself still wins.
|
|
163
164
|
# +counted+: the prompt the server's prompt_tokens counted ({chars:,
|
|
164
|
-
# image_tokens:}); what
|
|
165
|
+
# image_tokens:}); what the prompt has on top of it (the answer, tool
|
|
165
166
|
# results since) is added as an estimate, so the value doesn't read low
|
|
166
167
|
# during a long tool loop. A prompt shorter than that one (trimmed) or
|
|
167
168
|
# none known: the server's count alone.
|
|
168
|
-
def estimate(
|
|
169
|
+
def estimate(prompt_chars, window:, server_usage: nil, image_tokens: 0, counted: nil)
|
|
169
170
|
window_source = window.source
|
|
170
171
|
window_source = :server if server_usage && server_usage[:context_window_tokens]
|
|
171
172
|
|
|
172
173
|
if server_usage && server_usage[:prompt_tokens]
|
|
173
174
|
window_tokens = server_usage[:context_window_tokens] || window.tokens
|
|
174
|
-
used_tokens = server_usage[:prompt_tokens] + appended_tokens(
|
|
175
|
+
used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt_chars, image_tokens, counted)
|
|
175
176
|
source = "server"
|
|
176
177
|
else
|
|
177
178
|
window_tokens = window.tokens
|
|
178
|
-
used_tokens = (
|
|
179
|
+
used_tokens = (prompt_chars / @chars_per_token).ceil + image_tokens
|
|
179
180
|
source = "estimate"
|
|
180
181
|
end
|
|
181
182
|
|
|
@@ -191,11 +192,11 @@ module Samagotchi
|
|
|
191
192
|
|
|
192
193
|
private
|
|
193
194
|
|
|
194
|
-
# The estimate for what
|
|
195
|
-
def appended_tokens(
|
|
195
|
+
# The estimate for what the prompt added since the +counted+ one.
|
|
196
|
+
def appended_tokens(prompt_chars, image_tokens, counted)
|
|
196
197
|
return 0 unless counted
|
|
197
198
|
|
|
198
|
-
chars =
|
|
199
|
+
chars = prompt_chars - counted[:chars]
|
|
199
200
|
return 0 unless chars.positive?
|
|
200
201
|
|
|
201
202
|
(chars / @chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// the clipboard (a screenshot too). No Dock icon (LSUIElement); it stays
|
|
4
4
|
// running after the first launch. `ChiHelper --login on|off|status` (run
|
|
5
5
|
// directly by chi desktop) manages the login item and exits;
|
|
6
|
-
// `ChiHelper --kitty list|send` is the kitty targets' seam (Kitty.swift)
|
|
6
|
+
// `ChiHelper --kitty list|send` is the kitty targets' seam (Kitty.swift),
|
|
7
|
+
// `ChiHelper --model-pick` the model chooser's (Models.swift).
|
|
7
8
|
import AppKit
|
|
8
9
|
import ServiceManagement
|
|
9
10
|
|
|
@@ -101,6 +102,9 @@ struct ChiHelperMain {
|
|
|
101
102
|
if args.count >= 2, args[1] == "--kitty" {
|
|
102
103
|
exit(kittyCommand(Array(args.dropFirst(2))))
|
|
103
104
|
}
|
|
105
|
+
if args.count >= 2, args[1] == "--model-pick" {
|
|
106
|
+
exit(modelPickCommand(Array(args.dropFirst(2))))
|
|
107
|
+
}
|
|
104
108
|
let app = NSApplication.shared
|
|
105
109
|
let delegate = AppDelegate()
|
|
106
110
|
app.delegate = delegate
|
|
@@ -147,6 +147,25 @@ final class ChiRunner {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/// `chi models --format json`: every host's models and the aliases. Exit
|
|
151
|
+
/// 1 (no host listed) still prints the default and the warnings. 10 s:
|
|
152
|
+
/// the boot and chi's own 4 s cap on the hosts, with a margin.
|
|
153
|
+
func models(completion: @escaping (Result<ModelList, ChiError>) -> Void) {
|
|
154
|
+
run(["models", "--format", "json"], timeout: 10) { result in
|
|
155
|
+
switch result {
|
|
156
|
+
case .failure(let error): completion(.failure(error))
|
|
157
|
+
case .success(let r):
|
|
158
|
+
if r.timedOut { completion(.failure(.failed("chi models took over 10 s"))); return }
|
|
159
|
+
guard r.status == 0 || r.status == 1, let list = ModelList.decode(Data(r.stdout.utf8)) else {
|
|
160
|
+
let detail = r.stderr.isEmpty ? r.stdout : r.stderr
|
|
161
|
+
completion(.failure(.failed("chi models failed (exit \(r.status)): \(detail.prefix(300))")))
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
completion(.success(list))
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
150
169
|
private func list(_ flags: [String], completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
|
|
151
170
|
// Every project's sessions: the helper isn't in any one project (an
|
|
152
171
|
// older chi ignores the flag).
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// The new session's model: `chi models --format json` (every host's
|
|
2
|
+
// listing and the aliases, the names `--model` takes) and the chooser's pure
|
|
3
|
+
// rules: its rows, search ranking, the remembered pick and the recent picks.
|
|
4
|
+
// The helper never builds or rewrites a name: what a row says is what
|
|
5
|
+
// `chi send --new --model` gets. `ChiHelper --model-pick` is the build
|
|
6
|
+
// spec's seam (stdin: the payload).
|
|
7
|
+
import Foundation
|
|
8
|
+
|
|
9
|
+
/// `chi models --format json`; printed on exit 1 too (no host listed: the
|
|
10
|
+
/// default alone).
|
|
11
|
+
struct ModelList: Decodable, Equatable {
|
|
12
|
+
struct Model: Decodable, Equatable {
|
|
13
|
+
let name: String
|
|
14
|
+
let host: String
|
|
15
|
+
let id: String
|
|
16
|
+
/// An alias of the same name takes it elsewhere: not offered.
|
|
17
|
+
let shadowedBy: String?
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
struct Alias: Decodable, Equatable {
|
|
21
|
+
let name: String
|
|
22
|
+
let ref: String
|
|
23
|
+
let host: String?
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let `default`: String?
|
|
27
|
+
let defaultTyped: String?
|
|
28
|
+
let defaultHost: String?
|
|
29
|
+
let models: [Model]
|
|
30
|
+
let aliases: [Alias]?
|
|
31
|
+
let warnings: [String]?
|
|
32
|
+
|
|
33
|
+
static func decode(_ data: Data) -> ModelList? {
|
|
34
|
+
let decoder = JSONDecoder()
|
|
35
|
+
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
36
|
+
return try? decoder.decode(ModelList.self, from: data)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// "default (small → box:gemma)" style: the default as the row shows it.
|
|
40
|
+
var defaultLabel: String? {
|
|
41
|
+
guard let name = `default` else { return nil }
|
|
42
|
+
if let typed = defaultTyped, !typed.isEmpty { return "\(typed) → \(name)" }
|
|
43
|
+
return name
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// One line of the chooser. +name+ is what `--model` gets (the default row's
|
|
48
|
+
/// is the default's name, for show: picking it sends no `--model`).
|
|
49
|
+
struct ModelRow: Equatable {
|
|
50
|
+
enum Kind: String { case standard = "default", listed, alias, typed }
|
|
51
|
+
|
|
52
|
+
let kind: Kind
|
|
53
|
+
let name: String
|
|
54
|
+
/// The host that listed it ("" when unknown: a recent pick before the list).
|
|
55
|
+
let host: String
|
|
56
|
+
/// What the search looks at besides the host: the id, an alias's name
|
|
57
|
+
/// and target, a typed name.
|
|
58
|
+
let id: String
|
|
59
|
+
let label: String
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
enum ModelPick {
|
|
63
|
+
static let pickKey = "newSessionModel"
|
|
64
|
+
static let recentKey = "recentModels"
|
|
65
|
+
static let maxRecent = 5
|
|
66
|
+
/// ⌘1…⌘9.
|
|
67
|
+
static let maxRows = 9
|
|
68
|
+
|
|
69
|
+
static func same(_ a: String, _ b: String) -> Bool { a.caseInsensitiveCompare(b) == .orderedSame }
|
|
70
|
+
|
|
71
|
+
/// Every name the list offers, default host first, an alias after its
|
|
72
|
+
/// host's ids; the default itself left out (it is the Default row).
|
|
73
|
+
static func rows(_ list: ModelList) -> [ModelRow] {
|
|
74
|
+
let defaultHost = list.defaultHost ?? ""
|
|
75
|
+
let listed = list.models.filter { $0.shadowedBy == nil }
|
|
76
|
+
var hosts: [String] = []
|
|
77
|
+
for host in listed.map(\.host) + (list.aliases ?? []).compactMap(\.host) where !hosts.contains(host) {
|
|
78
|
+
hosts.append(host)
|
|
79
|
+
}
|
|
80
|
+
hosts = hosts.filter { $0 == defaultHost } + hosts.filter { $0 != defaultHost }
|
|
81
|
+
var rows: [ModelRow] = []
|
|
82
|
+
for host in hosts {
|
|
83
|
+
for model in listed where model.host == host {
|
|
84
|
+
rows.append(ModelRow(kind: .listed, name: model.name, host: host, id: model.id,
|
|
85
|
+
label: host == defaultHost ? model.id : "\(host) · \(model.id)"))
|
|
86
|
+
}
|
|
87
|
+
for alias in list.aliases ?? [] where alias.host == host {
|
|
88
|
+
rows.append(ModelRow(kind: .alias, name: alias.name, host: host, id: "\(alias.name) \(alias.ref)",
|
|
89
|
+
label: "\(alias.name) → \(alias.ref)"))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for alias in list.aliases ?? [] where alias.host == nil {
|
|
93
|
+
rows.append(ModelRow(kind: .alias, name: alias.name, host: "", id: "\(alias.name) \(alias.ref)",
|
|
94
|
+
label: "\(alias.name) → \(alias.ref)"))
|
|
95
|
+
}
|
|
96
|
+
guard let standard = list.default else { return rows }
|
|
97
|
+
return rows.filter { !same($0.name, standard) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// The chooser's rows, at most maxRows: Default first; with no query
|
|
101
|
+
/// the recent picks still offered (as they are while the list loads),
|
|
102
|
+
/// then the list; with one, the ranked matches and, when no row is named
|
|
103
|
+
/// exactly that, the typed name ("not listed").
|
|
104
|
+
static func chooserRows(list: ModelList?, fallbackDefault: String?, recents: [String], query: String) -> [ModelRow] {
|
|
105
|
+
let standardName = list?.default ?? fallbackDefault ?? ""
|
|
106
|
+
let standard = ModelRow(kind: .standard, name: standardName, host: list?.defaultHost ?? "", id: standardName,
|
|
107
|
+
label: standardName.isEmpty ? "Default" : "Default (\(list?.defaultLabel ?? standardName))")
|
|
108
|
+
let all = list.map(rows) ?? []
|
|
109
|
+
let text = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
110
|
+
var out: [ModelRow] = [standard]
|
|
111
|
+
if text.isEmpty {
|
|
112
|
+
for name in recents where !(list != nil && same(name, standardName)) {
|
|
113
|
+
if let row = all.first(where: { same($0.name, name) }) {
|
|
114
|
+
out.append(row)
|
|
115
|
+
} else if list == nil {
|
|
116
|
+
out.append(ModelRow(kind: .listed, name: name, host: "", id: name, label: name))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for row in all where !out.contains(where: { same($0.name, row.name) }) { out.append(row) }
|
|
120
|
+
return Array(out.prefix(maxRows))
|
|
121
|
+
}
|
|
122
|
+
let exact = all.contains { same($0.name, text) } || same(text, standardName)
|
|
123
|
+
let room = maxRows - 1 - (exact ? 0 : 1)
|
|
124
|
+
out += match(all, query: text).prefix(room)
|
|
125
|
+
if !exact {
|
|
126
|
+
out.append(ModelRow(kind: .typed, name: text, host: "", id: text, label: "\(text) — not listed"))
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Rows matching every word of +query+ on the host or the id, ranked
|
|
132
|
+
/// like the web's picker: substring-only rows first (at a segment start
|
|
133
|
+
/// cheaper), then subsequences of 3+ characters by their gaps, then the
|
|
134
|
+
/// shorter id, then list order.
|
|
135
|
+
static func match(_ rows: [ModelRow], query: String) -> [ModelRow] {
|
|
136
|
+
let words = query.lowercased().split(whereSeparator: \.isWhitespace).map(String.init)
|
|
137
|
+
guard !words.isEmpty else { return [] }
|
|
138
|
+
var found: [(row: ModelRow, index: Int, sub: Bool, cost: Int)] = []
|
|
139
|
+
for (index, row) in rows.enumerated() {
|
|
140
|
+
let host = Array(row.host.lowercased()), id = Array(row.id.lowercased())
|
|
141
|
+
var sub = true, cost = 0, ok = true
|
|
142
|
+
for word in words {
|
|
143
|
+
let w = Array(word)
|
|
144
|
+
let onId = matchWord(w, id), onHost = matchWord(w, host)
|
|
145
|
+
guard let pick = better(onHost, than: onId) ? onHost : onId else { ok = false; break }
|
|
146
|
+
sub = sub && pick.sub
|
|
147
|
+
cost += pick.cost
|
|
148
|
+
}
|
|
149
|
+
if ok { found.append((row, index, sub, cost)) }
|
|
150
|
+
}
|
|
151
|
+
found.sort { a, b in
|
|
152
|
+
if a.sub != b.sub { return a.sub }
|
|
153
|
+
if a.cost != b.cost { return a.cost < b.cost }
|
|
154
|
+
if a.row.id.count != b.row.id.count { return a.row.id.count < b.row.id.count }
|
|
155
|
+
return a.index < b.index
|
|
156
|
+
}
|
|
157
|
+
return found.map(\.row)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private static let separators = Set("/-:._ ")
|
|
161
|
+
|
|
162
|
+
private static func matchWord(_ word: [Character], _ text: [Character]) -> (sub: Bool, cost: Int)? {
|
|
163
|
+
guard !word.isEmpty, word.count <= text.count else { return nil }
|
|
164
|
+
var best: (sub: Bool, cost: Int)?
|
|
165
|
+
for i in 0...(text.count - word.count) where Array(text[i..<(i + word.count)]) == word {
|
|
166
|
+
let cost = i == 0 || separators.contains(text[i - 1]) ? 0 : 2
|
|
167
|
+
if best == nil || cost < best!.cost { best = (true, cost) }
|
|
168
|
+
if cost == 0 { break }
|
|
169
|
+
}
|
|
170
|
+
if best != nil || word.count < 3 { return best }
|
|
171
|
+
guard var at = text.firstIndex(of: word[0]) else { return nil }
|
|
172
|
+
var gaps = 0
|
|
173
|
+
for k in 1..<word.count {
|
|
174
|
+
guard let i = text[(at + 1)...].firstIndex(of: word[k]) else { return nil }
|
|
175
|
+
gaps += i - at - 1
|
|
176
|
+
at = i
|
|
177
|
+
}
|
|
178
|
+
return (false, 10 + gaps)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private static func better(_ a: (sub: Bool, cost: Int)?, than b: (sub: Bool, cost: Int)?) -> Bool {
|
|
182
|
+
guard let a else { return false }
|
|
183
|
+
guard let b else { return true }
|
|
184
|
+
return (a.sub && !b.sub) || (a.sub == b.sub && a.cost < b.cost)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// The remembered pick for this open: kept while the list loads, and
|
|
188
|
+
/// once it is in, only when offered (as the list spells it) or when no
|
|
189
|
+
/// host listed anything (no way to tell); else the default, with a note.
|
|
190
|
+
static func keep(_ stored: String?, in list: ModelList?) -> (pick: String?, note: String?) {
|
|
191
|
+
guard let stored = stored?.trimmingCharacters(in: .whitespacesAndNewlines), !stored.isEmpty else { return (nil, nil) }
|
|
192
|
+
guard let list else { return (stored, nil) }
|
|
193
|
+
if let standard = list.default, same(standard, stored) { return (nil, nil) }
|
|
194
|
+
if let row = rows(list).first(where: { same($0.name, stored) }) { return (row.name, nil) }
|
|
195
|
+
if list.models.isEmpty { return (stored, nil) }
|
|
196
|
+
return (nil, "\(stored) isn't listed any more; using the default")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// The recent picks after +name+: it first, no other copy (any case).
|
|
200
|
+
static func pushRecent(_ list: [String], _ name: String) -> [String] {
|
|
201
|
+
Array(([name] + list.filter { !same($0, name) }).prefix(maxRecent))
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// `ChiHelper --model-pick [--query Q] [--recent NAME]... [--stored NAME]
|
|
206
|
+
/// [--push NAME]`: the payload on stdin, the chooser as JSON on stdout
|
|
207
|
+
/// (rows, the kept pick and its note, the recents after --push). Exit 1
|
|
208
|
+
/// when stdin isn't a payload.
|
|
209
|
+
func modelPickCommand(_ args: [String]) -> Int32 {
|
|
210
|
+
var args = args
|
|
211
|
+
var query = "", recents: [String] = [], stored: String?, push: String?
|
|
212
|
+
while !args.isEmpty {
|
|
213
|
+
let arg = args.removeFirst()
|
|
214
|
+
switch arg {
|
|
215
|
+
case "--query" where !args.isEmpty: query = args.removeFirst()
|
|
216
|
+
case "--recent" where !args.isEmpty: recents.append(args.removeFirst())
|
|
217
|
+
case "--stored" where !args.isEmpty: stored = args.removeFirst()
|
|
218
|
+
case "--push" where !args.isEmpty: push = args.removeFirst()
|
|
219
|
+
default:
|
|
220
|
+
FileHandle.standardError.write("usage: ChiHelper --model-pick [--query Q] [--recent NAME]... [--stored NAME] [--push NAME]\n".data(using: .utf8)!)
|
|
221
|
+
return 2
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
guard let list = ModelList.decode(FileHandle.standardInput.readDataToEndOfFile()) else {
|
|
225
|
+
FileHandle.standardError.write("not a chi models payload\n".data(using: .utf8)!)
|
|
226
|
+
return 1
|
|
227
|
+
}
|
|
228
|
+
let rows = ModelPick.chooserRows(list: list, fallbackDefault: nil, recents: recents, query: query)
|
|
229
|
+
let kept = ModelPick.keep(stored, in: list)
|
|
230
|
+
var out: [String: Any] = [
|
|
231
|
+
"rows": rows.map { ["kind": $0.kind.rawValue, "name": $0.name, "label": $0.label] },
|
|
232
|
+
"pick": kept.pick ?? NSNull(), "note": kept.note ?? NSNull(), "warnings": list.warnings ?? [],
|
|
233
|
+
]
|
|
234
|
+
if let push { out["recent"] = ModelPick.pushRecent(recents, push) }
|
|
235
|
+
let data = try! JSONSerialization.data(withJSONObject: out, options: [.sortedKeys])
|
|
236
|
+
print(String(decoding: data, as: UTF8.self))
|
|
237
|
+
return 0
|
|
238
|
+
}
|