samagotchi 0.45.0 → 0.46.1

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +22 -7
  5. data/docs/configuration.md +105 -6
  6. data/docs/desktop.md +2 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-live.md +111 -0
  10. data/docs/sessions.md +2 -2
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
  12. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/config.rb +61 -4
  17. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  18. data/lib/samagotchi/engine.rb +41 -9
  19. data/lib/samagotchi/host_model.rb +123 -0
  20. data/lib/samagotchi/host_registry.rb +21 -10
  21. data/lib/samagotchi/kernel_loop.rb +2 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +109 -7
  23. data/lib/samagotchi/llm/errors.rb +8 -2
  24. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  25. data/lib/samagotchi/llm_context_strategy.rb +1 -0
  26. data/lib/samagotchi/model_catalog.rb +22 -16
  27. data/lib/samagotchi/model_price.rb +54 -0
  28. data/lib/samagotchi/model_profile.rb +7 -4
  29. data/lib/samagotchi/models_command.rb +12 -5
  30. data/lib/samagotchi/process_group.rb +56 -9
  31. data/lib/samagotchi/question_desk.rb +29 -4
  32. data/lib/samagotchi/recap_setup.rb +1 -1
  33. data/lib/samagotchi/self_report.rb +2 -1
  34. data/lib/samagotchi/served_model.rb +9 -0
  35. data/lib/samagotchi/session_commands.rb +27 -17
  36. data/lib/samagotchi/session_metrics.rb +40 -5
  37. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  38. data/lib/samagotchi/terminal_ui/formatting.rb +47 -15
  39. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  40. data/lib/samagotchi/terminal_ui.rb +15 -0
  41. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  42. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +43 -6
  45. data/lib/samagotchi/web/public/app.js +7 -3
  46. data/lib/samagotchi/web/public/ctx.js +20 -7
  47. data/lib/samagotchi/web/public/format.js +7 -2
  48. data/lib/samagotchi/web/public/index.html +3 -0
  49. data/lib/samagotchi/web/public/model_pick.js +36 -9
  50. data/lib/samagotchi/web/public/model_picker.js +11 -6
  51. data/lib/samagotchi/web/public/question_card.js +8 -0
  52. data/lib/samagotchi/web/public/stage_view.js +1 -0
  53. data/lib/samagotchi/web/public/turn_events.js +10 -5
  54. data/lib/samagotchi/web/public/turn_model.js +16 -0
  55. data/lib/samagotchi/web/public/turn_view.js +19 -0
  56. data/lib/samagotchi/worker.rb +20 -1
  57. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1dc1da4c0179829c1e78da27358f269fba9fcd1ee2a2412b73493bab37a1d81d
4
- data.tar.gz: b04878e89e0d634c817e748cc9fe435728e4c6c62863d719a53b4080b1ea5ff0
3
+ metadata.gz: 9143ff1fecadcc026c2e0b079d61551359985d72d080f33fc7074af8f90402c1
4
+ data.tar.gz: 04d7e899417b9684ac61d337361ca91e8fa58dcdd091611109fcfff677a351ba
5
5
  SHA512:
