Variable-resolution context: folds bulky stale tool output to disk behind always-visible stubs; hydrate() recovers detail on demand.

<img src="docs/img/origami-logo.png" alt="Origami — Curate Context, Not Compress" width="380">
Context folding for Claude Code — fold stale tool output to a link, hydrate it back byte-exact.
Your agent reads a 500-line file, reasons about it, and keeps working. Ten turns later the context fills up and Claude Code compacts it — that file becomes a sentence in a summary. The details are gone, and the model doesn't know they're gone.
Origami refuses that trade. When context needs to shrink, it folds the bulky tool result to disk and leaves a link in its place:
[origami fold-012 · Read result folded] src/auth.ts (480 lines):
[JWT validation](hydrate://fold-012#jwt),
[refresh flow](hydrate://fold-012#refresh),
[SECRET_ROTATION constant](hydrate://fold-012#rotation)
Nothing was summarized away. The exact bytes are one hydrate("fold-012") call from coming back. The conversation you and the model actually had is never rewritten. That's the whole idea:
Curate context, not compress it.
A stub is not a lossy summary — it's a retrieval handle. It's written by a small librarian model while it can still see the full content, as 2–4 concept-level links, so the agent knows exactly what's inside and exactly how to get it back. Compaction stops being amnesia and becomes an index.
hydrate returns the whole result; the link it followed is logged, groundwork for future prefetch.hydrate tells you so, handing you the original snapshot plus a pointer to re-read the current version. It never passes off stale bytes as current.Origami rides Claude Code's early-access function hooks ("Claude Mods"), so that flag must be on. Set it once for every session in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
Then add the marketplace and install:
claude plugin marketplace add dullfig/claude-plugins
claude plugin install origami@dullfig-plugins
…or interactively: /plugins → dullfig-plugins → origami. To try it from a checkout without installing, launch with --plugin-dir /path/to/origami.
If you install before setting the flag, origami still loads and tells you at session start instead of silently doing nothing — run /origami:enable once and it performs that one setup step for you.
Prerequisite: Claude Code ≥ 2.1.259.
turn ends ──► turn.complete: stale unpinned tool-result mass ≥ minFoldMass,
│ and grown ≥ 20% past the last skip?
│ no → nothing (window fill is irrelevant)
│ yes
▼
$.session.compact() (manual /compact and auto-compact
│ arrive at the same interceptor)
▼
session.compact hook
│ candidates = unpinned results older than foldAgeTurns
▼
Librarian (Haiku, full content visible)
│ keep / fold + stub per candidate
▼
Rebuilder → { messages } (verbatim text + stubs, pairs intact)
│ any failure anywhere → next(event) or {skip}
▼
Store: bodies → $.fs, index → $.store
│ ONLY once the reduction gate has passed — a skipped
│ sweep persists nothing and records its skip mass
▼
conversation continues ──► model calls hydrate(fold-012)
│ full content at tail; count++;
▼ logged; 2nd hydrate → pinned
eligible to refold on a later sweep
Because candidates age in at foldAgeTurns, accumulating mass sits near the tail; each sweep's cache invalidation extends only from the oldest new fold forward. There is no separate cadence — folding emerges entirely from the mass trigger.
The librarian writes each stub as anchor text — 2–4 concept-level concept links — while it can still see the content, not as a generic note written after the fact. hydrate always returns the whole fold regardless of which link (or none) motivated the call; the #slug fragment is logged as the anchor that pulled the model back, reserved for future range-hydration and prefetch work.
If a sweep's estimated reduction is too small to be worth the cache-cost, or anything in the pipeline fails (model error, storage error, malformed librarian output), Origami falls back to Claude Code's own built-in compaction — the session is never left worse off than stock behavior.
A fold body is a snapshot of a file at the moment it was read. If the file is edited afterward, hydrate still returns that snapshot — because it's what the model actually saw — but adds a warning that the source has changed since, and points at re-reading the live file. Two signals drive it, and they agree by construction:
Edit/Write to a folded path flags the fold immediately (and the next sweep marker names it once), andThe result: the write path was always safe (Claude Code's Edit matches the live file), and now the read/reasoning path is honest too — you won't quietly reason from a stale snapshot.
Set via .claude-plugin/plugin.json's userConfig (or your Claude Code plugin configuration UI):
| Option | Default | Meaning |
|---|---|---|
foldAgeTurns | 3 | Results younger than this are never candidates |
minFoldMass | 20000 | Candidate token mass that triggers a sweep |
workingSetBudget | 100000 | Absolute live-context tokens; above it, sweeps turn aggressive |
preserveRecentTurns | 3 | Turns the rebuilder never touches |
minReductionRatio | 0.15 | Below this, a sweep is skipped (and the trigger goes quiet until the candidate mass grows 20% past what it skipped on, so a skip cannot livelock into a librarian call every turn) |
pinAfterHydrations | 2 | Hysteresis threshold |
librarianModel | haiku | Passed to $.model.complete |
Token counts are estimated without a tokenizer (chars/4 heuristic, calibrated against $.session.usage()'s breakdown when available).
When live context exceeds workingSetBudget, the next sweep runs aggressive: foldAgeTurns and preserveRecentTurns both relax to 1 (only the current turn stays protected), and the librarian is told the working set is over budget so it folds everything not clearly needed.
| Tool | Description |
|---|---|
hydrate(fold_id, anchor?) | Expands a fold to its full stored content. Call it before re-running a tool whose result was folded — a re-run may not reproduce it (files change, tests flake, output drifts). fold_id appears in [origami fold-…] stubs and in hydrate:// links; pass a link's #fragment as anchor when a specific link motivated the call. The second hydrate of a given fold pins it: the next sweep restores it inline and it stays open until unpinned. |
unpin(fold_id) | Releases a pinned fold so it becomes fold-eligible again. Use it when pinned content stops earning its place in context — the recovery path for a pin that hysteresis triggered prematurely. |
Registered tool names are mcp__origami__hydrate and mcp__origami__unpin; the model sees and calls them by these names. Every hydrate and unpin call is appended to the event log — the future training set for anticipatory prefetch.
All Origami state lives under .claude/origami/ in the project:
.claude/origami/
├── folds/
│ ├── fold-001.md # a small header, a delimiter, then the exact bytes
│ ├── fold-002.md
│ └── ...
└── origami.log # append-only JSONL: sweep, hydrate, and unpin records
A fold body file is # <id> · <tool> <input>, the delimiter line <<<origami:body>>>, and then the original tool result verbatim. The header lives on the far side of the delimiter so a restore puts back the exact bytes and an unpin → refold cycle cannot nest a second header.
The fold index itself (id, stub, state, origin turn, size estimate, hydration count, staleness) lives in $.store, not on disk as a separate file. Both $.fs and $.store persist across --resume and process restarts.
$.store is plugin-global — one JSON file under the user's Claude Code configuration directory, shared by every project this plugin runs in — while $.fs relative paths resolve under the session's working directory. Origami therefore prefixes every store key with origami@<hash>/, where the hash is a short FNV-1a of await $.session.root(), so one project's fold index, sequence counter and flags never reach another's.
A fold entry is folded, pinned or evicted. Each sweep reconciles the index against reality: a folded entry whose stub no longer appears anywhere in the transcript becomes evicted — it stops counting toward the banner, stops matching the missed-hydrate observer, and is never restored. Its body stays on disk, so hydrate still serves it and says so.
On the first successful sweep, Origami prepends a synthetic message pair to the very top of the context: a user-role message starting [ORIGAMI v… that states the plugin is in beta, explains how to follow hydrate:// links and call hydrate before re-running a tool or concluding content was never seen, and assigns a "BETA DUTY" to explicitly flag anything that looks like a stub/reality mismatch — followed by a synthetic assistant acknowledgment.
This banner is static: it carries rules only, never a fold count, and is written once. Every later sweep leaves it untouched — the same message objects, handles intact — as long as its text still matches the current version's wording, which is a deliberate prompt-cache win (index 0/1 would otherwise be rewritten, and the cache busted from token zero, on every sweep). If the wording no longer matches (a version bump), it is rebuilt exactly once.
The mutable status lives inline instead: every successful sweep appends a small synthetic marker at the tail — [origami sweep report: …] — which states plainly that it is origami's in-place rebuild standing in for the stock compaction summary (so a fresh agent post-compaction knows nothing was lost), reports what was folded/restored and how many folds are active, and names any fold that just went stale. Each marker is historically true at its position forever, giving the model an explicit timeline boundary between "content visible" and "content stubbed."
Neither pair is part of the real conversation, and every synthetic acknowledgment is explicitly provenance-labeled, ending [synthetic acknowledgment inserted by origami], so it is never mistaken for something the model actually said.
Origami sweeps ride the same compaction machinery as Claude Code's built-in compaction, so any classic PostCompact hooks you have configured will fire after every Origami sweep, not just after a stock lossy compaction. This matters because classic PostCompact hooks are typically written for lossy compaction — e.g., a re-grounding banner telling the model to re-read memory after a summary — advice that is noise after an Origami sweep, which keeps conversation text verbatim and folded content fully recoverable via hydrate.
Observed (Claude Code 2.1.278): an automatic (mass-triggered) sweep reaches classic hooks with trigger value plugin; a sweep initiated by /compact reaches them as manual. Gate classic hooks that assume lossy compaction to auto (and manual if you never type /compact expecting a fold sweep) — plugin is always a lossless origami sweep.
Warning — stock compaction destroys fold context. If built-in summarization runs over a folded conversation (e.g. /compact when origami finds nothing to fold and passes through, or auto-compact at the window limit), the summary replaces the banner and every [origami fold-…] stub: fold bodies remain safe on disk, but the model loses its hydrate links. Avoid manual /compact when the banner shows active folds and the context is already lean.
Note for headless use: because $.session.compact() is unavailable under -p, automatic mass-triggered sweeps never fire there (the trigger detects the condition, logs, and falls through cleanly). A /compact prompt runs the full fold sweep in headless sessions.
Origami is beta, and function hooks ("Claude Mods") are themselves early access — the $ API surface may change between Claude Code releases without notice. types/claude-code.d.ts is a pinned, generated snapshot of that surface (this copy was generated by /plugin-types under Claude Code 2.1.278, recorded in the file's first line). After upgrading Claude Code, regenerate the types before trusting the surface again:
MSYS_NO_PATHCONV=1 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "/plugin-types"
The output lands in .claude/types/; copy the generated file over types/claude-code.d.ts.
MIT
hooks/origami.ts 573 lines1import type { Register, EngineInterface, SessionCompactInput, SessionCompactResult, SessionMessage } from 'claude-code';
2import { selectCandidates, rebuild, bannerText, stripBanner, applyBanner, foldIdsPresent, foldIndexMessage, sweepMarkerPair, contentHash, BANNER_PREFIX, BANNER_ACK, type Candidate, type FoldDecision, type RestoreDecision, candidateMass, estimateTokens } from './rebuild';
3import { runLibrarian, type CompleteFn } from './librarian';
4import { newFoldId, putFold, getFold, setFold, allFolds, getKeeps, putKeep, dropKeep, appendLog, inputKeyOf, type FoldEntry, type KeepEntry, type StoreIO } from './store';
5
6// $.store is PLUGIN-GLOBAL: one JSON file under the user's Claude Code config dir
7// (types/claude-code.d.ts ~:2823-2830), shared by every project this plugin runs in.
8// $.fs relative paths are NOT — they resolve under the session's working directory
9// (~:2700-2702), so fold BODIES are already project-local while the index would leak.
10// $.session.root() (~:2364-2369, "the session's project root, absolute") is the stable
11// per-project discriminator; a short hash of it prefixes every store key.
12function projectHash(root: string): string {
13 let h = 0x811c9dc5; // FNV-1a, 32-bit
14 for (let i = 0; i < root.length; i++) {
15 h ^= root.charCodeAt(i);
16 h = Math.imul(h, 0x01000193) >>> 0;
17 }
18 return h.toString(36);
19}
20
21// SAME-FILE closures over $: the engine validator follows $ only into functions
22// declared in this file, never across an import, so these adapters must live here
23// and be built at each hook call site before crossing into store.ts/librarian.ts.
24// The project prefix is baked INTO the closures, so store.ts never sees it: keys go
25// in prefixed and come back out of storeKeys() stripped, keeping the `origami:fold:`
26// filtering in store.ts unchanged.
27export async function storeIO($: EngineInterface): Promise<StoreIO> {
28 let prefix = 'origami@0/';
29 try {
30 const root = await $.session.root();
31 if (typeof root === 'string' && root !== '') prefix = `origami@${projectHash(root)}/`;
32 } catch { /* a host without session.root keeps the single shared namespace */ }
33 return {
34 fsRead: async (path) => String(await $.fs.read(path)),
35 fsWrite: (path, text) => $.fs.write(path, text),
36 fsExists: (path) => $.fs.exists(path),
37 storeGet: (key) => $.store.get(prefix + key),
38 storeSet: (key, value) => $.store.set(prefix + key, value),
39 storeDelete: (key) => $.store.delete(prefix + key),
40 storeKeys: async () => (await $.store.keys())
41 .filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length)),
42 };
43}
44function completeWith($: EngineInterface): CompleteFn {
45 return (req) => $.model.complete(req);
46}
47
48// Keep in sync with .claude-plugin/plugin.json's "version".
49export const ORIGAMI_VERSION = '1.1.1';
50
51export type OrigamiConfig = {
52 foldAgeTurns: number;
53 minFoldMass: number;
54 workingSetBudget: number;
55 preserveRecentTurns: number;
56 minReductionRatio: number;
57 pinAfterHydrations: number;
58 librarianModel: string;
59};
60
61const DEFAULTS: OrigamiConfig = {
62 foldAgeTurns: 3,
63 minFoldMass: 20000,
64 workingSetBudget: 100000,
65 preserveRecentTurns: 3,
66 minReductionRatio: 0.15,
67 pinAfterHydrations: 2,
68 librarianModel: 'haiku',
69};
70
71export function readConfig(options: unknown): OrigamiConfig {
72 const o = (options ?? {}) as Partial<Record<keyof OrigamiConfig, unknown>>;
73 const num = (k: keyof OrigamiConfig) =>
74 typeof o[k] === 'number' && Number.isFinite(o[k] as number) ? (o[k] as number) : (DEFAULTS[k] as number);
75 return {
76 foldAgeTurns: num('foldAgeTurns'),
77 minFoldMass: num('minFoldMass'),
78 workingSetBudget: num('workingSetBudget'),
79 preserveRecentTurns: num('preserveRecentTurns'),
80 minReductionRatio: num('minReductionRatio'),
81 pinAfterHydrations: num('pinAfterHydrations'),
82 librarianModel: typeof o.librarianModel === 'string' ? o.librarianModel : DEFAULTS.librarianModel,
83 };
84}
85
86// A sweep that ends in a skip (reduction below threshold, librarian kept everything)
87// records the candidate mass it skipped on. The trigger then stays quiet until the
88// mass has meaningfully grown, so the same fruitless librarian call cannot repeat
89// every turn forever. Cleared on any successful sweep.
90const LAST_SKIP_MASS = 'origami:lastSkipMass';
91const SKIP_COOLDOWN_FACTOR = 1.2;
92const AGGRESSIVE = 'origami:aggressive';
93
94// A candidate is keep-remembered when a previous sweep's librarian ruled it `keep`
95// AND its content still hashes to what that verdict was made about. Shared by the
96// trigger (which must not fire on mass the sweep will not offer) and the sweep
97// itself (which must not re-offer it), so the two can never disagree.
98export function isKeepRemembered(keeps: ReadonlyMap<string, KeepEntry>, c: Candidate): boolean {
99 const k = keeps.get(c.toolUseId);
100 return k !== undefined && k.hash === contentHash(c.text);
101}
102
103export async function shouldSweep(
104 $: EngineInterface, cfg: OrigamiConfig, messagesArg?: readonly SessionMessage[],
105): Promise<{ sweep: boolean; aggressive: boolean }> {
106 const io = await storeIO($);
107 const messages = messagesArg ?? await $.session.messages();
108 // every live fold's original result is already folded (or pinned open) — its mass
109 // must never re-trigger a sweep, whichever transcript view messages() returns
110 const excluded = new Set((await allFolds(io)).filter(f => f.state !== 'evicted').map(f => f.toolUseId));
111 // F11: the same discount applies to the working-set figure. A raw transcript view
112 // still carrying already-folded results must not push the session into aggressive
113 // mode on mass that is, on disk, already reduced.
114 //
115 // ASYMMETRY, deliberate: `excluded` (live folds) is discounted from BOTH figures
116 // below, but keep-memory is discounted from the CANDIDATE MASS only and never from
117 // liveTokens. The two figures answer different questions. Candidate mass asks "is
118 // there work a sweep would actually do?" — and a non-aggressive sweep will not
119 // re-offer keep-remembered content, so counting it there makes the trigger fire
120 // forever on mass no sweep will ever fold (the livelock cousin of the skip-mass
121 // loop). liveTokens asks "how full is the context?" — and kept content is genuinely
122 // live, un-reduced, still-in-the-window text. Discounting it there would hide real
123 // budget pressure and suppress exactly the aggressive sweep that exists to REVERSE
124 // those earlier keeps.
125 const liveTokens = messages.reduce((s, m) => s + estimateTokens(m.text)
126 + (m.toolResults ?? []).reduce((a, r) => a + (excluded.has(r.tool_use_id) ? 0 : estimateTokens(r.text)), 0), 0);
127 const aggressive = liveTokens > cfg.workingSetBudget;
128 const keeps = await getKeeps(io);
129 const mass = candidateMass(
130 selectCandidates(messages, excluded, cfg, aggressive)
131 .filter(c => !c.text.startsWith('[origami fold-'))
132 // an aggressive sweep ignores keep-memory and re-offers everything, so the
133 // trigger must not discount it either
134 .filter(c => aggressive || !isKeepRemembered(keeps, c)),
135 );
136 const triggered = mass >= cfg.minFoldMass || (aggressive && mass > 0);
137 const lastSkipMass = Number((await io.storeGet(LAST_SKIP_MASS)) ?? 0);
138 const cooled = !(lastSkipMass > 0) || mass > lastSkipMass * SKIP_COOLDOWN_FACTOR;
139 return { sweep: triggered && cooled, aggressive };
140}
141
142// returns a result to answer with, or undefined = caller must pass through via next(e)
143export async function runSweep(
144 $: EngineInterface, cfg: OrigamiConfig, e: Pick<SessionCompactInput, 'trigger' | 'agentId' | 'messages'>,
145): Promise<SessionCompactResult | undefined> {
146 if (e.agentId) return undefined; // main thread only
147 // PRECOMPUTE: still passed through, DELIBERATELY, on evidence rather than caution.
148 // The caching semantics ARE clear — types/claude-code.d.ts :8523-8524 ("`precompute`
149 // is the one dispatch that installs nothing: its result is kept for the compaction
150 // that comes, if the conversation it ran over still leads") and :8443-8445 ("What the
151 // transcript becomes (kept, on `precompute`, for the compaction that comes)"). So a
152 // precompute answer is cached and later applied, not run immediately, and answering
153 // it would NOT double-run the librarian for a precompute that is used.
154 //
155 // What blocks it is the DISCARD path, which the same sentence declares: the result
156 // is kept only "if the conversation it ran over still leads". runSweep is not pure —
157 // it allocates fold ids, writes body files and persists `state: 'folded'` entries.
158 // On a discarded precompute those entries exist while their stubs never reached the
159 // transcript, and shouldSweep excludes every non-evicted fold's toolUseId from the
160 // candidate mass (see `excluded` above). The trigger would then stay quiet on mass
161 // that is still fully inline, and only a sweep's reconcile pass can evict the orphans
162 // — a sweep the suppressed trigger never fires. That is a livelock, not a cost.
163 // :8511-8517 (`SessionCompactSkipped`: "on `precompute` nothing is computed or kept")
164 // is the only declared escape, and it is exactly what this early return takes.
165 //
166 // Answering precompute safely needs deferred persistence (buffer the fold entries,
167 // commit them when the result is actually installed), and NOTHING in the declarations
168 // tells a hook whether its precomputed result was installed. Out of scope here.
169 if (e.trigger === 'precompute') return undefined;
170 // F10: what a stock compaction would destroy if origami passes this event through.
171 // Declared OUTSIDE the try so the catch path can apply the same guard: a sweep that
172 // throws after the index was read (librarian down, rebuild invariant) must not hand
173 // a folded context to the stock summarizer either. An exception BEFORE the index is
174 // read leaves this [] — origami then has no verified picture and passes through.
175 let liveFolds: FoldEntry[] = [];
176 const manualGuard = (): SessionCompactResult | undefined =>
177 e.trigger === 'manual' && liveFolds.length > 0
178 ? { skip: `origami: nothing to fold — ${liveFolds.length} live folds already active; stock compaction would destroy their stubs` }
179 : undefined;
180 // Hoisted OUTSIDE the try for the same reason as liveFolds: the catch path needs
181 // them too. `io` is normally built at the top of the try, but a throw can happen
182 // before that assignment runs (or the try itself could fail differently later), so
183 // it is declared here and assigned as soon as it exists. `sweepCandidateMass` is
184 // set once candidates are selected; if the librarian call (or anything after it)
185 // then throws, the catch block still has the mass to arm the skip-mass cooldown —
186 // without this, a failing sweep re-fires the trigger and re-pays a full librarian
187 // call every turn instead of backing off like the explicit skip outcomes do.
188 let io: StoreIO | undefined;
189 let sweepCandidateMass = 0;
190 try {
191 io = await storeIO($);
192 // v1.1 item 6 (banner split/idempotence): the banner is STATIC — written once,
193 // rules only. If index 0/1 already carry exactly today's banner text, keep those
194 // ORIGINAL message objects (same references, handles intact) at assembly time
195 // instead of rebuilding them; that is the prompt-cache win. Detected against the
196 // raw event messages, before stripping.
197 const currentBanner = bannerText(ORIGAMI_VERSION);
198 const bannerUnchanged = e.messages[0]?.role === 'user' && e.messages[0].text.startsWith(BANNER_PREFIX)
199 && e.messages[0].text === currentBanner
200 && e.messages[1]?.role === 'assistant' && e.messages[1].text === BANNER_ACK;
201 // strip any existing banner pair first: all subsequent logic (reconcile scan,
202 // candidate selection, restores, rebuild) runs on the stripped array, never on
203 // e.messages directly — this holds whether or not the banner is being kept,
204 // since rebuild() never touches the banner pair either way (no toolResults).
205 const messages = stripBanner(e.messages);
206 // --- lifecycle reconcile: a 'folded' entry whose stub no longer appears anywhere
207 // in the transcript is dead (unpin->refold replaced it, the stub was edited away,
208 // the turn was rewound). Left alone it inflates the banner count forever and feeds
209 // missed_hydrate false positives. Entries created BY this sweep are not yet in the
210 // store, so reconciling here — before any creation — can never evict them.
211 const stubIds = foldIdsPresent(messages);
212 for (const f of await allFolds(io)) {
213 if (f.state === 'folded' && !stubIds.has(f.id)) await setFold(io, { ...f, state: 'evicted' });
214 }
215 // Keep-memory lifecycle, same reconcile pass: an entry whose tool_use_id no longer
216 // appears anywhere in this transcript view is dead (the turn was rewound, the
217 // result was compacted away by something else) and would otherwise accumulate
218 // forever in a plugin-global store. Collecting the live ids is one cheap scan.
219 const liveToolUseIds = new Set<string>();
220 for (const m of messages) for (const r of m.toolResults ?? []) liveToolUseIds.add(r.tool_use_id);
221 const keeps = await getKeeps(io);
222 for (const id of [...keeps.keys()]) {
223 if (!liveToolUseIds.has(id)) { await dropKeep(io, id); keeps.delete(id); }
224 }
225 const folds = await allFolds(io);
226 // pinned folds stay open: once restored inline their big results must never
227 // become candidates again, so exclusion is by the toolUseId the entry recorded
228 const excluded = new Set(folds.filter(f => f.state === 'pinned').map(f => f.toolUseId));
229 liveFolds = folds.filter(f => f.state === 'folded' || f.state === 'pinned');
230 const aggressive = Boolean(await io.storeGet(AGGRESSIVE));
231 const candidates = selectCandidates(messages, excluded, cfg, aggressive)
232 .filter(c => !c.text.startsWith('[origami fold-')); // never re-fold a stub
233 // --- DELTA SWEEP: partition candidates against keep-memory ---
234 // A candidate the librarian already ruled `keep`, whose content still hashes the
235 // same, needs no new decision: it stays inline exactly as it is. Excluding it from
236 // the offer is the whole point — steady-state sweeps prefill only NEW mass instead
237 // of re-reading the entire kept working set every time.
238 //
239 // AGGRESSIVE OVERRIDE: an over-budget sweep must be able to REVERSE earlier keeps,
240 // so it ignores keep-memory entirely and re-offers everything.
241 const offered: Candidate[] = [];
242 for (const c of candidates) {
243 if (!aggressive && isKeepRemembered(keeps, c)) continue;
244 // a stale entry (same id, different content) no longer describes anything the
245 // librarian saw: drop it now and let the fresh decision below replace it
246 if (!aggressive && keeps.has(c.toolUseId)) await dropKeep(io, c.toolUseId);
247 offered.push(c);
248 }
249 sweepCandidateMass = candidateMass(offered);
250 // reopen pinned folds whose stubs still sit in history
251 const restores: RestoreDecision[] = [];
252 for (const f of folds.filter(f => f.state === 'pinned')) {
253 for (const m of messages) {
254 for (const r of m.toolResults ?? []) {
255 if (r.text.includes(`[origami ${f.id} `)) {
256 const stored = await getFold(io, f.id);
257 if (stored) restores.push({ toolUseId: r.tool_use_id, foldId: f.id, body: stored.body });
258 }
259 }
260 }
261 }
262 // Nothing OFFERED (not merely nothing selected): a sweep whose whole candidate set
263 // is keep-remembered has no question to ask and must not pay a librarian call.
264 if (offered.length === 0 && restores.length === 0) {
265 if (e.trigger === 'plugin') return { skip: 'origami: nothing to fold' };
266 return manualGuard(); // manual+live folds → skip; otherwise pass through
267 }
268 const lib = offered.length > 0
269 ? await runLibrarian(completeWith($), cfg.librarianModel, offered, aggressive)
270 : { decisions: [], defaulted: [], unknown: [], inputTokens: 0, outputTokens: 0 };
271 // Record the verdicts. Only an EXPLICIT keep — one the librarian actually judged —
272 // is remembered. A `defaulted` id was never judged at all: parseSweepReply falls
273 // back to 'keep' as a safety default for THIS sweep only (an omission, or a whole
274 // rejected batch — see runLibrarian), and memoizing it would turn that one-sweep
275 // fallback into a permanent suppression from every future non-aggressive sweep.
276 // Leaving no entry means the candidate is simply re-offered next sweep, giving the
277 // librarian another chance to judge it. A `fold` clears any entry, since the
278 // content is about to become a stub.
279 const defaultedIds = new Set(lib.defaulted);
280 for (const d of lib.decisions) {
281 const c = offered.find(x => x.toolUseId === d.toolUseId);
282 if (!c) continue;
283 if (d.action === 'keep') {
284 if (!defaultedIds.has(d.toolUseId)) await putKeep(io, d.toolUseId, contentHash(c.text));
285 } else if (keeps.has(d.toolUseId)) await dropKeep(io, d.toolUseId);
286 }
287 // Ids are allocated and bodies BUFFERED here; nothing is persisted until rebuild
288 // has cleared the reduction gate. Persisting first left orphan entries + body
289 // files behind on every skipped sweep, inflating the banner count and feeding
290 // missed_hydrate false positives.
291 const foldDecisions: FoldDecision[] = [];
292 const pending: { entry: FoldEntry; body: string; header: string }[] = [];
293 for (const d of lib.decisions) {
294 if (d.action !== 'fold') continue;
295 const c = candidates.find(x => x.toolUseId === d.toolUseId)!;
296 const id = await newFoldId(io);
297 const stub = d.stub.replaceAll('hydrate://FOLD#', `hydrate://${id}#`); // librarian writes the FOLD token; the real id lands here
298 // Staleness fingerprint: for a file-backed fold, hash the RAW file bytes now so a
299 // later hydrate can tell whether the file changed since. We hash the raw file (not
300 // the fold body, which is the line-numbered Read RESULT) because only the raw file
301 // is reproducible at hydrate time — like-for-like. Unreadable here ⇒ leave unset.
302 let originHash: string | undefined;
303 if (typeof c.input.file_path === 'string') {
304 try { originHash = contentHash(await io.fsRead(c.input.file_path)); }
305 catch { /* not readable at fold time — this fold is simply not staleness-tracked */ }
306 }
307 const entry: FoldEntry = { id, stub, state: 'folded', tool: c.tool, toolUseId: c.toolUseId, inputKey: inputKeyOf(c.input), originAge: c.ageTurns, sizeTokens: c.sizeTokens, hydrations: 0, ...(originHash ? { originHash } : {}) };
308 // body is c.text VERBATIM; the header rides in its own slot so restores are
309 // byte-exact and refolding a restored body cannot nest a second header
310 pending.push({ entry, body: c.text, header: `# ${id} · ${c.tool} ${JSON.stringify(c.input)}` });
311 foldDecisions.push({ toolUseId: d.toolUseId, foldId: id, stub });
312 }
313 if (foldDecisions.length === 0 && restores.length === 0) {
314 await io.storeSet(LAST_SKIP_MASS, candidateMass(offered)); // the offer, not the selection: shouldSweep discounts keeps the same way
315 if (e.trigger === 'plugin') return { skip: 'origami: librarian kept everything' };
316 return manualGuard();
317 }
318 const outcome = rebuild(messages, foldDecisions, restores, cfg, aggressive);
319 if (outcome.kind === 'insufficient') {
320 await io.storeSet(LAST_SKIP_MASS, candidateMass(offered)); // the offer, not the selection: shouldSweep discounts keeps the same way
321 if (e.trigger === 'plugin') return { skip: `origami: reduction ${outcome.ratio.toFixed(2)} below threshold` };
322 return manualGuard();
323 }
324 for (const p of pending) await putFold(io, p.entry, p.body, p.header);
325 await io.storeSet(LAST_SKIP_MASS, 0);
326 await io.storeSet(AGGRESSIVE, false);
327 const all = await allFolds(io);
328 const activeFolds = all.filter(f => f.state === 'folded').length;
329 // Newly-stale folds get NAMED once (announce-once via staleAnnounced), but `stale`
330 // itself PERSISTS so hydrate keeps agreeing with this announcement — clearing it here
331 // was the v1.1.1 legibility bug (marker said stale, a later hydrate of an un-hashed
332 // fold stayed silent). The flag clears only on self-heal (hydrate re-hash matches) or
333 // supersession. A fresh edit resets staleAnnounced, so a re-edit re-announces.
334 const staleIds = all.filter(f => f.stale && !f.staleAnnounced && f.state !== 'evicted').map(f => f.id);
335 for (const id of staleIds) {
336 const f = all.find(x => x.id === id)!;
337 await setFold(io, { ...f, staleAnnounced: true });
338 }
339 // Reuse the original banner pair (same object references) when its text is
340 // already current; otherwise rebuild it (migration bust: old-style count-bearing
341 // banner, or a version bump). Either way, append this sweep's marker pair at the
342 // tail — the mutable status that used to live in the banner's count.
343 const marker = sweepMarkerPair({
344 foldedIds: foldDecisions.map(d => d.foldId),
345 restoredIds: restores.map(r => r.foldId),
346 activeFolds,
347 staleIds,
348 });
349 const finalMessages = bannerUnchanged
350 ? [e.messages[0], e.messages[1], ...outcome.messages, ...marker]
351 : [...applyBanner(outcome.messages, currentBanner), ...marker];
352 await appendLog(io, {
353 event: 'sweep', trigger: e.trigger, aggressive,
354 tokensBefore: outcome.tokensBefore, tokensAfter: outcome.tokensAfter,
355 librarianInputTokens: lib.inputTokens, librarianOutputTokens: lib.outputTokens,
356 ...(lib.defaulted.length > 0 ? { librarianDefaulted: lib.defaulted.length } : {}),
357 ...(lib.unknown.length > 0 ? { librarianUnknown: lib.unknown.length } : {}),
358 foldsCreated: foldDecisions.length, restores: restores.length, foldsActive: activeFolds,
359 });
360 return { messages: finalMessages, tokensBefore: outcome.tokensBefore, tokensAfter: outcome.tokensAfter };
361 } catch (err) {
362 $.ui.log(`origami sweep failed, falling back: ${err instanceof Error ? err.message : String(err)}`);
363 // Arm the same skip-mass cooldown a clean skip would: without it, a sweep that
364 // fails (librarian down, transient model error) re-fires the trigger and re-pays
365 // a full librarian call on every subsequent turn instead of backing off. Guarded
366 // in its own try/catch so a store failure here can never mask the original error.
367 if (io && sweepCandidateMass > 0) {
368 try { await io.storeSet(LAST_SKIP_MASS, sweepCandidateMass); } catch { /* best-effort */ }
369 }
370 if (e.trigger === 'plugin') return { skip: 'origami: sweep failed' };
371 return manualGuard(); // a failed sweep is still no reason to let stock wipe live stubs
372 }
373}
374
375// A tool.call hook must ANSWER, never throw: a throw escapes the tool and takes the
376// turn with it. Both handlers are therefore total — every failure path (missing body
377// file, store or fs refusal) comes back as instructive text the model can act on.
378export async function handleHydrate($: EngineInterface, cfg: OrigamiConfig, foldId: string, anchor?: string): Promise<string> {
379 try {
380 const io = await storeIO($);
381 const found = await getFold(io, foldId);
382 if (!found) return `Unknown fold id "${foldId}". Fold ids look like fold-001 and appear in [origami fold-…] stubs in the conversation.`;
383 const hydrations = found.entry.hydrations + 1;
384 const pinned = hydrations >= cfg.pinAfterHydrations && found.entry.state === 'folded';
385 // Staleness (computed BEFORE the state write so a self-heal folds into one setFold).
386 // The body returned is ALWAYS the snapshot the model saw; staleness only adds a
387 // warning that routes to the live path. Annotate, never falsify. Two detectors, and
388 // hydrate warns on their UNION so it can never contradict the sweep marker (which
389 // fires off the writer-hook flag): (1) the persistent `stale` flag set by an in-loop
390 // Edit — the only signal for un-hashed / pre-1.1.0 folds; (2) an originHash mismatch,
391 // which also catches OUT-of-loop edits the writer-hook never saw. When a fold carries
392 // originHash and the live file now matches it (edited then reverted, or a
393 // conservative flag), we self-heal: clear the flag so marker and hydrate stay agreed.
394 const editedMsg = `⚠️ origami: this fold is STALE — ${found.entry.inputKey} was edited after it was captured. The content below is the snapshot you originally read; Read ${found.entry.inputKey} for its current state before acting on it.`;
395 let staleLine = '';
396 let clearStale = false;
397 if (found.entry.originHash) {
398 try {
399 const current = contentHash(await io.fsRead(found.entry.inputKey));
400 if (current !== found.entry.originHash) staleLine = editedMsg;
401 else if (found.entry.stale) clearStale = true; // live file matches capture ⇒ no longer stale
402 } catch {
403 staleLine = `⚠️ origami: ${found.entry.inputKey} can no longer be read (moved or deleted); the content below is your original snapshot, not the current file.`;
404 }
405 } else if (found.entry.stale) {
406 staleLine = editedMsg; // un-hashed fold: the writer-hook flag is authoritative
407 }
408 await setFold(io, {
409 ...found.entry, hydrations,
410 state: pinned ? 'pinned' : found.entry.state,
411 ...(clearStale ? { stale: false, staleAnnounced: false } : {}),
412 });
413 await appendLog(io, { event: 'hydrate', foldId, hydrations, originAge: found.entry.originAge, ...(anchor ? { anchor } : {}) });
414 const pinNote = pinned
415 ? `\n\n[origami: ${foldId} has now been hydrated ${hydrations}× and is pinned — it will be restored inline and stay open. Call unpin("${foldId}") if that stops being useful.]`
416 : found.entry.state === 'evicted'
417 ? `\n\n[origami: ${foldId} is evicted — its stub is no longer in the conversation, so it will not be restored inline. The content above is still the full stored body.]`
418 : '';
419 const staleBlock = staleLine ? `\n\n[${staleLine}]` : '';
420 // F9: staleness and pin notices ride at BOTH ends. A large body may be preview-
421 // truncated (head only) in the model's view, so a tail-only notice can be lost.
422 const leadBits: string[] = [];
423 if (staleLine) leadBits.push(`[${staleLine}]`);
424 if (pinned) leadBits.push(pinNote.trimStart());
425 const lead = leadBits.length ? leadBits.join('\n\n') + '\n\n' : '';
426 return lead + (found.header ? found.header + '\n\n' : '') + found.body + staleBlock + pinNote;
427 } catch (err) {
428 return `origami could not hydrate "${foldId}": ${err instanceof Error ? err.message : String(err)}. The stored body may have been removed; re-run the original tool if you need the content.`;
429 }
430}
431
432export async function handleUnpin($: EngineInterface, foldId: string): Promise<string> {
433 try {
434 const io = await storeIO($);
435 const found = await getFold(io, foldId);
436 if (!found) return `Unknown fold id "${foldId}".`;
437 await setFold(io, { ...found.entry, state: 'folded', hydrations: 0 });
438 await appendLog(io, { event: 'unpin', foldId });
439 return `${foldId} unpinned: it is fold-eligible again and will refold on the next sweep.`;
440 } catch (err) {
441 return `origami could not unpin "${foldId}": ${err instanceof Error ? err.message : String(err)}.`;
442 }
443}
444
445// The degradation health metric (spec addendum): a tool call whose target matches
446// a live fold means the model re-ran a tool instead of hydrating. Observe-only.
447export async function observeMissedHydrate(
448 $: EngineInterface, call: { tool: string; input: Record<string, unknown> },
449): Promise<void> {
450 try {
451 if (call.tool !== 'Read') return; // v1 watches the highest-signal case only
452 const io = await storeIO($);
453 const key = inputKeyOf(call.input);
454 const match = (await allFolds(io)).find(f => f.state === 'folded' && f.tool === 'Read' && f.inputKey === key);
455 if (match) await appendLog(io, { event: 'missed_hydrate', foldId: match.id, tool: call.tool, inputKey: key });
456 } catch { /* observation must never break a tool call */ }
457}
458
459// The writer-hook (v1.1 staleness): an Edit/Write to a path with live file-backed
460// fold(s) means those snapshots no longer match disk. Flip the append-only `stale`
461// flag so the next sweep marker can announce it proactively. This is a HINT only —
462// hydrate re-hashes the live file authoritatively, so a miss here (a subagent edit,
463// an out-of-loop change) is still caught at the point of use. Observe-only: it reads
464// and writes the fold index but never blocks, rewrites, or throws into the tool call.
465export async function observeWrite(
466 $: EngineInterface, call: { tool: string; input: Record<string, unknown> },
467): Promise<void> {
468 try {
469 if (typeof call.input.file_path !== 'string') return; // Edit/Write/MultiEdit carry file_path
470 const io = await storeIO($);
471 const key = inputKeyOf(call.input);
472 for (const f of await allFolds(io)) {
473 if (f.inputKey === key && f.state !== 'evicted' && !f.stale) {
474 // stale is persistent; staleAnnounced:false so the next sweep marker names it once
475 await setFold(io, { ...f, stale: true, staleAnnounced: false });
476 await appendLog(io, { event: 'fold_stale', foldId: f.id, tool: call.tool, inputKey: key });
477 }
478 }
479 } catch { /* observation must never break a tool call */ }
480}
481
482// The standing session-start notice: independent of the sweep-time banner, which
483// only exists once a first sweep has folded something. It kills the pre-first-sweep
484// confusion ("no banner — is the hook misplaced?") and pre-arms the model against
485// discovering mid-session that its context changed shape.
486export const SESSION_NOTICE = '[origami is active in this session (BETA). Your context is a RENDERING that origami may rewrite between turns: bulky older tool results can be folded away to disk and replaced with [origami fold-…] stubs, and can return. Once folds exist, a status banner appears at the very top of the context. Nothing is ever lost — folded content is recoverable via the hydrate tool. If the context seems to have changed shape between turns, it has; your own earlier messages are your record of what you saw.]';
487
488export const register: Register = (on, options) => {
489 const config = readConfig(options);
490 let sweeping = false;
491 on('turn.complete', async ($, e, next) => {
492 if ((e as { agentId?: string }).agentId) return next(e); // main thread only
493 if (!sweeping) {
494 try {
495 const d = await shouldSweep($, config);
496 if (d.sweep) {
497 sweeping = true;
498 await (await storeIO($)).storeSet(AGGRESSIVE, d.aggressive);
499 await $.session.compact(); // rejects while a turn runs → caught below
500 }
501 } catch (err) {
502 $.ui.log(`origami trigger skipped: ${err instanceof Error ? err.message : String(err)}`);
503 } finally {
504 sweeping = false;
505 }
506 }
507 return next(e);
508 });
509 on('session.compact', async ($, e, next) => {
510 const result = await runSweep($, config, e);
511 if (result) return result;
512 // F10, `auto` row: origami must never block an auto compaction (the window is
513 // genuinely full), but it can make the stock summarizer's INPUT carry the fold
514 // index, so the recovery links have explicit list-shaped material to survive in.
515 // Only the main thread has folds; subagents and precompute pass through bare.
516 if (e.trigger === 'auto' && !e.agentId) {
517 try {
518 const index = foldIndexMessage(await allFolds(await storeIO($)));
519 if (index) return next({ ...e, messages: [...e.messages, index] });
520 } catch { /* insurance is best-effort: never block the compaction it protects */ }
521 }
522 return next(e);
523 });
524 on('session.start', async ($, e, next) => {
525 await $.tool.register({
526 name: 'hydrate',
527 description: 'Expand an origami fold to its full stored content. Use before re-running a tool whose result was folded — re-running may not reproduce it (files change, tests flake). fold_id appears in [origami fold-…] stubs and in hydrate:// links; when a specific link motivated this call, pass its #fragment as anchor.',
528 inputSchema: { type: 'object', properties: { fold_id: { type: 'string' }, anchor: { type: 'string' } }, required: ['fold_id'] },
529 });
530 await $.tool.register({
531 name: 'unpin',
532 description: 'Release a pinned origami fold so it can fold again. Use when pinned content is no longer earning its place in context.',
533 inputSchema: { type: 'object', properties: { fold_id: { type: 'string' } }, required: ['fold_id'] },
534 });
535 return next(e);
536 });
537 // The standing session-start notice (spec addendum). `session.start`'s own result
538 // is `{ cwd }` and nothing else (types/claude-code.d.ts :9031-9036 — "a hook's own
539 // value does not change the session"), so it cannot carry model-visible text. The
540 // SAME session-start event in its classic form can: ClassicResultFields.SessionStart
541 // lists 'additionalContext' (:1063), handed to the model with the event (:991-994).
542 on('classic.SessionStart', async ($, e, next) => {
543 const result = await next(e);
544 return { ...result, additionalContext: [...(result.additionalContext ?? []), SESSION_NOTICE] };
545 });
546 // Serve the two declared tools: a tool.call hook must answer with `{ result }`
547 // (core sets `text` for the model from it; a hook's own `{ text }` is not read —
548 // see ToolCallResult in types/claude-code.d.ts). Both tools' arguments ride
549 // flat on `e` (McpToolCallInputFallback's `[argument: string]: unknown`), not
550 // nested under an `input` key.
551 on('tool.call', { tool: 'mcp__origami__hydrate' }, async ($, e) => {
552 const input = e as unknown as { fold_id?: unknown; anchor?: unknown };
553 return { result: await handleHydrate($, config, String(input.fold_id ?? ''), typeof input.anchor === 'string' ? input.anchor : undefined) };
554 });
555 on('tool.call', { tool: 'mcp__origami__unpin' }, async ($, e) => {
556 const input = e as unknown as { fold_id?: unknown };
557 return { result: await handleUnpin($, String(input.fold_id ?? '')) };
558 });
559 on('tool.call', async ($, e, next) => {
560 // observe-only middleware: never blocks, never rewrites; main thread only
561 const call = e as unknown as { tool: string; agentId?: string; [k: string]: unknown };
562 if (!call.agentId) {
563 const input = call as unknown as Record<string, unknown>;
564 if (call.tool === 'Read') {
565 await observeMissedHydrate($, { tool: call.tool, input });
566 } else if (call.tool === 'Edit' || call.tool === 'Write' || call.tool === 'MultiEdit') {
567 await observeWrite($, { tool: call.tool, input });
568 }
569 }
570 return next(e);
571 });
572};
573hooks/rebuild.ts 280 lines1import type { SessionMessage } from 'claude-code';
2import type { OrigamiConfig } from './origami';
3
4const MIN_CANDIDATE_TOKENS = 256; // below this a fold saves nothing worth a stub
5
6export function estimateTokens(text: string): number {
7 return Math.ceil(text.length / 4);
8}
9
10// FNV-1a over the text, base36 — the same construction as origami.ts's projectHash,
11// which hashes the project root. It lives HERE rather than in origami.ts because the
12// $-rule confines origami.ts to top-level functions the validator can follow $ into;
13// a pure helper needed by store-facing code belongs in a dependency-free module.
14// Not cryptographic and not meant to be: it fingerprints a tool result so a keep
15// verdict can be invalidated if the same tool_use_id ever carries different text.
16// The length is mixed in so a collision needs matching length AND matching digest.
17export function contentHash(text: string): string {
18 let h = 0x811c9dc5; // FNV-1a, 32-bit
19 for (let i = 0; i < text.length; i++) {
20 h ^= text.charCodeAt(i);
21 h = Math.imul(h, 0x01000193) >>> 0;
22 }
23 return `${text.length.toString(36)}-${h.toString(36)}`;
24}
25
26// The synthetic sweep-marker user message's fixed text prefix (v1.1 item 6). Shared
27// by sweepMarkerPair (which writes it) and turnAges (which must not count it as a
28// turn start — it is inserted between real turns, not spoken by the user).
29export const MARKER_PREFIX = '[origami sweep report:';
30
31export function turnAges(messages: readonly SessionMessage[]): number[] {
32 const turnOf: number[] = [];
33 let turn = -1;
34 for (const m of messages) {
35 if (m.role === 'user' && m.text.trim() !== '' && !m.text.startsWith(MARKER_PREFIX)) turn += 1;
36 turnOf.push(Math.max(turn, 0));
37 }
38 const newest = Math.max(turn, 0);
39 return turnOf.map(t => newest - t);
40}
41
42export type Candidate = {
43 messageIndex: number;
44 resultIndex: number;
45 toolUseId: string;
46 tool: string;
47 input: Record<string, unknown>;
48 text: string;
49 sizeTokens: number;
50 ageTurns: number;
51};
52
53export function selectCandidates(
54 messages: readonly SessionMessage[],
55 excludedToolUseIds: ReadonlySet<string>,
56 cfg: OrigamiConfig,
57 aggressive: boolean,
58): Candidate[] {
59 const ages = turnAges(messages);
60 const minAge = Math.max(aggressive ? 1 : cfg.foldAgeTurns, 1);
61 const toolByUseId = new Map<string, { tool: string; input: Record<string, unknown> }>();
62 for (const m of messages) {
63 for (const u of m.toolUses) toolByUseId.set(u.tool_use_id, { tool: u.tool, input: u.input });
64 }
65 const out: Candidate[] = [];
66 messages.forEach((m, mi) => {
67 if (mi === 0) return; // first message is pinned by invariant
68 (m.toolResults ?? []).forEach((r, ri) => {
69 if (excludedToolUseIds.has(r.tool_use_id)) return;
70 if (ages[mi] < minAge) return;
71 const sizeTokens = estimateTokens(r.text);
72 if (sizeTokens < MIN_CANDIDATE_TOKENS) return;
73 const call = toolByUseId.get(r.tool_use_id);
74 out.push({
75 messageIndex: mi, resultIndex: ri, toolUseId: r.tool_use_id,
76 tool: call?.tool ?? 'unknown', input: call?.input ?? {},
77 text: r.text, sizeTokens, ageTurns: ages[mi],
78 });
79 });
80 });
81 return out;
82}
83
84export function candidateMass(candidates: readonly Candidate[]): number {
85 return candidates.reduce((sum, c) => sum + c.sizeTokens, 0);
86}
87
88export type FoldDecision = { toolUseId: string; foldId: string; stub: string };
89export type RestoreDecision = { toolUseId: string; foldId: string; body: string };
90
91export function stubText(foldId: string, tool: string, stub: string): string {
92 return `[origami ${foldId} · ${tool} result folded] ${stub} — call hydrate("${foldId}") for the full content.`;
93}
94
95// Every fold id whose stub actually appears in the transcript (message text or a tool
96// result). Used to reconcile the fold index: an entry in state 'folded' whose stub is
97// gone no longer exists as far as the conversation is concerned.
98//
99// v1.1 item 6 verification: the pattern requires the literal stub prefix `[origami `
100// immediately followed by `fold-NNN` — a bare mention of a fold id elsewhere in text
101// (e.g. inside a sweepMarkerPair marker, which lists ids like "folded fold-013" without
102// the `[origami ` prefix directly before them) does NOT match. Confirmed with a test
103// (rebuild.test.ts) rather than changed: this was already the correct, tight matcher.
104export function foldIdsPresent(messages: readonly SessionMessage[]): Set<string> {
105 const out = new Set<string>();
106 const scan = (text: string) => {
107 for (const m of text.matchAll(/\[origami (fold-\d+)[\s·]/g)) out.add(m[1]);
108 };
109 for (const m of messages) {
110 scan(m.text);
111 for (const r of m.toolResults ?? []) scan(r.text);
112 }
113 return out;
114}
115
116// --- Sweep marker (v1.1 item 6, banner split) ---
117// A small handle-less user+assistant pair appended at the tail of the rebuilt
118// messages on every SUCCESSFUL sweep. Unlike the banner (static, written once,
119// rules only), the marker is mutable status written fresh each sweep and left in
120// place forever after — historically true at its position in the transcript.
121//
122// CRITICAL: the text must never contain the substring `[origami fold-` (the stub
123// prefix) — fold ids are written bare so foldIdsPresent (which anchors on that
124// exact prefix) never mistakes a marker mention for a live stub.
125export function sweepMarkerPair(report: {
126 foldedIds: readonly string[]; restoredIds: readonly string[]; activeFolds: number;
127 staleIds?: readonly string[];
128}): SessionMessage[] {
129 // Fold ids stay BARE (no `[origami fold-` prefix) so foldIdsPresent never mistakes
130 // this marker's mentions for live stubs — the same rule the folded/restored lists follow.
131 const stale = report.staleIds && report.staleIds.length
132 ? ` Now STALE (source edited since capture): ${report.staleIds.join(', ')} — hydrate to see the snapshot plus a pointer to re-read the current file.`
133 : '';
134 // Self-announcing preamble (v1.1.1 legibility): runSweep only ever emits this marker on
135 // a summary-replacing sweep (plugin/manual triggers; auto and precompute never reach
136 // here), so it can state plainly that this rebuild stands IN PLACE of the stock
137 // compaction summary. Without it, a knowledge-free agent sees a compaction with no
138 // summary and cannot tell whether origami worked or the hooks reset — the exact
139 // confusion observed in a live smoke session.
140 const text = `${MARKER_PREFIX} this is origami's in-place rebuild of the context — not a stock compaction summary; nothing was lost, earlier turns remain, and any that were folded now render as origami stubs. This sweep folded ${report.foldedIds.join(', ') || 'nothing'}; restored ${report.restoredIds.join(', ') || 'nothing'}; ${report.activeFolds} folds now active.${stale} Content discussed above this point may now render as stubs — hydrate to recover it.]`;
141 return [
142 { role: 'user', text, toolUses: [] },
143 { role: 'assistant', text: 'Noted. [synthetic acknowledgment inserted by origami]', toolUses: [] },
144 ];
145}
146
147// --- Fold-index insurance (spec addendum F10, `auto` row) ---
148// When origami cannot reduce an `auto` compaction, the stock summarizer runs and
149// would otherwise wipe every stub. This handle-less user message is appended to
150// the event handed to next(), so the summarizer's input carries an explicit,
151// list-shaped inventory of the live folds to preserve the recovery links from.
152// Pure: takes store entries, returns undefined when no fold is live.
153export function foldIndexMessage(folds: readonly FoldIndexEntry[]): SessionMessage | undefined {
154 const live = folds.filter(f => f.state === 'folded' || f.state === 'pinned');
155 if (live.length === 0) return undefined;
156 const lines = live.map(f => `${f.id} — ${f.stub}`).join('\n');
157 return {
158 role: 'user',
159 text: `[origami fold index — preserve these recovery links in any summary]\n${lines}`,
160 toolUses: [],
161 // no handle: synthetic
162 };
163}
164
165// The shape foldIndexMessage needs of a store entry (structurally satisfied by
166// store.ts's FoldEntry; declared here so rebuild.ts stays dependency-free).
167export type FoldIndexEntry = { id: string; stub: string; state: 'folded' | 'pinned' | 'evicted' };
168
169export type RebuildOutcome =
170 | { kind: 'rebuilt'; messages: SessionMessage[]; tokensBefore: number; tokensAfter: number }
171 | { kind: 'insufficient'; ratio: number };
172
173export function rebuild(
174 messages: readonly SessionMessage[],
175 folds: readonly FoldDecision[],
176 restores: readonly RestoreDecision[],
177 cfg: OrigamiConfig,
178 aggressive = false,
179): RebuildOutcome {
180 const protectedTurns = aggressive ? 1 : cfg.preserveRecentTurns;
181 const ages = turnAges(messages);
182 const foldByUseId = new Map(folds.map(f => [f.toolUseId, f]));
183 const restoreByUseId = new Map(restores.map(r => [r.toolUseId, r]));
184 const toolByUseId = new Map<string, string>();
185 for (const mm of messages) for (const u of mm.toolUses) toolByUseId.set(u.tool_use_id, u.tool);
186 const massOf = (ms: readonly SessionMessage[]) =>
187 ms.reduce((s, m) => s + estimateTokens(m.text)
188 + (m.toolResults ?? []).reduce((a, r) => a + estimateTokens(r.text), 0)
189 + m.toolUses.reduce((a, u) => a + estimateTokens(JSON.stringify(u.input)), 0), 0);
190 const tokensBefore = massOf(messages);
191
192 const out: SessionMessage[] = messages.map((m, mi) => {
193 const results = m.toolResults ?? [];
194 const touched = results.some(r => foldByUseId.has(r.tool_use_id) || restoreByUseId.has(r.tool_use_id));
195 if (!touched) return m as SessionMessage;
196 if (mi === 0) throw new Error('origami invariant: first message is pinned');
197 if (ages[mi] < protectedTurns) {
198 throw new Error(`origami invariant: decision targets a message inside the protected recent turns (age ${ages[mi]})`);
199 }
200 return {
201 role: m.role,
202 text: m.text,
203 toolUses: m.toolUses,
204 toolResults: results.map(r => {
205 const f = foldByUseId.get(r.tool_use_id);
206 if (f) return { tool_use_id: r.tool_use_id, text: stubText(f.foldId, toolByUseId.get(r.tool_use_id) ?? 'tool', f.stub), isError: r.isError };
207 const p = restoreByUseId.get(r.tool_use_id);
208 if (p) return { tool_use_id: r.tool_use_id, text: p.body, isError: r.isError };
209 return r;
210 }),
211 // no handle: this message is rebuilt
212 };
213 });
214
215 const tokensAfter = massOf(out);
216 const ratio = tokensBefore > 0 ? (tokensBefore - tokensAfter) / tokensBefore : 0;
217 const restoring = restores.length > 0; // reopening pins may legitimately grow the context
218 if (!restoring && ratio < cfg.minReductionRatio) return { kind: 'insufficient', ratio };
219 return { kind: 'rebuilt', messages: out, tokensBefore, tokensAfter };
220}
221
222// --- Banner ---
223// A standalone synthetic user+assistant pair prepended at the very top of the
224// transcript so role alternation is preserved and no real message text is ever
225// edited. Rebuilt fresh every sweep: stripBanner() first, then applyBanner().
226
227export const BANNER_PREFIX = '[ORIGAMI v';
228
229// v1.1 item 6 (banner split): STATIC — rules only, no fold count. Written once at
230// the first sweep and never rewritten afterward (the point: index 0/1 then keep
231// their handles and their place in the prompt cache across every later sweep).
232// The mutable status moved inline — see sweepMarkerPair.
233export function bannerText(version: string): string {
234 return `[ORIGAMI v${version} — BETA. This session's older tool results have been FOLDED:
235replaced by short link-stubs written by a librarian that read the full
236content. The full content is intact on disk — nothing is lost.
237- A [linked phrase](hydrate://fold-NNN) is a fold. Follow it with the
238 hydrate tool BEFORE re-running a tool or concluding you never saw
239 something.
240- Never state details of folded content from the stub alone — hydrate
241 first. A stub is an advertisement, not the artifact.
242- BETA DUTY: if you notice a reference to content you cannot locate, a
243 stub that contradicts your memory, or anything that feels like a gap —
244 SAY SO TO THE USER explicitly. You are a test pilot; anomalies are data.
245- Statements you made before a fold were made with the full content in
246 view; your own earlier messages are your record of what you saw.
247 Distrust only claims sourced from a stub alone.
248- Do not be confused when the conversation below discusses content that
249 now shows only as a stub: the conversation is rewritten in place, and
250 that content was fully visible when those messages were written. It is
251 not a contradiction, and nobody misspoke.
252This notice is written once; per-sweep reports appear inline in the conversation below.]`;
253}
254
255export const BANNER_ACK = "Understood — I'll follow hydrate:// links before re-running tools or claiming I never saw something, and I'll flag anomalies to the user. [synthetic acknowledgment inserted by origami]";
256
257// Removes an existing banner pair if present: a user message whose text starts
258// with BANNER_PREFIX at index 0, plus the immediately following assistant
259// message when its text === BANNER_ACK. Returns a new array; input untouched.
260export function stripBanner(messages: readonly SessionMessage[]): SessionMessage[] {
261 const first = messages[0];
262 const second = messages[1];
263 if (first && first.role === 'user' && first.text.startsWith(BANNER_PREFIX)
264 && second && second.role === 'assistant' && second.text === BANNER_ACK) {
265 return messages.slice(2);
266 }
267 return messages.slice();
268}
269
270// Prepends a fresh banner pair: [{role:'user', text: banner, toolUses: []},
271// {role:'assistant', text: BANNER_ACK, toolUses: []}] — both handle-less
272// (synthetic, rebuilt every sweep). Does NOT strip; callers strip first.
273export function applyBanner(messages: readonly SessionMessage[], banner: string): SessionMessage[] {
274 return [
275 { role: 'user', text: banner, toolUses: [] },
276 { role: 'assistant', text: BANNER_ACK, toolUses: [] },
277 ...messages,
278 ];
279}
280hooks/librarian.ts 113 lines1import { estimateTokens, type Candidate } from './rebuild';
2
3export type LibrarianDecision = { toolUseId: string; action: 'keep' | 'fold'; stub: string };
4
5export type CompleteFn = (req: { model: string; prompt: string; maxTokens?: number }) => Promise<string>;
6
7export function buildSweepPrompt(candidates: readonly Candidate[], aggressive: boolean): string {
8 const head = [
9 'You are the librarian of a coding session. Each <candidate> below is a stale tool result',
10 'still occupying the live context. For each one decide:',
11 ' keep — its exact content is likely needed verbatim again soon;',
12 ' fold — a short stub suffices; full content stays recoverable on demand.',
13 aggressive ? 'The working set is over budget: fold everything not clearly needed.' : '',
14 'For EVERY candidate answer exactly one line:',
15 '<decision id="ID" action="keep|fold">stub</decision>',
16 'A stub is anchor text, not a note: name what it is, then 2-4 concept-level markdown links',
17 'to the most load-bearing things inside, grammar [concept](hydrate://FOLD#slug), where FOLD',
18 'is the literal token FOLD (replaced with the real fold id later) and #slug names the concept.',
19 'Example: src/auth.ts (480 lines): [JWT validation](hydrate://FOLD#jwt), [refresh flow](hydrate://FOLD#refresh), [SECRET_ROTATION](hydrate://FOLD#rotation)',
20 'Write anchors from the content you see. No other output.',
21 ].filter(Boolean).join('\n');
22 const body = candidates.map(c =>
23 `<candidate id="${c.toolUseId}" tool="${c.tool}" input=${JSON.stringify(JSON.stringify(c.input))} age_turns="${c.ageTurns}">\n${c.text}\n</candidate>`
24 ).join('\n');
25 return `${head}\n\n${body}`;
26}
27
28// A candidate the librarian's reply is silent on (or answers with a malformed block)
29// is NOT a failure: 'keep' is safe by construction — the content simply stays inline,
30// same as if the sweep had never picked it up. With N candidates the probability of
31// at least one omission grows with N, so on large sessions treating a miss as fatal
32// turns a routine sweep into a probabilistic outage. An UNKNOWN id in the reply (the
33// model naming something that was never offered — typically one transcribed character
34// wrong out of dozens of long random ids) is ALSO not a failure: `decisions` is built
35// by mapping over `expectedIds`, never by trusting whatever the reply names, so an
36// unknown entry has no path into `decisions` and cannot be consulted or corrupt
37// anything — it is simply dropped. A typo'd id therefore shows up as exactly one
38// unknown (the garbled name) plus one defaulted (its intended twin, now unanswered),
39// and the sweep degrades to keep+cooldown for that one candidate — safe, not fatal.
40export function parseSweepReply(
41 reply: string, expectedIds: readonly string[],
42): { decisions: LibrarianDecision[]; defaulted: string[]; unknown: string[] } {
43 const re = /<decision id="([^"]+)" action="(keep|fold)">([\s\S]*?)<\/decision>/g;
44 const found = new Map<string, LibrarianDecision>();
45 for (let m = re.exec(reply); m !== null; m = re.exec(reply)) {
46 found.set(m[1], { toolUseId: m[1], action: m[2] as 'keep' | 'fold', stub: m[3].trim() });
47 }
48 const unknown = [...found.keys()].filter(id => !expectedIds.includes(id));
49 const defaulted: string[] = [];
50 const decisions = expectedIds.map(id => {
51 const d = found.get(id);
52 if (d) return d;
53 defaulted.push(id);
54 return { toolUseId: id, action: 'keep' as const, stub: '' };
55 });
56 return { decisions, defaulted, unknown };
57}
58
59// A sweep's wall time is one serial librarian prefill over every candidate. Splitting
60// the offer into batches and running them concurrently turns that into the slowest
61// single batch. Batching is the GENERAL path: with <= LIBRARIAN_BATCH_SIZE candidates
62// there is exactly one batch, so the single-call behaviour is unchanged (one prompt,
63// one complete call, the same maxTokens scaling).
64//
65// A batch also contains the blast radius of an omission: parseSweepReply defaults a
66// missing id to 'keep', and because each batch is parsed against ITS OWN expectedIds,
67// a reply that drops everything defaults only its own candidates — the other batches'
68// decisions are unaffected.
69export const LIBRARIAN_BATCH_SIZE = 15;
70
71export async function runLibrarian(
72 complete: CompleteFn, model: string, candidates: readonly Candidate[], aggressive: boolean,
73): Promise<{ decisions: LibrarianDecision[]; defaulted: string[]; unknown: string[]; inputTokens: number; outputTokens: number }> {
74 const batches: Candidate[][] = [];
75 for (let i = 0; i < candidates.length; i += LIBRARIAN_BATCH_SIZE) {
76 batches.push(candidates.slice(i, i + LIBRARIAN_BATCH_SIZE));
77 }
78 const settled = await Promise.allSettled(batches.map(async (batch) => {
79 const prompt = buildSweepPrompt(batch, aggressive);
80 const maxTokens = Math.min(16384, 1024 + batch.length * 128);
81 const reply = await complete({ model, prompt, maxTokens });
82 const parsed = parseSweepReply(reply, batch.map(c => c.toolUseId));
83 return { ...parsed, inputTokens: estimateTokens(prompt), outputTokens: estimateTokens(reply) };
84 }));
85 // Promise.allSettled, not Promise.all: a batch's `complete` call CAN reject (a
86 // safety-classifier refusal on security-dense content is a real, observed failure
87 // mode), and with Promise.all that one rejection would discard every sibling batch's
88 // successfully-parsed work, failing the entire sweep. A rejected batch instead
89 // degrades exactly like an omitted reply would: every id it expected becomes
90 // `defaulted` (action 'keep', for this sweep only — see parseSweepReply above), so
91 // the sweep keeps that content inline and retries the batch whole next time. Tokens
92 // are counted only for fulfilled batches (a rejected one contributes 0 of each).
93 const runs = batches.map((batch, i) => {
94 const s = settled[i];
95 if (s.status === 'fulfilled') return s.value;
96 const ids = batch.map(c => c.toolUseId);
97 return {
98 decisions: ids.map(id => ({ toolUseId: id, action: 'keep' as const, stub: '' })),
99 defaulted: ids, unknown: [] as string[], inputTokens: 0, outputTokens: 0,
100 };
101 });
102 // Batches are contiguous, in-order slices, so flattening restores the caller's own
103 // candidate order — `decisions` stays aligned with `candidates` exactly as the
104 // single-call path produced it.
105 return {
106 decisions: runs.flatMap(r => r.decisions),
107 defaulted: runs.flatMap(r => r.defaulted),
108 unknown: runs.flatMap(r => r.unknown),
109 inputTokens: runs.reduce((s, r) => s + r.inputTokens, 0),
110 outputTokens: runs.reduce((s, r) => s + r.outputTokens, 0),
111 };
112}
113hooks/store.ts 128 lines1export type StoreIO = {
2 fsRead: (path: string) => Promise<string>;
3 fsWrite: (path: string, text: string) => Promise<void>;
4 fsExists: (path: string) => Promise<boolean>;
5 // storeGet/storeSet/storeKeys are PROJECT-SCOPED by the caller: origami.ts bakes a
6 // per-project prefix into these closures (see storeIO($)), so every key seen here is
7 // already this project's own and storeKeys() returns prefix-stripped keys.
8 storeGet: (key: string) => Promise<unknown>;
9 storeSet: (key: string, value: unknown) => Promise<void>;
10 storeDelete: (key: string) => Promise<void>;
11 storeKeys: () => Promise<string[]>;
12};
13
14export type FoldEntry = {
15 id: string; stub: string; state: 'folded' | 'pinned' | 'evicted';
16 tool: string; toolUseId: string; inputKey: string;
17 originAge: number; sizeTokens: number; hydrations: number;
18 // Staleness (v1.1). originHash: FNV of the RAW file bytes at fold time, set only for
19 // file-backed folds (input had a string file_path) that were readable then; absent ⇒
20 // no hash to re-verify against. stale: a PERSISTENT flag flipped by the writer-hook
21 // when an Edit/Write hits this fold's path; it stays set (so hydrate can honor it even
22 // for un-hashed folds) until the fold is superseded or a re-hash proves the file
23 // matches again. staleAnnounced: bookkeeping so the sweep marker names a newly-stale
24 // fold ONCE without clearing `stale`. Legibility (v1.1.1): the marker and hydrate must
25 // never disagree — a knowledge-free agent can't reconcile "marker says stale, hydrate
26 // is silent" — so hydrate warns on (stale === true) OR (originHash mismatch).
27 originHash?: string; stale?: boolean; staleAnnounced?: boolean;
28};
29
30export function inputKeyOf(input: Record<string, unknown>): string {
31 return typeof input.file_path === 'string' ? input.file_path : JSON.stringify(input);
32}
33
34const SEQ = 'origami:seq';
35const FOLD = (id: string) => `origami:fold:${id}`;
36const BODY = (id: string) => `.claude/origami/folds/${id}.md`;
37const LOG = '.claude/origami/origami.log';
38
39// A fold body file is `<header>` + this delimiter + the ORIGINAL bytes. Splitting on
40// the delimiter is what makes restores byte-exact and stops the header nesting on
41// unpin -> refold cycles. A file written without a header has no delimiter and is
42// returned whole.
43export const BODY_DELIMITER = '\n<<<origami:body>>>\n';
44
45export async function newFoldId(io: StoreIO): Promise<string> {
46 const n = Number((await io.storeGet(SEQ)) ?? 0) + 1;
47 await io.storeSet(SEQ, n);
48 return `fold-${String(n).padStart(3, '0')}`;
49}
50
51export async function putFold(io: StoreIO, entry: FoldEntry, body: string, header?: string): Promise<void> {
52 await io.fsWrite(BODY(entry.id), header === undefined ? body : header + BODY_DELIMITER + body);
53 await io.storeSet(FOLD(entry.id), entry);
54}
55
56export async function getFold(
57 io: StoreIO, id: string,
58): Promise<{ entry: FoldEntry; body: string; header?: string } | undefined> {
59 const entry = (await io.storeGet(FOLD(id))) as FoldEntry | undefined;
60 if (!entry) return undefined;
61 const raw = String(await io.fsRead(BODY(id)));
62 const at = raw.indexOf(BODY_DELIMITER);
63 if (at === -1) return { entry, body: raw };
64 return { entry, body: raw.slice(at + BODY_DELIMITER.length), header: raw.slice(0, at) };
65}
66
67export async function setFold(io: StoreIO, entry: FoldEntry): Promise<void> {
68 await io.storeSet(FOLD(entry.id), entry);
69}
70
71export async function allFolds(io: StoreIO): Promise<FoldEntry[]> {
72 const keys = (await io.storeKeys()).filter((k: string) => k.startsWith('origami:fold:'));
73 const out: FoldEntry[] = [];
74 for (const k of keys) {
75 const e = (await io.storeGet(k)) as FoldEntry | undefined;
76 if (e) out.push(e);
77 }
78 return out;
79}
80
81// --- Keep-memory (delta sweeps) ---
82// A candidate the librarian EXPLICITLY ruled `keep` stays inline, aged and bulky, and
83// would otherwise be re-offered wholesale on EVERY later sweep — the observed
84// ~100k-token, ~30s prefill that reads the same content twice running. Remembering the
85// verdict, keyed by the tool_use_id and fingerprinted by the content hash, makes
86// steady-state sweeps read only NEW mass. The hash is what makes the memory safe: if
87// the same id ever carries different text, the verdict no longer applies and the
88// candidate is offered again. A DEFAULTED keep (the librarian never actually judged
89// it — an omission, or a whole batch whose call failed) is never written here: it must
90// stay retryable, not become a permanent suppression (see origami.ts's recording loop).
91export type KeepEntry = { hash: string; ts: string };
92
93const KEEP_PREFIX = 'origami:keep:';
94const KEEP = (toolUseId: string) => `${KEEP_PREFIX}${toolUseId}`;
95
96// Mirrors allFolds: enumerate the project-scoped keys, read each entry back. Returns
97// a map tool_use_id -> entry so callers can probe by id without another round trip.
98export async function getKeeps(io: StoreIO): Promise<Map<string, KeepEntry>> {
99 const keys = (await io.storeKeys()).filter((k: string) => k.startsWith(KEEP_PREFIX));
100 const out = new Map<string, KeepEntry>();
101 for (const k of keys) {
102 const e = (await io.storeGet(k)) as KeepEntry | undefined;
103 if (e && typeof e.hash === 'string') out.set(k.slice(KEEP_PREFIX.length), e);
104 }
105 return out;
106}
107
108export async function putKeep(io: StoreIO, toolUseId: string, hash: string): Promise<void> {
109 const entry: KeepEntry = { hash, ts: new Date().toISOString() };
110 await io.storeSet(KEEP(toolUseId), entry);
111}
112
113export async function dropKeep(io: StoreIO, toolUseId: string): Promise<void> {
114 await io.storeDelete(KEEP(toolUseId));
115}
116
117// Best-effort telemetry: the log is a diagnostic, never a dependency. A write that
118// rejects (the engine's 4 MiB fs ceiling, an OS refusal) must not take down a hydrate
119// or abort a sweep, so every failure here is swallowed. Signature stays `Promise<void>`.
120export async function appendLog(io: StoreIO, record: Record<string, unknown>): Promise<void> {
121 try {
122 let prior = '';
123 try { if (await io.fsExists(LOG)) prior = String(await io.fsRead(LOG)); } catch { prior = ''; }
124 const line = JSON.stringify({ ts: new Date().toISOString(), ...record });
125 await io.fsWrite(LOG, prior === '' ? line + '\n' : prior + line + '\n');
126 } catch { /* telemetry must never break the caller */ }
127}
128