samagotchi 0.38.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +10 -2
  5. data/docs/internals/background-tasks.md +1 -1
  6. data/docs/internals/llm-context-bench.md +119 -0
  7. data/docs/internals/prompt-caching.md +3 -2
  8. data/docs/plugins.md +3 -2
  9. data/docs/testing.md +7 -0
  10. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  11. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -10
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +30 -4
  14. data/lib/samagotchi/context_note.rb +7 -3
  15. data/lib/samagotchi/context_status.rb +20 -2
  16. data/lib/samagotchi/engine.rb +19 -5
  17. data/lib/samagotchi/guardrails/targets.rb +14 -2
  18. data/lib/samagotchi/host_registry.rb +6 -3
  19. data/lib/samagotchi/kernel_loop.rb +109 -11
  20. data/lib/samagotchi/llm/chat_loop.rb +39 -14
  21. data/lib/samagotchi/llm/turn_settings.rb +5 -4
  22. data/lib/samagotchi/llm_context_apply.rb +153 -0
  23. data/lib/samagotchi/llm_context_edit.rb +71 -0
  24. data/lib/samagotchi/llm_context_stale.rb +255 -0
  25. data/lib/samagotchi/llm_context_strategy.rb +133 -0
  26. data/lib/samagotchi/llm_context_view.rb +124 -0
  27. data/lib/samagotchi/log_subscriber.rb +2 -1
  28. data/lib/samagotchi/prompt.rb +4 -11
  29. data/lib/samagotchi/session_manager.rb +9 -1
  30. data/lib/samagotchi/session_metrics.rb +18 -1
  31. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  32. data/lib/samagotchi/token_usage.rb +3 -0
  33. data/lib/samagotchi/tool_ids.rb +84 -0
  34. data/lib/samagotchi/tool_response.rb +67 -6
  35. data/lib/samagotchi/tools/context_read.rb +27 -10
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/worker.rb +4 -0
  38. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 35846da55e0107127da528067ad179a59cbd7a23eee66c1b770a17d1504c717b
4
- data.tar.gz: e28ce71127da751668251ddfca44dc8b05a97e038f5d03045357f1cdbac26f20
3
+ metadata.gz: 15442c0fe342d1193ff595b31c554f886076fc4da7dac938fe319ec784e053c6
4
+ data.tar.gz: 37e73e8590db29ba273346191954ba96b72de468a46663bc2f8820807d95278d
5
5
  SHA512:
6
- metadata.gz: b2be4346aaaa73f05518a5b62ffcfca820f94ea571b5524d1d8013224ff43ee1648951e40ee4290f80f088da6ffec4c26c4de6c8a6905ccc83340919173fe330
7
- data.tar.gz: 01027d62cb5f9b423196f386c68fc4cbb46d8c73866540e1aacb9778f3a414013a059c5df99df099d528574cb1a5a5d57b0cb764002de0a4524148fd91ad66e8
6
+ metadata.gz: a7d252dbf3ef0eb6ea34aa61bd47e5da389bf17b5f0f4784c9c0e2bf69d22732e83db29b8652bce40ef7ac4a0593e48bc481eb63b9b86a2485ff4027dac1de5f
7
+ data.tar.gz: 50d569dc04ca76c690c9f4f91ffcec90d85b54445b07f63d2bab1fc6aed5a47d214d9b400faa9b3915783a10e9952613b1a0b0db6361accb514eb4aab7ad3e67
data/CHANGELOG.md CHANGED
@@ -8,6 +8,38 @@ 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
+
11
43
  ## [0.38.0] - 2026-10-07
12
44
 
13
45
  ### Added
@@ -1842,7 +1874,8 @@ and long-lived sessions.
1842
1874
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1843
1875
  hotkey panel that send selected text or the clipboard to your sessions.
1844
1876
 
1845
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.38.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
1846
1879
  [0.38.0]: https://github.com/dm1try/samagotchi/compare/v0.37.0...v0.38.0
1847
1880
  [0.37.0]: https://github.com/dm1try/samagotchi/compare/v0.36.0...v0.37.0
