context-doctor 0.30.0 → 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 +2 -0
- package/dist/cli.js +12 -2
- package/dist/compactquality.d.ts +45 -0
- package/dist/compactquality.js +176 -0
- package/dist/compactwindow.d.ts +3 -1
- package/dist/compactwindow.js +16 -6
- package/dist/desktopusage.d.ts +20 -0
- package/dist/desktopusage.js +35 -0
- package/dist/doctor.js +10 -0
- package/dist/hook.js +14 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/install.js +7 -1
- package/dist/ledger.d.ts +1 -1
- package/dist/mcp.js +1 -1
- package/dist/precompact.d.ts +23 -0
- package/dist/precompact.js +118 -0
- package/dist/proxy.d.ts +1 -1
- package/dist/proxy.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
+
}
|
package/dist/compactwindow.d.ts
CHANGED
|
@@ -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;
|
package/dist/compactwindow.js
CHANGED
|
@@ -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
|
-
|
|
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");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How Claude Desktop's chat actually uses the MCP server, from Desktop's own
|
|
3
|
+
* log. Desktop gives an MCP server no hook and no transcript, so its
|
|
4
|
+
* per-server log (mcp-server-context-doctor.log: initialize, tools/list,
|
|
5
|
+
* tools/call) is the only evidence that the tools are offered and called.
|
|
6
|
+
* Desktop does not log which pages are opened, so chat activity itself cannot
|
|
7
|
+
* be measured locally; Code-tab sessions can (Claude Code transcripts record
|
|
8
|
+
* the desktop app as their entrypoint), and those reach context-doctor
|
|
9
|
+
* through Claude Code's hook and MCP config, not through this server.
|
|
10
|
+
*/
|
|
11
|
+
export interface DesktopUsage {
|
|
12
|
+
launches: number;
|
|
13
|
+
toolLists: number;
|
|
14
|
+
toolCalls: number;
|
|
15
|
+
lastCall?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function desktopLogDir(home?: string): string;
|
|
18
|
+
export declare function desktopUsage(dir?: string): DesktopUsage | undefined;
|
|
19
|
+
/** One line for `doctor`: the numbers, and what to do with them. `codeTabSessions`: this week's Claude Code sessions run in Desktop's Code tab. */
|
|
20
|
+
export declare function describeDesktopUsage(u: DesktopUsage, codeTabSessions?: number): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How Claude Desktop's chat actually uses the MCP server, from Desktop's own
|
|
3
|
+
* log. Desktop gives an MCP server no hook and no transcript, so its
|
|
4
|
+
* per-server log (mcp-server-context-doctor.log: initialize, tools/list,
|
|
5
|
+
* tools/call) is the only evidence that the tools are offered and called.
|
|
6
|
+
* Desktop does not log which pages are opened, so chat activity itself cannot
|
|
7
|
+
* be measured locally; Code-tab sessions can (Claude Code transcripts record
|
|
8
|
+
* the desktop app as their entrypoint), and those reach context-doctor
|
|
9
|
+
* through Claude Code's hook and MCP config, not through this server.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { homedir, platform } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
export function desktopLogDir(home = homedir()) {
|
|
15
|
+
switch (platform()) {
|
|
16
|
+
case "darwin": return join(home, "Library", "Logs", "Claude");
|
|
17
|
+
case "win32": return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Claude", "logs");
|
|
18
|
+
default: return join(home, ".config", "Claude", "logs");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function desktopUsage(dir = desktopLogDir()) {
|
|
22
|
+
const server = join(dir, "mcp-server-context-doctor.log");
|
|
23
|
+
if (!existsSync(server))
|
|
24
|
+
return undefined;
|
|
25
|
+
const text = readFileSync(server, "utf8");
|
|
26
|
+
const count = (m) => text.split(`method="${m}"`).length - 1;
|
|
27
|
+
const calls = [...text.matchAll(/^(\d{4}-\d\d-\d\d)T[^\n]*method="tools\/call"/gm)];
|
|
28
|
+
return { launches: count("initialize"), toolLists: count("tools/list"), toolCalls: count("tools/call"), lastCall: calls.at(-1)?.[1] };
|
|
29
|
+
}
|
|
30
|
+
/** One line for `doctor`: the numbers, and what to do with them. `codeTabSessions`: this week's Claude Code sessions run in Desktop's Code tab. */
|
|
31
|
+
export function describeDesktopUsage(u, codeTabSessions = 0) {
|
|
32
|
+
const base = `tools offered in ${u.toolLists} of ${u.launches} launches, called ${u.toolCalls} time(s)${u.lastCall ? ` (last ${u.lastCall})` : ""}`;
|
|
33
|
+
const code = codeTabSessions > 0 ? `; your ${codeTabSessions} Code-tab sessions this week use the hook instead` : "";
|
|
34
|
+
return `${base}${code}. In a chat the model decides when to call them: the + menu's context_checkup prompt runs one on demand`;
|
|
35
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
import { ledgerPath } from "./ledger.js";
|
|
15
15
|
import { loadConfig } from "./config.js";
|
|
16
16
|
import { geminiCliPresent, geminiSettingsPath } from "./install.js";
|
|
17
|
+
import { describeDesktopUsage, desktopUsage } from "./desktopusage.js";
|
|
17
18
|
function claudeDesktopConfigPath() {
|
|
18
19
|
switch (platform()) {
|
|
19
20
|
case "darwin": return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
@@ -108,6 +109,10 @@ function checkMcpHandshake() {
|
|
|
108
109
|
export async function runDoctor() {
|
|
109
110
|
const checks = [];
|
|
110
111
|
checks.push(checkMcpEntry("Claude Desktop", claudeDesktopConfigPath()));
|
|
112
|
+
// Desktop chat has no hook or transcript; its own logs are the only evidence the tools are used.
|
|
113
|
+
const desktop = desktopUsage();
|
|
114
|
+
if (desktop)
|
|
115
|
+
checks.push({ label: "Desktop chat tool use", status: "ok", detail: describeDesktopUsage(desktop, recentSurfaces(7).get("claude-desktop") ?? 0) });
|
|
111
116
|
checks.push(checkMcpEntry("Claude Code", join(homedir(), ".claude.json")));
|
|
112
117
|
checks.push(checkMcpEntry("Cursor", join(homedir(), ".cursor", "mcp.json")));
|
|
113
118
|
// Hook registration
|
|
@@ -128,6 +133,11 @@ export async function runDoctor() {
|
|
|
128
133
|
checks.push(missing
|
|
129
134
|
? { label: "Every-prompt hook", status: "fail", detail: `registered, but ${missing} no longer exists — re-run: context-doctor install` }
|
|
130
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" });
|
|
131
141
|
}
|
|
132
142
|
}
|
|
133
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
package/dist/index.js
CHANGED
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
|
-
|
|
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
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|