samagotchi 0.11.0 → 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 +29 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +4 -4
- data/docs/cli.md +25 -1
- data/docs/desktop.md +24 -2
- data/lib/samagotchi/bridge.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- 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/engine.rb +445 -482
- data/lib/samagotchi/host_registry.rb +26 -2
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +82 -120
- data/lib/samagotchi/llm/chat_loop.rb +17 -38
- 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 +1 -1
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +2 -6
- data/lib/samagotchi/terminal_ui.rb +8 -28
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- 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 +14 -33
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +6 -6
- data/lib/samagotchi/worker.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9668c1088aa3739aa60890e07c7d04cf41fb87debf9683b6cd42b6e6e61ee265
|
|
4
|
+
data.tar.gz: ecfd58cc93c0dee8d867936c75754f854da34cf436dce28fcbc442e7f61c6ec2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd2b82ad80038fb47d35a7b76177312521548a61764963697bebe2cb3b5389fd2d604debba118a87bca43077f2f350b7a0c5f63878df73c5ad75382951246ca0
|
|
7
|
+
data.tar.gz: ef64847c760b05f8b6ee084fcd39b0ffc2595de21538c388ee7dc8c37f59a952bf4f55b2cf8db8df8187cf4600dd890e996bb94473a9af4e3e99a8cef8ed48af
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,33 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.12.0] - 2026-10-02
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `chi models [--format json] [--timeout S] [TEXT]` lists the names `--model` takes from every host (default first,
|
|
16
|
+
then `host:id`, then aliases), waiting at most 4 s for the hosts; exit 1 when no host listed.
|
|
17
|
+
- The desktop helper chooses the model for a new session: ⌘M (or a click on the new row's model) opens a searchable
|
|
18
|
+
chooser of every host's models and aliases; the pick and five recent ones are remembered. Run `chi desktop upgrade`.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- `chi self --model` prints an alias default as the ref it resolves to (`box:gemma-small`, not `small`).
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- A failed turn's messages and its `turn_failed` event reach a joining client together (a snapshot taken in between
|
|
27
|
+
could show one without the other).
|
|
28
|
+
- A Ctrl-C while an `after_turn` or `session_end` hook runs no longer cancels the turn that already answered: the
|
|
29
|
+
answer stays in the session (it was replaced with a cancel note and a second end event).
|
|
30
|
+
- A web Stop sent right as a turn starts (while chi asks the model server for its settings, a first turn's /props
|
|
31
|
+
probe) cancels the turn at once; it used to answer "not running" and the turn ran on. A failed probe there now ends
|
|
32
|
+
the turn as failed instead of leaving the session marked running.
|
|
33
|
+
- Reminders keep firing on their own while a session is idle. A reminder that a prompt's turn delivered just as the
|
|
34
|
+
idle check found it due, or a reminder turn that failed before it started, could stop them until the next prompt.
|
|
35
|
+
|
|
36
|
+
Update with `chi update` (no bundle changes); run `chi desktop upgrade` for the desktop helper's model chooser.
|
|
37
|
+
|
|
11
38
|
## [0.11.0] - 2026-10-01
|
|
12
39
|
|
|
13
40
|
### Added
|
|
@@ -642,7 +669,8 @@ and long-lived sessions.
|
|
|
642
669
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
643
670
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
644
671
|
|
|
645
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
672
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.12.0...HEAD
|
|
673
|
+
[0.12.0]: https://github.com/dm1try/samagotchi/compare/v0.11.0...v0.12.0
|
|
646
674
|
[0.11.0]: https://github.com/dm1try/samagotchi/compare/v0.10.1...v0.11.0
|
|
647
675
|
[0.10.1]: https://github.com/dm1try/samagotchi/compare/v0.10.0...v0.10.1
|
|
648
676
|
[0.10.0]: https://github.com/dm1try/samagotchi/compare/v0.9.0...v0.10.0
|
data/bin/chi
CHANGED
|
@@ -23,13 +23,13 @@ if ARGV[0] == "self"
|
|
|
23
23
|
if %w[-h --help help].include?(ARGV[1])
|
|
24
24
|
puts "Usage: chi self [--model]"
|
|
25
25
|
puts " Print version, source dir, config/memory/session paths, model/host and bundles."
|
|
26
|
-
puts " --model print only the model a new session starts on (exit 1 when none is configured)"
|
|
26
|
+
puts " --model print only the model a new session starts on, an alias resolved (exit 1 when none is configured)"
|
|
27
27
|
exit 0
|
|
28
28
|
end
|
|
29
29
|
require "samagotchi/self_report"
|
|
30
30
|
# The desktop helper's hint for its "New session" row: offline and quick.
|
|
31
31
|
if ARGV[1] == "--model"
|
|
32
|
-
model = Samagotchi::SelfReport.
|
|
32
|
+
model = Samagotchi::SelfReport.model_ref_name
|
|
33
33
|
puts model if model
|
|
34
34
|
exit(model ? 0 : 1)
|
|
35
35
|
end
|
|
@@ -49,6 +49,8 @@ SUBCOMMANDS = {
|
|
|
49
49
|
"bootstrap" => ["samagotchi/bootstrap_command", "BootstrapCommand"],
|
|
50
50
|
# bring an installed chi up to date
|
|
51
51
|
"update" => ["samagotchi/update_command", "UpdateCommand"],
|
|
52
|
+
# the models --model takes, from every host (--format, --timeout)
|
|
53
|
+
"models" => ["samagotchi/models_command", "ModelsCommand"],
|
|
52
54
|
# the native "Send to chi" helper
|
|
53
55
|
"desktop" => ["samagotchi/desktop_command", "DesktopCommand"],
|
|
54
56
|
# install, upgrade, uninstall, status, diff, list, build (--scope, --force, …)
|
|
@@ -94,6 +96,7 @@ parser = OptionParser.new do |opts|
|
|
|
94
96
|
chi send [-m TEXT] ID...|--new a message into sessions, as if typed there
|
|
95
97
|
chi bundle <install|upgrade|uninstall|status|diff|list|build>
|
|
96
98
|
chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
|
|
99
|
+
chi models [--format json] [TEXT] the models --model takes, from every host
|
|
97
100
|
chi self version, paths, model and bundles
|
|
98
101
|
chi update [--dry-run] update this chi, its bundles and the desktop helper
|
|
99
102
|
(each subcommand takes --help)
|
data/docs/architecture.md
CHANGED
|
@@ -159,7 +159,7 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
|
|
|
159
159
|
engine = Samagotchi::Engine.new(mode: :assist, model_name: "gemma4", memories: [])
|
|
160
160
|
session = Samagotchi::Session.new_session(mode: "assist", model_name: "gemma4", working_directory: Dir.pwd)
|
|
161
161
|
|
|
162
|
-
engine.run_turn(session, "hello", on_event: nil) # =>
|
|
162
|
+
engine.run_turn(session, "hello", on_event: nil) # => LLM::ModelResult (`.output`)
|
|
163
163
|
```
|
|
164
164
|
|
|
165
165
|
#### The `on_event` seam
|
|
@@ -169,7 +169,7 @@ forwards the raw `KernelLoop` events unchanged (the low-level contract) and adds
|
|
|
169
169
|
higher-level events so UIs get clean turn boundaries without inferring them:
|
|
170
170
|
|
|
171
171
|
- `:turn_started` — `{ session_id:, prompt: }`
|
|
172
|
-
- `:turn_completed` — `{ result: }` (the final `
|
|
172
|
+
- `:turn_completed` — `{ result: }` (the final `LLM::ModelResult`)
|
|
173
173
|
- `:turn_canceled` — `{ cancellation_reason: }`
|
|
174
174
|
|
|
175
175
|
Every event is a `Hash` with a `:type` symbol key; the sink must not raise (the Engine
|
|
@@ -330,8 +330,8 @@ conversation (the prompt plus completed tool iterations) and emits
|
|
|
330
330
|
`:turn_failed` with `error_kind:`, `retryable:`, `host:` and a one-line
|
|
331
331
|
`summary:`, which the REPL, the attached TUI and the web show.
|
|
332
332
|
|
|
333
|
-
**Usage and models.**
|
|
334
|
-
|
|
333
|
+
**Usage and models.** A turn's token counts are `SessionMetrics`', read from the
|
|
334
|
+
stream events (server counts, else a chars/4 estimate). Model lists are `LLM::ModelInfo`
|
|
335
335
|
(`HostRegistry#list_all_models`); the context window comes from the running
|
|
336
336
|
server (`/props`), then the host's model list, then config.
|
|
337
337
|
|
data/docs/cli.md
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
- `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
|
|
20
20
|
- `chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one picks it up); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead, and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; see [Starting a session](sessions.md#starting-a-session))
|
|
21
21
|
- `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text to live sessions as context notes (see [Desktop helper](desktop.md))
|
|
22
|
-
- `chi
|
|
22
|
+
- `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
|
|
23
|
+
- `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles; `chi self --model` prints only the model a new session starts on, an alias resolved to its target (the desktop helper's hint)
|
|
23
24
|
- `chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
|
|
24
25
|
- `chi bundle install|upgrade|uninstall|status|diff|list|build` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle)). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for every other command
|
|
25
26
|
|
|
@@ -66,6 +67,29 @@ chi bootstrap # try localhost 8080, 11434, 1234, 8000
|
|
|
66
67
|
A bundle that fails to install makes the exit 1. `--dry-run` says what it
|
|
67
68
|
would install.
|
|
68
69
|
|
|
70
|
+
### Listing the models
|
|
71
|
+
|
|
72
|
+
`chi models` asks every host in `hosts:` for its models, in parallel, and prints the names `--model` (and
|
|
73
|
+
`chi send --new --model`, `/model`) takes, one per line:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
$ chi models
|
|
77
|
+
gemma-4 # the default (default.model), first
|
|
78
|
+
splash:incoai/Qwen3.8-27B-Splash # another host's id: host:id
|
|
79
|
+
openrouter:qwen/qwen3.8-27b
|
|
80
|
+
small -> box:gemma-small # an alias and the ref it resolves to
|
|
81
|
+
$ chi models qwen # only the names containing "qwen" (any case)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- A default-host id is bare, except one with a `:` (`qwen3:8b`), which is written `<host>:qwen3:8b` so it can't
|
|
85
|
+
read as a host name. An id that an alias of the same name hides is left out.
|
|
86
|
+
- Each run lists anew (no cache); `--timeout S` caps the wait for the hosts (default 4 s). A host that fails or doesn't
|
|
87
|
+
answer in time is noted on stderr (`chi models: box: no answer in 4 s`).
|
|
88
|
+
- `--format json` prints one object: `default` (and `default_typed` when it was set as an alias), `default_host`,
|
|
89
|
+
`models` (`name`, `host`, `id`), `aliases` (`name`, `ref`, `host`) and `warnings`. The desktop helper's model
|
|
90
|
+
chooser reads it.
|
|
91
|
+
- Exit 0 when any host listed its models, 1 when none did (the default is still printed), 2 on a usage error.
|
|
92
|
+
|
|
69
93
|
### Updating
|
|
70
94
|
|
|
71
95
|
`chi update` brings an installed chi up to date and prints one table:
|
data/docs/desktop.md
CHANGED
|
@@ -35,8 +35,30 @@ The first row, **New session in <folder>** (⌘0), starts a session with the mes
|
|
|
35
35
|
that of the most recently updated live session, else of the newest recent one, else your home folder. It is ticked
|
|
36
36
|
alone (ticking it clears the sessions and the reverse) and is preselected when no session is live. ⌘⏎ on it beeps: a
|
|
37
37
|
note needs a session. After the send the panel shows `started <id>…` for 3 s. On its right, in grey, the row names
|
|
38
|
-
the model the new session starts on (`chi self --model`: `default.model` from config.yml, or its env override
|
|
39
|
-
|
|
38
|
+
the model the new session starts on (`chi self --model`: `default.model` from config.yml, or its env override; an
|
|
39
|
+
alias as the model it names).
|
|
40
|
+
|
|
41
|
+
### Choosing the model
|
|
42
|
+
|
|
43
|
+
Click the model on the new row, or press **⌘M**, to choose another one for the new session. The chooser takes the
|
|
44
|
+
place of the session list: a search field (focused), then **Default (<model>)** (`Default (small → box:gemma-small)`
|
|
45
|
+
for an alias), your recent picks and every host's
|
|
46
|
+
models and aliases (from [`chi models`](cli.md#listing-the-models)), at most nine at a time.
|
|
47
|
+
|
|
48
|
+
- Type to search: every word must match the host or the id; names starting with what you typed rank first. A name
|
|
49
|
+
no host lists is offered at the bottom as "not listed" and sent as typed (a host that is down, a model just added).
|
|
50
|
+
- ↑/↓ and ⏎, a click, or ⌘1…⌘9 pick; Esc (or ⌘M) closes the chooser only, a second Esc the panel. While an input
|
|
51
|
+
method is composing, ⏎, the arrows and Esc are its own.
|
|
52
|
+
- The row then shows the pick in the accent colour, and ⏎ sends `chi send --new --model <name>`, the name exactly as
|
|
53
|
+
the chooser shows it; Default sends no `--model`. An existing session keeps its model (switch it there with `/model`).
|
|
54
|
+
- The pick is remembered for the next open (and the last five in the recent list), for every folder. A remembered
|
|
55
|
+
model no host lists any more falls back to the default, with a note in the panel.
|
|
56
|
+
- The list is fetched again at each open; the previous one shows meanwhile ("Loading models…" the first time after
|
|
57
|
+
the helper starts). Hosts that failed or took over 4 s are named under the list; the default and the recent picks
|
|
58
|
+
still work.
|
|
59
|
+
|
|
60
|
+
The chooser needs a chi with `chi models`: an older helper works with a newer chi, not the reverse
|
|
61
|
+
(`chi desktop upgrade` after updating chi).
|
|
40
62
|
|
|
41
63
|
A session open in a `chi --no-shared` REPL isn't listed: it takes no notes or messages.
|
|
42
64
|
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -153,7 +153,7 @@ module Samagotchi
|
|
|
153
153
|
@capture_handle = @engine.subscribe(observer: capture_observer)
|
|
154
154
|
@accumulator_handle = @engine.subscribe(observer: @accumulator)
|
|
155
155
|
# A plugin's ctx.messages mid-turn holds the turn so far (plan O1).
|
|
156
|
-
@engine.running_turn_messages = -> { @accumulator.current_messages }
|
|
156
|
+
@engine.running_turn_messages = -> { @accumulator.current_messages }
|
|
157
157
|
@cards_handle = @engine.subscribe(observer: @cards)
|
|
158
158
|
# One a worker that died left is not open.
|
|
159
159
|
@pending_card.clear
|
|
@@ -180,7 +180,7 @@ module Samagotchi
|
|
|
180
180
|
end
|
|
181
181
|
@capture_handle&.unsubscribe
|
|
182
182
|
@accumulator_handle&.unsubscribe
|
|
183
|
-
@engine.running_turn_messages = nil
|
|
183
|
+
@engine.running_turn_messages = nil
|
|
184
184
|
@cards_handle&.unsubscribe
|
|
185
185
|
@pending_card_handle&.unsubscribe
|
|
186
186
|
@pending_card&.clear
|
|
@@ -217,7 +217,7 @@ module Samagotchi
|
|
|
217
217
|
continue_offer: @accumulator.continue_offer,
|
|
218
218
|
guardrail_warning: @engine.guardrail_warning,
|
|
219
219
|
plugin_warning: @engine.plugin_warning,
|
|
220
|
-
init_tasks: @engine.
|
|
220
|
+
init_tasks: @engine.init_tasks,
|
|
221
221
|
cards: @cards.list,
|
|
222
222
|
commands: command_registry.listing,
|
|
223
223
|
event_seq: seq,
|
|
@@ -242,7 +242,7 @@ module Samagotchi
|
|
|
242
242
|
|
|
243
243
|
# The Engine's commands: the built-ins and its plugins'.
|
|
244
244
|
def command_registry
|
|
245
|
-
@engine.
|
|
245
|
+
@engine.command_registry
|
|
246
246
|
end
|
|
247
247
|
|
|
248
248
|
# The single shared capture observer: appends every event to the ring.
|
|
@@ -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
|
+
}
|