pi-fold 4.2.8 → 4.3.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.
package/README.md CHANGED
@@ -2,158 +2,94 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/pi-fold)](https://www.npmjs.com/package/pi-fold) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22142456.svg)](https://doi.org/10.5281/zenodo.22142456)
4
4
 
5
- **Rotate stale text out of the context window without losing it, and spend the prefix cache once per commit instead of continuously.** The oldest part of a session collapses into short briefs while the exact originals stay on disk, one tool call away. Folding is automatic and needs no agent participation. The shipped shape is deterministic briefs paired with a durable memory store, which is the cheapest configuration measured that kept recall intact.
5
+ **Long sessions without forgetting.** pi-fold rotates stale context out of the window and into short briefs, while the exact originals stay on disk one tool call away. It runs automatically, keeps your prefix cache warm, and never throws anything away.
6
6
 
7
7
  ![pi-fold folding a session in place: marks outline completed spans as the session works, and the marks commit together at a fold event](https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-demo.gif)
8
8
 
9
- *Marks outline spans as they complete and move nothing; the next commit applies every standing mark in one rewrite. Expanding a brief brings the exact messages back byte for byte. Illustrative animation drawn on the 2.x surface, where the agent laid the marks; since 3.0 the runtime lays them.*
10
-
11
9
  ## Install
12
10
 
13
11
  ```sh
14
12
  pi install npm:pi-fold
15
13
  ```
16
14
 
17
- Node 22 or later and Pi 0.83 or later. Pi 1.0 is supported and requires Node 22.19 or later. MIT licensed. Pi supplies the extension's runtime packages; they are declared as host-provided peers, not private dependencies.
15
+ Requires Node 22+ and Pi 0.83+ (Pi 1.0 needs Node 22.19+). MIT licensed.
18
16
 
19
- Folding does not require a memory store, but every best result measured came from pairing it with one. The campaigns used [pi-canon](https://github.com/shaneconner/canon), installed separately. The two began as a single working setup and were split into separate extensions for modularity, and the experiments are what established that they work better as complements than either does alone. Only that pairing has been measured. Any durable long-term memory store is expected to compose the same way, though that is a suspicion rather than a result.
17
+ For the best results, pair it with a long-term memory store such as [pi-canon](https://github.com/shaneconner/canon). Folding keeps the window lean; the store keeps what the session learned. Together they were the strongest setup in every benchmark.
20
18
 
21
- ## What it does
19
+ ## Why fold instead of compact
22
20
 
23
- Compaction replaces the transcript with a summary and discards the originals. Folding replaces stale spans with briefs and keeps the originals retrievable.
21
+ Compaction replaces the transcript with a summary and discards the originals. Folding replaces old spans with briefs and keeps the originals retrievable, so nothing is ever lost.
24
22
 
25
23
  <picture>
26
24
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-vs-compaction-dark.svg">
27
25
  <img alt="Compaction turns the transcript into a summary and discards the originals, so the session starts over. pi-fold turns it into a brief and keeps the exact originals on disk, one peek or expand away, so the session keeps going." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-vs-compaction-light.svg">
28
26
  </picture>
29
27
 
30
- A fold takes a contiguous span of session entries and replaces it, in the window only, with a short brief. The entries stay in Pi's session file byte for byte; the fold finds them by Pi's own entry ids and records each one's SHA-256 hash when it folds. The brief carries a handle, the handle resolves to the originals, and expansion restores them as Pi holds them. If an entry's bytes no longer match the recorded hash, the fold still stands and the change is logged as a `context.drift` event.
28
+ On a 64-stage coding assignment over the curl codebase, followed by a 16-question recall test about the whole session:
29
+
30
+ | Setup | Recall | Cost | Wall clock |
31
+ | --- | ---: | ---: | ---: |
32
+ | Compaction | 4 / 16 | $151 | 6.9h |
33
+ | Compaction + memory store | 14 / 16 | $202 | 9.0h |
34
+ | **pi-fold + memory store** | **14 / 16** | **$101** | **5.2h** |
35
+
36
+ Same recall, half the cost, and hours faster. The savings come from the cache: folding batches its rewrites so the provider replays the cached prefix instead of rereading the session from scratch, and roughly 95% of input in the pi-fold run was served from cache.
31
37
 
32
- The package registers one tool, `pi_fold_context`, with nine actions over the transcript: `status`, `peek`, `brief`, `expand`, `refold`, `pin`, `unpin`, `reboundary` and `unmark`. A session that never calls the tool still folds; it degrades into lossless hierarchical compaction.
38
+ Full write-ups, data and logs are on Zenodo ([design](https://doi.org/10.5281/zenodo.21856873), [working memory](https://doi.org/10.5281/zenodo.21980746), [retrieval](https://doi.org/10.5281/zenodo.22142454), [rotation and retention](https://doi.org/10.5281/zenodo.22142456)), with friendlier versions on [Medium](https://medium.com/@shane.conner/a-compaction-summary-is-one-record-doing-a-stores-job-3f46212ef059) and [shaneconner.com](https://shaneconner.com/projects/pi-fold/).
33
39
 
34
- ### The fold bar
40
+ ## What you get
35
41
 
36
- In an interactive session one compact, full-height bar sits directly above Pi's footer.
42
+ - **Automatic folding.** Completed work is marked as it finishes and folded when the window fills up. The agent doesn't have to do anything.
43
+ - **Lossless recall.** Every brief links back to the original messages, verified by hash. The agent can `peek` at them or `expand` them back in place.
44
+ - **A cache-friendly cadence.** Marks cost nothing. All of them land together in one commit, so the cache is spent once per commit instead of on every change.
45
+ - **A clean chat.** Folded spans collapse into a single muted row in the chat, so your screen matches what the model actually sees.
46
+ - **The fold bar.** A compact usage bar above Pi's footer shows how full the window is and what's in it, from deeply folded to still raw.
37
47
 
38
48
  <picture>
39
49
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-dark.svg">
40
- <img alt="A single full-height usage bar. Batlow colours group consolidated folds, span folds, tool folds, marked content, raw context and pinned content in that order. The suffix shows percentage, commit point, and fold, mark, and pin counts." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-light.svg">
50
+ <img alt="A single full-height usage bar. Colours group consolidated folds, span folds, tool folds, marked content, raw context and pinned content in that order. The suffix shows percentage, commit point, and fold, mark, and pin counts." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-light.svg">
41
51
  </picture>
42
52
 
43
- The forty-column bar uses full-height half-blocks for eighty steps of fill, reading Pi's `getContextUsage()` directly whenever the session moves, on the same key Pi's footer re-reads on (session, leaf entry, entry count, model), so a repaint never pays for a fresh reading. Its percentage uses Pi's full context window, the same scale as Pi's footer, including Pi's estimate of messages since the last provider response. Within that fill, colours show the estimated visible composition, grouped by distance from compression: **Consolidated Folds, Span Folds, Tool Folds, Marked, Raw, Pinned**. The first three are compressed now, deepest first; marked content folds at the next commit; raw content is eligible later; pinned content is held against every commit until released. The six shades, from consolidated to pinned, are read off a colour map, [Crameri's Batlow](https://www.fabiocrameri.ch/colourmaps/) end to end by default, so the bar reads as a gradient from most compressed to most settled. `/fold-settings` offers Batlow, viridis, plasma, magma and cividis, and where on the chosen map the two ends sit, so a slice of a map or a reversed one is a keypress away; the bar repaints on each step. Whatever the choice, each shade's lightness is moved away from the background, hue held, until it clears 4.5:1 on dark and light terminals, and label colours match the bar. Each nonempty category keeps at least one half-cell when the measured fill has room; remaining width follows relative mass. This visibility floor makes small compressed categories visible, so individual category widths are not token-exact. At very low occupancy, the largest shares take the available space. There are no individual fold edges, scores, or height differences.
44
-
45
- The suffix reads `34% · commit at 55% · N Folds (X Cons., Y Span, Z Tool, X Mark, Y Pin)`, naming only the kinds with something to count, in the bar's order; a fresh session reads `0 Folds`. `N Folds` counts visible compressed folds; `Mark` counts pending marks and `Pin` counts protected entries, not additional compressed folds. A tool fold is lossless compression of tool-result context, not a count of tool calls. `/fold-status` retains the detailed token and nesting figures. Its `Budget` row and the editor's `Budget` header report the separate serving budget and the tokens remaining before a commit.
46
-
47
- Two dotted marks stand in the band's edge cells: one where a commit aims to land and one where automatic folding is triggered. `minTarget` and `maxTarget` remain shares of the serving budget; the marks and displayed commit point are converted to Pi's full-window scale, so the trigger does not move. With a 272,000-token window and the default 255,616-token serving budget, the 80% trigger appears at about 75% of Pi's window. The usage label rounds down to a whole percentage, matching pi-cc's footer; bar fill and folding decisions keep the unrounded value. The displayed commit point rounds to the nearest whole percentage. Each is drawn over the fill's own colour in a deeper tone of that same hue, so the fill continues behind the mark; on the empty track they stand in muted ink. Neither takes a colour of its own or a warning colour. When Pi reports unknown usage, including immediately after native compaction, the row says `not measured yet`; no saved count or local estimate fills the gap. After a fold commit it identifies the old reading until the provider measures again. Crossing the target is routine runtime work, not a user action item. On reload, saved session context supplies the fold counts and composition without a new model message; the next real context event replaces that read-only view. Unavailable composition is stated as `mapping`; if folding stops, `FOLDING STOPPED` replaces the bar. Non-truecolor terminals use the theme's own colours.
48
-
49
- ### The chat
50
-
51
- The chat shows the model's window. Pi paints its chat from every session entry and only native compaction clears it, so without this a long folded session would keep its whole transcript on screen. Each fold the model holds collapsed appears as one muted row, `▸ consolidation · 59 entries · <brief>`, in place of the messages it covers. When the model expands a fold, its messages return where they were. The view follows the model one way: it changes when a commit, an expansion or a refold changes what the model sees, or when Pi repaints the chat, and nothing done in the chat changes the model's context. `/fold-editor` remains the place to read inside a fold.
52
-
53
- ## Results
54
-
55
- One 64-stage assignment over the curl C repository, run as a single agentic turn. Same model, same 251,520-token serving budget, every run. After all 64 stages landed, one withheld message asked sixteen questions about material from across the whole session.
53
+ ## Commands
56
54
 
57
- | Condition | Correct of 16 | Wrong | Cost | Fresh input | Cached input | Wall clock |
58
- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
59
- | Compaction | 4 | 3 | $151.24 | 7.64M | 160.2M | 6.9h |
60
- | Compaction + memory store | 14 | 0 | $201.70 | 9.65M | 231.7M | 9.0h |
61
- | pi-fold, deterministic briefs | 11.3 avg | 3.5 avg | $93.65 | 5.74M | 91.2M | 4.6h |
62
- | **pi-fold + memory store** | **14** | **0** | **$100.85** | **5.57M** | **102.2M** | **5.2h** |
63
- | pi-fold, agent-written briefs | 3 | 9 | $111.96 | 7.12M | 108.8M | 5.4h |
55
+ | Command | What it does |
56
+ | --- | --- |
57
+ | `/fold-status` | Detailed token and fold figures |
58
+ | `/fold` | Commit every pending mark now |
59
+ | `/fold-editor` | Interactive map of the window, for reading inside folds |
60
+ | `/fold-settings` | Edit options live; saved to `~/.config/pi-fold/settings.json` |
64
61
 
65
- *Fresh input is what the provider had to read for the first time; cached input is what it replayed. The deterministic-briefs row averages four runs that scored 14, 14, 9 and 8.*
66
-
67
- Compare the two store-paired rows. Both scored 14 correct with none wrong, and folding cost half. That gap is request count rather than price: compaction needed roughly twice the provider requests for the same work, plus summarizer calls that never show up in its own ledger.
68
-
69
- Compaction alone abstained nine times and was wrong three, so its dominant failure was declining rather than inventing, but it did invent. And the fold's deterministic runs are bimodal rather than average: two answered 14 with nothing wrong, two lost old material, and cost did not predict which. The store was tried once on each arm and both came back with nothing wrong, which is suggestive, not a rate.
70
-
71
- Agent-written briefs scored worst of anything measured, which is why that invitation ships off.
72
-
73
- Four papers with figure sources, redacted per-request ledgers and campaign logs are on Zenodo: [design and trace evaluation](https://doi.org/10.5281/zenodo.21856873), [working memory under context shedding](https://doi.org/10.5281/zenodo.21980746), [ephemeral retrieval](https://doi.org/10.5281/zenodo.22142454), and [rotation and retention](https://doi.org/10.5281/zenodo.22142456), which is the campaign above. Narrative versions on [Medium](https://medium.com/@shane.conner/a-compaction-summary-is-one-record-doing-a-stores-job-3f46212ef059), interactive versions at [shaneconner.com](https://shaneconner.com/projects/pi-fold/), and the full chronology in [the experiment log](https://github.com/shaneconner/fold/blob/main/docs/fold_vs_compaction/experiment-log.md).
74
-
75
- ## Limits
76
-
77
- - **Native compaction is strong and is the right default for most sessions.** It needs no installation and no tool surface. This is an alternative for workflows where the numbers above matter, not a replacement.
78
- - **The benchmark is narrow by construction.** It was built to reproduce specific things compaction handles badly, one to four runs per condition, and it says nothing about production behavior.
79
- - **Folding is not a memory store.** It rotates transcript source out of the window; durable placement of what a session learned is a separate job, and the pairing is where the measured value sat.
62
+ The agent gets one tool, `pi_fold_context`, with actions to `status`, `peek`, `expand`, `refold`, `pin` and `unpin` content, plus `brief`, `reboundary` and `unmark` for curating pending folds. It's all optional; a session that never calls the tool still folds.
80
63
 
81
64
  ## Configuration
82
65
 
83
- `registerPiFold(pi)` works with no arguments, and the defaults are the configuration the campaign above actually ran: deterministic briefs, the invitation off, the tool-call diet at half the window, and a 0.80/0.20 band. Nothing here needs tuning.
84
-
85
- The one thing worth adding is a long-term memory store. Rotation and retention are different jobs: folding gets stale material out of the window, a store keeps what the session learned. A handoff summary tries to do both in one record, written once under time pressure, bounded by the window it has to fit in. Folding is the rotation half and only that half.
66
+ The defaults are the benchmarked setup, so there's nothing you need to tune.
86
67
 
87
- Seven options, and every other name is refused.
88
-
89
- | Option | Default | Effect |
68
+ | Option | Default | What it controls |
90
69
  | --- | --- | --- |
91
- | `thresholds` | `{ maxTarget: 0.80, minTarget: 0.20, consolidateAfter: 10, minFoldChars: 8000 }` | The band, set whole or not at all. The first two are proportions of the serving budget. `consolidateAfter` is how many visible roots a parent is owed. `minFoldChars` is the size a fold has to reach to be worth making, and the floor a gap between folds has to clear to stay raw. Validated atomically; an impossible policy is refused by name rather than clamped. |
92
- | `providerInputBudget` | derived from Pi's context window | The tokens this deployment may actually put in a request, already net of any output reservation when supplied. Without it, folding retains its default headroom of 16,384 tokens, capped at 10% of the window. This is a folding allowance, not a measured provider output reservation or a read of Pi's per-model compaction settings. Thresholds use this serving budget; the fold bar uses Pi's full-window scale. |
93
- | `blacklistAutoFoldTools` | `new Set()` | The exception list. Every completed tool batch is foldable without an agent mark; name a tool here and its results stay raw. |
94
- | `toolFoldThreshold` | `0.50` | The tool-call diet: a share in `[0, 1)` naming the oldest fraction of the window whose tool results are clipped in view. `0` turns it off. |
95
- | `preCommitNotice` | `false` | The notice switch. False is the shape that won the campaign above: no carrier reaches the projection and every fold ships the runtime's own words. `true` states the window's status to the agent once per approach to a commit: where the commit fires, how far away it is, what is staged, what that frees, what is pinned and what the pin costs, with the verbs listed and no argument made for using any of them. |
96
- | `noticeLeadShare` | `0.10` | How far below the commit trigger that notice speaks, as a share of the serving budget. A distance rather than a point, so it follows a band that moves. Read only when `preCommitNotice` is on. |
97
- | `palette` | `{ map: "batlow", start: 0, end: 1 }` | The fold bar's colours, and nothing about folding. `map` is one of `batlow`, `viridis`, `plasma`, `magma`, `cividis`; `start` and `end` are positions on it in `[0, 1]` for the most compressed and the pinned shade, which may not coincide and may run backwards. Set whole or not at all. |
98
-
99
- Four commands ship for the human: `/fold-status`, `/fold` (commit every staged mark now), `/fold-editor` (the interactive map of the window) and `/fold-settings` (edit the options above; each change is saved to `~/.config/pi-fold/settings.json` and takes effect in the running session).
70
+ | `thresholds` | `{ maxTarget: 0.80, minTarget: 0.20, consolidateAfter: 10, minFoldChars: 8000 }` | When a commit fires (`maxTarget`), how far it folds down (`minTarget`), when folds consolidate, and the smallest span worth folding |
71
+ | `providerInputBudget` | from Pi's context window | The token budget folding works within |
72
+ | `blacklistAutoFoldTools` | `new Set()` | Tools whose results should never be auto-folded |
73
+ | `toolFoldThreshold` | `0.50` | Oldest share of the window where tool results are clipped in view (`0` turns it off) |
74
+ | `preCommitNotice` | `false` | Tell the agent when a commit is approaching |
75
+ | `noticeLeadShare` | `0.10` | How early that notice arrives |
76
+ | `palette` | `{ map: "batlow", start: 0, end: 1 }` | Fold bar colours: `batlow`, `viridis`, `plasma`, `magma` or `cividis` |
100
77
 
101
78
  ## How it works
102
79
 
103
- ### The cache invariant
80
+ **Prefix caches are positional.** A provider replays the longest byte-identical prefix of a prompt, so changing one early byte invalidates everything after it. pi-fold only rewrites the window at a commit; everything else is a pure append.
104
81
 
105
- **A provider prefix cache is positional.** It replays the longest byte-identical prefix of the prompt, so one changed byte at offset K discards everything cached after K. The cost of a mutation has nothing to do with its size.
106
-
107
- **So the runtime may move a byte in the window only at a moment it is already rewriting the window**, which is a commit or a fold. Everything else is a pure append, and an appended byte is never later altered, shortened, removed or repositioned. Never mutate the window, and never show something and then take it back. The second half is the one that costs features: anything that displays state and then updates it in place is out, because updating in place is a mid-prefix write with better manners.
108
-
109
- ### Marks and commits
82
+ **Marks, then commits.** As each tool batch or chapter finishes, the runtime marks it as a fold candidate. A mark lives outside the window, so it changes nothing and costs nothing. When the window reaches `maxTarget`, a single commit folds the stalest marks into briefs until it's back down to `minTarget`, leaving recent work raw.
110
83
 
111
84
  <picture>
112
85
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/shaneconner/fold/main/media/marks-and-commits-dark.svg">
113
86
  <img alt="A completed unit becomes a pending mark that leaves the window and its cached prefix untouched. Marks repeat for free below maxTarget; at maxTarget one commit applies every standing mark in a single rewrite. From the brief, peek appends verified history without thinking blocks and expand restores the original messages in place." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/marks-and-commits-light.svg">
114
87
  </picture>
115
88
 
116
- A mark moves nothing. The moment a tool batch or chapter closes, the runtime cuts it into a pending mark, up to eight cuts per pass, stalest first. The mark lives in durable state outside the window, so the projection stays byte-identical and the cached prefix survives. Marks accumulate for free until one commit applies all of them in a single rewrite.
117
-
118
- What the agent owns is the words. `brief` writes its own sentence onto a pending fold for free, because that brief reaches the window only when the commit writes the placeholder for the first time. A supplied brief is kept verbatim; an unbriefed fold commits with the deterministic brief the runtime writes from the span itself, and the provenance records which happened. Curation stays correctable until the commit: `reboundary` re-cuts a span, `unmark` withdraws a pending mark, and a pending brief can be rewritten any number of times at no cost. A standing fold's brief is never rewritten, because that rewrites the projection at its placeholder.
119
-
120
- ### The band
121
-
122
- A commit fires on one condition: occupancy reaching `maxTarget` of the serving budget. It applies marks stalest first and stops at `minTarget`, so the newest events stay raw and a mark past the bound waits for the next epoch. Below `maxTarget` the window is quiet, because a commit is an epoch transition that risks the whole prefix cache and the right cadence is the fewest commits that keep the window inside its budget.
123
-
124
- ```
125
- 272,000 Pi's full context window
126
- 255,616 default serving budget, after folding headroom
127
- ─────────────────────────────────────────────────────
128
- ~250k peak: inflow continues while the epoch computes
129
- 204,493 maxTarget 0.80 ← about 75% on Pi's scale
130
- ⋮ marks accumulate, free, quiet
131
- 51,123 minTarget 0.20 ← about 19% on Pi's scale
132
- ─────────────────────────────────────────────────────
133
- ```
134
-
135
- Every commit re-reads roughly `minTarget` of the budget uncached, so a shallower cut buys less runway for nearly the same bill: the rewrite tax is `minTarget / (maxTarget - minTarget)`, 0.33 at 0.80/0.20 against 1.0 at 0.80/0.40.
89
+ **Peek and expand.** `peek` appends a verified, plain-text view of a fold's originals (without thinking blocks or provider metadata) for one turn, then steps aside. `expand` restores the original messages in place until the fold is refolded.
136
90
 
137
- ### Peek and expand
138
-
139
- `peek` verifies a fold's original source against its SHA-256 references, then returns a history view as a tool result at any depth, with the ancestors still collapsed. Assistant thinking blocks and their opaque signatures are omitted before slicing; visible text, tool calls and tool results stay verbatim. It appends and never edits the prefix, so checking what a fold holds costs nothing a commit has to pay for. It takes `offset` and `bytes` within that view for a bounded slice and reads any child fold by id, so a large fold has a narrow read. The result reports `sourceFormat: "messages-without-thinking"` and `omittedThinkingBlocks`. Pi's stored transcript and exact `expand` restoration remain unchanged.
140
-
141
- A peek is ephemeral by default: the bytes ride the window until the model's next message, then their place holds a one-line placeholder and the reply that used them is the surviving trace. Peeking again is lossless and costs one append. The default follows what the agent chose: on the run measured for it, six of seven peeks asked for ephemeral at first exposure. `expand` is the commitment, restoring the source in place until the fold is refolded. Looking is not the same as taking.
142
-
143
- Anthropic documents [reasoning-extraction refusals](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#keep-reasoning-in-thinking-blocks). Omitting native thinking from history reads avoids replaying it as ordinary text, but does not guarantee that a provider will accept a request. Existing tool results can still contain previously printed transcript excerpts. A durable peek stays in later requests, and reloading makes an ephemeral peek durable. Fold does not automatically retry content-filter refusals or remove arbitrary transcript text.
144
-
145
- ### Clipping, pins and the compaction path
146
-
147
- Tool results get a second, lighter treatment at the same commit. Results in the oldest `toolFoldThreshold` share of the projected window, half of it by default, are clipped in view: the identifying head stays, the rest becomes a marker naming the entry id that peeks it back, and the stored bytes are untouched, so a later fold of that span still encodes the full result.
148
-
149
- Briefs are bounded at 2,000 characters each, so the visible index normally costs a low single-digit share of the window. Folds nest rather than partially overlap, and collapsed folds double as a browsable index: `status` with `detail: "tree"` lists every fold in transcript order with its depth and parent. `pin` holds entries raw through every fold and frees nothing, so pinned mass makes the rest of the window fold sooner; a pinned-share cap stops that from becoming a window nothing can reclaim.
150
-
151
- Pi's automatic compaction stays enabled underneath and is intercepted by kind. A threshold pass is cancelled, because the fold commit fires first. An overflow pass becomes an automated recovery: the session branches back to the last message before the error, and the retried pass folds and commits before transmitting. Manual `/compact` passes through untouched as the escape hatch.
152
-
153
- Evidence ingestion writes read-only artifacts under the session directory's `pi-fold-evidence/` at mode `0444`, under a 512 MB session cap. It is always on: the artifacts are what an oversized result folds against, so a deployment that stopped writing them would keep its folds and lose what makes them lossless.
91
+ **Plays well with Pi.** Pi's own compaction stays enabled underneath. Threshold compaction is skipped because folding gets there first, overflow is recovered by folding and retrying, and manual `/compact` still works as an escape hatch.
154
92
 
155
93
  ## Prior work
156
94
 
157
- Two recent lines inform this one. Lossless hierarchical compaction, exemplified by LCM ([arXiv:2605.04050](https://arxiv.org/abs/2605.04050)) with a walkthrough at [losslesscontext.ai](https://www.losslesscontext.ai/), builds a summary DAG over older messages with lossless pointers to every original. Agentic context management, exemplified by [Letta](https://github.com/letta-ai/letta) and by [arXiv:2510.11967](https://arxiv.org/abs/2510.11967), gives the agent tools over its own memory. See also [arXiv:2607.00692](https://arxiv.org/abs/2607.00692).
158
-
159
- What this one adds is an efficiency result, arrived at by running the mechanism repeatedly and following the bill. Rotation is the part every approach here shares. The cost sits in where the rewrites land, because a prefix cache charges by position rather than by size, so each fold is staged as a mark that moves nothing and one commit spends the cache for all of them at once. The same campaigns settled the recommended shape: deterministic briefs paired with a durable memory store, which held recall at the lowest cost and wall clock measured. Agent control over boundaries and brief text stays available, and the invitation to use it ships off.
95
+ pi-fold builds on lossless hierarchical compaction ([LCM, arXiv:2605.04050](https://arxiv.org/abs/2605.04050)) and agentic context management ([Letta](https://github.com/letta-ai/letta), [arXiv:2510.11967](https://arxiv.org/abs/2510.11967)); see also [arXiv:2607.00692](https://arxiv.org/abs/2607.00692). Its contribution is the cache-aware commit: stage every fold as a free mark, then pay for the rewrite once.
@@ -11,7 +11,12 @@ import {
11
11
  } from "node:fs/promises";
12
12
  import { dirname, isAbsolute, join } from "node:path";
13
13
  import { denseOwnArrayValues, evidenceValue, isPlainRecord, ownDataProperty, stableStringify } from "./json.ts";
14
- import { DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX, entryTypeNamespace } from "./lib/policy.ts";
14
+ import { contextWindowFor } from "./lib/measurement.ts";
15
+ import {
16
+ DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX,
17
+ entryTypeNamespace,
18
+ oversizedToolResultBytes,
19
+ } from "./lib/policy.ts";
15
20
 
16
21
  export const EVIDENCE_REFERENCE_VERSION = 1;
17
22
  export const TOOL_RESULT_PROJECTION_BYTES = 16 * 1024;
@@ -641,6 +646,22 @@ export function registerEvidenceIngestion(pi, {
641
646
  mcpServer: mcpServerForTool(toolName, evidenceNamespace),
642
647
  });
643
648
  }
649
+
650
+ // Any other tool: raw unless the one result would crowd the window by itself.
651
+ if (utf8Bytes(text) > oversizedToolResultBytes(contextWindowFor(ctx))) {
652
+ const descriptor = await writeEvidenceArtifact({
653
+ sessionFile,
654
+ ownerKind: "pi-session",
655
+ ownerId: sessionId,
656
+ bytes: Buffer.from(text, "utf8"),
657
+ mediaType: "text/plain",
658
+ encoding: "utf-8",
659
+ source: { toolName, toolCallId, artifactKind: "oversized-tool-result" },
660
+ evidenceDirectory,
661
+ });
662
+ const truncation = boundedTruncation(ownDataProperty(details, "truncation"));
663
+ return projectEvidenceText(text, descriptor, "utf8-head", truncation ? { truncation } : undefined);
664
+ }
644
665
  });
645
666
  }
646
667
 
@@ -1434,25 +1434,16 @@ export function peekFoldSource(input: {
1434
1434
  sessionId: input.sessionId,
1435
1435
  projectEntry: input.projectEntry,
1436
1436
  });
1437
- // Verify the ORIGINAL messages first, then build the history read. Native
1438
- // reasoning and opaque signatures belong to the provider's assistant protocol,
1439
- // not to a JSON transcript replayed as ordinary tool-result text (issue #10).
1440
- // Only structural assistant blocks are removed: prose and tool output containing
1441
- // words such as "thinking" stay verbatim. Stored refs, hashes and expand are intact.
1442
- let omittedThinkingBlocks = 0;
1443
- const messages = storedMessages.map((message) => {
1444
- const content = ownValue(message, "content");
1445
- if (!isPlainRecord(message) || ownValue(message, "role") !== "assistant" ||
1446
- !Array.isArray(content)) return message;
1447
- const visible = content.filter((block) => {
1448
- const type = ownValue(block, "type");
1449
- if (type !== "thinking" && type !== "redacted_thinking") return true;
1450
- omittedThinkingBlocks += 1;
1451
- return false;
1452
- });
1453
- return visible.length === content.length ? message : { ...message, content: visible };
1454
- });
1455
- const source = stableStringify(messages);
1437
+ // Verify the ORIGINAL messages first, then build the history read: plain text,
1438
+ // not the stored message JSON (issues #10 and #12). A JSON array of assistant
1439
+ // messages carrying model, provider, usage and response ids reads like exported
1440
+ // model output, and native reasoning with its opaque signatures belongs to the
1441
+ // provider's protocol, not to an ordinary tool result. The text keeps every
1442
+ // visible word, tool call and tool result; stored refs, hashes and expand are intact.
1443
+ const rendered = renderHistoryText(storedMessages);
1444
+ const { omittedThinkingBlocks } = rendered;
1445
+ const messages = storedMessages;
1446
+ const source = rendered.text;
1456
1447
  const sourceBytes = bytes(source);
1457
1448
  const offset = Math.min(Math.max(0, input.offset ?? 0), sourceBytes);
1458
1449
  // The widest read anyone may ask for is the policy's own source cap, the same ceiling
@@ -1493,7 +1484,7 @@ export function peekFoldSource(input: {
1493
1484
  brief: foldBrief(fold, input.state),
1494
1485
  sourceCount: messages.length,
1495
1486
  sourceSha256: fold.sourceSha256,
1496
- sourceFormat: "messages-without-thinking",
1487
+ sourceFormat: PEEK_SOURCE_FORMAT,
1497
1488
  omittedThinkingBlocks,
1498
1489
  sourceBytes,
1499
1490
  offset,
@@ -1517,12 +1508,12 @@ export function peekFoldSource(input: {
1517
1508
  `next commit reclaims it behind a placeholder naming ${fold.id}; peek ${fold.id} again for the ` +
1518
1509
  "same verbatim bytes, or pin this result to keep the copy raw.",
1519
1510
  note: (truncated
1520
- ? `Bounded read: ${returnedBytes} of ${sourceBytes} ${omittedThinkingBlocks ? "source view" : "exact source"} bytes, ${view.omittedBytes} omitted ` +
1511
+ ? `Bounded read: ${returnedBytes} of ${sourceBytes} history-text bytes, ${view.omittedBytes} omitted ` +
1521
1512
  `from the middle. Widen with bytes, page with offset, or expand ${fold.id} to restore it in place.`
1522
1513
  : children.length
1523
- ? `Complete stored span, one level: raw entries ${omittedThinkingBlocks ? "with thinking blocks omitted" : "exactly"}, and ${children.length} child fold(s) still ` +
1514
+ ? `Complete stored span, one level: raw entries as plain text, and ${children.length} child fold(s) still ` +
1524
1515
  "placeheld. Peek a child id to read its own span; the fold stayed collapsed and no projection changed."
1525
- : `Complete ${omittedThinkingBlocks ? "source view" : "exact source"}; the fold stayed collapsed and no projection changed.`) +
1516
+ : "Complete history text; the fold stayed collapsed and no projection changed.") +
1526
1517
  (omittedThinkingBlocks
1527
1518
  ? ` ${omittedThinkingBlocks} thinking blocks omitted from this read, including their opaque ` +
1528
1519
  "signatures. The original messages remain unchanged for exact in-place restoration."
@@ -1547,6 +1538,90 @@ export function peekFoldSource(input: {
1547
1538
  };
1548
1539
  }
1549
1540
 
1541
+ export const PEEK_SOURCE_FORMAT = "history-text";
1542
+
1543
+ // Provider bookkeeping a history read never needs. It is what makes a message array
1544
+ // look like an exported model transcript, and none of it is work content.
1545
+ const HISTORY_METADATA_KEYS = new Set([
1546
+ "role", "content", "provider", "model", "api", "usage", "responseId", "stopReason",
1547
+ "timestamp", "toolCallId", "toolName", "isError",
1548
+ ]);
1549
+
1550
+ function historyBlock(block: unknown, counter: { thinking: number }): string | null {
1551
+ if (typeof block === "string") return block;
1552
+ const type = ownValue(block, "type");
1553
+ if (type === "thinking" || type === "redacted_thinking") { counter.thinking += 1; return null; }
1554
+ if (type === "text") {
1555
+ const text = ownValue(block, "text");
1556
+ return typeof text === "string" ? text : "";
1557
+ }
1558
+ if (type === "toolCall") {
1559
+ const name = ownValue(block, "name");
1560
+ const id = ownValue(block, "id");
1561
+ const args = ownValue(block, "arguments");
1562
+ return `[tool call: ${typeof name === "string" ? name : "unknown"}` +
1563
+ `${typeof id === "string" ? ` (${id})` : ""}]\n` +
1564
+ (args === undefined ? "" : stableStringify(args));
1565
+ }
1566
+ if (type === "image") {
1567
+ const mime = ownValue(block, "mimeType");
1568
+ return `[image${typeof mime === "string" ? `: ${mime}` : ""}; expand restores it]`;
1569
+ }
1570
+ return stableStringify(block);
1571
+ }
1572
+
1573
+ function historyContent(content: unknown, counter: { thinking: number }): string {
1574
+ if (content === undefined || content === null) return "";
1575
+ if (typeof content === "string") return content;
1576
+ if (!Array.isArray(content)) return stableStringify(content);
1577
+ return content.map((block) => historyBlock(block, counter))
1578
+ .filter((part): part is string => part !== null)
1579
+ .join("\n\n");
1580
+ }
1581
+
1582
+ // The peek view of a stored span: one labelled section per message, each holding the
1583
+ // visible text, tool calls with their arguments, and tool results verbatim. Thinking
1584
+ // blocks and provider metadata are left out; nested fold placeholders read as a line
1585
+ // with their brief and the id that peeks them. Deterministic, so offset paging is stable.
1586
+ export function renderHistoryText(messages: readonly unknown[]): {
1587
+ text: string;
1588
+ omittedThinkingBlocks: number;
1589
+ } {
1590
+ const counter = { thinking: 0 };
1591
+ const sections = messages.map((message) => {
1592
+ if (!isPlainRecord(message)) return `### entry\n${stableStringify(message)}`;
1593
+ if (ownValue(message, "placeholder") === "fold") {
1594
+ const id = String(ownValue(message, "id"));
1595
+ const children = Number(ownValue(message, "children") ?? 0);
1596
+ return `### folded ${String(ownValue(message, "kind"))} ${id}` +
1597
+ `${children ? `, ${children} child fold(s)` : ""}\n` +
1598
+ `${String(ownValue(message, "brief") ?? "")}\n` +
1599
+ `(peek it with {"action":"peek","id":"${id}"})`;
1600
+ }
1601
+ const role = messageRole(message) ?? "entry";
1602
+ let header: string;
1603
+ if (role === "toolResult") {
1604
+ const name = ownValue(message, "toolName");
1605
+ const id = ownValue(message, "toolCallId");
1606
+ header = `### tool result: ${typeof name === "string" ? name : "unknown"}` +
1607
+ `${typeof id === "string" ? ` (${id})` : ""}` +
1608
+ `${ownValue(message, "isError") === true ? " [error]" : ""}`;
1609
+ } else {
1610
+ header = `### ${role}`;
1611
+ }
1612
+ const body = historyContent(ownValue(message, "content"), counter);
1613
+ // Any other own field (a bash command, a summary, an error message) is content the
1614
+ // reader may need; only the bookkeeping above is dropped.
1615
+ const extras = Object.keys(message).filter((key) => !HISTORY_METADATA_KEYS.has(key))
1616
+ .sort().map((key) => {
1617
+ const value = (message as Record<string, unknown>)[key];
1618
+ return `${key}: ${typeof value === "string" ? value : stableStringify(value)}`;
1619
+ });
1620
+ return [header, ...extras, ...(body ? [body] : [])].join("\n");
1621
+ });
1622
+ return { text: sections.join("\n\n"), omittedThinkingBlocks: counter.thinking };
1623
+ }
1624
+
1550
1625
  export function headTailMarker(omittedBytes: number): string {
1551
1626
  return omittedBytes > 0 ? `\n... [${omittedBytes} exact source bytes omitted] ...\n` : "";
1552
1627
  }
@@ -302,6 +302,28 @@ export const ESTIMATED_BYTES_PER_TOKEN = 4;
302
302
  export const IMAGE_ESTIMATED_TOKENS = 1_600;
303
303
  export const ESTIMATED_PLACEHOLDER_OVERHEAD_BYTES = 240;
304
304
 
305
+ /**
306
+ * ONE TOOL RESULT MAY NOT FILL THE WINDOW BY ITSELF (issue #13, 2026-10-26).
307
+ *
308
+ * Folding rewrites the old end of the window and leaves the fresh end alone, so a single
309
+ * fresh tool result larger than the serving budget is out of its reach: the provider
310
+ * rejects the request, the rollback retries it with the same result inside, and every
311
+ * later prompt carries it again. The images in issue #13 are priced flat now, but the
312
+ * same megabyte returned as text by any tool other than read, bash or MCP still wedged
313
+ * the session. Above this share of the window a result is pinned as exact evidence and
314
+ * the window gets its head and a reference to the artifact, the way an oversized read
315
+ * already does. A share rather than a byte count, so a small window is protected
316
+ * without clipping results that a large one holds comfortably.
317
+ */
318
+ export const OVERSIZED_TOOL_RESULT_WINDOW_SHARE = 0.25;
319
+
320
+ export function oversizedToolResultBytes(window: number | null | undefined): number {
321
+ const resolved = typeof window === "number" && Number.isFinite(window) && window > 0
322
+ ? window
323
+ : DEFAULT_CONTEXT_WINDOW;
324
+ return Math.floor(resolved * OVERSIZED_TOOL_RESULT_WINDOW_SHARE) * ESTIMATED_BYTES_PER_TOKEN;
325
+ }
326
+
305
327
  export const PEEK_MIN_SLICE_BYTES = 1_024;
306
328
 
307
329
  export const STATUS_DIET_INDEX_ROWS = 5;
@@ -29,7 +29,7 @@ export function buildActiveContextTool(input: {
29
29
  handler: ToolHandler;
30
30
  }) {
31
31
  const peekGuidance = input.allowedActions.includes("peek")
32
- ? ` Peek is the point read: it returns one fold's index view -- nested folds' briefs seated shallowest first inside the byte budget with the omitted rows counted, plus the head and tail of that fold's own span with the omitted middle stated -- for any fold id, ancestors still collapsed, changing nothing. It serves ONE level: raw entries come back with thinking blocks and their signatures omitted, all other content unchanged, folds nested inside stay placeheld, and peeking a child id you see there returns that child's own bytes, so anything folded is one hop away at any depth. It returns at most ${input.defaultPeekBytes} bytes unless you widen it with bytes, and it is EPHEMERAL by default: the bytes ride only until your next message, your reply then the surviving trace, so extract what your current task needs from them in that reply. Peeking again is lossless and costs one append, so a read you turn out to need twice is cheap; pass ephemeral false when you need the bytes standing across several turns. Expand is the durable in-place restoration.`
32
+ ? ` Peek is the point read: it returns one fold's index view -- nested folds' briefs seated shallowest first inside the byte budget with the omitted rows counted, plus the head and tail of that fold's own span with the omitted middle stated -- for any fold id, ancestors still collapsed, changing nothing. It serves ONE level: raw entries come back as plain-text history -- visible text, tool calls with their arguments and tool results verbatim, with thinking blocks and provider metadata left out -- folds nested inside stay placeheld, and peeking a child id you see there returns that child's own bytes, so anything folded is one hop away at any depth. It returns at most ${input.defaultPeekBytes} bytes unless you widen it with bytes, and it is EPHEMERAL by default: the bytes ride only until your next message, your reply then the surviving trace, so extract what your current task needs from them in that reply. Peeking again is lossless and costs one append, so a read you turn out to need twice is cheap; pass ephemeral false when you need the bytes standing across several turns. Expand is the durable in-place restoration.`
33
33
  : "";
34
34
  const correctionGuidance = input.allowedActions.includes("reboundary")
35
35
  ? " Curation is correctable: reboundary with ids re-cuts a span into exactly one fold, which merges adjacent folds when the span covers several and splits one when the span sits inside it. Reboundary with a single id returns that fold's span to raw. A brief is written when you mark the span; a standing fold's brief is not rewritten, because rewriting it rewrites the projection at that fold's placeholder and costs the whole prefix cache from there on."
@@ -80,7 +80,7 @@ export function buildActiveContextTool(input: {
80
80
  bytes: {
81
81
  type: "integer",
82
82
  minimum: input.minPeekSliceBytes,
83
- description: `Peek only: bytes of the source view to return, widening or narrowing the ${input.defaultPeekBytes}-byte default. Thinking blocks and their signatures are omitted before slicing.`,
83
+ description: `Peek only: bytes of the plain-text history view to return, widening or narrowing the ${input.defaultPeekBytes}-byte default.`,
84
84
  },
85
85
  limit: { type: "integer", minimum: 1, maximum: 100 },
86
86
  detail: { type: "string", enum: [...input.statusDetails] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.2.8",
3
+ "version": "4.3.0",
4
4
  "description": "Lossless context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
5
5
  "keywords": [
6
6
  "pi-package",