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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +25 -1
  6. data/docs/desktop.md +24 -2
  7. data/lib/samagotchi/bridge.rb +4 -4
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  10. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  11. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  12. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  13. data/lib/samagotchi/engine.rb +445 -482
  14. data/lib/samagotchi/host_registry.rb +26 -2
  15. data/lib/samagotchi/idle_reminders.rb +13 -36
  16. data/lib/samagotchi/kernel_loop.rb +82 -120
  17. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  18. data/lib/samagotchi/llm/model_result.rb +14 -20
  19. data/lib/samagotchi/llm/native_backend.rb +3 -21
  20. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  21. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  22. data/lib/samagotchi/llm/usage.rb +2 -57
  23. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  24. data/lib/samagotchi/model_catalog.rb +101 -0
  25. data/lib/samagotchi/model_profile.rb +1 -1
  26. data/lib/samagotchi/models_command.rb +112 -0
  27. data/lib/samagotchi/reminder_queue.rb +73 -0
  28. data/lib/samagotchi/reminder_store.rb +3 -3
  29. data/lib/samagotchi/self_report.rb +8 -1
  30. data/lib/samagotchi/send_command.rb +1 -1
  31. data/lib/samagotchi/session_commands.rb +5 -5
  32. data/lib/samagotchi/session_manager.rb +2 -6
  33. data/lib/samagotchi/terminal_ui.rb +8 -28
  34. data/lib/samagotchi/thinking.rb +1 -1
  35. data/lib/samagotchi/tool_runner.rb +4 -6
  36. data/lib/samagotchi/tools/builtins.rb +1 -1
  37. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  38. data/lib/samagotchi/turn_flow.rb +4 -4
  39. data/lib/samagotchi/turn_state.rb +126 -0
  40. data/lib/samagotchi/used_memories.rb +88 -0
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/vision_support.rb +4 -4
  43. data/lib/samagotchi/web/app.rb +14 -33
  44. data/lib/samagotchi/web/session_hub.rb +0 -4
  45. data/lib/samagotchi/web/session_summary.rb +6 -6
  46. data/lib/samagotchi/worker.rb +1 -1
  47. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f0638cac2109278f683d0b6caeca1bb633786721471c1f366f2cf3bc459b43f
4
- data.tar.gz: 988b05ee21480227d4a1ed814457dd26af11ed71bf21b3d6c62f863e07f18e1a
3
+ metadata.gz: 9668c1088aa3739aa60890e07c7d04cf41fb87debf9683b6cd42b6e6e61ee265
4
+ data.tar.gz: ecfd58cc93c0dee8d867936c75754f854da34cf436dce28fcbc442e7f61c6ec2
5
5
  SHA512:
6
- metadata.gz: b7905a2a451c696ca057def8388137bc6a03523bd43a8e9405d2086a99b979f4804fe51c3bb3fd7fc444ade5ea7909339a29c99a3373b9ad3b254d8702210906
7
- data.tar.gz: ce71c8a3a069014d738088e50372c9f50553bd9da12613a756c5941498ba2f3024f9954d2bbe672b10b6c0b00cc5c80cd296858ec84a1425e4ef3713e84cf0fd
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.11.0...HEAD
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.model_name
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) # => KernelLoop::Result (`.output`)
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 `KernelLoop::Result`)
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.** `ModelResult#usage` is an `LLM::Usage` (server counts, else
334
- a chars/4 estimate, else zeros; never nil). Model lists are `LLM::ModelInfo`
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 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 (the desktop helper's hint)
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); it
39
- is left out when none is configured.
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
 
@@ -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 } if @engine.respond_to?(:running_turn_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 if @engine.respond_to?(:running_turn_messages=)
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.respond_to?(:init_tasks) ? @engine.init_tasks : [],
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.respond_to?(:command_registry) ? @engine.command_registry : SessionCommands.builtin_registry
245
+ @engine.command_registry
246
246
  end
247
247
 
248
248
  # The single shared capture observer: appends every event to the ring.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.11.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:
@@ -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
+ }