6
- metadata.gz: c0331854a38cff99772512941a1ce8227dc10448c00d4d4c758ba532cfdd19b311e7f4d9733bce7745645231794ec93212a658a542563a8f56008c97492f8c90
7
- data.tar.gz: 984e5ce3436b750c872bf0a7dd10f1282ed7f4eaf9c084d9f1add023bdcee5088c8f48acbd9eb3e3d01e2efa8dad200c3a350a4a903725aa1de6597407c9ee36
6
+ metadata.gz: 272e94da604a43b0d3b6f405dc7b2bbe420e4c7adffc9fba6b8c5ecf52811b4411c4fce03e32e85e00ee60b1f30b395daf107a459e842e196803d08658bcfdf0
7
+ data.tar.gz: b3262e257e88b57a739e61c3774d5c2bbef910956fcd9fc6ef726275ddc4bbc10a59ee70a1a9236a50aada19e9663338e32077720bfc1d6b0db6fcbf7e059a43
data/CHANGELOG.md CHANGED
@@ -8,6 +8,95 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.46.1] - 2026-10-09
12
+
13
+ ### Changed
14
+
15
+ - The terminal and the web word a retry line the same: `↻ retrying (503) in 4.0s, 2/5`, why in parens (the HTTP
16
+ status, the error's short name such as `ECONNREFUSED`, or `stream dropped`), the wait with one decimal, and the
17
+ retry of all there will be. The terminal said `retrying (2/5 in 4.0s)`, the web `↻ retrying (503) in 4 s, 2/5`.
18
+
19
+ ### Fixed
20
+
21
+ - A question the model asks in a `chi -p --non-interactive` run no longer tells the model the user dismissed it:
22
+ it is told no one could answer (a non-interactive run). The question closes with reason `non_interactive`.
23
+ - The web's card for a question closed that way reads "No one could answer (non-interactive run)", not
24
+ "Cancelled (non_interactive)".
25
+ - A delegate's report read by a turn that ended canceled is no longer brought a second time when the Engine's
26
+ post-turn work then fails (a full disk, say).
27
+ - github-pr 0.3.3: a `path:line` into a vendored copy (`vendor/lib/foo.rb:21`, or `/abs/repo/vendor/lib/foo.rb:21`)
28
+ is no longer linked to the PR's `lib/foo.rb` in the web: an absolute path's part before the PR path must be a
29
+ worktree's root (a directory with `.git`), or no directory on this machine. An absolute path into a worktree, or
30
+ one up from a subdir (`../lib/foo.rb`), still is.
31
+ - A step whose stream kept dropping now fails saying how many requests it made ("after 3 attempts"), not
32
+ "after 1 attempts"; one attempt is said in the singular.
33
+ - A step that ends on another error after its stream dropped (a 503) counts the dropped requests in that
34
+ error's attempts too, and a step asked again without thinking fields (a host refused them) keeps its count of
35
+ stream drops: it no longer gets two more ask-agains of its own.
36
+ - A host that is down is named once in the models list's warning and in `/models` (`gw: connection refused`, not
37
+ `gw: gw: connection refused`).
38
+ - A host whose model listing kept failing on network errors is named once too, in the models list's warning, the
39
+ web's and `/models` (`gw: request failed after 3 attempts: …`, not `gw: gw request failed after …`).
40
+ - `/stats`' estimated cost names the host whose prices it used (`from hosts.gw.models prices`), not a literal
41
+ `hosts.<name>.models`.
42
+ - The config warning for a model price's old `cached` key suggests `cache_read`, its new name.
43
+ - The web's ⚠ for a model the server served instead of the one asked now shows on a session opened with no
44
+ worker running too (from its saved analytics), not only while its worker runs.
45
+ - A host whose `/v1/models` fails no longer drops the ids declared under `hosts.<name>.models` from `chi models`,
46
+ `/models` and the web model picker: they stay, marked `host down` (greyed in the picker, still pickable;
47
+ `unavailable: true` in the JSON), next to the host's warning. Ids only the host itself lists still drop out.
48
+
49
+ Update with `chi update`: it updates github-pr (0.3.3: no links from a vendored copy to the PR's file).
50
+
51
+ ## [0.46.0] - 2026-10-09
52
+
53
+ ### Added
54
+
55
+ - `hosts.<name>.models:` declares the model ids a host serves whatever its `/v1/models` lists (a gateway's
56
+ round-robin ids), as a map of ids or a plain list. A declared `host:id` is never warned about or re-listed at
57
+ `chi send --new` and delegate spawns, and a bare id routes to its declaring host as if listed, before any
58
+ `/models` and in workers too. Two hosts declaring one id warn at start. See "Models a host serves but doesn't
59
+ list" in [docs/configuration.md](docs/configuration.md).
60
+ - Declared `hosts.<name>.models` ids show first under their host in `/models` (as `rr/x (config)`, outside the 20
61
+ per host, also on a host that lists nothing), `chi models` (plain names; `--format json` adds `configured: true`)
62
+ and `GET /api/models` (`configured: true`). A host that doesn't answer shows none of them.
63
+ - `hosts.<name>.models.<id>.price: {input, output, cache_read?, cache_write?}` (USD per 1M tokens) estimates the
64
+ cost of a generation whose provider reports none, or reports 0. A reported non-zero cost always wins. The estimate
65
+ is saved apart (`cost_estimate` per turn, `cost_estimate_sum` in the totals). See "Prices" in
66
+ [docs/configuration.md](docs/configuration.md).
67
+ - `hosts.<name>.models.<id>.served: [ids]` (or `any`) names the models a gateway may answer a round-robin id with.
68
+ Those are no longer marked as served by another model (the web's `⚠`, the status line's `(served; asked …)`,
69
+ `/model`'s `served:`, `/stats`' "(asked for …)"); one it doesn't name still is.
70
+ - An estimated cost shows with `~`: `/stats` (`$0.42 reported + ~$0.12 from hosts.<name>.models prices`) and the
71
+ ctx tooltip on the info bar and the session cards (`cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`).
72
+ - The web model picker lists a host's declared ids first under it, noted `config` (the default model keeps
73
+ `default`), with `served by config (hosts.<name>.models)` in the row's tooltip.
74
+
75
+ ### Fixed
76
+
77
+ - A chat generation whose stream dropped mid-answer (a read timeout or a reset after deltas had shown) failed the
78
+ whole turn: the chat loop now drops the partial reply and asks again for the same step, from the same
79
+ conversation, at most twice, reporting each ask-again as `generation_retrying`, as the transport's retries do.
80
+ A cancel, a provider error, a first-token timeout and a connection refused still fail as before, and a step whose
81
+ retries run out fails the turn with the same error. Each ask-again waits the transport's backoff (`retry.*`),
82
+ which a cancel cuts short, and `retry.max` caps the asks-again (`retry.max: 0` turns them off). Its
83
+ `generation_retrying` carries `restarted: true`: the web's live step, the TUI's activity line, a reload in the
84
+ middle of the turn and the stream hooks drop the partial reply, so the answer shows once.
85
+ - `task_stop` could signal an unrelated process group whose leader reused a finished task's pid. A task now records
86
+ its process's start time, and a pid that started at another time is not signalled (the task ends as failed).
87
+ - A delegate's report merged into a reminder or continue turn was lost when the turn failed past its loop (the
88
+ turn's own end, say): the turn kept only its start, yet the report counted as read. Its ring now stays, and the
89
+ next turn brings it.
90
+ - A delegate wait that relayed a child's approval ran to its timeout when the child ended its turn with no reply
91
+ while the answer was being posted; it now ends there.
92
+ - Chi Helper: a send that ended after the panel was reopened wrote its "sent"/"failed" line into the new panel, and
93
+ a success could close it; like a broadcast, it now leaves the new open alone.
94
+ - `chi -p … --non-interactive` hung forever when the model asked a question (`ask_user_question`) while its stdin
95
+ was a pipe that stayed open (`sleep 30 | chi -p …`, a parent holding stdin): it waited for an answer line. Nobody
96
+ can answer there, as for an approval: the question is dismissed at once (one line on stderr), the model finishes
97
+ its reply, and stdin is never read. Piped answers without `--non-interactive` (`printf '2\n' | chi --no-shared -p
98
+ …`) work as before.
99
+
11
100
  ## [0.45.0] - 2026-10-08
12
101
 
