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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +23 -4
  5. data/docs/cli.md +49 -8
  6. data/docs/configuration.md +44 -11
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/context-telemetry.md +4 -2
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  13. data/lib/samagotchi/bridge.rb +4 -4
  14. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  15. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/client.rb +21 -1
  21. data/lib/samagotchi/config.rb +44 -40
  22. data/lib/samagotchi/context_status.rb +17 -16
  23. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  25. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  27. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  28. data/lib/samagotchi/engine.rb +506 -499
  29. data/lib/samagotchi/host_registry.rb +76 -92
  30. data/lib/samagotchi/idle_reminders.rb +13 -36
  31. data/lib/samagotchi/kernel_loop.rb +352 -407
  32. data/lib/samagotchi/llm/chat_loop.rb +95 -125
  33. data/lib/samagotchi/llm/model_result.rb +14 -20
  34. data/lib/samagotchi/llm/native_backend.rb +3 -21
  35. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  36. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  37. data/lib/samagotchi/llm/usage.rb +2 -57
  38. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  39. data/lib/samagotchi/model_catalog.rb +101 -0
  40. data/lib/samagotchi/model_profile.rb +23 -4
  41. data/lib/samagotchi/model_ref.rb +60 -0
  42. data/lib/samagotchi/models_command.rb +112 -0
  43. data/lib/samagotchi/prompt_history.rb +91 -0
  44. data/lib/samagotchi/recap_setup.rb +15 -3
  45. data/lib/samagotchi/reminder_queue.rb +73 -0
  46. data/lib/samagotchi/reminder_store.rb +3 -3
  47. data/lib/samagotchi/self_report.rb +19 -7
  48. data/lib/samagotchi/send_command.rb +9 -9
  49. data/lib/samagotchi/session.rb +9 -2
  50. data/lib/samagotchi/session_commands.rb +15 -17
  51. data/lib/samagotchi/session_manager.rb +22 -9
  52. data/lib/samagotchi/sessions_command.rb +57 -51
  53. data/lib/samagotchi/steer.rb +23 -0
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  56. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  57. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  58. data/lib/samagotchi/terminal_ui.rb +16 -35
  59. data/lib/samagotchi/thinking.rb +1 -1
  60. data/lib/samagotchi/tool_response.rb +67 -0
  61. data/lib/samagotchi/tool_runner.rb +10 -7
  62. data/lib/samagotchi/tools/builtins.rb +1 -1
  63. data/lib/samagotchi/tools/delegate.rb +4 -3
  64. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  65. data/lib/samagotchi/tools/memory.rb +8 -6
  66. data/lib/samagotchi/turn_flow.rb +4 -4
  67. data/lib/samagotchi/turn_state.rb +126 -0
  68. data/lib/samagotchi/used_memories.rb +88 -0
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/vision_support.rb +4 -4
  71. data/lib/samagotchi/web/app.rb +42 -35
  72. data/lib/samagotchi/web/public/app.js +29 -2
  73. data/lib/samagotchi/web/public/data.js +9 -1
  74. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  75. data/lib/samagotchi/web/session_hub.rb +0 -4
  76. data/lib/samagotchi/web/session_summary.rb +6 -6
  77. data/lib/samagotchi/worker.rb +3 -2
  78. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fbd9765613c81776d1a8d62e1edacd50cc426843dcae07d2c0e6a11ebe80972
4
- data.tar.gz: 8ed4b10801de4650ded6cd223d7723055acd8d0fa1ed3e98af36fe2cb174c403
3
+ metadata.gz: 9668c1088aa3739aa60890e07c7d04cf41fb87debf9683b6cd42b6e6e61ee265
4
+ data.tar.gz: ecfd58cc93c0dee8d867936c75754f854da34cf436dce28fcbc442e7f61c6ec2
5
5
  SHA512:
6
- metadata.gz: fd72e187dab11b52fa7ba403eeecd3ea29b43b2e2364b31cce773279cbbadb18c0539e30f16fed5e76ae849f82c31e76e300cb2f6a419699c127c7396cc14263
7
- data.tar.gz: 4abd84f3c1a83d0afa57239709919c9753c4eb8037503532e5a373a6ef3d0e674cfee330d15a717c50c48b11e5adf2be6611178c13aaf26fa4237fb89ed030e4
6
+ metadata.gz: cd2b82ad80038fb47d35a7b76177312521548a61764963697bebe2cb3b5389fd2d604debba118a87bca43077f2f350b7a0c5f63878df73c5ad75382951246ca0
7
+ data.tar.gz: ef64847c760b05f8b6ee084fcd39b0ffc2595de21538c388ee7dc8c37f59a952bf4f55b2cf8db8df8187cf4600dd890e996bb94473a9af4e3e99a8cef8ed48af
data/CHANGELOG.md CHANGED
@@ -8,6 +8,97 @@ 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
+
38
+ ## [0.11.0] - 2026-10-01
39
+
40
+ ### Added
41
+
42
+ - The web composer has the TUI's prompt history: ↑/↓ walk the prompts typed in either (↑ with the caret on the first
43
+ line; ↓ past the newest brings your draft back). A running TUI picks up lines typed in the web, or in another
44
+ terminal, without a restart.
45
+ - Hooks and plugins: `:after_tool_call` carries the call's `status` (`ok`, `error`, `blocked` or `stopped`), the one its
46
+ activity line shows, worked out from the full output (`output:` is capped).
47
+ - The `skills` bundle (0.1.5) tells a failed step by that `status` instead of matching the output's wording: an
48
+ execute killed by a signal now counts as failed, and an `Error:` line in the output of a command that exited 0 no
49
+ longer does. It needs this chi.
50
+
51
+ ### Changed
52
+
53
+ - The prompt history keeps 100 entries (was 20), is written under a lock and replaced whole (no lost lines when two
54
+ chi processes write at once), and is now mode 0600.
55
+ - With an OpenAI-compatible chat host (`api: openai`), the context value in the REPL's status line and the web's ctx
56
+ meter now moves during a turn, before each request, and the model gets the same short `[CONTEXT: …]` line the
57
+ llama.cpp loop gives it once usage rises past 40 % (`context.status_thresholds`). Before the host reports its first
58
+ token counts, the value is an estimate.
59
+ - Model aliases apply once (an alias pointing to another alias sends that name as written; chi warns at start), and a
60
+ session stores the resolved model (`box:gemma-small` for `tiny`) plus the name it was typed as (`model_typed`), so a
61
+ resumed session keeps its model when an alias is retargeted. With a `default.model` alias, memory overlays and the
62
+ guardrails' `models:` rules now go by the alias's target (an overlay saved under the alias is still read).
63
+ - Breaking: only `:` names a host in a model ref. `/` is part of the id, so `openai/gpt-4o` (an OpenRouter id) goes to
64
+ the default host as written even when a host is named `openai`. chi always wrote `host:`; a hand-written
65
+ `host/model` in `default.model`, an alias or a saved session warns at start (fix it with `host:model`).
66
+ - `/models` shows a `host:model` alias only under its own host.
67
+ - Breaking: an unqualified model name is routed by exact id only. It goes to the default host when that host lists it,
68
+ else to the first host in `hosts:` order that does (it was whichever host answered `/models` first), else to the
69
+ default host; substring routing is gone (`/model gemma` no longer picks a local host listing `gemma-…`: use
70
+ `box:gemma` or the exact id). chi warns at start about a host named like a model family (`qwen3`, `llama`, …).
71
+ - `recap.model` is a model ref like any other: an alias is applied, and `recap: {model: box:x}` with no `host_ref` recaps
72
+ on `box` (it turned recap off). Breaking: `recap: {host_ref: a, model: b:x}` now warns and turns recap off instead of
73
+ silently asking `a` for `x`.
74
+
75
+ ### Fixed
76
+
77
+ - `recap: {host_ref: openrouter, model: openai/gpt-4o}` sent `gpt-4o`.
78
+ - An alias in `default.model`, the web's new-session model, `chi send --new --model` or a plugin fork was sent to the
79
+ server as the alias, not its target; `chi self` named a different model than the worker sent.
80
+ - `/model box:tiny` (an alias for `box:gemma-small`) sent `box:gemma-small` to box; `/model openrouter:tiny` is now
81
+ refused (the alias names host `box`), in the TUI and as a web 400.
82
+ - `models: {small: …}` now applies after `chi --model small` and after resuming such a session.
83
+ - A TUI already waiting at `>` shows a prompt just typed in the web (or another terminal) at the first ↑, instead of
84
+ only from the next prompt on.
85
+ - The `known-names` bundle checks the name in `~name` (that user's home folder): `ls ~myname` is no longer rejected
86
+ as a near miss of `myname`, and `ls ~mynmae` is caught.
87
+ - `chi sessions` usage errors exit 2, as every other command's: an unknown subcommand, `stop` with no ids, a bad
88
+ `list --format` or `--scope`, and now also an unknown flag or a flag missing its value for `list`, `prune` and
89
+ `clean` (they were ignored). Each prints what was wrong and the usage on stderr. A session that is unknown or
90
+ refused still exits 1.
91
+ - With a llama.cpp, mlx or oMLX server (no `api: openai`), an empty answer cut short because the context is full
92
+ (90 % or more) is no longer asked again, as `retry.empty_answer` documents and as with OpenAI-compatible chat
93
+ hosts; a thinking loop cut by the output cap still is. These loops' `generation_completed` events and log lines now
94
+ carry the `finish_reason` too.
95
+ - With an OpenAI-compatible chat host, an answer of only whitespace is an empty answer, as with llama.cpp: asked again
96
+ (`retry.empty_answer`) instead of saved, and with no retry left the turn says the model returned an empty answer.
97
+ - `--no-interrupt` (and `no_interrupt: true`) now raises the tool-call limit to 1000 in the in-process REPL with an
98
+ OpenAI-compatible chat host too; it only applied to llama.cpp, mlx and oMLX hosts there.
99
+
100
+ Update with `chi update` (bundles: known-names 0.1.4, skills 0.1.5).
101
+
11
102
  ## [0.10.1] - 2026-10-01
12
103
 
13
104
  ### Fixed
@@ -578,7 +669,9 @@ and long-lived sessions.
578
669
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
579
670
  hotkey panel that send selected text or the clipboard to your sessions.
580
671
 
581
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.10.1...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
674
+ [0.11.0]: https://github.com/dm1try/samagotchi/compare/v0.10.1...v0.11.0
582
675
  [0.10.1]: https://github.com/dm1try/samagotchi/compare/v0.10.0...v0.10.1
583
676
  [0.10.0]: https://github.com/dm1try/samagotchi/compare/v0.9.0...v0.10.0
584
677
  [0.9.0]: https://github.com/dm1try/samagotchi/compare/v0.8.1...v0.9.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
@@ -269,6 +269,11 @@ a hub; an `App.new` without one answers both routes `503 no_hub`.
269
269
 
270
270
  ## Model loops and adapters
271
271
 
272
+ A model ref (`--model`, `/model`, `default.model`, an alias's target) is parsed
273
+ by one pure resolver, `ModelRef.parse` (host prefix, alias), given the configured
274
+ hosts and aliases; `HostRegistry` routes a name that names no host (its model
275
+ index after `/models`) and builds the `ModelTarget`.
276
+
272
277
  Engine picks the loop from the effective model's host (`HostRegistry#resolve`):
273
278
 
274
279
  | Loop | Class | Host | Talks through |
@@ -297,6 +302,20 @@ hook's `stop_generation` cancels the generation's own controller (a child of
297
302
  the turn's, `CancellationController#generation`), and each loop takes the cut
298
303
  as an empty answer and asks again.
299
304
 
305
+ Both loops keep one `EmptyAnswerRetry` per turn (the `retry.empty_answer`
306
+ budget, the hidden nudge, the retry's sampling), and both end each
307
+ generation's `:generation_completed` with a `finish_reason`: the chat host's
308
+ own, or for the raw-prompt loop what `Client::Transport#finish_reason_from`
309
+ reads from the stream's last payload (llama.cpp `/completion`'s `stop_type`:
310
+ `limit` is `length`, `eos` and a stop word are `stop`; `/v1/completions` sends
311
+ its own). An empty answer with a `length` stop and the context 90 % full or
312
+ more is not asked again (a full window, not a thinking loop); with no finish
313
+ reason it is. Both also keep one `ContextStatus` per turn: before each request
314
+ it estimates how full the window is (the server's last count plus what the turn
315
+ appended since), emits `:context_status` on a bucket change, and on a rise past
316
+ the second threshold puts the model's own `[CONTEXT: …]` line on the tail as a
317
+ system message (docs/internals/context-telemetry.md).
318
+
300
319
  **Adapters.** `Client` (raw-prompt servers) and `LLM::OpenAIChat` (one per host,
301
320
  `HostRegistry#adapter_for`) share `LLM::HTTP`: timeouts, TLS for https, a line
302
321
  reader for streamed bodies, the retry loop (`retry.*`; network errors, 429 and
@@ -311,8 +330,8 @@ conversation (the prompt plus completed tool iterations) and emits
311
330
  `:turn_failed` with `error_kind:`, `retryable:`, `host:` and a one-line
312
331
  `summary:`, which the REPL, the attached TUI and the web show.
313
332
 
314
- **Usage and models.** `ModelResult#usage` is an `LLM::Usage` (server counts, else
315
- 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`
316
335
  (`HostRegistry#list_all_models`); the context window comes from the running
317
336
  server (`/props`), then the host's model list, then config.
318
337
 
data/docs/cli.md CHANGED
@@ -15,11 +15,12 @@
15
15
  - `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
16
16
  - `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages
17
17
  - `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
18
- - `chi sessions list|stop|archive|unarchive|delete|prune|clean` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md))
18
+ - `chi sessions list|stop|archive|unarchive|delete|prune|clean` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). A usage error (an unknown subcommand or flag, a flag missing its value, `stop` or `delete` with no ids, a bad `list --format` or `--scope`) exits 2; an unknown or refused session exits 1
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:
@@ -576,13 +600,13 @@ access token", paste the token there (the part of the link after
576
600
  In interactive assist mode, you can switch the request model without restarting:
577
601
 
578
602
  - `/model <name>`: set a session-scoped model override.
579
- - `/model host:model` or `/model host/alias`: qualified host routing (`host:alias` expands alias bare, alias may itself be `host:model` — hybrid).
603
+ - `/model host:model` or `/model host:alias`: qualified host routing (only `:` names a host; `openai/gpt-4o` is a model id). `host:alias` applies the alias; an alias whose target names another host is refused (`alias 'tiny' names host 'box', not 'openrouter'`). Aliases apply once: an alias pointing to another alias sends that name as written.
580
604
  - `/model --default <name>`: set session model and persist as new default in `config.yml` (also updates `SAMAGOTCHI_DEFAULT_MODEL` for future sessions; supports `host:model` full ref).
581
605
  - `/model <name> --alias <alias>`: create alias for current effective model (alias value may be bare or `host:model`).
582
606
  - `/model`: show the effective model (and default when diverged: `runtime model: <effective> (default: <default>, profile=<name>, <source>)`, e.g. `profile=qwen36, server (chat_template)`).
583
607
  - `/model clear` (or `default`/`none`/`off`): clear the session override, reverting to the configured default.
584
608
  - `/guardrails`: the guardrail rules (by source), what failed to load, and your stored approvals, numbered; `/guardrails revoke N` removes approval N (see [Guardrails](guardrails.md)).
585
- - `/models`: list model ids aggregated across all `hosts:` (grouped `host (host:port):` with per-host `unreachable` warnings, e.g. an unset `api_key_env`; lists cached 60s, 10 minutes for a remote host; lazy — no startup prefill). At most 20 ids per host, then `… and N more`; `/models <text>` lists every id containing `<text>` (any case), e.g. `/models qwen` on OpenRouter.
609
+ - `/models`: list model ids aggregated across all `hosts:` (an alias shows next to its id: a bare alias on every host listing the id, a `host:model` one only under its host; grouped `host (host:port):` with per-host `unreachable` warnings, e.g. an unset `api_key_env`; lists cached 60s, 10 minutes for a remote host; lazy — no startup prefill). At most 20 ids per host, then `… and N more`; `/models <text>` lists every id containing `<text>` (any case), e.g. `/models qwen` on OpenRouter.
586
610
 
587
611
  Notes:
588
612
 
@@ -640,17 +664,34 @@ This is separate from verbose mode:
640
664
 
641
665
  ## Persistent Prompt History
642
666
 
643
- Assist mode keeps a small persistent prompt history across restarts.
667
+ Assist mode keeps a persistent prompt history across restarts, one for every
668
+ chi prompt: the REPL, attached mode and the `chi web` composer (↑/↓ there)
669
+ share it.
644
670
 
645
671
  - Default history file: `$XDG_STATE_HOME/samagotchi/history.json`
646
672
  - XDG fallback when unset: `~/.local/state/samagotchi/history.json`
647
- - Optional override: `SAMAGOTCHI_HISTORY_FILE=/custom/path/history.json`
648
- - Stored entries: most recent `20` prompts
649
- - Format: JSON array of prompt strings
673
+ - Optional override: `history.file` in the config, or
674
+ `SAMAGOTCHI_HISTORY_FILE=/custom/path/history.json`
675
+ - Stored entries: most recent `100` prompts (and `!commands`; not `/commands`
676
+ or `!rollback`)
677
+ - Format: JSON array of prompt strings, mode 0600. Each write holds
678
+ `<file>.lock` and replaces the file whole, so a TUI and the web writing at
679
+ once keep both lines (a symlinked history file becomes a regular file).
650
680
 
651
681
  Behavior details:
652
682
 
653
683
  - Prompt history is loaded on startup before the first `>` prompt.
684
+ - A running REPL or attached TUI picks up the lines other chi processes (the
685
+ web, another terminal) added: before each `>` prompt and on the first ↑ at
686
+ an open one (Ctrl-P, vi `k` too), when the file changed, its new lines join
687
+ the ↑ list after the ones already there, without a
688
+ restart. A `/command` you typed stays under ↑ too. `chi scratch` keeps its
689
+ own list and writes nothing.
690
+ - The web records a prompt once its session's worker took it (a refused or
691
+ timed-out send isn't saved), a `!command` likewise; an image-only message
692
+ isn't saved. `chi send`, `delegate` and plugin turns aren't typed at a
693
+ prompt and aren't saved. With `chi web --web-host lan` the list is served to
694
+ token holders (`GET /api/history`), as the sessions are.
654
695
  - Only real user prompts are persisted.
655
696
  - Continue-flow inputs (`yes`, `no`, `no, <reason>`, `/continue`) are not persisted as prompts.
656
697
  - In assist mode, pressing `Tab` on an `@`-prefixed token (for example `@lib/sama`) completes project file and directory paths while preserving the `@` prefix.
@@ -47,7 +47,10 @@ hosts:
47
47
  # (on a paid remote host that is one small request per idle window).
48
48
  # host_ref + model pin another model: host_ref asks that host's OpenAI API (its
49
49
  # url:, else http://host:port/v1) with its api_key_env; base_url is an OpenAI API
50
- # base as given (e.g. http://h:8081/v1). recap: false turns it off.
50
+ # base as given (e.g. http://h:8081/v1). model is a model ref like any other: an
51
+ # alias is applied, and its host:/the alias's host picks the host when host_ref
52
+ # is left out (a model naming another host than host_ref turns recap off with a
53
+ # warning). recap: false turns it off.
51
54
  recap:
52
55
  # host_ref: small-box
53
56
  # model: your-small-model-id
@@ -480,18 +483,31 @@ To explicitly route requests to a named model in llama.cpp, set:
480
483
 
481
484
  When `default.model` is unset or blank, Samagotchi fails fast with a clear startup/configuration error.
482
485
 
483
- With several `hosts:`, an unqualified model name goes to the host whose `/models`
484
- list has it (after `/models` ran), by exact id first, then by substring. A
485
- **remote** host (one with `api_key_env:` or an `https` url) is only chosen by exact
486
- id, `host:model` or an alias, never by a substring, and its model list is kept for
487
- 10 minutes (60s for local hosts). For a chat host the context window comes from
486
+ **The default host** is the host named `default`, else the first one under
487
+ `hosts:` (without `hosts:`, the `server:` section).
488
+
489
+ With several `hosts:`, `host:model` (or an alias naming a host) pins the host.
490
+ An unqualified model name goes to the default host when its `/models` list has
491
+ that exact id, else to the first host in `hosts:` order whose list has it, else
492
+ to the default host. Only exact ids count, never a substring: `/model gemma` on
493
+ a box that lists `gemma-3-12b` needs `box:gemma` or the exact id. The lists are
494
+ known only after `/models` ran (nothing is fetched before the first turn), so
495
+ until then an unqualified name goes to the default host; use `host:model` to pin
496
+ one. A **remote** host (one with `api_key_env:` or an `https` url) keeps its model
497
+ list for 10 minutes (60s for local hosts). For a chat host the context window comes from
488
498
  the running server (llama.cpp's `/props`), else the window the host's model list
489
499
  gives (`context_length`, `context_window`, `max_model_len` or llama.cpp's
490
500
  `meta.n_ctx`), else `context.window_tokens`.
491
501
 
492
- A `:` in a model name is often part of the id (`qwen3:8b`, `mistral:7b`,
502
+ Only `:` names a host: `/` is part of a model id (`openai/gpt-4o` is an
503
+ OpenRouter id and goes to the default host as written, even with a host named
504
+ `openai`). A `default.model`, an alias or a saved session that still says
505
+ `host/model` gets a warning at start; fix it with `host:model`. A `:` in a model
506
+ name is often part of the id (`qwen3:8b`, `mistral:7b`,
493
507
  `unsloth/Qwen3-8B-GGUF:Q4_K_M`), so the part before the first `:` picks a host
494
- only when it is a configured host's name. An unknown prefix is refused with an
508
+ only when it is a configured host's name. chi warns at start about a host named
509
+ like a model family (`qwen3`, `llama`, `gemma`, …): `qwen3:8b` would go to that
510
+ host as `8b`. An unknown prefix is refused with an
495
511
  error naming it and the configured hosts (with a "did you mean" for a near
496
512
  miss) when either
497
513
  - the rest is an `org/model` id (`nosuch:anthropic/claude-sonnet-4`), or
@@ -504,6 +520,23 @@ alias, `/model`, `chi send --new --model`, the web's new-session model and a
504
520
  delegate's model. Any other unknown prefix (`nosuch:x`) is sent to the default
505
521
  host as the model id.
506
522
 
523
+ ### Model aliases
524
+
525
+ `model_aliases:` maps a name to a model id or `host:model`. An alias works
526
+ wherever a model name does (`--model`, `default.model`, `/model`, the web's
527
+ new-session model, `chi send --new --model`, a delegate's or fork's model, the
528
+ `models:` keys) and the server is sent its target:
529
+
530
+ - Aliases apply once. An alias whose target is another alias sends that name as
531
+ written, and chi warns about it at start ("aliases don't chain").
532
+ - `host:alias` applies the alias on that host. When the alias's target names
533
+ another host, the model is refused (`alias 'tiny' names host 'box', not
534
+ 'openrouter'`).
535
+ - An alias named like a real model id shadows that id, also as `host:<id>`.
536
+ - A session stores the resolved ref (`box:your-small-model-id`) plus the name it
537
+ was typed as, so a resumed session keeps its model when an alias is
538
+ retargeted, and `models: {small: …}` still applies to it.
539
+
507
540
  ## Llama Network Retry Behavior
508
541
 
509
542
  Transient network failures are retried automatically with exponential backoff.
@@ -734,8 +767,8 @@ described in their own sections.
734
767
  | `server.read_timeout` | `600` | yes | Read timeout, seconds. |
735
768
  | `server.first_token_timeout` | 120 remote, off local | | Seconds to the first token; `0` = off. `hosts.<name>.first_token_timeout` wins. |
736
769
  | `recap.enabled` | on | | `false` (or `recap: false`) turns the idle recap off. |
737
- | `recap.model` | session's | yes | Model that writes the recap. |
738
- | `recap.host_ref` | session's | yes | A `hosts:` name to ask (`host:` is accepted too). |
770
+ | `recap.model` | session's | yes | Model that writes the recap: an id, alias or `host:model` (the host then picks where to ask). |
771
+ | `recap.host_ref` | session's | yes | A `hosts:` name to ask (`host:` is accepted too, also a host name, not a model ref). |
739
772
  | `recap.base_url` | none | yes | An OpenAI API base to ask instead (`http://h:8081/v1`). |
740
773
  | `recap.inactivity` | `180` | yes | Idle seconds before a recap. |
741
774
  | `recap.timeout` | `30` | yes | Seconds a recap request may take. |
@@ -789,7 +822,7 @@ described in their own sections.
789
822
  | `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
790
823
  | `web.view` | `stage` | yes | How `chi web` draws a turn: `stage` (the running turn pinned above the composer) or `turn` (one block per turn in the history). See [CLI](cli.md#web-views). |
791
824
  | `web.annotate_presets` | `Agreed\|Could you please elaborate?` | yes | Quick replies next to Annotate in `chi web`, `\|`-separated (a YAML list works too); `""` in the file or on the CLI leaves only Annotate (an empty env value means the default). See [CLI](cli.md#web-annotate-presets). |
792
- | `history.file` | state dir | | Prompt history path. |
825
+ | `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
793
826
  | `no_interrupt` | `false` | `--no-interrupt` | Raise the tool-call limit of a turn to 1000; a top-level key. |
794
827
  | `no_default_input` | `false` | `--no-default-input` | Don't pre-fill `default.input`; a top-level key. |
795
828
  | `skip_agent_md` | `false` | | Don't load AGENT.md; a top-level key (see below). |
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
 
data/docs/guardrails.md CHANGED
@@ -223,7 +223,8 @@ call's command, `cwd` or paths (never a write's content) that is within one
223
223
  edit of a known name shorter than 10 characters, or two edits of a longer
224
224
  one, is a near miss. Tokens and names shorter than `min_length` (6) are
225
225
  skipped, as is a token that equals another known name. A token with a shell
226
- glob (`*?[]{}`, as in `ls -d samagotchi*`) is not checked.
226
+ glob (`*?[]{}`, as in `ls -d samagotchi*`) is not checked. In `~name` (that
227
+ user's home folder) the name after `~` is what is checked.
227
228
 
228
229
  By default (`mode: reject`) the call is denied with advice in place of the
229
230
  usual tail, so the model retries it corrected:
data/docs/hooks.md CHANGED
@@ -55,7 +55,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
55
55
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
56
56
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
57
57
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
58
- | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." }` |
58
+ | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }` |
59
59
  | `:session_end` | After every turn (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
60
60
 
61
61
  Every event also carries the hook runtime (next section): `hook:` (the label
@@ -348,7 +348,8 @@ is sticky too), and the model gets `[<tool>] Error: blocked by guardrail: <reaso
348
348
  (default reason `blocked by hook`). A verdict's deny reads
349
349
  `[<tool>] Error: denied by guardrail (<rule or hook>): <reason>. … Do not retry it …`
350
350
  (after a user's Deny on an ask: `[<tool>] Error: The user declined this call… It needed approval (<rule or hook>): <reason>. …`).
351
- Either way the activity status is `blocked`, and `:after_tool_call` still fires.
351
+ Either way the activity status is `blocked`, and `:after_tool_call` still fires,
352
+ with `status: "blocked"`.
352
353
  Only `:before_tool_call` votes.
353
354
 
354
355
  **Mutating params (legacy):**
@@ -10,8 +10,10 @@ resumed session's line already names):
10
10
 
11
11
  `[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted and range reads over full-file dumps]`
12
12
 
13
- The native loop only (the chat loop has no context status). The event carries
14
- a `status` string using this prefix:
13
+ Both loops do this, before each request (`ContextStatus`, one per turn). The
14
+ chat loop's estimate counts the conversation's text and tool calls, not the
15
+ tool schemas it sends, until the host's first count (its `usage`). The event
16
+ carries a `status` string using this prefix:
15
17
 
16
18
  `CONTEXT_STATUS ...`
17
19
 
data/docs/sessions.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sessions
2
2
 
3
- Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, and `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise); files from before those keys read as empty lists and no parent.
3
+ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, and `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise); files from before those keys read as empty lists and no parent. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
4
4
 
5
5
  **Retention (file-based, opt-out via env):**
6
6
 
@@ -61,9 +61,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
61
61
  **Ordering:**
62
62
 
63
63
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
64
- - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
64
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
65
65
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
66
- - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
66
+ - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
67
67
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
68
68
 
69
69
  **Test-session hygiene:**
@@ -93,7 +93,7 @@ module Samagotchi
93
93
 
94
94
  # The entry's name: +requested+ as given (it must be free), else
95
95
  # +base+, with -2, -3… when the name is taken or is the prefix of a
96
- # model id (chi would read "openai/gpt-x" as host openai, model gpt-x).
96
+ # model id (chi would read "qwen3:8b" as host qwen3, model 8b).
97
97
  def host_name(base, requested: nil, model_ids: [])
98
98
  taken = current_hosts.keys
99
99
  if requested
@@ -104,7 +104,7 @@ module Samagotchi
104
104
  return name
105
105
  end
106
106
 
107
- prefixes = [*model_ids, configured_default_model].compact.map { |id| id.to_s.split(%r{[:/]}, 2).first.downcase }
107
+ prefixes = [*model_ids, configured_default_model].compact.map { |id| id.to_s.split(":", 2).first.downcase }
108
108
  name = base
109
109
  n = 1
110
110
  name = "#{base}-#{n += 1}" while taken.include?(name) || prefixes.include?(name)
@@ -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.
@@ -6,7 +6,8 @@
6
6
  # that once misspelled a name in a path keeps copying the wrong spelling;
7
7
  # a rejection that names the right one breaks the loop.
8
8
  #
9
- # A token with a shell glob (*?[]{}) is not checked.
9
+ # A token with a shell glob (*?[]{}) is not checked. In "~name" the name
10
+ # after "~" is checked.
10
11
  #
11
12
  # Settings (config.yml, `bundles: known-names:`), all optional:
12
13
  # names: [jonathandoe] names to protect besides the derived ones
@@ -21,8 +22,9 @@ class KnownNames
21
22
  DERIVATIONS = %w[home user git repo].freeze
22
23
  DEFAULT_MIN_LENGTH = 6
23
24
  # Splits a command or path into tokens; each token's [-_.] parts are
24
- # candidates too.
25
- TOKEN_SPLIT = %r{[/\s"'=:,;|&()<>@]+}.freeze
25
+ # candidates too. "~" splits: in "~name" (that user's home) the name
26
+ # after it is what is checked.
27
+ TOKEN_SPLIT = %r{[/\s"'=:,;|&()<>@~]+}.freeze
26
28
  PART_SPLIT = /[-_.]+/.freeze
27
29
  # A candidate with a shell glob character is not checked: `workato*`
28
30
  # is a pattern over the name, not a misspelling of it.