context-doctor 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,6 +45,7 @@ Pick where you work. Each takes about a minute and is fully reversible.
45
45
  - `ci` and the GitHub Action: review memory-file growth like any other cost.
46
46
 
47
47
  **Remove waste automatically**
48
+ - A compaction hook (`PreCompact`) tells each Claude Code compaction what the session cannot lose (files changed, open tasks, your instructions, failing tests), so compacting saves tokens without losing the thread. `context-doctor compactions` measures, on your own history, whether sessions work worse after compacting.
48
49
  - An every-prompt hook (Claude Code, Codex, Gemini CLI; Cursor after each tool call) tells the model its real context size and the largest waste, and offers `/compact` when you return to a big session after the cache expired.
49
50
  - `compact-window`: sets Claude Code's own auto-compact window, measured on your history first. Works in the desktop app too.
50
51
  - Autopilot: a local proxy (or SDK wrapper) that clears stale tool output only when the prompt cache is cold, so a request never costs more.
@@ -179,6 +180,7 @@ MCP servers are part of the same overhead: each one's tool definitions ride on e
179
180
 
180
181
  ## What's new
181
182
 
183
+ - **0.31 Save tokens without losing quality**: a PreCompact hook makes every Claude Code compaction keep the session's changed files, open tasks, your instructions and still-failing tests; `context-doctor compactions` measures re-reads, tool errors and corrections before and after each compaction on your history; `compact-window` now charges the re-reads to every compaction it simulates.
182
184
  - **0.30 Where the money goes, and a self-healing service**: `context-doctor subagents` compares each subagent's bill with the same work done in the parent session; `cache-plan` places Anthropic `cache_control` breakpoints in an API request and prices them. Fixes from a local audit: the autopilot service now restarts itself onto a newly installed version, `doctor` no longer writes fake hook checks into the activity ledger, and `<synthetic>` placeholder messages no longer hide a session's model.
183
185
  - **0.29 Python, and where the work goes**: a dependency-free Python package (`python/`) with `pack_documents` for LangChain and LlamaIndex results, `pack_context`, `estimate_tokens` and `profile_messages`, held byte for byte to the TypeScript by parity tests. `context-doctor reads` shows the files read into session after session; `context-doctor tasks` shows cost per commit per project, from your own transcripts.
184
186
  - **0.28 Wider reach**: `/context-doctor:overhead` and `/context-doctor:pack` in the Claude Code plugin; `pack` reads a folder inside a git repository the way git does, so `.gitignore`'d build output and secrets stay out; `withContextDoctor` wraps Google's `@google/genai` client too; `overhead` measures the fixed start-of-session cost of Codex and Gemini CLI sessions as well as Claude Code's, priced with each provider's cache rules.
package/dist/cli.js CHANGED
@@ -49,6 +49,7 @@ import { renderReads, repeatedReads } from "./reads.js";
49
49
  import { costPerTask, renderTasks } from "./tasks.js";
50
50
  import { planCache, renderCachePlan } from "./cacheplan.js";
51
51
  import { renderPayback, subagentPayback } from "./subagentpay.js";