13
102
  ### Added
@@ -2094,7 +2183,9 @@ and long-lived sessions.
2094
2183
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2095
2184
  hotkey panel that send selected text or the clipboard to your sessions.
2096
2185
 
2097
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.45.0...HEAD
2186
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.46.1...HEAD
2187
+ [0.46.1]: https://github.com/dm1try/samagotchi/compare/v0.46.0...v0.46.1
2188
+ [0.46.0]: https://github.com/dm1try/samagotchi/compare/v0.45.0...v0.46.0
2098
2189
  [0.45.0]: https://github.com/dm1try/samagotchi/compare/v0.44.0...v0.45.0
2099
2190
  [0.44.0]: https://github.com/dm1try/samagotchi/compare/v0.43.0...v0.44.0
2100
2191
  [0.43.0]: https://github.com/dm1try/samagotchi/compare/v0.42.0...v0.43.0
data/docs/architecture.md CHANGED
@@ -287,7 +287,8 @@ The metrics in `/state`, `/snapshot`, `/stats` and the SSE `snapshot`/`reset` fr
287
287
  newest turn the worker finished and any it hasn't saved yet (at most 20 turns), with their tool
288
288
  calls and the running turn's finished ones. The full history is the session's `analytics.json`,
289
289
  which `SessionMetrics#persist` rewrites after each turn; the web server merges the two by id.
290
- The totals' `tokens` block adds the cached, cache-write and reasoning sums, the cost, the decode time and tokens
290
+ The totals' `tokens` block adds the cached, cache-write and reasoning sums, the reported cost (`cost_sum`) and the
291
+ estimated one (`cost_estimate_sum`, from `hosts.<name>.models` prices; 0 for none), the decode time and tokens
291
292
  behind the average speed (`avg_decode_tps`) and the last speeds (`last_decode_tps`,
292
293
  `last_prefill_tps`, `tps_source`: `server` or `estimate`). Each `generation_completed` event
293
294
  carries the generation's `speed` (`{decode_tps, source}`, null without one), its own prompt-cache
data/docs/cli.md CHANGED
@@ -85,14 +85,20 @@ small -> box:gemma-small # an alias and the ref it resolves to
85
85
  $ chi models qwen # only the names containing "qwen" (any case)
86
86
  ```
87
87
 
88
+ - A host's ids declared under `hosts.<name>.models` (see
89
+ [configuration](configuration.md#models-a-host-serves-but-doesnt-list)) come first under that host, listed or not,
90
+ as plain names like any other. They stay when the host fails, and its stderr warning says so
91
+ (`chi models: box: connection refused (its declared ids are still listed)`).
88
92
  - A default-host id is bare, except one with a `:` (`qwen3:8b`), which is written `<host>:qwen3:8b` so it can't
89
93
  read as a host name. An id that an alias of the same name hides is left out.
90
94
  - Each run lists anew (no cache); `--timeout S` caps the wait for the hosts (default 4 s). A host that fails or doesn't
91
95
  answer in time is noted on stderr (`chi models: box: no answer in 4 s`).
92
96
  - `--format json` prints one object: `default` (and `default_typed` when it was set as an alias), `default_host`,
93
- `models` (`name`, `host`, `id`), `aliases` (`name`, `ref`, `host`) and `warnings`. The desktop helper's model
97
+ `models` (`name`, `host`, `id`, and `configured: true` on an id declared under `hosts.<name>.models`,
98
+ `unavailable: true` too when that host failed to list its models), `aliases` (`name`, `ref`, `host`) and `warnings`. The desktop helper's model
94
99
  chooser reads it.
95
- - Exit 0 when any host listed its models, 1 when none did (the default is still printed), 2 on a usage error.
100
+ - Exit 0 when any host listed its models, 1 when none did (the default and declared names still print), 2 on a
101
+ usage error.
96
102
 
97
103
  ### Updating
98
104
 
@@ -245,6 +251,10 @@ Notes:
245
251
  then `chi: the session is kept with your prompt; continue it with: chi
246
252
  --resume ID` (exit 1). Ctrl-C saves the session with the prompt and a
247
253
  cancel note and exits 130.
254
+ Nobody can answer there: an approval is denied, and a question the model
255
+ asks (`ask_user_question`) is dismissed at once (one line on stderr), never
256
+ read from stdin. The model is told no one could answer (a non-interactive
257
+ run), not that you dismissed it, and finishes its reply.
248
258
 
249
259
  ### Scratch sessions
250
260
 
@@ -430,8 +440,10 @@ reports them),
430
440
  the decode speed, generation latency, cancellations, retries (the network's),
431
441
  thinking cuts (generations a plugin such as loop-guard cut) and output cap hits
432
442
  (generations that ended at the provider's cap), context used and window (the used figure is counted against the smaller of the window and the session's LLM context budget, and says so: `context used: 16000 tokens (25.0% of the 64000 budget)`), the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the model notes it carried (`model notes: model_notes_deepseek (system, 612 chars)`, none without any; [Memory](memory.md#model-notes)), the prompt profile and the model the server says it ran, and the
433
- cost when the provider reports one (OpenRouter's `usage.cost`; no prices are
434
- guessed). The speed line is the last generation's and the session's average
443
+ cost when the provider reports one (OpenRouter's `usage.cost`), and the cost
444
+ estimated from a price under `hosts.<name>.models` when it reports none or 0,
445
+ marked `~` and naming the current model's host (`cost: $0.42 reported + ~$0.12 from hosts.gw.models prices (this session only)`;
446
+ see "Prices" in [configuration](configuration.md#prices)). The speed line is the last generation's and the session's average
435
447
  (weighted by tokens): llama.cpp reports it exactly (`87 tok/s out, 1.9k tok/s
