samagotchi 0.37.0 → 0.39.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/README.md +1 -1
  4. data/docs/broadcast.md +5 -2
  5. data/docs/cli.md +2 -1
  6. data/docs/configuration.md +11 -3
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +2 -2
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/engine.md +4 -2
  12. data/docs/internals/llm-context-bench.md +119 -0
  13. data/docs/internals/prompt-caching.md +3 -2
  14. data/docs/plugins.md +85 -32
  15. data/docs/testing.md +7 -0
  16. data/lib/samagotchi/broadcast/scope_card.rb +15 -2
  17. data/lib/samagotchi/broadcast/triage.rb +29 -12
  18. data/lib/samagotchi/broadcast/triage_log.rb +9 -4
  19. data/lib/samagotchi/broadcast_command.rb +4 -2
  20. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
  21. data/lib/samagotchi/bundles/mcp/plugin.rb +424 -89
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -2
  23. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  24. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  25. data/lib/samagotchi/config.rb +30 -4
  26. data/lib/samagotchi/context_note.rb +7 -3
  27. data/lib/samagotchi/context_status.rb +20 -2
  28. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  29. data/lib/samagotchi/desktop/macos/Broadcast.swift +97 -0
  30. data/lib/samagotchi/desktop/macos/ChiRunner.swift +12 -3
  31. data/lib/samagotchi/desktop/macos/Panel.swift +123 -26
  32. data/lib/samagotchi/desktop/macos.rb +27 -5
  33. data/lib/samagotchi/desktop_command.rb +1 -1
  34. data/lib/samagotchi/engine.rb +22 -7
  35. data/lib/samagotchi/guardrails/targets.rb +14 -2
  36. data/lib/samagotchi/host_registry.rb +6 -3
  37. data/lib/samagotchi/kernel_loop.rb +114 -14
  38. data/lib/samagotchi/llm/chat_loop.rb +39 -14
  39. data/lib/samagotchi/llm/turn_settings.rb +5 -4
  40. data/lib/samagotchi/llm_context_apply.rb +153 -0
  41. data/lib/samagotchi/llm_context_edit.rb +71 -0
  42. data/lib/samagotchi/llm_context_stale.rb +255 -0
  43. data/lib/samagotchi/llm_context_strategy.rb +133 -0
  44. data/lib/samagotchi/llm_context_view.rb +124 -0
  45. data/lib/samagotchi/log_subscriber.rb +2 -1
  46. data/lib/samagotchi/plugin/api.rb +5 -3
  47. data/lib/samagotchi/prompt.rb +4 -11
  48. data/lib/samagotchi/session_manager.rb +9 -1
  49. data/lib/samagotchi/session_metrics.rb +18 -1
  50. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  51. data/lib/samagotchi/token_usage.rb +3 -0
  52. data/lib/samagotchi/tool_ids.rb +84 -0
  53. data/lib/samagotchi/tool_response.rb +72 -11
  54. data/lib/samagotchi/tool_runner.rb +18 -9
  55. data/lib/samagotchi/tools/context_read.rb +27 -10
  56. data/lib/samagotchi/update_command.rb +1 -1
  57. data/lib/samagotchi/version.rb +1 -1
  58. data/lib/samagotchi/worker.rb +4 -0
  59. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15887ec5fe31be3f32d3e1bfa15b9cf5ca73b03e2101f6416eb2cfad4c2f477a
4
- data.tar.gz: 2226f5343806c3171babc66d075fc57498af03d313f180458e4f6ee1801c7384
3
+ metadata.gz: 15442c0fe342d1193ff595b31c554f886076fc4da7dac938fe319ec784e053c6
4
+ data.tar.gz: 37e73e8590db29ba273346191954ba96b72de468a46663bc2f8820807d95278d
5
5
  SHA512:
