context-doctor 0.31.0 → 0.32.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 +25 -25
- package/dist/autoclear.d.ts +58 -7
- package/dist/autoclear.js +155 -30
- package/dist/autopilot.d.ts +23 -3
- package/dist/autopilot.js +100 -29
- package/dist/cache.d.ts +6 -2
- package/dist/cache.js +48 -23
- package/dist/cacheplan.d.ts +5 -3
- package/dist/cacheplan.js +46 -8
- package/dist/calibration.d.ts +7 -2
- package/dist/calibration.js +14 -3
- package/dist/ci.js +25 -5
- package/dist/cli.js +61 -26
- package/dist/coldresume.d.ts +32 -7
- package/dist/coldresume.js +148 -34
- package/dist/compactquality.js +34 -12
- package/dist/compactwindow.js +59 -34
- package/dist/config.d.ts +6 -0
- package/dist/config.js +4 -1
- package/dist/cursor.d.ts +9 -0
- package/dist/cursor.js +53 -22
- package/dist/dashboard.d.ts +10 -0
- package/dist/dashboard.js +27 -2
- package/dist/diff.js +10 -3
- package/dist/doctor.js +133 -18
- package/dist/embed.d.ts +9 -0
- package/dist/embed.js +40 -5
- package/dist/experiment.d.ts +1 -0
- package/dist/experiment.js +6 -3
- package/dist/hook.js +23 -4
- package/dist/impact.d.ts +32 -0
- package/dist/impact.js +45 -6
- package/dist/install.d.ts +34 -2
- package/dist/install.js +223 -30
- package/dist/ledger.js +9 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +2 -2
- package/dist/mcpschema.d.ts +16 -1
- package/dist/mcpschema.js +95 -24
- package/dist/optimize.js +149 -64
- package/dist/overhead.js +57 -27
- package/dist/pack.d.ts +11 -0
- package/dist/pack.js +75 -12
- package/dist/parse.js +42 -18
- package/dist/pricing.d.ts +57 -5
- package/dist/pricing.js +94 -8
- package/dist/proxy.d.ts +13 -2
- package/dist/proxy.js +258 -43
- package/dist/reads.d.ts +1 -1
- package/dist/reads.js +61 -24
- package/dist/report.d.ts +10 -0
- package/dist/report.js +12 -4
- package/dist/savings.d.ts +20 -8
- package/dist/savings.js +79 -34
- package/dist/session.d.ts +37 -0
- package/dist/session.js +143 -6
- package/dist/split.js +6 -1
- package/dist/statusline.d.ts +3 -1
- package/dist/statusline.js +29 -17
- package/dist/subagentpay.d.ts +5 -2
- package/dist/subagentpay.js +50 -16
- package/dist/subagents.d.ts +7 -3
- package/dist/subagents.js +28 -8
- package/dist/tasks.d.ts +22 -7
- package/dist/tasks.js +47 -20
- package/dist/wrap.d.ts +6 -0
- package/dist/wrap.js +142 -6
- package/package.json +17 -2
package/dist/doctor.js
CHANGED
|
@@ -13,7 +13,7 @@ import { dirname, join } from "node:path";
|
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { ledgerPath } from "./ledger.js";
|
|
15
15
|
import { loadConfig } from "./config.js";
|
|
16
|
-
import { geminiCliPresent, geminiSettingsPath } from "./install.js";
|
|
16
|
+
import { binOnPath, codexMcpEntry, geminiCliPresent, geminiSettingsPath, launcherNamesMissingPackage } from "./install.js";
|
|
17
17
|
import { describeDesktopUsage, desktopUsage } from "./desktopusage.js";
|
|
18
18
|
function claudeDesktopConfigPath() {
|
|
19
19
|
switch (platform()) {
|
|
@@ -22,14 +22,30 @@ function claudeDesktopConfigPath() {
|
|
|
22
22
|
default: return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The verdict on a pre-0.31.2 launcher, `npx -y context-doctor-mcp`. No npm
|
|
27
|
+
* package has that name; npx only falls back to a command of that name on
|
|
28
|
+
* PATH. With a global install that keeps it working (until that install
|
|
29
|
+
* goes); without one npm answers 404 and the app shows no tools, no error.
|
|
30
|
+
*/
|
|
31
|
+
function missingPackageCheck(label) {
|
|
32
|
+
return binOnPath("context-doctor-mcp")
|
|
33
|
+
? { label, status: "warn", detail: "MCP entry runs `npx context-doctor-mcp`, which starts only through your global install (no npm package has that name) — re-run: context-doctor install" }
|
|
34
|
+
: { label, status: "fail", detail: "MCP entry runs `npx context-doctor-mcp`, a package that does not exist on npm, so the server never starts — re-run: context-doctor install" };
|
|
35
|
+
}
|
|
36
|
+
function checkMcpEntry(appName, configPath, plugin = false) {
|
|
37
|
+
const viaPlugin = { label: appName, status: "ok", detail: "MCP server provided by the context-doctor plugin" };
|
|
26
38
|
if (!existsSync(configPath))
|
|
27
|
-
return { label: appName, status: "skip", detail: "app not detected (config file absent)" };
|
|
39
|
+
return plugin ? viaPlugin : { label: appName, status: "skip", detail: "app not detected (config file absent)" };
|
|
28
40
|
try {
|
|
29
41
|
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
30
42
|
const entry = config.mcpServers?.["context-doctor"];
|
|
43
|
+
if (!entry && plugin)
|
|
44
|
+
return viaPlugin;
|
|
31
45
|
if (!entry)
|
|
32
46
|
return { label: appName, status: "fail", detail: `no context-doctor entry in ${configPath} — run: context-doctor install` };
|
|
47
|
+
if (launcherNamesMissingPackage(entry))
|
|
48
|
+
return missingPackageCheck(appName);
|
|
33
49
|
// Absolute-path entries must point at a file that still exists. Launcher
|
|
34
50
|
// forms (npx, and Windows' cmd /c npx) resolve at spawn time, not now.
|
|
35
51
|
const launcher = entry.command === "npx" || entry.command === "cmd";
|
|
@@ -70,6 +86,46 @@ function hookBinaryMissing(command) {
|
|
|
70
86
|
}
|
|
71
87
|
return null;
|
|
72
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Claude Code's settings files, lowest precedence first: user, project,
|
|
91
|
+
* local. A later file's value for a key wins, as in Claude Code itself.
|
|
92
|
+
*/
|
|
93
|
+
function claudeSettingsLayers() {
|
|
94
|
+
const paths = [
|
|
95
|
+
join(homedir(), ".claude", "settings.json"),
|
|
96
|
+
join(process.cwd(), ".claude", "settings.json"),
|
|
97
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
98
|
+
];
|
|
99
|
+
return [...new Set(paths)].flatMap((p) => {
|
|
100
|
+
try {
|
|
101
|
+
return existsSync(p) ? [JSON.parse(readFileSync(p, "utf8"))] : [];
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return []; // an unreadable user settings.json is reported by the hook check
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Is the context-doctor plugin enabled? Plugins are switched on with
|
|
110
|
+
* enabledPlugins: {"<plugin>@<marketplace>": true}; ours is named
|
|
111
|
+
* context-doctor in whichever marketplace it came from. A plugin brings its
|
|
112
|
+
* own MCP server and hooks, so neither ~/.claude.json nor settings.json's
|
|
113
|
+
* hooks mention us, and those checks failed on a working plugin install.
|
|
114
|
+
*/
|
|
115
|
+
function pluginEnabled(layers) {
|
|
116
|
+
const state = new Map();
|
|
117
|
+
for (const s of layers) {
|
|
118
|
+
for (const [key, on] of Object.entries((s.enabledPlugins ?? {}))) {
|
|
119
|
+
if (key.startsWith("context-doctor@"))
|
|
120
|
+
state.set(key, on === true);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [...state.values()].some(Boolean);
|
|
124
|
+
}
|
|
125
|
+
/** Claude Code's disableAllHooks: when true, no hook of anyone's runs. */
|
|
126
|
+
function hooksDisabled(layers) {
|
|
127
|
+
return layers.reduce((off, s) => (typeof s.disableAllHooks === "boolean" ? s.disableAllHooks : off), false);
|
|
128
|
+
}
|
|
73
129
|
/** Spawn our own MCP server and run the initialize handshake over stdio. */
|
|
74
130
|
function checkMcpHandshake() {
|
|
75
131
|
const label = "MCP server handshake";
|
|
@@ -113,16 +169,31 @@ export async function runDoctor() {
|
|
|
113
169
|
const desktop = desktopUsage();
|
|
114
170
|
if (desktop)
|
|
115
171
|
checks.push({ label: "Desktop chat tool use", status: "ok", detail: describeDesktopUsage(desktop, recentSurfaces(7).get("claude-desktop") ?? 0) });
|
|
116
|
-
|
|
172
|
+
const layers = claudeSettingsLayers();
|
|
173
|
+
const plugin = pluginEnabled(layers);
|
|
174
|
+
checks.push(checkMcpEntry("Claude Code", join(homedir(), ".claude.json"), plugin));
|
|
117
175
|
checks.push(checkMcpEntry("Cursor", join(homedir(), ".cursor", "mcp.json")));
|
|
118
|
-
// Hook registration
|
|
176
|
+
// Hook registration. ~/.claude without a settings.json is Claude Code with
|
|
177
|
+
// no hook registered, not Claude Code absent.
|
|
119
178
|
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
120
|
-
if (existsSync(settingsPath)) {
|
|
179
|
+
if (existsSync(settingsPath) || existsSync(join(homedir(), ".claude"))) {
|
|
121
180
|
try {
|
|
122
|
-
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
181
|
+
const settings = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf8")) : {};
|
|
123
182
|
const entries = settings.hooks?.UserPromptSubmit ?? [];
|
|
124
183
|
const ours = entries.map((e) => e.hooks?.[0]?.command ?? "").find((c) => /context-doctor|cli\.js"?\s+hook/.test(c));
|
|
125
|
-
if (
|
|
184
|
+
if (hooksDisabled(layers)) {
|
|
185
|
+
// Registered or not, nothing runs: say that first, it is the fix.
|
|
186
|
+
checks.push({
|
|
187
|
+
label: "Every-prompt hook",
|
|
188
|
+
status: "fail",
|
|
189
|
+
detail: `${ours || plugin ? "registered" : "not registered"}, and disableAllHooks is true in Claude Code's settings, so Claude Code runs no hooks at all; remove that setting${ours || plugin ? "" : ", then run: context-doctor install"}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else if (!ours && plugin) {
|
|
193
|
+
checks.push({ label: "Every-prompt hook", status: "ok", detail: "provided by the context-doctor plugin" });
|
|
194
|
+
checks.push({ label: "Compaction hook", status: "ok", detail: "PreCompact: provided by the context-doctor plugin" });
|
|
195
|
+
}
|
|
196
|
+
else if (!ours) {
|
|
126
197
|
checks.push({ label: "Every-prompt hook", status: "fail", detail: "not registered — run: context-doctor install" });
|
|
127
198
|
}
|
|
128
199
|
else {
|
|
@@ -134,7 +205,7 @@ export async function runDoctor() {
|
|
|
134
205
|
? { label: "Every-prompt hook", status: "fail", detail: `registered, but ${missing} no longer exists — re-run: context-doctor install` }
|
|
135
206
|
: { label: "Every-prompt hook", status: "ok", detail: "registered in ~/.claude/settings.json; command resolves" });
|
|
136
207
|
const pre = settings.hooks?.PreCompact ?? [];
|
|
137
|
-
const preOurs = pre.some((e) => /context-doctor|cli\.js"?\s+hook/.test(e.hooks?.[0]?.command ?? ""));
|
|
208
|
+
const preOurs = plugin || pre.some((e) => /context-doctor|cli\.js"?\s+hook/.test(e.hooks?.[0]?.command ?? ""));
|
|
138
209
|
checks.push(preOurs
|
|
139
210
|
? { label: "Compaction hook", status: "ok", detail: "PreCompact: each compaction's summary keeps the session's files, open tasks, your instructions and failing commands" }
|
|
140
211
|
: { label: "Compaction hook", status: "warn", detail: "not registered: compactions use generic instructions; run context-doctor install" });
|
|
@@ -151,9 +222,14 @@ export async function runDoctor() {
|
|
|
151
222
|
const codexHome = join(homedir(), ".codex");
|
|
152
223
|
if (existsSync(codexHome)) {
|
|
153
224
|
const toml = existsSync(join(codexHome, "config.toml")) ? readFileSync(join(codexHome, "config.toml"), "utf8") : "";
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
225
|
+
// Parsed rather than searched for one exact header: a quoted key or a
|
|
226
|
+
// trailing comment is the same table to Codex.
|
|
227
|
+
const codexEntry = codexMcpEntry(toml);
|
|
228
|
+
checks.push(!codexEntry
|
|
229
|
+
? { label: "Codex MCP", status: "fail", detail: "Codex detected but not wired — run: context-doctor install" }
|
|
230
|
+
: launcherNamesMissingPackage(codexEntry)
|
|
231
|
+
? missingPackageCheck("Codex MCP")
|
|
232
|
+
: { label: "Codex MCP", status: "ok", detail: "wired in ~/.codex/config.toml" });
|
|
157
233
|
try {
|
|
158
234
|
const hooksPath = join(codexHome, "hooks.json");
|
|
159
235
|
const hooks = existsSync(hooksPath) ? JSON.parse(readFileSync(hooksPath, "utf8")) : {};
|
|
@@ -181,9 +257,12 @@ export async function runDoctor() {
|
|
|
181
257
|
try {
|
|
182
258
|
const path = geminiSettingsPath();
|
|
183
259
|
const settings = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
260
|
+
const geminiEntry = settings.mcpServers?.["context-doctor"];
|
|
261
|
+
checks.push(!geminiEntry
|
|
262
|
+
? { label: "Gemini CLI MCP", status: "fail", detail: "Gemini CLI detected but not wired — run: context-doctor install" }
|
|
263
|
+
: launcherNamesMissingPackage(geminiEntry)
|
|
264
|
+
? missingPackageCheck("Gemini CLI MCP")
|
|
265
|
+
: { label: "Gemini CLI MCP", status: "ok", detail: "wired in ~/.gemini/settings.json" });
|
|
187
266
|
const entries = settings.hooks?.BeforeAgent ?? [];
|
|
188
267
|
const ours = entries.map((e) => e.hooks?.[0]?.command ?? "").find((c) => /context-doctor|cli\.js"?\s+hook/.test(c));
|
|
189
268
|
const missing = ours ? hookBinaryMissing(ours) : undefined;
|
|
@@ -229,7 +308,9 @@ export async function runDoctor() {
|
|
|
229
308
|
const missing = hookBinaryMissing(cmd);
|
|
230
309
|
checks.push(missing
|
|
231
310
|
? { label: "Status line", status: "fail", detail: `configured, but ${missing} no longer exists — re-run: context-doctor install --statusline` }
|
|
232
|
-
:
|
|
311
|
+
: hooksDisabled(layers)
|
|
312
|
+
? { label: "Status line", status: "fail", detail: "configured, but disableAllHooks also turns off custom status lines" }
|
|
313
|
+
: { label: "Status line", status: "ok", detail: "live context in Claude Code's status bar" });
|
|
233
314
|
}
|
|
234
315
|
else {
|
|
235
316
|
checks.push({ label: "Status line", status: "skip", detail: cmd ? "you have your own statusLine (left alone)" : "not enabled (optional: context-doctor install --statusline)" });
|
|
@@ -259,7 +340,12 @@ export async function runDoctor() {
|
|
|
259
340
|
// Project config: a setting that is silently ignored looks exactly like the
|
|
260
341
|
// feature being broken, so name it here rather than leaving it to be guessed.
|
|
261
342
|
const loaded = loadConfig(process.cwd());
|
|
262
|
-
if (loaded.path) {
|
|
343
|
+
if (loaded.path && loaded.error) {
|
|
344
|
+
// A file with a typo used to read as "no .contextdoctorrc": the budget the
|
|
345
|
+
// user wrote is silently not in force, which is the case worth naming.
|
|
346
|
+
checks.push({ label: "Project config", status: "fail", detail: `${loaded.path} cannot be read (${loaded.error}): every setting in it is ignored until it parses as a JSON object` });
|
|
347
|
+
}
|
|
348
|
+
else if (loaded.path) {
|
|
263
349
|
const warnings = loaded.warnings ?? [];
|
|
264
350
|
checks.push(warnings.length === 0
|
|
265
351
|
? { label: "Project config", status: "ok", detail: `${loaded.path} — all settings understood` }
|
|
@@ -273,11 +359,40 @@ export async function runDoctor() {
|
|
|
273
359
|
// dead proxy or a settings file pointing elsewhere is a real problem.
|
|
274
360
|
{
|
|
275
361
|
const paths = autopilotPaths();
|
|
362
|
+
// A truncated file used to throw at JSON.parse here, before a single line
|
|
363
|
+
// of the report was printed: one damaged file hid every other check.
|
|
364
|
+
let cfg;
|
|
365
|
+
let damage = "";
|
|
366
|
+
if (existsSync(paths.config)) {
|
|
367
|
+
try {
|
|
368
|
+
const parsed = JSON.parse(readFileSync(paths.config, "utf8"));
|
|
369
|
+
if (typeof parsed?.port === "number")
|
|
370
|
+
cfg = parsed;
|
|
371
|
+
else
|
|
372
|
+
damage = "no port in it";
|
|
373
|
+
}
|
|
374
|
+
catch (e) {
|
|
375
|
+
damage = e.message;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
276
378
|
if (!existsSync(paths.config)) {
|
|
277
379
|
checks.push({ label: "Autopilot", status: "skip", detail: "off (context-doctor autopilot on: clears stale tool output in every new Claude Code session)" });
|
|
278
380
|
}
|
|
381
|
+
else if (!cfg) {
|
|
382
|
+
let routedTo;
|
|
383
|
+
try {
|
|
384
|
+
routedTo = JSON.parse(readFileSync(paths.settings, "utf8"))?.env?.ANTHROPIC_BASE_URL;
|
|
385
|
+
}
|
|
386
|
+
catch { /* reported by the hook check */ }
|
|
387
|
+
checks.push({
|
|
388
|
+
label: "Autopilot",
|
|
389
|
+
status: "fail",
|
|
390
|
+
detail: `${paths.config} is damaged (${damage}). Run: context-doctor autopilot off` +
|
|
391
|
+
(routedTo ? `, then remove env.ANTHROPIC_BASE_URL (${routedTo}) from ~/.claude/settings.json if it is the proxy` : "") +
|
|
392
|
+
"; autopilot on starts it again cleanly",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
279
395
|
else {
|
|
280
|
-
const cfg = JSON.parse(readFileSync(paths.config, "utf8"));
|
|
281
396
|
const installed = typeof cfg.cli === "string" ? installedVersion(cfg.cli) : undefined;
|
|
282
397
|
const h = await health(cfg.port);
|
|
283
398
|
let routed = false;
|
package/dist/embed.d.ts
CHANGED
|
@@ -19,6 +19,15 @@ export interface SemanticResult {
|
|
|
19
19
|
/** Why semantic ranking was not used, or what it did. */
|
|
20
20
|
note: string;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* The URL to reach Ollama at, read from OLLAMA_HOST the way Ollama reads it:
|
|
24
|
+
* no scheme means http on 11434 (`0.0.0.0`, `localhost:8080`), an explicit
|
|
25
|
+
* http:// or https:// without a port means 80 or 443. Taking `0.0.0.0` as
|
|
26
|
+
* `http://0.0.0.0` aimed at port 80, so semantic ranking was silently off for
|
|
27
|
+
* the most common setting. 0.0.0.0 and :: are where a server listens, not an
|
|
28
|
+
* address to connect to, so they mean this machine.
|
|
29
|
+
*/
|
|
30
|
+
export declare function ollamaUrl(env?: string | undefined): string;
|
|
22
31
|
/** The embedding model to use: the one asked for, else the first installed model that looks like one. */
|
|
23
32
|
export declare function findEmbeddingModel(preferred?: string): Promise<string | undefined>;
|
|
24
33
|
/**
|
package/dist/embed.js
CHANGED
|
@@ -19,12 +19,47 @@ import { join } from "node:path";
|
|
|
19
19
|
const EMBED_NAME = /embed|bge|e5-|gte|minilm|nomic|mxbai|arctic|snowflake/i;
|
|
20
20
|
const BATCH = 64;
|
|
21
21
|
const CACHE_CAP = 50_000;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The URL to reach Ollama at, read from OLLAMA_HOST the way Ollama reads it:
|
|
24
|
+
* no scheme means http on 11434 (`0.0.0.0`, `localhost:8080`), an explicit
|
|
25
|
+
* http:// or https:// without a port means 80 or 443. Taking `0.0.0.0` as
|
|
26
|
+
* `http://0.0.0.0` aimed at port 80, so semantic ranking was silently off for
|
|
27
|
+
* the most common setting. 0.0.0.0 and :: are where a server listens, not an
|
|
28
|
+
* address to connect to, so they mean this machine.
|
|
29
|
+
*/
|
|
30
|
+
export function ollamaUrl(env = process.env.OLLAMA_HOST) {
|
|
31
|
+
let rest = (env ?? "").trim();
|
|
32
|
+
let scheme = "http";
|
|
33
|
+
let port = "11434";
|
|
34
|
+
const sep = rest.indexOf("://");
|
|
35
|
+
if (sep >= 0) {
|
|
36
|
+
scheme = rest.slice(0, sep);
|
|
37
|
+
rest = rest.slice(sep + 3);
|
|
38
|
+
if (scheme === "http")
|
|
39
|
+
port = "80";
|
|
40
|
+
else if (scheme === "https")
|
|
41
|
+
port = "443";
|
|
42
|
+
}
|
|
43
|
+
const slash = rest.indexOf("/");
|
|
44
|
+
const path = slash >= 0 ? rest.slice(slash).replace(/\/+$/, "") : "";
|
|
45
|
+
let host = slash >= 0 ? rest.slice(0, slash) : rest;
|
|
46
|
+
const m = /^(\[[^\]]*\]|[^:]*):([^:]*)$/.exec(host);
|
|
47
|
+
if (m) {
|
|
48
|
+
host = m[1];
|
|
49
|
+
if (/^\d+$/.test(m[2]) && Number(m[2]) <= 65535)
|
|
50
|
+
port = m[2];
|
|
51
|
+
}
|
|
52
|
+
else if (host.includes(":") && !host.startsWith("[")) {
|
|
53
|
+
host = `[${host}]`; // a bare IPv6 address
|
|
54
|
+
}
|
|
55
|
+
if (!host || host === "0.0.0.0")
|
|
56
|
+
host = "127.0.0.1";
|
|
57
|
+
else if (host === "[::]")
|
|
58
|
+
host = "[::1]";
|
|
59
|
+
return `${scheme}://${host}:${port}${path}`;
|
|
25
60
|
}
|
|
26
61
|
async function getJson(path, body, timeoutMs = 60_000) {
|
|
27
|
-
const r = await fetch(
|
|
62
|
+
const r = await fetch(ollamaUrl() + path, {
|
|
28
63
|
method: body ? "POST" : "GET",
|
|
29
64
|
headers: body ? { "content-type": "application/json" } : undefined,
|
|
30
65
|
body: body ? JSON.stringify(body) : undefined,
|
|
@@ -94,7 +129,7 @@ export async function semanticRerank(chunks, query, opts = {}) {
|
|
|
94
129
|
model = await findEmbeddingModel(opts.model);
|
|
95
130
|
}
|
|
96
131
|
catch {
|
|
97
|
-
return { used: false, note: `semantic ranking off: no Ollama at ${
|
|
132
|
+
return { used: false, note: `semantic ranking off: no Ollama at ${ollamaUrl()} (keyword ranking only)` };
|
|
98
133
|
}
|
|
99
134
|
if (!model)
|
|
100
135
|
return { used: false, note: "semantic ranking off: Ollama has no embedding model; run `ollama pull nomic-embed-text` (keyword ranking only)" };
|
package/dist/experiment.d.ts
CHANGED
|
@@ -49,6 +49,7 @@ export interface ArmResult {
|
|
|
49
49
|
}
|
|
50
50
|
/** Build the argv for one arm — exported so the dry run and the tests see exactly what runs. */
|
|
51
51
|
export declare function claudeArgs(opts: ExperimentOptions, arm: "fresh" | "existing"): string[];
|
|
52
|
+
export declare function findTranscript(sessionId: string, cwd: string): string | undefined;
|
|
52
53
|
export declare function runExperiment(opts: ExperimentOptions): {
|
|
53
54
|
arms: ArmResult[];
|
|
54
55
|
startCommit?: string;
|
package/dist/experiment.js
CHANGED
|
@@ -39,9 +39,12 @@ export function claudeArgs(opts, arm) {
|
|
|
39
39
|
args.push("--resume", opts.existing, "--fork-session");
|
|
40
40
|
return args;
|
|
41
41
|
}
|
|
42
|
-
function findTranscript(sessionId, cwd) {
|
|
43
|
-
// Claude Code keys project dirs by the cwd with
|
|
44
|
-
|
|
42
|
+
export function findTranscript(sessionId, cwd) {
|
|
43
|
+
// Claude Code keys project dirs by the cwd with EVERY non-alphanumeric
|
|
44
|
+
// character replaced (/Users/me/.claude/x_y -> -Users-me--claude-x-y), as
|
|
45
|
+
// overhead.ts does. Replacing only separators missed any cwd with a dot,
|
|
46
|
+
// underscore or space, and the "live context" row silently disappeared.
|
|
47
|
+
const projectDir = join(homedir(), ".claude", "projects", cwd.replace(/[^A-Za-z0-9]/g, "-"));
|
|
45
48
|
const candidate = join(projectDir, `${sessionId}.jsonl`);
|
|
46
49
|
return existsSync(candidate) ? candidate : undefined;
|
|
47
50
|
}
|
package/dist/hook.js
CHANGED
|
@@ -21,7 +21,7 @@ import { profileConversation } from "./profile.js";
|
|
|
21
21
|
import { parseSessionFile } from "./session.js";
|
|
22
22
|
import { formatTokens, CHARS_PER_TOKEN, LARGEST_WINDOW } from "./tokens.js";
|
|
23
23
|
import { ensureProxyUp } from "./autopilot.js";
|
|
24
|
-
import { detectColdResume, renderColdResume, renderColdResumeNotice } from "./coldresume.js";
|
|
24
|
+
import { detectColdResume, renderColdResume, renderColdResumeNotice, sessionWriteMultiplier } from "./coldresume.js";
|
|
25
25
|
import { formatUsd, pricingFor } from "./pricing.js";
|
|
26
26
|
import { checkBudget, loadConfig } from "./config.js";
|
|
27
27
|
import { compactFacts, compactInstructions } from "./precompact.js";
|
|
@@ -48,10 +48,21 @@ const REGROWTH_FACTOR = 1.4;
|
|
|
48
48
|
* hold that many tokens of context. Lean sessions cost one stat() call — the
|
|
49
49
|
* transcript is never even read. (This used 4 bytes per token until 0.19,
|
|
50
50
|
* which let Claude sessions past the threshold take the fast path.)
|
|
51
|
+
*
|
|
52
|
+
* The threshold is compared with the API's figure, which also counts what the
|
|
53
|
+
* transcript never holds: system prompt, tool schemas, memory files. Sized as
|
|
54
|
+
* if the transcript were the whole context, the gate hid sessions from low
|
|
55
|
+
* thresholds (a 60k budget saw nothing until 144 KB; half of one machine's
|
|
56
|
+
* sessions passed 60k live tokens below that). So that front matter is
|
|
57
|
+
* allowed for first. Measured on 78 Claude Code sessions with several MCP
|
|
58
|
+
* servers: first requests carried 27k-70k tokens, of which up to 43k could
|
|
59
|
+
* not have come from the transcript at its densest; 50k covers that. Lean
|
|
60
|
+
* sessions below the remaining byte count still cost one stat().
|
|
51
61
|
*/
|
|
52
62
|
const DENSEST_CHARS_PER_TOKEN = Math.min(...Object.values(CHARS_PER_TOKEN).map((r) => Math.min(r.prose, r.code)));
|
|
63
|
+
const FRONT_MATTER_ALLOWANCE = 50_000;
|
|
53
64
|
function minBytesForWarn(threshold) {
|
|
54
|
-
return Math.floor(threshold * DENSEST_CHARS_PER_TOKEN);
|
|
65
|
+
return Math.floor(Math.max(0, threshold - FRONT_MATTER_ALLOWANCE) * DENSEST_CHARS_PER_TOKEN);
|
|
55
66
|
}
|
|
56
67
|
/**
|
|
57
68
|
* State lives in one small file per session, not one shared map.
|
|
@@ -237,7 +248,11 @@ export async function runHook() {
|
|
|
237
248
|
const liveTokens = parsed.reportedInputTokens ?? profile.totalTokens;
|
|
238
249
|
// Record this parse so the next prompts take fast path 2.
|
|
239
250
|
const shouldWarn = liveTokens >= threshold && liveTokens >= prev.t * REGROWTH_FACTOR;
|
|
240
|
-
|
|
251
|
+
// The regrowth bar follows the context DOWN as well: after a compaction it
|
|
252
|
+
// is measured from the compacted size. Kept at the old peak, the next
|
|
253
|
+
// warning needed 1.4x a size the session no longer had (seen: warned at
|
|
254
|
+
// 843k, compacted, then regrew to 809k without a word).
|
|
255
|
+
writeSessionState(sessionId, { t: shouldWarn ? liveTokens : Math.min(prev.t, liveTokens), b: sizeBytes, cr });
|
|
241
256
|
recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: liveTokens, warn: shouldWarn });
|
|
242
257
|
if (!shouldWarn) {
|
|
243
258
|
emit(notes);
|
|
@@ -251,9 +266,13 @@ export async function runHook() {
|
|
|
251
266
|
// context at the cached rate (0.1x) and pays the full rate only when the
|
|
252
267
|
// cache has expired. Quoting the uncached figure alone overstated the
|
|
253
268
|
// per-message cost tenfold (fixed in 0.22).
|
|
269
|
+
// An expired cache is re-written at the TTL this session writes at, read
|
|
270
|
+
// from the transcript's tail: Claude Code's main thread has mostly
|
|
271
|
+
// written the 1-hour cache, at 2x input, where a flat 1.25x quoted the
|
|
272
|
+
// cold message 37% low (5-minute writes stay at 1.25x).
|
|
254
273
|
const pricing = pricingFor(model);
|
|
255
274
|
const cachedUsd = pricing ? (liveTokens * pricing.cacheReadPerM) / 1e6 : undefined;
|
|
256
|
-
const coldUsd = pricing ? (liveTokens * pricing.inputPerM *
|
|
275
|
+
const coldUsd = pricing ? (liveTokens * pricing.inputPerM * sessionWriteMultiplier(transcriptPath)) / 1e6 : undefined;
|
|
257
276
|
// Cursor's agent transcript records tool calls but not their output, and
|
|
258
277
|
// keeps turns Cursor has since summarized, so it measures how much the chat
|
|
259
278
|
// has accumulated, not the live context: no window share or price for it.
|
package/dist/impact.d.ts
CHANGED
|
@@ -8,4 +8,36 @@
|
|
|
8
8
|
* session cannot be re-run without it. The report says so instead of inventing
|
|
9
9
|
* a number.
|
|
10
10
|
*/
|
|
11
|
+
import { LedgerEntry } from "./ledger.js";
|
|
12
|
+
/** The parts of the proxy's /stats that lifetime savings depend on. */
|
|
13
|
+
export interface LiveProxyStats {
|
|
14
|
+
startedAt?: string;
|
|
15
|
+
tokensSaved?: number;
|
|
16
|
+
estUsdSaved?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ProxySavings {
|
|
19
|
+
/** Lifetime tokens the proxy removed: ledger checkpoints plus what the live run has not checkpointed. */
|
|
20
|
+
tokens: number;
|
|
21
|
+
usd: number;
|
|
22
|
+
/** The part of `tokens` from runs before the live one (all of it when none is running). */
|
|
23
|
+
earlier: number;
|
|
24
|
+
/** Ledger checkpoints written by those earlier runs. */
|
|
25
|
+
earlierCheckpoints: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Lifetime proxy savings, for `report` and the dashboard alike.
|
|
29
|
+
*
|
|
30
|
+
* A running proxy writes the DELTA of its total to the ledger every 60 s
|
|
31
|
+
* (proxy.ts, so history survives a restart), while /stats keeps returning the
|
|
32
|
+
* whole total since its startedAt. Ledger + /stats therefore counted every
|
|
33
|
+
* checkpointed token twice: one request saving 22k read "~45k" a minute later.
|
|
34
|
+
* Here every ledger checkpoint counts once, and the live run adds only what its
|
|
35
|
+
* own checkpoints (entries written since its startedAt) do not hold yet. The
|
|
36
|
+
* dashboard used to show the live figure alone, so a stopped proxy read 0.
|
|
37
|
+
*
|
|
38
|
+
* Known limit: if ledger rotation folds this run's own checkpoints into a
|
|
39
|
+
* rollup, they can no longer be told apart and count twice again. Only the
|
|
40
|
+
* proxy can close that (by reporting its checkpointed total in /stats).
|
|
41
|
+
*/
|
|
42
|
+
export declare function proxySavings(ledger: LedgerEntry[], live: LiveProxyStats | null): ProxySavings;
|
|
11
43
|
export declare function buildImpactReport(proxyPort?: number): Promise<string>;
|
package/dist/impact.js
CHANGED
|
@@ -17,6 +17,45 @@ import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
|
|
|
17
17
|
import { coldResumeFollowThrough } from "./coldresume.js";
|
|
18
18
|
/** Sessions larger than this are skipped in the report (keeps it snappy). */
|
|
19
19
|
const MAX_SESSION_BYTES = 30 * 1024 * 1024;
|
|
20
|
+
/**
|
|
21
|
+
* Lifetime proxy savings, for `report` and the dashboard alike.
|
|
22
|
+
*
|
|
23
|
+
* A running proxy writes the DELTA of its total to the ledger every 60 s
|
|
24
|
+
* (proxy.ts, so history survives a restart), while /stats keeps returning the
|
|
25
|
+
* whole total since its startedAt. Ledger + /stats therefore counted every
|
|
26
|
+
* checkpointed token twice: one request saving 22k read "~45k" a minute later.
|
|
27
|
+
* Here every ledger checkpoint counts once, and the live run adds only what its
|
|
28
|
+
* own checkpoints (entries written since its startedAt) do not hold yet. The
|
|
29
|
+
* dashboard used to show the live figure alone, so a stopped proxy read 0.
|
|
30
|
+
*
|
|
31
|
+
* Known limit: if ledger rotation folds this run's own checkpoints into a
|
|
32
|
+
* rollup, they can no longer be told apart and count twice again. Only the
|
|
33
|
+
* proxy can close that (by reporting its checkpointed total in /stats).
|
|
34
|
+
*/
|
|
35
|
+
export function proxySavings(ledger, live) {
|
|
36
|
+
const carried = foldTotals(ledger.filter((e) => e.ev === "rollup"));
|
|
37
|
+
const events = ledger.filter((e) => e.ev === "proxy");
|
|
38
|
+
const sum = (es, pick) => es.reduce((s, e) => s + (pick(e) ?? 0), 0);
|
|
39
|
+
const recorded = sum(events, (e) => e.saved) + carried.proxySaved;
|
|
40
|
+
const recordedUsd = sum(events, (e) => e.usd) + carried.proxyUsd;
|
|
41
|
+
if (!live)
|
|
42
|
+
return { tokens: recorded, usd: recordedUsd, earlier: recorded, earlierCheckpoints: events.length };
|
|
43
|
+
// Without a parseable startedAt no checkpoint can be attributed to this run;
|
|
44
|
+
// count the live total whole, as before.
|
|
45
|
+
const since = Date.parse(live.startedAt ?? "");
|
|
46
|
+
const thisRun = Number.isFinite(since) ? events.filter((e) => e.ts >= since) : [];
|
|
47
|
+
const liveTokens = live.tokensSaved ?? 0;
|
|
48
|
+
// max(0, …): another proxy checkpointing meanwhile must not make this negative.
|
|
49
|
+
const pending = Math.max(0, liveTokens - sum(thisRun, (e) => e.saved));
|
|
50
|
+
const pendingUsd = Math.max(0, (live.estUsdSaved ?? 0) - sum(thisRun, (e) => e.usd));
|
|
51
|
+
const tokens = recorded + pending;
|
|
52
|
+
return {
|
|
53
|
+
tokens,
|
|
54
|
+
usd: recordedUsd + pendingUsd,
|
|
55
|
+
earlier: Math.max(0, tokens - liveTokens),
|
|
56
|
+
earlierCheckpoints: events.length - thisRun.length,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
20
59
|
async function fetchProxyStats(port) {
|
|
21
60
|
try {
|
|
22
61
|
const res = await fetch(`http://127.0.0.1:${port}/stats`, { signal: AbortSignal.timeout(500) });
|
|
@@ -74,11 +113,11 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
74
113
|
optimizeUsd += inputCostUsd(e.saved, pricing);
|
|
75
114
|
}
|
|
76
115
|
const proxy = await fetchProxyStats(proxyPort);
|
|
77
|
-
// Persisted checkpoints cover
|
|
78
|
-
//
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
const
|
|
116
|
+
// Persisted checkpoints cover earlier runs AND the live one's past minutes;
|
|
117
|
+
// proxySavings counts each token once.
|
|
118
|
+
const savedByProxy = proxySavings(ledger, proxy);
|
|
119
|
+
const proxySaved = savedByProxy.tokens;
|
|
120
|
+
const proxyHistoric = savedByProxy.earlier;
|
|
82
121
|
// -- Headline: what context-doctor has saved ----------------------------------
|
|
83
122
|
// Only what context-doctor itself removed. Sessions also shrink when Claude
|
|
84
123
|
// Code auto-compacts or the user runs /compact; counting that as our saving
|
|
@@ -93,7 +132,7 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
93
132
|
}
|
|
94
133
|
else {
|
|
95
134
|
lines.push(proxyHistoric > 0
|
|
96
|
-
? ` · proxy (exact, from ${
|
|
135
|
+
? ` · proxy (exact, from ${savedByProxy.earlierCheckpoints} earlier run checkpoint(s)): ${formatTokens(proxyHistoric)} — not running now`
|
|
97
136
|
: ` · proxy: not running on :${proxyPort} (its exact savings appear here once it runs)`);
|
|
98
137
|
}
|
|
99
138
|
const familyNote = [...savedByFamily.entries()]
|
package/dist/install.d.ts
CHANGED
|
@@ -17,13 +17,37 @@ export declare function geminiCliPresent(): boolean;
|
|
|
17
17
|
* wrapper that every working Windows MCP config uses.
|
|
18
18
|
*
|
|
19
19
|
* Exported so the platform branch is testable from any host OS.
|
|
20
|
+
*
|
|
21
|
+
* npx resolves its first operand as a PACKAGE name. context-doctor-mcp is
|
|
22
|
+
* only a bin inside the context-doctor package, so `npx -y context-doctor-mcp`
|
|
23
|
+
* asked npm for a package that does not exist: unless a global install
|
|
24
|
+
* happened to put that command on PATH, every app got a server that never
|
|
25
|
+
* started. The main bin's `mcp` subcommand is the same server.
|
|
20
26
|
*/
|
|
21
27
|
export declare function npxLauncher(platformName: string): {
|
|
22
28
|
command: string;
|
|
23
29
|
args: string[];
|
|
24
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* True for the launcher installs before 0.31.2 wrote: npx asked to run a
|
|
33
|
+
* package named context-doctor-mcp, which is not on npm. npx does fall back
|
|
34
|
+
* to a command of that name already on PATH, so it starts where a global
|
|
35
|
+
* install provides one; everywhere else npm answers 404.
|
|
36
|
+
*/
|
|
37
|
+
export declare function launcherNamesMissingPackage(entry: {
|
|
38
|
+
command?: unknown;
|
|
39
|
+
args?: unknown;
|
|
40
|
+
} | undefined): boolean;
|
|
25
41
|
export declare function readJson(path: string): Record<string, any>;
|
|
42
|
+
/** Copy `path` to its .context-doctor.backup, once per run. */
|
|
43
|
+
export declare function backupOnce(path: string): void;
|
|
26
44
|
export declare function writeJsonWithBackup(path: string, data: Record<string, any>): void;
|
|
45
|
+
/**
|
|
46
|
+
* Find an executable on PATH without shelling out (works on Windows too).
|
|
47
|
+
* Used to prefer a global `context-doctor` install for the hook: a stable
|
|
48
|
+
* location that survives both package updates and Node upgrades.
|
|
49
|
+
*/
|
|
50
|
+
export declare function binOnPath(name: string): string | null;
|
|
27
51
|
/** Paths npm may delete at any time: the npx cache and the npm cache itself. */
|
|
28
52
|
export declare function isEphemeralPath(path: string): boolean;
|
|
29
53
|
/**
|
|
@@ -48,13 +72,21 @@ export declare function codexDir(): string;
|
|
|
48
72
|
* Add or replace our [mcp_servers.context-doctor] table in config.toml without
|
|
49
73
|
* a TOML library: the file is the user's, so everything outside our own table
|
|
50
74
|
* is copied through byte for byte. Our table is delimited by its header and the
|
|
51
|
-
* next header (or EOF).
|
|
75
|
+
* next header (or EOF). Our subtables (e.g. an env the user added) are kept.
|
|
52
76
|
*/
|
|
53
77
|
export declare function upsertCodexMcpTable(toml: string, entry: {
|
|
54
78
|
command: string;
|
|
55
79
|
args: string[];
|
|
56
80
|
}): string;
|
|
57
81
|
export declare function removeCodexMcpTable(toml: string): string;
|
|
82
|
+
/**
|
|
83
|
+
* The command and args of our Codex server, or undefined when config.toml
|
|
84
|
+
* does not define one. Reads the inline forms too: Codex runs those fine.
|
|
85
|
+
*/
|
|
86
|
+
export declare function codexMcpEntry(toml: string): {
|
|
87
|
+
command?: string;
|
|
88
|
+
args: string[];
|
|
89
|
+
} | undefined;
|
|
58
90
|
/** Outcome of an install run, so the CLI can set a truthful exit code. */
|
|
59
91
|
export interface InstallResult {
|
|
60
92
|
/** Detected targets that could not be configured, with the reason. */
|
|
@@ -73,4 +105,4 @@ export interface InstallResult {
|
|
|
73
105
|
export declare function runInstall(options?: {
|
|
74
106
|
statusLine?: boolean;
|
|
75
107
|
}): InstallResult;
|
|
76
|
-
export declare function runUninstall(): void
|
|
108
|
+
export declare function runUninstall(): Promise<void>;
|