436
448
  prompt (last, server)`); for other servers it is estimated from the first
437
449
  streamed chunk to the end and marked `~` (`~64 tok/s out (last, estimate)`).
@@ -734,7 +746,7 @@ In interactive assist mode, you can switch the request model without restarting:
734
746
  - `/model`: show the effective model (and default when diverged: `runtime model: <effective> (default: <default>, profile=<name>, <source>)`, e.g. `profile=qwen36, server (chat_template)`). When the session's prompt carries model notes the line ends with `; notes: model_notes_deepseek (system, 612 chars)`; a switch's `runtime model set to …` names the new model's ([Memory](memory.md#model-notes)).
735
747
  - `/model clear` (or `default`/`none`/`off`): clear the session override, reverting to the configured default.
736
748
  - `/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)).
737
- - `/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.
749
+ - `/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). Ids the host declares under `hosts.<name>.models` come first, as `rr/x (config)`, and don't count toward the cap; an unreachable host keeps them, under its warning, as `rr/x (config, host down)`. 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.
738
750
 
739
751
  Notes:
740
752
 
@@ -837,7 +849,8 @@ status> model=Qwen3.6-35B | ⑂ 2 running · 1 waiting | ctx=8.1% (under20) | me
837
849
  ```
838
850
 
839
851
  - `model=`: the model in use, `(default: …)` beside it when it isn't the config's default, and
840
- `model=<served> (served; asked <name>)` when the server said it served another model.
852
+ `model=<served> (served; asked <name>)` when the server said it served another model (not for one its host's
853
+ `hosts.<name>.models.<id>.served` names; see [configuration](configuration.md#models-a-host-serves-but-doesnt-list)).
841
854
  - `↳ <id>`: the session that delegated this one.
842
855
  - `⑂ …`: this session's delegate children (forks aren't counted): how many run and how many wait
843
856
  for an answer; only the counts there are, nothing when none does (`/children` has the rest:
@@ -884,7 +897,9 @@ While a turn runs, one row above the prompt says what it is doing, the spinner f
884
897
  Qwen `TURN:` prefix and inline markdown are left out.
885
898
  - `| waiting for the first token… 5s` after 2 s with nothing streamed.
886
899
  - `| running execute…` while a tool runs (its `tool>` line prints when it ends).
887
- - `| retrying (1/3 in 0.5s): Errno::ECONNREFUSED` while a network error is retried.
900
+ - `| ↻ retrying (ECONNREFUSED) in 0.5s, 1/3` while a failed request is asked again: why (the HTTP status such as
901
+ `503`, the error such as `ECONNREFUSED`, or `stream dropped`), the wait, and the retry of all there will be.
902
+ The web's timing line words it the same.
888
903
  - `| mcp: starting servers…` while a plugin's slow setup (an init task) runs, between turns too;
889
904
  `mcp> ✓ …` prints when it is done.
890
905
 
@@ -413,6 +413,41 @@ models:
413
413
  `SAMAGOTCHI_HOSTS_JSON`, and reads `models:` from the config file each turn: after changing a host's `sampling:`,
414
414
  stop the session's worker (`chi sessions stop`) for it to take effect.
415
415
 
416
+ ## Prices
417
+
418
+ A provider that reports no cost (`usage.cost`), or reports 0, can still be priced: give the model a price under
419
+ the host that serves it, in USD per 1M tokens.
420
+
421
+ ```yaml
422
+ hosts:
423
+ work:
424
+ url: https://gateway.example/v1
425
+ api: openai
426
+ models:
427
+ rr/deepseek-v4.1:
428
+ price: {input: 0.27, cache_read: 0.07, output: 1.10}
429
+ openrouter:
430
+ url: https://openrouter.ai/api/v1
431
+ api: openai
432
+ models:
433
+ qwen/qwen3.8-27b: {price: {input: 0.10, output: 0.40}} # a listed id, here only for its price
434
+ ```
435
+
436
+ - `input` and `output` are required; `cache_read` and `cache_write` are optional. Each is a number >= 0; a price
437
+ missing one of the required two, or with a value that isn't a number >= 0, warns at start and is ignored.
438
+ - An unset rate is the input rate: that overstates cached reads, and understates Anthropic cache writes (billed
439
+ 1.25x input for the 5 min cache, 2x for 1 h). Set them when it matters.
440
+ - A price is per host, since one model can cost differently on two hosts. It is looked up by the model the session
441
+ asked for (its host and id), never by the model the host says it served.
442
+ - Each generation with the server's token counts is estimated as uncached prompt × `input` + cached reads ×
443
+ `cache_read` + cache writes × `cache_write` + completion × `output`, over 1M. A generation with only estimated
444
+ counts (characters / 4, no usage from the server) isn't priced.
445
+ - A reported cost wins: a generation with a non-zero `usage.cost` is never estimated. A reported 0 with a price is
446
+ estimated (a gateway that bills elsewhere reports 0); the 0 is kept as the reported cost.
447
+ - The estimate is saved apart from the reported cost (`cost_estimate` per turn, `cost_estimate_sum` in the
448
+ session's totals in `analytics.json`) and shows with a `~`.
449
+ - A server that sends no cached counts has its whole prompt priced at `input`: an overestimate.
450
+
416
451
  ## Thinking
417
452
 
418
453
  How much a model thinks before it answers. One level, `off`, `low`, `medium`, `high` or `default`, set per model,
@@ -555,11 +590,12 @@ When `default.model` is unset or blank, Samagotchi fails fast with a clear start
555
590
  With several `hosts:`, `host:model` (or an alias naming a host) pins the host.
556
591
  An unqualified model name goes to the default host when its `/models` list has
557
592
  that exact id, else to the first host in `hosts:` order whose list has it, else
558
- to the default host. Only exact ids count, never a substring: `/model gemma` on
593
+ to the default host. A host's declared ids (`hosts.<name>.models`, below) count
594
+ as listed. Only exact ids count, never a substring: `/model gemma` on
559
595
  a box that lists `gemma-4-26b` needs `box:gemma` or the exact id. The lists are
560
596
  known only after `/models` ran (nothing is fetched before the first turn), so
561
- until then an unqualified name goes to the default host; use `host:model` to pin
562
- one. A **remote** host (an `https` url or a public address; see "Remote or local") keeps its model
597
+ until then an unqualified name goes to the default host, unless a host declares
598
+ it; use `host:model` to pin one. A **remote** host (an `https` url or a public address; see "Remote or local") keeps its model
563
599
  list for 10 minutes (60s for local hosts).
564
600
 
565
601
  **The context window** (what the context status and `ctx=` count against) comes from,
@@ -608,6 +644,59 @@ alias, `/model`, `chi send --new --model`, the web's new-session model and a
608
644
  delegate's model. Any other unknown prefix (`nosuch:x`) is sent to the default
609
645
  host as the model id.
610
646
 
647
+ ### Models a host serves but doesn't list
648
+
649
+ A gateway can serve ids its `/v1/models` never lists (round-robin aliases such
650
+ as `rr/deepseek-v4.1`). Declare them under the host:
651
+
652
+ ```yaml
653
+ hosts:
654
+ work:
655
+ url: https://gateway.example/v1
656
+ api: openai
657
+ api_key_env: WORK_API_KEY
658
+ models:
659
+ rr/deepseek-v4.1:
660
+ rr/qwen3.8-27b:
661
+ ```
662
+
663
+ A plain list says the same: `models: [rr/deepseek-v4.1, rr/qwen3.8-27b]`. Ids are
664
+ full ids (no globs), matched case-insensitively. A declared id is known on its
665
+ host whatever the host lists:
666
+
667
+ - `work:rr/deepseek-v4.1` never gets the "host 'work' doesn't list model" warning,
668
+ and the host isn't re-listed for it (`chi send --new --model`, delegates).
669
+ - A bare `rr/deepseek-v4.1` goes to `work` as if `work` listed it, also before
670
+ any `/models` and in a session's worker. When several hosts list or declare an
671
+ id, the default host wins if it is one of them, else the first in `hosts:`
672
+ order; two hosts declaring one id get a warning at start saying which one a
673
+ bare id goes to. `host:id` still pins a host.
674
+
675
+ **A gateway that answers with another model.** A round-robin id is served by one
676
+ of its targets, and the reply names that one (`fireworks/deepseek-v4.1`), so chi
677
+ marks every turn as served by another model than asked (the web's `⚠`, the status
678
+ line's `(served; asked …)`, `/model`'s `served:`). `served:` lists the models the
679
+ host may answer with, as exact ids (any case), or `any`:
680
+
681
+ ```yaml
682
+ hosts:
683
+ work:
684
+ models:
685
+ rr/deepseek-v4.1:
686
+ served: [fireworks/deepseek-v4.1, baseten/deepseek-v4.1]
687
+ rr/qwen3.8-27b:
688
+ served: any
689
+ ```
690
+
691
+ A served model it names is no mismatch: the web and the status line keep the name
692
+ you asked for, and `/stats` names the served model without "(asked for …)". One it
693
+ doesn't name is still marked. `any` gives that check up for the id.
694
+
695
+ **Root `models:` vs `hosts.<name>.models:`.** The root `models:` holds settings
696
+ by model name (sampling, thinking, window, profile…), on whatever host the model
697
+ runs on; it says nothing about which host serves a model. `hosts.<name>.models:`
698
+ says which models that host serves. Keep settings in the root one.
699
+
611
700
  ### Model aliases
612
701
 
613
702
  `model_aliases:` maps a name to a model id or `host:model`. An alias works
@@ -641,12 +730,17 @@ Transient network failures are retried automatically with exponential backoff.
641
730
  budget, so it fails at once (lower `default.max_tokens` or raise the key's limit).
642
731
  - A refused connection (nothing listening) is not retried: the turn fails at once with `can't reach host <name> at
