SLOPSHOPPER

origami

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

newguardtoolmodel
v1.1.1no licenseupdated 2026-09-23dullfig/origami
A shopper browsing a rack in a slop shop
README

<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.

status: beta Claude Code plugin built with TypeScript license: MIT


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.

Why it's different

  • 🔁 Lossless & reversible. Conversation text is never touched; only tool output folds, and every fold hydrates back byte-for-byte. A re-read can drift (files change, tests flake, output shifts) — a fold is the exact bytes the model saw.
  • 🔗 A handle, not a summary. Each stub carries concept links the model can follow on demand. hydrate returns the whole result; the link it followed is logged, groundwork for future prefetch.
  • 📈 Continuous, not cliff-edge. Folding triggers on token mass, not on how full the window is — so behavior is identical at a 200k or a 1M context. No dramatic end-of-window summary; the context just stays lean.
  • ⚠️ Staleness-aware. Edit a file after it's been folded and 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.
  • 🛟 Safe by construction. Any failure — model error, storage error, malformed librarian output, or a reduction too small to be worth it — falls back to Claude Code's built-in compaction. You're never worse off than stock.
  • 📦 Zero dependencies. The whole plugin is one TypeScript function-hooks module. No server, no API key, no Node/Python packages to install.

Quick start

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.


How it works

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.

Staleness

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:

  • an in-loop Edit/Write to a folded path flags the fold immediately (and the next sweep marker names it once), and
  • a content-hash taken at fold time is re-checked at hydrate, catching out-of-loop edits the writer-hook never saw.

The 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.

Configuration

Set via .claude-plugin/plugin.json's userConfig (or your Claude Code plugin configuration UI):

OptionDefaultMeaning
foldAgeTurns3Results younger than this are never candidates
minFoldMass20000Candidate token mass that triggers a sweep
workingSetBudget100000Absolute live-context tokens; above it, sweeps turn aggressive
preserveRecentTurns3Turns the rebuilder never touches
minReductionRatio0.15Below 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)
pinAfterHydrations2Hysteresis threshold
librarianModelhaikuPassed 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.

Tools

ToolDescription
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.

Data storage

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.

The status banner & sweep markers

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.

Coexistence with classic hooks

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.

Status & early-access caveat

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.

License

MIT

Source 4 files
hooks/origami.ts 573 lines
1import 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};
573
hooks/rebuild.ts 280 lines
1import 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}
280
hooks/librarian.ts 113 lines
1import { 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}
113
hooks/store.ts 128 lines
1export 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