1848
1881
  [0.36.0]: https://github.com/dm1try/samagotchi/compare/v0.35.0...v0.36.0
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. |
@@ -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).
@@ -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
 
data/docs/plugins.md CHANGED
@@ -848,8 +848,9 @@ bundles:
848
848
  `(failed: …)`, a first-run server still starting `(starting, try
849
849
  again)`. Its description names each server on one line, never its
850
850
  tools: the server's `description:`, else the first sentence of its
851
- `instructions`, else its first 6 tool names, and how many tools it has
852
- (a first run's server is just its name until the next session).
851
+ `instructions`, and how many tools it has (`- chrome (26 tools)` with
852
+ neither: tool names there get called blind, without a search; a first
853
+ run's server is just its name until the next session).
853
854
  - `mcp_call(tool, args)` calls one: `tool` is `<server>/<tool>`, and
854
855
  `mcp_<server>_<tool>` works too (looked up, not parsed). Two tools whose
855
856
  `mcp_<server>_<tool>` comes out the same (servers `git-hub` and
data/docs/testing.md CHANGED
@@ -63,3 +63,10 @@ a model that follows tool instructions: a small model may fail some of them.
63
63
  `spec/integration/omlx_spec.rb` tests oMLX's model-id resolution and runs only with
64
64
  `SAMAGOTCHI_INTEGRATION_TRANSPORT=omlx`, against an oMLX server (a short model selector in
65
65
  `SAMAGOTCHI_INTEGRATION_MODEL`).
66
+
67
+ ## The llm_context replay benchmark
68
+
69
+ `ruby script/llm_context_bench.rb SESSIONS_DIR` replays stored sessions offline and scores the LLM context
70
+ strategies (freed tokens, wrongly forgotten outputs, one-step re-reads, re-prefilled tokens). It is opt-in: the suite
71
+ runs its code only on small synthetic sessions, never on yours. See
72
+ [internals/llm-context-bench.md](internals/llm-context-bench.md).
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.6.0
3
+ version: 0.6.1
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; the model searches their tools with find_mcp_tools and calls one with mcp_call (<server>/<tool>); /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:561dfc3a765d6a72913a7bc416ac0120e14f5747fdfc8f692e3d4dfb241ff6a6
10
+ sha256: sha256:7778b1a8f82a043c06c0289a191bc2847c844665de0c961edb493f787f435ad5
11
11
  # 0.37.0 (published 2026-10-07) is the release whose targets: take acts_as:, args: and label:.
12
12
  requires_chi: ">= 0.37.0"
@@ -59,8 +59,6 @@ class Plugin
59
59
  # A query word in a tool's name (or its server's) counts this many times
60
60
  # one in its description.
61
61
  NAME_WEIGHT = 3
62
- # Tool names that stand for a server's summary when it has none.
63
- SAMPLE_NAMES = 6
64
62
  # A cached tool list older than this is refreshed in the background.
65
63
  CACHE_TTL = 24 * 60 * 60
66
64
  # Restarts of a server that exited, per session.
@@ -689,16 +687,14 @@ class Plugin
689
687
  end
690
688
 
691
689
  # The server's line: its description: from config, else the first
692
- # sentence of its instructions, else its first tool names.
690
+ # sentence of its instructions, else nothing (its line has the tool
691
+ # count). Never its tool names: a model that knows them calls them
692
+ # blind, without a search (a smoke run's Splash did).
693
693
  def server_summary(server)
694
694
  given = server.config["description"].to_s.strip
695
695
  return given unless given.empty?
696
- return server.info if server.info
697
696
 
698
- names = server.tools.map(&:name)
699
- return nil if names.empty?
700
-
701
- "tools #{names.take(SAMPLE_NAMES).join(", ")}#{", …" if names.size > SAMPLE_NAMES}"
697
+ server.info
702
698
  end
703
699
 
704
700
  # ── Search ────────────────────────────────────────────────────────────
@@ -808,7 +804,8 @@ class Plugin
808
804
  # The McpTool +name+ names: <server>/<tool>, or mcp_<server>_<tool>
809
805
  # looked up by chi_name (never parsed: server names may hold _, and long
810
806
  # names are cut). A miss while a server is starting waits for it (its
811
- # tools may be the one); then a miss is the error text: a failed
807
+ # tools may be the one; for a <server>/ name, that server's start only);
808
+ # then a miss is the error text: a failed
812
809
  # server's error, else the closest tools.
813
810
  # @return [McpTool, String]
814
811
  def lookup(name, waited: false)
@@ -828,7 +825,10 @@ class Plugin
828
825
  "call one as <server>/<tool>"
829
826
  end
830
827
 
831
- starting = @servers.select { |server| server.state == :starting }
828
+ # A name with a configured server's <server>/ waits for that server
829
+ # only; any other name (mcp_<server>_<tool>) for every one starting.
830
+ named = @servers.select { |server| name.start_with?("#{server.name}/") }
831
+ starting = (named.empty? ? @servers : named).select { |server| server.state == :starting }
832
832
  unless waited || starting.empty?
833
833
  starting.each { |server| wait_started(server) }
834
834
  return lookup(name, waited: true)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.38.0
3
+ version: 0.39.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:
@@ -9,6 +9,7 @@ require_relative "config_text_edit"
9
9
  require_relative "log"
10
10
  require_relative "model_ref"
11
11
  require_relative "paths"
12
+ require_relative "llm_context_strategy"
12
13
 
13
14
  module Samagotchi
14
15
  # Parses the thinking: levels of host and model entries (it needs Config).
@@ -154,6 +155,18 @@ module Samagotchi
154
155
  # 10 minutes, within session.max_wakes); false: it waits as a note for the next turn.
155
156
  Entry.new(key: "context.wake", yaml_path: %w[context wake], type: :bool, default: true, expose: %i[env config cli]),
156
157
 
158
+ # What the model is sent of the conversation (LLMContextStrategy, LLMContextView): none, or a list of layers
159
+ # (stale, forget; "|"-separated or a YAML list). none and stale are built: a list with forget warns and is none.
160
+ # models.<key>.llm_context_strategy and hosts.<name>.llm_context_strategy come first.
161
+ Entry.new(key: "llm_context.strategy", yaml_path: %w[llm_context strategy], type: :string, default: "none", expose: %i[env config]),
162
+ # When a layer's edits reach the prompt (LLMContextApply): payoff, next_request or turn_end;
163
+ # models.<key>.llm_context_apply and hosts.<name>.llm_context_apply come first.
164
+ Entry.new(key: "llm_context.apply", yaml_path: %w[llm_context apply], type: :string, default: "payoff", expose: %i[env config]),
165
+ # Whether an edit or write of a file makes its earlier reads stale too (opt-in, experimental).
166
+ Entry.new(key: "llm_context.stale_edits", yaml_path: %w[llm_context stale_edits], type: :bool, default: false, expose: %i[env config]),
167
+ # The steps (tool batches) back whose edited files' reads stale never stubs.
168
+ Entry.new(key: "llm_context.protect_steps", yaml_path: %w[llm_context protect_steps], type: :integer, default: 3, expose: %i[env config]),
169
+
157
170
  Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli]),
158
171
  # How much models think: off|low|medium|high|default, parsed by Thinking
159
172
  # (a :string, since YAML reads an unquoted off as false). The CLI flag
@@ -241,8 +254,8 @@ module Samagotchi
241
254
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
242
255
  MAP_ENTRY_KEYS = {
243
256
  "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
244
- remote window_tokens].freeze,
245
- "models" => %w[profile vision sampling thinking window_tokens].freeze
257
+ remote window_tokens llm_context_strategy llm_context_apply].freeze,
258
+ "models" => %w[profile vision sampling thinking window_tokens llm_context_strategy llm_context_apply].freeze
246
259
  }.freeze
