The model writes its own handoff through one tool and the plugin installs it at Claude Code's compaction, with facts recorded from typed events and every later…

Compaction for Claude Code where the model writes its own handoff: the main chat and every sub-agent hand off through one tool, and everything after the call stays verbatim.
forward_compaction({
"objective": "…", "done": "…", "remaining": "…", "next": "…",
"load_files": [{ "path": "/abs/plan.md", "why": "the plan I am executing" }]
})
<sup>One call, one argument per section. At Claude Code's next compaction the plugin replaces everything before the call with recorded facts plus this handoff, and keeps every message after it word for word.</sup>
Claude Code compacts by asking a summarizer to paraphrase the whole conversation. The paraphrase loses what matters most to the party being compacted: the person's exact words, a sub-agent's brief, the skills in force, and the frontier — what is done, what is left, what comes next.
forward-compaction lets the model write that frontier itself, while it still holds the context, and builds the rest from facts the plugin recorded as they happened.
forward_compaction takes one argument per section — objective, done, remaining and next — and assembles them into one markdown handoff. It is stored, never summarized, and installed verbatim./reload-plugins) does not lose them. The files touched and the task list come from each finished tool call.No benchmark of forward-compaction has been measured yet. The figures the earlier plugin of this repository published measured a different mechanism (a summarizer run at a milestone) and are not claimed for this one.
# 1. Turn on function hooks, and let Claude Code ask to compact from about 63k
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
export CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000
# 2. Install
claude plugin marketplace add https://github.com/rezzminator/sub-agent-compact.git
claude plugin install forward-compaction@forward-compaction
Both variables also work in the env block of settings.json. With the defaults the main chat is told its context size from 15% of its window, again every 5%, and asked to hand off at its next clean milestone; it is compacted mechanically at 60% of its window if it never does. A sub-agent is measured against a 200k max context: nudged from 80k every 20k, compacted mechanically at 160k.
Early access. This plugin is built on Claude Code's function hooks, an early-access surface that may change between releases. It is tested on Claude Code 2.1.309.
Moving from the earlier plugin of this repository? The CHANGELOG entry for 1.0.0-dev.1 maps every old setting.
flowchart LR
A[Tool call] --> B{Past a<br/>nudge point?}
B -- no --> A
B -- yes --> C[Nudge after the result:<br/>hand off at a clean point]
C --> D[Model calls forward_compaction:<br/>handoff stored]
D --> E[Next compaction door:<br/>facts + handoff + every later message]
E --> A
B -- at the ceiling,<br/>nothing stored --> F[Mechanical wake:<br/>facts + newest messages]
F --> A
A party's context is its last measured reading: the input, cache-read, cache-write and output tokens of its last completed request, as turn.step reports them. The nudge, the ceiling and the mechanical decision use it, so a nudge may come one step late. No number is ever estimated from characters: a party with no reading at all, none yet or none since an install, gets no nudge and no ceiling, and an automatic ask from it with nothing stored is held as no-reading until its next reading decides.
FORWARD_COMPACTION_MAIN_NUDGE_START or FORWARD_COMPACTION_SUBAGENT_NUDGE_START, a share of its max context), the model reads one line saying how large its context is, as tokens and as a share of its max context, and that every call re-sends all of it. One more follows every FORWARD_COMPACTION_{MAIN|SUBAGENT}_NUDGE_EVERY. The line asks for a handoff at the next clean milestone, once the step in hand is finished and its results are written down, and before the next step starts: the model picks the milestone, not whether, and skips it only when nothing is left but writing its result and its final answer. A held compaction ask adds no nudge of its own. After an install, the first reading's level is absorbed silently (a sub-agent's floor of system prompt, tools and skills is already 55-60k), so the next nudge goes only when the context crosses the level above it. A model on the ignore list gets no nudge. A sub-agent gets one step to pick its milestone: one nudge level past its first nudge since its last install (100k with the defaults), its calls wait for its handoff. Every call but forward_compaction, look_back and ToolSearch is refused, unrun, with a line saying to hand off now with what it has read and concluded, then repeat the call; the first nudge names the point. Once the handoff is stored every call runs again. Every nudge and refusal also says a handoff costs nothing: the model continues from it, as many times as the work needs, and never cuts its task short to avoid one. The main chat is never held this way: its person steers it.forward_compaction (the model sees mcp__forward-compaction__forward_compaction). The plugin checks the section arguments and files and stores the handoff in memory for that party, or refuses the call and stores nothing, naming what to fix. A later call replaces the stored one.forward_compaction({objective, done, remaining, next, blocked, findings, load_files, drop_skills}), with its schema in the prompt's tool list (not deferred), so a nudged model needs no ToolSearch.
objective, done, remaining, next (required): non-blank strings — what the work is for and what done means; the finished pieces, each with how it was verified; every step left, in order; the first concrete action after the wake.blocked, findings (optional): strings — what waits on whom; decisions with why, errors verbatim. Absent or blank, the section is left out.## Objective, ## Done, ## Remaining, ## Next, then ## Blocked and ## Findings when given, each heading followed by its argument verbatim and a blank line between sections. The assembled handoff is at most 20,000 characters. Nothing is ever trimmed.load_files (optional): at most 8 {path, lines?, why} entries, each an absolute path to a regular file of at most 1,048,576 bytes, why 1–200 characters, lines a range like 10-40 or 1-60,160-185. Each file is read when the handoff installs and carried whole. Carried files get a room of 5% of the party's max context, turned into characters at the party's measured characters per token (see the wake's budget below). A call whose files, one or together, exceed it is refused, never trimmed, and a lines range counts only its lines. With no ratio measured yet the files are accepted without a size check, and a warning row says so. A file that has grown past the room by install is named as not carried, never cut.drop_skills (optional): at most 32 names of skills or commands the party loaded and no longer needs. A name is dropped only when two handoffs in a row name it: the second one's wake leaves it out, and so does every later wake until the party loads it again. A handoff that does not name it starts the count again. A skill or command whose file's frontmatter sets forward-compaction-keep: true, or that FORWARD_COMPACTION_KEEP_SKILLS names, is never dropped, whoever asks; the wake marks it pinned: never dropped. The receipt says, per name, whether it was dropped, waits for the next handoff, is pinned, or was never loaded.The result is a receipt saying when the handoff installs — for the main chat at the end of this turn, for a sub-agent at Claude Code's next ask (with where that ask comes) — and, when it replaced one, that it did. It prints no context size: the plugin measures none for that moment. A refusal starts forward_compaction refused, nothing stored: and names what to fix: every unknown field and every bad section argument, one line each, in one refusal; then an assembled handoff over the cap, with its total; then a bad load_files entry, or a file that is absent, not a regular file, too large or could not be checked; then load files over the room, each named with its size, with the room, and asking for lines ranges or fewer files. A refusal is a tool answer, not an error, so the model reads it and calls again.
Every answer, receipt and refusal alike, ends with one line saying the plugin is experimental and asking the model to append a short note of any trouble to issues.md in the level-log directory (the reply itself when no directory resolves).
The installed context is, oldest first: one facts message, one handoff message, then the messages after the call, returned as Claude Code holds them. The handoff message carries the call's own receipt, since the install replaces the message that held it, and, like a mechanical wake's message, ends with the experimental line and where to note any trouble.
The facts message holds, each section only when it has content:
parentId its own id);Read, Edit, MultiEdit, Write and NotebookEdit calls; a denied or failed call touched nothing;TodoWrite or TaskCreate/TaskUpdate, whichever moved last, carried whole within 5% of the budget or named as not carried;Its size is held to a budget of half Claude Code's ask point (the autoCompactThreshold that $.session.usage reports, held at or below the party's max context: 31,500 tokens at CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000), turned into characters at the party's measured characters per token: the characters over the tokens it has grown since its base, its live window's characters (from $.session.messages({ as: 'api' }), the messages its next request is built from) and reading at its first reading since it started or was last compacted, so the system prompt, tools and skills every reading counts cancel. It is unmeasured until the reading has grown 5,000 tokens past the base, or outside 1 to 8 characters a token, and unmeasured nothing is fitted: a handoff's wake carries the facts, the files and every message after the call, and a mechanical wake keeps no earlier message. Each section has a share — brief or person's messages 25%, what arrived after the handoff 25%, delivered messages or notifications 5%, sibling results 10%, skills the whole budget (every skill and command the party invoked is carried whole), and the files their own room above — and is carried whole or named as not carried, never cut; the person's messages and skills are carried newest first while they fit. The room left goes to the messages after the call: the oldest are dropped whole until the rest fits, and the handoff message says how many were dropped.
The handoff message carries the handoff verbatim, tells the model it is its frontier and to continue from ## Next, and says how many later messages follow. A mechanical wake replaces it with a message saying no frontier was written, how many of the newest messages were kept and how many are gone, and to re-establish the objective from the person's messages or the brief.
Each install writes one line, measured: it waits for the party's first reading after the install. A handoff the model chose is routine, so its line is an info row in the logs, never on screen: main handoff landed — 164k → 46k context, measured; frontier 4,812 characters, 3 facts, 5 messages kept verbatim. A mechanical wake lost the model's frontier, so its line is also printed: Explore 3f2a9c had no handoff at 125k (ceiling 120k) — mechanical wake installed, 125k → 38k context, measured. Before is the party's last reading before the install, after its first one since. A party that ends before a request measures it gets its line at its end, with after said as unknown, never guessed.
/compact runs Claude Code's own summary untouched; a stored handoff is dropped, and the screen says so.precompute ask is skipped: it is never used.look_back (mcp__forward-compaction__look_back) searches only the caller's own transcript before its latest compaction, for an exact detail the wake no longer holds. Supply query (case-insensitive words; double quotes keep a phrase whole) or entry: N to fetch one entry whole. Results rank by how many terms an entry holds, then newest first. Every call is logged as a compaction miss, and the person is told what was looked up.
Every option is an environment variable, set in the shell that starts Claude Code or in the env block of settings.json. The plugin declares no userConfig and has no /config screen; an options object handed to it is named in one warning and not applied. An unset, empty or blank variable is absent and the default applies; a bad value is named by its variable on the screen and in the log, and the default applies.
A size is a token count (100000), a number with a k/m suffix (100k, 0.6m), or a percentage of the party's max context above 0 and at most 100 (60%). A max context is a token count, never a percentage. A flag is 1, 0, true or false in any case. A model list is comma-separated, case-insensitive substrings of the model id a step reports; set empty, it ignores no model (the one option whose empty value is a value).
| Variable | Type | Default | Meaning |
|---|---|---|---|
FORWARD_COMPACTION_MAIN_MAX_CONTEXT | max context | window | The main chat's max context, which its sizes are a percentage of: by default the window $.session.usage() reports, read at start and again after /model. |
FORWARD_COMPACTION_MAIN_CEILING | size | 60% | With no handoff stored, the main chat's context at which the plugin installs a mechanical wake (1M window → 600k). |
FORWARD_COMPACTION_MAIN_NUDGE_START | size | 15% | The main chat's context at which it is first told its size and that it may hand off. |
FORWARD_COMPACTION_MAIN_NUDGE_EVERY | size | 5% | How much the main chat's context grows between nudges. |
FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT | max context | 200k | Every sub-agent's max context, Haiku included: Claude Code's API reports no sub-agent's window (Max contexts). |
FORWARD_COMPACTION_SUBAGENT_CEILING | size | 80% | With no handoff stored, a sub-agent's context at which the plugin installs a mechanical wake (160k on 200k). |
FORWARD_COMPACTION_SUBAGENT_NUDGE_START | size | 40% | A sub-agent's first nudge (80k on 200k), above the 55-60k a sub-agent lands at after an install, so a handoff never loops. |
FORWARD_COMPACTION_SUBAGENT_NUDGE_EVERY | size | 10% | How much a sub-agent's context grows between nudges (20k on 200k). |
FORWARD_COMPACTION_CEILING_NATIVE | flag | 0 | 1: at any party's ceiling with no handoff, Claude Code's own summary runs instead of the mechanical wake. |
FORWARD_COMPACTION_IGNORE_MODELS | model list | haiku | Models that take no handoff: no nudge, the tool refused, every ask held to the ceiling and then compacted mechanically (Ignored models). |
FORWARD_COMPACTION_KEEP_SKILLS | name list | none | Skills and commands never dropped from a wake, comma-separated, as a file's forward-compaction-keep: true pins it (The tool). |
FORWARD_COMPACTION_DEBUG | flag | 1 | 0: no debug.jsonl, the row the plugin writes at every step. |
FORWARD_COMPACTION_LOG_FILE | path | ~/.local/state/forward-compaction/logs/decisions/.jsonl | Where decision rows go; each session writes {session}.jsonl beside it. With PLUGIN_LOG_DIR set the default is $PLUGIN_LOG_DIR/forward-compaction/decisions/.jsonl. A relative path is refused and the default applies. |
A nudge start at or past its party's ceiling in the same unit is kept and named once: no nudge goes out before the ceiling. A ceiling below Claude Code's ask point is named once at start, for the main chat and for sub-agents: a party with no handoff is then compacted mechanically at its first ask.
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "100000",
"FORWARD_COMPACTION_MAIN_CEILING": "60%",
"FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT": "200k",
"FORWARD_COMPACTION_IGNORE_MODELS": "haiku"
}
}
The plugin also reads, and does not own, PLUGIN_LOG_DIR, HOME and CLAUDE_CONFIG_DIR (for look_back). It reads no CLAUDE_CODE_AUTO_COMPACT_WINDOW: Claude Code's own compaction window and ask point come from $.session.usage.
A plugin can hold a compaction back, but it can't make Claude Code ask sooner. The plugin reads where Claude Code asks from $.session.usage: the breakdown's autoCompactThreshold (about 63k with CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000), against its compaction window rawMaxTokens. When auto-compaction is off, or no breakdown is reported, a warning row says which, and the ask point is taken as rawMaxTokens, else the window. A sub-agent's handoff installs only at such an ask, so set CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000: when the compaction window Claude Code reports is above 100000 the plugin says so once at start, with that window, its source and the ask point. Past that point Claude Code asks before every request, so a stored handoff installs at the party's next request. The low window is there so the plugin can install a handoff whenever the model writes one, never to force one: a held ask changes no nudge, and the model keeps picking its milestone until the ceiling. The main chat does not wait for an ask: its handoff installs at the end of its turn.
Letting a plugin start a compaction for any sub-agent from code would remove this rule. It is requested upstream in anthropics/claude-code#91870.
Sizes are percentages of each party's max context. The main chat's is the window Claude Code reports through $.session.usage(), read at session start, at the main chat's first request when not read yet, and again when its model changes (/model); FORWARD_COMPACTION_MAIN_MAX_CONTEXT sets it instead. Until it is known (set, or reported) the main chat gets no nudge and no ceiling. A sub-agent's is FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT, 200k by default, for every model: Claude Code's API reports only the main chat's window, so the plugin never g
hooks/forward-compaction.ts 961 lines1import type { EngineInterface, On, PluginOptions, Register, SessionCompactInput, SessionCompactResult, ToolCallResult, TurnStartInput, TurnStepInput, TurnStepResult, TurnCompleteInput, PromptSubmitInput, SessionReceiveInput, SkillPromptInput, AgentSpawnInput, AgentSpawnResult } from 'claude-code';
2import { bounded, unlessAborted } from '../src/bounded.ts';
3import { applyDrops, dropNote, pinnedBy, experimental, parseHandoff, loadFileRefusal, loadRoomRefusal, receipt, ignoredRefusal, parseLineRanges, sliceFile, toolInput, type FileCheck, type LoadFile } from '../src/handoff.ts';
4import { workOf, type CallAnswer } from '../src/work.ts';
5import { newLedger, markStart, addEntry, resolveSpawn, attributeSkill, shellsOf, factsFor, promptsKept, restorePrompts, skillsKept, restoreSkills, isPersonOrigin, keyOf, originKind, type Ledger, type PartyKey } from '../src/ledger.ts';
6import { splitAtCall, buildWake, mechanicalWake, landedLine, type WakeFile, type AgentRow, type Landing } from '../src/wake.ts';
7import { decide, turnEndDoor, readingTokens, knownTrigger } from '../src/decide.ts';
8import { k, nudgeLevel, nudgeText, gated, gateMark, gateText } from '../src/nudge.ts';
9import { Parties, type Judging } from '../src/parties.ts';
10import { askPointOf, charsPerToken, partyMarks, startWarnings, apiChars, type AskPoint, type Marks, type UsageBreakdown } from '../src/window.ts';
11import { unseenCompaction } from '../src/unseen.ts';
12import { parseLookBack, boundaryArgv, boundaryLine, searchArgv, entryArgv, namesArgv, processOutput, parseEntries, toolNames, resultIds, searchResults, entryResult, manualTranscriptCall, lookBackNotice, findTranscriptArgv, firstFound, type LookBackResult } from '../src/look-back.ts';
13import { DecisionWriteError, hookRow, sessionLogPath, runCwd, noticeOnScreen, decisionAppendArgv, retentionArgv, requeueRows, keptRowsText, endReport, SkipFold, pluginLogDir, levelRow, pluginLogEvent, PluginLogQueue, type PluginLogLevel, type PluginLogFields } from '../src/log.ts';
14import { resolveOptions, type OptionEnvironment, type ResolvedOptions } from '../src/options.ts';
15import { TOOLS, toolsToRegister, ENSURE_TOOLS_TIMEOUT_MS, END_CORE_RESERVE_MS, REGISTER_TRIES, PENDING_BEFORE_CONFIG, type ToolKind } from '../src/register.ts';
16
17// Thin adapter: every decision lives in src/. Each hook catches its own failures, logs them with context and lets the compaction through.
18
19type Row = Record<string, unknown>;
20
21const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
22
23type State = {
24 config: ResolvedOptions;
25 ignoredOptions: string[]; // The names of the options handed to `register`: none is read, the environment is the only home of an option.
26 pluginLog?: PluginLogQueue;
27 pluginInitialized: boolean;
28 pluginRows: Omit<PluginLogFields, 'plugin' | 'session'>[];
29 pluginLogDir?: string;
30 pluginWriting: Promise<void>;
31 pluginFailing: boolean;
32 started?: Promise<void>;
33 configured?: boolean; // True once `start` has applied the environment layer to `config`: before it, `config` holds the defaults alone.
34 home?: string; // The home directory, for the log directories.
35 toolNames: Partial<Record<ToolKind, string>>; // Each tool's full name once registered.
36 configDir?: string; // The Claude config directory, where transcripts are found.
37 sessionId?: Promise<string>; // Cached session id for the transcript lookup; no process runs.
38 restored: Set<PartyKey>; // Parties whose kept prompts and skills were put back this process (restoreKept).
39 toolTries: Record<ToolKind, number>; // How many times registering each tool was tried.
40 registering?: Promise<void>; // The registration in progress: hooks that need a tool wait on it, so none registers twice.
41 toolsAbandoned?: boolean; // Registering the tools has not finished within ENSURE_TOOLS_TIMEOUT_MS: no hook waits on it until it settles, and a late finish still names the tools.
42 toolsSettled?: boolean; // Every tool is registered or out of tries: no hook registers or sets a timer again.
43 warned: Set<string>; pending: string[];
44 logFailures: Set<string>; // The log-write failures already shown: a log that cannot be written fails every flush the same way, and the person hears each failure once.
45 rowsWriting: number; // Rows the decision append in flight carries; 0 when none is.
46 levelRowsWriting: number; // Level-log rows queued or being written: what a cut end flush may lose from the level log.
47 levelLinks: number; levelBatched: number; // Level-log rows whose write is chained, and rows queued for the burst's one append.
48 skips: SkipFold; // Routine skips fold into one `skips` row per streak.
49 levelSkips: SkipFold; // The same fold for the level log, which writes whether or not the decision log is on.
50 retained?: boolean; // Old sessions' logs were cleaned up once this session (or tried).
51 writeError?: string; // Why the last decision append failed, while its rows wait in `pending`; cleared by the next append that succeeds.
52 rowsDropped?: { rows: number; error: string }; // Rows the hold let go this session, and the failure that dropped the last of them: counted at session end.
53 logPath?: Promise<string | undefined>;
54 logOff?: boolean; // Reading the session id failed: no row is written this session, so a routine line goes to the screen.
55 writing: Promise<void>;
56 parties: Parties; ledger: Ledger;
57 inFlight: Map<string, { party: PartyKey; tool: string }>; // Skill and Agent calls running now, by tool_use_id: the party that made each, for skill attribution and a spawn's parent.
58 pendingCommand?: string; // The command main typed, until its first step: a skill it expands is main's.
59 pendingSpawns: Set<string>; // agent.spawn calls running now: a skill loaded meanwhile is a preload of the one spawn.
60 knownAgents: Map<string, string>; // Sub-agents spawned here, id to type: an ask for any other agent id is the engine's own (C6).
61 mainWindow?: number; // Main's window as `$.session.usage()` last reported it: main's max context unless FORWARD_COMPACTION_MAIN_MAX_CONTEXT is set.
62 apiAsk?: AskPoint; breakdown?: UsageBreakdown; // Claude Code's ask point and the breakdown it was read from, at session start and each main-window read.
63 installedSince: Set<string>; // Parties this plugin compacted since their last reading: a fall in that reading is no unseen compaction.
64};
65
66/** A line on the person's screen: Claude Code already names the plugin in front of it, so the line never names it again. */
67function show($: EngineInterface, line: string): void {
68 $.ui.log(screenLine(line));
69}
70
71const screenLine = (line: string): string => line.replace(/^forward-compaction: /, '');
72
73/** Every line said lands in the decision log as a `said` row; a routine one reaches the screen too while there is no log to hold it. A line said with its diagnostic row is one level-log entry: the row's, at the level said. */
74function say(st: State, $: EngineInterface, line: string, level: PluginLogLevel = 'error', row?: Row): void {
75 const shown = noticeOnScreen({ level, decisionLog: !!st.config.logFile, pluginLog: st.pluginLog !== undefined, logOff: st.logOff || st.writeError !== undefined || st.pluginFailing });
76 const diagnostic = row === undefined ? undefined : pluginLogEvent({ line: screenLine(line), ...row });
77 plog(st, $, diagnostic !== undefined ? { ...diagnostic, level } : { level, event: 'said', msg: screenLine(line), where: 'hooks/forward-compaction.ts#say' });
78 if (shown) show($, line);
79 record(st, $, { event: 'said', line: screenLine(line), shown });
80 if (row !== undefined) record(st, $, row, diagnostic === undefined);
81}
82
83/** Level logging is off the work path; failures report through show, never recursively through say. */
84function plog(st: State, $: EngineInterface, fields: Omit<PluginLogFields, 'plugin' | 'session'>): void {
85 const queue = st.pluginLog;
86 if (queue === undefined) { if (!st.pluginInitialized) st.pluginRows.push(fields); return; }
87 let rows = 1;
88 let held = false; // this row waits in the queue for a later row's append
89 st.levelRowsWriting += rows;
90 st.levelLinks++;
91 st.pluginWriting = st.pluginWriting.then(async () => {
92 let session: string | undefined;
93 let sessionError: string | undefined;
94 try { session = await $.session.id(); }
95 catch (error) { sessionError = `forward-compaction: reading the plugin log session id failed: ${message(error)}; row has no session`; }
96 const now = await $.clock.now();
97 if (sessionError !== undefined) {
98 rows++;
99 st.levelRowsWriting++;
100 queue.add(levelRow({ plugin: 'forward-compaction', level: 'error', event: 'plugin-log-session-error', msg: screenLine(sessionError), where: 'hooks/forward-compaction.ts#plog' }, now));
101 show($, sessionError);
102 }
103 queue.add(levelRow({ plugin: 'forward-compaction', ...fields, ...(session ? { session } : {}) }, now));
104 st.levelBatched += rows;
105 if (st.levelLinks > 1) return void (held = true); // a burst of rows is one append process: the burst's last row writes them all
106 [rows, st.levelBatched] = [st.levelBatched, 0];
107 const out = await queue.flush((argv) => $.process.run(argv, { ...runCwd(st.pluginLogDir ?? ''), timeoutMs: 15_000 }));
108 if (out.error !== undefined && !st.pluginFailing) pluginLogFailed(st, $, out.error, out.dropped);
109 st.pluginFailing = out.dropped !== 0;
110 }).catch((error) => {
111 if (!st.pluginFailing) pluginLogFailed(st, $, message(error), rows);
112 st.pluginFailing = true;
113 }).finally(() => { st.levelLinks--; if (!held) st.levelRowsWriting -= rows; });
114}
115
116/** Once per failing streak: on the screen and in the decision log, whose row maps to no level, so it never recurses into the failing log. */
117function pluginLogFailed(st: State, $: EngineInterface, error: string, dropped: number): void {
118 show($, `forward-compaction: plugin log ${st.pluginLogDir} failed: ${error}; dropped ${dropped} ${dropped === 1 ? 'row' : 'rows'}`);
119 record(st, $, { event: 'plugin-log-failed', dir: st.pluginLogDir ?? null, error, dropped });
120}
121
122// Rows queue in memory; each flush appends them to the session's file through the append script, under its lock, so a resumed session keeps its earlier rows, no row is rewritten and no other chat shares the file.
123function record(st: State, $: EngineInterface, row: Row, levelRow = true): void {
124 dbg(st, $, `row:${String(row.event)}`, row); // every decision row, whole, before any fold
125 const ts = new Date().toISOString(); // one stamp for both logs. Folded first: a held-ask streak reaches the level log as its first row and one `skips` row.
126 if (levelRow) for (const diagnostic of st.levelSkips.add({ ts, ...row }).map(pluginLogEvent)) if (diagnostic !== undefined) plog(st, $, diagnostic);
127 // The decision log's file may come from the environment, which `start` reads: a row before that waits, so the session's one file is chosen from the full config. `settleConfig` writes what waited.
128 if (!st.configured) {
129 for (const out of st.skips.add({ ts, ...row })) if (st.pending.length < PENDING_BEFORE_CONFIG) st.pending.push(JSON.stringify(out));
130 return;
131 }
132 if (!st.config.logFile) return;
133 for (const out of st.skips.add({ ts, ...row })) st.pending.push(JSON.stringify(out));
134 flushDecisions(st, $);
135}
136
137/** What every party is judged by now: main's max context known, and the ignore list. */
138const judging = (st: State): Judging => ({ mainKnown: st.config.main.maxContext !== undefined || st.mainWindow !== undefined, ignore: st.config.ignoreModels });
139
140/** A party's ignore judgement, written as an `ignore` row when it is new or has changed. */
141function judge(st: State, $: EngineInterface, key: string): void { const row = st.parties.judged(key, judging(st)); if (row) record(st, $, row); }
142
143/** A debug row (`debug.jsonl`) while FORWARD_COMPACTION_DEBUG is on: numbers as the API gave them. */
144function dbg(st: State, $: EngineInterface, event: string, detail: Row): void {
145 if (st.config.debug) plog(st, $, { level: 'debug', event, msg: event, where: 'hooks/forward-compaction.ts', detail });
146}
147
148/** Marks the config as final and writes the rows that waited for it, or drops them when no decision log is on. */
149function settleConfig(st: State, $: EngineInterface): void {
150 if (st.configured) return;
151 st.configured = true;
152 if (!st.config.logFile) {
153 st.pending.length = 0;
154 st.skips.drain();
155 } else {
156 for (const out of st.skips.drain()) if (st.pending.length < PENDING_BEFORE_CONFIG) st.pending.push(JSON.stringify(out));
157 if (st.pending.length > 0) flushDecisions(st, $);
158 }
159}
160
161/** `last`: the session-end flush, after which no write comes, so a failure promises none. */
162function flushDecisions(st: State, $: EngineInterface, last = false): void {
163 const file = st.config.logFile;
164 if (!file) return;
165 st.logPath ??= $.session.id().then(
166 (id) => sessionLogPath(file, id),
167 (error) => {
168 // The level log is independent of the decision log that is off.
169 st.logOff = true;
170 const line = `forward-compaction: reading the session id failed: ${message(error)}; the decision log is off for this session`;
171 plog(st, $, { level: 'error', event: 'decision-log-session-error', msg: screenLine(line), where: 'hooks/forward-compaction.ts#record', detail: { error: message(error) } });
172 show($, line);
173 return undefined;
174 },
175 );
176 // Taken now: rows queued before a /clear go to the ending session's file even when they are written after startOver.
177 const logPath = st.logPath;
178 const pending = st.pending;
179 // The failure line names the session's own file: the configured logFile is only its template (`decisions/.jsonl`).
180 let named: string = file;
181 st.writing = st.writing
182 .then(async () => {
183 const path = await logPath;
184 if (path) named = path;
185 const rows = pending.splice(0);
186 if (!path || rows.length === 0) return;
187 st.rowsWriting = rows.length;
188 try {
189 const out = await $.process.run(decisionAppendArgv(path), { ...runCwd(path), stdin: rows.map((line) => `${line}\n`).join(''), timeoutMs: 15_000 });
190 if (out.exitCode !== 0) throw new Error(out.stderr.split('\n')[0]?.trim() || `the append script exited ${out.exitCode}`);
191 st.writeError = undefined;
192 } catch (error) {
193 // The `$` of an abandoned dispatch (an interrupt, a rejected tool) aborts its run: the batch goes back to the front of the queue and the next flush, under a live dispatch, writes it. A run rejected after its child appended them writes them twice: a duplicate row, never a lost one; a child killed mid-row leaves a torn line, and the append script starts the retried rows on a line of their own.
194 st.writeError = message(error);
195 const dropped = requeueRows(pending, rows);
196 if (dropped > 0) st.rowsDropped = { rows: (st.rowsDropped?.rows ?? 0) + dropped, error: message(error) };
197 throw new DecisionWriteError(message(error), rows.length, dropped);
198 }
199 // Written: no longer in flight while the cleanup runs.
200 st.rowsWriting = 0;
201 // Once per session, after its first rows are written: they never wait behind the cleanup.
202 if (!st.retained) {
203 st.retained = true;
204 await retainLogs(st, $, file, path);
205 }
206 })
207 .finally(() => { st.rowsWriting = 0; })
208 // Screen and level log only, once per distinct failure: never queue another decision write. The key drops numbers, so one failure whose text carries a varying size or offset is still shown once.
209 .catch((error) => {
210 const kept = error instanceof DecisionWriteError ? keptRowsText(error.rows, error.dropped, last) : '';
211 const failed = `forward-compaction: writing the decision log for ${named} failed: ${message(error)}`;
212 const line = `${failed}${kept}`;
213 // The first drop past the hold is told even when its failure already was; the session-end line counts them all.
214 const key = `${failed}${error instanceof DecisionWriteError && error.dropped > 0 ? ' (hold)' : ''}`.replace(/\d+/g, '#');
215 if (st.logFailures.has(key)) return;
216 st.logFailures.add(key);
217 plog(st, $, { level: 'error', event: 'decision-log-write-error', msg: screenLine(line), where: 'hooks/forward-compaction.ts#record', detail: { error: message(error) } });
218 show($, line);
219 });
220}
221
222/** Once per session, after its first append: old sessions' logs beside this one go; a failure is told once, in both logs, and the log goes on. */
223async function retainLogs(st: State, $: EngineInterface, file: string, path: string): Promise<void> {
224 try {
225 const out = await $.process.run(retentionArgv(file, path), { ...runCwd(file, path), timeoutMs: 15_000 });
226 if (out.exitCode !== 0) throw new Error(out.stderr.split('\n')[0]?.trim() || `the retention script exited ${out.exitCode}`);
227 } catch (error) {
228 const line = `forward-compaction: cleaning up old decision logs beside ${path} failed: ${message(error)}; they stay`;
229 say(st, $, line, 'warning', { event: 'decision-retention-failed', file, error: message(error), line: screenLine(line) });
230 }
231}
232
233/** After a /clear or a resume: main's conversation is a new one, under a new session id and its own decision log file. */
234function startOver(st: State): void {
235 Object.assign(st, { sessionId: undefined, logPath: undefined, pending: [], retained: false, writeError: undefined, rowsDropped: undefined });
236}
237
238function warnOnce(st: State, $: EngineInterface, key: string, line: string, level: PluginLogLevel = 'warning'): void {
239 if (st.warned.has(key)) return;
240 st.warned.add(key);
241 say(st, $, line, level, { event: level === 'error' ? 'error' : 'warning', key, line });
242}
243
244/** The environment variable of each option, by name. Claude Code reads only a name spelled as a string literal, so each is written out twice here (the key and the call); a test holds them to `OPTION_ENV`. */
245async function readOptionEnv($: EngineInterface): Promise<Pick<OptionEnvironment, 'vars' | 'readErrors'>> {
246 const vars: Record<string, string> = {};
247 const readErrors: string[] = [];
248 const readOptionVar = async (name: string, get: () => Promise<string | undefined>): Promise<void> => {
249 try {
250 const value = await get();
251 if (value !== undefined) vars[name] = value;
252 } catch (error) {
253 readErrors.push(`forward-compaction: reading ${name} failed: ${message(error)}; using the default`);
254 }
255 };
256 await Promise.all([
257 readOptionVar('FORWARD_COMPACTION_MAIN_MAX_CONTEXT', () => $.env.get('FORWARD_COMPACTION_MAIN_MAX_CONTEXT')),
258 readOptionVar('FORWARD_COMPACTION_MAIN_CEILING', () => $.env.get('FORWARD_COMPACTION_MAIN_CEILING')),
259 readOptionVar('FORWARD_COMPACTION_MAIN_NUDGE_START', () => $.env.get('FORWARD_COMPACTION_MAIN_NUDGE_START')),
260 readOptionVar('FORWARD_COMPACTION_MAIN_NUDGE_EVERY', () => $.env.get('FORWARD_COMPACTION_MAIN_NUDGE_EVERY')),
261 readOptionVar('FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT', () => $.env.get('FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT')),
262 readOptionVar('FORWARD_COMPACTION_SUBAGENT_CEILING', () => $.env.get('FORWARD_COMPACTION_SUBAGENT_CEILING')),
263 readOptionVar('FORWARD_COMPACTION_SUBAGENT_NUDGE_START', () => $.env.get('FORWARD_COMPACTION_SUBAGENT_NUDGE_START')),
264 readOptionVar('FORWARD_COMPACTION_SUBAGENT_NUDGE_EVERY', () => $.env.get('FORWARD_COMPACTION_SUBAGENT_NUDGE_EVERY')),
265 readOptionVar('FORWARD_COMPACTION_CEILING_NATIVE', () => $.env.get('FORWARD_COMPACTION_CEILING_NATIVE')),
266 readOptionVar('FORWARD_COMPACTION_LOG_FILE', () => $.env.get('FORWARD_COMPACTION_LOG_FILE')),
267 readOptionVar('FORWARD_COMPACTION_IGNORE_MODELS', () => $.env.get('FORWARD_COMPACTION_IGNORE_MODELS')),
268 readOptionVar('FORWARD_COMPACTION_KEEP_SKILLS', () => $.env.get('FORWARD_COMPACTION_KEEP_SKILLS')),
269 readOptionVar('FORWARD_COMPACTION_DEBUG', () => $.env.get('FORWARD_COMPACTION_DEBUG')),
270 ]);
271 return { vars, readErrors };
272}
273
274// First hook call: report option errors, find the log directories, and resolve the options.
275function start(st: State, $: EngineInterface): Promise<void> {
276 st.started ??= (async () => {
277 const early: { line: string; level: PluginLogLevel }[] = [];
278 let home: string | undefined;
279 try {
280 home = await $.env.get('HOME');
281 st.home = home;
282 } catch (error) {
283 early.push({ line: `forward-compaction: reading HOME failed: ${message(error)}; the logs have no home directory`, level: 'error' });
284 }
285 let override: string | undefined;
286 try {
287 override = await $.env.get('PLUGIN_LOG_DIR');
288 } catch (error) {
289 early.push({ line: `forward-compaction: reading PLUGIN_LOG_DIR failed: ${message(error)}; using HOME fallback`, level: 'warning' });
290 }
291 st.pluginInitialized = true;
292 st.pluginLogDir = pluginLogDir(override, home, 'forward-compaction');
293 if (st.pluginLogDir !== undefined) st.pluginLog = new PluginLogQueue(st.pluginLogDir, 'forward-compaction');
294 else show($, 'plugin log off: no PLUGIN_LOG_DIR and no HOME');
295 const env = await readOptionEnv($);
296 st.config = resolveOptions({ pluginLogDir: override, home, ...env });
297 const earlyRows = st.pluginRows.splice(0); // rows held before the level log had a home: debug ones only while the debug log is on
298 if (st.pluginLog !== undefined) for (const fields of earlyRows) if (fields.level !== 'debug' || st.config.debug) plog(st, $, fields);
299 dbg(st, $, 'env', { HOME: home ?? null, PLUGIN_LOG_DIR: override ?? null, ...env.vars, readErrors: env.readErrors });
300 const { errors: _, ...options } = st.config;
301 dbg(st, $, 'options', options);
302 settleConfig(st, $);
303 for (const { line, level } of early) say(st, $, line, level);
304 for (const line of st.config.errors) {
305 say(st, $, line, 'error', { event: 'option-error', line });
306 }
307 if (st.ignoredOptions.length > 0) {
308 const line = `forward-compaction: options ${st.ignoredOptions.join(', ')} are not read; set FORWARD_COMPACTION_* environment variables instead (README, Options); their values are not applied`;
309 warnOnce(st, $, 'options-ignored', line, 'error');
310 }
311 try {
312 st.configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) || (home ? `${home}/.claude` : undefined);
313 } catch (error) {
314 say(st, $, `forward-compaction: reading CLAUDE_CONFIG_DIR failed: ${message(error)}; look_back reads ~/.claude only`, 'warning');
315 st.configDir = home ? `${home}/.claude` : undefined;
316 }
317 record(st, $, { event: 'start', main: st.config.main, subagent: st.config.subagent, ceilingNative: st.config.ceilingNative, ignoreModels: st.config.ignoreModels });
318 })().finally(() => settleConfig(st, $));
319 return st.started;
320}
321
322async function registerTool(st: State, $: EngineInterface, kind: ToolKind): Promise<void> {
323 const { lost, ...spec } = TOOLS[kind];
324 try {
325 const { tool } = await $.tool.register(spec);
326 st.toolNames[kind] = tool;
327 record(st, $, { event: 'tool-registered', tool });
328 } catch (error) {
329 warnOnce(st, $, `register-failed:${kind}`, `forward-compaction: registering the ${spec.name} tool failed: ${message(error)}; ${lost}`, 'error');
330 record(st, $, { event: 'tool-register-failed', tool: spec.name, error: message(error), tries: st.toolTries[kind] });
331 }
332}
333
334async function answerLookBackTool(st: State, $: EngineInterface, e: Record<string, unknown>): Promise<string> {
335 const agentId = typeof e.agentId === 'string' ? e.agentId : undefined;
336 const party = agentId ?? 'main';
337 const args = parseLookBack(toolInput(e));
338 const label = agentId ?? 'main';
339 let boundary: number | null = null;
340 let result: LookBackResult;
341 try {
342 await start(st, $);
343 if (st.configDir === undefined) throw new Error('no Claude config directory');
344 const session = await (st.sessionId ??= $.session.id());
345 const found = processOutput(await $.process.run(findTranscriptArgv({ configDir: st.configDir, session, ...(agentId !== undefined ? { agentId } : {}) }), { ...runCwd(st.configDir), timeoutMs: 15_000 }), 'transcript lookup');
346 const file = firstFound(found);
347 if (file === undefined) throw new Error(`no transcript file for ${agentId === undefined ? `session ${session}` : `agent ${agentId}`}`);
348 const bounds = processOutput(await $.process.run(boundaryArgv(file), { ...runCwd(file), timeoutMs: 15_000 }), 'compaction boundary', true);
349 boundary = boundaryLine(bounds) ?? null;
350 // Before any argument check: with nothing compacted there is no earlier transcript, whatever was asked.
351 if (boundary === null) result = { text: 'Nothing has been compacted yet, so everything is still in context.', hits: 0 };
352 else if (args.error) result = { text: `Look back failed: ${args.error}`, error: args.error, input: true };
353 else if (args.entry !== undefined && args.entry >= boundary) result = entryResult([], args.entry, boundary);
354 else {
355 const argv = args.entry !== undefined ? entryArgv(file, args.entry) : searchArgv(file, boundary, args.terms);
356 const raw = boundary === 1 ? '' : processOutput(await $.process.run(argv, { ...runCwd(file), timeoutMs: 30_000 }), args.entry !== undefined ? 'entry fetch' : 'transcript search', true);
357 const entries = parseEntries(raw).filter((entry) => entry.line < boundary!);
358 let names = toolNames(entries);
359 const ids = resultIds(entries).filter((id) => !Object.hasOwn(names, id));
360 if (ids.length > 0) {
361 const uses = processOutput(await $.process.run(namesArgv(file, boundary, ids), { ...runCwd(file), timeoutMs: 30_000 }), 'tool name lookup', true);
362 names = { ...names, ...toolNames(parseEntries(uses).filter((entry) => entry.line < boundary!)) };
363 }
364 result = args.entry !== undefined ? entryResult(entries, args.entry, boundary, names) : searchResults(entries, args.terms, boundary, names, st.toolNames.forward_compaction);
365 }
366 } catch (error) {
367 const reason = message(error);
368 result = { text: `Look back failed: ${reason}`, error: reason };
369 }
370 record(st, $, { event: 'look-back', party, ...(args.query !== undefined ? { query: args.query } : {}), ...(args.entry !== undefined ? { entry: args.entry } : {}), ...(result.hits !== undefined ? { hits: result.hits } : {}), boundary, ...(result.error !== undefined ? { error: result.error } : {}) });
371 // A failure the caller's input caused is a warning; only the plugin's own fault is an error.
372 say(st, $, lookBackNotice(label, args, result), result.error === undefined ? 'info' : result.input ? 'warning' : 'error');
373 return result.text;
374}
375
376/** Observation never rejects, rewrites or starts a process for an ordinary tool call. */
377async function observeManualLookBack(st: State, $: EngineInterface, e: Record<string, unknown>): Promise<void> {
378 const tool = typeof e.tool === 'string' ? e.tool : '';
379 if (!['Read', 'Grep', 'Bash'].includes(tool)) return;
380 const agentId = typeof e.agentId === 'string' ? e.agentId : undefined;
381 const party = agentId ?? 'main';
382 try {
383 const session = agentId === undefined ? await (st.sessionId ??= $.session.id()) : '';
384 if (!manualTranscriptCall(tool, toolInput(e), session, agentId)) return;
385 record(st, $, { event: 'look-back-manual', party, tool });
386 say(st, $, `look back by hand (${party}): ${tool} on the transcript`, 'info');
387 } catch (error) {
388 say(st, $, `forward-compaction: observing manual transcript lookup of ${party} via ${tool} failed: ${message(error)}`, 'error', { event: 'look-back-manual-error', party, tool, error: message(error) });
389 }
390}
391
392/** `$.clock.after`, or, where the engine has none, a timer that never fires and a warning once: the wait is then unbounded, as it was. A call a hook refuses without throwing never fires either and is not reported: that wait is unbounded too. */
393function afterOrNever(st: State, $: EngineInterface, ms: number, fn: () => void, what: string): { cancel: () => void } {
394 try {
395 return $.clock.after(ms, fn);
396 } catch (error) {
397 warnOnce(st, $, `no-clock:${what}`, `forward-compaction: $.clock.after is unavailable (${message(error)}); ${what} is not time-bounded`, 'error');
398 return { cancel: () => {} };
399 }
400}
401
402/** Registers each tool that is not registered yet, up to REGISTER_TRIES hook calls each: `session.start` may not fire (a plugin loaded mid-session) or may run before the session can take a tool. Hooks that need a tool wait on it. */
403async function ensureTools(st: State, $: EngineInterface, signal?: AbortSignal): Promise<void> {
404 if (st.toolsAbandoned || st.toolsSettled) return;
405 const left = () => toolsToRegister({ forward_compaction: true, look_back: true }, { forward_compaction: st.toolNames.forward_compaction !== undefined, look_back: st.toolNames.look_back !== undefined }, st.toolTries, REGISTER_TRIES);
406 const began = Date.now();
407 const names = () => Object.values(st.toolNames);
408 const had = new Set(names());
409 const registered = (async () => {
410 await start(st, $);
411 st.registering = (st.registering ?? Promise.resolve())
412 .then(async () => {
413 for (const kind of left()) {
414 st.toolTries[kind]++;
415 await registerTool(st, $, kind);
416 }
417 })
418 .catch((error) => say(st, $, `forward-compaction: registering the tools failed: ${message(error)}`));
419 await st.registering;
420 if (left().length === 0) st.toolsSettled = true;
421 })();
422 // A hang here would hold every turn of every chat: past the limit the turn goes on, and the person is told. The dispatch's abort ends the wait too: its timer may never fire, and a hook that waits on past its abort starves the hooks worker.
423 const outcome = await bounded(registered, ENSURE_TOOLS_TIMEOUT_MS, (ms, fn) => afterOrNever(st, $, ms, fn, 'registering the tools'), signal);
424 if (!outcome.timedOut) return;
425 st.toolsAbandoned = true;
426 // Once the slow registration settles, a later hook spends the tries left, and the late finish is told with the tools it named.
427 const resume = () => void (st.toolsAbandoned = false);
428 void registered.then(() => {
429 resume();
430 const ms = Date.now() - began;
431 // The tools this attempt registered, never an earlier one's; none is said as such, with the tries left still to come.
432 const tools = names().filter((name) => !had.has(name));
433 say(st, $, `forward-compaction: registering the tools finished after ${Math.round(ms / 1000)}s: ${tools.length > 0 ? tools.join(', ') : 'it registered none'}`, 'info', { event: 'tools-late', ms, tools });
434 }, resume);
435 // An interrupted wait is no slow registration: nothing to tell.
436 if (outcome.aborted) return;
437 warnOnce(st, $, 'tools-timeout', `forward-compaction: registering the tools did not finish within ${ENSURE_TOOLS_TIMEOUT_MS / 1000}s; it goes on, turns do not wait for it, and the tools come into use once it finishes`);
438}
439
440/** A line the person always sees (a landed compaction, a dropped handoff, a fault the plugin worked around), and its row. */
441function tell(st: State, $: EngineInterface, line: string, row: Row): void {
442 show($, line);
443 record(st, $, { ...row, line: screenLine(line) });
444}
445
446/** D9: a hook's own work never throws out of the plugin; a failure is said with the hook it came from, and the event goes on. */
447async function guard(st: State, $: EngineInterface, where: string, work: () => unknown, e?: unknown): Promise<void> {
448 dbg(st, $, 'hook', hookRow(where, e));
449 try {
450 await work();
451 } catch (error) {
452 say(st, $, `forward-compaction: ${where} failed: ${message(error)}`, 'error', { event: 'hook-error', where, error: message(error) });
453 }
454}
455
456/** A party's token marks against its max context (main's option or reported window, the sub-agent option); undefined while main's is not known. */
457function marks(st: State, key: string): Marks | undefined {
458 return key === 'main' ? partyMarks(st.config.main, st.config.main.maxContext ?? st.mainWindow, st.apiAsk?.tokens) : partyMarks(st.config.subagent, st.config.subagent.maxContext, st.apiAsk?.tokens);
459}
460
461/** After a /clear or a resume: no party, fact or call of the old conversation carries into the new one. */
462async function conversationStartsOver(st: State, $: EngineInterface, reason: string): Promise<void> {
463 st.parties = new Parties();
464 st.ledger = markStart(newLedger(), await $.clock.now(), reason);
465 st.inFlight.clear();
466 st.pendingSpawns.clear();
467 st.installedSince.clear();
468 st.restored.clear();
469 st.pendingCommand = undefined;
470}
471
472/** session.start: the ledger starts, main's window and Claude Code's ask point are read, and a compaction window that holds a handoff back is named once (R33). */
473async function sessionBegins(st: State, $: EngineInterface): Promise<void> {
474 st.ledger = markStart(st.ledger, await $.clock.now(), 'startup');
475 await readMainWindow(st, $);
476 judge(st, $, 'main');
477 for (const w of startWarnings(st.breakdown, st.apiAsk, marks(st, 'main'), marks(st, 'agent:')!)) if (!st.warned.has(w.key)) (st.warned.add(w.key), tell(st, $, w.line, w.row));
478}
479
480/** ENOENT is a missing file; any other failure to stat is said as the error it is. */
481async function statFile(st: State, $: EngineInterface, path: string): Promise<FileCheck> {
482 const check: FileCheck = await Promise.resolve().then(() => $.fs.stat(path)).then((stat) => ({ kind: stat.kind, size: stat.size }), (error: unknown) => ((error as { code?: unknown } | null)?.code === 'ENOENT' ? { absent: true } : { error: message(error) }));
483 dbg(st, $, 'fs.stat', { path, ...check });
484 return check;
485}
486
487/** The forward_compaction tool (R31): the handoff is checked, stored for the party, and answered with the receipt of its door. */
488async function answerForward(st: State, $: EngineInterface, e: Record<string, unknown>): Promise<string> {
489 const key = keyOf(e.agentId);
490 try {
491 await start(st, $);
492 const parsed = parseHandoff(toolInput(e));
493 const m = marks(st, key);
494 const matched = st.parties.ignored(key, st.config.ignoreModels);
495 let refusal = ignoredRefusal(matched, m?.ceiling) ?? ('refusal' in parsed ? parsed.refusal : undefined);
496 if (!('refusal' in parsed)) for (const [index, file] of parsed.loadFiles.entries()) refusal ??= loadFileRefusal(index, file.path, await statFile(st, $, file.path));
497 if (refusal === undefined && !('refusal' in parsed) && parsed.loadFiles.length > 0) {
498 const ratio = ratioOf(st, $, key, 'tool-call', await partyChars(st, $, key, e.agentId), m);
499 const files = (await readLoadFiles($, parsed.loadFiles)).map((f) => ({ path: f.path, chars: f.content?.length ?? 0 }));
500 if (ratio === undefined || m === undefined) say(st, $, `forward-compaction: ${key}'s load_files were accepted without a size check: ${m === undefined ? "main's max context is not known yet" : 'its characters per token are not measured yet'}; the wake names any file it cannot carry`, 'warning', { event: 'warning', key: 'load-files-unchecked', party: key, files: files.length });
501 else refusal = loadRoomRefusal(files, m.maxContext, ratio);
502 }
503 if (refusal === undefined && typeof e.tool_use_id !== 'string') refusal = 'forward_compaction refused, nothing stored: the call has no tool_use_id, so the wake cannot find it.';
504 if (refusal !== undefined || 'refusal' in parsed) {
505 record(st, $, { event: 'refused', party: key, refusal, ...(matched !== undefined ? { matched } : {}) }); // the refusal names the field that failed
506 return refusal!;
507 }
508 const tokens = st.parties.get(key).reading; // the last reading: the call's own context is measured only by the next request
509 const replaced = st.parties.store(key, { text: parsed.handoff, loadFiles: parsed.loadFiles, toolUseId: e.tool_use_id as string, storedAtTokens: tokens, since: st.ledger.nextSeq });
510 await restoreKept(st, $, key); // a skill kept across a plugin reload is in the ledger before a drop can take it out
511 const drop = applyDrops(st.ledger, key, parsed.dropSkills, st.parties.get(key).dropAsked); (st.ledger = drop.ledger), (st.parties.get(key).dropAsked = drop.outcome.pending); // a handoff naming nothing resets the count
512 if (parsed.dropSkills.length > 0) (record(st, $, { event: 'drop', party: key, ...drop.outcome }), await keepKept(st, $, key));
513 const files = parsed.loadFiles.length;
514 record(st, $, { event: 'stored', party: key, chars: parsed.handoff.length, files, tokens: tokens ?? null, ...(replaced !== undefined ? { replaced: replaced.storedAtTokens ?? null } : {}) });
515 return [key === 'main' ? receipt({ party: 'main', chars: parsed.handoff.length, files, tokens, askPoint: m?.askPoint, replaced: replaced !== undefined }) : receipt({ party: 'subagent', chars: parsed.handoff.length, files, tokens, askPoint: m!.askPoint, replaced: replaced !== undefined }), dropNote(drop.outcome)].filter(Boolean).join(' ');
516 } catch (error) {
517 say(st, $, `forward-compaction: storing ${key}'s handoff failed: ${message(error)}`, 'error', { event: 'store-error', party: key, error: message(error) });
518 return `forward_compaction failed, nothing stored: ${message(error)}`;
519 }
520}
521
522/** Past a nudge point with nothing stored, the tool result carries the nudge once per level (R37). */
523async function nudged(st: State, $: EngineInterface, key: string, result: ToolCallResult): Promise<ToolCallResult> {
524 if (result.deny !== undefined) return result;
525 try {
526 await start(st, $);
527 judge(st, $, key); const party = st.parties.get(key);
528 const tokens = party.reading; // measured only: a step late at worst, never an estimate
529 const m = marks(st, key);
530 const level = tokens === undefined || m === undefined ? 0 : nudgeLevel(tokens, m.start, m.every);
531 const withheld = tokens === undefined ? 'no-reading' : m === undefined ? 'window-unknown' : level === 0 ? 'below-start' : st.parties.ignored(key, st.config.ignoreModels) !== undefined ? 'ignored-model' : !st.parties.nudgeDue(key, level) ? 'not-due' : undefined;
532 if (withheld !== undefined) return (dbg(st, $, 'nudge-withheld', { party: key, why: withheld, tokens: tokens ?? null, level, start: m?.start ?? null, every: m?.every ?? null }), result);
533 record(st, $, { event: 'nudge', party: key, level, tokens, start: m!.start, every: m!.every });
534 return { ...result, context: [...(result.context ?? []), nudgeText({ tokens: tokens!, window: m!.maxContext, ceiling: m!.ceiling, tool: st.toolNames.forward_compaction, ...(key === 'main' || party.gateLevel === undefined ? {} : { gateAt: gateMark(m!.start, m!.every, party.gateLevel) }) })] };
535 } catch (error) {
536 say(st, $, `forward-compaction: nudging ${key} failed: ${message(error)}`, 'error', { event: 'nudge-error', party: key, error: message(error) });
537 return result;
538 }
539}
540
541/** The refusal for a call that waits on the party's handoff (src/nudge.ts `gated`); `opens` or `open` once the gate lets it run (Parties `gateAnswer`); undefined when not gated. */
542function gateRefusal(st: State, key: PartyKey, tool: string): string | undefined {
543 if (!key.startsWith('agent:') || !st.knownAgents.has(key.slice('agent:'.length))) return undefined; // main and Claude Code's own forks are never gated
544 const party = st.parties.get(key);
545 const m = marks(st, key);
546 const open = [st.toolNames.forward_compaction, st.toolNames.look_back, 'ToolSearch'].filter((t): t is string => t !== undefined);
547 const level = party.reading === undefined || m === undefined ? 0 : nudgeLevel(party.reading, m.start, m.every);
548 if (m === undefined || !gated({ party: 'subagent', level, gateLevel: party.gateLevel, stored: party.stored !== undefined, ignored: st.parties.ignored(key, st.config.ignoreModels) !== undefined, tool, open })) return undefined;
549 const answer = st.parties.gateAnswer(key);
550 return answer === 'refuse' ? gateText({ tokens: party.reading!, ceiling: m.ceiling, tool: st.toolNames.forward_compaction }) : answer;
551}
552
553/** A sub-agent spawned here is ours; any other agent id the engine's own run, unless `$.agent.list` knows it. */
554async function partyKind(st: State, $: EngineInterface, agentId: string): Promise<'subagent' | 'foreign'> {
555 if (st.knownAgents.has(agentId)) return 'subagent';
556 try {
557 const agents = await $.agent.list();
558 const agent = agents.find((a) => a.id === agentId);
559 dbg(st, $, 'agent.list', { count: agents.length, agentId, found: agent !== undefined });
560 if (agent === undefined) return 'foreign';
561 st.knownAgents.set(agentId, agent.type);
562 st.parties.describe(`agent:${agentId}`, { label: `${agent.type} ${agentId.slice(0, 6)}` });
563 return 'subagent';
564 } catch (error) {
565 warnOnce(st, $, 'agent-list', `forward-compaction: listing agents failed: ${message(error)}; agent ${agentId} is handled as a sub-agent`);
566 return 'subagent';
567 }
568}
569
570/** A rejected list is the wake's own agents error; a list that is no list throws, and the ask falls to C11. */
571async function agentRows($: EngineInterface): Promise<AgentRow[] | { error: string }> {
572 const list = await $.agent.list().then((agents) => agents, (error: unknown) => ({ error: message(error) }));
573 return 'error' in list ? list : list.map((a) => ({ id: a.id, type: a.type, status: a.status, description: a.description, ...(a.parentId !== undefined ? { parentId: a.parentId } : {}) }));
574}
575
576/** Each load file read now, whole or by its lines; one that cannot be read is carried as its problem. */
577async function readLoadFiles($: EngineInterface, files: readonly LoadFile[]): Promise<WakeFile[]> {
578 return Promise.all(files.map(async (file): Promise<WakeFile> => {
579 try {
580 const sliced = sliceFile(await $.fs.read(file.path), file.lines === undefined ? undefined : parseLineRanges(file.lines));
581 return 'body' in sliced ? { ...file, ...(sliced.shown ? { lines: sliced.shown } : {}), content: sliced.body } : { ...file, problem: sliced.problem };
582 } catch (error) {
583 return { ...file, problem: message(error) };
584 }
585 }));
586}
587
588/** A wake the engine took: no reading stands until the next request's, its next fall is no unseen compaction, and a landing still waiting from an earlier install is said now. */
589function installed(st: State, $: EngineInterface, key: string, landing: Landing | undefined, signal?: AbortSignal): void {
590 if (signal?.aborted) return;
591 land(st, $, key, st.parties.installed(key, landing));
592 st.installedSince.add(key);
593}
594
595/** M2: an installed wake's landed line and `landed` row, at the party's first reading after it (`after`), or at its end with `after` unknown. A chosen handoff's line is a routine info row in the logs; a mechanical wake lost the model's frontier, so the person sees it. */
596function land(st: State, $: EngineInterface, key: string, landing: Landing | undefined, after?: number): void {
597 if (landing !== undefined) { const line = landedLine(landing, after), row = { event: 'landed', party: key, kind: landing.kind, before: landing.before ?? null, after: after ?? null, line: screenLine(line) }; if (landing.kind === 'handoff') say(st, $, line, 'info', row); else tell(st, $, line, row); }
598}
599
600/** One session.compact ask, rows C1-C10: decide, then pass, hold, or install the handoff or a mechanical wake. */
601async function compactAsk(st: State, $: EngineInterface, e: SessionCompactInput, key: PartyKey, pass: () => Promise<SessionCompactResult>, signal?: AbortSignal): Promise<SessionCompactResult> {
602 await start(st, $);
603 for (const who of [key, 'unknown' as const]) await guard(st, $, 'kept restore', () => restoreKept(st, $, who));
604 let trigger = knownTrigger(e.trigger);
605 if (trigger === undefined) {
606 warnOnce(st, $, `trigger:${String(e.trigger)}`, `forward-compaction: session.compact trigger ${String(e.trigger)} is unknown; it is handled as an automatic ask`);
607 trigger = 'auto';
608 }
609 const kind = key === 'main' ? 'main' : await partyKind(st, $, e.agentId!);
610 judge(st, $, key); const party = st.parties.get(key);
611 const { label, stored: held } = party;
612 const found = held !== undefined && splitAtCall(e.messages, held.toolUseId) !== undefined;
613 const tokens = party.reading; // measured only; none since an install, or yet, is unknown
614 const m = marks(st, key);
615 const matched = st.parties.ignored(key, st.config.ignoreModels);
616 const action = decide({ trigger, party: kind, stored: found, storedMissing: held !== undefined && !found, tokens, ceiling: m?.ceiling, reactive: st.parties.reactive(key), ceilingNative: st.config.ceilingNative, label, ignored: matched !== undefined, windowUnknown: st.parties.windowUnknown(key, judging(st).mainKnown) });
617 const row: Row = { event: 'compact', party: key, trigger, decision: action.decision, why: action.why, tokens: tokens ?? null, ceiling: m?.ceiling ?? null, askPoint: m?.askPoint ?? null, ...(matched !== undefined ? { matched } : {}) };
618 if ('dropStored' in action && action.dropStored && held !== undefined) {
619 st.parties.takeStored(key);
620 tell(st, $, action.why === 'manual' ? `forward-compaction: /compact ran Claude Code's own summary; ${label}'s stored handoff was dropped.` : `forward-compaction: ${label}'s stored handoff is no longer in its transcript (compacted by something else); it was dropped.`, { event: 'dropped', party: key, why: action.why });
621 }
622 if (action.action === 'next') {
623 if (kind === 'foreign') st.parties.forget(key);
624 else (st.parties.asked(key, action.decision), st.installedSince.add(key)); // Claude Code's own summary: its fall is seen
625 record(st, $, row);
626 if (action.decision === 'native' && action.why !== 'manual') tell(st, $, `forward-compaction: ${label} ${action.why === 'ceiling' ? `reached its ceiling ${k(m!.ceiling)}` : 'was too long for its request'} with no handoff; Claude Code's own summary ran (FORWARD_COMPACTION_CEILING_NATIVE=1).`, { event: 'native', party: key, why: action.why });
627 return pass();
628 }
629 if (action.action === 'skip') {
630 // A precompute is no ask for a handoff: only a held one makes the next ask reactive (D8).
631 if (action.why !== 'precompute') st.parties.asked(key, 'skip');
632 record(st, $, { ...row, reason: action.reason });
633 return { skip: action.reason };
634 }
635 const shared = { messages: e.messages, party: kind === 'main' ? ('main' as const) : ('subagent' as const), tokens, askPoint: m?.askPoint ?? 0, charsPerToken: ratioOf(st, $, key, 'compact', await partyChars(st, $, key, e.agentId), m), now: await $.clock.now(), facts: (kept: readonly string[]) => factsFor(st.ledger, key, { keptTexts: kept, ...(action.action === 'install' ? { since: held!.since } : {}) }), agents: await agentRows($), logDir: st.pluginLogDir, ...(e.agentId !== undefined ? { self: e.agentId } : {}) };
636 const wake = action.action === 'install'
637 ? buildWake({ ...shared, toolUseId: held!.toolUseId, handoff: held!.text, maxContext: m?.maxContext, tool: st.toolNames.forward_compaction, files: await readLoadFiles($, held!.loadFiles) })
638 : mechanicalWake({ ...shared, tool: st.toolNames.forward_compaction, reason: action.why === 'ceiling' ? { kind: 'ceiling', ceiling: m!.ceiling } : { kind: 'reactive' } });
639 dbg(st, $, 'wake', { party: key, kind: action.action, ratio: shared.charsPerToken ?? null, askPoint: shared.askPoint, sizes: wake.sizes, headChars: wake.headChars, carried: wake.carried, notCarried: wake.notCarried, kept: wake.kept, dropped: wake.dropped });
640 installed(st, $, key, action.action === 'install' ? { kind: 'handoff', label, before: tokens, chars: held!.text.length, facts: wake.facts, kept: wake.kept, dropped: wake.dropped } : { kind: 'mechanical', label, before: tokens, tokens, ceiling: m?.ceiling ?? 0 }, signal);
641 record(st, $, { ...row, facts: wake.facts, kept: wake.kept, dropped: wake.dropped });
642 return { messages: wake.messages, ...(tokens !== undefined ? { tokensBefore: tokens } : {}) }; // tokensAfter: the next reading measures it (M2)
643}
644
645/** A party's live-window characters through `$.session.messages({ as: 'api' })` (main: no agentId), what its reading counts; a denied or failed read is said in its row and is no count. */
646async function partyChars(st: State, $: EngineInterface, key: string, agentId: unknown): Promise<number | undefined> {
647 return Promise.resolve().then(() => (typeof agentId !== 'string' ? $.session.messages({ as: 'api' }) : $.session.messages({ as: 'api', agentId }))).then((found) => ('deny' in found ? (dbg(st, $, 'session.messages-denied', { party: key, deny: found.deny }), undefined) : apiChars(found)), (error: unknown) => (dbg(st, $, 'session.messages-failed', { party: key, error: message(error) }), undefined));
648}
649
650/** A party's measured characters per token on its growth since its base, every count the live window `$.session.messages({ as: 'api' })` answers; unmeasured, or with no marks, nothing is converted, and its row says why. */
651function ratioOf(st: State, $: EngineInterface, key: string, at: 'compact' | 'tool-call', chars: number | undefined, m: Marks | undefined): number | undefined {
652 const { reading, base } = st.parties.get(key);
653 const r: { ratio?: number; why?: string; delta?: number } = m === undefined ? { why: 'no-marks' } : charsPerToken(base, chars, reading);
654 dbg(st, $, r.ratio === undefined ? 'ratio-unmeasured' : 'ratio', { party: key, at, baseChars: base?.chars ?? null, baseTokens: base?.tokens ?? null, chars: chars ?? null, reading: reading ?? null, ...r });
655 return r.ratio;
656}
657
658/** C11 (R35): an ask whose handling threw gets a mechanical wake, never the engine's summarizer by accident (A2); only when that throws too does Claude Code's own summary run. */
659async function compactFailed(st: State, $: EngineInterface, e: SessionCompactInput, key: PartyKey, error: unknown, pass: () => Promise<SessionCompactResult>, signal?: AbortSignal): Promise<SessionCompactResult> {
660 const party = st.parties.get(key);
661 const said = `forward-compaction: error in session.compact for ${party.label}: ${message(error)}`;
662 try {
663 const tokens = party.reading;
664 const m = marks(st, key);
665 const wake = mechanicalWake({ messages: e.messages, party: key === 'main' ? 'main' : 'subagent', tokens, askPoint: m?.askPoint ?? 0, charsPerToken: ratioOf(st, $, key, 'compact', await partyChars(st, $, key, e.agentId), m), now: Date.now(), facts: (kept) => factsFor(st.ledger, key, { keptTexts: kept }), tool: st.toolNames.forward_compaction, logDir: st.pluginLogDir, reason: m === undefined ? { kind: 'reactive' } : { kind: 'ceiling', ceiling: m.ceiling } });
666 installed(st, $, key, undefined, signal); // the error line below is said now; no landing waits
667 tell(st, $, `${said}; a mechanical wake was installed.`, { event: 'compact', party: key, decision: 'error', error: message(error), kept: wake.kept, dropped: wake.dropped });
668 return { messages: wake.messages, ...(tokens !== undefined ? { tokensBefore: tokens } : {}) };
669 } catch (again) {
670 tell(st, $, `${said}; Claude Code's own summary ran.`, { event: 'compact', party: key, decision: 'error', error: message(error), fallback: message(again) });
671 return pass();
672 }
673}
674
675/** Main's window and Claude Code's ask point, at session start, at main's first step when not read yet, and after its model changed (/model, `model`); a failed read keeps what is known. */
676async function readMainWindow(st: State, $: EngineInterface, model?: string): Promise<void> {
677 try {
678 const { context } = await $.session.usage({ breakdown: 'summary' });
679 const b = context.breakdown;
680 dbg(st, $, 'session.usage', { model: model ?? null, window: context.window ?? null, tokens: context.tokens ?? null, autoCompactThreshold: b?.autoCompactThreshold ?? null, rawMaxTokens: b?.rawMaxTokens ?? null, autocompactSource: b?.autocompactSource ?? null, isAutoCompactEnabled: b?.isAutoCompactEnabled ?? null, breakdownModel: b?.model ?? null });
681 const window = typeof context.window === 'number' && context.window > 0 ? context.window : st.mainWindow;
682 if (model !== undefined && st.mainWindow !== undefined && window !== st.mainWindow) record(st, $, { event: 'window-changed', party: 'main', model, from: st.mainWindow, to: window });
683 Object.assign(st, { mainWindow: window, breakdown: b, apiAsk: askPointOf(b, window) });
684 const missing = st.apiAsk?.missing;
685 if (missing !== undefined) warnOnce(st, $, `ask-point:${missing}`, `forward-compaction: $.session.usage reported no ${missing === 'breakdown' ? 'context breakdown' : 'autoCompactThreshold (auto-compaction is off)'}; Claude Code's ask point is taken as its ${st.apiAsk!.source} ${k(st.apiAsk!.tokens)}`);
686 } catch (error) {
687 warnOnce(st, $, 'usage', `forward-compaction: reading main's context window${model === undefined ? '' : ` after its model changed to ${model}`} failed: ${message(error)}; ${st.mainWindow === undefined ? "main's window stays unknown" : `keeping ${k(st.mainWindow)}`}`);
688 }
689}
690
691/** A step's usage is the party's reading; a sharp fall nobody here caused is an unseen compaction (R39). */
692async function readStep(st: State, $: EngineInterface, e: TurnStepInput, r: TurnStepResult): Promise<void> {
693 const key = keyOf(e.agentId);
694 const u = r?.usage;
695 dbg(st, $, 'turn.step', { party: key, model: e.model ?? null, input_tokens: u?.input_tokens ?? null, cache_read_input_tokens: u?.cache_read_input_tokens ?? null, cache_creation_input_tokens: u?.cache_creation_input_tokens ?? null, output_tokens: u?.output_tokens ?? null, usageModel: u?.model ?? null });
696 if (key === 'main' && e.model !== undefined && (st.mainWindow === undefined || (st.parties.get(key).model ?? e.model) !== e.model)) await readMainWindow(st, $, e.model); // not read yet, or after /model
697 st.parties.describe(key, { model: e.model }); judge(st, $, key);
698 if (key === 'main') st.pendingCommand = undefined;
699 const tokens = readingTokens(u);
700 if (tokens === undefined) return st.parties.answered(key);
701 const party = st.parties.get(key);
702 if (unseenCompaction(party.reading, tokens, st.installedSince.has(key))) {
703 tell(st, $, `forward-compaction: ${party.label}'s context fell from ${k(party.reading!)} to ${k(tokens)} with no compaction this plugin handled (another plugin or Claude Code compacted it).`, { event: 'unseen-compaction', party: key, before: party.reading, after: tokens });
704 st.parties.rebase(key, undefined);
705 }
706 const m = marks(st, key);
707 const level = m === undefined ? 0 : nudgeLevel(tokens, m.start, m.every);
708 dbg(st, $, 'reading', { party: key, tokens, level, from: party.reading ?? null, absorb: party.absorbNext });
709 land(st, $, key, st.parties.read(key, tokens, level), tokens);
710 st.installedSince.delete(key);
711 if (party.base !== undefined) return; // the first reading in its live window takes the ratio's base (window.ts `charsPerToken`)
712 const chars = await partyChars(st, $, key, e.agentId);
713 dbg(st, $, 'ratio-base', { party: key, chars: chars ?? null, tokens });
714 if (chars !== undefined) st.parties.rebase(key, { chars, tokens });
715}
716
717/** P1 (R32): main's answered turn with a handoff stored compacts once. A sub-agent's turn end is one run, never its finish (a child's notification resumes it): its party stays, only a waiting landing is said. */
718async function turnEnds(st: State, $: EngineInterface, e: TurnCompleteInput): Promise<void> {
719 const key = keyOf(e.agentId);
720 if (key !== 'main') return land(st, $, key, st.parties.takeLanding(key));
721 const party = st.parties.get('main');
722 const door = turnEndDoor({ main: true, reason: e.reason, stored: party.stored !== undefined, installedThisTurn: party.installedThisTurn });
723 st.parties.turnEnded('main');
724 if (!door.compact) return;
725 try {
726 const out = await $.session.compact();
727 if (out.skip !== undefined) tell(st, $, `forward-compaction: main's handoff did not install at turn end: ${out.skip}. It stays stored for the next turn end or ask.`, { event: 'turn-end-skipped', party: 'main', skip: out.skip });
728 } catch (error) {
729 record(st, $, { event: 'turn-end-rejected', party: 'main', error: message(error) });
730 }
731}
732
733/** What the person typed is a fact of main's; a message from anywhere else is a notice. */
734async function noteSubmit(st: State, $: EngineInterface, e: PromptSubmitInput): Promise<void> {
735 if (e.isMeta) return;
736 const at = await $.clock.now();
737 const origin = originKind(e.origin);
738 st.pendingCommand = e.command?.name;
739 st.ledger = isPersonOrigin(origin)
740 ? addEntry(st.ledger, { kind: 'person', party: 'main', at, text: e.text, ...(e.command ? { command: e.command.name } : {}) })
741 : addEntry(st.ledger, { kind: 'notice', party: 'main', at, origin, text: e.text });
742 if (isPersonOrigin(origin)) await keepKept(st, $, 'main');
743}
744
745/** The files a finished call touched and the task list it set, as the party's work (src/work.ts). */
746async function noteWork(st: State, $: EngineInterface, party: PartyKey, e: Record<string, unknown>, answer: ToolCallResult): Promise<void> {
747 for (const fact of workOf(String(e.tool), toolInput(e), answer as CallAnswer)) st.ledger = addEntry(st.ledger, { kind: 'work', party, at: await $.clock.now(), fact });
748}
749
750/** A party's newest prompts and its loaded skills, held by the host for the session (contract.d.ts), so a plugin reload does not lose them. */
751async function keepKept(st: State, $: EngineInterface, party: PartyKey): Promise<void> {
752 await restoreKept(st, $, party);
753 const id = `${await (st.sessionId ??= $.session.id())}/${party}`;
754 await $.state.set({ plugin: 'forward-compaction', key: 'prompts', id }, promptsKept(st.ledger, party));
755 await $.state.set({ plugin: 'forward-compaction', key: 'skills', id }, skillsKept(st.ledger, party));
756}
757
758/** What a party kept before a plugin reload, put back once per process: before its first write, so a write never drops it, and before its wake. */
759async function restoreKept(st: State, $: EngineInterface, party: PartyKey): Promise<void> {
760 if (st.restored.has(party)) return;
761 st.restored.add(party);
762 const id = `${await (st.sessionId ??= $.session.id())}/${party}`;
763 st.ledger = restorePrompts(st.ledger, party, (await $.state.get({ plugin: 'forward-compaction', key: 'prompts', id })).value);
764 st.ledger = restoreSkills(st.ledger, party, (await $.state.get({ plugin: 'forward-compaction', key: 'skills', id })).value);
765}
766
767/** A turn starts. A sub-agent's later run begins with what woke it (a background child's notification): recorded like a coordinator's message, once. */
768async function noteRun(st: State, $: EngineInterface, e: TurnStartInput): Promise<void> {
769 const key = keyOf(e.agentId);
770 const first = key !== 'main' && st.parties.get(key).requests === 0; // the first run begins with the brief, recorded at agent.spawn
771 st.parties.newTurn(key);
772 if (key === 'main' || first || e.text === '') return;
773 const last = [...st.ledger.entries].reverse().find((x) => (x.kind === 'brief' || x.kind === 'received') && x.party === key);
774 if (last !== undefined && 'text' in last && last.text === e.text) return; // session.receive recorded it already
775 st.ledger = addEntry(st.ledger, { kind: 'received', party: key, at: await $.clock.now(), origin: 'run-start', text: e.text });
776 await keepKept(st, $, key);
777}
778
779async function noteReceived(st: State, $: EngineInterface, e: SessionReceiveInput): Promise<void> {
780 if (e.agentId === undefined) return;
781 st.ledger = addEntry(st.ledger, { kind: 'received', party: keyOf(e.agentId), at: await $.clock.now(), origin: originKind(e.origin), text: e.text });
782 await keepKept(st, $, keyOf(e.agentId));
783}
784
785/** D5: the party a skill belongs to, from the calls in flight around it (the event names none). */
786async function noteSkill(st: State, $: EngineInterface, e: SkillPromptInput): Promise<void> {
787 const inFlightSkills = [...st.inFlight].filter(([, call]) => call.tool === 'Skill').map(([tool_use_id, call]) => ({ party: call.party, tool_use_id }));
788 const { party, how } = attributeSkill({ inFlightSkills, pendingCommand: st.pendingCommand, pendingSpawns: st.pendingSpawns, skill: e.skill });
789 st.ledger = addEntry(st.ledger, { kind: 'skill', party, at: await $.clock.now(), skill: e.skill, text: e.text, how, ...(pinnedBy(e.frontmatter) || st.config.keepSkills.includes(e.skill) ? { keep: true as const } : {}), ...(e.source !== undefined ? { source: e.source } : {}), ...(e.path !== undefined ? { path: e.path } : {}) });
790 record(st, $, { event: 'skill', party, skill: e.skill, how });
791 await keepKept(st, $, party);
792}
793
794/** A spawned agent's brief is its first fact, and the skills it preloaded become its own. */
795async function noteSpawn(st: State, $: EngineInterface, e: AgentSpawnInput, r: AgentSpawnResult): Promise<void> {
796 const id = r?.agentId;
797 if (typeof id !== 'string' || id === '') return;
798 const key: PartyKey = `agent:${id}`;
799 st.ledger = resolveSpawn(addEntry(st.ledger, { kind: 'brief', party: key, at: await $.clock.now(), text: e.prompt, description: e.description, subagentType: e.subagentType, spawnedBy: st.inFlight.get(e.tool_use_id)?.party ?? 'unknown' }), e.tool_use_id, id);
800 st.knownAgents.set(id, e.subagentType);
801 st.parties.describe(key, { label: `${e.subagentType} ${id.slice(0, 6)}` }); judge(st, $, key);
802 await keepKept(st, $, key); // its brief, and its preloaded skills filed under the spawn until now
803}
804
805/** The shells and crons running at a stop: the latest snapshot is a fact of every wake. */
806async function noteShells(st: State, $: EngineInterface, e: Record<string, unknown>): Promise<void> {
807 st.ledger = addEntry(st.ledger, { kind: 'shells', at: await $.clock.now(), ...shellsOf(e) });
808}
809
810export const register: Register = (on: On, options: PluginOptions) => {
811 const st: State = {
812 config: resolveOptions(),
813 ignoredOptions: options !== null && typeof options === 'object' ? Object.keys(options) : [],
814 pluginRows: [], pluginInitialized: false, pluginWriting: Promise.resolve(), pluginFailing: false,
815 toolNames: {}, toolTries: { forward_compaction: 0, look_back: 0 },
816 warned: new Set(), logFailures: new Set(),
817 pending: [], rowsWriting: 0, levelRowsWriting: 0, levelLinks: 0, levelBatched: 0, skips: new SkipFold(), levelSkips: new SkipFold(), writing: Promise.resolve(),
818 parties: new Parties(), ledger: newLedger(), restored: new Set(),
819 inFlight: new Map(), pendingSpawns: new Set(), knownAgents: new Map(), installedSince: new Set(),
820 };
821
822 on('session.start', async ($, e, next) => {
823 const result = await next(e);
824 await ensureTools(st, $, next.signal);
825 await guard(st, $, 'session.start', () => sessionBegins(st, $), e);
826 return result;
827 });
828
829 on('tool.call', async ($, e, next) => {
830 dbg(st, $, 'hook', hookRow('tool.call', e));
831 if (st.toolNames.look_back !== undefined && e.tool === st.toolNames.look_back) return { result: await answerLookBackTool(st, $, e as Record<string, unknown>) };
832 if (st.toolNames.forward_compaction !== undefined && e.tool === st.toolNames.forward_compaction) return { result: experimental(await answerForward(st, $, e as Record<string, unknown>), st.pluginLogDir) };
833 await unlessAborted(observeManualLookBack(st, $, e as Record<string, unknown>), next.signal);
834 const key = keyOf(e.agentId);
835 const refused = gateRefusal(st, key, String(e.tool));
836 if (refused === 'opens') record(st, $, { event: 'gate-opened', party: key, tool: e.tool, tokens: st.parties.get(key).reading ?? null });
837 else if (refused !== undefined && refused !== 'open') return (record(st, $, { event: 'gated', party: key, tool: e.tool, tokens: st.parties.get(key).reading ?? null }), { deny: refused });
838 const id = e.tool_use_id;
839 const tracked = typeof id === 'string' && (e.tool === 'Skill' || e.tool === 'Agent');
840 if (tracked) st.inFlight.set(id, { party: key, tool: e.tool });
841 let result: ToolCallResult;
842 try {
843 result = await next(e);
844 } finally {
845 if (tracked) st.inFlight.delete(id);
846 }
847 await guard(st, $, 'tool.call work', () => noteWork(st, $, key, e, result));
848 return nudged(st, $, key, result);
849 });
850
851 on('session.compact', async ($, e, next) => {
852 const key = keyOf(e.agentId);
853 dbg(st, $, 'hook', hookRow('session.compact', e));
854 try {
855 return await compactAsk(st, $, e, key, () => next(e), next.signal);
856 } catch (error) {
857 return compactFailed(st, $, e, key, error, () => next(e), next.signal);
858 }
859 });
860
861 on('turn.step', async function* ($, e, next) {
862 const r = yield* next(e);
863 await guard(st, $, 'turn.step', () => readStep(st, $, e, r), e);
864 return r;
865 });
866
867 on('turn.start', async ($, e, next) => {
868 await guard(st, $, 'turn.start', () => noteRun(st, $, e), e);
869 return next(e);
870 });
871
872 on('turn.complete', async ($, e, next) => {
873 const r = await next(e);
874 await guard(st, $, 'turn.complete', () => turnEnds(st, $, e), e);
875 return r;
876 });
877
878 on('prompt.submit', async ($, e, next) => {
879 await guard(st, $, 'prompt.submit', () => noteSubmit(st, $, e), e);
880 return next(e);
881 });
882
883 on('session.receive', async ($, e, next) => {
884 await guard(st, $, 'session.receive', () => noteReceived(st, $, e), e);
885 return next(e);
886 });
887
888 on('skill.prompt', async ($, e, next) => {
889 await guard(st, $, 'skill.prompt', () => noteSkill(st, $, e), e);
890 return next(e);
891 });
892
893 on('agent.spawn', async ($, e, next) => {
894 st.pendingSpawns.add(e.tool_use_id);
895 let r: AgentSpawnResult;
896 try {
897 r = await next(e);
898 } finally {
899 st.pendingSpawns.delete(e.tool_use_id);
900 }
901 await guard(st, $, 'agent.spawn', () => noteSpawn(st, $, e, r), e);
902 return r;
903 });
904
905 on('classic.Stop', async ($, e, next) => {
906 await guard(st, $, 'classic.Stop', () => noteShells(st, $, e as Record<string, unknown>), e);
907 return next(e);
908 });
909
910 on('classic.SubagentStop', async ($, e, next) => {
911 await guard(st, $, 'classic.SubagentStop', () => (land(st, $, keyOf(e.agent_id), st.parties.takeLanding(keyOf(e.agent_id))), noteShells(st, $, e as Record<string, unknown>)), e);
912 return next(e);
913 });
914
915 on('session.end', async ($, e, next) => {
916 // A /clear or a resume ends the conversation, and the process goes on under a new session id with no session.start.
917 const goesOn = e.reason === 'clear' || e.reason === 'resume';
918 dbg(st, $, 'hook', { ...hookRow('session.end', e), reason: e.reason });
919 if (goesOn) record(st, $, { event: 'cleared', party: 'main', reason: e.reason });
920 for (const [key, landing] of st.parties.takeLandings()) land(st, $, key, landing); // ended with no reading after its install
921 // The open skip streaks close now, and the session ends only once every queued row is written.
922 for (const diagnostic of st.levelSkips.drain().map(pluginLogEvent)) if (diagnostic !== undefined) plog(st, $, diagnostic);
923 if (st.config.logFile) {
924 for (const out of st.skips.drain()) st.pending.push(JSON.stringify(out));
925 flushDecisions(st, $, true);
926 }
927 // The whole end chain shares one short bound: the writes get what it leaves less core's share, and the conversation starts over either way. A write queued while the end waits (a cleanup failure said) is waited for too.
928 const settled = async (): Promise<void> => {
929 for (;;) {
930 const writing = st.writing, plugin = st.pluginWriting;
931 await Promise.all([writing, plugin]);
932 if (writing === st.writing && plugin === st.pluginWriting) return;
933 }
934 };
935 const left = next.budget?.remainingMs ?? Infinity;
936 const outcome = Number.isFinite(left) ? await bounded(settled(), Math.max(0, left - END_CORE_RESERVE_MS), (ms, fn) => afterOrNever(st, $, ms, fn, 'the session-end log flush'), next.signal) : (await settled(), { timedOut: false as const });
937 const report = endReport({
938 timedOut: outcome.timedOut, boundMs: next.budget?.ms ?? left, decisionRows: st.pending.length + st.rowsWriting, levelRows: st.levelRowsWriting,
939 held: st.config.logFile && st.writeError !== undefined ? st.pending.length : 0, dropped: st.config.logFile ? st.rowsDropped?.rows ?? 0 : 0,
940 error: st.writeError ?? st.rowsDropped?.error ?? 'unknown', // a cut chain may hold the path's own lookup: it is awaited only once settled, or once an append ran
941 path: !st.config.logFile ? '' : !outcome.timedOut || st.rowsDropped !== undefined ? (await st.logPath) ?? st.config.logFile : st.config.logFile,
942 });
943 if (report.cut !== undefined) show($, report.cut);
944 if (report.lost !== undefined) {
945 // The rows a failing append kept have no later flush, and the hold's drops were told only once: they are lost, and said so, never silently.
946 const { line, rows, dropped, error } = report.lost;
947 plog(st, $, { level: 'error', event: 'decision-log-rows-lost', msg: screenLine(line), where: 'hooks/forward-compaction.ts#session.end', detail: { rows, dropped, error } });
948 show($, line);
949 // The level row is the record of the loss: the end waits for it, within what the bound leaves.
950 const rest = next.budget?.remainingMs ?? Infinity;
951 if (Number.isFinite(rest)) await bounded(st.pluginWriting, Math.max(0, rest - END_CORE_RESERVE_MS), (ms, fn) => afterOrNever(st, $, ms, fn, 'the session-end log flush'), next.signal);
952 else await st.pluginWriting;
953 }
954 if (goesOn) {
955 startOver(st);
956 await guard(st, $, 'session.end', () => conversationStartsOver(st, $, e.reason));
957 }
958 return next(e);
959 });
960};
961src/bounded.ts 53 lines1/** `$.clock.after`: the hooks' environment has no `setTimeout`, so the wait is passed in. */
2export type After = (ms: number, fn: () => void) => { cancel: () => void };
3
4/** `aborted`: the wait ended because the hook's signal aborted, not because the time ran out. */
5export type Bounded<T> = { timedOut: true; aborted?: true } | { timedOut: false; value: T };
6
7/**
8 * The promise's value if it settles within `ms`, else `{ timedOut: true }`.
9 * A rejection passes on. The timer is cancelled whichever way it ends; the
10 * promise itself is not cancelled and may settle later.
11 *
12 * With a `signal`, its abort ends the wait at once as `{ timedOut: true, aborted: true }`:
13 * the timer belongs to the dispatch that was aborted and may never fire, and a
14 * hook that waits on past its abort is a runaway to the hooks worker.
15 */
16export async function bounded<T>(promise: Promise<T>, ms: number, after: After, signal?: AbortSignal): Promise<Bounded<T>> {
17 let timer: { cancel: () => void } | undefined;
18 let onAbort: (() => void) | undefined;
19 const limit = new Promise<{ timedOut: true; aborted?: true }>((resolve) => {
20 if (signal !== undefined) {
21 onAbort = () => resolve({ timedOut: true, aborted: true });
22 if (signal.aborted) onAbort();
23 else signal.addEventListener('abort', onAbort, { once: true });
24 }
25 if (signal?.aborted !== true) timer = after(ms, () => resolve({ timedOut: true }));
26 });
27 try {
28 return await Promise.race([promise.then((value) => ({ timedOut: false as const, value })), limit]);
29 } finally {
30 timer?.cancel();
31 if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort);
32 }
33}
34
35/**
36 * The promise's value, or `undefined` once `signal` aborts first (at once when it already has). A rejection passes on;
37 * the promise itself is not cancelled and may settle later. No timer: this is for a hook's hot path, where a wait
38 * past the dispatch's abort is a runaway to the hooks worker.
39 */
40export async function unlessAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<{ value: T } | undefined> {
41 let onAbort: (() => void) | undefined;
42 const aborted = new Promise<undefined>((resolve) => {
43 onAbort = () => resolve(undefined);
44 if (signal.aborted) onAbort();
45 else signal.addEventListener('abort', onAbort, { once: true });
46 });
47 try {
48 return await Promise.race([promise.then((value) => ({ value })), aborted]);
49 } finally {
50 if (onAbort !== undefined) signal.removeEventListener('abort', onAbort);
51 }
52}
53src/handoff.ts 256 lines1// The forward_compaction tool contract: what the model may send, what is refused (never trimmed), and what the receipt says.
2
3import { k } from './nudge.ts';
4import { loadFilesRoom } from './window.ts';
5import { DROP_MAX } from './drop.ts';
6export { applyDrops, dropNote, pinnedBy } from './drop.ts';
7
8/** The tool's name as registered; the model sees `mcp__forward-compaction__forward_compaction`. */
9export const FORWARD_TOOL = 'forward_compaction';
10
11export const FORWARD_TOOL_DESCRIPTION =
12 'Hands off your context — at a clean milestone once context is large. Stores your handoff, one argument per section (20,000 characters in all), written for yourself after a reset; at your next compaction everything before this call is replaced by it plus recorded facts (the person\'s messages or your brief, your skills), and every later message stays verbatim. Returns a receipt of when it installs; a refusal names what to fix and stores nothing. Example: forward_compaction({"objective": "…", "done": "…", "remaining": "…", "next": "…", "load_files": []}). Exact old detail later → look_back.';
13
14/** The handoff's sections in the order they are assembled: the argument, its heading, whether it is required, and what it holds. */
15const SECTIONS = [
16 { key: 'objective', heading: 'Objective', required: true, description: 'What the work is for and what done means.' },
17 { key: 'done', heading: 'Done', required: true, description: 'Finished pieces, each with how it was verified.' },
18 { key: 'remaining', heading: 'Remaining', required: true, description: 'Every step left, in order.' },
19 { key: 'next', heading: 'Next', required: true, description: 'The first concrete action after the wake.' },
20 { key: 'blocked', heading: 'Blocked', required: false, description: 'Optional: what waits on whom.' },
21 { key: 'findings', heading: 'Findings', required: false, description: 'Optional: decisions with why, errors verbatim.' },
22] as const;
23
24export const FORWARD_INPUT_SCHEMA = {
25 type: 'object',
26 additionalProperties: false,
27 required: ['objective', 'done', 'remaining', 'next'],
28 properties: {
29 objective: { type: 'string', description: SECTIONS[0].description },
30 done: { type: 'string', description: SECTIONS[1].description },
31 remaining: { type: 'string', description: SECTIONS[2].description },
32 next: { type: 'string', description: SECTIONS[3].description },
33 blocked: { type: 'string', description: SECTIONS[4].description },
34 findings: { type: 'string', description: SECTIONS[5].description },
35 load_files: {
36 type: 'array',
37 maxItems: 8,
38 description: 'Files carried whole into the new context, read when it installs. Files that come to more than 5% of your window are refused at the call: pass lines ranges or fewer files.',
39 items: {
40 type: 'object',
41 additionalProperties: false,
42 required: ['path', 'why'],
43 properties: {
44 path: { type: 'string', description: 'Absolute path.' },
45 lines: { type: 'string', description: 'Line ranges, like "10-40" or "1-60,160-185".' },
46 why: { type: 'string', maxLength: 200, description: 'What you need it for.' },
47 },
48 },
49 },
50 drop_skills: {
51 type: 'array',
52 maxItems: DROP_MAX,
53 items: { type: 'string' },
54 description: 'Optional: names of skills or commands in your context you no longer need. Name one on two handoffs in a row and the second wake leaves it out; one its file pins stays.',
55 },
56 },
57} as const;
58
59// Format caps: design budgets on what the model may send, never a measured size.
60/** The assembled handoff's cap: a frontier stays a page or two the woken model reads whole, well inside any wake budget. */
61export const HANDOFF_MAX_CHARS = 20_000;
62/** Load files per call: enough for a plan, a spec and the files in hand, few enough that each is named and chosen. */
63export const LOAD_FILES_MAX = 8;
64/** A load file's `why`: one line saying what it is for. */
65export const WHY_MAX_CHARS = 200;
66/** A load file's size on disk (1 MiB): a file past it is a dump to slice with `lines`, not a file to carry whole. */
67export const LOAD_FILE_MAX_BYTES = 1_048_576;
68
69/** One file the model asked to carry whole into the wake; every field is the model's own text, kept verbatim. */
70export type LoadFile = { path: string; lines?: string; why: string };
71
72export type ParsedHandoff = { handoff: string; loadFiles: LoadFile[]; dropSkills: string[] };
73export type Refusal = { refusal: string };
74
75const FIELDS = [...SECTIONS.map((section) => section.key), 'load_files', 'drop_skills'];
76const PREFIX = 'forward_compaction refused, nothing stored: ';
77const refuse = (text: string): Refusal => ({ refusal: PREFIX + text });
78const num = (n: number): string => n.toLocaleString('en-US');
79const kind = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
80const isAbsolutePath = (p: string): boolean => /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(p);
81const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
82
83/** One line per bad argument: an unknown field, a required section missing, not a string or blank, an optional one given but not a string. */
84function argumentFaults(fields: Record<string, unknown>): string[] {
85 const unknown = Object.keys(fields).filter((key) => !FIELDS.includes(key)).map((key) => `unknown field "${key}". The fields are ${FIELDS.slice(0, -1).join(', ')} and drop_skills.`);
86 const sections = SECTIONS.flatMap(({ key, required }) => {
87 const value = fields[key];
88 if (required && (typeof value !== 'string' || value.trim() === '')) return [`${key} must be a non-blank string; got ${typeof value === 'string' ? 'a blank string' : kind(value)}.`];
89 if (!required && value !== undefined && typeof value !== 'string') return [`${key} must be a string when given; got ${kind(value)}.`];
90 return [];
91 });
92 return [...unknown, ...sections];
93}
94
95/**
96 * Validates the model's tool input before anything is stored (V1-V11 of the spec table), every bad argument named in
97 * one refusal, then assembles the handoff from its sections in order, an absent or blank optional one left out. Each
98 * section's text and the files come back exactly as sent: nothing is trimmed, a fault is refused for the model to fix.
99 * `$.fs.stat` checks (V12-V15) are the adapter's, through `loadFileRefusal`.
100 */
101export function parseHandoff(input: unknown): ParsedHandoff | Refusal {
102 const fields = isObject(input) ? input : {};
103 const faults = argumentFaults(fields);
104 if (faults.length > 0) return refuse(faults.join('\n'));
105 const handoff = SECTIONS.flatMap(({ key, heading }) => {
106 const text = fields[key];
107 return typeof text === 'string' && text.trim() !== '' ? [`## ${heading}\n${text}`] : [];
108 }).join('\n\n');
109 if (handoff.length > HANDOFF_MAX_CHARS) return refuse(`the handoff assembled from your sections is ${num(handoff.length)} characters, over the ${num(HANDOFF_MAX_CHARS)} cap. Shorten it (detail can go in load_files, or stay in the transcript for look_back) and call again.`);
110 const drops = fields.drop_skills;
111 if (drops !== undefined && (!Array.isArray(drops) || drops.length > DROP_MAX || drops.some((d) => typeof d !== 'string' || d.trim() === ''))) return refuse(`drop_skills must be an array of at most ${DROP_MAX} skill or command names, each a non-blank string.`);
112 const dropSkills = (drops ?? []) as string[];
113 const files = fields.load_files;
114
115 if (files === undefined) return { handoff, loadFiles: [], dropSkills };
116 if (!Array.isArray(files)) return refuse('load_files must be an array of {path, lines?, why}.');
117 if (files.length > LOAD_FILES_MAX) return refuse(`load_files has ${files.length} entries, over the ${LOAD_FILES_MAX} cap.`);
118 const loadFiles: LoadFile[] = [];
119 for (const [i, entry] of files.entries()) {
120 const { path, why, lines: ranges } = isObject(entry) ? entry : ({} as Record<string, unknown>);
121 if (typeof path !== 'string' || !isAbsolutePath(path)) return refuse(`load_files[${i}].path must be an absolute path; got "${String(path)}".`);
122 const whyLength = typeof why === 'string' ? why.length : 0;
123 if (typeof why !== 'string' || why.trim() === '' || whyLength > WHY_MAX_CHARS) return refuse(`load_files[${i}].why must be 1\u2013${WHY_MAX_CHARS} characters; it is ${whyLength}.`);
124 if (ranges !== undefined && (typeof ranges !== 'string' || parseLineRanges(ranges) === undefined)) return refuse(`load_files[${i}].lines "${String(ranges)}" is not a range like "10-40" or "1-60,160-185".`);
125 loadFiles.push({ path, why, ...(ranges !== undefined ? { lines: ranges as string } : {}) });
126 }
127 return { handoff, loadFiles, dropSkills };
128}
129
130/** What the adapter learned from `$.fs.stat(path)` on one load file: its stat, or `absent` (ENOENT), or the error text of any other throw. */
131export type FileCheck = { kind: 'file' | 'dir' | 'other'; size: number } | { absent: true } | { error: string };
132
133/** V12-V15: the refusal for one load file (`index` is its place in `load_files`, from 0), or undefined when it can be carried. */
134export function loadFileRefusal(index: number, path: string, check: FileCheck): string | undefined {
135 const at = `load_files[${index}] ${path}`;
136 if ('error' in check) return PREFIX + `${at} could not be checked: ${check.error}.`;
137 if ('absent' in check) return PREFIX + `${at}: no such file.`;
138 if (check.kind !== 'file') return PREFIX + `${at} is not a regular file.`;
139 if (check.size > LOAD_FILE_MAX_BYTES) return PREFIX + `${at} is ${num(check.size)} bytes, over the ${num(LOAD_FILE_MAX_BYTES)} cap for a carried file.`;
140 return undefined;
141}
142
143/** `tokens`: the party's last reading, absent when it has none; it picks the sentence and is never printed, being a step behind the call. */
144export type ReceiptInput =
145 | { party: 'main'; chars: number; files: number; tokens?: number; askPoint?: number; replaced?: boolean }
146 | { party: 'subagent'; chars: number; files: number; tokens?: number; askPoint: number; replaced?: boolean };
147
148/** The tool result of a stored handoff: when it installs, true to the party's door (a sub-agent's only door is Claude Code's ask). */
149export function receipt(input: ReceiptInput): string {
150 const stored = `Handoff stored (${num(input.chars)} characters, ${input.files} files).`;
151 const behind = 'Everything after this call is kept verbatim behind it';
152 const replaced = input.replaced ? ' It replaces the handoff you stored before.' : '';
153 if (input.party === 'main') return `${stored} It installs at the end of this turn, or at Claude Code's next compaction ask if one comes first. ${behind}, so finish the step in hand; you need not stop.${replaced}`;
154 if (input.tokens !== undefined && input.tokens >= input.askPoint) return `${stored} It installs at Claude Code's next compaction ask, before your next request. ${behind}.${replaced}`;
155 return `${stored} Claude Code has not asked to compact you yet: it installs at Claude Code's next ask, from about ${k(input.askPoint)} context. ${behind}. Carry on with your work.${replaced}`;
156}
157
158/**
159 * The refusal for load files whose content comes to more than `loadFilesRoom` of the party's max context at its measured
160 * characters per token (`ratio`), each file named with its size (`chars`, as read for the wake: a `lines` range counts its
161 * lines alone); undefined when they fit, and with no ratio (unmeasured yet), when nothing can be measured. Refused, never trimmed.
162 */
163export function loadRoomRefusal(files: readonly { path: string; chars: number }[], maxContext: number, ratio: number | undefined): string | undefined {
164 const room = loadFilesRoom(maxContext, ratio);
165 const total = files.reduce((sum, f) => sum + f.chars, 0);
166 if (total <= room) return undefined;
167 return [
168 PREFIX + `load_files come to ${num(total)} characters, over the ${num(room)}-character room for carried files (5% of your ${k(maxContext)} max context):`,
169 ...files.map((f) => `- ${f.path}: ${num(f.chars)} characters`),
170 'Pass lines ranges (like "10-40" or "1-60,160-185") to carry only what you need, or fewer files, and call again.',
171 ].join('\n');
172}
173
174/** The answer to a forward_compaction call from a party whose model is on the ignore list (`Parties.ignored`, the entry it matched): refused, nothing stored. Undefined for a party the plugin manages. */
175export function ignoredRefusal(matched: string | undefined, ceiling: number | undefined): string | undefined {
176 if (matched === undefined) return undefined;
177 return `forward_compaction refused, nothing stored: your model is on forward-compaction's ignore list ("${matched}"), so it takes no handoff from you; carry on with your work, and at ${ceiling === undefined ? 'your ceiling' : k(ceiling)} the plugin compacts you mechanically.`;
178}
179
180/** Keys the tool.call event carries beside the tool's arguments. */
181const EVENT_KEYS = new Set(['tool', 'tool_use_id', 'agentId']);
182
183/** The arguments of a tool call: every key of the event but its own (a key that is not a field included, so the check names it) or, when none is there, the event's `input`. */
184export function toolInput(e: Record<string, unknown>): Record<string, unknown> {
185 const top: Record<string, unknown> = {};
186 for (const [key, value] of Object.entries(e)) if (!EVENT_KEYS.has(key) && key !== 'input' && value !== undefined) top[key] = value;
187 if (Object.keys(top).length > 0) return top;
188 const nested = e.input;
189 return nested !== null && typeof nested === 'object' && !Array.isArray(nested) ? (nested as Record<string, unknown>) : {};
190}
191
192/** Every forward_compaction answer, receipt or refusal, ends with this line; `logDir` is the level-log directory, where the issues file lives. */
193export const EXPERIMENTAL_OPENING = 'forward-compaction is experimental.';
194export function experimental(text: string, logDir: string | undefined): string {
195 const where = logDir === undefined ? 'your reply.' : `${logDir.replace(/\/+$/, '')}/issues.md; it will be taken care of later.`;
196 return `${text}\n${EXPERIMENTAL_OPENING} If it gave you any trouble, append a short note of what happened to ${where}`;
197}
198
199export function parseLineRanges(text: string): { from: number; to: number }[] | undefined {
200 const out: { from: number; to: number }[] = [];
201 for (const part of text.split(',')) {
202 const m = /^\s*(\d+)\s*(?:[-:]\s*(\d+))?\s*$/.exec(part);
203 if (!m) return undefined;
204 const from = Number(m[1]);
205 const to = m[2] === undefined ? from : Number(m[2]);
206 if (from < 1 || to < from) return undefined;
207 out.push({ from, to });
208 }
209 return out;
210}
211
212const span = (r: { from: number; to: number }): string => (r.from === r.to ? `${r.from}` : `${r.from}-${r.to}`);
213
214/**
215 * A file's text as `load_files` asked for it: whole without ranges, else its ranges in file order, overlapping or
216 * touching ones merged, the lines between two of them named by one `[lines a-b not loaded]` line. `shown` is the ranges that loaded, clipped to the file. A range
217 * past the file's end, or clipped by it, is a `problem`, and an empty load is one too: a slice of nothing never
218 * reaches the wake as a Load block that says nothing.
219 */
220export function sliceFile(text: string, ranges: readonly { from: number; to: number }[] | undefined): { body: string; shown: string; problem?: string } | { problem: string } {
221 if (ranges === undefined) return text.trim() === '' ? { problem: 'the file is empty' } : { body: text, shown: '' };
222 const lines = text.split('\n');
223 if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
224 const count = lines.length;
225 const parts: string[] = [];
226 const shown: { from: number; to: number }[] = [];
227 const problems: string[] = [];
228 // In file order and merged: an overlap never loads a line twice, and a reversed list never reads as one stretch.
229 const ordered: { from: number; to: number }[] = [];
230 for (const r of [...ranges].sort((a, b) => a.from - b.from || a.to - b.to)) {
231 const prev = ordered[ordered.length - 1];
232 if (prev !== undefined && r.from <= prev.to + 1) prev.to = Math.max(prev.to, r.to);
233 else ordered.push({ ...r });
234 }
235 let held = false;
236 for (const r of ordered) {
237 if (r.from > count) {
238 problems.push(`lines ${span(r)}: past the end of the file, which has ${count} ${count === 1 ? 'line' : 'lines'}`);
239 continue;
240 }
241 const to = Math.min(r.to, count);
242 if (to < r.to) problems.push(`lines ${span(r)}: the file ends at line ${count}`);
243 const last = shown[shown.length - 1];
244 if (last !== undefined && r.from > last.to + 1) parts.push(`[lines ${span({ from: last.to + 1, to: r.from - 1 })} not loaded]`);
245 const taken = lines.slice(r.from - 1, to);
246 if (taken.some((line) => line.trim() !== '')) held = true;
247 parts.push(...taken);
248 shown.push({ from: r.from, to });
249 }
250 const problem = problems.length > 0 ? problems.join('; ') : undefined;
251 if (shown.length === 0) return { problem: problem ?? 'no lines asked for' };
252 if (!held) return { problem: [problem, `lines ${shown.map(span).join(',')}: only blank lines`].filter((p) => p !== undefined).join('; ') };
253 return { body: parts.join('\n'), shown: shown.map(span).join(','), ...(problem !== undefined ? { problem } : {}) };
254}
255
256src/work.ts 77 lines1// The work a party's tool calls record as each one finishes: the files touched and the task list. Read from the call's
2// own arguments and answer, never from rendered text, so the wake restores what Claude Code's own compaction restores
3// (the recent files, the todo list).
4
5/** R read, E edited, W written whole (Write). A file keeps the strongest touch it had: W over E over R. */
6export type FileTag = 'R' | 'E' | 'W';
7export type WorkFact =
8 | { kind: 'file'; path: string; tag: FileTag }
9 | { kind: 'todos'; lines: string[] }
10 | { kind: 'task-create'; id?: string; subject: string }
11 | { kind: 'task-update'; id: string; status?: string; subject?: string };
12
13/** The answer of a finished call, as `next(e)` resolved it: a deny or an error touched nothing. */
14export type CallAnswer = { deny?: string; isError?: boolean; text?: string };
15
16const str = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined);
17const MARK: Record<string, string> = { completed: '[x]', in_progress: '[~]' };
18const mark = (status: unknown): string => MARK[String(status)] ?? '[ ]';
19
20/** The facts a finished call records, none for a call that was denied, errored or touches no file or task. */
21export function workOf(tool: string, input: Record<string, unknown>, answer: CallAnswer): WorkFact[] {
22 if (answer.deny !== undefined || answer.isError === true) return [];
23 const file = (path: string | undefined, tag: FileTag): WorkFact[] => (path === undefined ? [] : [{ kind: 'file', path, tag }]);
24 switch (tool) {
25 case 'Read':
26 return file(str(input.file_path), 'R');
27 case 'Edit':
28 case 'MultiEdit':
29 case 'Write':
30 return file(str(input.file_path), tool === 'Write' ? 'W' : 'E');
31 case 'NotebookEdit':
32 return file(str(input.notebook_path), 'E');
33 case 'TodoWrite':
34 return Array.isArray(input.todos) ? [{ kind: 'todos', lines: (input.todos as Record<string, unknown>[]).map((t) => `${mark(t?.status)} ${String(t?.content ?? '')}`) }] : [];
35 case 'TaskCreate': {
36 const id = /#(\d+)/.exec(answer.text ?? '')?.[1];
37 return [{ kind: 'task-create', ...(id !== undefined ? { id } : {}), subject: String(input.subject ?? '') }];
38 }
39 case 'TaskUpdate': {
40 const id = str(input.taskId);
41 return id === undefined ? [] : [{ kind: 'task-update', id, ...(str(input.status) !== undefined ? { status: str(input.status) } : {}), ...(str(input.subject) !== undefined ? { subject: str(input.subject) } : {}) }];
42 }
43 default:
44 return [];
45 }
46}
47
48const RANK: Record<FileTag, number> = { R: 0, E: 1, W: 2 };
49
50export type WorkSummary = { files: { path: string; tag: FileTag }[]; tasks: string[] };
51
52/** A party's work, from its facts in the order recorded: every file newest touch first, and the task list as last set (TodoWrite's, or the TaskCreate/TaskUpdate list, whichever moved last). */
53export function workSummary(facts: readonly WorkFact[]): WorkSummary {
54 const files = new Map<string, FileTag>();
55 const tasks = new Map<string, { subject: string; status: string }>();
56 let todos: string[] = [];
57 let todosLast = false;
58 for (const f of facts) {
59 if (f.kind === 'file') {
60 const before = files.get(f.path);
61 files.delete(f.path); // re-inserted last: the map's order is the touch order
62 files.set(f.path, before !== undefined && RANK[before] > RANK[f.tag] ? before : f.tag);
63 } else if (f.kind === 'todos') (todos = f.lines), (todosLast = true);
64 else if (f.kind === 'task-create') (tasks.set(f.id ?? String(tasks.size + 1), { subject: f.subject, status: 'pending' }), (todosLast = false));
65 else {
66 const task = tasks.get(f.id);
67 if (task !== undefined && f.status === 'deleted') tasks.delete(f.id);
68 else if (task !== undefined) Object.assign(task, f.status !== undefined ? { status: f.status } : {}, f.subject !== undefined ? { subject: f.subject } : {});
69 todosLast = false;
70 }
71 }
72 return {
73 files: [...files].reverse().map(([path, tag]) => ({ path, tag })),
74 tasks: todosLast ? todos : [...tasks.values()].map((t) => `${mark(t.status)} ${t.subject}`),
75 };
76}
77src/ledger.ts 194 lines1// The ledger: typed facts recorded as events happen, reduced by pure functions. Nothing is read from rendered text.
2
3import { workSummary, type WorkFact, type WorkSummary } from './work.ts';
4
5export type PartyKey = 'main' | 'unknown' | `agent:${string}` | `spawn:${string}`;
6
7/** The party an event's `agentId` names: main when there is none. */
8export const keyOf = (agentId: unknown): PartyKey => (typeof agentId === 'string' && agentId !== '' ? `agent:${agentId}` : 'main');
9
10/** An event origin's kind, `unknown` when it carries none. */
11export const originKind = (origin: unknown): string => (origin !== null && typeof origin === 'object' && typeof (origin as { kind?: unknown }).kind === 'string' ? (origin as { kind: string }).kind : 'unknown');
12export type SkillHow = 'skill-tool' | 'typed-command' | 'preload' | 'ambiguous';
13export type ShellTask = { id: string; type: string; status: string; description: string; command?: string };
14export type CronJob = { id: string; schedule: string; recurring: boolean; prompt: string };
15
16type Base = { seq: number; at: number };
17export type PersonEntry = Base & { kind: 'person'; party: 'main'; text: string; command?: string };
18export type NoticeEntry = Base & { kind: 'notice'; party: 'main'; origin: string; text: string };
19export type ReceivedEntry = Base & { kind: 'received'; party: PartyKey; origin: string; text: string };
20export type BriefEntry = Base & {
21 kind: 'brief'; party: PartyKey; text: string; description?: string; subagentType?: string; spawnedBy: PartyKey;
22};
23export type SkillEntry = Base & {
24 kind: 'skill'; party: PartyKey; skill: string; text: string; source?: string; path?: string; how: SkillHow; keep?: true;
25};
26export type ShellsEntry = Base & { kind: 'shells'; tasks: ShellTask[]; crons: CronJob[] };
27export type WorkEntry = Base & { kind: 'work'; party: PartyKey; fact: WorkFact };
28export type LedgerEntry = PersonEntry | NoticeEntry | ReceivedEntry | BriefEntry | SkillEntry | ShellsEntry | WorkEntry;
29type Without<T, K extends string> = T extends unknown ? Omit<T, K> : never;
30export type EntryInput = Without<LedgerEntry, 'seq'>;
31
32export type Ledger = { startedAt?: number; startReason?: string; nextSeq: number; entries: readonly LedgerEntry[] };
33
34/** Prompt origins that are the person typing (composer, a bridge client, the SDK); any other origin is a notice. */
35export const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk']);
36export function isPersonOrigin(kind: string | undefined): boolean {
37 return kind !== undefined && PERSON_ORIGINS.has(kind);
38}
39
40/** The shells and crons of a Stop event's `background_tasks` and `session_crons`; anything that is not a list is none. */
41export function shellsOf(e: Record<string, unknown>): { tasks: ShellTask[]; crons: CronJob[] } {
42 const list = (value: unknown): Record<string, unknown>[] => (Array.isArray(value) ? value : []);
43 const tasks = list(e.background_tasks).map((t) => ({ id: String(t.id), type: String(t.type), status: String(t.status), description: String(t.description ?? ''), ...(typeof t.command === 'string' ? { command: t.command } : {}) }));
44 const crons = list(e.session_crons).map((c) => ({ id: String(c.id), schedule: String(c.schedule), recurring: c.recurring === true, prompt: String(c.prompt ?? '') }));
45 return { tasks, crons };
46}
47
48export function newLedger(): Ledger {
49 return { nextSeq: 1, entries: [] };
50}
51
52/** Records when and why the process's ledger started (session.start); entries are kept. */
53export function markStart(ledger: Ledger, at: number, reason: string): Ledger {
54 return { ...ledger, startedAt: at, startReason: reason };
55}
56
57/** Appends an entry numbered in ledger order; a shells entry replaces the previous one. Returns a new ledger. */
58export function addEntry(ledger: Ledger, entry: EntryInput): Ledger {
59 const kept = entry.kind === 'shells' ? ledger.entries.filter((e) => e.kind !== 'shells') : ledger.entries;
60 return { ...ledger, nextSeq: ledger.nextSeq + 1, entries: [...kept, { ...entry, seq: ledger.nextSeq } as LedgerEntry] };
61}
62
63/** The most prompts a party keeps, for its wake and across a plugin reload: the person's messages (main) or its coordinator's (a sub-agent, its brief besides), the newest ten, the owner's ruling. */
64export const PROMPTS_KEPT = 10;
65
66/** A prompt as `$.state` keeps it: a ledger entry without its seq and party. */
67export type KeptPrompt = { kind: 'person' | 'received' | 'brief'; at: number; text: string; command?: string; origin?: string; description?: string; subagentType?: string; spawnedBy?: string };
68type PromptEntry = PersonEntry | ReceivedEntry | BriefEntry;
69const isPrompt = (e: LedgerEntry, party: PartyKey): e is PromptEntry => (e.kind === 'person' || e.kind === 'received' || e.kind === 'brief') && e.party === party;
70const promptKey = (p: { kind: string; at: number; text: string }): string => `${p.kind}\u0000${p.at}\u0000${p.text}`;
71
72/** A party's prompts to keep, oldest first: main's newest PROMPTS_KEPT messages; a sub-agent's brief and its coordinator's newest PROMPTS_KEPT. */
73export function promptsKept(ledger: Ledger, party: PartyKey): KeptPrompt[] {
74 const mine = ledger.entries.filter((e): e is PromptEntry => isPrompt(e, party));
75 const brief = mine.filter((e) => e.kind === 'brief').slice(-1);
76 const said = mine.filter((e) => e.kind !== 'brief').slice(-PROMPTS_KEPT);
77 return [...brief, ...said].map(({ seq: _seq, party: _party, ...kept }) => kept as KeptPrompt);
78}
79
80/** The ledger with a party's kept prompts put back ahead of its own, each only once and a brief only when it has none; anything not a prompt of that party's kind is left out. */
81export function restorePrompts(ledger: Ledger, party: PartyKey, kept: unknown): Ledger {
82 if (!Array.isArray(kept)) return ledger;
83 const own = ledger.entries.filter((e): e is PromptEntry => isPrompt(e, party));
84 const seen = new Set(own.map(promptKey));
85 const kinds = party === 'main' ? ['person'] : ['brief', 'received'];
86 const hasBrief = own.some((e) => e.kind === 'brief');
87 const back = kept.filter((p): p is KeptPrompt => p !== null && typeof p === 'object' && kinds.includes(p.kind) && typeof p.at === 'number' && typeof p.text === 'string')
88 .filter((p) => !seen.has(promptKey(p)) && !(p.kind === 'brief' && hasBrief));
89 if (back.length === 0) return ledger;
90 const restored = back.map((p, i) => ({ ...p, party, seq: ledger.nextSeq + i, ...(p.kind === 'brief' ? { spawnedBy: (p.spawnedBy ?? 'unknown') as PartyKey } : {}), ...(p.kind === 'received' ? { origin: p.origin ?? 'unknown' } : {}) }) as LedgerEntry);
91 return { ...ledger, nextSeq: ledger.nextSeq + back.length, entries: [...restored, ...ledger.entries] };
92}
93
94const latestPerSkill = (skills: SkillEntry[]): SkillEntry[] => {
95 const seen = new Set<string>();
96 return skills.filter((s) => (seen.has(s.skill) ? false : (seen.add(s.skill), true)));
97};
98
99/** A skill as `$.state` keeps it: a skill entry without its seq and party. */
100export type KeptSkill = { at: number; skill: string; text: string; how: SkillHow; source?: string; path?: string; keep?: true };
101
102/** A party's skills to keep: the latest text of each skill it loaded, oldest first. */
103export function skillsKept(ledger: Ledger, party: PartyKey): KeptSkill[] {
104 const mine = ledger.entries.filter((e): e is SkillEntry => e.kind === 'skill' && e.party === party);
105 return latestPerSkill(mine.reverse()).reverse().map(({ seq: _seq, party: _party, kind: _kind, ...kept }) => kept);
106}
107
108/** The ledger with a party's kept skills put back ahead of its own, each only when the party has not loaded it since; anything not a skill is left out. */
109export function restoreSkills(ledger: Ledger, party: PartyKey, kept: unknown): Ledger {
110 if (!Array.isArray(kept)) return ledger;
111 const own = new Set(ledger.entries.filter((e): e is SkillEntry => e.kind === 'skill' && e.party === party).map((e) => e.skill));
112 const back = kept.filter((s): s is KeptSkill => s !== null && typeof s === 'object' && typeof s.at === 'number' && typeof s.skill === 'string' && typeof s.text === 'string' && !own.has(s.skill));
113 if (back.length === 0) return ledger;
114 const restored = back.map((s, i) => ({ ...s, how: typeof s.how === 'string' ? s.how : 'ambiguous', kind: 'skill', party, seq: ledger.nextSeq + i }) as SkillEntry);
115 return { ...ledger, nextSeq: ledger.nextSeq + back.length, entries: [...restored, ...ledger.entries] };
116}
117
118/** A preload skill is first filed under `spawn:{tool_use_id}`; once the spawn returns, it belongs to the child. */
119export function resolveSpawn(ledger: Ledger, toolUseId: string, agentId: string): Ledger {
120 const from = `spawn:${toolUseId}`;
121 const entries = ledger.entries.map((e) => (e.kind === 'skill' && e.party === from ? { ...e, party: `agent:${agentId}` as const } : e));
122 return { ...ledger, entries };
123}
124
125export type SkillAttribution = { party: PartyKey; how: SkillHow };
126
127/** D5: which party loaded a skill, from the typed events around skill.prompt (the event carries no agentId). */
128export function attributeSkill(input: {
129 inFlightSkills: readonly { party: PartyKey; tool_use_id: string }[];
130 pendingCommand: string | undefined;
131 pendingSpawns: ReadonlySet<string>;
132 skill: string;
133}): SkillAttribution {
134 const { inFlightSkills, pendingCommand, pendingSpawns, skill } = input;
135 const [only] = inFlightSkills;
136 if (inFlightSkills.length === 1 && only) return { party: only.party, how: 'skill-tool' };
137 if (inFlightSkills.length === 0) {
138 const command = pendingCommand?.replace(/^\//, '');
139 if (command !== undefined && (command === skill || command.endsWith(`:${skill}`))) return { party: 'main', how: 'typed-command' };
140 const [spawn] = pendingSpawns;
141 if (pendingSpawns.size === 1 && spawn !== undefined) return { party: `spawn:${spawn}`, how: 'preload' };
142 }
143 return { party: 'unknown', how: 'ambiguous' };
144}
145
146export type Facts = {
147 person: PersonEntry[];
148 /** Person messages left out because the kept tail already holds them. */
149 personInTail: number;
150 notices: NoticeEntry[];
151 /** Every delivery to the party recorded from seq `since` (its handoff stored) that the kept tail does not hold, oldest first: the model wrote its handoff without them. Left out of person, notices and received. */
152 arrived: { at: number; origin: string; text: string }[];
153 brief?: BriefEntry;
154 received: ReceivedEntry[];
155 skills: SkillEntry[];
156 unattributedSkills: SkillEntry[];
157 shells?: ShellsEntry;
158 /** The files the party's calls touched and its task list, from its finished tool calls. */
159 work: WorkSummary;
160 startedAt?: number;
161 startReason?: string;
162};
163
164/** What the wake records for a party. Text already in a kept tail message (equal or contained) is left out. */
165export function factsFor(ledger: Ledger, party: PartyKey, opts: { keptTexts: readonly string[]; /** A ledger seq: deliveries from it on are `arrived`. */ since?: number }): Facts {
166 const fresh = <T extends { text: string }>(e: T): boolean =>
167 e.text === '' || !opts.keptTexts.some((kept) => kept.includes(e.text));
168 const before = (e: { seq: number }): boolean => opts.since === undefined || e.seq < opts.since;
169 const entries = ledger.entries;
170 const mine = <K extends LedgerEntry['kind']>(kind: K, who: PartyKey): Extract<LedgerEntry, { kind: K }>[] =>
171 entries.filter((e): e is Extract<LedgerEntry, { kind: K }> => e.kind === kind && 'party' in e && e.party === who);
172 // Latest text per skill name wins before the kept filter: an older version is stale, not "already there".
173 const newestFirst = (who: PartyKey) => latestPerSkill(mine('skill', who).reverse());
174 const carried = new Set(newestFirst(party).map((s) => s.skill));
175 const delivered = entries.filter((e): e is PersonEntry | NoticeEntry | ReceivedEntry => (e.kind === 'person' || e.kind === 'notice' || e.kind === 'received') && e.party === party);
176 const arrived = delivered.filter((e) => !before(e) && e.text !== '' && fresh(e)).map((e) => ({ at: e.at, origin: e.kind === 'person' ? 'person' : e.origin, text: e.text }));
177 const person = mine('person', party).filter(before);
178 const kept = person.filter(fresh);
179 return {
180 person: kept,
181 personInTail: person.length - kept.length,
182 notices: mine('notice', party).filter(before).filter(fresh),
183 arrived,
184 brief: mine('brief', party).filter(fresh).at(-1),
185 received: mine('received', party).filter(before).filter(fresh),
186 skills: newestFirst(party).filter(fresh),
187 unattributedSkills: newestFirst('unknown').filter((s) => !carried.has(s.skill)).filter(fresh),
188 shells: entries.find((e): e is ShellsEntry => e.kind === 'shells'),
189 work: workSummary(mine('work', party).map((e) => e.fact)),
190 startedAt: ledger.startedAt,
191 startReason: ledger.startReason,
192 };
193}
194src/wake.ts 414 lines1// The wake: what a compaction installs. [facts] [handoff] [tail…] — the facts and the handoff are built here from typed values,
2// the tail is the engine's own messages returned untouched. Pure: files, agents and the clock arrive as inputs.
3import { DEFAULT_TOOL, FREE, k as kilo } from './nudge.ts';
4import { loadFilesRoom } from './window.ts';
5import { PROMPTS_KEPT } from './ledger.ts';
6import { experimental, EXPERIMENTAL_OPENING } from './handoff.ts';
7
8/** A message in `session.compact`'s shape; a SessionMessage fits. */
9export type WakeMessage = {
10 role: 'user' | 'assistant';
11 text: string;
12 toolUses: readonly { tool_use_id: string; tool?: string; input?: unknown; text?: string }[];
13 toolResults?: readonly { tool_use_id: string; text?: string }[];
14 handle?: string;
15};
16
17/** A message this module builds; no handle, so the engine reads it as built. */
18export type BuiltMessage = { role: 'user'; text: string; toolUses: [] };
19
20export type Sibling = { tool: string; input: unknown; text: string };
21
22// Characters and tokens convert only at the party's measured ratio (`charsPerToken` in window.ts), never at a fixed one.
23const tokensOf = (chars: number, ratio: number): number => Math.ceil(chars / ratio);
24const charsIn = (tokens: number, ratio: number | undefined): number => (ratio === undefined ? Number.POSITIVE_INFINITY : Math.floor(tokens * ratio));
25const num = (value: number): string => value.toLocaleString('en-US');
26const iso = (at: number): string => new Date(at).toISOString();
27
28/**
29 * Per-section shares of the budget B (D18), design budgets and never measured sizes: the person's words or the brief and the
30 * skills in force are what a woken model most needs (a quarter each), sibling results a tenth, coordinator notices a
31 * twentieth; the rest of B goes to the messages kept after the call. Load files have their own room, `loadFilesRoom`.
32 */
33const SHARE = { people: 0.25, arrived: 0.25, notices: 0.05, siblings: 0.1, skills: 1, tasks: 0.05 } as const;
34/** The most files the Files section lists, newest touch first: a path list stays a glance, as the old carry's trail did. */
35export const FILES_MAX = 15;
36/** The agents section's row cap: a design budget, enough for a coordinator's fleet, never the size of one. */
37export const AGENTS_MAX_ROWS = 50;
38
39/** B = half the ask point, a design budget: a wake above it would draw the next ask at once, and half leaves the woken party room to work before it (D18). */
40export function wakeBudget(askPoint: number): number {
41 return Math.floor(0.5 * askPoint);
42}
43
44/**
45 * The anchor is the last assistant message whose tool uses hold `toolUseId`; the response is the run of assistant
46 * messages around it; the user messages right after it whose results all answer that response's calls are its results.
47 * `tail` is everything after them, the same objects (handles kept); `siblings` are the response's other calls with
48 * their result text; `own` is the call's own result (its receipt and experimental line), which the tail no longer holds.
49 * Undefined when the anchor is not in `messages`.
50 */
51export function splitAtCall<M extends WakeMessage>(messages: readonly M[], toolUseId: string): { tail: M[]; siblings: Sibling[]; own?: string } | undefined {
52 let at = -1;
53 for (let i = messages.length - 1; i >= 0; i--) {
54 const m = messages[i]!;
55 if (m.role === 'assistant' && m.toolUses.some((u) => u.tool_use_id === toolUseId)) {
56 at = i;
57 break;
58 }
59 }
60 if (at < 0) return undefined;
61 let first = at;
62 while (first > 0 && messages[first - 1]!.role === 'assistant') first--;
63 let last = at;
64 while (last + 1 < messages.length && messages[last + 1]!.role === 'assistant') last++;
65 const uses = messages.slice(first, last + 1).flatMap((m) => m.toolUses);
66 const ids = new Set(uses.map((u) => u.tool_use_id));
67 const results = new Map<string, string>();
68 let next = last + 1;
69 while (next < messages.length) {
70 const m = messages[next]!;
71 const answers = m.toolResults ?? [];
72 if (m.role !== 'user' || answers.length === 0 || !answers.every((r) => ids.has(r.tool_use_id))) break;
73 for (const r of answers) results.set(r.tool_use_id, r.text ?? '');
74 next++;
75 }
76 const siblings = uses
77 .filter((u) => u.tool_use_id !== toolUseId)
78 .map((u) => ({ tool: u.tool ?? 'tool', input: u.input, text: results.get(u.tool_use_id) ?? u.text ?? '(no result recorded)' }));
79 const own = results.get(toolUseId);
80 return { tail: messages.slice(next), siblings, ...(own !== undefined ? { own } : {}) };
81}
82
83/** A message's size at `ratio` characters a token: its text, its tool inputs and its tool results (a call's own result text is the answer's, counted there). */
84function messageTokens(m: WakeMessage, ratio: number): number {
85 let chars = m.text.length;
86 for (const u of m.toolUses) chars += JSON.stringify(u.input ?? {}).length;
87 for (const r of m.toolResults ?? []) chars += (r.text ?? '').length;
88 return tokensOf(chars, ratio);
89}
90
91/**
92 * The newest whole messages that fit `budgetTokens`: the oldest are dropped until the rest fits, then any leading user
93 * message carrying tool results too, so no result stands without its call. The kept messages are the input objects.
94 */
95export function fitTail<M extends WakeMessage>(messages: readonly M[], budgetTokens: number, ratio: number): { messages: M[]; dropped: number } {
96 let total = messages.reduce((sum, m) => sum + messageTokens(m, ratio), 0);
97 let from = 0;
98 while (from < messages.length && total > budgetTokens) total -= messageTokens(messages[from++]!, ratio);
99 while (from < messages.length && messages[from]!.role === 'user' && (messages[from]!.toolResults?.length ?? 0) > 0) from++;
100 return { messages: messages.slice(from), dropped: from };
101}
102
103/** What the ledger gives for a party (ledger.ts `Facts` fits); only what the wake renders. */
104export type WakeFacts = {
105 person: readonly { at: number; text: string }[];
106 /** Person messages the ledger left out because the tail holds them. */
107 personInTail?: number;
108 notices: readonly { at: number; origin: string; text: string }[];
109 /** What reached the party after it stored its handoff and the kept tail lacks (ledger.ts `Facts`); absent on a mechanical wake. */
110 arrived?: readonly { at: number; origin: string; text: string }[];
111 /** The `agent.spawn` prompt; absent when the agent was spawned before the ledger started. */
112 brief?: { text: string };
113 received: readonly { at: number; origin: string; text: string }[];
114 /** Latest text per skill, newest first. */
115 skills: readonly { skill: string; text: string; source?: string; path?: string; keep?: true }[];
116 unattributedSkills: readonly { skill: string }[];
117 shells?: {
118 at: number;
119 tasks: readonly { id: string; type: string; status: string; description: string; command?: string }[];
120 crons: readonly { id: string; schedule: string; recurring: boolean; prompt: string }[];
121 };
122 /** The files the party's finished calls touched, newest first, and its task list as last set. */
123 work?: { files: readonly { path: string; tag: 'R' | 'E' | 'W' }[]; tasks: readonly string[] };
124 startedAt?: number;
125 startReason?: string;
126};
127
128/** A load file, already read by the adapter: its content, or why it could not be read. */
129export type WakeFile = { path: string; lines?: string; why: string } & ({ content: string; problem?: undefined } | { problem: string; content?: undefined });
130export type AgentRow = { id: string; type: string; status: string; description: string; parentId?: string };
131
132type Shared<M> = {
133 /** The party's current `e.messages`. */
134 messages: readonly M[];
135 party: 'main' | 'subagent';
136 /** The party's context reading, for the wake's own wording; undefined when it has none. */
137 tokens?: number;
138 askPoint: number;
139 /** The party's measured characters per token (window.ts `charsPerToken`); undefined while unmeasured: then nothing is fitted, see `assemble`. */
140 charsPerToken?: number;
141 /** `$.clock.now()`, for the age of the shells snapshot. */
142 now: number;
143 /** The party's facts, leaving out entries already inside `keptTexts` (the kept tail's texts): the tail is fitted around the facts and the facts around the tail. */
144 facts: (keptTexts: readonly string[]) => WakeFacts;
145 /** The woken sub-agent's id: its wake lists only the agents it spawned, and leaves it out of the background tasks. Absent for main, whose wake lists every agent. */
146 self?: string;
147 /** `$.agent.list()` rows, or its error; absent, no section. */
148 agents?: readonly AgentRow[] | { error: string };
149 /** The registered `forward_compaction` name, DEFAULT_TOOL when absent. */
150 tool?: string;
151 /** The level-log directory, where the issues file the wake's last line names lives. */
152 logDir?: string;
153};
154/** `maxContext`: the party's max context, whose `loadFilesRoom` the files are carried in. */
155export type BuildWakeInput<M> = Shared<M> & { toolUseId: string; handoff: string; files?: readonly WakeFile[]; maxContext?: number };
156export type MechanicalWakeInput<M> = Shared<M> & { reason: { kind: 'ceiling'; ceiling: number } | { kind: 'reactive' } };
157
158export type Wake<M> = {
159 /** [F, H, ...tail]: H is the last message built here; the tail is the input objects. */
160 messages: (BuiltMessage | M)[];
161 /** F sections that hold content. */
162 facts: number;
163 kept: number;
164 dropped: number;
165 /** For the debug row: each F section's heading and characters, the head's characters, and the load files carried and not. */
166 sizes: { section: string; chars: number }[];
167 headChars: number;
168 carried: string[];
169 notCarried: string[];
170};
171
172type Section = { text: string; content: boolean };
173const section = (text: string): Section => ({ text, content: true });
174
175/** Entries newest first while they fit, rendered oldest first; the older ones left are counted. */
176function newestFitting<T>(entries: readonly T[], render: (entry: T) => string, room: number): { shown: string[]; left: number } {
177 const shown: string[] = [];
178 let used = 0;
179 for (let i = entries.length - 1; i >= 0; i--) {
180 const block = render(entries[i]!);
181 if (used + block.length + 1 > room) return { shown: shown.reverse(), left: i + 1 };
182 shown.push(block);
183 used += block.length + 1;
184 }
185 return { shown: shown.reverse(), left: 0 };
186}
187
188function age(ms: number): string {
189 const s = Math.max(0, Math.round(ms / 1000));
190 if (s < 60) return `${s}s`;
191 if (s < 3600) return `${Math.round(s / 60)}m`;
192 return `${Math.round(s / 3600)}h`;
193}
194
195type Extras = { siblings: readonly Sibling[]; files: readonly WakeFile[]; fileRoom: number };
196
197type Rendered = { text: string; sections: number; sizes: Wake<never>['sizes']; carried: string[]; notCarried: string[] };
198
199/** F: every section with its cap; whole or not, never cut. With no measured ratio no section is capped. */
200function renderFacts<M>(input: Shared<M>, facts: WakeFacts, { siblings, files, fileRoom }: Extras): Rendered {
201 const B = wakeBudget(input.askPoint);
202 const room = (share: number): number => charsIn(B * share, input.charsPerToken);
203 const filesCarried: string[] = [];
204 const filesNotCarried: string[] = [];
205 const sections: Section[] = [];
206 const sub = input.party === 'subagent';
207
208 // First: what the model has not seen, which a notification-driven party would otherwise wait on forever.
209 const arrived = facts.arrived ?? [];
210 if (arrived.length > 0) {
211 const { shown, left } = newestFitting(arrived, (m) => `### ${iso(m.at)} (${m.origin})\n${m.text}`, room(SHARE.arrived));
212 sections.push(section(['## Arrived after you wrote your handoff: you have not read these yet', ...(left > 0 ? [`(${left} older not carried; look_back finds them)`] : []), ...shown].join('\n')));
213 }
214
215 if (sub) {
216 const cap = room(SHARE.people);
217 if (facts.brief === undefined) sections.push({ text: '## Your brief\n(not recorded: this agent was spawned before forward-compaction started in this process)', content: false });
218 else if (facts.brief.text.length > cap) sections.push({ text: `## Your brief\n(not carried: ${num(facts.brief.text.length)} characters, over the ${num(cap)}-character room; look_back finds it)`, content: false });
219 else sections.push(section(`## Your brief\n${facts.brief.text}`));
220 } else if (facts.person.length === 0 && (facts.personInTail ?? 0) > 0) {
221 sections.push({ text: `## The person's messages\n(all ${facts.personInTail} are in the messages below)`, content: false });
222 } else if (facts.person.length === 0) {
223 sections.push({ text: `## The person's messages\n(none recorded${facts.startedAt === undefined ? '' : ` since ${iso(facts.startedAt)}`})`, content: false });
224 } else {
225 const { shown } = newestFitting(facts.person.slice(-PROMPTS_KEPT), (p) => `### ${iso(p.at)}\n${p.text}`, room(SHARE.people));
226 const count = facts.person.length;
227 const left = count - shown.length;
228 const heading = left === 0 ? `(${shown.length} of ${count})` : `(${shown.length} of ${count}; the ${left} oldest are not carried: look_back finds them)`;
229 sections.push(section([`## The person's messages, oldest first ${heading}`, ...shown].join('\n')));
230 }
231
232 const inbox = sub ? { entries: facts.received, title: 'Messages delivered to you' } : { entries: facts.notices, title: 'Notifications delivered to you' };
233 if (inbox.entries.length > 0) {
234 const { shown } = newestFitting(sub ? inbox.entries.slice(-PROMPTS_KEPT) : inbox.entries, (m) => `### ${iso(m.at)} (${m.origin})\n${m.text}`, room(SHARE.notices));
235 const left = inbox.entries.length - shown.length;
236 sections.push(section([`## ${inbox.title}`, ...(left > 0 ? [`(${left} older not carried)`] : []), ...shown].join('\n')));
237 }
238
239 if (siblings.length > 0) {
240 let left = room(SHARE.siblings);
241 const rows = siblings.map((s) => {
242 const block = `### ${s.tool} ${JSON.stringify(s.input ?? {})}\n${s.text}`;
243 if (block.length + 1 > left) return `- ${s.tool}: result not carried (${num(s.text.length)} characters)`;
244 left -= block.length + 1;
245 return block;
246 });
247 sections.push(section(['## Results of the calls you made beside the handoff', ...rows].join('\n')));
248 }
249
250 const notCarried: string[] = [];
251 const carriedSkills: string[] = [];
252 let skillRoom = room(SHARE.skills); // every skill the party invoked is carried whole: the whole budget, the tail fitted after it
253 for (const s of facts.skills) {
254 const block = `### ${s.skill} (${s.source ?? 'unknown source'}${s.path === undefined ? '' : `, ${s.path}`}${s.keep ? ', pinned: never dropped' : ''})\n${s.text}`;
255 if (block.length + 1 > skillRoom) notCarried.push(`- ${s.skill}: over the skill room (${num(s.text.length)} characters); call Skill ${s.skill} to load it again.`);
256 else {
257 skillRoom -= block.length + 1;
258 carriedSkills.push(block);
259 }
260 }
261 if (carriedSkills.length > 0) sections.push(section(['## Skills in force, as loaded (one you no longer need: name it in drop_skills on two handoffs in a row)', ...carriedSkills].join('\n')));
262 for (const s of facts.unattributedSkills) notCarried.push(`- ${s.skill}: loaded in this session by an unidentified party; call Skill ${s.skill} if it applies to you.`);
263 if (notCarried.length > 0) sections.push({ text: ['## Skills not carried', ...notCarried].join('\n'), content: false });
264
265 const work = facts.work;
266 if (work !== undefined && work.files.length > 0) {
267 const more = work.files.length - FILES_MAX;
268 sections.push(section(['## Files touched, newest first (R read, E edited, W written)', ...work.files.slice(0, FILES_MAX).map((f) => `${f.tag} ${f.path}`), ...(more > 0 ? [`(${more} older not listed)`] : [])].join('\n')));
269 }
270 if (work !== undefined && work.tasks.length > 0) {
271 const text = ['## Tasks, as last set', ...work.tasks].join('\n');
272 const cap = room(SHARE.tasks);
273 sections.push(text.length <= cap ? section(text) : { text: `## Tasks\n(not carried: ${num(text.length)} characters, over the ${num(cap)}-character room; look_back finds them)`, content: false });
274 }
275
276 if (input.agents !== undefined) {
277 if (!Array.isArray(input.agents)) sections.push({ text: `## Agents\n(could not be listed: ${(input.agents as { error: string }).error})`, content: false });
278 else if (input.agents.some((a) => input.self === undefined || a.parentId === input.self)) {
279 const others = input.agents.filter((a) => input.self === undefined || a.parentId === input.self);
280 const rows = others.slice(0, AGENTS_MAX_ROWS).map((a) => `- ${a.id} ${a.type} ${a.status}: ${a.description}`);
281 const more = others.length - AGENTS_MAX_ROWS;
282 sections.push(section(['## Agents, listed at install', ...rows, ...(more > 0 ? [`(${more} more not listed)`] : [])].join('\n')));
283 }
284 }
285
286 if (facts.shells === undefined) sections.push({ text: '## Background shells and crons\n(unknown: no Stop event yet in this process)', content: false });
287 else {
288 const rows = [
289 ...facts.shells.tasks.filter((t) => t.id !== input.self).map((t) => `- ${t.type} ${t.id} ${t.status}: ${t.command ?? t.description}`),
290 ...facts.shells.crons.map((c) => `- cron ${c.id} ${c.schedule}${c.recurring ? ' recurring' : ''}: ${c.prompt}`),
291 ];
292 const header = `## Background shells and crons (session-wide, from the Stop event ${age(input.now - facts.shells.at)} before install)`;
293 sections.push({ text: [header, ...(rows.length > 0 ? rows : ['(none running)'])].join('\n'), content: rows.length > 0 });
294 }
295
296 if (files.length > 0) {
297 let left = fileRoom; // the content alone counts, as the store-time check (`loadRoomRefusal`) counts it
298 const carried: string[] = [];
299 const dropped: string[] = [];
300 for (const f of files) {
301 if (f.problem !== undefined) {
302 dropped.push(`- ${f.path}: ${f.problem}`);
303 filesNotCarried.push(f.path);
304 continue;
305 }
306 if (f.content.length > left) (dropped.push(`- ${f.path}: ${num(f.content.length)} characters, over the room left`), filesNotCarried.push(f.path));
307 else {
308 left -= f.content.length;
309 filesCarried.push(f.path);
310 carried.push(`### ${f.path}${f.lines === undefined ? '' : ` lines ${f.lines}`} — ${f.why}\n${f.content}`);
311 }
312 }
313 if (carried.length > 0) sections.push(section(['## Files you asked to carry', ...carried].join('\n')));
314 if (dropped.length > 0) sections.push({ text: ['## Files not carried', ...dropped].join('\n'), content: false });
315 }
316
317 const resume = facts.startReason === 'resume' && facts.startedAt !== undefined ? [`The ledger started at ${iso(facts.startedAt)} when this process resumed; anything earlier is not recorded.`] : [];
318 const head = '[forward-compaction: facts] Your context was compacted by forward-compaction. These facts were recorded as they happened, never reconstructed. Your own handoff follows in the next message.';
319 return {
320 text: [[head, ...resume].join('\n'), ...sections.map((s) => s.text)].join('\n\n'),
321 sections: sections.filter((s) => s.content).length,
322 sizes: sections.map((s) => ({ section: s.text.split('\n', 1)[0]!, chars: s.text.length })),
323 carried: filesCarried,
324 notCarried: filesNotCarried,
325 };
326}
327
328/** Every text a kept message holds, for the facts to leave out what the tail already says. */
329function keptTexts(kept: readonly WakeMessage[]): string[] {
330 return kept.flatMap((m) => [m.text, ...m.toolUses.map((u) => u.text ?? ''), ...(m.toolResults ?? []).map((r) => r.text ?? '')]).filter((t) => t !== '');
331}
332
333/**
334 * [F, ...H, ...tail] around a head text: the tail is fitted to what the facts and the head leave of the budget, and the
335 * facts are rendered against the tail kept, so each is sized for the other (the tail is only ever shrunk, so this ends).
336 * With no measured ratio nothing is fitted: `unmeasured` says whether every candidate is kept (the messages after a
337 * handoff's call) or none is (a mechanical wake's earlier tail).
338 */
339function assemble<M extends WakeMessage>(
340 input: Shared<M>,
341 candidates: readonly M[],
342 extras: Extras,
343 head: (counts: { kept: number; dropped: number }) => string,
344 unmeasured: 'all' | 'none',
345): Wake<M> {
346 const B = wakeBudget(input.askPoint);
347 const ratio = input.charsPerToken;
348 const longest = head({ kept: Math.max(1, candidates.length), dropped: Math.max(1, candidates.length) }).length;
349 let kept: readonly M[] = candidates;
350 for (;;) {
351 const f = renderFacts(input, input.facts(keptTexts(kept)), extras);
352 const fit = ratio === undefined ? { messages: unmeasured === 'all' ? [...candidates] : [] } : fitTail(candidates, B - tokensOf(f.text.length, ratio) - tokensOf(longest, ratio), ratio);
353 // only a smaller fit is taken: facts can shrink with the tail, and a fit growing back would loop forever
354 if (fit.messages.length < kept.length) {
355 kept = fit.messages;
356 continue;
357 }
358 const counts = { kept: kept.length, dropped: candidates.length - kept.length };
359 const text = head(counts);
360 return {
361 messages: [{ role: 'user', text: f.text, toolUses: [] }, { role: 'user', text, toolUses: [] }, ...kept],
362 facts: f.sections,
363 ...counts,
364 sizes: f.sizes,
365 headChars: text.length,
366 carried: f.carried,
367 notCarried: f.notCarried,
368 };
369 }
370}
371
372/** The wake for a stored handoff: facts, the handoff verbatim, then every message after the `forward_compaction` call. Throws when the call is not in `messages`. */
373export function buildWake<M extends WakeMessage>(input: BuildWakeInput<M>): Wake<M> {
374 const split = splitAtCall(input.messages, input.toolUseId);
375 if (split === undefined) throw new Error(`forward-compaction: call ${input.toolUseId} is not in the transcript to compact`);
376 return assemble(input, split.tail, { siblings: split.siblings, files: input.files ?? [], fileRoom: loadFilesRoom(input.maxContext ?? 0, input.charsPerToken) }, ({ kept, dropped }) => {
377 const lines = [
378 '[forward-compaction: handoff] Below is the handoff you wrote with forward_compaction. It is your frontier: continue from its ## Next.',
379 `=== handoff ===\n${input.handoff}\n=== end of handoff ===`,
380 ];
381 // The receipt's own experimental line moves to the wake's end, where it is read last.
382 if (split.own !== undefined) lines.push(`Your forward_compaction call returned:\n${split.own.split(`\n${EXPERIMENTAL_OPENING}`)[0]}`);
383 if (kept > 0) lines.push(`The ${kept} messages after this one are what happened after you wrote it, kept verbatim. Continue from where they leave off.`);
384 if (dropped > 0) lines.push(`${dropped} older messages after your handoff did not fit and are gone (look_back finds an exact detail); call ${input.tool ?? DEFAULT_TOOL} again at your next milestone.`);
385 if (kept === 0) lines.push('Nothing happened after you wrote it. Continue from ## Next.');
386 lines.push(FREE); // a woken party budgets what still fits before the next handoff and cuts the task short: say it is free
387 return experimental(lines.join('\n\n'), input.logDir);
388 }, 'all');
389}
390
391/** The wake for a party with no handoff: the same facts, then the newest messages of `messages` that fit. */
392export function mechanicalWake<M extends WakeMessage>(input: MechanicalWakeInput<M>): Wake<M> {
393 const why = input.reason.kind === 'ceiling' ? `, past the ${kilo(input.reason.ceiling)} ceiling` : ', and Claude Code reported your request too long';
394 const source = input.party === 'main' ? "the person's messages" : 'your brief';
395 return assemble(input, input.messages, { siblings: [], files: [], fileRoom: 0 }, ({ kept, dropped }) => experimental(
396 `[forward-compaction: no frontier written by the model] Your context reached ${input.tokens === undefined ? 'a size not measured yet' : `${kilo(input.tokens)} tokens`}${why}, and you had not called forward_compaction, so the plugin compacted it mechanically: the facts above and your newest ${kept} messages below, kept verbatim; ${dropped} older messages are gone (look_back finds an exact detail). No objective or next step was written. Re-establish them from ${source} and the messages below, then continue; call ${input.tool ?? DEFAULT_TOOL} at your next milestone. ${FREE}`, input.logDir),
397 'none');
398}
399
400/** An installed wake whose landed line waits for the party's first reading after it: what the line says, `before` the last reading before the install. */
401export type Landing = { label: string; before?: number } & ({ kind: 'handoff'; chars: number; facts: number; kept: number; dropped: number } | { kind: 'mechanical'; tokens?: number; ceiling: number });
402
403/** The measured sizes: the last reading before the install and the first after it; one never read is said as unknown, never estimated. */
404function measured(before: number | undefined, after: number | undefined): string {
405 const unknown = after === undefined ? ' (it ended before a request after the install measured it)' : ', measured';
406 return `${before === undefined ? 'unknown' : kilo(before)} → ${after === undefined ? 'unknown' : kilo(after)} context${before === undefined && after !== undefined ? ' (no reading before the install), measured after' : unknown}`;
407}
408
409/** The screen line for an installed wake (always shown), at the party's first reading after it (`after`), or at its end with `after` unknown. */
410export function landedLine(l: Landing, after?: number): string {
411 if (l.kind === 'mechanical') return `forward-compaction: ${l.label} had no handoff at ${l.tokens === undefined ? 'an unmeasured size (no reading yet)' : kilo(l.tokens)} (ceiling ${kilo(l.ceiling)}) — mechanical wake installed, ${measured(l.before, after)}.`;
412 return `forward-compaction: ${l.label} handoff landed — ${measured(l.before, after)}; frontier ${num(l.chars)} characters, ${l.facts} facts, ${l.kept} messages kept verbatim${l.dropped > 0 ? `, ${l.dropped} dropped` : ''}.`;
413}
414src/decide.ts 109 lines1export type Trigger = 'manual' | 'auto' | 'plugin' | 'precompute';
2
3const TRIGGERS: readonly Trigger[] = ['auto', 'manual', 'plugin', 'precompute'];
4
5/** The trigger when the plugin knows it; undefined for any other value a newer Claude Code may send. */
6export function knownTrigger(trigger: unknown): Trigger | undefined {
7 return TRIGGERS.find((known) => known === trigger);
8}
9
10/** The context a response was answered over: uncached input plus cache reads and writes. */
11export function contextTokens(usage: {
12 input_tokens?: number;
13 cache_read_input_tokens?: number;
14 cache_creation_input_tokens?: number;
15} | null | undefined): number | undefined {
16 if (!usage || typeof usage.input_tokens !== 'number') return undefined;
17 return usage.input_tokens + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
18}
19
20/** A party's reading: the context its response was answered over plus that response's own output, which the next request carries too. */
21export function readingTokens(usage: ({ output_tokens?: number } & NonNullable<Parameters<typeof contextTokens>[0]>) | null | undefined): number | undefined {
22 const context = contextTokens(usage);
23 return context === undefined ? undefined : context + (typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0);
24}
25
26export type PartyKind = 'main' | 'subagent' | 'foreign';
27
28export type DecideInput = {
29 trigger: Trigger;
30 party: PartyKind;
31 /** A handoff is stored and its `forward_compaction` call is in `e.messages`. */
32 stored?: boolean;
33 /** A handoff is stored but its call is not in `e.messages`: something else compacted the party since. */
34 storedMissing?: boolean;
35 /** The party's last reading; undefined when it has none (none yet, or none since an install): no ceiling is reached then. */
36 tokens?: number;
37 /** The context at which a party with no handoff gets a mechanical wake. Left out, it is never reached. */
38 ceiling?: number;
39 /** `parties.reactive`: the ask follows a skip with no completed request, so Claude Code found the request too long (D8). */
40 reactive?: boolean;
41 /** `FORWARD_COMPACTION_CEILING_NATIVE`: at the ceiling, Claude Code's own summary runs. */
42 ceilingNative?: boolean;
43 /** Screen name of the party: "main", or "{subagentType} {agentId first 6}". */
44 label?: string;
45 /** `Parties.ignored`: the party's model is on the ignore list: it takes no handoff, and its asks are held until its ceiling. */
46 ignored?: boolean;
47 /** `Parties.windowUnknown`: the party's ceiling is not known, so it is never reached; a reactive ask stays mechanical. */
48 windowUnknown?: boolean;
49};
50
51/** The decision row's `decision` for the action. */
52export type Action =
53 | { action: 'next'; decision: 'native' | 'pass' | 'foreign'; why: 'manual' | 'plugin' | 'foreign' | 'ceiling' | 'reactive'; dropStored?: true }
54 | { action: 'skip'; decision: 'skip'; why: 'precompute' | 'waiting' | 'no-reading' | 'ignored-model'; reason: string; dropStored?: true }
55 | { action: 'install'; decision: 'install'; why: 'stored' }
56 | { action: 'mechanical'; decision: 'mechanical'; why: 'ceiling' | 'reactive'; dropStored?: true };
57
58/**
59 * One `session.compact` ask (rows C1-C10 of the decision table). A manual ask runs Claude Code's own summary and
60 * drops the stored handoff; a precompute is skipped; a handoff stored for main installs at a plugin request
61 * (the turn-end door) and for any party at an automatic ask. With nothing stored an automatic ask is held until
62 * the ceiling, or at once when it follows a held ask unanswered (the request was too long), and then the party
63 * gets a mechanical wake, or Claude Code's own summary when the ceiling is native. A foreign party (the engine's own
64 * fork) is never ours: its automatic ask passes. A party whose model is on the ignore list stores nothing, so its ask is
65 * held as `ignored-model` until the ceiling. An automatic ask from any other party with no reading and nothing stored is
66 * held as `no-reading`: the next reading decides. A stored handoff whose call left the transcript is dropped, and the ask goes on as if none were stored.
67 */
68export function decide({ trigger, party, stored = false, storedMissing = false, tokens, ceiling = Number.POSITIVE_INFINITY, reactive = false, ceilingNative = false, label = 'main', ignored = false, windowUnknown = false }: DecideInput): Action {
69 const drop = storedMissing ? ({ dropStored: true } as const) : {};
70 if (trigger === 'manual') return { action: 'next', decision: 'native', why: 'manual', dropStored: true };
71 if (trigger === 'precompute') return { action: 'skip', decision: 'skip', why: 'precompute', reason: 'forward-compaction: precompute is never used' };
72 if (trigger === 'plugin') {
73 if (party === 'main' && stored) return { action: 'install', decision: 'install', why: 'stored' };
74 return { action: 'next', decision: 'pass', why: 'plugin', ...(party === 'main' ? drop : {}) };
75 }
76 if (party === 'foreign') return { action: 'next', decision: 'foreign', why: 'foreign' };
77 if (stored && !ignored) return { action: 'install', decision: 'install', why: 'stored' };
78 const atCeiling = !windowUnknown && tokens !== undefined && tokens >= ceiling;
79 if (atCeiling || reactive) {
80 const why = atCeiling ? 'ceiling' : 'reactive';
81 if (ceilingNative) return { action: 'next', decision: 'native', why, ...drop };
82 return { action: 'mechanical', decision: 'mechanical', why, ...drop };
83 }
84 if (ignored) return { action: 'skip', decision: 'skip', why: 'ignored-model', reason: `forward-compaction: holding Claude Code's compaction for ${label}: its model is on the ignore list, so it is compacted mechanically at its ceiling`, ...drop };
85 if (tokens === undefined) return { action: 'skip', decision: 'skip', why: 'no-reading', reason: `forward-compaction: holding Claude Code's compaction for ${label}: it has no reading yet, and its next reading decides`, ...drop };
86 return { action: 'skip', decision: 'skip', why: 'waiting', reason: `forward-compaction: holding Claude Code's compaction for ${label} until its handoff is stored or the ceiling is reached`, ...drop };
87}
88
89export type TurnEndInput = {
90 main: boolean;
91 /** `turn.complete`'s reason. */
92 reason: string;
93 /** A handoff is stored for the party. */
94 stored: boolean;
95 /** `parties.get(key).installedThisTurn`. */
96 installedThisTurn: boolean;
97};
98
99export type TurnEnd = { compact: true } | { compact: false; why: 'not-main' | 'not-answered' | 'nothing-stored' | 'installed-this-turn' };
100
101/** Row P1: whether main's turn end calls `$.session.compact()`. Everything else keeps the handoff stored for the next door. */
102export function turnEndDoor({ main, reason, stored, installedThisTurn }: TurnEndInput): TurnEnd {
103 if (!main) return { compact: false, why: 'not-main' };
104 if (reason !== 'answer') return { compact: false, why: 'not-answered' };
105 if (!stored) return { compact: false, why: 'nothing-stored' };
106 if (installedThisTurn) return { compact: false, why: 'installed-this-turn' };
107 return { compact: true };
108}
109src/nudge.ts 60 lines1/** The tool's name as the installed copy registered it; a `-dev` copy's differs. */
2export const DEFAULT_TOOL = 'mcp__forward-compaction__forward_compaction';
3
4/** A token size for model-facing and screen text: thousands as `51k`, a million or more as `1M` or `1.5M`. */
5export function k(tokens: number): string {
6 const thousands = Math.round(tokens / 1000);
7 return thousands < 1000 ? `${thousands}k` : `${Math.round(tokens / 100_000) / 10}M`;
8}
9
10/** 0 below `start`, 1 at it, and one more per `every` tokens past it. */
11export function nudgeLevel(tokens: number, start: number, every: number): number {
12 if (tokens < start) return 0;
13 return 1 + Math.floor((tokens - start) / Math.max(1, every));
14}
15
16export type NudgeInput = {
17 /** The party's last measured reading. */
18 tokens: number;
19 /** The party's model window, which the nudge percentages are taken against. */
20 window: number;
21 /** The context at which the plugin compacts the party mechanically. */
22 ceiling: number;
23 /** The registered `forward_compaction` name; DEFAULT_TOOL when absent. */
24 tool?: string;
25 /** A sub-agent's gate mark (`gateMark`): the nudge says that from there every other call is refused until it hands off. */
26 gateAt?: number;
27};
28
29/** What the model reads after a tool result once its context passes a nudge point: hand off at the next clean milestone; the model picks the moment, not whether. */
30/** Said in every nudge and refusal: a model that reads the handoff as a cost cuts its task short to avoid one. */
31export const FREE = 'A handoff costs you nothing: you continue from it with every message after it, as many times as the work needs, so carry the task through to its end; never cut it short to avoid one.';
32
33export function nudgeText({ tokens, window, ceiling, tool = DEFAULT_TOOL, gateAt }: NudgeInput): string {
34 const percent = Math.round((tokens / Math.max(1, window)) * 100);
35 return `forward-compaction: your context is ${k(tokens)} tokens (${percent}% of your ${k(window)} max context), and every call re-sends all of it. Hand off at your next clean milestone: once the step in hand is finished and its results are written down, and before you start the next step, call ${tool} with objective, done, remaining and next. You pick the milestone, not whether: skip it only if nothing is left but writing your result and your final answer. ${gateAt !== undefined && tokens < gateAt ? `From ${k(gateAt)} your other calls wait until you have handed off. ` : ''}At ${k(ceiling)} the plugin compacts you mechanically, with no frontier from you. ${FREE} If this tool is not in your tool list, carry on.`;
36}
37
38/** How many nudge levels past its first nudge a sub-agent's calls start waiting for its handoff: one, so the first nudge leaves it one step to pick its milestone. */
39export const GATE_STEPS = 1;
40
41/** The context at which a gate level closes. */
42export const gateMark = (start: number, every: number, gateLevel: number): number => start + (gateLevel - 1) * every;
43
44export type GateInput = { party: 'main' | 'subagent'; level: number; gateLevel?: number; stored: boolean; ignored: boolean; tool: string; open: readonly string[] };
45
46/**
47 * Whether a call waits for the party's handoff: a managed sub-agent at or past its gate level (GATE_STEPS past its first
48 * nudge since the last install) with nothing stored, calling anything but the tools that lead to the handoff (`open`).
49 * A fast reader passes its milestones between two nudges; the gate makes the handoff its next call instead of the
50 * mechanical wake. Main is never gated: its person steers it.
51 */
52export function gated({ party, level, gateLevel, stored, ignored, tool, open }: GateInput): boolean {
53 return party === 'subagent' && !ignored && !stored && gateLevel !== undefined && level >= gateLevel && !open.includes(tool);
54}
55
56/** The refusal a gated call answers with: nothing ran, hand off now, then repeat the call. */
57export function gateText({ tokens, ceiling, tool = DEFAULT_TOOL }: { tokens: number; ceiling: number; tool?: string }): string {
58 return `forward-compaction: this call did not run. Your context is ${k(tokens)} tokens and you have not handed off; at ${k(ceiling)} the plugin compacts you mechanically and your work so far is lost. Call ${tool} now, with objective, done, remaining and next: what you have read and concluded so far goes in done and findings, what is left in remaining. Then repeat this call; every call runs again once the handoff is stored. If ${tool} is not in your tool list, repeat this call: it runs. ${FREE}`;
59}
60src/parties.ts 262 lines1import type { Landing } from './wake.ts';
2import { ignoredBy, type RatioBase } from './window.ts';
3import { GATE_STEPS } from './nudge.ts';
4
5/** A file the model asked the wake to carry (the tool's `load_files` entry). */
6export type LoadFile = { path: string; lines?: string; why: string };
7
8/** A handoff the model wrote, held in memory until a door installs it. */
9export type StoredHandoff = {
10 text: string;
11 loadFiles: LoadFile[];
12 /** The `forward_compaction` call that stored it: the anchor `splitAtCall` finds in the messages. */
13 toolUseId: string;
14 /** The party's last reading when it stored the handoff; absent when it had none (none since an install). */
15 storedAtTokens?: number;
16 /** The ledger's next seq when it was stored: what the ledger records for the party from it, the model never saw (ledger.ts `factsFor` `since`). */
17 since: number;
18 /** Store order across all parties. */
19 seq: number;
20};
21
22/** What the party's last `session.compact` ask ended in (the decision row's `decision`). */
23export type AskDecision = 'skip' | 'install' | 'mechanical' | 'native' | 'pass' | 'foreign' | 'dropped' | 'error';
24
25/** What the plugin does with a party: manage it, leave it to Claude Code (its model is on the ignore list), or not yet know. */
26export type Judgement = 'managed' | 'ignored' | 'unknown';
27
28/** What a party is judged by: whether main's max context is known (set, or its window read), and the ignore list (`FORWARD_COMPACTION_IGNORE_MODELS`). */
29export type Judging = { mainKnown: boolean; ignore: readonly string[] };
30
31/** The decision row `ignore`: a party's ignore judgement, written when first made and each time it changes, with the list entry its model matched. */
32export type IgnoreRow = { event: 'ignore'; party: string; label: string; model: string | null; judged: Judgement; matched: string | null };
33
34export type PartyState = {
35 /** "main" or "agent:{id}". */
36 key: string;
37 /** What screen lines call the party: "main", or "{subagentType} {agentId first 6}". */
38 label: string;
39 model?: string;
40 /** The measured context of the party's last completed request; none from an install until its next reading. */
41 reading?: number;
42 /** The ratio base (window.ts `charsPerToken`), taken at the party's first reading in its live window; none from any compaction of it until its next reading. */
43 base?: RatioBase;
44 /** An installed wake whose landed line waits for the party's first reading after it. */
45 landing?: Landing;
46 stored?: StoredHandoff;
47 lastDecision?: AskDecision;
48 /** False from a skip until a request completes: the next ask is then a prompt too long (D8). */
49 answeredSinceSkip: boolean;
50 /** The highest nudge level delivered, or absorbed after an install, since the last install. */
51 nudgeLevel: number;
52 /** True from an install until the party's next reading, whose nudge level is then absorbed silently. */
53 absorbNext: boolean;
54 /** The nudge level from which the party's calls wait for its handoff (nudge.ts `gated`): GATE_STEPS past its first nudge since the last install; absent until that nudge. */
55 gateLevel?: number;
56 /** The party's completed requests: a gated call's request is told from a later one by it. */
57 requests: number;
58 /** The request whose calls the gate refused since the last install (`gateAnswer`). */
59 gateRequest?: number;
60 /** The gate let the party's calls run since the last install: it answered a refused request with no handoff. */
61 gateOpen?: boolean;
62 installs: number;
63 /** An install happened in the party's current turn: the turn-end door (P1) does not fire again. */
64 installedThisTurn: boolean;
65 /** The loaded, unpinned names the last stored handoff's drop_skills asked once: the next handoff naming one again drops it. */
66 dropAsked: string[];
67 /** The judgement last returned by `judged`. */
68 judged?: Judgement;
69};
70
71/** Per-party state, memory only, keyed "main" or "agent:{id}". A party is created on first touch. */
72export class Parties {
73 private readonly states = new Map<string, PartyState>();
74 private seq = 0;
75
76 /** The party's state, created with defaults when new. */
77 get(key: string): PartyState {
78 let state = this.states.get(key);
79 if (!state) {
80 state = { key, label: key, answeredSinceSkip: true, nudgeLevel: 0, absorbNext: false, requests: 0, installs: 0, installedThisTurn: false, dropAsked: [] };
81 this.states.set(key, state);
82 }
83 return state;
84 }
85
86 describe(key: string, patch: { label?: string; model?: string }): void {
87 Object.assign(this.get(key), patch);
88 }
89
90 /** The ignore-list entry the party's model matches: the plugin leaves it to Claude Code. Undefined while its model is not known. */
91 ignored(key: string, ignore: readonly string[]): string | undefined {
92 return ignoredBy(this.get(key).model, ignore);
93 }
94
95 /**
96 * Main's max context is not known: `FORWARD_COMPACTION_MAIN_MAX_CONTEXT` is unset and Claude Code has not reported its
97 * window yet. Main then gets no nudge and no ceiling. A sub-agent's is always known: `FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT`.
98 */
99 windowUnknown(key: string, mainKnown: boolean): boolean {
100 return key === 'main' && !mainKnown;
101 }
102
103 /** The party's judgement as known now: `ignored`, a sub-agent whose model no step has named, and `windowUnknown`, in one word. */
104 judgement(key: string, { mainKnown, ignore }: Judging): Judgement {
105 if (this.ignored(key, ignore) !== undefined) return 'ignored';
106 if (key !== 'main' && this.get(key).model === undefined) return 'unknown'; // no step has named its model: the list cannot judge it yet
107 return this.windowUnknown(key, mainKnown) ? 'unknown' : 'managed';
108 }
109
110 /** The ignore row when the judgement is new or has changed since the last row returned for the party; undefined while it holds. */
111 judged(key: string, judging: Judging): IgnoreRow | undefined {
112 const state = this.get(key);
113 const judged = this.judgement(key, judging);
114 if (state.judged === judged) return undefined;
115 state.judged = judged;
116 return { event: 'ignore', party: key, label: state.label, model: state.model ?? null, judged, matched: this.ignored(key, judging.ignore) ?? null };
117 }
118
119 /** Stores the party's handoff, the latest winning; returns the one it replaced. */
120 store(key: string, handoff: Omit<StoredHandoff, 'seq'>): StoredHandoff | undefined {
121 const state = this.get(key);
122 const replaced = state.stored;
123 state.stored = { ...handoff, seq: ++this.seq };
124 return replaced;
125 }
126
127 /** Removes the party's stored handoff and returns it; a party never seen gains no state. */
128 takeStored(key: string): StoredHandoff | undefined {
129 const state = this.states.get(key);
130 const stored = state?.stored;
131 if (state) state.stored = undefined;
132 return stored;
133 }
134
135 /**
136 * A completed request's reading and its nudge level; that request also answers a held ask. The first reading after
137 * an install is the party's floor plus what it kept: its level is absorbed, so the next nudge goes only past it.
138 * Returns the landing that reading measures, taken: its line is due now.
139 */
140 read(key: string, tokens: number, level = 0): Landing | undefined {
141 const state = this.get(key);
142 const landing = state.landing;
143 state.reading = tokens;
144 state.landing = undefined;
145 if (state.absorbNext) {
146 state.absorbNext = false;
147 state.nudgeLevel = Math.max(state.nudgeLevel, level);
148 }
149 this.answered(key);
150 return landing;
151 }
152
153 /** Takes the landing still waiting for a reading (the party ends), or undefined; a party never seen gains no state. */
154 takeLanding(key: string): Landing | undefined {
155 const state = this.states.get(key);
156 const landing = state?.landing;
157 if (state) state.landing = undefined;
158 return landing;
159 }
160
161 /** Every party's waiting landing, taken (the session ends). */
162 takeLandings(): [string, Landing][] {
163 return [...this.states.keys()].flatMap((key) => {
164 const landing = this.takeLanding(key);
165 return landing === undefined ? [] : [[key, landing] as [string, Landing]];
166 });
167 }
168
169 /** What the party's ask ended in. A skip makes the next ask reactive until a request completes; a compaction left to Claude Code (native, pass) ends the ratio base. */
170 asked(key: string, decision: AskDecision): void {
171 const state = this.get(key);
172 state.lastDecision = decision;
173 if (decision === 'skip') state.answeredSinceSkip = false;
174 if (decision === 'native' || decision === 'pass') state.base = undefined;
175 }
176
177 /** Sets the party's ratio base, or ends it (an unseen compaction): the next reading takes a new one. A party forgotten meanwhile gains no state. */
178 rebase(key: string, base: RatioBase | undefined): void {
179 const state = this.states.get(key);
180 if (state) state.base = base;
181 }
182
183 /** A new turn of the party: an ask held before it ended with the old turn, so the next ask is a fresh one, never a request too long. */
184 newTurn(key: string): void {
185 const state = this.states.get(key);
186 if (state) state.answeredSinceSkip = true;
187 }
188
189 /** A request completed (with or without a usage reading). */
190 answered(key: string): void {
191 const state = this.get(key);
192 state.answeredSinceSkip = true;
193 state.requests += 1;
194 }
195
196 /**
197 * What a gated call gets: refused while it belongs to the request that met the gate (its parallel calls too), let
198 * run from any later one, `opens` the first time. No API names a sub-agent's tools: one whose list lacks the handoff
199 * tool answers the refusal with another call, and would otherwise wait forever; its ceiling's mechanical wake stays.
200 */
201 gateAnswer(key: string): 'refuse' | 'opens' | 'open' {
202 const state = this.get(key);
203 state.gateRequest ??= state.requests;
204 if (state.gateRequest === state.requests) return 'refuse';
205 if (state.gateOpen) return 'open';
206 state.gateOpen = true;
207 return 'opens';
208 }
209
210 /** The ask follows a skip with no completed request between: Claude Code reports the request too long, not a fresh ask (D8). */
211 reactive(key: string): boolean {
212 const state = this.states.get(key);
213 return state?.lastDecision === 'skip' && !state.answeredSinceSkip;
214 }
215
216 /**
217 * Whether a nudge goes out now, recorded as sent: once per level while nothing is stored. None goes between an
218 * install and the party's next reading: until then no size is measured.
219 */
220 nudgeDue(key: string, level: number): boolean {
221 const state = this.get(key);
222 if (state.stored || state.absorbNext || level <= state.nudgeLevel) return false;
223 state.nudgeLevel = level;
224 state.gateLevel ??= level + GATE_STEPS;
225 return true;
226 }
227
228 /**
229 * A compaction installed: the handoff goes, the nudges start over from the next reading's level, and no reading
230 * stands until the next request measures one, whose landing line then goes out. Returns a landing still waiting from an earlier install.
231 */
232 installed(key: string, landing?: Landing): Landing | undefined {
233 const earlier = this.get(key).landing;
234 Object.assign(this.get(key), {
235 stored: undefined,
236 lastDecision: 'install',
237 answeredSinceSkip: true,
238 nudgeLevel: 0,
239 absorbNext: true,
240 gateLevel: undefined,
241 gateRequest: undefined,
242 gateOpen: undefined,
243 reading: undefined,
244 base: undefined,
245 landing,
246 installedThisTurn: true,
247 });
248 this.get(key).installs += 1;
249 return earlier;
250 }
251
252 /** The party's turn ended. */
253 turnEnded(key: string): void {
254 this.get(key).installedThisTurn = false;
255 }
256
257 /** The party starts over as if never seen: its agent finished, or the conversation was cleared. */
258 forget(key: string): void {
259 this.states.delete(key);
260 }
261}
262src/window.ts 131 lines1import { k } from './nudge.ts';
2import { sizeTokens, type PartySizes, type Size } from './options.ts';
3
4/** `$.session.usage({ breakdown: 'summary' })`'s breakdown, the fields the ask point is read from. */
5export type UsageBreakdown = { autoCompactThreshold?: number; rawMaxTokens?: number; autocompactSource?: string };
6
7/** Where the ask point came from, and what Claude Code left out when it is not its auto-compact threshold. */
8export type AskPoint = { tokens: number; source: 'autoCompactThreshold' | 'rawMaxTokens' | 'window'; missing?: 'breakdown' | 'autoCompactThreshold' };
9
10const positive = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0;
11
12/**
13 * The context at which Claude Code asks to compact a loop: the breakdown's `autoCompactThreshold`. Absent (auto-compaction
14 * off) or with no breakdown at all, the compaction window `rawMaxTokens`, else main's `context.window`; `missing` names what was absent.
15 * Undefined while not even a window was reported.
16 */
17export function askPointOf(breakdown: UsageBreakdown | undefined, window: number | undefined): AskPoint | undefined {
18 if (breakdown !== undefined && positive(breakdown.autoCompactThreshold)) return { tokens: breakdown.autoCompactThreshold, source: 'autoCompactThreshold' };
19 const missing = breakdown === undefined ? 'breakdown' : 'autoCompactThreshold';
20 if (breakdown !== undefined && positive(breakdown.rawMaxTokens)) return { tokens: breakdown.rawMaxTokens, source: 'rawMaxTokens', missing };
21 return positive(window) ? { tokens: window, source: 'window', missing } : undefined;
22}
23
24/** The ignore-list entry a model id matches, case-insensitive substring; undefined when none does or the model is not known. */
25export function ignoredBy(model: string | undefined, list: readonly string[]): string | undefined {
26 if (model === undefined) return undefined;
27 const id = model.toLowerCase();
28 return list.find((entry) => entry !== '' && id.includes(entry.toLowerCase()));
29}
30
31/**
32 * The context at which a party with no handoff stored is compacted mechanically: the size against the party's max context.
33 * A party past it is still asked to compact before every request, so the ceiling is where the plugin gives up waiting for a handoff.
34 */
35export function ceilingTokens(size: Size, maxContext: number): number {
36 return sizeTokens(size, maxContext);
37}
38
39/** A party's token marks: its max context, Claude Code's ask point (held at or below the max context), the ceiling, the nudge start and step. */
40export type Marks = { maxContext: number; askPoint: number; ceiling: number; start: number; every: number };
41
42/** The marks of a party with these sizes against its max context; undefined while that is not known (main's, before Claude Code reports its window). */
43export function partyMarks(sizes: PartySizes, maxContext: number | undefined, apiAsk: number | undefined): Marks | undefined {
44 if (maxContext === undefined) return undefined;
45 return { maxContext, askPoint: Math.min(apiAsk ?? maxContext, maxContext), ceiling: ceilingTokens(sizes.ceiling, maxContext), start: sizeTokens(sizes.nudgeStart, maxContext), every: sizeTokens(sizes.nudgeEvery, maxContext) };
46}
47
48/** A live window as `$.session.messages({ as: 'api' })` answers it: each message's content blocks. */
49export type ApiWindow = readonly { role?: string; content: readonly { type: string; [field: string]: unknown }[] }[];
50
51/** One block's characters: its text and thinking, a tool_use's input as JSON, a tool_result's content text; media counts none (its tokens are no characters). */
52function blockChars(block: { type: string; [field: string]: unknown }): number {
53 const str = (value: unknown): number => (typeof value === 'string' ? value.length : 0);
54 if (block.type === 'tool_use') return JSON.stringify(block.input ?? {}).length;
55 if (block.type === 'tool_result') return typeof block.content === 'string' ? block.content.length : Array.isArray(block.content) ? block.content.reduce((n: number, inner) => n + (inner !== null && typeof inner === 'object' ? blockChars(inner) : 0), 0) : 0;
56 return str(block.text) + str(block.thinking);
57}
58
59/** A live window's characters: every block's text, thinking, tool inputs and tool results, the text whose tokens the reading counts. */
60export function apiChars(window: ApiWindow): number {
61 return window.reduce((n, message) => n + message.content.reduce((m, block) => m + blockChars(block), 0), 0);
62}
63
64/** A party's ratio base: its live window's characters (`apiChars`) and its reading at its first reading in its live window (first ever, or first after a compaction). */
65export type RatioBase = { chars: number; tokens: number };
66
67/** The token growth past the base a ratio is measured over, a design budget: below it one tool result's tokenization dominates the ratio. */
68export const RATIO_MIN_GROWTH = 5_000;
69/** The characters per token a measured ratio must fall in, a design budget: text tokenizes inside it, so a ratio outside is a stale base or a cut transcript, never text. */
70export const RATIO_RANGE = { min: 1, max: 8 } as const;
71
72/** Why a party's ratio is unmeasured; `delta` is the growth ratio that fell outside RATIO_RANGE. */
73export type Unmeasured = { ratio?: undefined; why: 'no-base' | 'no-reading' | 'no-chars' | 'below-min-growth' | 'out-of-range'; delta?: number };
74
75/**
76 * A party's measured characters per token, on its growth since its base: `(chars - base.chars) / (reading - base.tokens)`,
77 * so the system prompt, tools and skills in every reading, never in the transcript, cancel. Unmeasured with no base, no
78 * reading, no characters, growth under RATIO_MIN_GROWTH or a ratio outside RATIO_RANGE: then nothing is converted.
79 */
80export function charsPerToken(base: RatioBase | undefined, chars: number | undefined, reading: number | undefined): { ratio: number } | Unmeasured {
81 if (base === undefined) return { why: 'no-base' };
82 if (reading === undefined) return { why: 'no-reading' };
83 if (chars === undefined) return { why: 'no-chars' };
84 if (reading - base.tokens < RATIO_MIN_GROWTH) return { why: 'below-min-growth' };
85 const delta = (chars - base.chars) / (reading - base.tokens);
86 return delta >= RATIO_RANGE.min && delta <= RATIO_RANGE.max ? { ratio: delta } : { why: 'out-of-range', delta };
87}
88
89/** The share of a party's max context its carried load files may take, a design budget the owner reads, never a measured size: a twentieth leaves the wake budget to the facts and the kept messages. */
90export const LOAD_FILES_SHARE = 0.05;
91
92/** The characters of load-file content a party's wake carries, at its measured ratio; unbounded with no ratio. */
93export function loadFilesRoom(maxContext: number, ratio: number | undefined): number {
94 return ratio === undefined ? Number.POSITIVE_INFINITY : Math.floor(maxContext * LOAD_FILES_SHARE * ratio);
95}
96
97/**
98 * The `CLAUDE_CODE_AUTO_COMPACT_WINDOW` the README asks for: Claude Code then asks about every request from about 63k, early
99 * enough for a stored handoff to install (CLAUDE.md, Spine). A recommendation named to the owner, never a measured size.
100 */
101export const RECOMMENDED_COMPACT_WINDOW = 100_000;
102
103export type StartWarning = { key: string; line: string; row: Record<string, unknown> };
104
105/**
106 * What session.start names once, from Claude Code's own numbers: a compaction window (`rawMaxTokens`) past
107 * RECOMMENDED_COMPACT_WINDOW, which holds every handoff back to Claude Code's late ask, and a party whose ceiling is below
108 * its ask point. Nothing it cannot read is named.
109 */
110export function startWarnings(breakdown: UsageBreakdown | undefined, apiAsk: AskPoint | undefined, main: Marks | undefined, subagent: Marks): StartWarning[] {
111 const out: StartWarning[] = [];
112 const raw = breakdown?.rawMaxTokens;
113 if (raw !== undefined && raw > RECOMMENDED_COMPACT_WINDOW && apiAsk !== undefined) {
114 const source = breakdown?.autocompactSource ?? 'unknown';
115 out.push({
116 key: 'compact-window',
117 line: `forward-compaction: Claude Code compacts against a ${k(raw)} window (${source}) and asks to compact from about ${k(apiAsk.tokens)}, so a handoff installs no earlier. Set CLAUDE_CODE_AUTO_COMPACT_WINDOW to ${RECOMMENDED_COMPACT_WINDOW} in your settings env.`,
118 row: { event: 'warning', key: 'compact-window', rawMaxTokens: raw, autocompactSource: source, askPoint: apiAsk.tokens },
119 });
120 }
121 for (const [kind, key, m] of [['main', 'ceiling-below-ask', main], ['sub-agent', 'ceiling-below-ask:subagent', subagent]] as const) {
122 if (m === undefined || m.ceiling >= m.askPoint) continue;
123 out.push({
124 key,
125 line: `forward-compaction: the ${kind} ceiling ${k(m.ceiling)} is below Claude Code's ask point ${k(m.askPoint)}; a ${kind} with no handoff is compacted mechanically at its first ask.`,
126 row: { event: 'warning', key, ceiling: m.ceiling, askPoint: m.askPoint },
127 });
128 }
129 return out;
130}
131src/unseen.ts 13 lines1// A compaction the plugin never heard of: Claude Code can compact the main chat without
2// dispatching session.compact to user plugins (2.1.299 passes it over on a machine with managed
3// settings). The next token reading is then the only trace, so a sharp fall is logged.
4
5/** The share of the previous reading a reading falls to, or below, for the fall to count as a compaction. */
6export const UNSEEN_DROP = 0.5;
7
8/** True when main's reading fell to half the one before or less, and no session.compact was handled in between. */
9export function unseenCompaction(before: number | undefined, after: number | undefined, seen: boolean): boolean {
10 if (seen || before === undefined || after === undefined) return false;
11 return after <= before * UNSEEN_DROP;
12}
13src/look-back.ts 329 lines1// Recovery is bounded to one party's pre-compaction prefix. Only the adapter does I/O.
2export const LOOK_BACK_TOOL = 'look_back';
3export const LOOK_BACK_DESCRIPTION = 'Recover an exact detail — when you need an error text, code snippet, value, or what the user said from before your latest compaction. Searches only your own transcript’s part you no longer hold. Each call tells the person what you looked up. Returns numbered snippets or a whole entry; failures say why, no matches suggests other words. Example: {query:"connection refused"}.';
4export const LOOK_BACK_HINT = 'Need an exact detail from before this handoff? look_back finds it.';
5export const LOOK_BACK_INPUT_SCHEMA = {
6 type: 'object',
7 properties: {
8 query: { type: 'string', description: 'Case-insensitive words; entries holding the most of them rank first, then newest. Double quotes keep a phrase whole; \\" is a literal quote.' },
9 entry: { type: 'integer', minimum: 1, description: 'Line number from a look_back result to fetch whole, before the latest compaction.' },
10 },
11 // Query-or-entry is parseLookBack's check, not the schema's: a client sending `entry: null` beside a query is not refused.
12 additionalProperties: false,
13};
14export type LookBackArgs = { query?: string; entry?: number; terms: string[]; error?: string };
15/** `input`: the error is the caller's input (a bad argument, an entry it cannot fetch), not a plugin fault. */
16export type LookBackResult = { text: string; hits?: number; error?: string; input?: true };
17const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim();
18/**
19 * The search terms of a query's pieces: a quoted phrase as typed; a bare word without the punctuation prose wraps
20 * around it (`(KEY` → `KEY`, `review.md)` → `review.md`), its inner punctuation kept; each term once, case aside.
21 */
22function queryTerms(pieces: readonly string[]): string[] {
23 const out: string[] = [];
24 for (const piece of pieces) {
25 const term = piece.startsWith('"') ? piece.slice(1, -1).replace(/\\"/g, '"') : piece.replace(/\\"/g, '"').replace(/^[([{<'`,;]+/, '').replace(/[)\]}>'`,;:!?.]+$/, '');
26 if (term !== '' && !out.some((t) => t.toLowerCase() === term.toLowerCase())) out.push(term);
27 }
28 return out;
29}
30
31export function parseLookBack(raw: Record<string, unknown>): LookBackArgs {
32 // A null query or entry is absent: clients send unset optional fields as null.
33 const input = Object.fromEntries(Object.entries(raw).filter(([key, value]) => !(value === null && (key === 'query' || key === 'entry'))));
34 const args: LookBackArgs = { terms: [] };
35 if (typeof input.query === 'string') args.query = input.query.trim();
36 if (typeof input.entry === 'number') args.entry = input.entry;
37 const bad = Object.keys(input).find((key) => !['query', 'entry'].includes(key));
38 if (bad) args.error = `unknown argument ${bad}`;
39 else if (input.entry !== undefined && (!Number.isSafeInteger(input.entry) || Number(input.entry) < 1)) args.error = 'entry must be a positive integer';
40 else if (input.query !== undefined && typeof input.query !== 'string') args.error = 'query must be a string';
41 else if (args.entry === undefined) {
42 if (args.query === undefined) args.error = 'query or entry is required';
43 else {
44 // A backslash-escaped quote is a literal quote character, never a phrase delimiter; any other backslash is literal.
45 // A newline inside a phrase would turn grep -e into OR patterns; refuse it.
46 const pieces = args.query.match(/"(?:\\"|\\(?!")|[^"\\\r\n])*"|(?:\\"|\\(?!")|[^\s"\\])+/g) ?? [];
47 const delimiters = args.query.replace(/\\"/g, '');
48 if ((delimiters.match(/"/g)?.length ?? 0) % 2 !== 0) args.error = 'query has an unclosed quote';
49 else if (args.query.includes('\0') || /"[^"\n]*[\r\n]/.test(delimiters)) args.error = 'query terms must not contain NUL or newlines';
50 else args.terms = queryTerms(pieces);
51 if (!args.error && args.terms.length === 0) args.error = 'query must contain at least one word or quoted phrase';
52 }
53 }
54 return args;
55}
56
57const COMPLETE = '\n[look-back-complete]\n';
58const finish = `; printf '\\n[look-back-complete]\\n'`;
59// POSIX pipelines lose upstream status. Report each failure even without stderr;
60// grep's rc=1 alone means no matches, not an error.
61const checked = `checked() { empty="$1"; shift; "$@"; rc=$?; if [ "$rc" -ne 0 ] && ! { [ "$empty" = 1 ] && [ "$rc" -eq 1 ]; }; then printf 'look-back subprocess failed (exit %s): %s\\n' "$rc" "$*" >&2; return "$rc"; fi; }; `;
62const prefix = 'checked 0 sed -n "1,$(($2 - 1))p" "$1"';
63/**
64 * The script contains only fixed code and argument indexes; values stay in argv. grep reads the raw JSONL line, where a
65 * term sits as typed (a tool input's own JSON structure) or in the form JSON writes a string value (a quote as \", a
66 * backslash as \\): a term whose two forms differ goes to grep as both; searchResults matches the decoded text.
67 */
68export function searchArgv(file: string, boundary: number, terms: readonly string[]): string[] {
69 const forms = terms.map((term) => [...new Set([term, JSON.stringify(term).slice(1, -1)])]);
70 const refs = forms.flat().map((_, i) => `"\${${i + 3}}"`);
71 // The term each form belongs to, as fixed numbers: awk counts a line's terms, not its forms.
72 const groups = forms.flatMap((f, i) => f.map(() => i + 1)).join(' ');
73 return ['sh', '-c', `${checked}${prefix} | ${STRIP_IMAGES} | checked 1 grep -a -n -i -F ${refs.map((r) => `-e ${r}`).join(' ')} | checked 0 awk -v groups='${groups}' '${RANK}' ${refs.join(' ')} | checked 0 env LC_ALL=C sort -t: -k1,1nr -k2,2nr | ${BUDGET} | checked 0 env LC_ALL=C sort -t: -k2,2n | checked 0 cut -d: -f2-${finish}`, 'sh', file, String(boundary), ...forms.flat()];
74}
75/**
76 * `$.process.run` keeps 4,194,304 characters of output (Claude Code 2.1.307) and cuts the rest, the completion marker with it.
77 * An image's base64 holds nothing to search: emptied, its entry still reads as an image block. The best 200 lines go on
78 * while they fit 3,000,000 bytes; every line is read, so no stage upstream is cut off mid-write.
79 */
80const STRIP_IMAGES = `checked 0 sed -E 's|"data":"[A-Za-z0-9+/=]{200,}"|"data":""|g'`;
81const BUDGET = `checked 0 env LC_ALL=C awk '{ n += length($0) + 1; if (NR > 200 || (NR > 1 && n > 3000000)) next; print }'`;
82/**
83 * Prefixes each `grep -n` line with how many terms it holds, case aside; the forms come as operands, taken in BEGIN and
84 * never read as files, so a term like `a=b` is never an assignment. The most-matching 200, newest first among equals, go on.
85 */
86const RANK = String.raw`BEGIN { n = split(groups, g, " "); for (i = 1; i <= n; i++) { f[i] = tolower(ARGV[i]); delete ARGV[i] } }
87{ line = tolower($0); c = 0; split("", seen); for (i = 1; i <= n; i++) if (!(g[i] in seen) && index(line, f[i]) > 0) { seen[g[i]] = 1; c++ } print c ":" $0 }`;
88// Filter candidate lines while streaming: nested tool input is not a boundary.
89// Track JSON depth outside strings; escaped quotes/braces must not change it.
90const topLevelBoundary = String.raw`checked 0 awk '
91 {
92 depth = 0; quoted = 0; escaped = 0
93 for (i = 1; i <= length($0); i++) {
94 c = substr($0, i, 1)
95 if (quoted) {
96 if (escaped) escaped = 0
97 else if (c == "\\") escaped = 1
98 else if (c == "\"") quoted = 0
99 } else {
100 if (depth == 1 && substr($0, i) ~ /^"subtype":"compact_boundary"/) { print; break }
101 if (c == "\"") quoted = 1
102 else if (c == "{" || c == "[") depth++
103 else if (c == "}" || c == "]") depth--
104 }
105 }
106 }'`;
107export function boundaryArgv(file: string): string[] {
108 return ['sh', '-c', `${checked}checked 1 grep -a -n '"subtype":"compact_boundary"' "$1" | ${topLevelBoundary} | checked 0 tail -n 1${finish}`, 'sh', file];
109}
110export function entryArgv(file: string, entry: number): string[] {
111 return ['sh', '-c', `${checked}checked 0 sed -n "\${2}{s/^/\${2}:/;p;q;}" "$1"${finish}`, 'sh', file, String(entry)];
112}
113export function namesArgv(file: string, boundary: number, ids: readonly string[]): string[] {
114 if (ids.some((id) => /[\r\n\0]/.test(id))) throw new Error('tool result id contains NUL or newline');
115 const filters = ids.map((_, i) => `-e "\${${i + 3}}"`).join(' ');
116 return ['sh', '-c', `${checked}${prefix} | checked 1 grep -a -n '"tool_use"' | checked 1 grep -a -F ${filters} | checked 0 tail -n 200${finish}`, 'sh', file, String(boundary), ...ids];
117}
118export function processOutput(out: { exitCode: number; stdout: string; stderr: string }, stage: string, framed = false): string {
119 // POSIX sh reports tail's status, not upstream grep/sed: stderr is also a failure.
120 if (out.exitCode !== 0 || out.stderr.trim()) throw new Error(`${stage}: ${out.stderr.trim() || `exit ${out.exitCode}`}`);
121 if (!framed) return out.stdout;
122 if (!out.stdout.endsWith(COMPLETE)) throw new Error(`${stage}: missing completion marker; output may be truncated`);
123 return out.stdout.slice(0, -COMPLETE.length);
124}
125export function boundaryLine(stdout: string): number | undefined {
126 for (const line of stdout.trim().split('\n').reverse()) {
127 if (!line) continue;
128 const match = line.match(/^(\d+):([\s\S]*)$/);
129 const n = Number(match?.[1]);
130 if (!Number.isSafeInteger(n) || n < 1) throw new Error('invalid compaction boundary output');
131 let data: unknown;
132 try { data = JSON.parse(match![2]!); }
133 catch { throw new Error('invalid compaction boundary JSON'); }
134 if (object(data)?.subtype === 'compact_boundary') return n;
135 }
136 return undefined;
137}
138
139type ToolUse = { id: string; name: string };
140export type LookBackEntry = { line: number; time: string; kind: string; text: string; uses: ToolUse[]; results: string[]; warnings: string[] };
141const object = (v: unknown): Record<string, unknown> | undefined => v !== null && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : undefined;
142function readable(content: unknown, uses: ToolUse[], results: string[], warnings: string[]): string {
143 if (typeof content === 'string') return content;
144 if (!Array.isArray(content)) { warnings.push('unsupported content shape'); return '[unsupported content shape]'; }
145 return content.flatMap((value) => {
146 const b = object(value);
147 if (!b) { warnings.push('unsupported content block'); return ['[unsupported content block]']; }
148 if (b.type === 'thinking' || b.type === 'redacted_thinking') return [];
149 if (b.type === 'text' && typeof b.text === 'string') return [b.text];
150 if (b.type === 'tool_use') {
151 const name = typeof b.name === 'string' ? b.name : '[unknown tool]';
152 if (typeof b.id === 'string') uses.push({ id: b.id, name });
153 return [`${name} ${JSON.stringify(b.input ?? {})}`];
154 }
155 if (b.type === 'tool_result') {
156 if (typeof b.tool_use_id === 'string') results.push(b.tool_use_id);
157 else { results.push('[missing id]'); warnings.push('tool result has no tool_use_id'); }
158 return [readable(b.content, uses, [], warnings)];
159 }
160 // A tool search's result names the tools it loaded.
161 if (b.type === 'tool_reference') {
162 const name = typeof b.tool_name === 'string' ? b.tool_name : typeof b.name === 'string' ? b.name : undefined;
163 if (name !== undefined) return [`[tool ${name}]`];
164 }
165 const kind = typeof b.type === 'string' ? b.type : 'unknown';
166 // An image or a document is known and holds no text to search: a note, not a failure.
167 const note = kind === 'image' || kind === 'document' ? `${kind} block` : `unsupported ${kind} block`;
168 warnings.push(note); return [`[${note}]`];
169 }).join('\n');
170}
171export function parseEntries(stdout: string): LookBackEntry[] {
172 return stdout.split('\n').filter(Boolean).map((line) => {
173 const m = line.match(/^(\d+):([\s\S]*)$/);
174 if (!m || !Number.isSafeInteger(Number(m[1])) || Number(m[1]) < 1) throw new Error('invalid numbered transcript output');
175 const e: LookBackEntry = { line: Number(m[1]), time: 'unknown time', kind: 'raw', text: m[2]!, uses: [], results: [], warnings: [] };
176 let data: unknown;
177 try { data = JSON.parse(m[2]!); }
178 catch { return e; } // Malformed JSON is deliberately readable raw text (R4).
179 const r = object(data);
180 const msg = object(r?.message);
181 const role = r?.type ?? msg?.role;
182 if (typeof r?.timestamp === 'string' && Number.isFinite(Date.parse(r.timestamp))) e.time = new Date(r.timestamp).toISOString().slice(0, 16).replace('T', ' ');
183 if (r?.type === 'attachment') return attachmentEntry(e, object(r.attachment));
184 if (r?.type === 'queue-operation' && r.operation === 'enqueue' && typeof r.content === 'string') { e.kind = 'queued prompt'; e.text = r.content; return e; }
185 if (typeof r?.type === 'string' && (BOOKKEEPING.has(r.type) || (r.type === 'system' && typeof r.content !== 'string'))) {
186 e.kind = 'bookkeeping'; e.text = r.type; return e;
187 }
188 if (r?.type === 'system' && typeof r.content === 'string') { e.kind = 'system'; e.text = r.content; return e; }
189 if (r?.type === 'summary' && typeof r.summary === 'string') { e.kind = 'summary'; e.text = r.summary; return e; }
190 if (!msg || !['user', 'assistant'].includes(String(role))) {
191 e.kind = 'unknown'; e.text = `[unsupported entry type ${String(role)}]`; e.warnings.push(e.text.slice(1, -1)); return e;
192 }
193 e.kind = String(role);
194 e.text = readable(msg.content, e.uses, e.results, e.warnings);
195 if (e.uses.length) e.kind = `tool ${e.uses.map((u) => u.name).join(', ')}`;
196 return e;
197 });
198}
199/**
200 * Entry types Claude Code writes for its own bookkeeping, never part of the conversation (read off 2.1.300 transcripts):
201 * a queue operation other than an enqueued prompt (a dequeue, removal or pop repeats or empties it), a copy of a request
202 * sent, titles, modes, links, file history and cost. Skipped without a note; a `system` entry without text is too.
203 */
204const BOOKKEEPING = new Set([
205 'queue-operation', 'api-request', 'api-request-blob', 'api-request-shape', 'last-prompt', 'mode', 'permission-mode',
206 'atis-latch', 'ai-title', 'custom-title', 'agent-name', 'bridge-session', 'history-suppression', 'frame-link', 'pr-link',
207 'file-history-snapshot', 'file-history-delta', 'cost-state', 'artifact-comment-monitor', 'artifact-autoreact-ledger',
208]);
209/**
210 * An `attachment` entry: what Claude Code put into the conversation beside the messages. A task notification (a child's
211 * result) is a `queued_command` whose `commandMode` is `task-notification`; its text is `prompt`. Others carry `content`
212 * (a string or a list of strings) or `text`. One with none of these (engine records such as a prompt snapshot, or a
213 * file whose text is nested) is shown raw, its JSON verbatim, so nothing it holds is hidden.
214 */
215function attachmentEntry(e: LookBackEntry, a: Record<string, unknown> | undefined): LookBackEntry {
216 const type = typeof a?.type === 'string' ? a.type : 'unknown';
217 const content = a?.content;
218 const text = typeof a?.prompt === 'string' ? a.prompt
219 : typeof content === 'string' ? content
220 : Array.isArray(content) && content.every((item) => typeof item === 'string') ? content.join('\n')
221 : typeof a?.text === 'string' ? a.text
222 : undefined;
223 if (text === undefined) {
224 e.kind = `attachment ${type} raw`; e.text = JSON.stringify(a ?? null); return e;
225 }
226 e.kind = a?.commandMode === 'task-notification' ? 'task-notification' : `attachment ${type}`;
227 e.text = text;
228 return e;
229}
230/** A warning that leaves an entry, or part of one, unread: a failure the caller sees, not a routine note. */
231const unread = (warning: string): boolean => warning.startsWith('unsupported');
232export function toolNames(entries: readonly LookBackEntry[]): Record<string, string> {
233 return Object.fromEntries(entries.flatMap((e) => e.uses.map((u) => [u.id, u.name])));
234}
235export function resultIds(entries: readonly LookBackEntry[]): string[] {
236 return [...new Set(entries.flatMap((e) => e.results))];
237}
238function header(e: LookBackEntry, names: Record<string, string>): string {
239 const kind = e.results.length ? `result of ${e.results.map((id) => Object.hasOwn(names, id) ? names[id] : `unknown tool ${id}`).join(', ')}` : e.kind;
240 return `#${e.line} ${e.time} ${oneLine(kind)}`;
241}
242export function snippet(text: string, terms: readonly string[]): string {
243 const lower = text.toLowerCase();
244 const first = terms.map((t) => lower.indexOf(t.toLowerCase())).find((at) => at >= 0) ?? -1;
245 const start = Math.max(0, Math.min(Math.max(0, text.length - 400), first - 200));
246 const end = start + 400;
247 return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`;
248}
249// A tool call's text shows its input as JSON, a quote in a value as \": a term holding the quote itself matches it there too.
250const unescaped = (text: string): string => text.replace(/\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})/g, (m) => JSON.parse(`"${m}"`) as string);
251/**
252 * The matches, those holding the most terms first, then newest; the party's own calls of `demote` (its forward_compaction call, whose fields restate what it
253 * searches for) and their results rank below every other hit. An entry left unread is the result's error, hits still shown.
254 */
255export function searchResults(entries: readonly LookBackEntry[], terms: readonly string[], boundary: number, names: Record<string, string> = {}, demote?: string): LookBackResult {
256 const candidates = entries.filter((e) => e.line < boundary && e.kind !== 'bookkeeping');
257 const own = (e: LookBackEntry): boolean => demote !== undefined && (e.uses.some((u) => u.name === demote) || e.results.some((id) => names[id] === demote));
258 // Ranked by how many terms an entry holds, so a query of many words finds the entry that holds most of them.
259 const held = new Map(candidates.map((e) => { const text = e.text.toLowerCase(), plain = unescaped(text); return [e, terms.filter((term) => text.includes(term.toLowerCase()) || plain.includes(term.toLowerCase())).length] as const; }));
260 const count = (e: LookBackEntry): number => held.get(e) ?? 0;
261 const matches = candidates
262 .filter((e) => count(e) > 0)
263 .sort((a, b) => Number(own(a)) - Number(own(b)) || count(b) - count(a) || b.line - a.line);
264 const notes = [...new Set(candidates.flatMap((e) => e.warnings.map((w) => `#${e.line}: ${w}`)))].join('\n');
265 const out: string[] = [];
266 for (const e of matches) {
267 if (out.length === 20) break;
268 const part = count(e) < terms.length ? ` (${count(e)} of ${terms.length} terms)` : '';
269 const hit = `${header(e, names)}${part}\n${snippet(e.text, terms.filter((t) => e.text.toLowerCase().includes(t.toLowerCase()) || unescaped(e.text.toLowerCase()).includes(t.toLowerCase())))}`;
270 // Reserve room for the footer and unsupported-shape report within the total cap.
271 if ([...out, hit].join('\n\n').length + Math.min(notes.length, 1000) + 120 > 12_000) break;
272 out.push(hit);
273 }
274 const more = matches.length - out.length;
275 let rendered = out.join('\n\n');
276 if (matches.length === 0) {
277 const query = terms.join(', ');
278 const echo = query.length > 10_000 ? query.slice(0, 10_000) + '[query cut]' : query;
279 rendered = `No entry before the compaction at line ${boundary} matches: ${echo}\nTry other words.`;
280 } else if (terms.length > 1 && matches.every((e) => count(e) < terms.length)) rendered = `No entry holds every term (${terms.join(', ')}); these hold the most:\n\n${rendered}`;
281 if (notes) rendered += `\n${notes.slice(0, 1000)}${notes.length > 1000 ? '\n[unsupported-shape report cut]' : ''}`;
282 if (more) rendered += `\n${more} more matches: quote a phrase or use rarer words to narrow it, or fetch one with entry`;
283 const failed = candidates.flatMap((e) => e.warnings.filter(unread).map((w) => `#${e.line}: ${w}`));
284 const unreadEntries = new Set(failed.map((f) => f.split(':')[0])).size;
285 const error = failed.length === 0 ? undefined : `${unreadEntries} ${unreadEntries === 1 ? 'entry' : 'entries'} could not be read: ${[...new Set(failed)].slice(0, 5).join('; ')}${failed.length > 5 ? '; …' : ''}`;
286 return { text: rendered, hits: out.length, ...(error ? { error } : {}) };
287}
288export function entryResult(entries: readonly LookBackEntry[], n: number, boundary: number, names: Record<string, string> = {}): LookBackResult {
289 const error = n >= boundary ? `entry ${n} is at or after the compaction at line ${boundary}, so already in context` : undefined;
290 if (error) return { text: error, error, input: true };
291 const e = entries.find((e) => e.line === n);
292 if (!e) { const error = `entry ${n} was not found in the transcript`; return { text: error, error, input: true }; }
293 if (e.kind === 'bookkeeping') {
294 const error = `entry ${n} is Claude Code bookkeeping (${e.text}), not part of the conversation`;
295 return { text: error, error, input: true };
296 }
297 if (e.kind === 'unknown') {
298 const error = `entry ${n} could not be read: ${e.warnings.join(', ')}`;
299 return { text: error, error };
300 }
301 const text = e.text.slice(0, 24_000) + (e.text.length > 24_000 ? `\n[cut at 24k of ${e.text.length} chars]` : '');
302 return { text: `${header(e, names)}\n${text}` };
303}
304export function manualTranscriptCall(tool: string, input: Record<string, unknown>, session: string, agentId?: string): boolean {
305 if (!['Read', 'Grep', 'Bash'].includes(tool)) return false;
306 const name = agentId === undefined ? `${session}.jsonl` : `agent-${agentId}.jsonl`;
307 const literal = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
308 return new RegExp(`(^|[^A-Za-z0-9_.-])${literal}($|[^A-Za-z0-9_.-])`).test(JSON.stringify(input));
309}
310export function lookBackNotice(label: string, args: Pick<LookBackArgs, 'query' | 'entry'>, result: { hits?: number; error?: string }): string {
311 const query = oneLine(args.query ?? '');
312 const asked = args.entry !== undefined ? `entry ${args.entry}` : args.query !== undefined ? `query ${query.length > 120 ? query.slice(0, 115) + '[cut]' : query}` : 'no query or entry';
313 const outcome = result.error ? `failed: ${oneLine(result.error)}` : args.entry !== undefined ? '' : `${result.hits ?? 0} hits`;
314 return `look back (${oneLine(label)}): ${asked}${outcome ? ` — ${outcome}` : ''}`;
315}
316
317/** The command that finds a transcript by its session id, the way Claude Code placed it: the project directory is whatever Claude Code named it. `-H`: a `projects` that is a link to the real directory is searched through. */
318export function findTranscriptArgv({ configDir, session, agentId }: { configDir: string; session: string; agentId?: string }): string[] {
319 const projects = `${configDir}/projects`;
320 return agentId === undefined
321 ? ['find', '-H', projects, '-maxdepth', '2', '-type', 'f', '-name', `${session}.jsonl`]
322 : ['find', '-H', projects, '-maxdepth', '4', '-type', 'f', '-path', `*/${session}/subagents/agent-${agentId}.jsonl`];
323}
324
325/** The path `find` printed first, or undefined when it printed none. */
326export function firstFound(stdout: string): string | undefined {
327 return stdout.split('\n').find((line) => line.startsWith('/'));
328}
329