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.
Files changed (68) hide show
  1. package/README.md +25 -25
  2. package/dist/autoclear.d.ts +58 -7
  3. package/dist/autoclear.js +155 -30
  4. package/dist/autopilot.d.ts +23 -3
  5. package/dist/autopilot.js +100 -29
  6. package/dist/cache.d.ts +6 -2
  7. package/dist/cache.js +48 -23
  8. package/dist/cacheplan.d.ts +5 -3
  9. package/dist/cacheplan.js +46 -8
  10. package/dist/calibration.d.ts +7 -2
  11. package/dist/calibration.js +14 -3
  12. package/dist/ci.js +25 -5
  13. package/dist/cli.js +61 -26
  14. package/dist/coldresume.d.ts +32 -7
  15. package/dist/coldresume.js +148 -34
  16. package/dist/compactquality.js +34 -12
  17. package/dist/compactwindow.js +59 -34
  18. package/dist/config.d.ts +6 -0
  19. package/dist/config.js +4 -1
  20. package/dist/cursor.d.ts +9 -0
  21. package/dist/cursor.js +53 -22
  22. package/dist/dashboard.d.ts +10 -0
  23. package/dist/dashboard.js +27 -2
  24. package/dist/diff.js +10 -3
  25. package/dist/doctor.js +133 -18
  26. package/dist/embed.d.ts +9 -0
  27. package/dist/embed.js +40 -5
  28. package/dist/experiment.d.ts +1 -0
  29. package/dist/experiment.js +6 -3
  30. package/dist/hook.js +23 -4
  31. package/dist/impact.d.ts +32 -0
  32. package/dist/impact.js +45 -6
  33. package/dist/install.d.ts +34 -2
  34. package/dist/install.js +223 -30
  35. package/dist/ledger.js +9 -2
  36. package/dist/mcp.d.ts +1 -1
  37. package/dist/mcp.js +2 -2
  38. package/dist/mcpschema.d.ts +16 -1
  39. package/dist/mcpschema.js +95 -24
  40. package/dist/optimize.js +149 -64
  41. package/dist/overhead.js +57 -27
  42. package/dist/pack.d.ts +11 -0
  43. package/dist/pack.js +75 -12
  44. package/dist/parse.js +42 -18
  45. package/dist/pricing.d.ts +57 -5
  46. package/dist/pricing.js +94 -8
  47. package/dist/proxy.d.ts +13 -2
  48. package/dist/proxy.js +258 -43
  49. package/dist/reads.d.ts +1 -1
  50. package/dist/reads.js +61 -24
  51. package/dist/report.d.ts +10 -0
  52. package/dist/report.js +12 -4
  53. package/dist/savings.d.ts +20 -8
  54. package/dist/savings.js +79 -34
  55. package/dist/session.d.ts +37 -0
  56. package/dist/session.js +143 -6
  57. package/dist/split.js +6 -1
  58. package/dist/statusline.d.ts +3 -1
  59. package/dist/statusline.js +29 -17
  60. package/dist/subagentpay.d.ts +5 -2
  61. package/dist/subagentpay.js +50 -16
  62. package/dist/subagents.d.ts +7 -3
  63. package/dist/subagents.js +28 -8
  64. package/dist/tasks.d.ts +22 -7
  65. package/dist/tasks.js +47 -20
  66. package/dist/wrap.d.ts +6 -0
  67. package/dist/wrap.js +142 -6
  68. 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
- function checkMcpEntry(appName, configPath) {
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
- checks.push(checkMcpEntry("Claude Code", join(homedir(), ".claude.json")));
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 (!ours) {
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
- checks.push(toml.includes("[mcp_servers.context-doctor]")
155
- ? { label: "Codex MCP", status: "ok", detail: "wired in ~/.codex/config.toml" }
156
- : { label: "Codex MCP", status: "fail", detail: "Codex detected but not wired — run: context-doctor install" });
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
- checks.push(settings.mcpServers?.["context-doctor"]
185
- ? { label: "Gemini CLI MCP", status: "ok", detail: "wired in ~/.gemini/settings.json" }
186
- : { label: "Gemini CLI MCP", status: "fail", detail: "Gemini CLI detected but not wired — run: context-doctor install" });
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
- : { label: "Status line", status: "ok", detail: "live context in Claude Code's status bar" });
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
- function host() {
23
- const h = process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434";
24
- return h.startsWith("http") ? h.replace(/\/$/, "") : `http://${h}`;
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(host() + path, {
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 ${host()} (keyword ranking only)` };
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)" };
@@ -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;
@@ -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 separators replaced.
44
- const projectDir = join(homedir(), ".claude", "projects", cwd.replace(/[\\/:]/g, "-"));
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
- writeSessionState(sessionId, { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes, cr });
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 * 1.25) / 1e6 : undefined;
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 proxy runs that have since exited; the live
78
- // process reports whatever it has not checkpointed yet.
79
- const proxyEvents = ledger.filter((e) => e.ev === "proxy");
80
- const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.proxySaved;
81
- const proxySaved = proxyHistoric + (proxy?.tokensSaved ?? 0);
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 ${proxyEvents.length} earlier run checkpoint(s)): ${formatTokens(proxyHistoric)} — not running now`
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>;