context-doctor 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +16 -0
- package/dist/config.js +40 -0
- package/dist/diff.d.ts +13 -0
- package/dist/diff.js +92 -0
- package/dist/mcp.js +1 -1
- package/dist/optimize.js +30 -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
|
@@ -61,3 +61,19 @@ export declare function checkBudget(budget: ContextBudget | undefined, profile:
|
|
|
61
61
|
perCallUsd: number;
|
|
62
62
|
};
|
|
63
63
|
}): BudgetVerdict;
|
|
64
|
+
/**
|
|
65
|
+
* Starting points for `.contextdoctorrc`.
|
|
66
|
+
*
|
|
67
|
+
* An empty rc file is technically valid and completely useless: nobody knows
|
|
68
|
+
* what a reasonable token budget is for their kind of work until they have
|
|
69
|
+
* blown through one. These encode the three shapes that actually differ —
|
|
70
|
+
* a chat product, a coding agent, and a batch pipeline — so a budget can be
|
|
71
|
+
* adopted in one command and tuned later.
|
|
72
|
+
*/
|
|
73
|
+
export interface Preset {
|
|
74
|
+
id: string;
|
|
75
|
+
summary: string;
|
|
76
|
+
config: ContextDoctorConfig;
|
|
77
|
+
}
|
|
78
|
+
export declare const PRESETS: Preset[];
|
|
79
|
+
export declare function findPreset(id: string): Preset | undefined;
|
package/dist/config.js
CHANGED
|
@@ -71,3 +71,43 @@ export function checkBudget(budget, profile) {
|
|
|
71
71
|
}
|
|
72
72
|
return { overBudget: breaches.length > 0, breaches, maxTokens: budget.maxTokens };
|
|
73
73
|
}
|
|
74
|
+
export const PRESETS = [
|
|
75
|
+
{
|
|
76
|
+
id: "chat",
|
|
77
|
+
summary: "Interactive chat product — short contexts, latency matters most",
|
|
78
|
+
config: {
|
|
79
|
+
// Chat turns are small; a context this large means history is not being
|
|
80
|
+
// summarized, and every extra token is felt directly as time-to-first-token.
|
|
81
|
+
budget: { maxTokens: 30_000, maxWindowPct: 40, maxCostPerMessageUsd: 0.15 },
|
|
82
|
+
strategies: ["dedupe", "strip-base64"],
|
|
83
|
+
keepRecent: 10,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "agent",
|
|
88
|
+
summary: "Coding or tool-using agent — long runs, tool output dominates",
|
|
89
|
+
config: {
|
|
90
|
+
// Agents legitimately hold a lot of context, so the budget is generous;
|
|
91
|
+
// the tight controls go on tool traffic, which is where the waste is.
|
|
92
|
+
budget: { maxTokens: 150_000, maxWindowPct: 70, maxCostPerMessageUsd: 1.5 },
|
|
93
|
+
strategies: ["dedupe", "trim-tool-results", "strip-base64"],
|
|
94
|
+
keepRecent: 6,
|
|
95
|
+
maxToolResultTokens: 300,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "batch",
|
|
100
|
+
summary: "Batch or pipeline jobs — cost per call is the whole story",
|
|
101
|
+
config: {
|
|
102
|
+
// Nothing is interactive, so aggressive trimming costs nothing in feel
|
|
103
|
+
// and everything is multiplied by the number of items in the run.
|
|
104
|
+
budget: { maxTokens: 60_000, maxCostPerMessageUsd: 0.05 },
|
|
105
|
+
strategies: ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64"],
|
|
106
|
+
keepRecent: 4,
|
|
107
|
+
maxToolResultTokens: 150,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
export function findPreset(id) {
|
|
112
|
+
return PRESETS.find((p) => p.id === id);
|
|
113
|
+
}
|
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/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.0" }, { 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
|
}
|
|
@@ -172,7 +200,7 @@ export function optimizeConversation(input, options = {}) {
|
|
|
172
200
|
}
|
|
173
201
|
// -- trim-tool-results: shrink stale tool output ------------------------------
|
|
174
202
|
if (opts.strategies.includes("trim-tool-results")) {
|
|
175
|
-
const cutoff = messages.length
|
|
203
|
+
const cutoff = stableCutoff(messages.length, opts.keepRecent);
|
|
176
204
|
messages.forEach((m, i) => {
|
|
177
205
|
if (i >= cutoff || !isToolResultMessage(m))
|
|
178
206
|
return;
|
|
@@ -192,7 +220,7 @@ export function optimizeConversation(input, options = {}) {
|
|
|
192
220
|
}
|
|
193
221
|
// -- trim-tool-calls: shrink the arguments of calls that already ran ----------
|
|
194
222
|
if (opts.strategies.includes("trim-tool-calls")) {
|
|
195
|
-
const cutoff = messages.length
|
|
223
|
+
const cutoff = stableCutoff(messages.length, opts.keepRecent);
|
|
196
224
|
messages.forEach((m, i) => {
|
|
197
225
|
if (i >= cutoff)
|
|
198
226
|
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.0",
|
|
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"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.0.0",
|