6
- metadata.gz: 8986ea963bda07138d6874b9024a031c5c6c000d03bad8281c9c24f57a079234fab5de68efe08305dd38ea4f138bf7ef7b02637e310e69f061182bb17d01d8a3
7
- data.tar.gz: d9e07e7a5bacd114b8916a538fa2265bbd5b43ed78373fc306766455788b75ef5c1b0eb21d054288887483c93385063293150339abf4e3d02284bc1320cd9509
6
+ metadata.gz: a7d252dbf3ef0eb6ea34aa61bd47e5da389bf17b5f0f4784c9c0e2bf69d22732e83db29b8652bce40ef7ac4a0593e48bc481eb63b9b86a2485ff4027dac1de5f
7
+ data.tar.gz: 50d569dc04ca76c690c9f4f91ffcec90d85b54445b07f63d2bab1fc6aed5a47d214d9b400faa9b3915783a10e9952613b1a0b0db6361accb514eb4aab7ad3e67
data/CHANGELOG.md CHANGED
@@ -8,6 +8,75 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.39.0] - 2026-10-07
12
+
13
+ ### Added
14
+
15
+ - `llm_context.strategy` (default `none`), and per model or host `llm_context_strategy`. `stale` sends a file read
16
+ that a later read of the same lines superseded as a one-line stub; the session keeps the output. With
17
+ `llm_context.stale_edits: true` (opt-in, experimental) a read a later edit or write of the file superseded is
18
+ stubbed too; on the replay bench these cost about 5x the tokens they free in re-prefill, and 26% were needed again
19
+ (against a 6% base rate). No read of a file edited in the last `llm_context.protect_steps` (3) steps is stubbed.
20
+ `llm_context.apply` (and per model or host `llm_context_apply`) says when stubs reach the prompt: `payoff` (the
21
+ default) sends them at a request when they free at least the tail the server reads again, else at the end of a
22
+ turn the model answered, which in practice is usually where they go; `next_request` and `turn_end` are the
23
+ others. See [docs/configuration.md](docs/configuration.md).
24
+ - `/stats` shows the re-prefilled tokens for every session (what the server prefilled again of the previous
25
+ request's prompt, 128 tokens or more), and the log's `generation_completed` line has them as `reprefill=`.
26
+ - For development: `script/llm_context_bench.rb`, a replay benchmark for the LLM context strategies over a folder of
27
+ stored sessions (offline; `--live` asks a model, for picks). It isn't part of the gem. See
28
+ [docs/internals/llm-context-bench.md](docs/internals/llm-context-bench.md).
29
+
30
+ ### Fixed
31
+
32
+ - Answering a step-limit question with Continue and then sending a prompt runs the continue turn first, even when
33
+ both arrive while the worker is between turns (the prompt could run first and drop the offer). A session started
34
+ from another session's `execute` (`chi send`) no longer inherits that session's `SAMAGOTCHI_PARENT_SESSION` and
35
+ `SAMAGOTCHI_SESSION_MODEL`, so your `!chi context add` there attaches to it, not to the other one. `context_read`
36
+ pages within `max_tool_output_chars` whatever its header holds, so a page is never cut a second time behind its
37
+ "Pass offset". The mcp bundle (0.6.1): `mcp_call` with a mistyped `<server>/<tool>` waits only for that server's
38
+ start, and `find_mcp_tools` names a server with no `description:` or instructions by its tool count, not its first
39
+ tool names (a model called those without searching).
40
+
41
+ Update with `chi update` (mcp 0.6.1).
42
+
43
+ ## [0.38.0] - 2026-10-07
44
+
45
+ ### Added
46
+
47
+ - The desktop helper's panel has an **Everyone it concerns** row (⌘B): ⌘⏎ there runs `chi broadcast` with the note
48
+ (your line, then the selection; a first line like `shopfront/checkout` keeps it to that project), instead of a
49
+ note to the sessions you pick. The panel shows "broadcasting…", then the summary line, and stays open; `chi
50
+ broadcast log` has the details. It may take `broadcast.triage_deadline` + 10 s: after changing the deadline, run
51
+ `chi update`. Rebuilt by `chi update`. See [docs/desktop.md](docs/desktop.md#everyone-it-concerns).
52
+
53
+ ### Changed
54
+
55
+ - The mcp bundle (0.6.0) no longer declares every MCP tool to the model. It gets two fixed tools: `find_mcp_tools`
56
+ searches the servers' tools by keywords and answers the best 5 with their schemas, and `mcp_call` calls one as
57
+ `<server>/<tool>`. Requests get smaller (about half on a 30-tool server, in a spike on a local model), and the
58
+ model's tools no longer change when a server starts, refreshes or fails, so the prompt cache keeps; a tool turn
59
+ takes a few more steps (search, then call). A failed call answers the tool's schema, so a call can be fixed on the
60
+ next step. A server's new optional `description:` names it in the search tool, else the first sentence of its
61
+ instructions. Guardrail rules on `mcp_<server>_<tool>` names still match (`mcp_call` acts as that tool), and the
62
+ question names `<server>: <tool>`; approvals stored under the old names don't carry over. Hooks and loop-guard's
63
+ `ignore_tools` see `mcp_call`. `/mcp` lists the tools as `<server>/<tool>` and says what every request carries.
64
+ Needs chi 0.37.0. See [docs/plugins.md](docs/plugins.md#the-mcp-bundle).
65
+
66
+ ### Fixed
67
+
68
+ - `chi broadcast`'s triage ends at most a second after `broadcast.triage_deadline` (requests that ignore the cancel
69
+ could hold it up to ~4 s past it), turns logprobs off for a host only when its 400 was about them, and two broadcasts
70
+ at once no longer both rotate the triage log (one failed, or the old lines were lost). A scope card's folder is right
71
+ under a symlinked path (`/tmp` on macOS read `../../../tmp/…`), and the summary line shortens a reason that quotes a
72
+ setting (`3 unchecked: no triage model: broadcast.triage_host_ref`).
73
+ - `max_tool_output_chars` caps the tool outputs the model gets back on the native loop too (a host without
74
+ `api: openai`): it fed each output whole, so one long result could fill the context. On both loops a cut output now
75
+ ends with `[cut: N of M chars; read it in parts]`, so the model knows it didn't get it all (the chat loop cut it
76
+ silently); the tool row shows the same text.
77
+
78
+ Update with `chi update` (mcp 0.6.0; it also rebuilds the desktop helper for the new row).
79
+
11
80
  ## [0.37.0] - 2026-10-07
12
81
 
13
82
  ### Added
@@ -1805,7 +1874,9 @@ and long-lived sessions.
1805
1874
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1806
1875
  hotkey panel that send selected text or the clipboard to your sessions.
1807
1876
 
1808
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.37.0...HEAD
1877
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.39.0...HEAD
1878
+ [0.39.0]: https://github.com/dm1try/samagotchi/compare/v0.38.0...v0.39.0
1879
+ [0.38.0]: https://github.com/dm1try/samagotchi/compare/v0.37.0...v0.38.0
1809
1880
  [0.37.0]: https://github.com/dm1try/samagotchi/compare/v0.36.0...v0.37.0
1810
1881
  [0.36.0]: https://github.com/dm1try/samagotchi/compare/v0.35.0...v0.36.0
1811
1882
  [0.35.0]: https://github.com/dm1try/samagotchi/compare/v0.34.0...v0.35.0
data/README.md CHANGED
@@ -179,7 +179,7 @@ only tells you (`context.wake`). See [Attached context](docs/context.md).
179
179
  `chi desktop install` builds a small native helper: select text in any app →
180
180
  Services → **Send to chi** (or ⌃⌥⌘N with the clipboard) → pick sessions in a
181
181
  Spotlight-like panel → ⏎ sends it quoted under your question as a message, ⌘⏎ as a
182
- context note. It needs the
182
+ context note (on the "Everyone it concerns" row, a `chi broadcast`). It needs the
183
183
  Command Line Tools. See [Desktop helper](docs/desktop.md).
184
184
 
185
185
  ### Guardrails
data/docs/broadcast.md CHANGED
@@ -16,7 +16,9 @@ chi broadcast log # the last broadcasts and who got them
16
16
  chi broadcast deliver b-7f3a1c9e c0ffee12 # one it skipped, after all
17
17
  ```
18
18
 
19
- To tell one session or a few you picked, use `chi note ID...` instead.
19
+ To tell one session or a few you picked, use `chi note ID...` instead. From
20
+ any app, the desktop helper's **Everyone it concerns** row broadcasts the
21
+ selection: see [Desktop helper](desktop.md#everyone-it-concerns).
20
22
 
21
23
  ## Who it reaches
22
24
 
@@ -157,7 +159,8 @@ Each broadcast (not a `--dry-run`) leaves one line in
157
159
  id, time, your text, the note's tags and each recipient's verdict (result,
158
160
  reason, P(yes), who decided, the session's tags). Scope cards are not kept:
159
161
  they quote your prompts. Past 2 MiB the file becomes `log.jsonl.1`, one old
160
- file kept. Sessions don't read it: a broadcast stays fire-and-forget.
162
+ file kept (`log.jsonl.lock` keeps two broadcasts at once from both rotating
163
+ it). Sessions don't read it: a broadcast stays fire-and-forget.
161
164
 
162
165
  ```sh
163
166
  chi broadcast log # the last 5 broadcasts
data/docs/cli.md CHANGED
@@ -421,7 +421,8 @@ file and index line stay as they are; only this session doesn't see it.
421
421
  `/help` lists the commands this session takes, the installed bundles' too, each
422
422
  with a line on what it does. `/stats` shows the session's numbers: turns, tool
423
423
  calls (by tool, with errors), iterations, tokens in/out summed over every
424
- request (with the cached tokens, the cache writes and the reasoning tokens when the server reports them),
424
+ request (with the cached tokens, the cache writes, the re-prefilled tokens and the reasoning tokens when the server
425
+ reports them),
425
426
  the decode speed, generation latency, cancellations, retries (the network's),
426
427
  thinking cuts (generations a plugin such as loop-guard cut) and output cap hits
427
428
  (generations that ended at the provider's cap), context used and window, the prompt profile and the model the server says it ran, and the
@@ -535,8 +535,10 @@ cache:
535
535
  key: session # off (default) or session
536
536
  ```
537
537
  Each request's own counts are in the debug log's `generation_completed` line:
538
- `prompt=` (its prompt tokens), `cached=` (read from the server's cache) and `cache_write=` (written to it, when the
539
- server reports writes); recap and side requests log theirs as `recap request_usage`.
538
+ `prompt=` (its prompt tokens), `cached=` (read from the server's cache), `cache_write=` (written to it, when the
539
+ server reports writes) and `reprefill=` (of the previous request's prompt on the same model, what the server
540
+ prefilled again: an `llm_context` edit's cache break, or anything else that rewrote the prompt's middle; only when the
541
+ server reports a cached count, and not on the first request after a warm-up); recap and side requests log theirs as `recap request_usage`.
540
542
 
541
543
  ## Llama Model Routing
542
544
 
@@ -915,6 +917,12 @@ described in their own sections.
915
917
  | `context.status` | `true` | yes | Context-usage telemetry for the model. See [context telemetry](internals/context-telemetry.md). |
916
918
  | `context.window_tokens` | server's, else 256000 | yes | Context window when the server doesn't report one. `models.<key>.window_tokens` and `hosts.<name>.window_tokens` come first; see "The context window". |
917
919
  | `models.<key>.window_tokens`, `hosts.<name>.window_tokens` | none | | A model's or host's context window when the server and its model list report none. |
920
+ | `llm_context.strategy` | `none` | | What the model is sent of the conversation: `none` (everything, unchanged), or a list of layers (`stale`, `forget`; `"\|"`-separated or a YAML list). `stale`: a file read that a later read covering its lines superseded (a read that came back whole: not the head/tail preview of a big file, nor cut at `max_tool_output_chars`) is sent as a stub (`[read] lib/x.rb lines 1-200: superseded by a later read`). With `llm_context.stale_edits: true` (opt-in, experimental) so is a read a later successful edit or write of the file superseded (`superseded by a later edit`); on the replay bench these stubs cost about 5x the tokens they free in re-prefill, and 26% of them were needed again (against a 6% base rate). No read of a file edited or written in the last `llm_context.protect_steps` steps is stubbed. When stubs reach the prompt is `llm_context.apply`: `next_request` sends a read a later read superseded from the next request, and an edit-driven stub at the end of a turn the model answered; `turn_end` sends them all at the end of a turn the model answered (a turn that ran out of steps keeps them staged; on the native loop against a local llama.cpp the turn-end warm-up prefills the edited prompt, elsewhere the next turn's first request carries the cache break); `payoff` sends the batch at a request when the tokens it frees are at least the tail after its earliest stub (what the server reads again), or the context is in the top `context.status_thresholds` bucket (which needs `context.status` on), else at the end of a turn the model answered. A re-read's tail holds the later read, so in practice payoff usually waits for the turn's end. Each batch breaks the prompt cache from its earliest stub; `/stats` counts the re-prefilled tokens. The session keeps the outputs and the stubs it sent, so a `--resume` sends the same. `forget` isn't built yet: a list with it warns and is `none`; an unknown name warns and is `none`. `models.<key>.llm_context_strategy` and `hosts.<name>.llm_context_strategy` come first. |
921
+ | `models.<key>.llm_context_strategy`, `hosts.<name>.llm_context_strategy` | none | | A model's or host's `llm_context.strategy` (`none` or `stale` for now). |
922
+ | `llm_context.apply` | `payoff` | | When a layer's edits reach the prompt (each batch breaks the prompt cache from its earliest edit): `payoff`, `next_request` or `turn_end`. An unknown value warns and is `payoff`. `models.<key>.llm_context_apply` and `hosts.<name>.llm_context_apply` come first. |
923
+ | `models.<key>.llm_context_apply`, `hosts.<name>.llm_context_apply` | none | | A model's or host's `llm_context.apply`. |
924
+ | `llm_context.stale_edits` | `false` | | Opt-in, experimental: `stale` also stubs a read that a later successful edit or write of the file superseded. |
925
+ | `llm_context.protect_steps` | `3` | | `stale` never stubs a read of a file edited or written in the last N steps (tool batches; counted across the whole conversation, not only the turn). `0` turns the protection off. |
918
926
  | `context.chars_per_token` | `4.0` | yes | Estimate ratio when the server reports no usage. |
919
927
  | `context.status_thresholds` | `20,40,60,80` | yes | Percentages that trigger a status. |
920
928
  | `context.status_cadence` | `0` | yes | Also every N rounds; `0` = thresholds only. |
@@ -924,7 +932,7 @@ described in their own sections.
924
932
  | `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". |
925
933
  | `models.<key>.thinking`, `hosts.<name>.thinking` | none | | A model's or host's level. See "Thinking". |
926
934
  | `hosts.<name>.remote` | by address | | `true`/`false`: treat the host as a remote provider or a local server. See "Remote or local". |
927
- | `max_tool_output_chars` | `10000` | yes | Tool output kept in the conversation; a top-level key (see below). |
935
+ | `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). |
928
936
  | `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). |
929
937
  | `cache.ttl` | `5m` | | How long Anthropic keeps the prompt-cache breakpoints of a Claude model on a remote host: `5m` (Anthropic's default) or `1h`. A 1h cache write costs 2× the input price (5m: 1.25×), a read the same 0.1×. See "Remote or local". |
930
938
  | `cache.key` | `off` | | `session`: send the session id as `prompt_cache_key` to OpenAI's API and OpenRouter, which route and cache by it; no other host gets it. `off`: none. |
data/docs/desktop.md CHANGED
@@ -38,6 +38,22 @@ note needs a session. After the send the panel shows `started <id>…` for 3 s.
38
38
  the model the new session starts on (`chi self --model`: `default.model` from config.yml, or its env override; an
39
39
  alias as the model it names).
40
40
 
41
+ ### Everyone it concerns
42
+
43
+ The second row, **Everyone it concerns** (⌘B), shares a note with every session it may concern instead of the ones
44
+ you pick: ⌘⏎ (the **Broadcast** button) runs [`chi broadcast`](broadcast.md) with the line, then the text, on stdin.
45
+ chi picks the sessions (a shared ticket, PR or link, else a small triage model), so the preselected session doesn't
46
+ count: the row is ticked alone, like the new row. Write it the way you'd tell a colleague: a first line such as
47
+ `shopfront/checkout` keeps the note to that project's sessions, and the rest is the selection.
48
+
49
+ - The panel shows "broadcasting…", then the broadcast's id and chi's summary line (`b-7f3a1c9e: delivered 4 ·
50
+ skipped 2 · 1 unchecked: triage deadline`), and **stays open**: Esc closes. Which sessions got it and why:
51
+ `chi broadcast log` in a terminal.
52
+ - A broadcast is a text note: ⏎ and images beep. The source field and Send are hidden (its source is `broadcast`).
53
+ - It may take `broadcast.triage_deadline` (20 s) and 10 s more before the helper stops it ("the note may be partly
54
+ delivered"). The helper reads that from its launch file: after changing the deadline, run `chi update` (or
55
+ `chi desktop upgrade`).
56
+
41
57
  ### Choosing the model
42
58
 
43
59
  Click the model on the new row, or press **⌘M**, to choose another one for the new session. The chooser takes the
@@ -179,7 +195,9 @@ exports. So `install` writes `~/Library/Application Support/Chi Helper/launch.js
179
195
  - the absolute path of the Ruby running chi and of chi itself (the gem's wrapper, or a checkout's `bin/chi`);
180
196
  - `LANG=en_US.UTF-8`;
181
197
  - only these variables, and only when they are set: `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `GEM_HOME`, `GEM_PATH`,
182
- `RUBYLIB`. No tokens and no `SAMAGOTCHI_*` settings go in.
198
+ `RUBYLIB`. No tokens and no `SAMAGOTCHI_*` settings go in;
199
+ - the settings the panel needs from config.yml, as chi read them then: the `kitty:` section (when
200
+ `kitty.listen_on` is set) and `broadcast_timeout` (`broadcast.triage_deadline` + 10 s).
183
201
 
184
202
  The file freezes the install shell's values. If you install with a temporary `XDG_STATE_HOME`, the helper keeps
185
203
  using it. `status` prints the baked dirs.
@@ -194,10 +212,14 @@ The helper uses only these commands, so it could ship on its own later:
194
212
  - `chi send [-m LINE] [--image PATH]... ID...` and `chi send --new --dir DIR [--model NAME] [-m LINE] [--image PATH]...`
195
213
  with the text on stdin (or none), and `chi note [--source NAME] ID...` with the text on stdin → one line per session
196
214
  on stdout; exit 0 means all sent or queued, 1 means some were refused or failed.
215
+ - `chi broadcast` with the text on stdin → its id first (`broadcast b-…`), a line per recipient, then the summary line
216
+ (`delivered N · skipped M…`); exit 0 when it ran, 1 when refused or a delivery failed.
197
217
  - `chi self --model` → the model a new session starts on (the panel's hint).
198
218
  - `chi models --format json` → the models the hosts offer (the new-session model picker).
199
219
 
200
- Each call is stopped after 10 s, a send with images after 30 s. A stopped `chi note` says the note may be partly delivered.
220
+ Each call is stopped after 10 s, a send with images after 30 s, a broadcast after the launch file's
221
+ `broadcast_timeout` (`broadcast.triage_deadline` + 10 s; 30 s when it has none). A stopped `chi note` or `chi
222
+ broadcast` says the note may be partly delivered.
201
223
 
202
224
  ## Troubleshooting
203
225
 
data/docs/guardrails.md CHANGED
@@ -289,7 +289,7 @@ A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
289
289
  say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
290
290
  `{a,b}`, matched with `File.fnmatch`). `/guardrails` lists the glob as given.
291
291
  A plugin tool whose `targets:` name no command or path (an MCP tool) is asked
292
- about with its arguments (`mcp_x_sum: a=20 b=22`), and an approval of "this
292
+ about with its arguments (`x: sum: a=20 b=22`), and an approval of "this
293
293
  call" is keyed by them.
294
294
 
295
295
  A plugin tool that runs other tools for the model (a dispatcher such as
@@ -309,7 +309,7 @@ name.
309
309
 
310
310
  ```yaml
311
311
  - id: mcp-ask
312
- tool: "mcp_*" # every MCP tool (the mcp bundle's mcp_<server>_<tool>)
312
+ tool: "mcp_*" # every MCP call (mcp_call, acting as mcp_<server>_<tool>); not a find_mcp_tools search
313
313
  verdict: ask
314
314
  reason: an MCP server's tool
315
315
  ```
data/docs/hooks.md CHANGED
@@ -56,7 +56,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
56
56
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
57
57
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
58
58
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
59
- | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped |
59
+ | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars, a cut one ending in a [cut: N of M chars …] line), status: "ok" \| "error" \| "blocked" \| "stopped" }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped |
60
60
  | `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
61
61
 
62
62
  Every event also carries the hook runtime (next section): `hook:` (the label
@@ -444,7 +444,8 @@ stripped of surrounding whitespace, except file text, an edit's
444
444
  A `write`'s `content:` is the file text; an `edit` carries `old_text:` and
445
445
  `new_text:` (its `content:` is `""`). A plugin or MCP tool's call has its
446
446
  arguments whole on `args:` (string keys) instead, and its `content:` is those
447
- arguments as JSON.
447
+ arguments as JSON. An MCP tool's call is the mcp bundle's `mcp_call`: `tool:
448
+ "mcp_call"`, with `args: {"tool" => "<server>/<tool>", "args" => {…}}`.
448
449
 
449
450
  ## Bundle Hooks (unified workflow bundle)
450
451
 
@@ -23,6 +23,6 @@ Behavior:
23
23
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
24
24
  - `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
25
25
  - `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
26
- - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent) and `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown). `chi self` reads the latter for its `model` row.
26
+ - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent) and `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown). `chi self` reads the latter for its `model` row. A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
27
27
 
28
28
  Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
@@ -192,7 +192,7 @@ One iteration with tool calls:
192
192
  → returns {output:, capped_output:, truncated:, activity:, images:, shown_params:,
193
193
  shown_label:, diff:}
194
194
  → :tool_dispatch_completed {iteration, call_count}
195
- → native: ToolResponse.joined(runs), one entry with the full outputs
195
+ → native: ToolResponse.joined(runs), one entry with the capped outputs
196
196
  chat: ToolResponse.single(run, tool_call_id:) per call, with the capped output
197
197
  ```
