samagotchi 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +14 -5
  5. data/docs/configuration.md +102 -5
  6. data/docs/internals/background-tasks.md +1 -0
  7. data/docs/internals/engine.md +1 -1
  8. data/docs/internals/llm-context-live.md +111 -0
  9. data/docs/sessions.md +2 -2
  10. data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
  11. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  13. data/lib/samagotchi/config.rb +57 -4
  14. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  15. data/lib/samagotchi/engine.rb +39 -6
  16. data/lib/samagotchi/host_model.rb +116 -0
  17. data/lib/samagotchi/host_registry.rb +15 -9
  18. data/lib/samagotchi/kernel_loop.rb +2 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +89 -7
  20. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  21. data/lib/samagotchi/model_catalog.rb +17 -12
  22. data/lib/samagotchi/model_price.rb +54 -0
  23. data/lib/samagotchi/model_profile.rb +3 -1
  24. data/lib/samagotchi/process_group.rb +56 -9
  25. data/lib/samagotchi/recap_setup.rb +1 -1
  26. data/lib/samagotchi/self_report.rb +2 -1
  27. data/lib/samagotchi/session_commands.rb +23 -14
  28. data/lib/samagotchi/session_metrics.rb +40 -5
  29. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  30. data/lib/samagotchi/terminal_ui/formatting.rb +26 -9
  31. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  32. data/lib/samagotchi/terminal_ui.rb +13 -0
  33. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  34. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +5 -2
  37. data/lib/samagotchi/web/public/app.js +7 -3
  38. data/lib/samagotchi/web/public/ctx.js +20 -7
  39. data/lib/samagotchi/web/public/format.js +7 -2
  40. data/lib/samagotchi/web/public/model_pick.js +23 -3
  41. data/lib/samagotchi/web/public/model_picker.js +5 -3
  42. data/lib/samagotchi/web/public/stage_view.js +1 -0
  43. data/lib/samagotchi/web/public/turn_events.js +3 -2
  44. data/lib/samagotchi/web/public/turn_model.js +16 -0
  45. data/lib/samagotchi/web/public/turn_view.js +19 -0
  46. data/lib/samagotchi/worker.rb +16 -1
  47. 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: 0fb03f917133e78236dfb0bf16eae6c21b00ec2fb3720501d6c1982aeb9dc220
4
+ data.tar.gz: b3edff20b87e1f954c6079cd0315ab64559a5814f9a64e92e215fedc745a2778
5
5
  SHA512:
6
- metadata.gz: c0331854a38cff99772512941a1ce8227dc10448c00d4d4c758ba532cfdd19b311e7f4d9733bce7745645231794ec93212a658a542563a8f56008c97492f8c90
7
- data.tar.gz: 984e5ce3436b750c872bf0a7dd10f1282ed7f4eaf9c084d9f1add023bdcee5088c8f48acbd9eb3e3d01e2efa8dad200c3a350a4a903725aa1de6597407c9ee36
6
+ metadata.gz: 96b8d90741656cae236454b5f1b56f49e77ad1a511eb81cdc31594b311031b57363993091ae0df70cf78c6f346a2b6b867896b53e0093ae2e59671146cd12eb6
7
+ data.tar.gz: 6041ee413db16da9147f763f83cfabcf91e0c62ea95380fa8ae454aab9e242b646501fafdd2ed5071008b4b218ad740c585517ee1e3cd0fb84531cb36a43d59d
data/CHANGELOG.md CHANGED
@@ -8,6 +8,55 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.46.0] - 2026-10-09
12
+
13
+ ### Added
14
+
15
+ - `hosts.<name>.models:` declares the model ids a host serves whatever its `/v1/models` lists (a gateway's
16
+ round-robin ids), as a map of ids or a plain list. A declared `host:id` is never warned about or re-listed at
17
+ `chi send --new` and delegate spawns, and a bare id routes to its declaring host as if listed, before any
18
+ `/models` and in workers too. Two hosts declaring one id warn at start. See "Models a host serves but doesn't
19
+ list" in [docs/configuration.md](docs/configuration.md).
20
+ - Declared `hosts.<name>.models` ids show first under their host in `/models` (as `rr/x (config)`, outside the 20
21
+ per host, also on a host that lists nothing), `chi models` (plain names; `--format json` adds `configured: true`)
22
+ and `GET /api/models` (`configured: true`). A host that doesn't answer shows none of them.
23
+ - `hosts.<name>.models.<id>.price: {input, output, cache_read?, cache_write?}` (USD per 1M tokens) estimates the
24
+ cost of a generation whose provider reports none, or reports 0. A reported non-zero cost always wins. The estimate
25
+ is saved apart (`cost_estimate` per turn, `cost_estimate_sum` in the totals). See "Prices" in
26
+ [docs/configuration.md](docs/configuration.md).
27
+ - `hosts.<name>.models.<id>.served: [ids]` (or `any`) names the models a gateway may answer a round-robin id with.
28
+ Those are no longer marked as served by another model (the web's `⚠`, the status line's `(served; asked …)`,
29
+ `/model`'s `served:`, `/stats`' "(asked for …)"); one it doesn't name still is.
30
+ - An estimated cost shows with `~`: `/stats` (`$0.42 reported + ~$0.12 from hosts.<name>.models prices`) and the
31
+ ctx tooltip on the info bar and the session cards (`cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`).
32
+ - The web model picker lists a host's declared ids first under it, noted `config` (the default model keeps
33
+ `default`), with `served by config (hosts.<name>.models)` in the row's tooltip.
34
+
35
+ ### Fixed
36
+
37
+ - A chat generation whose stream dropped mid-answer (a read timeout or a reset after deltas had shown) failed the
38
+ whole turn: the chat loop now drops the partial reply and asks again for the same step, from the same
39
+ conversation, at most twice, reporting each ask-again as `generation_retrying`, as the transport's retries do.
40
+ A cancel, a provider error, a first-token timeout and a connection refused still fail as before, and a step whose
41
+ retries run out fails the turn with the same error. Each ask-again waits the transport's backoff (`retry.*`),
42
+ which a cancel cuts short, and `retry.max` caps the asks-again (`retry.max: 0` turns them off). Its
43
+ `generation_retrying` carries `restarted: true`: the web's live step, the TUI's activity line, a reload in the
44
+ middle of the turn and the stream hooks drop the partial reply, so the answer shows once.
45
+ - `task_stop` could signal an unrelated process group whose leader reused a finished task's pid. A task now records
46
+ its process's start time, and a pid that started at another time is not signalled (the task ends as failed).
47
+ - A delegate's report merged into a reminder or continue turn was lost when the turn failed past its loop (the
48
+ turn's own end, say): the turn kept only its start, yet the report counted as read. Its ring now stays, and the
49
+ next turn brings it.
50
+ - A delegate wait that relayed a child's approval ran to its timeout when the child ended its turn with no reply
51
+ while the answer was being posted; it now ends there.
52
+ - Chi Helper: a send that ended after the panel was reopened wrote its "sent"/"failed" line into the new panel, and
53
+ a success could close it; like a broadcast, it now leaves the new open alone.
54
+ - `chi -p … --non-interactive` hung forever when the model asked a question (`ask_user_question`) while its stdin
55
+ was a pipe that stayed open (`sleep 30 | chi -p …`, a parent holding stdin): it waited for an answer line. Nobody
56
+ can answer there, as for an approval: the question is dismissed at once (one line on stderr), the model finishes
57
+ its reply, and stdin is never read. Piped answers without `--non-interactive` (`printf '2\n' | chi --no-shared -p
58
+ …`) work as before.
59
+
11
60
  ## [0.45.0] - 2026-10-08
12
61
 
13
62
  ### Added
@@ -2094,7 +2143,8 @@ and long-lived sessions.
2094
2143
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2095
2144
  hotkey panel that send selected text or the clipboard to your sessions.
2096
2145
 
2097
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.45.0...HEAD
2146
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.46.0...HEAD
2147
+ [0.46.0]: https://github.com/dm1try/samagotchi/compare/v0.45.0...v0.46.0
2098
2148
  [0.45.0]: https://github.com/dm1try/samagotchi/compare/v0.44.0...v0.45.0
2099
2149
  [0.44.0]: https://github.com/dm1try/samagotchi/compare/v0.43.0...v0.44.0