52
+ import { compactionQuality, renderCompactionQuality } from "./compactquality.js";
52
53
  const HELP = `context-doctor — profile and optimize LLM context windows
53
54
 
54
55
  Usage:
@@ -109,6 +110,8 @@ Usage:
109
110
  context-doctor cache-plan <request.json> [--calls n] [--out file]
110
111
  Where an Anthropic request should carry cache_control,
111
112
  what it saves over n calls; --out writes it applied
113
+ context-doctor compactions [--days n] Do sessions work worse right after compacting? Re-reads,
114
+ tool errors and your corrections, before vs after
112
115
  context-doctor subagents [--days n] Which subagents paid for themselves: their bill vs
113
116
  what carrying their reads would have cost the parent
114
117
  context-doctor tasks [--days n] Cost per commit, per project: session cost at list
@@ -607,7 +610,7 @@ async function main() {
607
610
  if (progress)
608
611
  process.stderr.write("\r\x1b[K");
609
612
  const setWindow = currentCompactWindow();
610
- const cwReport = estimateCompactWindows([setWindow ?? 400_000], args.days ?? 30);
613
+ const cwReport = estimateCompactWindows([setWindow ?? 400_000], args.days ?? 30, undefined, compactionQuality(60).extraRereadTokensPerCompaction);
611
614
  const est = cwReport.estimates[0];
612
615
  const cwLine = cwReport.sessions > 0 && est
613
616
  ? { window: est.window, savedUsd: est.savedUsd, compactionsPerWeek: (est.compactions / cwReport.days) * 7, nowPerWeek: (cwReport.actualCompactions / cwReport.days) * 7, isCurrent: setWindow !== undefined }
@@ -662,7 +665,8 @@ async function main() {
662
665
  const windows = [200_000, 300_000, 400_000, 600_000, 800_000];
663
666
  if (current && !windows.includes(current))
664
667
  windows.push(current);
665
- console.log(renderCompactWindows(estimateCompactWindows(windows.sort((a, b) => a - b), args.days ?? 30), current));
668
+ const reread = compactionQuality(60).extraRereadTokensPerCompaction;
669
+ console.log(renderCompactWindows(estimateCompactWindows(windows.sort((a, b) => a - b), args.days ?? 30, undefined, reread), current));
666
670
  return;
667
671
  }
668
672
  if (args.command === "autopilot") {
@@ -727,6 +731,12 @@ async function main() {
727
731
  });
728
732
  return; // server keeps the process alive
729
733
  }
734
+ if (args.command === "compactions") {
735
+ const days = args.days ?? 60;
736
+ const q = compactionQuality(days);
737
+ console.log(args.json ? JSON.stringify(q, null, 2) : renderCompactionQuality(q, days));
738
+ return;
739
+ }
730
740
  if (args.command === "subagents") {
731
741
  const report = subagentPayback(args.days ?? 30);
732
742
  console.log(args.json ? JSON.stringify(report, null, 2) : renderPayback(report, args.limit ?? 8));
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `context-doctor compactions`: does a session work worse right after it
3
+ * compacts? Measured on the user's own history, so the decision to compact
4
+ * earlier (compact-window) rests on evidence about quality, not only cost.
5
+ *
6
+ * For every compaction in recent Claude Code transcripts, the requests just
7
+ * before it are compared with the same number just after, on three signals a
8
+ * transcript records:
9
+ * - re-reads: Read of a file the session had already read before the
10
+ * compaction (the summary lost it, so the model goes back for it);
11
+ * - tool errors: tool results marked as errors, per tool call;
12
+ * - corrections: user messages that push back ("no", "wrong", "revert",
13
+ * "you forgot", "I said"), per user message.
14
+ * A compaction that costs quality shows up as all three rising after it.
15
+ * Compactions shaped by context-doctor's PreCompact hook are reported apart,
16
+ * so its effect can be seen as it accumulates.
17
+ */
18
+ interface Signals {
19
+ requests: number;
20
+ rereads: number;
21
+ /** Tokens of the re-reads: what forgetting a file costs to fetch again. */
22
+ rereadTokens: number;
23
+ reads: number;
24
+ calls: number;
25
+ errors: number;
26
+ userMsgs: number;
27
+ corrections: number;
28
+ }
29
+ export interface CompactionQuality {
30
+ compactions: number;
31
+ /** Tokens re-read after a compaction beyond the before-window rate, per compaction. */
32
+ extraRereadTokensPerCompaction: number;
33
+ sessions: number;
34
+ before: Signals;
35
+ after: Signals;
36
+ /** The same, for compactions context-doctor's PreCompact hook shaped. */
37
+ shaped?: {
38
+ compactions: number;
39
+ before: Signals;
40
+ after: Signals;
41
+ };
42
+ }
43
+ export declare function compactionQuality(days?: number, paths?: string[]): CompactionQuality;
44
+ export declare function renderCompactionQuality(q: CompactionQuality, days?: number): string;
45
+ export {};
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `context-doctor compactions`: does a session work worse right after it
3
+ * compacts? Measured on the user's own history, so the decision to compact
4
+ * earlier (compact-window) rests on evidence about quality, not only cost.
5
+ *
6
+ * For every compaction in recent Claude Code transcripts, the requests just
7
+ * before it are compared with the same number just after, on three signals a
8
+ * transcript records:
9
+ * - re-reads: Read of a file the session had already read before the
10
+ * compaction (the summary lost it, so the model goes back for it);
11
+ * - tool errors: tool results marked as errors, per tool call;
12
+ * - corrections: user messages that push back ("no", "wrong", "revert",
13
+ * "you forgot", "I said"), per user message.
14
+ * A compaction that costs quality shows up as all three rising after it.
15
+ * Compactions shaped by context-doctor's PreCompact hook are reported apart,
16
+ * so its effect can be seen as it accumulates.
17
+ */
18
+ import { forEachLine, listSessions } from "./session.js";
19
+ import { readLedger } from "./ledger.js";
20
+ import { estimateTokens, formatTokens } from "./tokens.js";
21
+ const WINDOW = 15;
22
+ const CORRECTION = /^(no\b|nope\b|wrong\b|that'?s (not|wrong)|not what i|you forgot|i (said|told you|asked)|revert\b|undo\b|stop\b|again\??$|why did you)/i;
23
+ const empty = () => ({ requests: 0, rereads: 0, rereadTokens: 0, reads: 0, calls: 0, errors: 0, userMsgs: 0, corrections: 0 });
24
+ function add(s, reqs, readBefore) {
25
+ for (const r of reqs) {
26
+ s.requests++;
27
+ s.calls += r.calls;
28
+ s.errors += r.errors;
29
+ s.userMsgs += r.userMsgs;
30
+ s.corrections += r.corrections;
31
+ r.reads.forEach((p, i) => {
32
+ s.reads++;
33
+ if (readBefore.has(p)) {
34
+ s.rereads++;
35
+ s.rereadTokens += r.readTokens[i] ?? 0;
36
+ }
37
+ });
38
+ }
39
+ }
40
+ export function compactionQuality(days = 60, paths) {
41
+ const since = Date.now() - days * 86_400_000;
42
+ const files = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
43
+ const shapedAt = readLedger().filter((e) => e.ev === "precompact").map((e) => e.ts);
44
+ const q = { compactions: 0, extraRereadTokensPerCompaction: 0, sessions: 0, before: empty(), after: empty() };
45
+ const shaped = { compactions: 0, before: empty(), after: empty() };
46
+ for (const path of files) {
47
+ const reqs = [];
48
+ const boundaries = [];
49
+ let cur;
50
+ // User messages between a compaction and the next request belong to the "after" side.
51
+ let pendingUser = 0, pendingCorrections = 0;
52
+ const readIds = new Map();
53
+ let lastId;
54
+ forEachLine(path, (line) => {
55
+ let e;
56
+ try {
57
+ e = JSON.parse(line);
58
+ }
59
+ catch {
60
+ return;
61
+ }
62
+ if (!e || e.isSidechain)
63
+ return;
64
+ const at = Date.parse(e.timestamp);
65
+ if (e.type === "system" && e.subtype === "compact_boundary") {
66
+ boundaries.push({ index: reqs.length, at: Number.isFinite(at) ? at : 0 });
67
+ cur = undefined;
68
+ return;
69
+ }
70
+ const c = e.message?.content;
71
+ if (e.type === "assistant") {
72
+ if (e.message?.id !== lastId && e.message?.usage) {
73
+ lastId = e.message.id;
74
+ cur = { at, reads: [], readTokens: [], calls: 0, errors: 0, userMsgs: pendingUser, corrections: pendingCorrections };
75
+ pendingUser = pendingCorrections = 0;
76
+ reqs.push(cur);
77
+ }
78
+ if (cur && Array.isArray(c)) {
79
+ for (const b of c) {
80
+ if (b?.type !== "tool_use")
81
+ continue;
82
+ cur.calls++;
83
+ if (b.name === "Read" && typeof b.input?.file_path === "string")
84
+ readIds.set(b.id, b.input.file_path);
85
+ }
86
+ }
87
+ }
88
+ else if (e.type === "user") {
89
+ if (typeof c === "string" && !e.isMeta && !e.isCompactSummary) {
90
+ const corr = CORRECTION.test(c.trim()) ? 1 : 0;
91
+ if (cur) {
92
+ cur.userMsgs++;
93
+ cur.corrections += corr;
94
+ }
95
+ else {
96
+ pendingUser++;
97
+ pendingCorrections += corr;
98
+ }
99
+ }
100
+ else if (cur && Array.isArray(c)) {
101
+ for (const b of c) {
102
+ if (b?.type !== "tool_result")
103
+ continue;
104
+ if (b.is_error)
105
+ cur.errors++;
106
+ const p = readIds.get(b.tool_use_id);
107
+ if (p && !b.is_error) {
108
+ cur.reads.push(p);
109
+ const text = typeof b.content === "string" ? b.content : JSON.stringify(b.content ?? "");
110
+ cur.readTokens.push(estimateTokens(text, "claude-opus-5"));
111
+ }
112
+ }
113
+ }
114
+ }
115
+ });
116
+ let counted = false;
117
+ for (const b of boundaries) {
118
+ const before = reqs.slice(Math.max(0, b.index - WINDOW), b.index);
119
+ const after = reqs.slice(b.index, b.index + WINDOW);
120
+ if (before.length < 5 || after.length < 5)
121
+ continue;
122
+ // Files read before the compaction: re-reading them afterwards means the summary lost them.
123
+ const readBefore = new Set(reqs.slice(0, b.index).flatMap((r) => r.reads));
124
+ // Before the compaction, a "re-read" is a read of a file already read earlier in that stretch.
125
+ const earlier = new Set(reqs.slice(0, Math.max(0, b.index - WINDOW)).flatMap((r) => r.reads));
126
+ add(q.before, before, earlier);
127
+ add(q.after, after, readBefore);
128
+ q.compactions++;
129
+ counted = true;
130
+ if (shapedAt.some((t) => Math.abs(t - b.at) < 10 * 60_000)) {
131
+ shaped.compactions++;
132
+ add(shaped.before, before, earlier);
133
+ add(shaped.after, after, readBefore);
134
+ }
135
+ }
136
+ if (counted)
137
+ q.sessions++;
138
+ }
139
+ if (shaped.compactions)
140
+ q.shaped = shaped;
141
+ // Only the rise counts against compacting: some re-reading happens anyway.
142
+ const baseline = q.before.reads ? q.before.rereadTokens / Math.max(1, q.before.rereads) * (q.before.rereads / q.before.reads) * q.after.reads : 0;
143
+ q.extraRereadTokensPerCompaction = q.compactions ? Math.max(0, q.after.rereadTokens - baseline) / q.compactions : 0;
144
+ return q;
145
+ }
146
+ function rates(s) {
147
+ return {
148
+ reread: s.reads ? s.rereads / s.reads : 0,
149
+ error: s.calls ? s.errors / s.calls : 0,
150
+ correction: s.userMsgs ? s.corrections / s.userMsgs : 0,
151
+ };
152
+ }
153
+ export function renderCompactionQuality(q, days = 60) {
154
+ const out = ["DO SESSIONS WORK WORSE AFTER COMPACTING?", "═".repeat(56)];
155
+ if (!q.compactions)
156
+ return [...out, `No compactions with enough work on both sides in the last ${days} days.`].join("\n");
157
+ const b = rates(q.before), a = rates(q.after);
158
+ const pct = (x) => `${(x * 100).toFixed(1)}%`;
159
+ const row = (label, x, y) => ` ${label.padEnd(36)} ${pct(x).padStart(7)} ${pct(y).padStart(7)} ${y - x > 0.0005 ? "+" : y - x < -0.0005 ? "−" : " "}${pct(Math.abs(y - x))}`;
160
+ const thin = q.before.reads + q.after.reads < 50;
161
+ out.push(`${q.compactions} compactions in ${q.sessions} sessions, last ${days} days; ${WINDOW} requests before vs after each.`, `Sample: ${q.before.reads} file reads before, ${q.after.reads} after; ${q.before.calls} and ${q.after.calls} tool calls; ${q.before.userMsgs} and ${q.after.userMsgs} of your messages.${thin ? " Few reads: the re-read figure is a weak signal." : ""}`, "", ` ${"".padEnd(36)} ${"before".padStart(7)} ${"after".padStart(7)} change`, row("files re-read (forgotten)", b.reread, a.reread), row("tool calls that errored", b.error, a.error), row("your messages correcting course", b.correction, a.correction));
162
+ if (q.shaped) {
163
+ const sb = rates(q.shaped.before), sa = rates(q.shaped.after);
164
+ out.push("", `Shaped by context-doctor's PreCompact hook (${q.shaped.compactions}): re-reads ${pct(sb.reread)} → ${pct(sa.reread)}, errors ${pct(sb.error)} → ${pct(sa.error)}, corrections ${pct(sb.correction)} → ${pct(sa.correction)}.`);
165
+ }
166
+ const forgets = a.reread - b.reread > 0.05;
167
+ const worse = a.error - b.error > 0.03 || a.correction - b.correction > 0.05;
168
+ out.push("");
169
+ if (forgets) {
170
+ out.push(`Compacting forgets files: each compaction is followed by ~${formatTokens(q.extraRereadTokensPerCompaction)} tokens of extra re-reads. compact-window charges that to every compaction it simulates.`);
171
+ }
172
+ out.push(worse
173
+ ? "Sessions also work worse after compacting (more tool errors or corrections): compact less often, and let the PreCompact hook tell each summary what to keep."
174
+ : "No sign of worse work after compacting: tool errors and corrections are no higher. The cost of compacting here is tokens, not quality.");
175
+ return out.join("\n");
176
+ }
@@ -34,8 +34,10 @@ export interface CompactWindowReport {
34
34
  /** Compactions that actually happened over the period (Claude Code's own, or /compact). */
35
35
  actualCompactions: number;
36
36
  estimates: WindowEstimate[];
37
+ /** Re-read tokens charged to each simulated compaction (0 when not measured). */
38
+ rereadTokens?: number;
37
39
  }