198
198
 
@@ -207,7 +207,9 @@ One iteration with tool calls:
207
207
  - For edit and write: diffs the file before and after, and refreshes a memory's index line
208
208
  (`MemoryBundle::IndexSync`).
209
209
  - Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
210
- - Scrubs bytes that aren't UTF-8, and caps the event's output at `max_tool_output_chars`.
210
+ - Scrubs bytes that aren't UTF-8, and caps the output at `max_tool_output_chars`: a longer one is cut and ends
211
+ with `[cut: N of M chars; read it in parts]` (N kept of M, the whole within the cap unless the cap is shorter
212
+ than that line). The model (both loops), the event and the after_tool_call hook get that text.
211
213
  - Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, activity,
212
214
  images, diff, waited_ms, view}, then fires `:after_tool_call` {iteration, tool, output, status}.
213
215
 
@@ -0,0 +1,119 @@
1
+ # The llm_context replay benchmark
2
+
3
+ `script/llm_context_bench.rb` replays stored chi sessions offline and reports what an LLM context strategy
4
+ (`llm_context.strategy`) would have changed in them. Every strategy or policy change gets a number from it before it
5
+ becomes a default. It is a development tool: it lives under `script/`, isn't shipped with the gem, and no spec runs
6
+ it on real sessions (the specs use small synthetic ones).
7
+
8
+ ```sh
9
+ ruby script/llm_context_bench.rb [SESSIONS_DIR] [options] # --help lists the options
10
+ ```
11
+
12
+ `SESSIONS_DIR` is a folder of `<session id>.json` files: an argument, else `$LLM_CONTEXT_BENCH_SESSIONS`, else chi's
13
+ own (`~/.local/state/samagotchi/sessions`). The benchmark only reads it. Sessions with fewer than two turns are
14
+ skipped; `--top N` keeps the N heaviest, `--session PREFIX` picks some.
15
+
16
+ It measures what chi would send: sessions load through `Session.from_h`, a native batch's joined result splits into
17
+ its runs as `LLMContextView` splits it (calls read by chi's `ToolCallParser`), every output is named by its
18
+ `ToolIds` id, and a strategy's edits are saved as `LLMContextEdit` records on copies of the entries and rendered
19
+ through `LLMContextView`. Tokens are chars/4 throughout, which undercounts code-heavy prompts by about 1.25x.
20
+
21
+ ## What it reports
22
+
23
+ **The profile (none, every session):** the resent tokens by category (tool outputs, call arguments, the system
24
+ prompt, prose, user messages, context notes, a native session's inline thinking; stored thinking apart, since chi
25
+ never resends it), tool outputs by tool and size, turns, how much of a turn's output a later turn refers to, and
26
+ repeat reads.
27
+
28
+ **A row per strategy × model × policy**, summed over the cases. A case is the end of a turn that has a next turn,
29
+ scored at the next turn's first request. Cases are named `<first 8 of the session id>_t<turn>`; they come from a
30
+ `--cases FILE` (one name per line), else from the picks given, else every turn with at least `--min-turn-tool`
31
+ (3000) tokens of its own tool output.
32
+
33
+ Each case says how its turn ends: `answer` (the model's final prose, no calls), `tool_result` (it stopped mid-task on
34
+ a tool's output), `turn_note` (chi's note on a turn that ended without an answer), `tool_call` (calls never
35
+ answered) or `empty`. The report's second line tallies them, `--per-case` has an `ends` column, and the JSON has
36
+ `case_ends` (name => ending) and `ends` on each case. `--ends answer|any` picks which turns make cases: by default the
37
+ turn-size rule keeps only `answer` ends, the point where a forget-at-turn-end offer would come (the layout check
38
+ found a model behaves differently mid-task), and named cases (`--cases`, picks) are kept as named. `--ends answer`
39
+ drops named cases that end otherwise and says how many; `--ends any` takes every turn.
40
+
41
+ | Column | Meaning |
42
+ |---|---|
43
+ | forgot/outputs | outputs the strategy edits / outputs in context at the case |
44
+ | freed/tool | what the prompt loses, stubs counted / the tool output tokens in context |
45
+ | wrong strict (base) | edited outputs a later step needs: from the step the edit reaches through the end of the next turn, a call re-reads the file or re-runs the command. In brackets the base rate: the share of all outputs in context that are needed, what a random pick scores |
46
+ | wrong loose (base) | the same, counting a mention of the output's path or of an identifier it brought in |
47
+ | 1-step | edited outputs the step the edit reaches re-reads (a read of the file, not an edit) or re-runs. Offline that step is the session's own, made without the stub, so it is a floor, not the stub's effect |
48
+ | re-prefilled | what the server prefills again because an edit broke its prompt cache: from the earliest changed entry to the end of what the previous request left cached. A case counts the breaks of its own window only (after the previous turn's case point), so a strategy that edits as the session goes isn't counted twice |
49
+ | breaks | the requests of the case's window that an edit first reaches: one prompt-cache break each, a batch's edits together |
50
+
51
+ The proxies are the context-edit spike's (lexical and lenient: a basename mention counts).
52
+
53
+ ## Strategies
54
+
55
+ - `none`: nothing changes. The base.
56
+ - `forget_all`: at the turn's end, forget every output in context with an empty note. The spike's base rate; about
57
+ what a model told "free context now" does.
58
+ - `stale`: chi's own stale layer (`Samagotchi::LLMContextStale`) as chi runs it: each read a later read covering its
59
+ lines superseded is stubbed from the request after that read (chi applies it at the next request), so a case's
60
+ numbers count every stub since the session began. An edit or write of the file supersedes nothing, nor does a read
61
+ that came back as a preview or cut. A relative
62
+ path is taken against the session's working directory.
63
+ - `stale_edits_next_request`, `stale_edits_turn_end`, `stale_edits_payoff`: the stale layer with edit-driven stubs
64
+ under an `llm_context.apply` rule, as chi runs it (`Samagotchi::LLMContextApply`, `llm_context.protect_steps`'
65
+ default), over each session from its start: at each request, and at the end of each turn that ends with the
66
+ model's answer (those stubs reach the next turn's first request, the case point). The top context bucket isn't
67
+ modelled (the bench has no window), so `payoff` applies at a request only when the freed tokens are at least the
68
+ tail. A turn-end batch's re-prefill is counted at the next turn's first request even where chi's warm-up would
69
+ prefill it while the user reads.
70
+ - `forget_outputs` (P4): a slot. It prints "not built yet" until its phase adds it to
71
+ `LLMContextBench::Strategies`; a strategy answers `#plans(kase)` with `Plan`s of `PlannedEdit`s (the output id, the
72
+ edit kind, the note, the request it first reaches), and the scorer and the report take it as is.
73
+ - `--picks LABEL=DIR`: model picks recorded as responses, one file per pick, `<case>.<variant>.json` (the spike's
74
+ `out/<model>/` files, or what `--live` saves). The variant is the row's policy, the label its model.
75
+
76
+ ```sh
77
+ ruby script/llm_context_bench.rb "$SESSIONS" --strategy none,forget_all,stale --per-case
78
+ ruby script/llm_context_bench.rb "$SESSIONS" --strategy none,stale,stale_edits_turn_end,stale_edits_payoff --ends any
79
+ ruby script/llm_context_bench.rb "$SESSIONS" --picks deepseek=picks/deepseek --picks splash=picks/splash --json
80
+ ```
81
+
82
+ ## Live picks (the D7 tool-name A/B)
83
+
84
+ The one part that calls a model, and only with `--live MODEL`: at each case it asks the model which of its tool
85
+ outputs to forget, through chi's own chat client (the model must be on an `api: openai` host), and saves each
86
+ answer under `--out DIR` for `--picks` to score. A saved pick is never asked for again. The request is the
87
+ session's conversation up to the turn's end (thinking dropped, every tool result led by its id `[#tN]`), chi's
88
+ built-in chat tools plus the forget tool, and the policy's tail line; a model that doesn't call the tool is asked
89
+ once more with it forced (`--no-force` asks once).
90
+
91
+ A pick record holds the answer (`unforced`), the forced one when it took that (`forced`) and why it was forced
92
+ (`forced_because`: `no_call`, or `length` when the unforced reply was cut off at `max_tokens`, finish reason
93
+ `length`, before it could call anything), how it was asked (`bench`), and `cost`: what the server reported for the
94
+ pick's requests, summed (OpenRouter's `usage.cost`, asked for with `usage.include`; absent when the server sends
95
+ none). The run's last line counts the picks written, the forced ones, those forced after a cut, the errors, and sums
96
+ the cost.
97
+
98
+ A request that fails is saved as an error record and the run goes on to the next case, except a payment error: a
99
+ 402, or an error that mentions credits or balance, stops the run at once (exit 1), saves nothing for that case, and
100
+ says how many picks it wrote before it. A rerun with the same `--out` asks only for the rest.
101
+
102
+ - `--tool-name forget_outputs|forget_llm_context`: the name under test; the description stays the same.
103
+ - `--policy subtask|soft|now`: `subtask` offers the tool and puts the plan's policy line in its description;
104
+ `soft` and `now` are the spike's selective and "free context now" lines.
105
+ - `--layout tail_system|tail_user|boundary`: where the tail line goes. `tail_system` (the default) is a system
106
+ message after the case's last entry; `tail_user` the same line as a user message; `boundary` a system message
107
+ worded as a stopping point. A layout other than the default names its picks `<policy>_<layout>`.
108
+ - `--samples N`, and `--dry-run` to count the requests and prompt tokens (and the cases' endings) without calling
109
+ anything.
110
+
111
+ Case selection matters here: at a turn that ends with the model's answer the forget offer reads as a real turn end,
112
+ while mid-task (a `tool_result` or `turn_note` end) a model mostly carries on with the task, or, worded as a
113
+ stopping point, forgets at the base rate. A forget-at-turn-end test should run on `answer` cases, the default for
114
+ the turn-size rule; give `--ends answer` with a `--cases` file to hold one to that too.
115
+
116
+ ```sh
117
+ ruby script/llm_context_bench.rb "$SESSIONS" --cases cases.txt --live openrouter:deepseek/deepseek-v4.1-flash \
118
+ --tool-name forget_llm_context --policy subtask --out picks/deepseek --dry-run
119
+ ```
@@ -110,10 +110,11 @@ is prefilled once. The native and the chat loop use the same plan.
110
110
  ## Reading the numbers
111
111
 
112
112
  - The debug log's `generation_completed` line: `prompt=` (the request's prompt tokens), `cached=` (read from the
113
- server's cache) and `cache_write=` (written to it, where the provider reports writes). The `http` line of a
113
+ server's cache), `cache_write=` (written to it, where the provider reports writes) and `reprefill=` (of the last
114
+ request's prompt on the same model, what was prefilled again). The `http` line of a
114
115
  remote Claude request shows `cache=on` (or `cache=1h`) when it carried breakpoints.
115
116
  - The warm-up logs `model warmup` with `cached=`, `prefilled=` and `prefill_ms=`.
116
- - `/stats`: "cached N (P%)" and "cache writes" for the session.
117
+ - `/stats`: "cached N (P%)", "cache writes" and "re-prefilled" for the session.
117
118
  - A healthy session: from the second request on `cached=` is close to `prompt=`; a second fresh session in the
118
119
  same project reads everything up to the volatile tail.
119
120