context-doctor 0.11.0 → 0.12.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 +5 -2
- package/dist/blob.d.ts +15 -0
- package/dist/blob.js +44 -0
- package/dist/cli.js +2 -1
- package/dist/doctor.js +15 -3
- package/dist/install.d.ts +14 -0
- package/dist/install.js +115 -19
- package/dist/mcp.js +3 -3
- package/dist/optimize.d.ts +2 -2
- package/dist/optimize.js +67 -4
- package/dist/parse.d.ts +6 -0
- package/dist/parse.js +21 -4
- package/dist/profile.d.ts +3 -1
- package/dist/profile.js +21 -2
- package/dist/report.js +29 -0
- package/dist/session.js +61 -12
- package/package.json +3 -3
- package/skills/context-doctor/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Findings (4)
|
|
|
34
34
|
npx context-doctor install
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
That single command is also all it takes to **set up context-doctor on anyone else's machine**. Prefer a global install, or want the unreleased `main`? Both work (Node
|
|
37
|
+
That single command is also all it takes to **set up context-doctor on anyone else's machine**. Prefer a global install, or want the unreleased `main`? Both work (Node 20+):
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
npm install -g context-doctor && context-doctor install
|
|
@@ -89,7 +89,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
89
89
|
|---|---|
|
|
90
90
|
| `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor, the Agent Skill, the every-prompt hook |
|
|
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
|
-
| `context-doctor optimize <file>` | Apply the safe fixes; `--strategy prune-history` for consented lossy compaction |
|
|
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
94
|
| `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
|
|
95
95
|
| `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
|
|
@@ -261,8 +261,11 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
|
|
|
261
261
|
| `dedupe` — replace repeated content with a reference | No | ✅ |
|
|
262
262
|
| `trim-tool-results` — truncate stale tool outputs | Mostly no | ✅ |
|
|
263
263
|
| `strip-base64` — remove inline binary blobs | No (for the model) | ✅ |
|
|
264
|
+
| `trim-tool-calls` — shrink the arguments of calls that already ran | Mostly no | opt-in |
|
|
264
265
|
| `prune-history` — collapse old turns into a stub for summarization | Yes | opt-in |
|
|
265
266
|
|
|
267
|
+
`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
|
+
|
|
266
269
|
Everything the optimizer does is inspectable: it prints exactly which messages changed and how many tokens each change saved.
|
|
267
270
|
|
|
268
271
|
**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.
|
package/dist/blob.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline base64 detection, shared by the profiler and the optimizer.
|
|
3
|
+
*
|
|
4
|
+
* A charset match alone is not enough: `"a".repeat(5000)` is valid base64
|
|
5
|
+
* alphabet, and so are long hex digests, IDs and minified identifiers. Calling
|
|
6
|
+
* those "base64" makes the profiler wrong and — far worse — makes the
|
|
7
|
+
* `strip-base64` strategy DELETE real content. So a candidate must also look
|
|
8
|
+
* like encoded binary: spanning most of the 64-symbol alphabet, with no single
|
|
9
|
+
* character dominating.
|
|
10
|
+
*/
|
|
11
|
+
export declare const BASE64_PLACEHOLDER = "[context-doctor: base64 blob removed \u2014 use file/image APIs instead]";
|
|
12
|
+
/** True when the text carries at least one inline base64 blob. */
|
|
13
|
+
export declare function hasBase64Blob(text: string): boolean;
|
|
14
|
+
/** Replace real base64 blobs with a placeholder, leaving lookalikes intact. */
|
|
15
|
+
export declare function stripBase64Blobs(text: string): string;
|
package/dist/blob.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline base64 detection, shared by the profiler and the optimizer.
|
|
3
|
+
*
|
|
4
|
+
* A charset match alone is not enough: `"a".repeat(5000)` is valid base64
|
|
5
|
+
* alphabet, and so are long hex digests, IDs and minified identifiers. Calling
|
|
6
|
+
* those "base64" makes the profiler wrong and — far worse — makes the
|
|
7
|
+
* `strip-base64` strategy DELETE real content. So a candidate must also look
|
|
8
|
+
* like encoded binary: spanning most of the 64-symbol alphabet, with no single
|
|
9
|
+
* character dominating.
|
|
10
|
+
*/
|
|
11
|
+
/** Runs of base64 alphabet long enough to be worth reporting (~375 bytes+). */
|
|
12
|
+
const BASE64_RE = /(?:data:[\w/+.-]+;base64,)?[A-Za-z0-9+/]{500,}={0,2}/g;
|
|
13
|
+
export const BASE64_PLACEHOLDER = "[context-doctor: base64 blob removed — use file/image APIs instead]";
|
|
14
|
+
/** Distinct characters a genuine base64 payload is expected to span. */
|
|
15
|
+
const MIN_DISTINCT_CHARS = 24;
|
|
16
|
+
/** Above this share for one character, the run is padding or repetition. */
|
|
17
|
+
const MAX_SINGLE_CHAR_SHARE = 0.35;
|
|
18
|
+
function isBase64Blob(candidate) {
|
|
19
|
+
// An explicit data: URI declares its own encoding — no need to guess.
|
|
20
|
+
if (candidate.startsWith("data:"))
|
|
21
|
+
return true;
|
|
22
|
+
const counts = new Map();
|
|
23
|
+
for (const ch of candidate)
|
|
24
|
+
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
25
|
+
if (counts.size < MIN_DISTINCT_CHARS)
|
|
26
|
+
return false;
|
|
27
|
+
let max = 0;
|
|
28
|
+
for (const n of counts.values())
|
|
29
|
+
if (n > max)
|
|
30
|
+
max = n;
|
|
31
|
+
return max / candidate.length < MAX_SINGLE_CHAR_SHARE;
|
|
32
|
+
}
|
|
33
|
+
/** True when the text carries at least one inline base64 blob. */
|
|
34
|
+
export function hasBase64Blob(text) {
|
|
35
|
+
for (const m of text.matchAll(BASE64_RE)) {
|
|
36
|
+
if (isBase64Blob(m[0]))
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/** Replace real base64 blobs with a placeholder, leaving lookalikes intact. */
|
|
42
|
+
export function stripBase64Blobs(text) {
|
|
43
|
+
return text.replace(BASE64_RE, (m) => (isBase64Blob(m) ? BASE64_PLACEHOLDER : m));
|
|
44
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -71,7 +71,8 @@ Options:
|
|
|
71
71
|
exceeded — lets CI gate a pull request on context size
|
|
72
72
|
--out <file> (optimize) Write result to file instead of stdout
|
|
73
73
|
--strategy <id> (optimize) Strategy to run; repeatable.
|
|
74
|
-
Available: dedupe, trim-tool-results, strip-base64,
|
|
74
|
+
Available: dedupe, trim-tool-results, trim-tool-calls, strip-base64,
|
|
75
|
+
prune-history
|
|
75
76
|
Default: dedupe, trim-tool-results, strip-base64 (lossless-ish set)
|
|
76
77
|
--keep-recent <n> (optimize) Messages at the tail to leave untouched (default 6)
|
|
77
78
|
--max-tool-tokens <n> (optimize) Token budget for trimmed tool results (default 300)
|
package/dist/doctor.js
CHANGED
|
@@ -26,12 +26,24 @@ function checkMcpEntry(appName, configPath) {
|
|
|
26
26
|
const entry = config.mcpServers?.["context-doctor"];
|
|
27
27
|
if (!entry)
|
|
28
28
|
return { label: appName, status: "fail", detail: `no context-doctor entry in ${configPath} — run: context-doctor install` };
|
|
29
|
-
// Absolute-path entries must point at a file that still exists.
|
|
30
|
-
|
|
29
|
+
// Absolute-path entries must point at a file that still exists. Launcher
|
|
30
|
+
// forms (npx, and Windows' cmd /c npx) resolve at spawn time, not now.
|
|
31
|
+
const launcher = entry.command === "npx" || entry.command === "cmd";
|
|
32
|
+
const target = launcher ? null : entry.args?.[0];
|
|
31
33
|
if (target && !existsSync(target)) {
|
|
32
34
|
return { label: appName, status: "fail", detail: `MCP entry points at missing file ${target} — re-run: context-doctor install` };
|
|
33
35
|
}
|
|
34
|
-
|
|
36
|
+
// Configs written before 0.12 pinned the exact node binary, which a Node
|
|
37
|
+
// upgrade removes; the app then silently loses the tools.
|
|
38
|
+
const cmd = String(entry.command ?? "");
|
|
39
|
+
if (cmd.includes("/") && !existsSync(cmd)) {
|
|
40
|
+
return {
|
|
41
|
+
label: appName,
|
|
42
|
+
status: "fail",
|
|
43
|
+
detail: `MCP command ${cmd} no longer exists (a Node upgrade moves version-pinned paths) — re-run: context-doctor install`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { label: appName, status: "ok", detail: `MCP wired (${launcher ? "npx, tracks npm releases" : "local build"})` };
|
|
35
47
|
}
|
|
36
48
|
catch (e) {
|
|
37
49
|
return { label: appName, status: "fail", detail: `${configPath} is not valid JSON (${e.message})` };
|
package/dist/install.d.ts
CHANGED
|
@@ -6,5 +6,19 @@
|
|
|
6
6
|
* Every config edit is a careful JSON merge with a .backup file written first.
|
|
7
7
|
* `context-doctor uninstall` reverses it.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* How to invoke the published package as an MCP server on a given platform.
|
|
11
|
+
*
|
|
12
|
+
* On Windows npx is `npx.cmd` — a batch script, not an executable. MCP clients
|
|
13
|
+
* spawn their server directly, without a shell, so a bare "npx" fails with
|
|
14
|
+
* ENOENT and the app simply shows no tools and no error. Hence the cmd /c
|
|
15
|
+
* wrapper that every working Windows MCP config uses.
|
|
16
|
+
*
|
|
17
|
+
* Exported so the platform branch is testable from any host OS.
|
|
18
|
+
*/
|
|
19
|
+
export declare function npxLauncher(platformName: string): {
|
|
20
|
+
command: string;
|
|
21
|
+
args: string[];
|
|
22
|
+
};
|
|
9
23
|
export declare function runInstall(): void;
|
|
10
24
|
export declare function runUninstall(): void;
|
package/dist/install.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync } from "node:fs";
|
|
10
10
|
import { homedir, platform } from "node:os";
|
|
11
|
-
import { dirname, join } from "node:path";
|
|
11
|
+
import { delimiter, dirname, join, sep } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
function claudeDesktopConfigPath() {
|
|
14
14
|
switch (platform()) {
|
|
@@ -28,18 +28,40 @@ function targets() {
|
|
|
28
28
|
];
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
|
-
* The server command to write into configs.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* The server command to write into configs.
|
|
32
|
+
*
|
|
33
|
+
* Never write `process.execPath`: on Homebrew, nvm and asdf that is a
|
|
34
|
+
* VERSION-PINNED path (…/node/25.6.0/bin/node), so the next Node upgrade
|
|
35
|
+
* silently breaks every config we wrote — the apps simply stop showing the
|
|
36
|
+
* tools, with no error to explain why. `node` from PATH survives upgrades.
|
|
37
|
+
*
|
|
38
|
+
* Installed from npm → npx, which also picks up package updates. Local
|
|
39
|
+
* checkout → the built file, so the repo works before/without publishing.
|
|
34
40
|
*/
|
|
35
41
|
function serverEntry() {
|
|
36
42
|
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
37
43
|
const localMcp = join(selfDir, "mcp.js");
|
|
38
|
-
const
|
|
39
|
-
if (!
|
|
40
|
-
|
|
44
|
+
const fromPackage = selfDir.includes(`${sep}node_modules${sep}`) || selfDir.includes("_npx");
|
|
45
|
+
if (!fromPackage && existsSync(localMcp)) {
|
|
46
|
+
// `node` is node.exe on Windows — directly spawnable, no shell needed.
|
|
47
|
+
return { command: "node", args: [localMcp] };
|
|
41
48
|
}
|
|
42
|
-
return
|
|
49
|
+
return npxLauncher(platform());
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* How to invoke the published package as an MCP server on a given platform.
|
|
53
|
+
*
|
|
54
|
+
* On Windows npx is `npx.cmd` — a batch script, not an executable. MCP clients
|
|
55
|
+
* spawn their server directly, without a shell, so a bare "npx" fails with
|
|
56
|
+
* ENOENT and the app simply shows no tools and no error. Hence the cmd /c
|
|
57
|
+
* wrapper that every working Windows MCP config uses.
|
|
58
|
+
*
|
|
59
|
+
* Exported so the platform branch is testable from any host OS.
|
|
60
|
+
*/
|
|
61
|
+
export function npxLauncher(platformName) {
|
|
62
|
+
return platformName === "win32"
|
|
63
|
+
? { command: "cmd", args: ["/c", "npx", "-y", "context-doctor-mcp"] }
|
|
64
|
+
: { command: "npx", args: ["-y", "context-doctor-mcp"] };
|
|
43
65
|
}
|
|
44
66
|
function readJson(path) {
|
|
45
67
|
if (!existsSync(path))
|
|
@@ -57,17 +79,68 @@ function writeJsonWithBackup(path, data) {
|
|
|
57
79
|
copyFileSync(path, path + ".context-doctor.backup");
|
|
58
80
|
writeFileSync(path, JSON.stringify(data, null, 2));
|
|
59
81
|
}
|
|
60
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Find an executable on PATH without shelling out (works on Windows too).
|
|
84
|
+
* Used to prefer a global `context-doctor` install for the hook: a stable
|
|
85
|
+
* location that survives both package updates and Node upgrades.
|
|
86
|
+
*/
|
|
87
|
+
function binOnPath(name) {
|
|
88
|
+
const exts = platform() === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
|
|
89
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
90
|
+
if (!dir)
|
|
91
|
+
continue;
|
|
92
|
+
for (const ext of exts) {
|
|
93
|
+
const candidate = join(dir, name + ext);
|
|
94
|
+
if (existsSync(candidate))
|
|
95
|
+
return candidate;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Shell command used for the Claude Code every-prompt hook.
|
|
102
|
+
*
|
|
103
|
+
* The hook runs on EVERY prompt, so the command must be both fast and durable.
|
|
104
|
+
* In preference order:
|
|
105
|
+
* 1. a local checkout's built cli.js — absolute, stable, zero resolution cost;
|
|
106
|
+
* 2. a global `context-doctor` binary on PATH — same, for npm -g installs;
|
|
107
|
+
* 3. `npx -y context-doctor hook` — last resort.
|
|
108
|
+
*
|
|
109
|
+
* Critically, a path inside npx's `_npx` cache is NEVER written: npm garbage-
|
|
110
|
+
* collects that directory, and the hook would then fail silently on every
|
|
111
|
+
* prompt. `node` (not process.execPath) keeps it alive across Node upgrades.
|
|
112
|
+
*/
|
|
61
113
|
function hookCommand() {
|
|
62
114
|
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
63
115
|
const localCli = join(selfDir, "cli.js");
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
66
|
-
return `
|
|
67
|
-
|
|
116
|
+
const ephemeral = selfDir.includes("_npx");
|
|
117
|
+
if (!ephemeral && existsSync(localCli))
|
|
118
|
+
return `node "${localCli}" hook`;
|
|
119
|
+
const global = binOnPath("context-doctor");
|
|
120
|
+
if (global)
|
|
121
|
+
return `"${global}" hook`;
|
|
68
122
|
return "npx -y context-doctor hook";
|
|
69
123
|
}
|
|
124
|
+
/** True when the hook had to fall back to npx — worth telling the user. */
|
|
125
|
+
function hookUsesNpx() {
|
|
126
|
+
return hookCommand().startsWith("npx ");
|
|
127
|
+
}
|
|
70
128
|
const HOOK_MARKER = "context-doctor";
|
|
129
|
+
/**
|
|
130
|
+
* Is this settings.json hook entry ours?
|
|
131
|
+
*
|
|
132
|
+
* Usually the command contains "context-doctor" (npx form, or a path through
|
|
133
|
+
* the package directory). A repo cloned into a differently-named folder does
|
|
134
|
+
* not, so a command ending in `cli.js hook` counts too — specific enough not
|
|
135
|
+
* to claim an unrelated hook.
|
|
136
|
+
*/
|
|
137
|
+
function isOurHookEntry(entry) {
|
|
138
|
+
const raw = JSON.stringify(entry ?? "");
|
|
139
|
+
if (raw.includes(HOOK_MARKER))
|
|
140
|
+
return true;
|
|
141
|
+
const command = String(entry?.hooks?.[0]?.command ?? "");
|
|
142
|
+
return /(cli\.js|context-doctor(\.cmd|\.exe|\.bat)?)"?\s+hook\s*$/.test(command);
|
|
143
|
+
}
|
|
71
144
|
/**
|
|
72
145
|
* Register the UserPromptSubmit hook in ~/.claude/settings.json so EVERY
|
|
73
146
|
* Claude Code query gets a context-size check. Idempotent.
|
|
@@ -79,12 +152,28 @@ function installHook() {
|
|
|
79
152
|
const settings = readJson(settingsPath);
|
|
80
153
|
settings.hooks = settings.hooks ?? {};
|
|
81
154
|
const entries = settings.hooks.UserPromptSubmit ?? [];
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
155
|
+
const want = hookCommand();
|
|
156
|
+
// Re-running install must REPAIR a stale entry, not skip it. Earlier versions
|
|
157
|
+
// wrote a version-pinned node binary; if we only checked "is it present?" an
|
|
158
|
+
// upgrade would leave that broken command in place forever.
|
|
159
|
+
const ours = entries.filter(isOurHookEntry);
|
|
160
|
+
const current = ours[0]?.hooks?.[0]?.command;
|
|
161
|
+
if (ours.length === 0) {
|
|
162
|
+
entries.push({ hooks: [{ type: "command", command: want }] });
|
|
163
|
+
}
|
|
164
|
+
else if (current !== want) {
|
|
165
|
+
// Replace every entry of ours with exactly one correct entry.
|
|
166
|
+
const others = entries.filter((e) => !isOurHookEntry(e));
|
|
167
|
+
others.push({ hooks: [{ type: "command", command: want }] });
|
|
168
|
+
settings.hooks.UserPromptSubmit = others;
|
|
86
169
|
writeJsonWithBackup(settingsPath, settings);
|
|
170
|
+
return settingsPath;
|
|
87
171
|
}
|
|
172
|
+
else {
|
|
173
|
+
return settingsPath; // already correct — leave the file untouched
|
|
174
|
+
}
|
|
175
|
+
settings.hooks.UserPromptSubmit = entries;
|
|
176
|
+
writeJsonWithBackup(settingsPath, settings);
|
|
88
177
|
return settingsPath;
|
|
89
178
|
}
|
|
90
179
|
function uninstallHook() {
|
|
@@ -95,7 +184,7 @@ function uninstallHook() {
|
|
|
95
184
|
const entries = settings.hooks?.UserPromptSubmit;
|
|
96
185
|
if (!entries)
|
|
97
186
|
return;
|
|
98
|
-
const filtered = entries.filter((e) => !
|
|
187
|
+
const filtered = entries.filter((e) => !isOurHookEntry(e));
|
|
99
188
|
if (filtered.length !== entries.length) {
|
|
100
189
|
settings.hooks.UserPromptSubmit = filtered;
|
|
101
190
|
if (filtered.length === 0)
|
|
@@ -128,6 +217,7 @@ export function runInstall() {
|
|
|
128
217
|
try {
|
|
129
218
|
const config = readJson(target.configPath);
|
|
130
219
|
config.mcpServers = config.mcpServers ?? {};
|
|
220
|
+
// Always overwrite: re-running install is how a stale entry gets repaired.
|
|
131
221
|
config.mcpServers["context-doctor"] = entry;
|
|
132
222
|
writeJsonWithBackup(target.configPath, config);
|
|
133
223
|
console.log(`✓ ${target.name}: MCP server added (${target.configPath})`);
|
|
@@ -140,8 +230,14 @@ export function runInstall() {
|
|
|
140
230
|
if (skillPath)
|
|
141
231
|
console.log(`✓ Agent Skill installed for Claude Code (${skillPath})`);
|
|
142
232
|
const hookPath = installHook();
|
|
143
|
-
if (hookPath)
|
|
233
|
+
if (hookPath) {
|
|
144
234
|
console.log(`✓ Claude Code every-prompt hook installed (${hookPath}) — heavy sessions get automatic hygiene guidance`);
|
|
235
|
+
// npx resolves the package on every single prompt; a global install makes
|
|
236
|
+
// the hook a plain exec instead, which is both faster and update-proof.
|
|
237
|
+
if (hookUsesNpx()) {
|
|
238
|
+
console.log(" note: the hook falls back to npx. For a faster, permanent hook: npm i -g context-doctor && context-doctor install");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
145
241
|
console.log("\nDone. Restart the apps to pick up the new tools, then try:");
|
|
146
242
|
console.log(' "What\'s eating my context?" — or paste a conversation and ask for a profile.');
|
|
147
243
|
}
|
package/dist/mcp.js
CHANGED
|
@@ -30,14 +30,14 @@ import { recordLedger } from "./ledger.js";
|
|
|
30
30
|
// Kept deliberately terse: these ride in EVERY conversation's context, and a
|
|
31
31
|
// context-saving tool must not itself be context overhead (~110 tokens).
|
|
32
32
|
const SERVER_INSTRUCTIONS = `Context hygiene, always: summarize large pastes/tool results instead of carrying them verbatim; reference earlier content, don't re-quote; never inline base64. Past ~30 turns or several large pastes, proactively offer to run profile_context. Any question about tokens, cost, or latency: call profile_context, don't estimate. If optimize_context returns a pruned-turns digest, you write the ≤150-token replacement summary.`;
|
|
33
|
-
const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-history"];
|
|
33
|
+
const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64", "prune-history"];
|
|
34
34
|
/**
|
|
35
35
|
* Build a fully-configured server instance. A factory (not a singleton) so the
|
|
36
36
|
* stateless HTTP mode can hand every request its own server, per the MCP SDK's
|
|
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.12.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"),
|
|
@@ -48,7 +48,7 @@ function createServer() {
|
|
|
48
48
|
server.tool("optimize_context", "Rewrite a conversation to reclaim tokens using deterministic strategies: dedupe repeated content, trim stale tool results, strip base64 blobs, optionally prune old history. Returns the slimmed conversation JSON plus a savings summary. No LLM calls — safe and inspectable. Call this after profile_context finds recoverable waste and the user wants it fixed; add the prune-history strategy only with the user's consent, then write the replacement summary yourself as the result instructs.", {
|
|
49
49
|
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array)"),
|
|
50
50
|
strategies: z.array(z.enum(STRATEGY_IDS)).optional()
|
|
51
|
-
.describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add prune-history for lossy compaction of old turns."),
|
|
51
|
+
.describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add trim-tool-calls to shrink big inline file writes, or prune-history for lossy compaction of old turns."),
|
|
52
52
|
keep_recent: z.number().int().positive().optional().describe("Messages at the tail to leave untouched (default 6)"),
|
|
53
53
|
max_tool_result_tokens: z.number().int().positive().optional().describe("Token budget for trimmed tool results (default 300)"),
|
|
54
54
|
}, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
|
package/dist/optimize.d.ts
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* Strategies operate on the ORIGINAL JSON structure (not the normalized view)
|
|
7
7
|
* so the output is a drop-in replacement for the input conversation.
|
|
8
8
|
*/
|
|
9
|
-
export type StrategyId = "dedupe" | "trim-tool-results" | "prune-history" | "strip-base64";
|
|
9
|
+
export type StrategyId = "dedupe" | "trim-tool-results" | "trim-tool-calls" | "prune-history" | "strip-base64";
|
|
10
10
|
export interface OptimizeOptions {
|
|
11
11
|
strategies?: StrategyId[];
|
|
12
12
|
/** Tool results older than this many messages from the end get trimmed. */
|
|
13
13
|
keepRecent?: number;
|
|
14
|
-
/** Max tokens a trimmed tool result keeps. */
|
|
14
|
+
/** Max tokens a trimmed tool result — or tool-call argument set — keeps. */
|
|
15
15
|
maxToolResultTokens?: number;
|
|
16
16
|
}
|
|
17
17
|
export interface AppliedChange {
|
package/dist/optimize.js
CHANGED
|
@@ -8,12 +8,36 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { estimateTokens } from "./tokens.js";
|
|
11
|
+
import { hasBase64Blob, stripBase64Blobs } from "./blob.js";
|
|
11
12
|
const DEFAULTS = {
|
|
12
13
|
strategies: ["dedupe", "trim-tool-results", "strip-base64"],
|
|
13
14
|
keepRecent: 6,
|
|
14
15
|
maxToolResultTokens: 300,
|
|
15
16
|
};
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Shrink the arguments of a tool call that has already run.
|
|
19
|
+
*
|
|
20
|
+
* In file-heavy agent sessions the biggest single items in context are not
|
|
21
|
+
* tool RESULTS but tool CALLS: a Write or a `cat > file <<EOF` carries the
|
|
22
|
+
* whole file inline, forever. Once the call has returned, the live context
|
|
23
|
+
* only needs enough of the arguments to identify what was done.
|
|
24
|
+
*
|
|
25
|
+
* Keys are preserved (so the call still reads as itself) and only long string
|
|
26
|
+
* values are cut, with an explicit marker so nothing looks silently complete.
|
|
27
|
+
*/
|
|
28
|
+
function trimCallArguments(input, maxTokens) {
|
|
29
|
+
const budgetChars = maxTokens * 4;
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const [key, value] of Object.entries(input)) {
|
|
32
|
+
if (typeof value === "string" && value.length > budgetChars) {
|
|
33
|
+
out[key] = value.slice(0, budgetChars) + `\n[context-doctor: ${value.length - budgetChars} more chars trimmed — this call already ran]`;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
out[key] = value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
17
41
|
function hash(text) {
|
|
18
42
|
return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
|
|
19
43
|
}
|
|
@@ -109,11 +133,10 @@ export function optimizeConversation(input, options = {}) {
|
|
|
109
133
|
if (opts.strategies.includes("strip-base64")) {
|
|
110
134
|
messages.forEach((m, i) => {
|
|
111
135
|
const text = textOf(m.content);
|
|
112
|
-
if (!
|
|
136
|
+
if (!hasBase64Blob(text))
|
|
113
137
|
return;
|
|
114
|
-
BASE64_RE.lastIndex = 0;
|
|
115
138
|
const before = estimateTokens(text);
|
|
116
|
-
const cleaned = text
|
|
139
|
+
const cleaned = stripBase64Blobs(text);
|
|
117
140
|
const saved = before - estimateTokens(cleaned);
|
|
118
141
|
if (saved > 50) {
|
|
119
142
|
m.content = replaceText(m.content, cleaned);
|
|
@@ -159,6 +182,46 @@ export function optimizeConversation(input, options = {}) {
|
|
|
159
182
|
});
|
|
160
183
|
});
|
|
161
184
|
}
|
|
185
|
+
// -- trim-tool-calls: shrink the arguments of calls that already ran ----------
|
|
186
|
+
if (opts.strategies.includes("trim-tool-calls")) {
|
|
187
|
+
const cutoff = messages.length - opts.keepRecent;
|
|
188
|
+
messages.forEach((m, i) => {
|
|
189
|
+
if (i >= cutoff)
|
|
190
|
+
return;
|
|
191
|
+
let saved = 0;
|
|
192
|
+
// Anthropic shape: tool_use blocks with a structured `input`.
|
|
193
|
+
if (Array.isArray(m.content)) {
|
|
194
|
+
for (const b of m.content) {
|
|
195
|
+
if (b?.type !== "tool_use" || b.input == null || typeof b.input !== "object")
|
|
196
|
+
continue;
|
|
197
|
+
const before = estimateTokens(JSON.stringify(b.input));
|
|
198
|
+
if (before <= opts.maxToolResultTokens)
|
|
199
|
+
continue;
|
|
200
|
+
b.input = trimCallArguments(b.input, opts.maxToolResultTokens);
|
|
201
|
+
saved += before - estimateTokens(JSON.stringify(b.input));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// OpenAI shape: tool_calls[].function.arguments is a JSON string.
|
|
205
|
+
for (const tc of m.tool_calls ?? []) {
|
|
206
|
+
const args = tc?.function?.arguments;
|
|
207
|
+
if (typeof args !== "string")
|
|
208
|
+
continue;
|
|
209
|
+
const before = estimateTokens(args);
|
|
210
|
+
if (before <= opts.maxToolResultTokens)
|
|
211
|
+
continue;
|
|
212
|
+
tc.function.arguments = truncateToTokens(args, opts.maxToolResultTokens);
|
|
213
|
+
saved += before - estimateTokens(tc.function.arguments);
|
|
214
|
+
}
|
|
215
|
+
if (saved > 0) {
|
|
216
|
+
applied.push({
|
|
217
|
+
strategy: "trim-tool-calls",
|
|
218
|
+
messageIndex: i,
|
|
219
|
+
tokensSaved: saved,
|
|
220
|
+
note: "Arguments of a completed tool call truncated",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
162
225
|
// -- prune-history: replace the older half with a stub ------------------------
|
|
163
226
|
// Opt-in only: it is lossy, so it is not in the default strategy set.
|
|
164
227
|
let prunedDigest;
|
package/dist/parse.d.ts
CHANGED
|
@@ -27,5 +27,11 @@ export interface NormalizedConversation {
|
|
|
27
27
|
messages: NormalizedMessage[];
|
|
28
28
|
/** Format detected, for reporting. */
|
|
29
29
|
sourceFormat: "openai" | "anthropic" | "array" | "text";
|
|
30
|
+
/**
|
|
31
|
+
* Set when the input could not be read as a conversation. Silently profiling
|
|
32
|
+
* a broken file as one big "user message" produces a confident, wrong report
|
|
33
|
+
* — the caller should show this instead.
|
|
34
|
+
*/
|
|
35
|
+
parseWarning?: string;
|
|
30
36
|
}
|
|
31
37
|
export declare function parseConversation(input: string): NormalizedConversation;
|
package/dist/parse.js
CHANGED
|
@@ -90,16 +90,30 @@ export function parseConversation(input) {
|
|
|
90
90
|
try {
|
|
91
91
|
data = JSON.parse(input);
|
|
92
92
|
}
|
|
93
|
-
catch {
|
|
94
|
-
// Not JSON — treat the whole thing as one user message so profiling still
|
|
93
|
+
catch (e) {
|
|
94
|
+
// Not JSON — treat the whole thing as one user message so profiling still
|
|
95
|
+
// works for raw prompts. But if it LOOKS like JSON, the user handed us a
|
|
96
|
+
// broken conversation file and deserves to be told, not given a report
|
|
97
|
+
// about a single 9-token "message".
|
|
98
|
+
const head = input.trimStart()[0];
|
|
99
|
+
const parseWarning = input.trim() === ""
|
|
100
|
+
? "Input is empty — nothing to profile."
|
|
101
|
+
: head === "{" || head === "["
|
|
102
|
+
? `Input starts like JSON but does not parse (${e.message}). Profiling it as raw text, which is almost certainly not what you want.`
|
|
103
|
+
: undefined;
|
|
95
104
|
return {
|
|
96
105
|
sourceFormat: "text",
|
|
97
|
-
|
|
106
|
+
parseWarning,
|
|
107
|
+
// Empty input has no message: reporting "1 message, ~4 tokens" for it
|
|
108
|
+
// would be inventing content that is not there.
|
|
109
|
+
messages: input.trim() === "" ? [] : [{ index: 0, role: "user", kind: "user", text: input, hasBinary: false }],
|
|
98
110
|
};
|
|
99
111
|
}
|
|
100
112
|
if (Array.isArray(data)) {
|
|
113
|
+
const looksLikeMessages = data.length === 0 || data.some((m) => m && typeof m === "object" && "role" in m);
|
|
101
114
|
return {
|
|
102
115
|
sourceFormat: "array",
|
|
116
|
+
parseWarning: looksLikeMessages ? undefined : "This is a JSON array, but no element has a `role` field — it does not look like a conversation.",
|
|
103
117
|
messages: data.map((m, i) => normalizeMessage(m, i)),
|
|
104
118
|
};
|
|
105
119
|
}
|
|
@@ -114,5 +128,8 @@ export function parseConversation(input) {
|
|
|
114
128
|
messages.push(...rawMessages.map((m, i) => normalizeMessage(m, i)));
|
|
115
129
|
const isAnthropic = obj.system != null ||
|
|
116
130
|
rawMessages.some((m) => Array.isArray(m.content) && m.content.some((b) => b?.type === "tool_use" || b?.type === "tool_result"));
|
|
117
|
-
|
|
131
|
+
const parseWarning = messages.length === 0
|
|
132
|
+
? "This JSON has no `messages` array (and no `system`) — it does not look like a conversation. Expected {\"messages\":[{\"role\":…,\"content\":…}]}."
|
|
133
|
+
: undefined;
|
|
134
|
+
return { sourceFormat: isAnthropic ? "anthropic" : "openai", parseWarning, messages };
|
|
118
135
|
}
|
package/dist/profile.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface MessageProfile {
|
|
|
12
12
|
preview: string;
|
|
13
13
|
toolName?: string;
|
|
14
14
|
}
|
|
15
|
-
export type FindingId = "large_tool_result" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "repeated_file_read" | "retained_error_output" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
|
|
15
|
+
export type FindingId = "large_tool_result" | "large_tool_call" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "repeated_file_read" | "retained_error_output" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
|
|
16
16
|
export interface Finding {
|
|
17
17
|
id: FindingId;
|
|
18
18
|
severity: "info" | "warn" | "high";
|
|
@@ -51,5 +51,7 @@ 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
|
+
/** Propagated from parsing: input could not be read as a conversation. */
|
|
55
|
+
parseWarning?: string;
|
|
54
56
|
}
|
|
55
57
|
export declare function profileConversation(conv: NormalizedConversation, model?: string): ContextProfile;
|
package/dist/profile.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
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
|
+
import { hasBase64Blob } from "./blob.js";
|
|
8
9
|
function categoryOf(m) {
|
|
9
10
|
switch (m.kind) {
|
|
10
11
|
case "system": return "system";
|
|
@@ -56,7 +57,6 @@ function preview(text, len = 90) {
|
|
|
56
57
|
function contentHash(text) {
|
|
57
58
|
return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
|
|
58
59
|
}
|
|
59
|
-
const BASE64_RE = /(?:data:[\w/+.-]+;base64,|[A-Za-z0-9+/]{500,}={0,2})/;
|
|
60
60
|
/** FNV-1a — cheap deterministic hash for shingle sampling. */
|
|
61
61
|
function fnv1a(s) {
|
|
62
62
|
let h = 0x811c9dc5;
|
|
@@ -121,6 +121,24 @@ export function profileConversation(conv, model) {
|
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
+
// -- Large individual tool calls --------------------------------------------
|
|
125
|
+
// Writing a file through a tool call puts the ENTIRE file contents in the
|
|
126
|
+
// context permanently, and in file-heavy agent sessions these outweigh every
|
|
127
|
+
// tool result put together. Unlike a result, the argument cannot be trimmed
|
|
128
|
+
// before the call — but once the call has returned, the live context only
|
|
129
|
+
// needs a reference to what was written.
|
|
130
|
+
for (const p of perMessage) {
|
|
131
|
+
if (p.msg.kind === "tool_call" && p.tokens > 2000) {
|
|
132
|
+
findings.push({
|
|
133
|
+
id: "large_tool_call",
|
|
134
|
+
severity: p.tokens > 8000 ? "high" : "warn",
|
|
135
|
+
estSavings: Math.round(p.tokens * 0.8),
|
|
136
|
+
message: `Tool call at message #${p.msg.index}${p.msg.toolName ? ` (${p.msg.toolName})` : ""} carries ~${p.tokens} tokens of arguments.`,
|
|
137
|
+
suggestion: "Usually a whole file being written inline. Once it has run, replace the arguments with a reference (\"wrote <path>\") — or write files in smaller pieces so no single call carries the entire content.",
|
|
138
|
+
messages: [p.msg.index],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
124
142
|
// -- Exact duplicate content ------------------------------------------------
|
|
125
143
|
const seen = new Map();
|
|
126
144
|
for (const p of perMessage) {
|
|
@@ -259,7 +277,7 @@ export function profileConversation(conv, model) {
|
|
|
259
277
|
}
|
|
260
278
|
// -- Base64 / binary blobs ---------------------------------------------------
|
|
261
279
|
for (const p of perMessage) {
|
|
262
|
-
if (
|
|
280
|
+
if (hasBase64Blob(p.msg.text)) {
|
|
263
281
|
findings.push({
|
|
264
282
|
id: "base64_blob",
|
|
265
283
|
severity: "high",
|
|
@@ -359,5 +377,6 @@ export function profileConversation(conv, model) {
|
|
|
359
377
|
totalEstSavings,
|
|
360
378
|
cost,
|
|
361
379
|
sourceFormat: conv.sourceFormat,
|
|
380
|
+
parseWarning: conv.parseWarning,
|
|
362
381
|
};
|
|
363
382
|
}
|
package/dist/report.js
CHANGED
|
@@ -29,6 +29,13 @@ export function renderProfile(profile, options = {}) {
|
|
|
29
29
|
const p = profile;
|
|
30
30
|
lines.push("CONTEXT DOCTOR — profile");
|
|
31
31
|
lines.push("═".repeat(56));
|
|
32
|
+
// A malformed or non-conversation input still profiles (as raw text), but a
|
|
33
|
+
// report that does not say so reads as a confident answer to the wrong
|
|
34
|
+
// question. Lead with the warning.
|
|
35
|
+
if (p.parseWarning) {
|
|
36
|
+
lines.push(`⚠ ${p.parseWarning}`);
|
|
37
|
+
lines.push("");
|
|
38
|
+
}
|
|
32
39
|
lines.push(`Total: ~${formatTokens(p.totalTokens)} tokens across ${p.messageCount} messages (${p.sourceFormat} format)`);
|
|
33
40
|
if (p.model) {
|
|
34
41
|
const windowNote = p.contextWindow
|
|
@@ -67,10 +74,32 @@ export function renderProfile(profile, options = {}) {
|
|
|
67
74
|
if (p.findings.length > 0) {
|
|
68
75
|
lines.push(`Findings (${p.findings.length})`);
|
|
69
76
|
lines.push("─".repeat(56));
|
|
77
|
+
// A file-heavy session can produce a dozen findings of one kind, each with
|
|
78
|
+
// the same advice. Printing them all buries the other kinds, so show the
|
|
79
|
+
// worst few per kind and total the rest into one line.
|
|
80
|
+
const MAX_PER_KIND = 3;
|
|
81
|
+
const shownPerKind = new Map();
|
|
82
|
+
const heldPerKind = new Map();
|
|
70
83
|
for (const f of p.findings) {
|
|
84
|
+
const shown = shownPerKind.get(f.id) ?? 0;
|
|
85
|
+
if (shown >= MAX_PER_KIND) {
|
|
86
|
+
const held = heldPerKind.get(f.id) ?? { count: 0, savings: 0 };
|
|
87
|
+
heldPerKind.set(f.id, { count: held.count + 1, savings: held.savings + f.estSavings });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
shownPerKind.set(f.id, shown + 1);
|
|
71
91
|
const savings = f.estSavings > 0 ? ` [save ~${formatTokens(f.estSavings)}]` : "";
|
|
72
92
|
lines.push(`${SEVERITY_MARK[f.severity]} ${options.redact ? redactText(f.message) : f.message}${savings}`);
|
|
73
93
|
lines.push(` → ${f.suggestion}`);
|
|
94
|
+
const held = heldPerKind.get(f.id);
|
|
95
|
+
if (shown + 1 === MAX_PER_KIND && held === undefined)
|
|
96
|
+
heldPerKind.set(f.id, { count: 0, savings: 0 });
|
|
97
|
+
}
|
|
98
|
+
for (const [id, held] of heldPerKind) {
|
|
99
|
+
if (held.count === 0)
|
|
100
|
+
continue;
|
|
101
|
+
const more = held.savings > 0 ? `, ~${formatTokens(held.savings)} more recoverable` : "";
|
|
102
|
+
lines.push(` … and ${held.count} more of the same kind (${id})${more}. Use --json for the full list.`);
|
|
74
103
|
}
|
|
75
104
|
lines.push("");
|
|
76
105
|
if (p.totalEstSavings > 0) {
|
package/dist/session.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* where `message` is in Anthropic Messages format. Everything else
|
|
9
9
|
* (titles, mode changes, hook records) is metadata and skipped.
|
|
10
10
|
*/
|
|
11
|
-
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
11
|
+
import { readdirSync, readFileSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
12
|
+
import { StringDecoder } from "node:string_decoder";
|
|
12
13
|
import { homedir } from "node:os";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
function projectsDir() {
|
|
@@ -72,12 +73,60 @@ function parseChatGPTExport(data, path) {
|
|
|
72
73
|
path,
|
|
73
74
|
};
|
|
74
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Read a JSONL transcript line by line without ever materializing the whole
|
|
78
|
+
* file as one string.
|
|
79
|
+
*
|
|
80
|
+
* Agent sessions with large tool results reach hundreds of MB, and those are
|
|
81
|
+
* exactly the sessions that most need analysis — but V8 refuses to build a
|
|
82
|
+
* string past ~512MB, so readFileSync would throw on them (and in the hook,
|
|
83
|
+
* throw *silently*). Streaming has no such ceiling and keeps peak memory at
|
|
84
|
+
* one chunk. StringDecoder carries partial UTF-8 sequences across chunk
|
|
85
|
+
* boundaries so multi-byte characters are never corrupted.
|
|
86
|
+
*/
|
|
87
|
+
function forEachLine(path, onLine) {
|
|
88
|
+
const fd = openSync(path, "r");
|
|
89
|
+
const decoder = new StringDecoder("utf8");
|
|
90
|
+
const buf = Buffer.allocUnsafe(4 * 1024 * 1024);
|
|
91
|
+
let pending = "";
|
|
92
|
+
try {
|
|
93
|
+
for (;;) {
|
|
94
|
+
const bytes = readSync(fd, buf, 0, buf.length, null);
|
|
95
|
+
if (bytes === 0)
|
|
96
|
+
break;
|
|
97
|
+
pending += decoder.write(buf.subarray(0, bytes));
|
|
98
|
+
let nl;
|
|
99
|
+
while ((nl = pending.indexOf("\n")) !== -1) {
|
|
100
|
+
onLine(pending.slice(0, nl));
|
|
101
|
+
pending = pending.slice(nl + 1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
pending += decoder.end();
|
|
105
|
+
if (pending)
|
|
106
|
+
onLine(pending);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
closeSync(fd);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Peek at the first bytes to tell a ChatGPT export (JSON array) from JSONL. */
|
|
113
|
+
function startsWithArray(path) {
|
|
114
|
+
const fd = openSync(path, "r");
|
|
115
|
+
try {
|
|
116
|
+
const buf = Buffer.allocUnsafe(64);
|
|
117
|
+
const bytes = readSync(fd, buf, 0, 64, 0);
|
|
118
|
+
return buf.subarray(0, bytes).toString("utf8").trimStart().startsWith("[");
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
closeSync(fd);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
75
124
|
export function parseSessionFile(path) {
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
if (
|
|
125
|
+
// ChatGPT exports are one big JSON array, not JSONL — and small enough to
|
|
126
|
+
// read whole. Only peek first, so multi-hundred-MB JSONL is never slurped.
|
|
127
|
+
if (startsWithArray(path)) {
|
|
79
128
|
try {
|
|
80
|
-
const data = JSON.parse(
|
|
129
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
81
130
|
if (Array.isArray(data) && data.some((c) => c && typeof c.mapping === "object")) {
|
|
82
131
|
return parseChatGPTExport(data, path);
|
|
83
132
|
}
|
|
@@ -93,15 +142,15 @@ export function parseSessionFile(path) {
|
|
|
93
142
|
let lastCompactIndex = -1;
|
|
94
143
|
/** Newest API-reported input size, if the transcript carries usage. */
|
|
95
144
|
let reportedInputTokens;
|
|
96
|
-
|
|
145
|
+
forEachLine(path, (line) => {
|
|
97
146
|
if (!line.trim())
|
|
98
|
-
|
|
147
|
+
return;
|
|
99
148
|
let entry;
|
|
100
149
|
try {
|
|
101
150
|
entry = JSON.parse(line);
|
|
102
151
|
}
|
|
103
152
|
catch {
|
|
104
|
-
|
|
153
|
+
return;
|
|
105
154
|
}
|
|
106
155
|
// Titles are metadata lines; the last one wins.
|
|
107
156
|
if (entry.type === "custom-title" && entry.customTitle)
|
|
@@ -109,12 +158,12 @@ export function parseSessionFile(path) {
|
|
|
109
158
|
if (entry.type === "ai-title" && entry.aiTitle && !title)
|
|
110
159
|
title = entry.aiTitle;
|
|
111
160
|
if ((entry.type !== "user" && entry.type !== "assistant") || !entry.message)
|
|
112
|
-
|
|
161
|
+
return;
|
|
113
162
|
if (entry.isSidechain)
|
|
114
|
-
|
|
163
|
+
return; // subagent traffic has its own context window
|
|
115
164
|
const message = entry.message;
|
|
116
165
|
if (!message.role || message.content == null)
|
|
117
|
-
|
|
166
|
+
return;
|
|
118
167
|
if (typeof message.model === "string")
|
|
119
168
|
model = message.model;
|
|
120
169
|
const usage = message.usage;
|
|
@@ -126,7 +175,7 @@ export function parseSessionFile(path) {
|
|
|
126
175
|
if (entry.isCompactSummary)
|
|
127
176
|
lastCompactIndex = messages.length;
|
|
128
177
|
messages.push({ role: message.role, content: message.content });
|
|
129
|
-
}
|
|
178
|
+
});
|
|
130
179
|
// A compaction replaces everything before it: the summary entry IS the live
|
|
131
180
|
// history from that point on. Counting the pre-compaction turns would
|
|
132
181
|
// overstate context, cost per message and window fill — sometimes hugely.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
"LICENSE"
|
|
37
37
|
],
|
|
38
38
|
"engines": {
|
|
39
|
-
"node": ">=
|
|
39
|
+
"node": ">=20"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
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/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"
|
|
45
|
+
"test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.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"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
@@ -27,7 +27,7 @@ Proactively (do not wait to be asked):
|
|
|
27
27
|
|
|
28
28
|
If the `context-doctor` MCP tools are available:
|
|
29
29
|
- `profile_context` — pass a conversation JSON (OpenAI or Anthropic format) or raw text; returns a token breakdown, largest messages, findings with estimated savings.
|
|
30
|
-
- `optimize_context` — applies deterministic fixes (dedupe, trim stale tool results, strip base64; opt-in `prune-history`). When the result contains pruned-turn source material and asks for a summary, **you write that summary** (≤150 tokens, dense, factual) and place it where the stub indicates — this is how summarization works without any API key.
|
|
30
|
+
- `optimize_context` — applies deterministic fixes (dedupe, trim stale tool results, strip base64; opt-in `trim-tool-calls` for big inline file writes and `prune-history`). When the result contains pruned-turn source material and asks for a summary, **you write that summary** (≤150 tokens, dense, factual) and place it where the stub indicates — this is how summarization works without any API key.
|
|
31
31
|
- `context_best_practices` — provider-specific checklist to share with the user.
|
|
32
32
|
|
|
33
33
|
If the tools are not connected, the CLI does the same: `npx context-doctor analyze <file> --model <model>` and `npx context-doctor optimize <file>`. For always-on optimization of the user's own apps: `npx context-doctor proxy` then point `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` at it.
|