38
- export declare function estimateCompactWindows(windows: number[], days?: number, paths?: string[]): CompactWindowReport;
40
+ export declare function estimateCompactWindows(windows: number[], days?: number, paths?: string[], rereadTokens?: number): CompactWindowReport;
39
41
  export declare function settingsPath(home?: string): string;
40
42
  /** The autoCompactWindow Claude Code will use from user settings, if set. */
41
43
  export declare function currentCompactWindow(home?: string): number | undefined;
@@ -70,7 +70,12 @@ function readRequests(path) {
70
70
  * idle: 1.25x for everything); each simulated compaction also pays its own
71
71
  * request and summary.
72
72
  */
73
- function replay(reqs, window, since) {
73
+ /**
74
+ * `reread`: tokens a session fetches again after a compaction because the
75
+ * summary dropped them (measured by `context-doctor compactions`). Each
76
+ * simulated compaction writes them to the cache and carries them onward.
77
+ */
78
+ function replay(reqs, window, since, reread = 0) {
74
79
  let base = 0, sim = 0, extra = 0, actual = 0;
75
80
  let removed = 0, prevActual, prevSim, prevAt;
76
81
  for (const r of reqs) {
@@ -104,11 +109,11 @@ function replay(reqs, window, since) {
104
109
  let simPrev = prevSim;
105
110
  if (ctx >= TRIGGER_SHARE * window) {
106
111
  if (counted) {
107
- sim += 0.1 * ctx + OUTPUT_RATIO * SUMMARY_OUTPUT;
112
+ sim += 0.1 * ctx + OUTPUT_RATIO * SUMMARY_OUTPUT + 1.25 * reread;
108
113
  extra++;
109
114
  }
110
- removed = r.prompt - AFTER_COMPACT;
111
- ctx = AFTER_COMPACT;
115
+ removed = r.prompt - AFTER_COMPACT - reread;
116
+ ctx = AFTER_COMPACT + reread;
112
117
  simPrev = undefined; // everything after a compaction is new to the cache
113
118
  }
114
119
  if (counted)
@@ -119,7 +124,7 @@ function replay(reqs, window, since) {
119
124
  }
120
125
  return { base, sim, extra, actual };
121
126
  }
122
- export function estimateCompactWindows(windows, days = 30, paths) {
127
+ export function estimateCompactWindows(windows, days = 30, paths, rereadTokens = 0) {
123
128
  const since = Date.now() - days * 86_400_000;
124
129
  const targets = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
125
130
  const acc = windows.map((w) => ({ window: w, base: 0, sim: 0, extra: 0 }));
@@ -138,7 +143,7 @@ export function estimateCompactWindows(windows, days = 30, paths) {
138
143
  sessions++;
139
144
  actualCompactions += replay(parsed.reqs, Number.MAX_SAFE_INTEGER, since).actual;
140
145
  for (const a of acc) {
141
- const r = replay(parsed.reqs, a.window, since);
146
+ const r = replay(parsed.reqs, a.window, since, rereadTokens);
142
147
  a.base += (r.base * perM) / 1e6;
143
148
  a.sim += (r.sim * perM) / 1e6;
144
149
  a.extra += r.extra;
@@ -148,6 +153,7 @@ export function estimateCompactWindows(windows, days = 30, paths) {
148
153
  days,
149
154
  sessions,
150
155
  actualCompactions,
156
+ rereadTokens,
151
157
  estimates: acc.map((a) => ({
152
158
  window: a.window,
153
159
  baselineUsd: a.base,
@@ -215,6 +221,10 @@ export function renderCompactWindows(r, current) {
215
221
  }
216
222
  lines.push(` as now ${usd(r.estimates[0]?.baselineUsd ?? 0).padStart(10)} ${String(r.actualCompactions).padStart(4)} (${((r.actualCompactions / r.days) * 7).toFixed(1)}/week)`);
217
223
  lines.push("");
224
+ if (r.rereadTokens) {
225
+ lines.push(`Each simulated compaction also pays for ~${Math.round(r.rereadTokens / 100) / 10}k tokens of files read again afterwards`);
226
+ lines.push("(measured on your compactions: context-doctor compactions).");
227
+ }
218
228
  lines.push("Each compaction replaces the session's history with a summary, so a smaller");
219
229
  lines.push("window trades detail for cost. Set one with: context-doctor compact-window 400k");
220
230
  lines.push("Undo with: context-doctor compact-window off");
package/dist/doctor.js CHANGED
@@ -133,6 +133,11 @@ export async function runDoctor() {
133
133
  checks.push(missing
134
134
  ? { label: "Every-prompt hook", status: "fail", detail: `registered, but ${missing} no longer exists — re-run: context-doctor install` }
135
135
  : { label: "Every-prompt hook", status: "ok", detail: "registered in ~/.claude/settings.json; command resolves" });
136
+ const pre = settings.hooks?.PreCompact ?? [];
137
+ const preOurs = pre.some((e) => /context-doctor|cli\.js"?\s+hook/.test(e.hooks?.[0]?.command ?? ""));
138
+ checks.push(preOurs
139
+ ? { label: "Compaction hook", status: "ok", detail: "PreCompact: each compaction's summary keeps the session's files, open tasks, your instructions and failing commands" }
140
+ : { label: "Compaction hook", status: "warn", detail: "not registered: compactions use generic instructions; run context-doctor install" });
136
141
  }
137
142
  }
138
143
  catch (e) {
package/dist/hook.js CHANGED
@@ -24,6 +24,7 @@ import { ensureProxyUp } from "./autopilot.js";
24
24
  import { detectColdResume, renderColdResume, renderColdResumeNotice } from "./coldresume.js";
25
25
  import { formatUsd, pricingFor } from "./pricing.js";
26
26
  import { checkBudget, loadConfig } from "./config.js";
27
+ import { compactFacts, compactInstructions } from "./precompact.js";
27
28
  /** Default nudge threshold; a project budget or env var can lower/raise it. */
28
29
  const DEFAULT_WARN_TOKENS = 80_000;
29
30
  /**
@@ -146,6 +147,19 @@ export async function runHook() {
146
147
  }
147
148
  const input = JSON.parse(await readStdin());
148
149
  await heal;
150
+ // Before a compaction: tell the summary what this session cannot lose.
151
+ // Claude Code adds a PreCompact hook's plain-text output to the
152
+ // compaction's instructions (auto and manual alike).
153
+ if (input.hook_event_name === "PreCompact") {
154
+ if (input.transcript_path && existsSync(input.transcript_path)) {
155
+ const text = compactInstructions(compactFacts(input.transcript_path));
156
+ if (text) {
157
+ process.stdout.write(text + "\n");
158
+ recordLedger({ ev: "precompact", sid: (input.session_id ?? "").slice(0, 12), tok: text.length });
159
+ }
160
+ }
161
+ return;
162
+ }
149
163
  // Cursor runs this hook twice over: Claude Code's UserPromptSubmit as its
150
164
  // beforeSubmitPrompt (which cannot add context, so there is nothing to do),
151
165
  // and natively after each tool call, where `additional_context` reaches
package/dist/index.d.ts CHANGED
@@ -36,3 +36,5 @@ export * from "./reads.js";
36
36
  export * from "./tasks.js";
37
37
  export * from "./cacheplan.js";
38
38
  export * from "./subagentpay.js";
39
+ export * from "./compactquality.js";
40
+ export * from "./precompact.js";
package/dist/index.js CHANGED
@@ -31,3 +31,5 @@ export * from "./reads.js";
31
31
  export * from "./tasks.js";
32
32
  export * from "./cacheplan.js";
33
33
  export * from "./subagentpay.js";
34
+ export * from "./compactquality.js";
35
+ export * from "./precompact.js";
package/dist/install.js CHANGED
@@ -207,7 +207,12 @@ function isOurHookEntry(entry) {
207
207
  * Claude Code query gets a context-size check. Idempotent.
208
208
  */
209
209
  function installHook() {
210
- return installHookInto(join(homedir(), ".claude", "settings.json"), join(homedir(), ".claude"));
210
+ const settings = join(homedir(), ".claude", "settings.json");
211
+ const path = installHookInto(settings, join(homedir(), ".claude"));
212
+ // The same command also answers PreCompact: what each compaction's summary must keep.
213
+ if (path)
214
+ installHookInto(settings, join(homedir(), ".claude"), "PreCompact");
215
+ return path;
211
216
  }
212
217
  /**
213
218
  * Register the UserPromptSubmit hook in a hooks file. Claude Code's
@@ -247,6 +252,7 @@ function installHookInto(settingsPath, gateDir, event = "UserPromptSubmit") {
247
252
  }
248
253
  function uninstallHook() {
249
254
  uninstallHookFrom(join(homedir(), ".claude", "settings.json"), "Claude Code every-prompt hook");
255
+ uninstallHookFrom(join(homedir(), ".claude", "settings.json"), "Claude Code compaction hook", "PreCompact");
250
256
  uninstallHookFrom(join(homedir(), ".codex", "hooks.json"), "Codex every-prompt hook");
251
257
  uninstallHookFrom(geminiSettingsPath(), "Gemini CLI every-prompt hook", "BeforeAgent");
252
258
  uninstallCursorHook();
package/dist/ledger.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
  export interface LedgerEntry {
19
19
  ts: number;
20
- ev?: "check" | "optimize" | "proxy" | "rollup" | "cold_resume";
20
+ ev?: "check" | "optimize" | "proxy" | "rollup" | "cold_resume" | "precompact";
21
21
  sid?: string;
22
22
  tok?: number;
23
23
  warn?: boolean;
package/dist/mcp.js CHANGED
@@ -52,7 +52,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
52
52
  * recommended pattern.
53
53
  */
54
54
  function createServer({ fileAccess = true } = {}) {
55
- const server = new McpServer({ name: "context-doctor", version: "0.30.1" }, { instructions: SERVER_INSTRUCTIONS });
55
+ const server = new McpServer({ name: "context-doctor", version: "0.31.0" }, { instructions: SERVER_INSTRUCTIONS });
56
56
  server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~120 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
57
57
  conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
58
58
  sketch: z.object({
@@ -0,0 +1,23 @@
1
+ /**
2
+ * PreCompact: tell Claude Code's compaction what this session cannot lose.
3
+ *
4
+ * A compaction replaces the session's history with a summary written by the
5
+ * model, guided by generic instructions. Whatever a PreCompact hook prints is
6
+ * added to those instructions (Claude Code merges it as custom instructions,
7
+ * for automatic and manual compactions alike; checked in 2.1.295). So the
8
+ * summary can be told, from the transcript itself, which files were changed,
9
+ * what is still open, what the user insisted on, and which commands are still
10
+ * failing: the things whose loss makes a session re-read, re-ask and repeat
11
+ * mistakes after compacting. Facts only, short, and nothing invented: every
12
+ * line comes from the transcript.
13
+ */
14
+ export interface CompactFacts {
15
+ edited: string[];
16
+ todos: string[];
17
+ directives: string[];
18
+ failing: string[];
19
+ }
20
+ /** Facts from the session since the last compaction (the summary before it already carries the earlier ones). */
21
+ export declare function compactFacts(transcriptPath: string): CompactFacts;
22
+ /** The custom instructions a PreCompact hook prints; empty when the transcript holds nothing to pin. */
23
+ export declare function compactInstructions(f: CompactFacts): string;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * PreCompact: tell Claude Code's compaction what this session cannot lose.
3
+ *
4
+ * A compaction replaces the session's history with a summary written by the
5
+ * model, guided by generic instructions. Whatever a PreCompact hook prints is
6
+ * added to those instructions (Claude Code merges it as custom instructions,
7
+ * for automatic and manual compactions alike; checked in 2.1.295). So the
8
+ * summary can be told, from the transcript itself, which files were changed,
9
+ * what is still open, what the user insisted on, and which commands are still
10
+ * failing: the things whose loss makes a session re-read, re-ask and repeat
11
+ * mistakes after compacting. Facts only, short, and nothing invented: every
12
+ * line comes from the transcript.
13
+ */
14
+ import { forEachLine } from "./session.js";
15
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
16
+ /** A user line that sets a rule or corrects course. */
17
+ const DIRECTIVE = /\b(don'?t|do not|never|always|instead|make sure|must|should not|shouldn'?t|stop|wrong|not what|revert|undo|keep|prefer|use )\b/i;
18
+ const MAX_CHARS = 2400;
19
+ /** Build, test and lint runs: the commands whose failure is state worth keeping (not a grep that found nothing). */
20
+ const CHECK_COMMAND = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|build|lint|typecheck|check)\b|\b(pytest|tox|cargo\s+(test|build|check|clippy)|go\s+(test|build|vet)|make(\s+\w+)?|tsc|eslint|ruff|mypy|gradle\w*\s+\w+|mvn\s+\w+|swift\s+(build|test)|xcodebuild|dotnet\s+(build|test)|jest|vitest|phpunit|rspec)\b/;
21
+ /** What kind of check a command runs ("npm test", "pytest", "cargo build"): a later success of the same kind clears an earlier failure. */
22
+ function checkKind(cmd) {
23
+ return CHECK_COMMAND.exec(cmd)?.[0].replace(/\s+run\s+/, " ").replace(/\s+/g, " ");
24
+ }
25
+ function clip(s, n) {
26
+ const t = s.replace(/\s+/g, " ").trim();
27
+ return t.length > n ? t.slice(0, n - 1) + "…" : t;
28
+ }
29
+ /** Facts from the session since the last compaction (the summary before it already carries the earlier ones). */
30
+ export function compactFacts(transcriptPath) {
31
+ const edited = [];
32
+ let todos = [];
33
+ const directives = [];
34
+ const commands = new Map(); // tool_use id -> a build/test/lint command
35
+ const failing = new Map(); // kind -> "command → last error line", removed when that kind later succeeds
36
+ forEachLine(transcriptPath, (line) => {
37
+ let e;
38
+ try {
39
+ e = JSON.parse(line);
40
+ }
41
+ catch {
42
+ return;
43
+ }
44
+ if (!e || e.isSidechain)
45
+ return;
46
+ if (e.type === "system" && e.subtype === "compact_boundary") {
47
+ edited.length = 0;
48
+ todos = [];
49
+ directives.length = 0;
50
+ commands.clear();
51
+ failing.clear();
52
+ return;
53
+ }
54
+ const c = e.message?.content;
55
+ if (e.type === "assistant" && Array.isArray(c)) {
56
+ for (const b of c) {
57
+ if (b?.type !== "tool_use")
58
+ continue;
59
+ const path = b.input?.file_path ?? b.input?.notebook_path;
60
+ if (EDIT_TOOLS.has(b.name) && typeof path === "string") {
61
+ const i = edited.indexOf(path);
62
+ if (i >= 0)
63
+ edited.splice(i, 1);
64
+ edited.push(path);
65
+ }
66
+ else if (b.name === "TodoWrite" && Array.isArray(b.input?.todos)) {
67
+ todos = b.input.todos.filter((t) => t?.status !== "completed").map((t) => `${t.status === "in_progress" ? "[in progress] " : ""}${clip(String(t.content ?? ""), 140)}`);
68
+ }
69
+ else if (b.name === "Bash" && typeof b.input?.command === "string") {
70
+ const kind = checkKind(b.input.command);
71
+ if (kind)
72
+ commands.set(b.id, { cmd: clip(b.input.command, 120), kind });
73
+ }
74
+ }
75
+ }
76
+ else if (e.type === "user") {
77
+ if (typeof c === "string" && !e.isMeta && !e.isCompactSummary) {
78
+ const text = c.replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, " ").trim();
79
+ if (text && text.length < 2000 && DIRECTIVE.test(text))
80
+ directives.push(clip(text, 200));
81
+ }
82
+ else if (Array.isArray(c)) {
83
+ for (const b of c) {
84
+ if (b?.type !== "tool_result" || !commands.has(b.tool_use_id))
85
+ continue;
86
+ const { cmd, kind } = commands.get(b.tool_use_id);
87
+ if (b.is_error) {
88
+ const out = typeof b.content === "string" ? b.content : JSON.stringify(b.content ?? "");
89
+ failing.set(kind, `${cmd} → ${clip((out.split("\n").find((l) => /error|exception/i.test(l)) ?? out.split("\n").find((l) => /fail/i.test(l)) ?? out.split("\n")[0] ?? ""), 160)}`);
90
+ }
91
+ else {
92
+ failing.delete(kind);
93
+ }
94
+ }
95
+ }
96
+ }
97
+ });
98
+ return { edited: edited.slice(-25).reverse(), todos: todos.slice(0, 15), directives: directives.slice(-8), failing: [...failing.values()].slice(-5) };
99
+ }
100
+ /** The custom instructions a PreCompact hook prints; empty when the transcript holds nothing to pin. */
101
+ export function compactInstructions(f) {
102
+ const sections = [];
103
+ const list = (title, items) => items.length && sections.push(`${title}:\n${items.map((i) => `- ${i}`).join("\n")}`);
104
+ list("Files changed in this session (keep every path exactly)", f.edited);
105
+ list("Open tasks (keep them, with their status)", f.todos);
106
+ list("The user's own instructions and corrections (keep their substance, they still apply)", f.directives);
107
+ list("Commands still failing at this point (keep the command and the error)", f.failing);
108
+ if (!sections.length)
109
+ return "";
110
+ let out = [
111
+ "context-doctor, from this session's transcript: the summary must carry the following, so the work continues without re-reading files, re-asking the user, or repeating a failed approach.",
112
+ ...sections,
113
+ "Keep exact identifiers (paths, function names, commands, versions, IDs, URLs). Leave out raw tool output already acted on; one line on what it showed is enough.",
114
+ ].join("\n\n");
115
+ if (out.length > MAX_CHARS)
116
+ out = out.slice(0, MAX_CHARS - 1) + "…";
117
+ return out;
118
+ }
package/dist/proxy.d.ts CHANGED
@@ -48,7 +48,7 @@ export interface ProxyOptions extends OptimizeOptions {
48
48
  googleUpstream?: string;
49
49
  }
50
50
  /** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
51
- export declare const PROXY_VERSION = "0.30.1";
51
+ export declare const PROXY_VERSION = "0.31.0";
52
52
  /**
53
53
  * Remove a leading `/t/<token>` from a request path, or return undefined when
54
54
  * the prefix is absent or the token differs. The comparison is constant time
package/dist/proxy.js CHANGED
@@ -20,7 +20,7 @@ import { formatTokens, CHARS_PER_TOKEN, providerFor } from "./tokens.js";
20
20
  import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
21
21
  import { recordLedger } from "./ledger.js";
22
22
  /** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
23
- export const PROXY_VERSION = "0.30.1";
23
+ export const PROXY_VERSION = "0.31.0";
24
24
  /** Connection-level headers that must not be forwarded. */
25
25
  const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
26
26
  const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "Cut your Claude Code bill, measured on your own sessions: `npx context-doctor savings` shows what each lever saves, then the hook, autopilot and an earlier auto-compact window do it. Also Cursor, Codex, Claude Desktop and any Anthropic or OpenAI API app. MCP server and Claude Code plugin.",
5
5
  "keywords": [
6
6
  "agent",