247
260
  # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
248
261
  SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
@@ -826,6 +839,10 @@ module Samagotchi
826
839
  "hosts entry '#{name}'")
827
840
  sampling = ConfigFile.sampling_map(raw_cfg.key?("sampling") ? raw_cfg["sampling"] : raw_cfg[:sampling], "hosts entry '#{name}'")
828
841
  thinking = Thinking.level(raw_cfg.key?("thinking") ? raw_cfg["thinking"] : raw_cfg[:thinking], "hosts entry '#{name}'")
842
+ llm_context = LLMContextStrategy.parse(raw_cfg.key?(LLMContextStrategy::KEY) ? raw_cfg[LLMContextStrategy::KEY] : raw_cfg[:llm_context_strategy],
843
+ "hosts entry '#{name}'")
844
+ llm_context_apply = LLMContextStrategy.parse_apply(raw_cfg.key?(LLMContextStrategy::APPLY_KEY) ? raw_cfg[LLMContextStrategy::APPLY_KEY] : raw_cfg[:llm_context_apply],
845
+ "hosts entry '#{name}'")
829
846
  unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
830
847
  warn_once "Warning: hosts entry '#{name}': first_token_timeout must be seconds (0 = off); using the default"
831
848
  first_token_timeout = nil
@@ -890,7 +907,8 @@ module Samagotchi
890
907
  url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