643
732
  <address> (connection refused) — is the server running?`.
644
- - A stream that has already produced output is never retried (the retry would repeat it); it fails the turn.
733
+ - A stream that has already produced output is never retried at the transport (the retry would repeat it). The
734
+ chat loop (an OpenAI-compatible host) drops the partial reply and asks again for the whole step, from the same
735
+ conversation, at most twice (at most `retry.max` times when that is smaller: `retry.max: 0` turns it off), after
736
+ the same backoff, reported as a retry (`↻ retrying (stream dropped) …`); once those run out the turn fails with
737
+ the error, as before. A refusal, a first-token timeout, a provider error and a cancel are never retried this way.
645
738
  - Cancellation (`Ctrl-C`) is never retried.
646
739
 
647
740
  Configuration:
648
741
 
649
- - `retry.max` (default `5`, env `SAMAGOTCHI_RETRY_MAX`): number of retries after the first failed attempt.
742
+ - `retry.max` (default `5`, env `SAMAGOTCHI_RETRY_MAX`): number of retries after the first failed attempt; it also
743
+ caps a dropped stream's asks-again (at most 2), and `0` turns those off too.
650
744
  - `retry.base_delay` (default `0.5`, env `SAMAGOTCHI_RETRY_BASE_DELAY`): backoff base delay in seconds.
651
745
  - `retry.max_delay` (default `8.0`, env `SAMAGOTCHI_RETRY_MAX_DELAY`): cap for backoff delay in seconds.
652
746
 
@@ -680,7 +774,9 @@ and a plugin's steer (`ctx.steer`, `ctx.sessions.send`) never cuts.
680
774
 
681
775
  Assist-mode UX:
682
776
 
683
- - While waiting, retry notices are rendered in the existing thinking spinner area as a red `network error: retrying ...` status.
777
+ - While waiting, a retry shows in the activity row (the web's timing line words it the same):
778
+ `↻ retrying (503) in 4.0s, 2/5`: why (the HTTP status, the error's short name such as `ECONNREFUSED`, or
779
+ `stream dropped`), the wait, and the retry of all there will be.
684
780
  - If retry attempts are exhausted, the submitted prompt is restored into the input editor so you can edit and resubmit.
685
781
 
686
782
  ## Server errors
@@ -1036,6 +1132,9 @@ described in their own sections.
1036
1132
  | `thinking.turn_preamble` | `true` | yes | Ask a `qwen36` model to open its thinking with a short `TURN:` line (the step label). |
1037
1133
  | `thinking.level` | `default` | `--thinking` | `off`, `low`, `medium`, `high` or `default` for every model; the flag and env outrank the `models:`/`hosts:` entries, the file's value doesn't. See "Thinking". |
1038
1134
  | `models.<key>.thinking`, `hosts.<name>.thinking` | none | | A model's or host's level. See "Thinking". |
1135
+ | `hosts.<name>.models` | none | | The model ids a host serves whatever its `/v1/models` lists: a map of ids, or a plain list. See "Models a host serves but doesn't list". |
1136
+ | `hosts.<name>.models.<id>.served` | none | | The models the host may answer that id with (exact ids, any case), or `any`: no served-model warning for those. See "Models a host serves but doesn't list". |
1137
+ | `hosts.<name>.models.<id>.price` | none | | The model's price on that host, USD per 1M tokens: `input`, `output`, optional `cache_read` and `cache_write`. Estimates the cost a provider doesn't report. See "Prices". |
1039
1138
  | `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
