context-doctor 0.13.5 → 0.14.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 +57 -2
- package/dist/calibration.d.ts +28 -0
- package/dist/calibration.js +75 -0
- package/dist/cli.js +62 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -2
- package/dist/doctor.js +19 -0
- package/dist/experiment.d.ts +58 -0
- package/dist/experiment.js +214 -0
- package/dist/install.d.ts +10 -1
- package/dist/install.js +67 -4
- package/dist/mcp.js +1 -1
- package/dist/optimize.d.ts +9 -0
- package/dist/optimize.js +53 -7
- package/dist/profile.d.ts +8 -0
- package/dist/profile.js +5 -1
- package/dist/proxy.js +39 -2
- package/dist/report.js +5 -0
- package/dist/statusline.d.ts +44 -0
- package/dist/statusline.js +104 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -94,6 +94,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
94
94
|
| `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 |
|
|
95
95
|
| `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**, and **where the wall clock went** per tool (from transcript timestamps, permission waits included and said so). Also reads ChatGPT data exports (`conversations.json`) |
|
|
96
96
|
| `context-doctor init [preset]` | Write a `.contextdoctorrc` from a preset (`chat`, `agent`, `batch`) — a budget you can adopt in one command and tune later |
|
|
97
|
+
| `context-doctor experiment --task "…"` | Run one task twice from the same commit, in a fresh session and forked from an `--existing` one, same model and tools; compare bill, cache split, wall clock, and whether `--check` passed. The only command here that spends money, so it caps spend per arm and refuses a dirty tree |
|
|
97
98
|
| `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 |
|
|
98
99
|
| `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 |
|
|
99
100
|
| `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
|
|
@@ -102,6 +103,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
102
103
|
| `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
|
|
103
104
|
| `context-doctor doctor` | Self-check the whole installation — one pasteable ✓/✗ diagnosis with fixes |
|
|
104
105
|
| `context-doctor dashboard` | Local savings dashboard on 127.0.0.1: tokens saved per day, sessions by context in use vs recoverable, budget status |
|
|
106
|
+
| `context-doctor statusline` | Claude Code status bar: live context vs window, cache share, cost. Wired by `install --statusline`; never overwrites a statusLine you already have |
|
|
105
107
|
| `context-doctor hook` | The every-prompt Claude Code hook (registered by `install`; you never run this yourself). Warning threshold tunable via `CONTEXT_DOCTOR_WARN_TOKENS` (default 80000) |
|
|
106
108
|
| `context-doctor-mcp` | The MCP server itself — stdio by default (what the installer wires); `--http [--port 8808] [--host H]` serves streamable HTTP at `/mcp` for URL-based clients like ChatGPT developer-mode connectors |
|
|
107
109
|
|
|
@@ -168,7 +170,7 @@ The proxy dedupes repeated content, trims stale tool results, and strips base64
|
|
|
168
170
|
[context-doctor] POST /v1/messages → 200 in 842ms | optimized 7.3k → 518 tokens (2 changes) | session total: 6.9k tokens ≈ $0.021 saved
|
|
169
171
|
```
|
|
170
172
|
|
|
171
|
-
`GET http://localhost:8787/stats` returns cumulative savings (requests, tokens, estimated USD), **exact upstream usage** read from every response (JSON and SSE), and **prompt-cache advisories** — the proxy watches your real traffic and flags big stable prefixes missing `cache_control` or prefix churn that silently re-bills the cache. Per-model behavior via `--config`:
|
|
173
|
+
`GET http://localhost:8787/stats` returns cumulative savings (requests, tokens, estimated USD), **exact upstream usage** read from every response (JSON and SSE), and **prompt-cache advisories** — the proxy watches your real traffic and flags big stable prefixes missing `cache_control` or prefix churn that silently re-bills the cache. Per-model behavior via `--config`: The advisor now says *where*: for a large system/tools prefix it names the block to mark (the last tool definition, or the last system block), and when the older messages were byte-identical to the previous request it names the exact message to put `cache_control` on, with the token count that run is re-billing each turn. Anyone who already placed breakpoints is left alone.
|
|
172
174
|
|
|
173
175
|
```json
|
|
174
176
|
{ "routes": [{ "modelPrefix": "gpt", "strategies": ["strip-base64"], "keepRecent": 4 }] }
|
|
@@ -246,6 +248,57 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
|
|
|
246
248
|
});
|
|
247
249
|
```
|
|
248
250
|
|
|
251
|
+
## Context health in Claude Code's status bar
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
context-doctor install --statusline
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Claude Code shows the first line a `statusLine` command prints, on every refresh, while you type. With this on, that line is the number that matters:
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
ctx 801k/1.0M ▮▮▮▮▮▮▮▮░░ 80% ⚠ · cache 100% · $12.34
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Live context against the model's window, a warning mark from 70%, the share served from cache, and the session's cost. It reads the size from the status payload when Claude Code provides it, and otherwise from the last 256KB of the transcript (about 1ms on a 20MB file; 80ms end to end including Node startup). It is opt-in and polite: there is only one status line, so it never overwrites one you already have, and `uninstall` removes only its own. Any failure prints nothing rather than an error.
|
|
264
|
+
|
|
265
|
+
This is the "editor status bar" roadmap item, delivered for the editor most users of this tool are actually in. A VS Code / Cursor extension for the same number remains future work.
|
|
266
|
+
|
|
267
|
+
## Does a smaller context actually help? Measure it
|
|
268
|
+
|
|
269
|
+
Everything else in this tool measures what is *in* the context. None of it can tell you whether the task succeeded, so a smaller transcript can be a cheaper failure. `experiment` is the honest test:
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
context-doctor experiment \
|
|
273
|
+
--task "Add a null check to parseHeader in src/parse.ts and make the tests pass" \
|
|
274
|
+
--check "npm test" \
|
|
275
|
+
--existing 9c6f9dc9-457b-4d09-bf6d-a499c2f2f919 \
|
|
276
|
+
--model sonnet --budget 1
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
It runs the task in a **fresh** session, resets the tree to the starting commit, then runs it again **forked from your existing session** (`--resume … --fork-session`, so your real session is never touched), same model, same tools. For each arm it records what you were billed (input, cache read, cache write, output), cost, wall clock, turns, and whether your `--check` command passed, then puts them side by side:
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
fresh existing
|
|
283
|
+
──────────────────────────────────────────────────
|
|
284
|
+
billed input 20k 65k
|
|
285
|
+
of which cache read 0 60k
|
|
286
|
+
of which cache write 8.0k 4.0k
|
|
287
|
+
cost $0.110 $0.420
|
|
288
|
+
wall clock 4s 9s
|
|
289
|
+
check PASS FAIL (1)
|
|
290
|
+
|
|
291
|
+
Verdict: fresh was cheaper AND passed; existing failed the check. Clear win for fresh.
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
The verdict line is the point: cheaper only counts if it also passed. Because this spends your Claude budget it caps spend per arm (`--budget`, default $1), refuses to start on a dirty tree (both arms must begin from one commit, and the tree is reset between them), and refuses to run from inside a Claude Code session, where the CLI cannot start. `--dry-run` shows the exact commands first.
|
|
295
|
+
|
|
296
|
+
## Exact counts, and what they teach the estimator
|
|
297
|
+
|
|
298
|
+
The default token count is a chars-per-token heuristic so everything runs with no key and no tokenizer. Its error is content-dependent, and there is no honest way to fix that from transcripts alone (the billed number includes content the transcript never sees). `analyze --exact` fetches a true count for the exact bytes just estimated (Anthropic's count-tokens API with `ANTHROPIC_API_KEY`; tiktoken for GPT if installed) and prints the drift.
|
|
299
|
+
|
|
300
|
+
Since 0.13.9 it also **remembers the comparison**, per model family, on this machine, and later estimates for that family are scaled by it. Nothing about this is silent: the profile header says `estimates calibrated +12% from 3 exact count(s) you ran on this machine`. No exact count ever run means no calibration and unchanged numbers; out-of-range samples are ignored; `CONTEXT_DOCTOR_NO_CALIBRATION=1` returns to the raw heuristic.
|
|
301
|
+
|
|
249
302
|
## What it detects
|
|
250
303
|
|
|
251
304
|
- **Oversized tool results** — the #1 context killer in agent loops
|
|
@@ -270,10 +323,12 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
|
|
|
270
323
|
| `trim-tool-calls` — shrink the arguments of calls that already ran | Mostly no | opt-in |
|
|
271
324
|
| `prune-history` — collapse old turns into a stub for summarization | Yes | opt-in |
|
|
272
325
|
|
|
273
|
-
`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.
|
|
326
|
+
`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, and that is measured, not cautious: across 42 real sessions, of 73 large writes 18 were later edited and **16 of those edits had no read in between**, meaning the model built the edit from its own earlier `Write` input, at a median distance of 43 messages. Trimming those would turn each into a failed edit plus a recovery read. In the offline optimizer the rest of the conversation is known, so exactly those writes are left intact and the 63% never touched again are trimmed; in the live proxy the future is not known, which is why it stays off there unless you turn it on.
|
|
274
327
|
|
|
275
328
|
**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.
|
|
276
329
|
|
|
330
|
+
The step size is a trade-off, not a formula, and it is yours to set: `"trimBoundaryStep": 20` in `.contextdoctorrc` (default 10). Measured on a growing agent session at 400 turns: a step of 10 invalidated the cache on 21% of turns with ~2 stale results waiting on average; 20 gave 12% and ~4; 40 gave 10% and ~9. Adaptive steps were worse everywhere, because a step that changes size moves the boundary by itself. Heavy API users who lean on caching want a bigger step; interactive users who want stale output gone promptly want a smaller one.
|
|
331
|
+
|
|
277
332
|
Everything the optimizer does is inspectable: it prints exactly which messages changed and how many tokens each change saved.
|
|
278
333
|
|
|
279
334
|
**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,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic calibration from the user's own exact counts.
|
|
3
|
+
*
|
|
4
|
+
* The chars-per-token heuristic is what lets everything run with no key and
|
|
5
|
+
* no tokenizer, and its error is content-dependent: dense JSON tokenizes very
|
|
6
|
+
* differently from prose. There is no honest way to fix that from transcripts
|
|
7
|
+
* alone (the billed number includes content the transcript never sees). But
|
|
8
|
+
* when someone runs `analyze --exact`, they fetch a true count for the exact
|
|
9
|
+
* bytes the heuristic just estimated — the one clean comparison available.
|
|
10
|
+
*
|
|
11
|
+
* So that comparison is remembered, per model family, on this machine, and
|
|
12
|
+
* applied to later estimates for the same family. Nothing is applied silently:
|
|
13
|
+
* the profile carries the factor and the sample count, and the report prints
|
|
14
|
+
* them. No exact count ever run means no calibration, and the numbers are
|
|
15
|
+
* exactly what they were before.
|
|
16
|
+
*/
|
|
17
|
+
export interface Calibration {
|
|
18
|
+
/** Multiply heuristic estimates by this. 1 means uncalibrated. */
|
|
19
|
+
factor: number;
|
|
20
|
+
samples: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function calibrationPath(): string;
|
|
23
|
+
/** "claude", "gpt", "gemini", … — coarse on purpose; tokenizers are per vendor. */
|
|
24
|
+
export declare function modelFamily(model?: string): string;
|
|
25
|
+
/** Remember one exact-vs-heuristic observation. Best-effort; never throws. */
|
|
26
|
+
export declare function recordCalibration(model: string | undefined, exactTokens: number, heuristicTokens: number): void;
|
|
27
|
+
/** The factor to apply for a model, or 1 with zero samples when there is none. */
|
|
28
|
+
export declare function calibrationFor(model?: string): Calibration;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic calibration from the user's own exact counts.
|
|
3
|
+
*
|
|
4
|
+
* The chars-per-token heuristic is what lets everything run with no key and
|
|
5
|
+
* no tokenizer, and its error is content-dependent: dense JSON tokenizes very
|
|
6
|
+
* differently from prose. There is no honest way to fix that from transcripts
|
|
7
|
+
* alone (the billed number includes content the transcript never sees). But
|
|
8
|
+
* when someone runs `analyze --exact`, they fetch a true count for the exact
|
|
9
|
+
* bytes the heuristic just estimated — the one clean comparison available.
|
|
10
|
+
*
|
|
11
|
+
* So that comparison is remembered, per model family, on this machine, and
|
|
12
|
+
* applied to later estimates for the same family. Nothing is applied silently:
|
|
13
|
+
* the profile carries the factor and the sample count, and the report prints
|
|
14
|
+
* them. No exact count ever run means no calibration, and the numbers are
|
|
15
|
+
* exactly what they were before.
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { statePath } from "./ledger.js";
|
|
20
|
+
/** Anything outside this is a bad sample, not a calibration. */
|
|
21
|
+
const MIN_FACTOR = 0.5;
|
|
22
|
+
const MAX_FACTOR = 2.0;
|
|
23
|
+
export function calibrationPath() {
|
|
24
|
+
return join(dirname(statePath()), ".context-doctor-calibration.json");
|
|
25
|
+
}
|
|
26
|
+
/** "claude", "gpt", "gemini", … — coarse on purpose; tokenizers are per vendor. */
|
|
27
|
+
export function modelFamily(model) {
|
|
28
|
+
const m = (model ?? "").toLowerCase();
|
|
29
|
+
if (m.includes("claude"))
|
|
30
|
+
return "claude";
|
|
31
|
+
if (/gpt|^o\d/.test(m))
|
|
32
|
+
return "gpt";
|
|
33
|
+
if (m.includes("gemini"))
|
|
34
|
+
return "gemini";
|
|
35
|
+
return m ? m.split(/[-_/]/)[0] : "unknown";
|
|
36
|
+
}
|
|
37
|
+
function readAll() {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(calibrationPath(), "utf8"));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Remember one exact-vs-heuristic observation. Best-effort; never throws. */
|
|
46
|
+
export function recordCalibration(model, exactTokens, heuristicTokens) {
|
|
47
|
+
if (!(exactTokens > 0) || !(heuristicTokens > 0))
|
|
48
|
+
return;
|
|
49
|
+
const ratio = exactTokens / heuristicTokens;
|
|
50
|
+
if (ratio < MIN_FACTOR || ratio > MAX_FACTOR)
|
|
51
|
+
return; // a broken sample must not poison the file
|
|
52
|
+
try {
|
|
53
|
+
const all = readAll();
|
|
54
|
+
const key = modelFamily(model);
|
|
55
|
+
const rec = all[key] ?? { exactSum: 0, heuristicSum: 0, samples: 0 };
|
|
56
|
+
all[key] = { exactSum: rec.exactSum + exactTokens, heuristicSum: rec.heuristicSum + heuristicTokens, samples: rec.samples + 1 };
|
|
57
|
+
mkdirSync(dirname(calibrationPath()), { recursive: true });
|
|
58
|
+
writeFileSync(calibrationPath(), JSON.stringify(all, null, 2));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* calibration is a refinement; failing to save it must not fail the command */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The factor to apply for a model, or 1 with zero samples when there is none. */
|
|
65
|
+
export function calibrationFor(model) {
|
|
66
|
+
if (process.env.CONTEXT_DOCTOR_NO_CALIBRATION)
|
|
67
|
+
return { factor: 1, samples: 0 };
|
|
68
|
+
const rec = readAll()[modelFamily(model)];
|
|
69
|
+
if (!rec || rec.samples < 1 || rec.heuristicSum <= 0)
|
|
70
|
+
return { factor: 1, samples: 0 };
|
|
71
|
+
const factor = rec.exactSum / rec.heuristicSum;
|
|
72
|
+
if (!Number.isFinite(factor) || factor < MIN_FACTOR || factor > MAX_FACTOR)
|
|
73
|
+
return { factor: 1, samples: 0 };
|
|
74
|
+
return { factor, samples: rec.samples };
|
|
75
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -24,9 +24,12 @@ import { recordLedger } from "./ledger.js";
|
|
|
24
24
|
import { runDoctor } from "./doctor.js";
|
|
25
25
|
import { measureAccuracy, renderAccuracy } from "./accuracy.js";
|
|
26
26
|
import { renderDiff } from "./diff.js";
|
|
27
|
+
import { renderExperiment, runExperiment } from "./experiment.js";
|
|
28
|
+
import { runStatusLine } from "./statusline.js";
|
|
27
29
|
import { findPreset, PRESETS, RC_FILENAME } from "./config.js";
|
|
28
30
|
import { runWatch } from "./watch.js";
|
|
29
31
|
import { exactTokenCount } from "./exact.js";
|
|
32
|
+
import { modelFamily, recordCalibration } from "./calibration.js";
|
|
30
33
|
import { checkBudget, loadConfig } from "./config.js";
|
|
31
34
|
import { startDashboard } from "./dashboard.js";
|
|
32
35
|
import { listCursorChats, parseCursorChat } from "./cursor.js";
|
|
@@ -45,6 +48,9 @@ Usage:
|
|
|
45
48
|
context-doctor session [file] Profile a Claude Code session transcript or a
|
|
46
49
|
ChatGPT export (default: most recent; --list to browse)
|
|
47
50
|
context-doctor cursor [--list] Profile a Cursor chat from its local history
|
|
51
|
+
context-doctor statusline Claude Code status bar line: live context size, cache
|
|
52
|
+
share, cost (wired by \`install --statusline\`; reads
|
|
53
|
+
the status JSON on stdin)
|
|
48
54
|
context-doctor hook Claude Code UserPromptSubmit hook (installed
|
|
49
55
|
automatically by \`install\`; reads hook JSON on stdin)
|
|
50
56
|
context-doctor report Impact report: exact proxy savings, hook activity,
|
|
@@ -55,6 +61,9 @@ Usage:
|
|
|
55
61
|
default 8790) — charts from your own machine only
|
|
56
62
|
context-doctor init [preset] Write a .contextdoctorrc from a preset
|
|
57
63
|
(chat, agent, batch; no argument lists them)
|
|
64
|
+
context-doctor experiment --task "<t>" Run one task twice from the same commit, in a fresh
|
|
65
|
+
session and forked from an --existing one; compare
|
|
66
|
+
bill, cache, time, and whether --check passed
|
|
58
67
|
context-doctor diff <before> <after> Compare two profiles: what moved, which findings
|
|
59
68
|
were resolved, and what it saves
|
|
60
69
|
context-doctor accuracy Measure the token heuristic against the API's own
|
|
@@ -88,6 +97,13 @@ Options:
|
|
|
88
97
|
--keep-recent <n> (optimize) Messages at the tail to leave untouched (default 6)
|
|
89
98
|
--max-tool-tokens <n> (optimize) Token budget for trimmed tool results (default 300)
|
|
90
99
|
--limit <n> (accuracy) Sessions to sample (default 20)
|
|
100
|
+
--check <cmd> (experiment) Command whose exit code is the pass/fail for each arm
|
|
101
|
+
--existing <id> (experiment) Session id to fork the second arm from (never mutated)
|
|
102
|
+
--budget <usd> (experiment) Spend cap per arm (default 1)
|
|
103
|
+
--dry-run (experiment) Print the claude commands and stop
|
|
104
|
+
--allow-dirty (experiment) Skip the clean-tree check (uncommitted changes will be lost)
|
|
105
|
+
--statusline (install) Also set Claude Code's statusLine to context-doctor (never
|
|
106
|
+
overwrites a statusLine you already have)
|
|
91
107
|
--port <n> (proxy) Port to listen on (default 8787)
|
|
92
108
|
--host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
|
|
93
109
|
--config <file> (proxy) Per-route overrides: {"routes":[{"modelPrefix":"gpt","strategies":[...],
|
|
@@ -104,7 +120,7 @@ Examples:
|
|
|
104
120
|
export OPENAI_BASE_URL=http://localhost:8787/v1
|
|
105
121
|
`;
|
|
106
122
|
function parseArgs(argv) {
|
|
107
|
-
const args = { json: false, strategies: [], list: false, exact: false, redact: false, failOverBudget: false };
|
|
123
|
+
const args = { json: false, strategies: [], list: false, exact: false, redact: false, failOverBudget: false, dryRun: false, allowDirty: false, statusLine: false };
|
|
108
124
|
const positional = [];
|
|
109
125
|
for (let i = 0; i < argv.length; i++) {
|
|
110
126
|
const a = argv[i];
|
|
@@ -152,6 +168,27 @@ function parseArgs(argv) {
|
|
|
152
168
|
case "--limit":
|
|
153
169
|
args.limit = Number(argv[++i]);
|
|
154
170
|
break;
|
|
171
|
+
case "--task":
|
|
172
|
+
args.task = argv[++i];
|
|
173
|
+
break;
|
|
174
|
+
case "--check":
|
|
175
|
+
args.check = argv[++i];
|
|
176
|
+
break;
|
|
177
|
+
case "--existing":
|
|
178
|
+
args.existing = argv[++i];
|
|
179
|
+
break;
|
|
180
|
+
case "--budget":
|
|
181
|
+
args.budgetUsd = Number(argv[++i]);
|
|
182
|
+
break;
|
|
183
|
+
case "--dry-run":
|
|
184
|
+
args.dryRun = true;
|
|
185
|
+
break;
|
|
186
|
+
case "--allow-dirty":
|
|
187
|
+
args.allowDirty = true;
|
|
188
|
+
break;
|
|
189
|
+
case "--statusline":
|
|
190
|
+
args.statusLine = true;
|
|
191
|
+
break;
|
|
155
192
|
case "--host":
|
|
156
193
|
args.host = argv[++i];
|
|
157
194
|
break;
|
|
@@ -244,6 +281,22 @@ function main() {
|
|
|
244
281
|
console.log(" Gate a pull request on it with: context-doctor analyze <file> --fail-over-budget");
|
|
245
282
|
return;
|
|
246
283
|
}
|
|
284
|
+
if (args.command === "statusline") {
|
|
285
|
+
void runStatusLine();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (args.command === "experiment") {
|
|
289
|
+
if (!args.task) {
|
|
290
|
+
console.error('Usage: context-doctor experiment --task "<what to do>" [--check "<cmd>"] [--existing <session-id>] [--model m] [--budget usd] [--dry-run]');
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
const opts = { task: args.task, check: args.check, existing: args.existing, model: args.model, budgetUsd: args.budgetUsd, dryRun: args.dryRun, allowDirty: args.allowDirty };
|
|
294
|
+
const result = runExperiment(opts);
|
|
295
|
+
console.log(args.json ? JSON.stringify(result, null, 2) : renderExperiment(result, opts));
|
|
296
|
+
if (result.refused)
|
|
297
|
+
process.exitCode = 1;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
247
300
|
if (args.command === "diff") {
|
|
248
301
|
const [before, after] = args.positionals ?? [];
|
|
249
302
|
if (!before || !after) {
|
|
@@ -367,7 +420,7 @@ function main() {
|
|
|
367
420
|
if (args.command === "install") {
|
|
368
421
|
// Partial success is still installed, but not silent: any failed target
|
|
369
422
|
// makes the exit code non-zero so automation can react.
|
|
370
|
-
if (runInstall().failures.length > 0)
|
|
423
|
+
if (runInstall({ statusLine: args.statusLine }).failures.length > 0)
|
|
371
424
|
process.exitCode = 1;
|
|
372
425
|
return;
|
|
373
426
|
}
|
|
@@ -396,6 +449,7 @@ function main() {
|
|
|
396
449
|
strategies: args.strategies.length > 0 ? args.strategies : loadedRc.config.strategies,
|
|
397
450
|
keepRecent: args.keepRecent ?? loadedRc.config.keepRecent,
|
|
398
451
|
maxToolResultTokens: args.maxToolTokens ?? loadedRc.config.maxToolResultTokens,
|
|
452
|
+
trimBoundaryStep: loadedRc.config.trimBoundaryStep,
|
|
399
453
|
});
|
|
400
454
|
return; // server keeps the process alive
|
|
401
455
|
}
|
|
@@ -424,6 +478,11 @@ function main() {
|
|
|
424
478
|
if (exact.tokens !== undefined) {
|
|
425
479
|
const drift = profile.totalTokens > 0 ? Math.round(((exact.tokens - profile.totalTokens) / exact.tokens) * 100) : 0;
|
|
426
480
|
console.log(`\nExact input tokens: ${exact.tokens} (${exact.source}) — heuristic was off by ${drift}%`);
|
|
481
|
+
// Remember the comparison so the next estimate for this model family
|
|
482
|
+
// starts from the user's own ground truth instead of a constant.
|
|
483
|
+
const raw = profile.calibration ? profile.totalTokens / profile.calibration.factor : profile.totalTokens;
|
|
484
|
+
recordCalibration(args.model ?? profile.model, exact.tokens, raw);
|
|
485
|
+
console.log(`Remembered: future ${modelFamily(args.model ?? profile.model)} estimates on this machine are calibrated from this (CONTEXT_DOCTOR_NO_CALIBRATION=1 to disable).`);
|
|
427
486
|
}
|
|
428
487
|
else {
|
|
429
488
|
console.log(`\nExact count unavailable: ${exact.note}`);
|
|
@@ -440,6 +499,7 @@ function main() {
|
|
|
440
499
|
strategies: args.strategies.length > 0 ? args.strategies : loaded.config.strategies,
|
|
441
500
|
keepRecent: args.keepRecent ?? loaded.config.keepRecent,
|
|
442
501
|
maxToolResultTokens: args.maxToolTokens ?? loaded.config.maxToolResultTokens,
|
|
502
|
+
trimBoundaryStep: loaded.config.trimBoundaryStep,
|
|
443
503
|
});
|
|
444
504
|
}
|
|
445
505
|
catch (e) {
|
package/dist/config.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface ContextDoctorConfig {
|
|
|
25
25
|
strategies?: StrategyId[];
|
|
26
26
|
keepRecent?: number;
|
|
27
27
|
maxToolResultTokens?: number;
|
|
28
|
+
/** Messages the trim boundary moves at a time; see OptimizeOptions. */
|
|
29
|
+
trimBoundaryStep?: number;
|
|
28
30
|
/** Proxy per-model overrides, same shape as `proxy --config`. */
|
|
29
31
|
routes?: Array<{
|
|
30
32
|
modelPrefix: string;
|
package/dist/config.js
CHANGED
|
@@ -38,7 +38,7 @@ function candidatePaths(startDir) {
|
|
|
38
38
|
*/
|
|
39
39
|
/** Strategy ids the optimizer actually implements. */
|
|
40
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"]);
|
|
41
|
+
const KNOWN_KEYS = new Set(["budget", "strategies", "keepRecent", "maxToolResultTokens", "trimBoundaryStep", "routes", "model"]);
|
|
42
42
|
const KNOWN_BUDGET_KEYS = new Set(["maxTokens", "maxCostPerMessageUsd", "maxWindowPct"]);
|
|
43
43
|
/**
|
|
44
44
|
* Report anything in an rc file that will be silently ignored.
|
|
@@ -92,7 +92,7 @@ export function validateConfig(config, path) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
-
for (const key of ["keepRecent", "maxToolResultTokens"]) {
|
|
95
|
+
for (const key of ["keepRecent", "maxToolResultTokens", "trimBoundaryStep"]) {
|
|
96
96
|
const value = c[key];
|
|
97
97
|
if (value === undefined)
|
|
98
98
|
continue;
|
package/dist/doctor.js
CHANGED
|
@@ -135,6 +135,25 @@ export async function runDoctor() {
|
|
|
135
135
|
else {
|
|
136
136
|
checks.push({ label: "Every-prompt hook", status: "skip", detail: "Claude Code not detected" });
|
|
137
137
|
}
|
|
138
|
+
// Status line (opt-in, so absence is a note, not a failure)
|
|
139
|
+
if (existsSync(settingsPath)) {
|
|
140
|
+
try {
|
|
141
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
142
|
+
const cmd = settings.statusLine?.command;
|
|
143
|
+
if (cmd && /context-doctor|cli\.js"?\s+statusline/.test(cmd)) {
|
|
144
|
+
const missing = hookBinaryMissing(cmd);
|
|
145
|
+
checks.push(missing
|
|
146
|
+
? { label: "Status line", status: "fail", detail: `configured, but ${missing} no longer exists — re-run: context-doctor install --statusline` }
|
|
147
|
+
: { label: "Status line", status: "ok", detail: "live context in Claude Code's status bar" });
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
checks.push({ label: "Status line", status: "skip", detail: cmd ? "you have your own statusLine (left alone)" : "not enabled (optional: context-doctor install --statusline)" });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
/* settings.json unreadable is already reported by the hook check */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
138
157
|
// Skill
|
|
139
158
|
const skillPath = join(homedir(), ".claude", "skills", "context-doctor", "SKILL.md");
|
|
140
159
|
checks.push(existsSync(skillPath)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor experiment` — the fresh-vs-existing session harness.
|
|
3
|
+
*
|
|
4
|
+
* Everything else in this tool measures what is IN the context. None of it can
|
|
5
|
+
* say whether the task succeeded, so a smaller transcript can be a cheaper
|
|
6
|
+
* failure. This runs the same task twice against the same starting commit —
|
|
7
|
+
* once in a fresh session, once forked from an existing one — with the same
|
|
8
|
+
* model and tools, records what each was billed and how long it took, runs the
|
|
9
|
+
* same check command against each result, and puts the two side by side.
|
|
10
|
+
*
|
|
11
|
+
* It spends the user's Claude budget, so it refuses to start on a dirty tree,
|
|
12
|
+
* caps spend per arm, forks the existing session rather than mutating it, and
|
|
13
|
+
* resets the tree between arms only because it verified the tree was clean.
|
|
14
|
+
*/
|
|
15
|
+
export interface ExperimentOptions {
|
|
16
|
+
task: string;
|
|
17
|
+
/** Shell command whose exit code decides pass/fail, e.g. "npm test". */
|
|
18
|
+
check?: string;
|
|
19
|
+
/** Session id to fork the "existing" arm from. Omit to run the fresh arm only. */
|
|
20
|
+
existing?: string;
|
|
21
|
+
model?: string;
|
|
22
|
+
/** Spend cap per arm, USD. */
|
|
23
|
+
budgetUsd?: number;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
/** Print the commands and stop. */
|
|
26
|
+
dryRun?: boolean;
|
|
27
|
+
/** Skip the clean-tree requirement (you accept the reset that follows). */
|
|
28
|
+
allowDirty?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface ArmResult {
|
|
31
|
+
arm: "fresh" | "existing";
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
costUsd: number;
|
|
34
|
+
durationMs: number;
|
|
35
|
+
turns: number;
|
|
36
|
+
inputTokens: number;
|
|
37
|
+
cacheRead: number;
|
|
38
|
+
cacheWrite: number;
|
|
39
|
+
outputTokens: number;
|
|
40
|
+
/** Billed input the transcript reports on the last request, when found. */
|
|
41
|
+
liveContextTokens?: number;
|
|
42
|
+
check?: {
|
|
43
|
+
command: string;
|
|
44
|
+
passed: boolean;
|
|
45
|
+
exitCode: number;
|
|
46
|
+
};
|
|
47
|
+
diffStat?: string;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
/** Build the argv for one arm — exported so the dry run and the tests see exactly what runs. */
|
|
51
|
+
export declare function claudeArgs(opts: ExperimentOptions, arm: "fresh" | "existing"): string[];
|
|
52
|
+
export declare function runExperiment(opts: ExperimentOptions): {
|
|
53
|
+
arms: ArmResult[];
|
|
54
|
+
startCommit?: string;
|
|
55
|
+
refused?: string;
|
|
56
|
+
treeClean?: boolean;
|
|
57
|
+
};
|
|
58
|
+
export declare function renderExperiment(result: ReturnType<typeof runExperiment>, opts: ExperimentOptions): string;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor experiment` — the fresh-vs-existing session harness.
|
|
3
|
+
*
|
|
4
|
+
* Everything else in this tool measures what is IN the context. None of it can
|
|
5
|
+
* say whether the task succeeded, so a smaller transcript can be a cheaper
|
|
6
|
+
* failure. This runs the same task twice against the same starting commit —
|
|
7
|
+
* once in a fresh session, once forked from an existing one — with the same
|
|
8
|
+
* model and tools, records what each was billed and how long it took, runs the
|
|
9
|
+
* same check command against each result, and puts the two side by side.
|
|
10
|
+
*
|
|
11
|
+
* It spends the user's Claude budget, so it refuses to start on a dirty tree,
|
|
12
|
+
* caps spend per arm, forks the existing session rather than mutating it, and
|
|
13
|
+
* resets the tree between arms only because it verified the tree was clean.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { parseSessionFile } from "./session.js";
|
|
20
|
+
import { formatTokens } from "./tokens.js";
|
|
21
|
+
import { formatUsd } from "./pricing.js";
|
|
22
|
+
/** Where a test can point at a stub instead of the real CLI. */
|
|
23
|
+
function claudeBinary() {
|
|
24
|
+
return process.env.CONTEXT_DOCTOR_CLAUDE_BIN ?? "claude";
|
|
25
|
+
}
|
|
26
|
+
function git(cwd, ...args) {
|
|
27
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
28
|
+
}
|
|
29
|
+
function num(v) {
|
|
30
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
31
|
+
}
|
|
32
|
+
/** Build the argv for one arm — exported so the dry run and the tests see exactly what runs. */
|
|
33
|
+
export function claudeArgs(opts, arm) {
|
|
34
|
+
const args = ["-p", opts.task, "--output-format", "json", "--permission-mode", "acceptEdits"];
|
|
35
|
+
if (opts.model)
|
|
36
|
+
args.push("--model", opts.model);
|
|
37
|
+
args.push("--max-budget-usd", String(opts.budgetUsd ?? 1));
|
|
38
|
+
if (arm === "existing" && opts.existing)
|
|
39
|
+
args.push("--resume", opts.existing, "--fork-session");
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
function findTranscript(sessionId, cwd) {
|
|
43
|
+
// Claude Code keys project dirs by the cwd with separators replaced.
|
|
44
|
+
const projectDir = join(homedir(), ".claude", "projects", cwd.replace(/[\\/:]/g, "-"));
|
|
45
|
+
const candidate = join(projectDir, `${sessionId}.jsonl`);
|
|
46
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
47
|
+
}
|
|
48
|
+
function runArm(opts, arm, cwd) {
|
|
49
|
+
const started = Date.now();
|
|
50
|
+
const proc = spawnSync(claudeBinary(), claudeArgs(opts, arm), {
|
|
51
|
+
cwd,
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
54
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
55
|
+
});
|
|
56
|
+
const base = {
|
|
57
|
+
arm, costUsd: 0, durationMs: Date.now() - started, turns: 0,
|
|
58
|
+
inputTokens: 0, cacheRead: 0, cacheWrite: 0, outputTokens: 0,
|
|
59
|
+
};
|
|
60
|
+
if (proc.error)
|
|
61
|
+
return { ...base, error: `could not run ${claudeBinary()}: ${proc.error.message}` };
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(proc.stdout);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { ...base, error: `claude did not return JSON (exit ${proc.status}): ${(proc.stderr || proc.stdout).slice(0, 300)}` };
|
|
68
|
+
}
|
|
69
|
+
const usage = parsed.usage ?? {};
|
|
70
|
+
const result = {
|
|
71
|
+
...base,
|
|
72
|
+
sessionId: parsed.session_id,
|
|
73
|
+
costUsd: num(parsed.total_cost_usd),
|
|
74
|
+
durationMs: num(parsed.duration_ms) || base.durationMs,
|
|
75
|
+
turns: num(parsed.num_turns),
|
|
76
|
+
inputTokens: num(usage.input_tokens),
|
|
77
|
+
cacheRead: num(usage.cache_read_input_tokens),
|
|
78
|
+
cacheWrite: num(usage.cache_creation_input_tokens),
|
|
79
|
+
outputTokens: num(usage.output_tokens),
|
|
80
|
+
error: parsed.is_error ? `claude reported an error: ${String(parsed.result ?? "").slice(0, 300)}` : undefined,
|
|
81
|
+
};
|
|
82
|
+
if (parsed.session_id) {
|
|
83
|
+
const transcript = findTranscript(parsed.session_id, cwd);
|
|
84
|
+
if (transcript) {
|
|
85
|
+
try {
|
|
86
|
+
result.liveContextTokens = parseSessionFile(transcript).reportedInputTokens;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* the numbers above stand on their own */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (opts.check) {
|
|
94
|
+
const check = spawnSync(opts.check, { cwd, shell: true, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
95
|
+
result.check = { command: opts.check, passed: check.status === 0, exitCode: check.status ?? -1 };
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
result.diffStat = git(cwd, "diff", "--stat") || "(no changes)";
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* not a git repo after all; the arm still ran */
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
export function runExperiment(opts) {
|
|
106
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
107
|
+
if (process.env.CLAUDECODE && !opts.dryRun) {
|
|
108
|
+
return { arms: [], refused: "This runs the claude CLI, which refuses to start inside another Claude Code session. Run the experiment from a regular terminal." };
|
|
109
|
+
}
|
|
110
|
+
let startCommit;
|
|
111
|
+
let clean = false;
|
|
112
|
+
try {
|
|
113
|
+
startCommit = git(cwd, "rev-parse", "HEAD");
|
|
114
|
+
clean = git(cwd, "status", "--porcelain") === "";
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return { arms: [], refused: `${cwd} is not a git repository. The experiment needs a commit to reset to between arms.` };
|
|
118
|
+
}
|
|
119
|
+
// A dry run touches nothing, so a dirty tree only needs mentioning.
|
|
120
|
+
if (opts.dryRun)
|
|
121
|
+
return { arms: [], startCommit, treeClean: clean };
|
|
122
|
+
if (!clean && !opts.allowDirty) {
|
|
123
|
+
return { arms: [], refused: "Working tree has uncommitted changes. Both arms must start from the same commit, and the tree is reset between them — commit or stash first (or pass --allow-dirty to accept losing those changes)." };
|
|
124
|
+
}
|
|
125
|
+
const arms = [];
|
|
126
|
+
const reset = () => {
|
|
127
|
+
// Safe only because the tree was verified clean (or the user opted in).
|
|
128
|
+
git(cwd, "reset", "--hard", startCommit);
|
|
129
|
+
git(cwd, "clean", "-fd");
|
|
130
|
+
};
|
|
131
|
+
arms.push(runArm(opts, "fresh", cwd));
|
|
132
|
+
reset();
|
|
133
|
+
if (opts.existing) {
|
|
134
|
+
arms.push(runArm(opts, "existing", cwd));
|
|
135
|
+
reset();
|
|
136
|
+
}
|
|
137
|
+
return { arms, startCommit };
|
|
138
|
+
}
|
|
139
|
+
export function renderExperiment(result, opts) {
|
|
140
|
+
const lines = [];
|
|
141
|
+
lines.push("CONTEXT DOCTOR — fresh vs existing session");
|
|
142
|
+
lines.push("═".repeat(56));
|
|
143
|
+
if (result.refused) {
|
|
144
|
+
lines.push(`Refused: ${result.refused}`);
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
}
|
|
147
|
+
lines.push(`task: ${opts.task}`);
|
|
148
|
+
if (result.startCommit)
|
|
149
|
+
lines.push(`commit: ${result.startCommit.slice(0, 12)} (both arms start here)`);
|
|
150
|
+
if (opts.model)
|
|
151
|
+
lines.push(`model: ${opts.model}`);
|
|
152
|
+
if (opts.check)
|
|
153
|
+
lines.push(`check: ${opts.check}`);
|
|
154
|
+
lines.push(`budget: ${formatUsd(opts.budgetUsd ?? 1)} per arm`);
|
|
155
|
+
if (opts.dryRun) {
|
|
156
|
+
lines.push("");
|
|
157
|
+
lines.push("Dry run. Commands that would execute:");
|
|
158
|
+
lines.push(` fresh: claude ${claudeArgs(opts, "fresh").map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`);
|
|
159
|
+
if (opts.existing)
|
|
160
|
+
lines.push(` existing: claude ${claudeArgs(opts, "existing").map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`);
|
|
161
|
+
lines.push(" (tree is reset to the start commit after each arm)");
|
|
162
|
+
if (result.treeClean === false)
|
|
163
|
+
lines.push(" NOTE: the tree is dirty right now; a real run would refuse unless you commit, stash, or pass --allow-dirty.");
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
lines.push("");
|
|
167
|
+
const col = (s, w = 14) => s.padStart(w);
|
|
168
|
+
const header = `${"".padEnd(22)}${col("fresh")}${opts.existing ? col("existing") : ""}`;
|
|
169
|
+
lines.push(header);
|
|
170
|
+
lines.push("─".repeat(header.length));
|
|
171
|
+
const row = (label, pick) => {
|
|
172
|
+
lines.push(`${label.padEnd(22)}${result.arms.map((a) => col(pick(a))).join("")}`);
|
|
173
|
+
};
|
|
174
|
+
row("billed input", (a) => formatTokens(a.inputTokens + a.cacheRead + a.cacheWrite));
|
|
175
|
+
row(" of which cache read", (a) => formatTokens(a.cacheRead));
|
|
176
|
+
row(" of which cache write", (a) => formatTokens(a.cacheWrite));
|
|
177
|
+
row("output tokens", (a) => formatTokens(a.outputTokens));
|
|
178
|
+
row("cost", (a) => formatUsd(a.costUsd));
|
|
179
|
+
row("wall clock", (a) => `${(a.durationMs / 1000).toFixed(0)}s`);
|
|
180
|
+
row("turns", (a) => String(a.turns));
|
|
181
|
+
if (result.arms.some((a) => a.liveContextTokens))
|
|
182
|
+
row("live context (last)", (a) => (a.liveContextTokens ? formatTokens(a.liveContextTokens) : "—"));
|
|
183
|
+
if (opts.check)
|
|
184
|
+
row("check", (a) => (a.check ? (a.check.passed ? "PASS" : `FAIL (${a.check.exitCode})`) : "—"));
|
|
185
|
+
lines.push("");
|
|
186
|
+
for (const a of result.arms) {
|
|
187
|
+
if (a.error)
|
|
188
|
+
lines.push(`${a.arm}: ${a.error}`);
|
|
189
|
+
if (a.diffStat && a.diffStat !== "(no changes)")
|
|
190
|
+
lines.push(`${a.arm} changed:\n${a.diffStat.split("\n").map((l) => " " + l).join("\n")}`);
|
|
191
|
+
}
|
|
192
|
+
// The verdict is the whole point: cheaper is only better if it also passed.
|
|
193
|
+
if (opts.existing && result.arms.length === 2 && opts.check) {
|
|
194
|
+
const [fresh, existing] = result.arms;
|
|
195
|
+
const cheaper = fresh.costUsd <= existing.costUsd ? fresh : existing;
|
|
196
|
+
const other = cheaper === fresh ? existing : fresh;
|
|
197
|
+
if (cheaper.check?.passed && !other.check?.passed) {
|
|
198
|
+
lines.push(`Verdict: ${cheaper.arm} was cheaper AND passed; ${other.arm} failed the check. Clear win for ${cheaper.arm}.`);
|
|
199
|
+
}
|
|
200
|
+
else if (cheaper.check?.passed && other.check?.passed) {
|
|
201
|
+
lines.push(`Verdict: both passed; ${cheaper.arm} was cheaper by ${formatUsd(Math.abs(fresh.costUsd - existing.costUsd))}.`);
|
|
202
|
+
}
|
|
203
|
+
else if (!cheaper.check?.passed && other.check?.passed) {
|
|
204
|
+
lines.push(`Verdict: ${cheaper.arm} was cheaper but FAILED the check. A smaller bill for a wrong answer is not a saving; ${other.arm} wins.`);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
lines.push("Verdict: neither arm passed the check. Cost comparison is moot until one does.");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else if (opts.check) {
|
|
211
|
+
lines.push("One arm only. Pass --existing <session-id> to compare against a forked existing session.");
|
|
212
|
+
}
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|
package/dist/install.d.ts
CHANGED
|
@@ -20,6 +20,13 @@ export declare function npxLauncher(platformName: string): {
|
|
|
20
20
|
command: string;
|
|
21
21
|
args: string[];
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Claude Code's status bar: a `statusLine` command whose stdout is shown while
|
|
25
|
+
* the user types. Opt-in, because there is only one status line and it may
|
|
26
|
+
* already be someone's own — this never overwrites a statusLine that is not
|
|
27
|
+
* ours. Returns what happened so install can print the truth.
|
|
28
|
+
*/
|
|
29
|
+
export declare function installStatusLine(): "installed" | "already" | "kept-foreign" | "no-claude-code";
|
|
23
30
|
/** Outcome of an install run, so the CLI can set a truthful exit code. */
|
|
24
31
|
export interface InstallResult {
|
|
25
32
|
/** Detected targets that could not be configured, with the reason. */
|
|
@@ -35,5 +42,7 @@ export interface InstallResult {
|
|
|
35
42
|
* failed target is a lie that surfaces later as "the tools never showed up".
|
|
36
43
|
* So: keep going, summarize, and return the failures for a non-zero exit.
|
|
37
44
|
*/
|
|
38
|
-
export declare function runInstall(
|
|
45
|
+
export declare function runInstall(options?: {
|
|
46
|
+
statusLine?: boolean;
|
|
47
|
+
}): InstallResult;
|
|
39
48
|
export declare function runUninstall(): void;
|
package/dist/install.js
CHANGED
|
@@ -122,14 +122,54 @@ function isEphemeralPath(path) {
|
|
|
122
122
|
* prompt. `node` (not process.execPath) keeps it alive across Node upgrades.
|
|
123
123
|
*/
|
|
124
124
|
function hookCommand() {
|
|
125
|
+
return cliCommand("hook");
|
|
126
|
+
}
|
|
127
|
+
/** The same durable command resolution, for any subcommand Claude Code runs for us. */
|
|
128
|
+
function cliCommand(subcommand) {
|
|
125
129
|
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
126
130
|
const localCli = join(selfDir, "cli.js");
|
|
127
131
|
if (!isEphemeralPath(selfDir + sep) && existsSync(localCli))
|
|
128
|
-
return `node "${localCli}"
|
|
132
|
+
return `node "${localCli}" ${subcommand}`;
|
|
129
133
|
const global = binOnPath("context-doctor");
|
|
130
134
|
if (global)
|
|
131
|
-
return `"${global}"
|
|
132
|
-
return
|
|
135
|
+
return `"${global}" ${subcommand}`;
|
|
136
|
+
return `npx -y context-doctor ${subcommand}`;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Claude Code's status bar: a `statusLine` command whose stdout is shown while
|
|
140
|
+
* the user types. Opt-in, because there is only one status line and it may
|
|
141
|
+
* already be someone's own — this never overwrites a statusLine that is not
|
|
142
|
+
* ours. Returns what happened so install can print the truth.
|
|
143
|
+
*/
|
|
144
|
+
export function installStatusLine() {
|
|
145
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
146
|
+
if (!existsSync(join(homedir(), ".claude")))
|
|
147
|
+
return "no-claude-code";
|
|
148
|
+
const settings = readJson(settingsPath);
|
|
149
|
+
const want = cliCommand("statusline");
|
|
150
|
+
const current = settings.statusLine?.command;
|
|
151
|
+
if (current === want)
|
|
152
|
+
return "already";
|
|
153
|
+
if (current && !isOurStatusLine(current))
|
|
154
|
+
return "kept-foreign";
|
|
155
|
+
settings.statusLine = { type: "command", command: want };
|
|
156
|
+
writeJsonWithBackup(settingsPath, settings);
|
|
157
|
+
return "installed";
|
|
158
|
+
}
|
|
159
|
+
function isOurStatusLine(command) {
|
|
160
|
+
return /context-doctor|cli\.js"?\s+statusline\s*$/.test(command);
|
|
161
|
+
}
|
|
162
|
+
function uninstallStatusLine() {
|
|
163
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
164
|
+
if (!existsSync(settingsPath))
|
|
165
|
+
return;
|
|
166
|
+
const settings = readJson(settingsPath);
|
|
167
|
+
const current = settings.statusLine?.command;
|
|
168
|
+
if (current && isOurStatusLine(current)) {
|
|
169
|
+
delete settings.statusLine;
|
|
170
|
+
writeJsonWithBackup(settingsPath, settings);
|
|
171
|
+
console.log("✓ Claude Code status line removed");
|
|
172
|
+
}
|
|
133
173
|
}
|
|
134
174
|
/** True when the hook had to fall back to npx — worth telling the user. */
|
|
135
175
|
function hookUsesNpx() {
|
|
@@ -224,7 +264,7 @@ function installSkill() {
|
|
|
224
264
|
* failed target is a lie that surfaces later as "the tools never showed up".
|
|
225
265
|
* So: keep going, summarize, and return the failures for a non-zero exit.
|
|
226
266
|
*/
|
|
227
|
-
export function runInstall() {
|
|
267
|
+
export function runInstall(options = {}) {
|
|
228
268
|
const entry = serverEntry();
|
|
229
269
|
const found = targets().filter((t) => t.detect());
|
|
230
270
|
if (found.length === 0) {
|
|
@@ -274,6 +314,28 @@ export function runInstall() {
|
|
|
274
314
|
console.error(`✗ Claude Code every-prompt hook: ${e.message}`);
|
|
275
315
|
failures.push("Claude Code hook");
|
|
276
316
|
}
|
|
317
|
+
if (options.statusLine) {
|
|
318
|
+
try {
|
|
319
|
+
switch (installStatusLine()) {
|
|
320
|
+
case "installed":
|
|
321
|
+
console.log("✓ Claude Code status line installed — live context size, cache share and cost while you type");
|
|
322
|
+
break;
|
|
323
|
+
case "already":
|
|
324
|
+
console.log("✓ Claude Code status line already installed");
|
|
325
|
+
break;
|
|
326
|
+
case "kept-foreign":
|
|
327
|
+
console.log("– Claude Code status line left alone: you already have your own statusLine command. Remove it first if you want ours.");
|
|
328
|
+
break;
|
|
329
|
+
case "no-claude-code":
|
|
330
|
+
console.log("– status line skipped: Claude Code not detected");
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
console.error(`✗ Claude Code status line: ${e.message}`);
|
|
336
|
+
failures.push("Claude Code status line");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
277
339
|
if (failures.length > 0) {
|
|
278
340
|
console.log(`\nDone with ${failures.length} problem(s): ${failures.join(", ")}. See the ✗ lines above.`);
|
|
279
341
|
console.log("Everything else was installed. Exit code is 1 so scripts can tell; fix the file(s) and re-run install.");
|
|
@@ -306,6 +368,7 @@ export function runUninstall() {
|
|
|
306
368
|
console.log("✓ Agent Skill removed");
|
|
307
369
|
}
|
|
308
370
|
uninstallHook();
|
|
371
|
+
uninstallStatusLine();
|
|
309
372
|
// Remove our bookkeeping files too — uninstall means gone.
|
|
310
373
|
for (const file of [".context-doctor-hook-state.json", ".context-doctor-ledger.jsonl"]) {
|
|
311
374
|
const p = join(homedir(), ".claude", file);
|
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.14.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.d.ts
CHANGED
|
@@ -13,6 +13,15 @@ export interface OptimizeOptions {
|
|
|
13
13
|
keepRecent?: number;
|
|
14
14
|
/** Max tokens a trimmed tool result — or tool-call argument set — keeps. */
|
|
15
15
|
maxToolResultTokens?: number;
|
|
16
|
+
/**
|
|
17
|
+
* How many messages the trim boundary moves at a time. Bigger steps keep the
|
|
18
|
+
* prompt cache alive longer but leave stale results in place longer.
|
|
19
|
+
* Measured on a growing agent session: at 400 turns a step of 10 invalidated
|
|
20
|
+
* the cache on 21% of turns with ~2 stale results waiting on average; 20
|
|
21
|
+
* gave 12% and ~4; 40 gave 10% and ~9. Adaptive steps were worse everywhere,
|
|
22
|
+
* because a step that changes size moves the boundary by itself. Default 10.
|
|
23
|
+
*/
|
|
24
|
+
trimBoundaryStep?: number;
|
|
16
25
|
}
|
|
17
26
|
export interface AppliedChange {
|
|
18
27
|
strategy: StrategyId;
|
package/dist/optimize.js
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { estimateTokens } from "./tokens.js";
|
|
11
11
|
import { hasBase64Blob, stripBase64Blobs } from "./blob.js";
|
|
12
|
+
const TRIM_BOUNDARY_STEP = 10;
|
|
12
13
|
const DEFAULTS = {
|
|
13
14
|
strategies: ["dedupe", "trim-tool-results", "strip-base64"],
|
|
14
15
|
keepRecent: 6,
|
|
15
16
|
maxToolResultTokens: 300,
|
|
17
|
+
trimBoundaryStep: TRIM_BOUNDARY_STEP,
|
|
16
18
|
};
|
|
17
19
|
/**
|
|
18
20
|
* Shrink the arguments of a tool call that has already run.
|
|
@@ -25,6 +27,42 @@ const DEFAULTS = {
|
|
|
25
27
|
* Keys are preserved (so the call still reads as itself) and only long string
|
|
26
28
|
* values are cut, with an explicit marker so nothing looks silently complete.
|
|
27
29
|
*/
|
|
30
|
+
/** The file a Write-like call creates, or null for anything else. */
|
|
31
|
+
function writtenPath(block) {
|
|
32
|
+
if (block?.name === "Write" && typeof block.input?.file_path === "string")
|
|
33
|
+
return block.input.file_path;
|
|
34
|
+
const cmd = block?.name === "Bash" ? String(block.input?.command ?? "") : "";
|
|
35
|
+
const heredoc = /(?:cat|tee)\s*>+\s*([^\s<]+)\s*<</.exec(cmd);
|
|
36
|
+
return heredoc ? heredoc[1] : null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Does the model later Edit this file without Reading it first? If so it is
|
|
40
|
+
* relying on the Write input still being in context, and trimming that input
|
|
41
|
+
* would break the Edit.
|
|
42
|
+
*/
|
|
43
|
+
function editedLaterWithoutRead(messages, writeIndex, path) {
|
|
44
|
+
if (!path)
|
|
45
|
+
return false;
|
|
46
|
+
for (let j = writeIndex + 1; j < messages.length; j++) {
|
|
47
|
+
const content = messages[j]?.content;
|
|
48
|
+
if (!Array.isArray(content))
|
|
49
|
+
continue;
|
|
50
|
+
for (const b of content) {
|
|
51
|
+
if (b?.type !== "tool_use")
|
|
52
|
+
continue;
|
|
53
|
+
const target = b.input?.file_path;
|
|
54
|
+
const cmd = b.name === "Bash" ? String(b.input?.command ?? "") : "";
|
|
55
|
+
const touchesPath = target === path || cmd.includes(path);
|
|
56
|
+
if (!touchesPath)
|
|
57
|
+
continue;
|
|
58
|
+
if (b.name === "Read" || /\b(cat|head|tail|sed|less)\b/.test(cmd))
|
|
59
|
+
return false; // it re-read: safe
|
|
60
|
+
if (b.name === "Edit" || b.name === "MultiEdit")
|
|
61
|
+
return true; // edited blind: keep the Write
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
28
66
|
function trimCallArguments(input, maxTokens) {
|
|
29
67
|
const budgetChars = maxTokens * 4;
|
|
30
68
|
const out = {};
|
|
@@ -54,17 +92,16 @@ function trimCallArguments(input, maxTokens) {
|
|
|
54
92
|
* STEP turns instead of once per turn. Older content is trimmed slightly later
|
|
55
93
|
* than it otherwise would be; that is much cheaper than losing the cache.
|
|
56
94
|
*/
|
|
57
|
-
|
|
58
|
-
function stableCutoff(messageCount, keepRecent) {
|
|
95
|
+
function stableCutoff(messageCount, keepRecent, step = TRIM_BOUNDARY_STEP) {
|
|
59
96
|
const raw = messageCount - keepRecent;
|
|
60
97
|
if (raw <= 0)
|
|
61
98
|
return 0;
|
|
62
99
|
// Below one step there is nothing to quantize to except zero, which would
|
|
63
100
|
// silently disable trimming on every short conversation. Such a conversation
|
|
64
101
|
// has no long stable prefix worth protecting anyway.
|
|
65
|
-
if (raw <
|
|
102
|
+
if (raw < step)
|
|
66
103
|
return raw;
|
|
67
|
-
return Math.floor(raw /
|
|
104
|
+
return Math.floor(raw / step) * step;
|
|
68
105
|
}
|
|
69
106
|
function hash(text) {
|
|
70
107
|
return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
|
|
@@ -171,6 +208,7 @@ export function optimizeConversation(input, options = {}) {
|
|
|
171
208
|
strategies: options.strategies ?? DEFAULTS.strategies,
|
|
172
209
|
keepRecent: options.keepRecent ?? DEFAULTS.keepRecent,
|
|
173
210
|
maxToolResultTokens: options.maxToolResultTokens ?? DEFAULTS.maxToolResultTokens,
|
|
211
|
+
trimBoundaryStep: options.trimBoundaryStep && options.trimBoundaryStep > 0 ? Math.floor(options.trimBoundaryStep) : DEFAULTS.trimBoundaryStep,
|
|
174
212
|
};
|
|
175
213
|
let data;
|
|
176
214
|
try {
|
|
@@ -216,7 +254,7 @@ export function optimizeConversation(input, options = {}) {
|
|
|
216
254
|
// their CURRENT question swapped for a pointer to a message ten turns back,
|
|
217
255
|
// and just sees a worse answer with no explanation. Older copies are fair
|
|
218
256
|
// game; the live turn is not.
|
|
219
|
-
const cutoff = stableCutoff(messages.length, opts.keepRecent);
|
|
257
|
+
const cutoff = stableCutoff(messages.length, opts.keepRecent, opts.trimBoundaryStep);
|
|
220
258
|
const seen = new Map();
|
|
221
259
|
messages.forEach((m, i) => {
|
|
222
260
|
const text = textOf(m.content);
|
|
@@ -241,7 +279,7 @@ export function optimizeConversation(input, options = {}) {
|
|
|
241
279
|
}
|
|
242
280
|
// -- trim-tool-results: shrink stale tool output ------------------------------
|
|
243
281
|
if (opts.strategies.includes("trim-tool-results")) {
|
|
244
|
-
const cutoff = stableCutoff(messages.length, opts.keepRecent);
|
|
282
|
+
const cutoff = stableCutoff(messages.length, opts.keepRecent, opts.trimBoundaryStep);
|
|
245
283
|
messages.forEach((m, i) => {
|
|
246
284
|
if (i >= cutoff || !isToolResultMessage(m))
|
|
247
285
|
return;
|
|
@@ -266,7 +304,7 @@ export function optimizeConversation(input, options = {}) {
|
|
|
266
304
|
}
|
|
267
305
|
// -- trim-tool-calls: shrink the arguments of calls that already ran ----------
|
|
268
306
|
if (opts.strategies.includes("trim-tool-calls")) {
|
|
269
|
-
const cutoff = stableCutoff(messages.length, opts.keepRecent);
|
|
307
|
+
const cutoff = stableCutoff(messages.length, opts.keepRecent, opts.trimBoundaryStep);
|
|
270
308
|
messages.forEach((m, i) => {
|
|
271
309
|
if (i >= cutoff)
|
|
272
310
|
return;
|
|
@@ -276,6 +314,14 @@ export function optimizeConversation(input, options = {}) {
|
|
|
276
314
|
for (const b of m.content) {
|
|
277
315
|
if (b?.type !== "tool_use" || b.input == null || typeof b.input !== "object")
|
|
278
316
|
continue;
|
|
317
|
+
// Measured across 42 real sessions: of 73 large Writes, 18 were later
|
|
318
|
+
// Edited and 16 of those Edits had no Read in between — the model
|
|
319
|
+
// built old_string from its own Write input, at a median distance of
|
|
320
|
+
// 43 messages. Trimming such a Write turns that Edit into a failure
|
|
321
|
+
// plus a recovery Read. Offline we can see the future, so leave
|
|
322
|
+
// those alone; the 63% never touched again are still pure gain.
|
|
323
|
+
if (editedLaterWithoutRead(messages, i, writtenPath(b)))
|
|
324
|
+
continue;
|
|
279
325
|
const before = estimateTokens(JSON.stringify(b.input));
|
|
280
326
|
if (before <= opts.maxToolResultTokens)
|
|
281
327
|
continue;
|
package/dist/profile.d.ts
CHANGED
|
@@ -51,6 +51,14 @@ export interface ContextProfile {
|
|
|
51
51
|
/** Present when the model has a known price. All figures are estimates. */
|
|
52
52
|
cost?: CostEstimate;
|
|
53
53
|
sourceFormat: string;
|
|
54
|
+
/**
|
|
55
|
+
* Present when estimates were scaled by a factor learned from this machine's
|
|
56
|
+
* own `--exact` counts for this model family. Absent means raw heuristic.
|
|
57
|
+
*/
|
|
58
|
+
calibration?: {
|
|
59
|
+
factor: number;
|
|
60
|
+
samples: number;
|
|
61
|
+
};
|
|
54
62
|
/** Propagated from parsing: input could not be read as a conversation. */
|
|
55
63
|
parseWarning?: string;
|
|
56
64
|
}
|
package/dist/profile.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { contextWindowFor, estimateTokens, MESSAGE_OVERHEAD_TOKENS, providerFor } from "./tokens.js";
|
|
7
7
|
import { estimatedTtftSeconds, inputCostUsd, pricingFor } from "./pricing.js";
|
|
8
8
|
import { hasBase64Blob } from "./blob.js";
|
|
9
|
+
import { calibrationFor } from "./calibration.js";
|
|
9
10
|
function categoryOf(m) {
|
|
10
11
|
switch (m.kind) {
|
|
11
12
|
case "system": return "system";
|
|
@@ -145,9 +146,11 @@ function filesReadBy(toolName, toolCallText) {
|
|
|
145
146
|
return [...paths];
|
|
146
147
|
}
|
|
147
148
|
export function profileConversation(conv, model) {
|
|
149
|
+
// Learned from the user's own exact counts, if they ever fetched any.
|
|
150
|
+
const calibration = calibrationFor(model);
|
|
148
151
|
const perMessage = conv.messages.map((m) => ({
|
|
149
152
|
msg: m,
|
|
150
|
-
tokens: estimateTokens(m.text) + MESSAGE_OVERHEAD_TOKENS,
|
|
153
|
+
tokens: Math.round(estimateTokens(m.text) * calibration.factor) + MESSAGE_OVERHEAD_TOKENS,
|
|
151
154
|
}));
|
|
152
155
|
const totalTokens = perMessage.reduce((sum, p) => sum + p.tokens, 0);
|
|
153
156
|
const categories = {
|
|
@@ -445,6 +448,7 @@ export function profileConversation(conv, model) {
|
|
|
445
448
|
totalEstSavings,
|
|
446
449
|
cost,
|
|
447
450
|
sourceFormat: conv.sourceFormat,
|
|
451
|
+
calibration: calibration.samples > 0 ? calibration : undefined,
|
|
448
452
|
parseWarning: conv.parseWarning,
|
|
449
453
|
};
|
|
450
454
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -66,6 +66,8 @@ export function startProxy(opts = {}) {
|
|
|
66
66
|
};
|
|
67
67
|
/** Last stable-prefix fingerprint per model, for cache-invalidation advice. */
|
|
68
68
|
const prefixFingerprints = new Map();
|
|
69
|
+
/** Per-message fingerprints of the previous request per model, for breakpoint placement. */
|
|
70
|
+
const messageFingerprints = new Map();
|
|
69
71
|
const advise = (msg) => {
|
|
70
72
|
if (stats.advice.includes(msg) || stats.advice.length >= 10)
|
|
71
73
|
return;
|
|
@@ -117,14 +119,24 @@ export function startProxy(opts = {}) {
|
|
|
117
119
|
strategies: route.strategies ?? opts.strategies,
|
|
118
120
|
keepRecent: route.keepRecent ?? opts.keepRecent,
|
|
119
121
|
maxToolResultTokens: route.maxToolResultTokens ?? opts.maxToolResultTokens,
|
|
122
|
+
trimBoundaryStep: opts.trimBoundaryStep,
|
|
120
123
|
};
|
|
121
124
|
}
|
|
122
125
|
// Prompt-cache advisor (Anthropic requests): the proxy sees real
|
|
123
126
|
// sequences, so cache-hostile patterns are observable facts here.
|
|
124
127
|
if (url.startsWith("/v1/messages") && requestModel) {
|
|
125
128
|
const stablePrefix = JSON.stringify(parsedBody.tools ?? null) + JSON.stringify(parsedBody.system ?? null);
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
const hasBreakpoint = body.includes("cache_control");
|
|
130
|
+
if (stablePrefix.length > 4000 && !hasBreakpoint) {
|
|
131
|
+
// Say WHERE, not just that. A breakpoint caches everything up
|
|
132
|
+
// to and including the block it sits on, so it belongs on the
|
|
133
|
+
// LAST stable block: the final tool definition if there are
|
|
134
|
+
// tools, otherwise the final system block.
|
|
135
|
+
const where = Array.isArray(parsedBody.tools) && parsedBody.tools.length > 0
|
|
136
|
+
? `the last entry in "tools" (tools come before system in the cached prefix)`
|
|
137
|
+
: `the last block of "system"`;
|
|
138
|
+
advise(`~${Math.round(stablePrefix.length / 4)}+ tokens of stable system/tools on ${requestModel} without cache_control. ` +
|
|
139
|
+
`Add {"cache_control":{"type":"ephemeral"}} to ${where}; everything before it then bills at ~10% on every call`);
|
|
128
140
|
}
|
|
129
141
|
const fp = fnv1a(stablePrefix);
|
|
130
142
|
const prev = prefixFingerprints.get(requestModel);
|
|
@@ -132,6 +144,31 @@ export function startProxy(opts = {}) {
|
|
|
132
144
|
advise(`system/tools prefix changed between ${requestModel} requests — every change re-bills the whole cached prefix; keep it byte-stable`);
|
|
133
145
|
}
|
|
134
146
|
prefixFingerprints.set(requestModel, fp);
|
|
147
|
+
// Second breakpoint: the conversation itself. Between two
|
|
148
|
+
// consecutive requests the older messages are usually identical;
|
|
149
|
+
// that run is cacheable too, and it is what re-bills every turn
|
|
150
|
+
// when nothing marks it. Find the longest message prefix that
|
|
151
|
+
// survived from the previous request and point at its last message.
|
|
152
|
+
const msgs = Array.isArray(parsedBody.messages)
|
|
153
|
+
? parsedBody.messages
|
|
154
|
+
: [];
|
|
155
|
+
const hashes = msgs.map((m) => fnv1a(JSON.stringify(m)));
|
|
156
|
+
const prevHashes = messageFingerprints.get(requestModel);
|
|
157
|
+
if (prevHashes && !hasBreakpoint) {
|
|
158
|
+
let stable = 0;
|
|
159
|
+
while (stable < hashes.length && stable < prevHashes.length && hashes[stable] === prevHashes[stable])
|
|
160
|
+
stable++;
|
|
161
|
+
if (stable >= 2) {
|
|
162
|
+
const stableChars = msgs.slice(0, stable).reduce((n, m) => n + JSON.stringify(m).length, 0);
|
|
163
|
+
const stableTokens = Math.round(stableChars / 4);
|
|
164
|
+
// Anthropic will not cache a prefix under ~1024 tokens (2048 on Haiku).
|
|
165
|
+
if (stableTokens >= 1024) {
|
|
166
|
+
advise(`messages #0-#${stable - 1} (~${stableTokens} tokens) were identical to the previous ${requestModel} request and carry no cache_control. ` +
|
|
167
|
+
`Put {"cache_control":{"type":"ephemeral"}} on the last content block of message #${stable - 1}; that run then reads from cache instead of re-billing each turn`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
messageFingerprints.set(requestModel, hashes);
|
|
135
172
|
}
|
|
136
173
|
}
|
|
137
174
|
catch {
|
package/dist/report.js
CHANGED
|
@@ -37,6 +37,11 @@ export function renderProfile(profile, options = {}) {
|
|
|
37
37
|
lines.push("");
|
|
38
38
|
}
|
|
39
39
|
lines.push(`Total: ~${formatTokens(p.totalTokens)} tokens across ${p.messageCount} messages (${p.sourceFormat} format)`);
|
|
40
|
+
if (p.calibration) {
|
|
41
|
+
// Scaled numbers must say so, or they read as the raw heuristic.
|
|
42
|
+
const pct = Math.round((p.calibration.factor - 1) * 100);
|
|
43
|
+
lines.push(` estimates calibrated ${pct >= 0 ? "+" : ""}${pct}% from ${p.calibration.samples} exact count(s) you ran on this machine (analyze --exact)`);
|
|
44
|
+
}
|
|
40
45
|
if (p.model) {
|
|
41
46
|
const windowNote = p.contextWindow
|
|
42
47
|
? ` of ${formatTokens(p.contextWindow)} window (${p.usagePct.toFixed(1)}%)`
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor statusline` — live context health in Claude Code's status bar.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's `statusLine` setting runs a command on every refresh and shows
|
|
5
|
+
* its first line of stdout. That is "context health where the work happens"
|
|
6
|
+
* without an editor extension: the number that matters, visible while typing.
|
|
7
|
+
*
|
|
8
|
+
* It has to be fast and silent. Fast: the status payload on stdin carries the
|
|
9
|
+
* live context size in recent versions; when it does not, only the tail of the
|
|
10
|
+
* transcript is read (last 256KB, ~1ms on a 20MB file). Silent: any failure
|
|
11
|
+
* prints nothing at all, because a status line that shows an error is worse
|
|
12
|
+
* than one that shows nothing.
|
|
13
|
+
*/
|
|
14
|
+
/** The parts of Claude Code's status payload this reads. All optional. */
|
|
15
|
+
interface StatusInput {
|
|
16
|
+
transcript_path?: string;
|
|
17
|
+
model?: {
|
|
18
|
+
id?: string;
|
|
19
|
+
display_name?: string;
|
|
20
|
+
};
|
|
21
|
+
cost?: {
|
|
22
|
+
total_cost_usd?: number;
|
|
23
|
+
};
|
|
24
|
+
context_window?: {
|
|
25
|
+
total_input_tokens?: number;
|
|
26
|
+
context_window_size?: number;
|
|
27
|
+
current_usage?: {
|
|
28
|
+
input_tokens?: number;
|
|
29
|
+
cache_read_input_tokens?: number;
|
|
30
|
+
cache_creation_input_tokens?: number;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
interface LiveUsage {
|
|
35
|
+
tokens: number;
|
|
36
|
+
cacheShare?: number;
|
|
37
|
+
model?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Newest assistant usage from the END of a transcript, without reading the file. */
|
|
40
|
+
export declare function tailUsage(path: string, tailBytes?: number): LiveUsage | undefined;
|
|
41
|
+
/** Build the status line, or null when there is nothing trustworthy to show. */
|
|
42
|
+
export declare function renderStatusLine(input: StatusInput): string | null;
|
|
43
|
+
export declare function runStatusLine(): Promise<void>;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor statusline` — live context health in Claude Code's status bar.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's `statusLine` setting runs a command on every refresh and shows
|
|
5
|
+
* its first line of stdout. That is "context health where the work happens"
|
|
6
|
+
* without an editor extension: the number that matters, visible while typing.
|
|
7
|
+
*
|
|
8
|
+
* It has to be fast and silent. Fast: the status payload on stdin carries the
|
|
9
|
+
* live context size in recent versions; when it does not, only the tail of the
|
|
10
|
+
* transcript is read (last 256KB, ~1ms on a 20MB file). Silent: any failure
|
|
11
|
+
* prints nothing at all, because a status line that shows an error is worse
|
|
12
|
+
* than one that shows nothing.
|
|
13
|
+
*/
|
|
14
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
15
|
+
import { contextWindowFor, formatTokens } from "./tokens.js";
|
|
16
|
+
import { formatUsd } from "./pricing.js";
|
|
17
|
+
/** Newest assistant usage from the END of a transcript, without reading the file. */
|
|
18
|
+
export function tailUsage(path, tailBytes = 256 * 1024) {
|
|
19
|
+
try {
|
|
20
|
+
const size = statSync(path).size;
|
|
21
|
+
const n = Math.min(size, tailBytes);
|
|
22
|
+
const fd = openSync(path, "r");
|
|
23
|
+
const buf = Buffer.alloc(n);
|
|
24
|
+
try {
|
|
25
|
+
readSync(fd, buf, 0, n, size - n);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
closeSync(fd);
|
|
29
|
+
}
|
|
30
|
+
for (const line of buf.toString("utf8").split("\n").reverse()) {
|
|
31
|
+
let entry;
|
|
32
|
+
try {
|
|
33
|
+
entry = JSON.parse(line);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue; // the first line of a tail is usually a partial one
|
|
37
|
+
}
|
|
38
|
+
const usage = entry.type === "assistant" ? entry.message?.usage : undefined;
|
|
39
|
+
if (!usage)
|
|
40
|
+
continue;
|
|
41
|
+
const read = usage.cache_read_input_tokens ?? 0;
|
|
42
|
+
const tokens = (usage.input_tokens ?? 0) + read + (usage.cache_creation_input_tokens ?? 0);
|
|
43
|
+
if (tokens > 0)
|
|
44
|
+
return { tokens, cacheShare: read / tokens, model: entry.message?.model };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* unreadable: the caller shows nothing */
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/** Ten-cell bar; the visual half of the number. */
|
|
53
|
+
function bar(pct) {
|
|
54
|
+
const filled = Math.max(0, Math.min(10, Math.round(pct / 10)));
|
|
55
|
+
return "▮".repeat(filled) + "░".repeat(10 - filled);
|
|
56
|
+
}
|
|
57
|
+
/** Build the status line, or null when there is nothing trustworthy to show. */
|
|
58
|
+
export function renderStatusLine(input) {
|
|
59
|
+
let live;
|
|
60
|
+
const cw = input.context_window;
|
|
61
|
+
const fromPayload = cw?.current_usage
|
|
62
|
+
? (cw.current_usage.input_tokens ?? 0) + (cw.current_usage.cache_read_input_tokens ?? 0) + (cw.current_usage.cache_creation_input_tokens ?? 0)
|
|
63
|
+
: cw?.total_input_tokens;
|
|
64
|
+
if (fromPayload && fromPayload > 0) {
|
|
65
|
+
const read = cw?.current_usage?.cache_read_input_tokens;
|
|
66
|
+
live = { tokens: fromPayload, cacheShare: read !== undefined ? read / fromPayload : undefined, model: input.model?.id };
|
|
67
|
+
}
|
|
68
|
+
else if (input.transcript_path) {
|
|
69
|
+
live = tailUsage(input.transcript_path);
|
|
70
|
+
}
|
|
71
|
+
if (!live)
|
|
72
|
+
return null;
|
|
73
|
+
const window = cw?.context_window_size ?? contextWindowFor(live.model ?? input.model?.id);
|
|
74
|
+
const parts = [];
|
|
75
|
+
if (window) {
|
|
76
|
+
const pct = (live.tokens / window) * 100;
|
|
77
|
+
parts.push(`ctx ${formatTokens(live.tokens)}/${formatTokens(window)} ${bar(pct)} ${pct.toFixed(0)}%${pct >= 70 ? " ⚠" : ""}`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
parts.push(`ctx ${formatTokens(live.tokens)}`);
|
|
81
|
+
}
|
|
82
|
+
if (live.cacheShare !== undefined)
|
|
83
|
+
parts.push(`cache ${Math.round(live.cacheShare * 100)}%`);
|
|
84
|
+
if (input.cost?.total_cost_usd && input.cost.total_cost_usd > 0)
|
|
85
|
+
parts.push(formatUsd(input.cost.total_cost_usd));
|
|
86
|
+
return parts.join(" · ");
|
|
87
|
+
}
|
|
88
|
+
async function readStdin() {
|
|
89
|
+
const chunks = [];
|
|
90
|
+
for await (const chunk of process.stdin)
|
|
91
|
+
chunks.push(chunk);
|
|
92
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
93
|
+
}
|
|
94
|
+
export async function runStatusLine() {
|
|
95
|
+
try {
|
|
96
|
+
const input = JSON.parse(await readStdin());
|
|
97
|
+
const line = renderStatusLine(input);
|
|
98
|
+
if (line)
|
|
99
|
+
console.log(line);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* silent by design */
|
|
103
|
+
}
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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 test",
|
|
44
44
|
"dev": "tsc --watch",
|
|
45
|
-
"test": "npm run build && node
|
|
45
|
+
"test": "npm run build && node scripts/test.mjs"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.0.0",
|