891
908
  profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
892
909
  vision: vision, sampling: sampling, thinking: thinking, remote: remote,
893
- window_tokens: window }
910
+ window_tokens: window, llm_context_strategy: llm_context,
911
+ llm_context_apply: llm_context_apply }
894
912
  end
895
913
  end
896
914
 
@@ -1037,7 +1055,9 @@ module Samagotchi
1037
1055
  location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
1038
1056
  "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
1039
1057
  "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s,
1040
- "remote" => v[:remote], "window_tokens" => v[:window_tokens]).compact
1058
+ "remote" => v[:remote], "window_tokens" => v[:window_tokens],
1059
+ "llm_context_strategy" => v[:llm_context_strategy]&.map(&:to_s),
1060
+ "llm_context_apply" => v[:llm_context_apply]&.to_s).compact
1041
1061
  end
1042
1062
  # Disabled hosts travel as just that, so a worker refuses "box:x"
1043
1063
  # the way its parent does instead of sending it to the default host.
@@ -1129,6 +1149,12 @@ module Samagotchi
1129
1149
  result[key][:thinking] = thinking if thinking
1130
1150
  window = window_tokens(v.key?("window_tokens") ? v["window_tokens"] : v[:window_tokens], "models: #{key}")
1131
1151
  result[key][:window_tokens] = window if window
1152
+ layers = LLMContextStrategy.parse(v.key?(LLMContextStrategy::KEY) ? v[LLMContextStrategy::KEY] : v[:llm_context_strategy],
1153
+ "models: #{key}")
1154
+ result[key][:llm_context_strategy] = layers if layers
1155
+ apply = LLMContextStrategy.parse_apply(v.key?(LLMContextStrategy::APPLY_KEY) ? v[LLMContextStrategy::APPLY_KEY] : v[:llm_context_apply],
1156
+ "models: #{key}")
1157
+ result[key][:llm_context_apply] = apply if apply
1132
1158
  end
1133
1159
  rescue StandardError
1134
1160
  {}
@@ -64,9 +64,13 @@ module Samagotchi
64
64
  # (a note that arrived before the first turn, a turn note left by a
65
65
  # failed first turn) stays and the prompt goes before it.
66
66
  def with_system_head(messages, system_message)
67
- first = messages.first
68
- replace = first && fetch(first, :role).to_s == "system" && fetch(first, :kind).to_s.empty?
69
- [system_message] + (replace ? messages.drop(1) : messages)
67
+ [system_message] + (system_head?(messages.first) ? messages.drop(1) : messages)
68
+ end
69
+
70
+ # A system prompt at a conversation's head: a system message of no kind
71
+ # (#with_system_head replaces it).
72
+ def system_head?(message)
73
+ message.is_a?(Hash) && fetch(message, :role).to_s == "system" && fetch(message, :kind).to_s.empty?
70
74
  end
71
75
 
72
76
  def clock(created_at)
@@ -85,6 +85,7 @@ module Samagotchi
85
85
  @last_bucket = self.class.last_bucket(conversation)