1040
1139
  | `max_tool_output_chars` | `10000` | yes | Characters of each tool output kept in the conversation (both loops); a longer one is cut and ends with `[cut: N of M chars; read it in parts]`. A top-level key (see below). |
1041
1140
  | `cache.warmup` | `auto` | | `auto`: after a turn, send the next turn's prompt (up to the next message) to a local llama.cpp host on the native loop, so the next turn prefills only its message; never a remote or `api: openai` host. `off`: never. See [prompt caching](internals/prompt-caching.md#the-turn-end-warm-up). |
data/docs/desktop.md CHANGED
@@ -62,7 +62,8 @@ for an alias), your recent picks and every host's
62
62
  models and aliases (from [`chi models`](cli.md#listing-the-models)), at most nine at a time.
63
63
 
64
64
  - Type to search: every word must match the host or the id; names starting with what you typed rank first. A name
65
- no host lists is offered at the bottom as "not listed" and sent as typed (a host that is down, a model just added).
65
+ no host lists is offered at the bottom as "not listed" and sent as typed (a model just added, or one a down host
66
+ doesn't declare: a down host's `hosts.<name>.models` ids stay in the list).
66
67
  - ↑/↓ and ⏎, a click, or ⌘1…⌘9 pick; Esc (or ⌘M) closes the chooser only, a second Esc the panel. While an input
67
68
  method is composing, ⏎, the arrows and Esc are its own.
68
69
  - The row then shows the pick in the accent colour, and ⏎ sends `chi send --new --model <name>`, the name exactly as
@@ -19,6 +19,7 @@ Behavior:
19
19
 
20
20
  - `execute`, `task_create` and the `!cmd` shell bang run the command in a non-login shell: `/bin/sh -c` on Linux; on macOS, when `/bin/zsh` is there, `/bin/zsh --emulate sh +o ignore_braces +o sh_glob -o bash_rematch +o bsd_echo -c` (macOS's `/bin/sh` is bash 3.2, which can't parse an apostrophe in a heredoc inside `"$( )"`, the usual commit message shape). That mode reads no rc files and keeps POSIX word splitting, unmatched globs left as is, `{1..3}`, `[[ … =~ (…) ]]` with `BASH_REMATCH`, arrays, `$'…'`, `&>` and echo expanding `\t`. Bash-only builtins and variables (`shopt`, `BASH_VERSION`, `${!prefix*}`, `read -p`) are not there.
21
21
  - Task metadata and output are persisted under `tmp/tasks/`.
22
+ - A task record keeps its process's pid and start time (`pid_started`). Before `task_stop` signals, the pid must still lead its own process group and must not have started at another time; otherwise the pid was reused by another process, nothing is signalled and the task ends as failed. A start time that can't be read at that moment (a failed `ps`) falls back to the group check, as does a record without `pid_started` (from an older chi). `task_get`, `task_list` and `task_wait`'s polls check the group only (no `ps` per poll).
22
23
  - Task listing is workspace-scoped (current project only).
23
24
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
24
25
  - `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
@@ -293,7 +293,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
293
293
  | `:context_status` | the window's fill crossed a bucket | `{iteration, ...}` |
294
294
  | `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source}` (native adds `profile`, `profile_source`) |
295
295
  | `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` |
296
- | `:generation_retrying` | the transport retries the request | `{iteration, ...}` (the retry's fields) |
296
+ | `:generation_retrying` | the transport retries the request, or the chat loop asks again for a step whose stream dropped mid-answer (`restarted: true`: what the step streamed is void, and the web's live step, the TUI's activity line, the Bridge's TurnAccumulator and the stream hooks' watch start it over) | `{iteration, attempt, max_retries, next_delay, error_class, error_message, status, restarted?}` |
297
297
  | `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens` |
298
298
  | `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
299
299
  | `:empty_answer_retry` | an empty, cut or malformed answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
@@ -0,0 +1,111 @@
1
+ # The llm_context live runs
2
+
3
+ `script/llm_context_live.rb` runs chi itself on real coding tasks under each LLM context strategy and scores the
4
+ sessions. The replay benchmark ([llm-context-bench.md](llm-context-bench.md)) scores stored sessions offline. These
5
+ runs close the loop: the model lives with the strategy for a whole task, so they measure what the replay can't, such
6
+ as whether a forgotten output comes back as a re-read and whether a note narrows the scope (context strategies P6).
7
+ It is a development tool under `script/` and isn't shipped with the gem. It calls a paid model, so it is opt-in: the
8
+ specs drive it with fakes on synthetic data.
9
+
10
+ ```sh
11
+ ruby script/llm_context_live.rb validate TASKS ROOT # no model
12
+ ruby script/llm_context_live.rb run TASKS ROOT --model openrouter:deepseek/deepseek-v4.1-flash \
13
+ [--tasks T1,T3] [--arms none,stale,stale_forget] [--samples 3] [--budget 64000|off] [--cap 8] [--jobs 3] [--limit 9]
14
+ ruby script/llm_context_live.rb report ROOT [--json]
15
+ ruby script/llm_context_live.rb grade|collect TASKS ROOT RUN... # again, for runs already done
16
+ ```
17
+
18
+ `TASKS` (a YAML file) and `ROOT` (where the runs and results go) live outside the repo. They hold task prompts and
19
+ session files, and no session text goes into the repo, scrubbed or not.
20
+
21
+ ## Tasks
22
+
23
+ A task is a real merged fix, replayed from its parent commit. The fix's spec changes are held back and graded
24
+ afterwards.
25
+
26
+ ```yaml
27
+ source_repo: /path/to/samagotchi
28
+ tasks:
29
+ T1:
30
+ fix: 545a3562 # the run's repo is its parent
31
+ size: S
32
+ turns: # one chi turn each; {repo} is the run's repo path
33
+ - "In {repo} … find the cause and plan the fix. Don't edit yet."
34
+ - "Implement the plan and run the specs."
35
+ hidden: [spec/broadcast/triage_spec.rb] # optional: the fix's *_spec.rb
36
+ regression: [spec/broadcast/triage_spec.rb] # optional: the hidden ones' paths
37
+ fix_files: [lib/samagotchi/broadcast/triage.rb, spec/broadcast/triage_spec.rb] # optional: what the fix changed
38
+ ```
39
+
40
+ A file at `hidden/<task id>/<path>` beside the tasks file stands in for that hidden spec. Use it when the fix's spec
41
+ pins a detail that another correct fix would get differently, such as an exact page size; the override keeps only the
42
+ contract.
43
+
44
+ `validate` checks each task's hidden specs: they must fail at the fix's parent and pass at the fix.
45
+
46
+ ## A run
47
+
48
+ Each task × arm × sample is a run in `ROOT/runs/<task>-<arm>-s<n>/`:
49
+
50
+ 1. **prepare:** `git archive <fix>^` into `repo/`, then a fresh git repo with one base commit. It writes chi's config
51
+ in `config/` (the model's host copied from `--hosts-config`, default the user's config.yml; `turn.max_iterations:
52
+ 150`; `recap: false`; `llm_context.apply: payoff`, no `stale_edits`). State goes in `state/`, and it runs `chi
53
+ bundle install loop-guard`. Every chi and rspec command gets `SAMAGOTCHI_ENV=test`, the run's `XDG_STATE_HOME`
54
+ and `XDG_CONFIG_HOME`, and no inherited `SAMAGOTCHI_HOSTS_JSON` (the smoke-run isolation).
55
+ 2. **run (Driver):** `chi send --new --wait --format json --dir repo --model M --llm-context <arm>
56
+ --llm-context-budget <N|off>` with the first turn, then each later turn into the same session. The arms are
57
+ `none`, `stale` and `stale_forget` (`stale,forget`). The tasks use two turns, because forget is offered at turn
58
+ ends. The driver answers questions as a parent agent would:
59
+ - the model's first question gets "use your judgement" (option 1 when it takes no text), and later ones are
60
+ dismissed;
61
+ - an approval is denied;
62
+ - the step-limit question gets Stop: there is no Continue, since the limit is part of the task.
63
+
64
+ A turn waits at most 45 minutes. Afterwards the session is stopped with `chi sessions stop`, then `pkill -f
65
+ <the run's state dir>`. Nothing is ever killed by a pid read from a file.
66
+ 3. **collect:** the numbers below, read before grading, and `diff.patch` (the work against the base commit).
67
+ 4. **grade (Grader):** the hidden specs are copied in as `<name>_p6hidden_spec.rb` and run in one `bundle exec rspec`
68
+ with the regression specs (the model's own copies), then removed. Pass means rspec exits 0 with at least one
69
+ example. The working tree is graded: nothing needs committing.
70
+
71
+ The result goes to `ROOT/results/<run>.json`. A rerun skips runs that have a result, and starts over any run that
72
+ has none.
73
+
74
+ ## Cost
75
+
76
+ Each run's cost (OpenRouter's reported `cost`, summed over the turn records) goes to `ROOT/ledger.jsonl`. No run
77
+ starts once the ledger reaches `--cap` (default $8). With `--jobs N` the runs in flight can pass the cap by what they
78
+ spend. A payment error (chi send's `error_kind` `credits` or `credits_held`, or a 402 / over_budget in the detail)
79
+ ends that run at once and writes `ROOT/STOP`, and no further run starts until someone removes it. The account's own
80
+ key limit stays the hard stop.
81
+
82
+ ## What it scores
83
+
84
+ Per run (`Collector`), from the session file, `analytics.json` and the repo:
85
+
86
+ - **success:** the grade, plus a manual 0/1/2 in `ROOT/grades.yml` (`T1-none-s1: {grade: 2, note: …}`);
87
+ - **steps, tool calls, edits, commits:** `ModelNotesReport::SessionReader`;
88
+ - **reads, re-reads, re-reads after a stub** (by kind): `LLMContextBench::Replay#read_counts`. A re-read reads a
89
+ path the session read before. It counts as after a stub when an earlier read of that path had an applied stub
90
+ whose cause came before it: a forget's cause is its `forget_outputs` call, a stale stub's the read that superseded
91
+ it;
92
+ - **stale stubs, forget calls, forgotten ids and their tokens:** `Replay#stubs` (chars/4);
93
+ - **tokens:** prompt tokens sent (summed over requests), cached, re-prefilled and completion tokens, and the cost,
94
+ from the turn records;
95
+ - **peak context:** the largest prompt of any turn (`prompt_tokens_max` on the turn record);
96
+ - **scope:** distinct files read, files read and files changed outside the fix's set, and diff lines;
97
+ - **wall time.**
98
+
99
+ `report` gives each task × arm its pass count, manual grades, step-limit stops, and every metric's median with its
100
+ min–max spread. It also gives the gate: `stale_forget` against `none`. Per task, a metric is better when
101
+ `stale_forget`'s median is below `none`'s whole spread, worse when above it, and the same otherwise. The gate passes
102
+ when, on both re-reads and scope (files read + files changed outside), the better tasks outnumber the worse, and
103
+ `stale_forget` passed at least as many runs as `none`.
104
+
105
+ ## Limits
106
+
107
+ - Three samples a cell is little. Read nothing into a difference inside one arm's spread.
108
+ - Tokens from `Replay` are chars/4, and those from the turn records are the server's.
109
+ - A Splash host may not report cached tokens or cost (they read 0).
110
+ - The fix's spec file is copied in whole, so its old examples run twice: once hidden and once as the model left
111
+ them.
data/docs/sessions.md CHANGED
@@ -65,7 +65,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
65
65
  **Ordering:**
66
66
 
67
67
  - `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.
68
- - 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 (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). 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. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only, and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). 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, sampling?, llm_context?}], warning?}`; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; 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). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). 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 line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ 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).
68
+ - 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 (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). 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. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). 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 (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); 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, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; 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). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). 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 line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ 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).
69
69
  - 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).
