context-doctor 0.12.2 → 0.13.1
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 +8 -2
- package/dist/accuracy.d.ts +37 -0
- package/dist/accuracy.js +124 -0
- package/dist/cli.js +61 -1
- package/dist/config.d.ts +26 -2
- package/dist/config.js +116 -2
- package/dist/dashboard.js +9 -6
- package/dist/diff.d.ts +13 -0
- package/dist/diff.js +92 -0
- package/dist/doctor.js +13 -0
- package/dist/hook.js +69 -14
- package/dist/impact.js +9 -6
- package/dist/ledger.d.ts +30 -1
- package/dist/ledger.js +74 -2
- package/dist/mcp.js +1 -1
- package/dist/optimize.js +43 -2
- package/dist/profile.js +51 -7
- package/dist/session.d.ts +16 -0
- package/dist/session.js +10 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -91,6 +91,9 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
91
91
|
| `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
|
|
92
92
|
| `context-doctor optimize <file>` | Apply the safe fixes; add `--strategy trim-tool-calls` for big inline file writes, `--strategy prune-history` for consented lossy compaction |
|
|
93
93
|
| `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**. Also reads ChatGPT data exports (`conversations.json`) |
|
|
94
|
+
| `context-doctor init [preset]` | Write a `.contextdoctorrc` from a preset (`chat`, `agent`, `batch`) — a budget you can adopt in one command and tune later |
|
|
95
|
+
| `context-doctor diff <before> <after>` | Compare two profiles: what moved by category, which findings were resolved or introduced, and what it saves in money and latency |
|
|
96
|
+
| `context-doctor accuracy` | How much of what you are billed for is visible in your transcript — the fixed harness baseline and the per-turn injected content neither you nor the profiler can see |
|
|
94
97
|
| `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
|
|
95
98
|
| `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
|
|
96
99
|
| `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
|
|
@@ -244,12 +247,13 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
|
|
|
244
247
|
## What it detects
|
|
245
248
|
|
|
246
249
|
- **Oversized tool results** — the #1 context killer in agent loops
|
|
250
|
+
- **Oversized tool calls** — a `Write` or a `cat > file <<EOF` puts the whole file in context permanently. In file-heavy agent work these outweigh every tool result combined, and `--strategy trim-tool-calls` reclaims them
|
|
247
251
|
- **Duplicate content** — the same doc/result pasted twice
|
|
248
252
|
- **Near-duplicates** — the same doc re-pasted with different surrounding words (shingle similarity, ≥60%)
|
|
249
|
-
- **Repeated file reads** — the same file pulled in three or more times, every copy still in context
|
|
253
|
+
- **Repeated file reads** — the same file pulled in three or more times, every copy still in context. Counts shell reads too (`cat`, `head`, `tail`, `less`), which is where most of them hide in agent sessions
|
|
250
254
|
- **Retained error output** — stack traces and failed commands kept verbatim long after the fix landed
|
|
251
255
|
- **Repeated identical tool calls** — a signal your agent forgot earlier results
|
|
252
|
-
- **Base64 / binary blobs** in text content
|
|
256
|
+
- **Base64 / binary blobs** in text content — checked by character distribution, not just alphabet, so hex digests and long identifiers are not mistaken for encoded binary
|
|
253
257
|
- **Long history** past the point where models track the middle
|
|
254
258
|
- **Cache-hostile ordering** — volatile content before stable content breaks prompt caching (Anthropic `cache_control`, OpenAI automatic prefix caching)
|
|
255
259
|
- **Window pressure** — usage % against the target model's real context window
|
|
@@ -266,6 +270,8 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
|
|
|
266
270
|
|
|
267
271
|
`trim-tool-calls` is the big one for agent sessions. Writing a file through a tool call puts the entire file in context permanently, so in file-heavy work the calls outweigh every tool result combined — on a real 278k-token session, the default set reached 248k and adding `trim-tool-calls` reached 102k. It is opt-in because it edits what the model itself wrote.
|
|
268
272
|
|
|
273
|
+
**Optimization is cache-aware.** Prompt caches match a byte-identical prefix, so editing a message in the middle invalidates everything after it — and the naive "trim everything older than the last N messages" boundary moves every single turn. On a 25-turn agent conversation that invalidated the cached prefix on 22 of 24 turns, paying the 1.25x cache-write price on the whole prefix to save a few hundred tokens. The trim boundary is quantized so it holds still between steps (8 of 24 on the same fixture), while still reaching 15 of 20 tool results.
|
|
274
|
+
|
|
269
275
|
Everything the optimizer does is inspectable: it prints exactly which messages changed and how many tokens each change saved.
|
|
270
276
|
|
|
271
277
|
**Summarization without an API key:** when `prune-history` runs through the MCP tools, context-doctor hands a digest of the pruned turns back to the model that called it (the Claude/GPT already running in your app) and asks *it* to write the replacement summary — LLM-quality compaction, zero extra cost, no keys.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor accuracy` — how much of what you are billed for is actually
|
|
3
|
+
* visible in your transcript.
|
|
4
|
+
*
|
|
5
|
+
* Every profile in this tool describes the CONVERSATION: the messages the
|
|
6
|
+
* transcript records. Your bill covers the whole request, which also carries
|
|
7
|
+
* the harness's system prompt, tool schemas, skills, and per-turn injected
|
|
8
|
+
* content that is never written to the transcript at all.
|
|
9
|
+
*
|
|
10
|
+
* Both numbers are correct; they answer different questions. This command
|
|
11
|
+
* measures the distance between them on your own sessions, so "why is my bill
|
|
12
|
+
* bigger than the profile?" has an answer with evidence behind it.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS IS NOT: a tokenizer benchmark. It cannot be — the content behind
|
|
15
|
+
* the gap is unavailable to us, so the gap cannot be attributed to estimator
|
|
16
|
+
* drift. To measure the estimator itself, use `analyze --exact`, which counts
|
|
17
|
+
* the same bytes with the provider's own tokenizer.
|
|
18
|
+
*/
|
|
19
|
+
export interface AccuracyReport {
|
|
20
|
+
sessionsScanned: number;
|
|
21
|
+
/** Turn-to-turn observations used. */
|
|
22
|
+
samples: number;
|
|
23
|
+
/** Median share of a turn's billed growth that the transcript accounts for. */
|
|
24
|
+
medianCoverage: number;
|
|
25
|
+
/** Median tokens per turn billed but absent from the transcript. */
|
|
26
|
+
medianInvisiblePerTurn: number;
|
|
27
|
+
/** Median fixed baseline: system prompt + tool schemas, before any turn. */
|
|
28
|
+
medianBaseline?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* @param limit How many recent sessions to sample.
|
|
32
|
+
* @param paths Explicit transcripts to measure instead of discovering them.
|
|
33
|
+
* Callers (and tests) that already know which files they mean
|
|
34
|
+
* should not have to impersonate a home directory to say so.
|
|
35
|
+
*/
|
|
36
|
+
export declare function measureAccuracy(limit?: number, paths?: string[]): AccuracyReport;
|
|
37
|
+
export declare function renderAccuracy(report: AccuracyReport): string;
|
package/dist/accuracy.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor accuracy` — how much of what you are billed for is actually
|
|
3
|
+
* visible in your transcript.
|
|
4
|
+
*
|
|
5
|
+
* Every profile in this tool describes the CONVERSATION: the messages the
|
|
6
|
+
* transcript records. Your bill covers the whole request, which also carries
|
|
7
|
+
* the harness's system prompt, tool schemas, skills, and per-turn injected
|
|
8
|
+
* content that is never written to the transcript at all.
|
|
9
|
+
*
|
|
10
|
+
* Both numbers are correct; they answer different questions. This command
|
|
11
|
+
* measures the distance between them on your own sessions, so "why is my bill
|
|
12
|
+
* bigger than the profile?" has an answer with evidence behind it.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS IS NOT: a tokenizer benchmark. It cannot be — the content behind
|
|
15
|
+
* the gap is unavailable to us, so the gap cannot be attributed to estimator
|
|
16
|
+
* drift. To measure the estimator itself, use `analyze --exact`, which counts
|
|
17
|
+
* the same bytes with the provider's own tokenizer.
|
|
18
|
+
*/
|
|
19
|
+
import { estimateTokens, formatTokens, MESSAGE_OVERHEAD_TOKENS } from "./tokens.js";
|
|
20
|
+
import { parseConversation } from "./parse.js";
|
|
21
|
+
import { listSessions, parseSessionFile } from "./session.js";
|
|
22
|
+
function median(values) {
|
|
23
|
+
if (values.length === 0)
|
|
24
|
+
return 0;
|
|
25
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
26
|
+
const mid = Math.floor(sorted.length / 2);
|
|
27
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
28
|
+
}
|
|
29
|
+
/** Below this, a turn is too small for the ratio to mean anything. */
|
|
30
|
+
const MIN_DELTA_TOKENS = 200;
|
|
31
|
+
/**
|
|
32
|
+
* @param limit How many recent sessions to sample.
|
|
33
|
+
* @param paths Explicit transcripts to measure instead of discovering them.
|
|
34
|
+
* Callers (and tests) that already know which files they mean
|
|
35
|
+
* should not have to impersonate a home directory to say so.
|
|
36
|
+
*/
|
|
37
|
+
export function measureAccuracy(limit = 20, paths) {
|
|
38
|
+
const coverages = [];
|
|
39
|
+
const invisible = [];
|
|
40
|
+
const baselines = [];
|
|
41
|
+
let scanned = 0;
|
|
42
|
+
const targets = paths ? paths.map((path) => ({ path })) : listSessions(limit);
|
|
43
|
+
for (const info of targets) {
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = parseSessionFile(info.path);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue; // an unreadable session is not a measurement
|
|
50
|
+
}
|
|
51
|
+
const usage = parsed.usageSamples ?? [];
|
|
52
|
+
if (usage.length < 2)
|
|
53
|
+
continue;
|
|
54
|
+
scanned++;
|
|
55
|
+
// Estimate through the same parser the profiler uses, with the same
|
|
56
|
+
// per-message overhead — anything else would measure a different estimator.
|
|
57
|
+
const normalized = parseConversation(parsed.conversationJson).messages;
|
|
58
|
+
const estimateAt = (i) => {
|
|
59
|
+
const m = normalized[i];
|
|
60
|
+
return m ? estimateTokens(m.text) + MESSAGE_OVERHEAD_TOKENS : 0;
|
|
61
|
+
};
|
|
62
|
+
let baseline = usage[0].input;
|
|
63
|
+
for (let i = 0; i < usage[0].index; i++)
|
|
64
|
+
baseline -= estimateAt(i);
|
|
65
|
+
if (baseline > 0)
|
|
66
|
+
baselines.push(baseline);
|
|
67
|
+
for (let s = 1; s < usage.length; s++) {
|
|
68
|
+
const billed = usage[s].input - usage[s - 1].input;
|
|
69
|
+
if (billed < MIN_DELTA_TOKENS)
|
|
70
|
+
continue;
|
|
71
|
+
let visible = 0;
|
|
72
|
+
for (let i = usage[s - 1].index; i < usage[s].index; i++)
|
|
73
|
+
visible += estimateAt(i);
|
|
74
|
+
if (visible <= 0)
|
|
75
|
+
continue;
|
|
76
|
+
// A turn cannot be more than fully visible; ratios above 1 mean the
|
|
77
|
+
// billed figure moved for another reason (a compaction, a schema change).
|
|
78
|
+
const coverage = visible / billed;
|
|
79
|
+
if (coverage > 1)
|
|
80
|
+
continue;
|
|
81
|
+
coverages.push(coverage);
|
|
82
|
+
invisible.push(billed - visible);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
sessionsScanned: scanned,
|
|
87
|
+
samples: coverages.length,
|
|
88
|
+
medianCoverage: median(coverages),
|
|
89
|
+
medianInvisiblePerTurn: median(invisible),
|
|
90
|
+
medianBaseline: baselines.length ? median(baselines) : undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function renderAccuracy(report) {
|
|
94
|
+
const lines = [];
|
|
95
|
+
lines.push("CONTEXT DOCTOR — billed context vs what your transcript shows");
|
|
96
|
+
lines.push("═".repeat(56));
|
|
97
|
+
if (report.samples === 0) {
|
|
98
|
+
lines.push("No usable samples found.");
|
|
99
|
+
lines.push("");
|
|
100
|
+
lines.push("This reads input-token counts recorded in Claude Code transcripts, so it");
|
|
101
|
+
lines.push("needs local sessions with at least two assistant turns.");
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
const pct = (n) => `${(n * 100).toFixed(0)}%`;
|
|
105
|
+
lines.push(`${report.samples} turns across ${report.sessionsScanned} session(s).`);
|
|
106
|
+
lines.push("");
|
|
107
|
+
if (report.medianBaseline !== undefined) {
|
|
108
|
+
lines.push(`Fixed baseline ~${formatTokens(report.medianBaseline)} tokens before your first turn —`);
|
|
109
|
+
lines.push(" system prompt, tool schemas and skills, none of which the");
|
|
110
|
+
lines.push(" transcript stores.");
|
|
111
|
+
}
|
|
112
|
+
lines.push(`Transcript covers ${pct(report.medianCoverage)} of a typical turn's billed growth`);
|
|
113
|
+
lines.push(`Not in transcript ~${formatTokens(report.medianInvisiblePerTurn)} tokens per turn (injected reminders,`);
|
|
114
|
+
lines.push(" skill and file content, and other per-request additions)");
|
|
115
|
+
lines.push("");
|
|
116
|
+
lines.push("Both numbers are right. Profiles describe the conversation you can see and");
|
|
117
|
+
lines.push("act on; your bill covers the whole request. Trimming what the profile shows");
|
|
118
|
+
lines.push("still saves real money — it just starts from a higher floor than the profile");
|
|
119
|
+
lines.push("implies.");
|
|
120
|
+
lines.push("");
|
|
121
|
+
lines.push("This does NOT measure tokenizer drift: the missing content is not available");
|
|
122
|
+
lines.push("to compare against. For that, use `analyze --exact` (provider tokenizer).");
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* `-` reads from stdin, so you can pipe: `cat chat.json | context-doctor analyze -`
|
|
9
9
|
*/
|
|
10
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
11
12
|
import process from "node:process";
|
|
12
13
|
import { parseConversation } from "./parse.js";
|
|
13
14
|
import { profileConversation } from "./profile.js";
|
|
@@ -21,6 +22,9 @@ import { runHook } from "./hook.js";
|
|
|
21
22
|
import { buildImpactReport } from "./impact.js";
|
|
22
23
|
import { recordLedger } from "./ledger.js";
|
|
23
24
|
import { runDoctor } from "./doctor.js";
|
|
25
|
+
import { measureAccuracy, renderAccuracy } from "./accuracy.js";
|
|
26
|
+
import { renderDiff } from "./diff.js";
|
|
27
|
+
import { findPreset, PRESETS, RC_FILENAME } from "./config.js";
|
|
24
28
|
import { runWatch } from "./watch.js";
|
|
25
29
|
import { exactTokenCount } from "./exact.js";
|
|
26
30
|
import { checkBudget, loadConfig } from "./config.js";
|
|
@@ -48,6 +52,12 @@ Usage:
|
|
|
48
52
|
MCP handshake) with one pasteable diagnosis
|
|
49
53
|
context-doctor dashboard Local savings dashboard on 127.0.0.1 (--port n,
|
|
50
54
|
default 8790) — charts from your own machine only
|
|
55
|
+
context-doctor init [preset] Write a .contextdoctorrc from a preset
|
|
56
|
+
(chat, agent, batch; no argument lists them)
|
|
57
|
+
context-doctor diff <before> <after> Compare two profiles: what moved, which findings
|
|
58
|
+
were resolved, and what it saves
|
|
59
|
+
context-doctor accuracy Measure the token heuristic against the API's own
|
|
60
|
+
counts recorded in your transcripts (--limit n)
|
|
51
61
|
context-doctor watch [file] Live-monitor a growing session/agent trace: running
|
|
52
62
|
token/cost line per change, new findings as they appear
|
|
53
63
|
(--interval-ms n, default 2000)
|
|
@@ -76,6 +86,7 @@ Options:
|
|
|
76
86
|
Default: dedupe, trim-tool-results, strip-base64 (lossless-ish set)
|
|
77
87
|
--keep-recent <n> (optimize) Messages at the tail to leave untouched (default 6)
|
|
78
88
|
--max-tool-tokens <n> (optimize) Token budget for trimmed tool results (default 300)
|
|
89
|
+
--limit <n> (accuracy) Sessions to sample (default 20)
|
|
79
90
|
--port <n> (proxy) Port to listen on (default 8787)
|
|
80
91
|
--host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
|
|
81
92
|
--config <file> (proxy) Per-route overrides: {"routes":[{"modelPrefix":"gpt","strategies":[...],
|
|
@@ -137,6 +148,9 @@ function parseArgs(argv) {
|
|
|
137
148
|
case "--interval-ms":
|
|
138
149
|
args.intervalMs = Number(argv[++i]);
|
|
139
150
|
break;
|
|
151
|
+
case "--limit":
|
|
152
|
+
args.limit = Number(argv[++i]);
|
|
153
|
+
break;
|
|
140
154
|
case "--host":
|
|
141
155
|
args.host = argv[++i];
|
|
142
156
|
break;
|
|
@@ -154,6 +168,7 @@ function parseArgs(argv) {
|
|
|
154
168
|
}
|
|
155
169
|
args.command = positional[0];
|
|
156
170
|
args.file = positional[1];
|
|
171
|
+
args.positionals = positional.slice(1);
|
|
157
172
|
return args;
|
|
158
173
|
}
|
|
159
174
|
/** Print budget status under a profile when a .contextdoctorrc defines one. */
|
|
@@ -203,6 +218,51 @@ function main() {
|
|
|
203
218
|
void runDoctor();
|
|
204
219
|
return;
|
|
205
220
|
}
|
|
221
|
+
if (args.command === "init") {
|
|
222
|
+
const requested = args.positionals?.[0];
|
|
223
|
+
if (!requested || args.list) {
|
|
224
|
+
console.log("Presets for .contextdoctorrc — pick the one that matches your workload:\n");
|
|
225
|
+
for (const p of PRESETS)
|
|
226
|
+
console.log(` ${p.id.padEnd(7)} ${p.summary}`);
|
|
227
|
+
console.log("\nThen: context-doctor init <preset>");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const preset = findPreset(requested);
|
|
231
|
+
if (!preset) {
|
|
232
|
+
console.error(`Unknown preset "${requested}". Available: ${PRESETS.map((p) => p.id).join(", ")}`);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
const target = join(process.cwd(), RC_FILENAME);
|
|
236
|
+
if (existsSync(target)) {
|
|
237
|
+
console.error(`${target} already exists — edit it, or delete it first.`);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
writeFileSync(target, JSON.stringify(preset.config, null, 2) + "\n");
|
|
241
|
+
console.log(`✓ Wrote ${target} (${preset.id}: ${preset.summary})`);
|
|
242
|
+
console.log(" Budgets are enforced by the every-prompt hook and reported by analyze/session.");
|
|
243
|
+
console.log(" Gate a pull request on it with: context-doctor analyze <file> --fail-over-budget");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (args.command === "diff") {
|
|
247
|
+
const [before, after] = args.positionals ?? [];
|
|
248
|
+
if (!before || !after) {
|
|
249
|
+
console.error("Usage: context-doctor diff <before> <after>");
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
console.log(renderDiff(before, after, args.model));
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
console.error(`Could not diff: ${e.message}`);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (args.command === "accuracy") {
|
|
262
|
+
const report = measureAccuracy(args.limit ?? 20);
|
|
263
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : renderAccuracy(report));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
206
266
|
if (args.command === "report") {
|
|
207
267
|
void buildImpactReport(args.port).then((r) => console.log(r));
|
|
208
268
|
return;
|
package/dist/config.d.ts
CHANGED
|
@@ -39,11 +39,19 @@ export interface LoadedConfig {
|
|
|
39
39
|
config: ContextDoctorConfig;
|
|
40
40
|
/** Absolute path of the rc file, or undefined when none was found. */
|
|
41
41
|
path?: string;
|
|
42
|
+
/** Settings that will be silently ignored, if any. */
|
|
43
|
+
warnings?: string[];
|
|
42
44
|
}
|
|
43
45
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
+
* Report anything in an rc file that will be silently ignored.
|
|
47
|
+
*
|
|
48
|
+
* Every invalid value here fails quietly and looks like the feature not
|
|
49
|
+
* working: `"trim-tool-result"` (missing s) trims nothing, a negative
|
|
50
|
+
* keepRecent disables trimming entirely, and a budget written as a string is
|
|
51
|
+
* never compared against. For a tool whose whole job is measurement, silently
|
|
52
|
+
* doing nothing is the worst available behaviour.
|
|
46
53
|
*/
|
|
54
|
+
export declare function validateConfig(config: unknown, path: string): string[];
|
|
47
55
|
export declare function loadConfig(startDir?: string, onWarn?: (msg: string) => void): LoadedConfig;
|
|
48
56
|
export interface BudgetVerdict {
|
|
49
57
|
/** True when any configured limit is exceeded. */
|
|
@@ -61,3 +69,19 @@ export declare function checkBudget(budget: ContextBudget | undefined, profile:
|
|
|
61
69
|
perCallUsd: number;
|
|
62
70
|
};
|
|
63
71
|
}): BudgetVerdict;
|
|
72
|
+
/**
|
|
73
|
+
* Starting points for `.contextdoctorrc`.
|
|
74
|
+
*
|
|
75
|
+
* An empty rc file is technically valid and completely useless: nobody knows
|
|
76
|
+
* what a reasonable token budget is for their kind of work until they have
|
|
77
|
+
* blown through one. These encode the three shapes that actually differ —
|
|
78
|
+
* a chat product, a coding agent, and a batch pipeline — so a budget can be
|
|
79
|
+
* adopted in one command and tuned later.
|
|
80
|
+
*/
|
|
81
|
+
export interface Preset {
|
|
82
|
+
id: string;
|
|
83
|
+
summary: string;
|
|
84
|
+
config: ContextDoctorConfig;
|
|
85
|
+
}
|
|
86
|
+
export declare const PRESETS: Preset[];
|
|
87
|
+
export declare function findPreset(id: string): Preset | undefined;
|
package/dist/config.js
CHANGED
|
@@ -36,14 +36,88 @@ function candidatePaths(startDir) {
|
|
|
36
36
|
* Load the nearest config. Malformed rc files are reported (so a typo is not
|
|
37
37
|
* silently ignored) but never throw — the tool keeps working with defaults.
|
|
38
38
|
*/
|
|
39
|
+
/** Strategy ids the optimizer actually implements. */
|
|
40
|
+
const KNOWN_STRATEGIES = new Set(["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64", "prune-history"]);
|
|
41
|
+
const KNOWN_KEYS = new Set(["budget", "strategies", "keepRecent", "maxToolResultTokens", "routes", "model"]);
|
|
42
|
+
const KNOWN_BUDGET_KEYS = new Set(["maxTokens", "maxCostPerMessageUsd", "maxWindowPct"]);
|
|
43
|
+
/**
|
|
44
|
+
* Report anything in an rc file that will be silently ignored.
|
|
45
|
+
*
|
|
46
|
+
* Every invalid value here fails quietly and looks like the feature not
|
|
47
|
+
* working: `"trim-tool-result"` (missing s) trims nothing, a negative
|
|
48
|
+
* keepRecent disables trimming entirely, and a budget written as a string is
|
|
49
|
+
* never compared against. For a tool whose whole job is measurement, silently
|
|
50
|
+
* doing nothing is the worst available behaviour.
|
|
51
|
+
*/
|
|
52
|
+
export function validateConfig(config, path) {
|
|
53
|
+
const warnings = [];
|
|
54
|
+
const where = (key) => `${path}: ${key}`;
|
|
55
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
56
|
+
return [`${path}: expected a JSON object`];
|
|
57
|
+
}
|
|
58
|
+
const c = config;
|
|
59
|
+
for (const key of Object.keys(c)) {
|
|
60
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
61
|
+
warnings.push(`${where(key)} is not a known setting — ignored (known: ${[...KNOWN_KEYS].join(", ")})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (c.budget !== undefined) {
|
|
65
|
+
if (typeof c.budget !== "object" || c.budget === null || Array.isArray(c.budget)) {
|
|
66
|
+
warnings.push(`${where("budget")} must be an object — ignored`);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const budget = c.budget;
|
|
70
|
+
for (const [key, value] of Object.entries(budget)) {
|
|
71
|
+
if (!KNOWN_BUDGET_KEYS.has(key)) {
|
|
72
|
+
warnings.push(`${where(`budget.${key}`)} is not a known budget limit — ignored`);
|
|
73
|
+
}
|
|
74
|
+
else if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
75
|
+
warnings.push(`${where(`budget.${key}`)} must be a positive number, got ${JSON.stringify(value)} — this limit will never trigger`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (typeof budget.maxWindowPct === "number" && budget.maxWindowPct > 100) {
|
|
79
|
+
warnings.push(`${where("budget.maxWindowPct")} is above 100 — a percentage of the context window cannot exceed 100`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (c.strategies !== undefined) {
|
|
84
|
+
if (!Array.isArray(c.strategies)) {
|
|
85
|
+
warnings.push(`${where("strategies")} must be an array — ignored`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
for (const id of c.strategies) {
|
|
89
|
+
if (!KNOWN_STRATEGIES.has(String(id))) {
|
|
90
|
+
warnings.push(`${where("strategies")}: "${id}" is not a strategy — ignored (known: ${[...KNOWN_STRATEGIES].join(", ")})`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const key of ["keepRecent", "maxToolResultTokens"]) {
|
|
96
|
+
const value = c[key];
|
|
97
|
+
if (value === undefined)
|
|
98
|
+
continue;
|
|
99
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
100
|
+
warnings.push(`${where(key)} must be a positive whole number, got ${JSON.stringify(value)} — ignored`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (c.routes !== undefined && !Array.isArray(c.routes)) {
|
|
104
|
+
warnings.push(`${where("routes")} must be an array — ignored`);
|
|
105
|
+
}
|
|
106
|
+
return warnings;
|
|
107
|
+
}
|
|
39
108
|
export function loadConfig(startDir = process.cwd(), onWarn) {
|
|
40
109
|
for (const path of candidatePaths(startDir)) {
|
|
41
110
|
if (!existsSync(path))
|
|
42
111
|
continue;
|
|
43
112
|
try {
|
|
44
113
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
45
|
-
|
|
46
|
-
|
|
114
|
+
// Arrays are objects too, hence the explicit check.
|
|
115
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
116
|
+
const warnings = validateConfig(parsed, path);
|
|
117
|
+
for (const warning of warnings)
|
|
118
|
+
onWarn?.(warning);
|
|
119
|
+
return { config: parsed, path, warnings };
|
|
120
|
+
}
|
|
47
121
|
onWarn?.(`${path}: expected a JSON object — ignoring`);
|
|
48
122
|
}
|
|
49
123
|
catch (e) {
|
|
@@ -71,3 +145,43 @@ export function checkBudget(budget, profile) {
|
|
|
71
145
|
}
|
|
72
146
|
return { overBudget: breaches.length > 0, breaches, maxTokens: budget.maxTokens };
|
|
73
147
|
}
|
|
148
|
+
export const PRESETS = [
|
|
149
|
+
{
|
|
150
|
+
id: "chat",
|
|
151
|
+
summary: "Interactive chat product — short contexts, latency matters most",
|
|
152
|
+
config: {
|
|
153
|
+
// Chat turns are small; a context this large means history is not being
|
|
154
|
+
// summarized, and every extra token is felt directly as time-to-first-token.
|
|
155
|
+
budget: { maxTokens: 30_000, maxWindowPct: 40, maxCostPerMessageUsd: 0.15 },
|
|
156
|
+
strategies: ["dedupe", "strip-base64"],
|
|
157
|
+
keepRecent: 10,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "agent",
|
|
162
|
+
summary: "Coding or tool-using agent — long runs, tool output dominates",
|
|
163
|
+
config: {
|
|
164
|
+
// Agents legitimately hold a lot of context, so the budget is generous;
|
|
165
|
+
// the tight controls go on tool traffic, which is where the waste is.
|
|
166
|
+
budget: { maxTokens: 150_000, maxWindowPct: 70, maxCostPerMessageUsd: 1.5 },
|
|
167
|
+
strategies: ["dedupe", "trim-tool-results", "strip-base64"],
|
|
168
|
+
keepRecent: 6,
|
|
169
|
+
maxToolResultTokens: 300,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "batch",
|
|
174
|
+
summary: "Batch or pipeline jobs — cost per call is the whole story",
|
|
175
|
+
config: {
|
|
176
|
+
// Nothing is interactive, so aggressive trimming costs nothing in feel
|
|
177
|
+
// and everything is multiplied by the number of items in the run.
|
|
178
|
+
budget: { maxTokens: 60_000, maxCostPerMessageUsd: 0.05 },
|
|
179
|
+
strategies: ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64"],
|
|
180
|
+
keepRecent: 4,
|
|
181
|
+
maxToolResultTokens: 150,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
export function findPreset(id) {
|
|
186
|
+
return PRESETS.find((p) => p.id === id);
|
|
187
|
+
}
|
package/dist/dashboard.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the server reads local files and answers only the loopback interface.
|
|
8
8
|
*/
|
|
9
9
|
import http from "node:http";
|
|
10
|
-
import { readLedger } from "./ledger.js";
|
|
10
|
+
import { foldTotals, readLedger } from "./ledger.js";
|
|
11
11
|
import { listSessions, parseSessionFile } from "./session.js";
|
|
12
12
|
import { parseConversation } from "./parse.js";
|
|
13
13
|
import { profileConversation } from "./profile.js";
|
|
@@ -28,6 +28,9 @@ async function fetchProxyStats(port) {
|
|
|
28
28
|
}
|
|
29
29
|
export async function collectDashboardData(proxyPort = 8787) {
|
|
30
30
|
const ledger = readLedger();
|
|
31
|
+
// Totals folded in when the ledger rotated; excluded from the daily series,
|
|
32
|
+
// which describes individual days rather than a carried-forward sum.
|
|
33
|
+
const carried = foldTotals(ledger.filter((e) => e.ev === "rollup"));
|
|
31
34
|
const checks = ledger.filter((e) => e.ev === "check" || e.ev === undefined);
|
|
32
35
|
const optimizes = ledger.filter((e) => e.ev === "optimize");
|
|
33
36
|
// Observed shrinkage: a session getting SMALLER between two deep checks is a
|
|
@@ -38,14 +41,14 @@ export async function collectDashboardData(proxyPort = 8787) {
|
|
|
38
41
|
continue;
|
|
39
42
|
perSession.set(c.sid, [...(perSession.get(c.sid) ?? []), c.tok]);
|
|
40
43
|
}
|
|
41
|
-
let shrinkage =
|
|
44
|
+
let shrinkage = carried.shrinkage;
|
|
42
45
|
for (const toks of perSession.values()) {
|
|
43
46
|
for (let i = 1; i < toks.length; i++)
|
|
44
47
|
if (toks[i] < toks[i - 1])
|
|
45
48
|
shrinkage += toks[i - 1] - toks[i];
|
|
46
49
|
}
|
|
47
|
-
const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0);
|
|
48
|
-
let usdSaved =
|
|
50
|
+
const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.optimizeSaved;
|
|
51
|
+
let usdSaved = carried.optimizeUsd;
|
|
49
52
|
for (const e of optimizes) {
|
|
50
53
|
const pricing = pricingFor(e.model);
|
|
51
54
|
if (pricing && e.saved)
|
|
@@ -93,8 +96,8 @@ export async function collectDashboardData(proxyPort = 8787) {
|
|
|
93
96
|
totals: {
|
|
94
97
|
tokensSaved: optimizeSaved + shrinkage + (proxy?.tokensSaved ?? 0),
|
|
95
98
|
usdSaved: usdSaved + (proxy?.estUsdSaved ?? 0),
|
|
96
|
-
checks: checks.length,
|
|
97
|
-
warnings: checks.filter((c) => c.warn).length,
|
|
99
|
+
checks: checks.length + carried.checks,
|
|
100
|
+
warnings: checks.filter((c) => c.warn).length + carried.warnings,
|
|
98
101
|
optimizeRuns: optimizes.length,
|
|
99
102
|
},
|
|
100
103
|
daily,
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor diff <before> <after>` — compare two profiles.
|
|
3
|
+
*
|
|
4
|
+
* Optimization currently has to be taken on trust: you run it, a number
|
|
5
|
+
* changes, and nothing shows what actually moved. This puts two profiles side
|
|
6
|
+
* by side, so "we cut the context" becomes a claim with evidence — which
|
|
7
|
+
* categories shrank, which findings were resolved, and what it means in money
|
|
8
|
+
* and latency.
|
|
9
|
+
*
|
|
10
|
+
* Works on any two inputs `analyze` accepts, plus session transcripts, so it
|
|
11
|
+
* covers before/after an optimization and one session against another.
|
|
12
|
+
*/
|
|
13
|
+
export declare function renderDiff(beforePath: string, afterPath: string, model?: string): string;
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor diff <before> <after>` — compare two profiles.
|
|
3
|
+
*
|
|
4
|
+
* Optimization currently has to be taken on trust: you run it, a number
|
|
5
|
+
* changes, and nothing shows what actually moved. This puts two profiles side
|
|
6
|
+
* by side, so "we cut the context" becomes a claim with evidence — which
|
|
7
|
+
* categories shrank, which findings were resolved, and what it means in money
|
|
8
|
+
* and latency.
|
|
9
|
+
*
|
|
10
|
+
* Works on any two inputs `analyze` accepts, plus session transcripts, so it
|
|
11
|
+
* covers before/after an optimization and one session against another.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { parseConversation } from "./parse.js";
|
|
15
|
+
import { profileConversation } from "./profile.js";
|
|
16
|
+
import { parseSessionFile } from "./session.js";
|
|
17
|
+
import { formatTokens } from "./tokens.js";
|
|
18
|
+
import { formatUsd } from "./pricing.js";
|
|
19
|
+
/** Load either a conversation file or a Claude Code / ChatGPT transcript. */
|
|
20
|
+
function loadProfile(path, model) {
|
|
21
|
+
if (path.endsWith(".jsonl")) {
|
|
22
|
+
const session = parseSessionFile(path);
|
|
23
|
+
return profileConversation(parseConversation(session.conversationJson), model ?? session.model);
|
|
24
|
+
}
|
|
25
|
+
return profileConversation(parseConversation(readFileSync(path, "utf8")), model);
|
|
26
|
+
}
|
|
27
|
+
function signed(n) {
|
|
28
|
+
return n === 0 ? "±0" : `${n > 0 ? "+" : "−"}${formatTokens(Math.abs(n))}`;
|
|
29
|
+
}
|
|
30
|
+
/** A finding is "the same finding" when its id and message positions match. */
|
|
31
|
+
function findingKey(f) {
|
|
32
|
+
return `${f.id}:${f.messages.join(",")}`;
|
|
33
|
+
}
|
|
34
|
+
export function renderDiff(beforePath, afterPath, model) {
|
|
35
|
+
const before = loadProfile(beforePath, model);
|
|
36
|
+
const after = loadProfile(afterPath, model);
|
|
37
|
+
const lines = [];
|
|
38
|
+
lines.push("CONTEXT DOCTOR — diff");
|
|
39
|
+
lines.push("═".repeat(56));
|
|
40
|
+
lines.push(`before: ${beforePath}`);
|
|
41
|
+
lines.push(`after: ${afterPath}`);
|
|
42
|
+
lines.push("");
|
|
43
|
+
const delta = after.totalTokens - before.totalTokens;
|
|
44
|
+
const pct = before.totalTokens > 0 ? (delta / before.totalTokens) * 100 : 0;
|
|
45
|
+
lines.push(`Total: ${formatTokens(before.totalTokens)} → ${formatTokens(after.totalTokens)} tokens ` +
|
|
46
|
+
`(${signed(delta)}, ${pct >= 0 ? "+" : ""}${pct.toFixed(1)}%)`);
|
|
47
|
+
lines.push(`Messages: ${before.messageCount} → ${after.messageCount}`);
|
|
48
|
+
if (before.cost && after.cost) {
|
|
49
|
+
const costDelta = after.cost.per1kCallsUsd - before.cost.per1kCallsUsd;
|
|
50
|
+
const ttftDelta = after.cost.ttftSeconds - before.cost.ttftSeconds;
|
|
51
|
+
lines.push(`Cost: ${formatUsd(before.cost.per1kCallsUsd)} → ${formatUsd(after.cost.per1kCallsUsd)} per 1k calls ` +
|
|
52
|
+
`(${costDelta <= 0 ? "saves " : "costs "}${formatUsd(Math.abs(costDelta))}), ` +
|
|
53
|
+
`${Math.abs(ttftDelta).toFixed(1)}s ${ttftDelta <= 0 ? "faster" : "slower"} per call`);
|
|
54
|
+
}
|
|
55
|
+
lines.push("");
|
|
56
|
+
// Where the change actually landed.
|
|
57
|
+
const categories = Object.keys(before.categories).map((key) => ({ label: key, before: before.categories[key], after: after.categories[key] }));
|
|
58
|
+
const moved = categories.filter((c) => c.before !== c.after);
|
|
59
|
+
if (moved.length > 0) {
|
|
60
|
+
lines.push("Where it changed");
|
|
61
|
+
lines.push("─".repeat(56));
|
|
62
|
+
for (const c of moved.sort((a, b) => Math.abs(b.after - b.before) - Math.abs(a.after - a.before))) {
|
|
63
|
+
lines.push(` ${c.label.padEnd(14)} ${formatTokens(c.before).padStart(7)} → ${formatTokens(c.after).padStart(7)} ${signed(c.after - c.before)}`);
|
|
64
|
+
}
|
|
65
|
+
lines.push("");
|
|
66
|
+
}
|
|
67
|
+
// Findings resolved and introduced — the qualitative half of the story.
|
|
68
|
+
const beforeKeys = new Map(before.findings.map((f) => [findingKey(f), f]));
|
|
69
|
+
const afterKeys = new Map(after.findings.map((f) => [findingKey(f), f]));
|
|
70
|
+
const resolved = [...beforeKeys].filter(([k]) => !afterKeys.has(k)).map(([, f]) => f);
|
|
71
|
+
const introduced = [...afterKeys].filter(([k]) => !beforeKeys.has(k)).map(([, f]) => f);
|
|
72
|
+
lines.push(`Findings: ${before.findings.length} → ${after.findings.length}`);
|
|
73
|
+
lines.push("─".repeat(56));
|
|
74
|
+
for (const f of resolved.slice(0, 5))
|
|
75
|
+
lines.push(` ✓ resolved: ${f.message}`);
|
|
76
|
+
if (resolved.length > 5)
|
|
77
|
+
lines.push(` ✓ … and ${resolved.length - 5} more resolved`);
|
|
78
|
+
for (const f of introduced.slice(0, 5))
|
|
79
|
+
lines.push(` ✗ new: ${f.message}`);
|
|
80
|
+
if (introduced.length > 5)
|
|
81
|
+
lines.push(` ✗ … and ${introduced.length - 5} more introduced`);
|
|
82
|
+
if (resolved.length === 0 && introduced.length === 0)
|
|
83
|
+
lines.push(" (no change in findings)");
|
|
84
|
+
lines.push("");
|
|
85
|
+
const verdict = delta < 0
|
|
86
|
+
? `Net improvement: ${formatTokens(-delta)} tokens removed, ${resolved.length} finding(s) resolved.`
|
|
87
|
+
: delta > 0
|
|
88
|
+
? `Context grew by ${formatTokens(delta)} tokens.`
|
|
89
|
+
: "No change in total context.";
|
|
90
|
+
lines.push(verdict);
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -11,6 +11,7 @@ import { homedir, platform } from "node:os";
|
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { ledgerPath, recordLedger } from "./ledger.js";
|
|
14
|
+
import { loadConfig } from "./config.js";
|
|
14
15
|
function claudeDesktopConfigPath() {
|
|
15
16
|
switch (platform()) {
|
|
16
17
|
case "darwin": return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
@@ -120,6 +121,18 @@ export async function runDoctor() {
|
|
|
120
121
|
catch {
|
|
121
122
|
checks.push({ label: "Ledger", status: "fail", detail: `cannot write ${ledgerPath()}` });
|
|
122
123
|
}
|
|
124
|
+
// Project config: a setting that is silently ignored looks exactly like the
|
|
125
|
+
// feature being broken, so name it here rather than leaving it to be guessed.
|
|
126
|
+
const loaded = loadConfig(process.cwd());
|
|
127
|
+
if (loaded.path) {
|
|
128
|
+
const warnings = loaded.warnings ?? [];
|
|
129
|
+
checks.push(warnings.length === 0
|
|
130
|
+
? { label: "Project config", status: "ok", detail: `${loaded.path} — all settings understood` }
|
|
131
|
+
: { label: "Project config", status: "fail", detail: `${warnings.length} setting(s) will be ignored:\n` + warnings.map((w) => ` ${w}`).join("\n") });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
checks.push({ label: "Project config", status: "skip", detail: "no .contextdoctorrc (optional; create one with: context-doctor init <preset>)" });
|
|
135
|
+
}
|
|
123
136
|
checks.push(await checkMcpHandshake());
|
|
124
137
|
const mark = { ok: "✓", fail: "✗", skip: "–" };
|
|
125
138
|
console.log("CONTEXT DOCTOR — self-check");
|
package/dist/hook.js
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* Registered by `context-doctor install` under hooks.UserPromptSubmit in
|
|
11
11
|
* ~/.claude/settings.json; removed by `context-doctor uninstall`.
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { join } from "node:path";
|
|
14
16
|
import { recordLedger, statePath } from "./ledger.js";
|
|
15
17
|
import { parseConversation } from "./parse.js";
|
|
16
18
|
import { profileConversation } from "./profile.js";
|
|
@@ -43,6 +45,70 @@ const REGROWTH_FACTOR = 1.4;
|
|
|
43
45
|
function minBytesForWarn(threshold) {
|
|
44
46
|
return threshold * 4;
|
|
45
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* State lives in one small file per session, not one shared map.
|
|
50
|
+
*
|
|
51
|
+
* The hook runs once per prompt in every Claude Code window, and people keep
|
|
52
|
+
* several open. With a shared JSON map, concurrent hooks each read the whole
|
|
53
|
+
* map and wrote it back, so the last writer erased everyone else: measured,
|
|
54
|
+
* 12 simultaneous sessions left 4 surviving entries. The cost of losing an
|
|
55
|
+
* entry is a repeated warning the regrowth gate exists to prevent, plus a full
|
|
56
|
+
* re-parse of a transcript that can be hundreds of megabytes.
|
|
57
|
+
*
|
|
58
|
+
* A process that only ever writes its own session's file cannot race another.
|
|
59
|
+
*/
|
|
60
|
+
function stateDir() {
|
|
61
|
+
return statePath().replace(/\.json$/, "") + ".d";
|
|
62
|
+
}
|
|
63
|
+
function sessionStatePath(sessionId) {
|
|
64
|
+
// Session ids are usually uuids, but the fallback id is a filesystem path.
|
|
65
|
+
// Hashing keeps the filename valid whatever the id looks like.
|
|
66
|
+
return join(stateDir(), createHash("sha1").update(sessionId).digest("hex").slice(0, 16) + ".json");
|
|
67
|
+
}
|
|
68
|
+
function readSessionState(sessionId) {
|
|
69
|
+
try {
|
|
70
|
+
const raw = JSON.parse(readFileSync(sessionStatePath(sessionId), "utf8"));
|
|
71
|
+
if (typeof raw?.t === "number" && typeof raw?.b === "number")
|
|
72
|
+
return raw;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* absent or half-written: treat as a first run */
|
|
76
|
+
}
|
|
77
|
+
// Migration: entries written by the shared-map versions are still useful.
|
|
78
|
+
try {
|
|
79
|
+
const legacy = JSON.parse(readFileSync(statePath(), "utf8"));
|
|
80
|
+
const entry = legacy[sessionId];
|
|
81
|
+
if (typeof entry === "number")
|
|
82
|
+
return { t: entry, b: 0 };
|
|
83
|
+
if (entry && typeof entry.t === "number")
|
|
84
|
+
return { t: entry.t, b: entry.b ?? 0 };
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* no legacy file */
|
|
88
|
+
}
|
|
89
|
+
return { t: 0, b: 0 };
|
|
90
|
+
}
|
|
91
|
+
/** Keep the directory from growing without bound as sessions come and go. */
|
|
92
|
+
const MAX_STATE_FILES = 200;
|
|
93
|
+
function writeSessionState(sessionId, state) {
|
|
94
|
+
const dir = stateDir();
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
|
|
97
|
+
try {
|
|
98
|
+
const files = readdirSync(dir);
|
|
99
|
+
if (files.length <= MAX_STATE_FILES)
|
|
100
|
+
return;
|
|
101
|
+
const byAge = files
|
|
102
|
+
.map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs }))
|
|
103
|
+
.sort((a, b) => b.t - a.t)
|
|
104
|
+
.slice(MAX_STATE_FILES);
|
|
105
|
+
for (const { f } of byAge)
|
|
106
|
+
rmSync(join(dir, f), { force: true });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* pruning is housekeeping, never worth failing a prompt over */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
46
112
|
async function readStdin() {
|
|
47
113
|
const chunks = [];
|
|
48
114
|
for await (const chunk of process.stdin)
|
|
@@ -68,16 +134,7 @@ export async function runHook() {
|
|
|
68
134
|
// since the last full parse, nothing new can trigger — exit without the
|
|
69
135
|
// expensive read. Heavy-but-quiet sessions cost one stat + tiny state read.
|
|
70
136
|
const sessionId = input.session_id ?? transcriptPath;
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
state = JSON.parse(readFileSync(statePath(), "utf8"));
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
/* first run */
|
|
77
|
-
}
|
|
78
|
-
const rawPrev = state[sessionId];
|
|
79
|
-
// Migrate pre-0.3.5 numeric entries ({tokens only}) to the new shape.
|
|
80
|
-
const prev = typeof rawPrev === "number" ? { t: rawPrev, b: 0 } : rawPrev ?? { t: 0, b: 0 };
|
|
137
|
+
const prev = readSessionState(sessionId);
|
|
81
138
|
if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR)
|
|
82
139
|
return;
|
|
83
140
|
// Slow path (growth events only): full parse + profile.
|
|
@@ -91,9 +148,7 @@ export async function runHook() {
|
|
|
91
148
|
const liveTokens = parsed.reportedInputTokens ?? profile.totalTokens;
|
|
92
149
|
// Record this parse so the next prompts take fast path 2.
|
|
93
150
|
const shouldWarn = liveTokens >= threshold && liveTokens >= prev.t * REGROWTH_FACTOR;
|
|
94
|
-
|
|
95
|
-
const entries = Object.entries({ ...state, [sessionId]: nextState });
|
|
96
|
-
writeFileSync(statePath(), JSON.stringify(Object.fromEntries(entries.slice(-100))));
|
|
151
|
+
writeSessionState(sessionId, { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes });
|
|
97
152
|
recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: liveTokens, warn: shouldWarn });
|
|
98
153
|
if (!shouldWarn)
|
|
99
154
|
return;
|
package/dist/impact.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* session cannot be re-run without it. The report says so instead of inventing
|
|
9
9
|
* a number.
|
|
10
10
|
*/
|
|
11
|
-
import { readLedger } from "./ledger.js";
|
|
11
|
+
import { foldTotals, readLedger } from "./ledger.js";
|
|
12
12
|
import { listSessions, parseSessionFile } from "./session.js";
|
|
13
13
|
import { parseConversation } from "./parse.js";
|
|
14
14
|
import { profileConversation } from "./profile.js";
|
|
@@ -32,6 +32,9 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
32
32
|
lines.push("CONTEXT DOCTOR — impact report");
|
|
33
33
|
lines.push("═".repeat(56));
|
|
34
34
|
const ledger = readLedger();
|
|
35
|
+
// Rotation folds dropped entries into a rollup. Counting it is what keeps
|
|
36
|
+
// these lifetime totals from going backwards once the cap is hit.
|
|
37
|
+
const carried = foldTotals(ledger.filter((e) => e.ev === "rollup"));
|
|
35
38
|
const checks = ledger.filter((e) => e.ev === "check" || e.ev === undefined);
|
|
36
39
|
const optimizes = ledger.filter((e) => e.ev === "optimize");
|
|
37
40
|
// Observed per-session reductions: when a session SHRANK between two deep
|
|
@@ -55,9 +58,9 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
55
58
|
}
|
|
56
59
|
reductionBySession.set(sid, reduction);
|
|
57
60
|
}
|
|
58
|
-
const totalReduction = [...reductionBySession.values()].reduce((a, b) => a + b, 0);
|
|
61
|
+
const totalReduction = [...reductionBySession.values()].reduce((a, b) => a + b, 0) + carried.shrinkage;
|
|
59
62
|
// Optimize-event savings, split by model family (claude / gpt / other).
|
|
60
|
-
const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0);
|
|
63
|
+
const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.optimizeSaved;
|
|
61
64
|
const savedByFamily = new Map();
|
|
62
65
|
let optimizeUsd = 0;
|
|
63
66
|
for (const e of optimizes) {
|
|
@@ -71,7 +74,7 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
71
74
|
// Persisted checkpoints cover proxy runs that have since exited; the live
|
|
72
75
|
// process reports whatever it has not checkpointed yet.
|
|
73
76
|
const proxyEvents = ledger.filter((e) => e.ev === "proxy");
|
|
74
|
-
const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0);
|
|
77
|
+
const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.proxySaved;
|
|
75
78
|
const proxySaved = proxyHistoric + (proxy?.tokensSaved ?? 0);
|
|
76
79
|
// -- Headline: what context-doctor has saved ----------------------------------
|
|
77
80
|
const totalSaved = proxySaved + optimizeSaved + totalReduction;
|
|
@@ -100,8 +103,8 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
100
103
|
lines.push("Hygiene activity (every-prompt hook)");
|
|
101
104
|
lines.push("─".repeat(56));
|
|
102
105
|
if (checks.length > 0) {
|
|
103
|
-
const warnings = checks.filter((e) => e.warn).length;
|
|
104
|
-
lines.push(`${checks.length} deep context checks across ${bySession.size} session(s); ${warnings} warning(s) delivered to the model.`);
|
|
106
|
+
const warnings = checks.filter((e) => e.warn).length + carried.warnings;
|
|
107
|
+
lines.push(`${checks.length + carried.checks} deep context checks across ${bySession.size} session(s); ${warnings} warning(s) delivered to the model.`);
|
|
105
108
|
lines.push("(Prompt-level fast checks are not logged — they cost ~1ms and leave no trace by design.)");
|
|
106
109
|
}
|
|
107
110
|
else {
|
package/dist/ledger.d.ts
CHANGED
|
@@ -8,10 +8,16 @@
|
|
|
8
8
|
* (pre-0.3.6 hook entries have no `ev` field; treated as checks)
|
|
9
9
|
* optimize — an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
|
|
10
10
|
* proxy — proxy savings checkpoint {ev: "proxy", saved, usd?, requests?}
|
|
11
|
+
* rollup — totals folded in on rotation {ev: "rollup", ...carried sums}
|
|
12
|
+
*
|
|
13
|
+
* The ledger is capped, and everything it feeds is a LIFETIME total. Simply
|
|
14
|
+
* dropping old lines made those totals go backwards — measured: 1,692,000
|
|
15
|
+
* tokens saved became 501,000 the moment the cap was hit. So rotation folds
|
|
16
|
+
* what it drops into a single rollup entry instead of discarding it.
|
|
11
17
|
*/
|
|
12
18
|
export interface LedgerEntry {
|
|
13
19
|
ts: number;
|
|
14
|
-
ev?: "check" | "optimize" | "proxy";
|
|
20
|
+
ev?: "check" | "optimize" | "proxy" | "rollup";
|
|
15
21
|
sid?: string;
|
|
16
22
|
tok?: number;
|
|
17
23
|
warn?: boolean;
|
|
@@ -20,7 +26,30 @@ export interface LedgerEntry {
|
|
|
20
26
|
requests?: number;
|
|
21
27
|
saved?: number;
|
|
22
28
|
model?: string;
|
|
29
|
+
/** rollup only: sums carried forward from entries rotation removed. */
|
|
30
|
+
carried?: CarriedTotals;
|
|
31
|
+
}
|
|
32
|
+
/** Everything the reports total up, preserved across ledger rotation. */
|
|
33
|
+
export interface CarriedTotals {
|
|
34
|
+
optimizeSaved: number;
|
|
35
|
+
optimizeUsd: number;
|
|
36
|
+
proxySaved: number;
|
|
37
|
+
proxyUsd: number;
|
|
38
|
+
proxyRequests: number;
|
|
39
|
+
checks: number;
|
|
40
|
+
warnings: number;
|
|
41
|
+
/** Session shrinkage observed between consecutive checks. */
|
|
42
|
+
shrinkage: number;
|
|
43
|
+
/** Timestamp of the oldest folded entry, so reports can say "since". */
|
|
44
|
+
since?: number;
|
|
23
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Reduce a set of entries to the totals the reports care about.
|
|
48
|
+
*
|
|
49
|
+
* Absorbs existing rollups, so folding stays correct across any number of
|
|
50
|
+
* rotations rather than only the first.
|
|
51
|
+
*/
|
|
52
|
+
export declare function foldTotals(entries: LedgerEntry[]): CarriedTotals;
|
|
24
53
|
export declare function statePath(): string;
|
|
25
54
|
export declare function ledgerPath(): string;
|
|
26
55
|
export declare function recordLedger(entry: Omit<LedgerEntry, "ts">): void;
|
package/dist/ledger.js
CHANGED
|
@@ -8,10 +8,68 @@
|
|
|
8
8
|
* (pre-0.3.6 hook entries have no `ev` field; treated as checks)
|
|
9
9
|
* optimize — an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
|
|
10
10
|
* proxy — proxy savings checkpoint {ev: "proxy", saved, usd?, requests?}
|
|
11
|
+
* rollup — totals folded in on rotation {ev: "rollup", ...carried sums}
|
|
12
|
+
*
|
|
13
|
+
* The ledger is capped, and everything it feeds is a LIFETIME total. Simply
|
|
14
|
+
* dropping old lines made those totals go backwards — measured: 1,692,000
|
|
15
|
+
* tokens saved became 501,000 the moment the cap was hit. So rotation folds
|
|
16
|
+
* what it drops into a single rollup entry instead of discarding it.
|
|
11
17
|
*/
|
|
12
18
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
19
|
import { homedir } from "node:os";
|
|
14
20
|
import { dirname, join } from "node:path";
|
|
21
|
+
const EMPTY_CARRIED = {
|
|
22
|
+
optimizeSaved: 0, optimizeUsd: 0, proxySaved: 0, proxyUsd: 0,
|
|
23
|
+
proxyRequests: 0, checks: 0, warnings: 0, shrinkage: 0,
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Reduce a set of entries to the totals the reports care about.
|
|
27
|
+
*
|
|
28
|
+
* Absorbs existing rollups, so folding stays correct across any number of
|
|
29
|
+
* rotations rather than only the first.
|
|
30
|
+
*/
|
|
31
|
+
export function foldTotals(entries) {
|
|
32
|
+
const out = { ...EMPTY_CARRIED };
|
|
33
|
+
const perSession = new Map();
|
|
34
|
+
for (const e of entries) {
|
|
35
|
+
if (e.ev === "rollup" && e.carried) {
|
|
36
|
+
out.optimizeSaved += e.carried.optimizeSaved;
|
|
37
|
+
out.optimizeUsd += e.carried.optimizeUsd;
|
|
38
|
+
out.proxySaved += e.carried.proxySaved;
|
|
39
|
+
out.proxyUsd += e.carried.proxyUsd;
|
|
40
|
+
out.proxyRequests += e.carried.proxyRequests;
|
|
41
|
+
out.checks += e.carried.checks;
|
|
42
|
+
out.warnings += e.carried.warnings;
|
|
43
|
+
out.shrinkage += e.carried.shrinkage;
|
|
44
|
+
if (e.carried.since)
|
|
45
|
+
out.since = Math.min(out.since ?? e.carried.since, e.carried.since);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
out.since = Math.min(out.since ?? e.ts, e.ts);
|
|
49
|
+
if (e.ev === "optimize") {
|
|
50
|
+
out.optimizeSaved += e.saved ?? 0;
|
|
51
|
+
out.optimizeUsd += e.usd ?? 0;
|
|
52
|
+
}
|
|
53
|
+
else if (e.ev === "proxy") {
|
|
54
|
+
out.proxySaved += e.saved ?? 0;
|
|
55
|
+
out.proxyUsd += e.usd ?? 0;
|
|
56
|
+
out.proxyRequests += e.requests ?? 0;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
out.checks++;
|
|
60
|
+
if (e.warn)
|
|
61
|
+
out.warnings++;
|
|
62
|
+
if (e.sid && typeof e.tok === "number")
|
|
63
|
+
perSession.set(e.sid, [...(perSession.get(e.sid) ?? []), e.tok]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const toks of perSession.values()) {
|
|
67
|
+
for (let i = 1; i < toks.length; i++)
|
|
68
|
+
if (toks[i] < toks[i - 1])
|
|
69
|
+
out.shrinkage += toks[i - 1] - toks[i];
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
15
73
|
export function statePath() {
|
|
16
74
|
return process.env.CONTEXT_DOCTOR_HOOK_STATE ?? join(homedir(), ".claude", ".context-doctor-hook-state.json");
|
|
17
75
|
}
|
|
@@ -24,10 +82,24 @@ export function recordLedger(entry) {
|
|
|
24
82
|
// Claude-Desktop-only machines have no ~/.claude — create it so their
|
|
25
83
|
// optimize events count in `context-doctor report` too.
|
|
26
84
|
mkdirSync(dirname(path), { recursive: true });
|
|
27
|
-
// Cap growth: past ~256KB keep the most recent 500 entries
|
|
85
|
+
// Cap growth: past ~256KB keep the most recent 500 entries — but fold the
|
|
86
|
+
// dropped ones into a rollup first, or every lifetime total in the reports
|
|
87
|
+
// silently shrinks the moment a heavy user crosses the cap.
|
|
28
88
|
if (existsSync(path) && statSync(path).size > 256 * 1024) {
|
|
29
89
|
const lines = readFileSync(path, "utf8").trimEnd().split("\n");
|
|
30
|
-
|
|
90
|
+
const parse = (line) => {
|
|
91
|
+
try {
|
|
92
|
+
return [JSON.parse(line)];
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const kept = lines.slice(-500);
|
|
99
|
+
const dropped = lines.slice(0, -500).flatMap(parse);
|
|
100
|
+
const carried = foldTotals(dropped);
|
|
101
|
+
const rollup = { ts: Date.now(), ev: "rollup", carried };
|
|
102
|
+
writeFileSync(path, [JSON.stringify(rollup), ...kept].join("\n") + "\n");
|
|
31
103
|
}
|
|
32
104
|
appendFileSync(path, JSON.stringify({ ts: Date.now(), ...entry }) + "\n");
|
|
33
105
|
}
|
package/dist/mcp.js
CHANGED
|
@@ -37,7 +37,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
|
|
|
37
37
|
* recommended pattern.
|
|
38
38
|
*/
|
|
39
39
|
function createServer() {
|
|
40
|
-
const server = new McpServer({ name: "context-doctor", version: "0.
|
|
40
|
+
const server = new McpServer({ name: "context-doctor", version: "0.13.1" }, { instructions: SERVER_INSTRUCTIONS });
|
|
41
41
|
server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown by category, largest messages, and actionable findings about wasted context (duplicates, oversized tool results, base64 blobs, cache-unfriendly ordering). Accepts OpenAI/Anthropic conversation JSON or raw text. Call this immediately whenever the user asks about token usage, context size, LLM cost, or latency — and proactively offer it once a conversation grows long or accumulates large pasted content.", {
|
|
42
42
|
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
|
|
43
43
|
model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
|
package/dist/optimize.js
CHANGED
|
@@ -38,6 +38,34 @@ function trimCallArguments(input, maxTokens) {
|
|
|
38
38
|
}
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Where trimming stops — quantized so the prompt cache survives.
|
|
43
|
+
*
|
|
44
|
+
* The obvious boundary, `length - keepRecent`, advances by one on every turn.
|
|
45
|
+
* That rewrites the message that just aged out of the recent window, and since
|
|
46
|
+
* prompt caches only match a byte-identical prefix, editing anything in the
|
|
47
|
+
* middle invalidates the cache from that point on. Measured on a 25-turn agent
|
|
48
|
+
* conversation, the naive boundary invalidated the cached prefix on 24 of 24
|
|
49
|
+
* turns: paying the 1.25x cache-write price every turn to save a few hundred
|
|
50
|
+
* tokens, which is a straight loss on any sizeable context.
|
|
51
|
+
*
|
|
52
|
+
* Quantizing means the boundary holds still for STEP turns at a time, so the
|
|
53
|
+
* prefix stays byte-identical in between and the cache is rebuilt once per
|
|
54
|
+
* STEP turns instead of once per turn. Older content is trimmed slightly later
|
|
55
|
+
* than it otherwise would be; that is much cheaper than losing the cache.
|
|
56
|
+
*/
|
|
57
|
+
const TRIM_BOUNDARY_STEP = 10;
|
|
58
|
+
function stableCutoff(messageCount, keepRecent) {
|
|
59
|
+
const raw = messageCount - keepRecent;
|
|
60
|
+
if (raw <= 0)
|
|
61
|
+
return 0;
|
|
62
|
+
// Below one step there is nothing to quantize to except zero, which would
|
|
63
|
+
// silently disable trimming on every short conversation. Such a conversation
|
|
64
|
+
// has no long stable prefix worth protecting anyway.
|
|
65
|
+
if (raw < TRIM_BOUNDARY_STEP)
|
|
66
|
+
return raw;
|
|
67
|
+
return Math.floor(raw / TRIM_BOUNDARY_STEP) * TRIM_BOUNDARY_STEP;
|
|
68
|
+
}
|
|
41
69
|
function hash(text) {
|
|
42
70
|
return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
|
|
43
71
|
}
|
|
@@ -154,11 +182,24 @@ export function optimizeConversation(input, options = {}) {
|
|
|
154
182
|
}
|
|
155
183
|
// -- dedupe: identical content beyond the first occurrence --------------------
|
|
156
184
|
if (opts.strategies.includes("dedupe")) {
|
|
185
|
+
// The recent tail is what the model is actually answering. Replacing a
|
|
186
|
+
// message there with "identical to #0" is technically true and practically
|
|
187
|
+
// awful: through the proxy, a user who pastes the same document twice has
|
|
188
|
+
// their CURRENT question swapped for a pointer to a message ten turns back,
|
|
189
|
+
// and just sees a worse answer with no explanation. Older copies are fair
|
|
190
|
+
// game; the live turn is not.
|
|
191
|
+
const cutoff = stableCutoff(messages.length, opts.keepRecent);
|
|
157
192
|
const seen = new Map();
|
|
158
193
|
messages.forEach((m, i) => {
|
|
159
194
|
const text = textOf(m.content);
|
|
160
195
|
if (text.length < 300)
|
|
161
196
|
return;
|
|
197
|
+
if (i >= cutoff) {
|
|
198
|
+
// Still record it, so a later duplicate can point back here.
|
|
199
|
+
if (!seen.has(hash(text)))
|
|
200
|
+
seen.set(hash(text), i);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
162
203
|
const h = hash(text);
|
|
163
204
|
const first = seen.get(h);
|
|
164
205
|
if (first === undefined) {
|
|
@@ -172,7 +213,7 @@ export function optimizeConversation(input, options = {}) {
|
|
|
172
213
|
}
|
|
173
214
|
// -- trim-tool-results: shrink stale tool output ------------------------------
|
|
174
215
|
if (opts.strategies.includes("trim-tool-results")) {
|
|
175
|
-
const cutoff = messages.length
|
|
216
|
+
const cutoff = stableCutoff(messages.length, opts.keepRecent);
|
|
176
217
|
messages.forEach((m, i) => {
|
|
177
218
|
if (i >= cutoff || !isToolResultMessage(m))
|
|
178
219
|
return;
|
|
@@ -192,7 +233,7 @@ export function optimizeConversation(input, options = {}) {
|
|
|
192
233
|
}
|
|
193
234
|
// -- trim-tool-calls: shrink the arguments of calls that already ran ----------
|
|
194
235
|
if (opts.strategies.includes("trim-tool-calls")) {
|
|
195
|
-
const cutoff = messages.length
|
|
236
|
+
const cutoff = stableCutoff(messages.length, opts.keepRecent);
|
|
196
237
|
messages.forEach((m, i) => {
|
|
197
238
|
if (i >= cutoff)
|
|
198
239
|
return;
|
package/dist/profile.js
CHANGED
|
@@ -96,6 +96,54 @@ function jaccard(a, b) {
|
|
|
96
96
|
inter++;
|
|
97
97
|
return inter / (a.size + b.size - inter);
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Shell commands that pull a file's contents into context. An agent that runs
|
|
101
|
+
* `cat config.json` has re-read that file just as surely as one that calls a
|
|
102
|
+
* Read tool — but the call looks like an opaque Bash invocation, so the
|
|
103
|
+
* repeated-read detector used to score shell-heavy sessions as clean when they
|
|
104
|
+
* were the worst offenders.
|
|
105
|
+
*
|
|
106
|
+
* Deliberately narrow: only commands whose whole purpose is to emit file
|
|
107
|
+
* contents, and only when the argument looks like a path rather than a flag or
|
|
108
|
+
* a glob. `grep` and `ls` are excluded — they summarize, they do not dump.
|
|
109
|
+
*/
|
|
110
|
+
const FILE_DUMPING_COMMANDS = /\b(?:cat|bat|head|tail|less|more|nl)\s+((?:-[^\s;|&]+\s+)*)([^\s|;&><'"`]{3,})/g;
|
|
111
|
+
/**
|
|
112
|
+
* Does this token name a file, or is it the next shell word?
|
|
113
|
+
*
|
|
114
|
+
* `head -20; echo done` used to report a file called "echo": the flag pattern
|
|
115
|
+
* swallowed the separator and the following command became the target. A real
|
|
116
|
+
* path has a directory separator or an extension; a bare command word has
|
|
117
|
+
* neither.
|
|
118
|
+
*/
|
|
119
|
+
function looksLikePath(token) {
|
|
120
|
+
if (/[*?$]/.test(token) || token.startsWith("-"))
|
|
121
|
+
return false; // glob or flag
|
|
122
|
+
if (token === "EOF" || token === "<<")
|
|
123
|
+
return false; // heredoc, which writes
|
|
124
|
+
if (!/^[\w./~@+-]+$/.test(token))
|
|
125
|
+
return false;
|
|
126
|
+
return token.includes("/") || /\.[A-Za-z0-9]{1,8}$/.test(token);
|
|
127
|
+
}
|
|
128
|
+
/** Extract every file path a tool call reads, whether by tool or by shell. */
|
|
129
|
+
function filesReadBy(toolName, toolCallText) {
|
|
130
|
+
// Explicit read-style tools name their target in a known argument.
|
|
131
|
+
if (/read|open|cat|view|get_file/i.test(toolName ?? "")) {
|
|
132
|
+
const match = /"(?:file_path|filePath|path|file)"\s*:\s*"([^"]{3,})"/.exec(toolCallText);
|
|
133
|
+
if (match)
|
|
134
|
+
return [match[1]];
|
|
135
|
+
}
|
|
136
|
+
if (!/bash|shell|terminal|exec|run_command/i.test(toolName ?? ""))
|
|
137
|
+
return [];
|
|
138
|
+
const paths = new Set();
|
|
139
|
+
FILE_DUMPING_COMMANDS.lastIndex = 0;
|
|
140
|
+
for (const match of toolCallText.matchAll(FILE_DUMPING_COMMANDS)) {
|
|
141
|
+
const candidate = match[2].replace(/\\+$/, "");
|
|
142
|
+
if (looksLikePath(candidate))
|
|
143
|
+
paths.add(candidate);
|
|
144
|
+
}
|
|
145
|
+
return [...paths];
|
|
146
|
+
}
|
|
99
147
|
export function profileConversation(conv, model) {
|
|
100
148
|
const perMessage = conv.messages.map((m) => ({
|
|
101
149
|
msg: m,
|
|
@@ -231,13 +279,9 @@ export function profileConversation(conv, model) {
|
|
|
231
279
|
for (const p of perMessage) {
|
|
232
280
|
if (p.msg.kind !== "tool_call" || !p.msg.toolCallText)
|
|
233
281
|
continue;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (!match)
|
|
238
|
-
continue;
|
|
239
|
-
const path = match[1];
|
|
240
|
-
readsByPath.set(path, [...(readsByPath.get(path) ?? []), p.msg.index]);
|
|
282
|
+
for (const path of filesReadBy(p.msg.toolName, p.msg.toolCallText)) {
|
|
283
|
+
readsByPath.set(path, [...(readsByPath.get(path) ?? []), p.msg.index]);
|
|
284
|
+
}
|
|
241
285
|
}
|
|
242
286
|
for (const [path, indexes] of readsByPath) {
|
|
243
287
|
if (indexes.length < 3)
|
package/dist/session.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ export interface SessionInfo {
|
|
|
14
14
|
modifiedAt: Date;
|
|
15
15
|
sizeBytes: number;
|
|
16
16
|
}
|
|
17
|
+
/** One API-reported input size, positioned in the message array. */
|
|
18
|
+
export interface UsageSample {
|
|
19
|
+
/** Index into the live `messages` array of the assistant message reporting it. */
|
|
20
|
+
index: number;
|
|
21
|
+
/** input + cache-read + cache-creation tokens for that request. */
|
|
22
|
+
input: number;
|
|
23
|
+
}
|
|
17
24
|
export interface ParsedSession {
|
|
18
25
|
/**
|
|
19
26
|
* The real input size of the most recent request, as reported by the API
|
|
@@ -30,6 +37,15 @@ export interface ParsedSession {
|
|
|
30
37
|
* was compacted away so the difference can be shown rather than hidden.
|
|
31
38
|
*/
|
|
32
39
|
compactedAway?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Every API-reported input size in the transcript, tagged with its position
|
|
42
|
+
* in the live message array. Consecutive samples are what make key-free
|
|
43
|
+
* accuracy measurement possible: the harness's system prompt and tool
|
|
44
|
+
* schemas are constant between two calls, so the DIFFERENCE between two
|
|
45
|
+
* reported figures is the cost of the messages in between — directly
|
|
46
|
+
* comparable to what the heuristic estimates for those same messages.
|
|
47
|
+
*/
|
|
48
|
+
usageSamples?: UsageSample[];
|
|
33
49
|
/** Conversation JSON string in Anthropic-ish format, ready for parseConversation(). */
|
|
34
50
|
conversationJson: string;
|
|
35
51
|
title?: string;
|
package/dist/session.js
CHANGED
|
@@ -142,6 +142,8 @@ export function parseSessionFile(path) {
|
|
|
142
142
|
let lastCompactIndex = -1;
|
|
143
143
|
/** Newest API-reported input size, if the transcript carries usage. */
|
|
144
144
|
let reportedInputTokens;
|
|
145
|
+
/** Every reported size, positioned — the basis for `context-doctor accuracy`. */
|
|
146
|
+
const usageSamples = [];
|
|
145
147
|
forEachLine(path, (line) => {
|
|
146
148
|
if (!line.trim())
|
|
147
149
|
return;
|
|
@@ -169,8 +171,10 @@ export function parseSessionFile(path) {
|
|
|
169
171
|
const usage = message.usage;
|
|
170
172
|
if (entry.type === "assistant" && usage) {
|
|
171
173
|
const total = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
172
|
-
if (total > 0)
|
|
174
|
+
if (total > 0) {
|
|
173
175
|
reportedInputTokens = total;
|
|
176
|
+
usageSamples.push({ index: messages.length, input: total });
|
|
177
|
+
}
|
|
174
178
|
}
|
|
175
179
|
if (entry.isCompactSummary)
|
|
176
180
|
lastCompactIndex = messages.length;
|
|
@@ -188,6 +192,11 @@ export function parseSessionFile(path) {
|
|
|
188
192
|
messageCount: live.length,
|
|
189
193
|
compactedAway,
|
|
190
194
|
reportedInputTokens,
|
|
195
|
+
// Samples before the compaction boundary describe a context that no longer
|
|
196
|
+
// exists; re-base the rest onto the live array.
|
|
197
|
+
usageSamples: usageSamples
|
|
198
|
+
.filter((u) => u.index >= compactedAway)
|
|
199
|
+
.map((u) => ({ index: u.index - compactedAway, input: u.input })),
|
|
191
200
|
path,
|
|
192
201
|
};
|
|
193
202
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude, GPT, Gemini, and any MCP-capable AI app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
|
|
43
43
|
"prepublishOnly": "npm run build",
|
|
44
44
|
"dev": "tsc --watch",
|
|
45
|
-
"test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/proxy-abort.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js dist/test/cache.test.js dist/test/session.test.js"
|
|
45
|
+
"test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/proxy-abort.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js dist/test/cache.test.js dist/test/session.test.js dist/test/accuracy.test.js dist/test/cache-stability.test.js dist/test/ledger.test.js"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.0.0",
|