86
86
  @server_usage = nil
87
87
  @counted = nil
88
+ @edited = false
88
89
  @display = nil
89
90
  @guidance = nil
90
91
  end
@@ -95,6 +96,12 @@ module Samagotchi
95
96
 
96
97
  def enabled? = @enabled
97
98
 
99
+ # The status line's value is in the top configured bucket (the apply
100
+ # rule's payoff applies staged edits there at once).
101
+ def top_bucket?
102
+ !@display.nil? && bucket_rank(@display[:bucket]) == @thresholds.size
103
+ end
104
+
98
105
  # Before a request: estimate the usage of a prompt of +prompt_chars+
99
106
  # against +window+ (this request's ContextWindow::Resolved) and update
100
107
  # the status line's value. A rise into a bucket that asks the model for
@@ -120,6 +127,14 @@ module Samagotchi
120
127
  { status: status_message(usage: usage, bucket: bucket), usage: usage, bucket: bucket, source: usage[:source] }
121
128
  end
122
129
 
130
+ # An LLM context edit was applied (LLMContextApply): the prompt is
131
+ # shorter than the one the server's count was for. Until the next
132
+ # count the estimate takes what the prompt lost off that count too,
133
+ # instead of holding it (a shorter prompt counts as trimmed otherwise).
134
+ def edited!
135
+ @edited = true
136
+ end
137
+
123
138
  # @return [Hash, nil] the model's line from the last #observe (once)
124
139
  def take_guidance
125
140
  line = @guidance
@@ -145,6 +160,7 @@ module Samagotchi
145
160
  return unless usage
146
161
 
147
162
  @counted = { chars: prompt_chars, image_tokens: image_tokens }
163
+ @edited = false
148
164
  value = display_for(used_tokens: usage[:total_tokens], window_tokens: usage[:context_window_tokens] || window&.tokens)
149
165
  @display = value if value
150
166
  end
@@ -172,7 +188,7 @@ module Samagotchi
172
188
 
173
189
  if server_usage && server_usage[:prompt_tokens]
174
190
  window_tokens = server_usage[:context_window_tokens] || window.tokens
175
- used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt_chars, image_tokens, counted)
191
+ used_tokens = [server_usage[:prompt_tokens] + appended_tokens(prompt_chars, image_tokens, counted), 0].max
176
192
  source = "server"
177
193
  else
178
194
  window_tokens = window.tokens
@@ -192,11 +208,13 @@ module Samagotchi
192
208
 
193
209
  private
194
210
 
195
- # The estimate for what the prompt added since the +counted+ one.
211
+ # The estimate for what the prompt added since the +counted+ one;
212
+ # after an edit (#edited!) what it lost too, as a negative.
196
213
  def appended_tokens(prompt_chars, image_tokens, counted)
197
214
  return 0 unless counted
198
215
 
199
216
  chars = prompt_chars - counted[:chars]
217
+ return -(-chars / @chars_per_token).ceil if @edited && chars.negative?
200
218
  return 0 unless chars.positive?
201
219
 
202
220
  (chars / @chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
@@ -1727,7 +1727,7 @@ module Samagotchi
1727
1727
  @turn_thinking = [thinking_level(thinking_target), thinking_target]
1728
1728
  announce_thinking_level(*@turn_thinking)
1729
1729
  LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil,
1730
- window_setting: nil)
1730
+ window_setting: nil, llm_context: nil)
1731
1731
  end
1732
1732
  private :turn_settings
1733
1733
 
@@ -1754,11 +1754,14 @@ module Samagotchi
1754
1754
  return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
1755
1755
 
1756
1756
  messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(target) })
1757
- prompt, images = @kernel.warmup_prompt(messages)
1757
+ # Under the next turn's strategy (a /model switch may have changed it);
1758
+ # the last turn's when it can't be resolved.
1759
+ prompt, images = @kernel.warmup_prompt(messages, llm_context: turn_llm_context || @kernel.turn_settings&.llm_context)
1758
1760
  return unless prompt
1759
1761
 
1760
- @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
1761
- slot: @kernel.last_slot, images: images)
1762
+ started = @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
1763
+ slot: @kernel.last_slot, images: images)
1764
+ @kernel.warmed_up! if started
1762
1765
  rescue StandardError => e
1763
1766
  Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
1764
1767
  end
@@ -1816,7 +1819,8 @@ module Samagotchi
1816
1819
  # The turn's settings, set once here. The chat loop dispatches tools
1817
1820
  # through the kernel without its #run: tag those dumps with this
1818
1821
  # turn's model, not the last native one.
1819
- @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting)
1822
+ @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting,
1823
+ llm_context: turn_llm_context)
1820
1824
 
1821
1825
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
1822
1826
  backend.complete(
@@ -2512,6 +2516,16 @@ module Samagotchi
2512
2516
  nil
2513
2517
  end
2514
2518
 
2519
+ # The effective model's LLM context strategy (models:/hosts:
2520
+ # llm_context_strategy, else llm_context.strategy), read per turn as
2521
+ # the window is.
2522
+ def turn_llm_context
2523
+ target = @host_registry.resolve(@effective_model_name)
2524
+ LLMContextStrategy.resolve(target, names: model_lookup_names(target))
2525
+ rescue StandardError
2526
+ nil
2527
+ end
2528
+
2515
2529
  # See #served_model. A report for another name (before a /model switch)
2516
2530
  # doesn't count. Without a target, no probe. A remote chat host has no
2517
2531
  # /props: nil until a turn.
@@ -149,11 +149,23 @@ module Samagotchi
149
149
  end
150
150
  return name unless whose
151
151
 
152
- Log.warn(:plugins, "plugin_acts_as_dropped", tool: entry.name, acts_as: name,
153
- msg: "#{entry.name} targets: acts_as #{name} dropped: it is #{whose}")
152
+ if first_drop?(registry, entry.name, name)
153
+ Log.warn(:plugins, "plugin_acts_as_dropped", tool: entry.name, acts_as: name,
154
+ msg: "#{entry.name} targets: acts_as #{name} dropped: it is #{whose}")
155
+ end
154
156
  nil
155
157
  end
156
158
 
159
+ # The drops logged, per registry (a session's): each tool's acts_as
160
+ # once, not at every call (targets run several times a call).
161
+ DROPS_LOGGED = ObjectSpace::WeakKeyMap.new
162
+ DROPS_LOCK = Mutex.new
163
+
164
+ def self.first_drop?(registry, tool, acts_as)
165
+ DROPS_LOCK.synchronize { !(DROPS_LOGGED[registry] ||= Set.new).add?([tool, acts_as]).nil? }
166
+ end
167
+ private_class_method :first_drop?
168
+
157
169
  # The longest label: the question takes from targets:.
158
170
  LABEL_CHARS = 80
159
171
 
@@ -36,10 +36,12 @@ module Samagotchi
36
36
  # sampling: the configured request parameters (SamplingSettings), nil when unset;
37
37
  # thinking: the configured level (Thinking), nil when unset;
38
38
  # remote: the configured true/false (#remote?), nil when unset;
39
- # window_tokens: the configured context window (ContextWindow.setting), nil when unset.
39
+ # window_tokens: the configured context window (ContextWindow.setting), nil when unset;
40
+ # llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
41
+ # llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset.
40
42
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
41
43
  :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
42
- keyword_init: true) do
44
+ :llm_context_strategy, :llm_context_apply, keyword_init: true) do
43
45
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
44
46
  # raw-prompt loop.
45
47
  def chat? = api == :openai
@@ -99,7 +101,8 @@ module Samagotchi
99
101
  api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
100
102
  profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
101
103
  vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
102
- remote: cfg[:remote], window_tokens: cfg[:window_tokens])
104
+ remote: cfg[:remote], window_tokens: cfg[:window_tokens],
105
+ llm_context_strategy: cfg[:llm_context_strategy], llm_context_apply: cfg[:llm_context_apply])
103
106
  entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
104
107
  first_token_timeout: entry.first_token_limit, name: entry.name,
105
108
  api_key_env: entry.api_key_env, env: env)