2100
2150
  [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,12 +85,15 @@ 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.
88
91
  - A default-host id is bare, except one with a `:` (`qwen3:8b`), which is written `<host>:qwen3:8b` so it can't
89
92
  read as a host name. An id that an alias of the same name hides is left out.
90
93
  - 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
94
  answer in time is noted on stderr (`chi models: box: no answer in 4 s`).
92
95
  - `--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
96
+ `models` (`name`, `host`, `id`, and `configured: true` on an id declared under `hosts.<name>.models`), `aliases` (`name`, `ref`, `host`) and `warnings`. The desktop helper's model
94
97
  chooser reads it.
95
98
  - Exit 0 when any host listed its models, 1 when none did (the default is still printed), 2 on a usage error.
96
99
 
@@ -245,6 +248,9 @@ Notes:
245
248
  then `chi: the session is kept with your prompt; continue it with: chi
246
249
  --resume ID` (exit 1). Ctrl-C saves the session with the prompt and a
247
250
  cancel note and exits 130.
251
+ Nobody can answer there: an approval is denied, and a question the model
252
+ asks (`ask_user_question`) is dismissed at once (one line on stderr), never
253
+ read from stdin, so the model finishes its reply.
248
254
 
249
255
  ### Scratch sessions
250
256
 
@@ -430,8 +436,10 @@ reports them),
430
436
  the decode speed, generation latency, cancellations, retries (the network's),
431
437
  thinking cuts (generations a plugin such as loop-guard cut) and output cap hits
432
438
  (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
439
+ cost when the provider reports one (OpenRouter's `usage.cost`), and the cost
440
+ estimated from a price under `hosts.<name>.models` when it reports none or 0,
441
+ marked `~` (`cost: $0.42 reported + ~$0.12 from hosts.<name>.models prices (this session only)`;
442
+ see "Prices" in [configuration](configuration.md#prices)). The speed line is the last generation's and the session's average
435
443
  (weighted by tokens): llama.cpp reports it exactly (`87 tok/s out, 1.9k tok/s
436
444
  prompt (last, server)`); for other servers it is estimated from the first
437
445
  streamed chunk to the end and marked `~` (`~64 tok/s out (last, estimate)`).
@@ -734,7 +742,7 @@ In interactive assist mode, you can switch the request model without restarting:
734
742
  - `/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
743
  - `/model clear` (or `default`/`none`/`off`): clear the session override, reverting to the configured default.
736
744
  - `/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.
745
+ - `/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. 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
746
 
739
747
  Notes:
740
748
 
@@ -837,7 +845,8 @@ status> model=Qwen3.6-35B | ⑂ 2 running · 1 waiting | ctx=8.1% (under20) | me
837
845
  ```
838
846
 
839
847
  - `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.
848
+ `model=<served> (served; asked <name>)` when the server said it served another model (not for one its host's
849
+ `hosts.<name>.models.<id>.served` names; see [configuration](configuration.md#models-a-host-serves-but-doesnt-list)).
841
850
  - `↳ <id>`: the session that delegated this one.
842
851
  - `⑂ …`: this session's delegate children (forks aren't counted): how many run and how many wait
843
852
  for an answer; only the counts there are, nothing when none does (`/children` has the rest:
@@ -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
 
@@ -1036,6 +1130,9 @@ described in their own sections.
1036
1130
  | `thinking.turn_preamble` | `true` | yes | Ask a `qwen36` model to open its thinking with a short `TURN:` line (the step label). |
1037
1131
  | `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
1132
  | `models.<key>.thinking`, `hosts.<name>.thinking` | none | | A model's or host's level. See "Thinking". |
1133
+ | `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". |
1134
+ | `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". |
1135
+ | `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
1136
  | `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
1040
1137
  | `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
1138
  | `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). |
@@ -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)`); 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?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host; `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" }
@@ -57,7 +57,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
57
57
  **Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
58
58
 
59
59
  - `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
60
- - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
60
+ - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
61
  - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
63
63
  - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.45.0
3
+ version: 0.46.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
- config_modification_protocol.md: sha256:2d4135ce4fafcd391d1782dc3a54f7cbf10cd35ebe9f004b2d6cb256517a8451
9
+ config_modification_protocol.md: sha256:337ad1b4cc9308dc5abb47b13f51a76df4691ba80fe41648f391687b5b929b65
10
10
  memory_guide.md: sha256:15cd3b24eda853cc458371c6c75809a2d4b5352311d075aaa4384926b5221c27
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614