70
70
  - 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; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `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`).
71
71
  - 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).
@@ -175,7 +175,7 @@ chi send --wait -m "and the tests?" 3fa2 # a follow-up in the same
175
175
  chi send --wait 3fa2 # sends nothing: waits for its next reply
176
176
  ```
177
177
 
178
- - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one). A host config.yml doesn't have is refused before anything is spawned (exit 1). A `host:id` whose id that host's last saved list doesn't have (`~/.local/state/samagotchi/model_lists.json`, what `chi models` / `/models` / the web last listed for that host, at most a week old) gets one warning line on stderr, with up to three close ids as a hint (`did you mean: gemma-small?`), and the session starts anyway: some hosts serve ids they don't list (a one-model llama.cpp server takes any name). A saved list older than 10 minutes that misses the id is re-listed once (that one host, capped at 5 s) before the warning: the list can be stale by content, e.g. a one-model server reloaded with another model; a list re-listed that way names the typed id, nothing is said. A list younger than 10 minutes is taken at its word (a host that serves ids it never lists would otherwise pay the re-list on every launch). A re-list that fails, times out or lists nothing falls back to the saved list. A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists it — and with no saved list for the host nothing is said; a wrong id then fails the worker's first turn.
178
+ - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one). A host config.yml doesn't have is refused before anything is spawned (exit 1). A `host:id` whose id that host's last saved list doesn't have (`~/.local/state/samagotchi/model_lists.json`, what `chi models` / `/models` / the web last listed for that host, at most a week old) gets one warning line on stderr, with up to three close ids as a hint (`did you mean: gemma-small?`), and the session starts anyway: some hosts serve ids they don't list (a one-model llama.cpp server takes any name). A saved list older than 10 minutes that misses the id is re-listed once (that one host, capped at 5 s) before the warning: the list can be stale by content, e.g. a one-model server reloaded with another model; a list re-listed that way names the typed id, nothing is said. A list younger than 10 minutes is taken at its word (a host that serves ids it never lists would otherwise pay the re-list on every launch). A re-list that fails, times out or lists nothing falls back to the saved list. An id its host declares under `hosts.<name>.models` (see [configuration](configuration.md#models-a-host-serves-but-doesnt-list)) is never warned about or re-listed. A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists or declares it — and with no saved list for the host nothing is said; a wrong id then fails the worker's first turn.
179
179
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
180
180
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
181
181
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
@@ -264,6 +264,11 @@ module Samagotchi
264
264
  else
265
265
  append_text(event[:iteration], "text", event[:content])
266
266
  end
267
+ when :generation_retrying
268
+ # A dropped stream's step asked again: what it streamed is void.
269
+ if event[:restarted]
270
+ parts.reject! { |part| %w[thinking text].include?(part[:kind]) && part[:iteration] == event[:iteration] }
271
+ end
267
272
  when :tool_call_started
268
273
  part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
269
274
  tool: event[:tool], params: event[:params], status: "running" }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.3.1
3
+ version: 0.3.3
4
4
  scope: system
5
5
  description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can offer the PR with a card first, or attach nothing
6
6
  trust_level: reviewed
@@ -11,7 +11,7 @@ needs:
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:eb6207a0aec4267eb15d28e4db2edb10d458c3ff574c2be56f225b24b7afb733
14
+ sha256: sha256:f68b53bfb552e35c9b151ee42cc10c07923481fc3d5496f023422e388df6719a
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers: