pi-fold 4.2.7 → 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 +45 -107
- package/extensions/evidence.js +22 -1
- package/extensions/lib/folding.ts +105 -6
- package/extensions/lib/policy.ts +22 -0
- package/extensions/lib/tool-surface.ts +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,156 +2,94 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/pi-fold) [](https://doi.org/10.5281/zenodo.22142456)
|
|
4
4
|
|
|
5
|
-
**
|
|
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
|

|
|
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
|
|
15
|
+
Requires Node 22+ and Pi 0.83+ (Pi 1.0 needs Node 22.19+). MIT licensed.
|
|
18
16
|
|
|
19
|
-
|
|
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
|
-
##
|
|
19
|
+
## Why fold instead of compact
|
|
22
20
|
|
|
23
|
-
Compaction replaces the transcript with a summary and discards the originals. Folding replaces
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
## What you get
|
|
35
41
|
|
|
36
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
|
58
|
-
| --- |
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }` |
|
|
92
|
-
| `providerInputBudget` |
|
|
93
|
-
| `blacklistAutoFoldTools` | `new Set()` |
|
|
94
|
-
| `toolFoldThreshold` | `0.50` |
|
|
95
|
-
| `preCommitNotice` | `false` |
|
|
96
|
-
| `noticeLeadShare` | `0.10` | How
|
|
97
|
-
| `palette` | `{ map: "batlow", start: 0, end: 1 }` |
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
<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
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
`peek` returns a fold's exact SHA-256-verified source as a tool result at any depth, with the ancestors still collapsed. 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` for a bounded slice and reads any child fold by id, so a large fold has a narrow read.
|
|
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
|
-
### Clipping, pins and the compaction path
|
|
144
|
-
|
|
145
|
-
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.
|
|
146
|
-
|
|
147
|
-
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.
|
|
148
|
-
|
|
149
|
-
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.
|
|
150
|
-
|
|
151
|
-
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.
|
|
152
92
|
|
|
153
93
|
## Prior work
|
|
154
94
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
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.
|
package/extensions/evidence.js
CHANGED
|
@@ -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 {
|
|
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
|
|
|
@@ -1427,14 +1427,23 @@ export function peekFoldSource(input: {
|
|
|
1427
1427
|
}): Record<string, unknown> {
|
|
1428
1428
|
const fold = input.state.folds.find((item) => item.id === input.foldId);
|
|
1429
1429
|
if (!fold) throw new Error(`Unknown active-context fold ${input.foldId}`);
|
|
1430
|
-
const
|
|
1430
|
+
const storedMessages = foldStoredSpan({
|
|
1431
1431
|
foldId: input.foldId,
|
|
1432
1432
|
state: input.state,
|
|
1433
1433
|
entries: input.entries,
|
|
1434
1434
|
sessionId: input.sessionId,
|
|
1435
1435
|
projectEntry: input.projectEntry,
|
|
1436
1436
|
});
|
|
1437
|
-
|
|
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;
|
|
1438
1447
|
const sourceBytes = bytes(source);
|
|
1439
1448
|
const offset = Math.min(Math.max(0, input.offset ?? 0), sourceBytes);
|
|
1440
1449
|
// The widest read anyone may ask for is the policy's own source cap, the same ceiling
|
|
@@ -1475,6 +1484,8 @@ export function peekFoldSource(input: {
|
|
|
1475
1484
|
brief: foldBrief(fold, input.state),
|
|
1476
1485
|
sourceCount: messages.length,
|
|
1477
1486
|
sourceSha256: fold.sourceSha256,
|
|
1487
|
+
sourceFormat: PEEK_SOURCE_FORMAT,
|
|
1488
|
+
omittedThinkingBlocks,
|
|
1478
1489
|
sourceBytes,
|
|
1479
1490
|
offset,
|
|
1480
1491
|
returnedBytes,
|
|
@@ -1493,16 +1504,20 @@ export function peekFoldSource(input: {
|
|
|
1493
1504
|
? { wider: { action: "peek", id: fold.id, bytes: Math.min(sourceBytes, ACTIVE_CONTEXT_POLICY.maxSourceChars) } }
|
|
1494
1505
|
: {}),
|
|
1495
1506
|
lifetime: "these bytes stay in the window exactly as returned, like any other tool result, and " +
|
|
1496
|
-
`nothing rewrites this result in place. It is a COPY of fold ${fold.id}'s
|
|
1507
|
+
`nothing rewrites this result in place. It is a COPY of fold ${fold.id}'s source view, so the ` +
|
|
1497
1508
|
`next commit reclaims it behind a placeholder naming ${fold.id}; peek ${fold.id} again for the ` +
|
|
1498
1509
|
"same verbatim bytes, or pin this result to keep the copy raw.",
|
|
1499
1510
|
note: (truncated
|
|
1500
|
-
? `Bounded read: ${returnedBytes} of ${sourceBytes}
|
|
1511
|
+
? `Bounded read: ${returnedBytes} of ${sourceBytes} history-text bytes, ${view.omittedBytes} omitted ` +
|
|
1501
1512
|
`from the middle. Widen with bytes, page with offset, or expand ${fold.id} to restore it in place.`
|
|
1502
1513
|
: children.length
|
|
1503
|
-
? `Complete stored span, one level: raw entries
|
|
1514
|
+
? `Complete stored span, one level: raw entries as plain text, and ${children.length} child fold(s) still ` +
|
|
1504
1515
|
"placeheld. Peek a child id to read its own span; the fold stayed collapsed and no projection changed."
|
|
1505
|
-
: "Complete
|
|
1516
|
+
: "Complete history text; the fold stayed collapsed and no projection changed.") +
|
|
1517
|
+
(omittedThinkingBlocks
|
|
1518
|
+
? ` ${omittedThinkingBlocks} thinking blocks omitted from this read, including their opaque ` +
|
|
1519
|
+
"signatures. The original messages remain unchanged for exact in-place restoration."
|
|
1520
|
+
: "") +
|
|
1506
1521
|
(catalog.omitted
|
|
1507
1522
|
? ` The nested index seats the shallowest ${index.length} of ${index.length + catalog.omitted} ` +
|
|
1508
1523
|
"descendant briefs inside the byte budget; the deeper rows are one hop away by peeking a child id, " +
|
|
@@ -1523,6 +1538,90 @@ export function peekFoldSource(input: {
|
|
|
1523
1538
|
};
|
|
1524
1539
|
}
|
|
1525
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
|
+
|
|
1526
1625
|
export function headTailMarker(omittedBytes: number): string {
|
|
1527
1626
|
return omittedBytes > 0 ? `\n... [${omittedBytes} exact source bytes omitted] ...\n` : "";
|
|
1528
1627
|
}
|
package/extensions/lib/policy.ts
CHANGED
|
@@ -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
|
|
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
|
|
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] },
|