context-doctor 0.13.0 → 0.13.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/dist/config.d.ts CHANGED
@@ -39,11 +39,19 @@ export interface LoadedConfig {
39
39
  config: ContextDoctorConfig;
40
40
  /** Absolute path of the rc file, or undefined when none was found. */
41
41
  path?: string;
42
+ /** Settings that will be silently ignored, if any. */
43
+ warnings?: string[];
42
44
  }
43
45
  /**
44
- * Load the nearest config. Malformed rc files are reported (so a typo is not
45
- * silently ignored) but never throw — the tool keeps working with defaults.
46
+ * Report anything in an rc file that will be silently ignored.
47
+ *
48
+ * Every invalid value here fails quietly and looks like the feature not
49
+ * working: `"trim-tool-result"` (missing s) trims nothing, a negative
50
+ * keepRecent disables trimming entirely, and a budget written as a string is
51
+ * never compared against. For a tool whose whole job is measurement, silently
52
+ * doing nothing is the worst available behaviour.
46
53
  */
54
+ export declare function validateConfig(config: unknown, path: string): string[];
47
55
  export declare function loadConfig(startDir?: string, onWarn?: (msg: string) => void): LoadedConfig;
48
56
  export interface BudgetVerdict {
49
57
  /** True when any configured limit is exceeded. */
package/dist/config.js CHANGED
@@ -36,14 +36,88 @@ function candidatePaths(startDir) {
36
36
  * Load the nearest config. Malformed rc files are reported (so a typo is not
37
37
  * silently ignored) but never throw — the tool keeps working with defaults.
38
38
  */
39
+ /** Strategy ids the optimizer actually implements. */
40
+ const KNOWN_STRATEGIES = new Set(["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64", "prune-history"]);
41
+ const KNOWN_KEYS = new Set(["budget", "strategies", "keepRecent", "maxToolResultTokens", "routes", "model"]);
42
+ const KNOWN_BUDGET_KEYS = new Set(["maxTokens", "maxCostPerMessageUsd", "maxWindowPct"]);
43
+ /**
44
+ * Report anything in an rc file that will be silently ignored.
45
+ *
46
+ * Every invalid value here fails quietly and looks like the feature not
47
+ * working: `"trim-tool-result"` (missing s) trims nothing, a negative
48
+ * keepRecent disables trimming entirely, and a budget written as a string is
49
+ * never compared against. For a tool whose whole job is measurement, silently
50
+ * doing nothing is the worst available behaviour.
51
+ */
52
+ export function validateConfig(config, path) {
53
+ const warnings = [];
54
+ const where = (key) => `${path}: ${key}`;
55
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
56
+ return [`${path}: expected a JSON object`];
57
+ }
58
+ const c = config;
59
+ for (const key of Object.keys(c)) {
60
+ if (!KNOWN_KEYS.has(key)) {
61
+ warnings.push(`${where(key)} is not a known setting — ignored (known: ${[...KNOWN_KEYS].join(", ")})`);
62
+ }
63
+ }
64
+ if (c.budget !== undefined) {
65
+ if (typeof c.budget !== "object" || c.budget === null || Array.isArray(c.budget)) {
66
+ warnings.push(`${where("budget")} must be an object — ignored`);
67
+ }
68
+ else {
69
+ const budget = c.budget;
70
+ for (const [key, value] of Object.entries(budget)) {
71
+ if (!KNOWN_BUDGET_KEYS.has(key)) {
72
+ warnings.push(`${where(`budget.${key}`)} is not a known budget limit — ignored`);
73
+ }
74
+ else if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
75
+ warnings.push(`${where(`budget.${key}`)} must be a positive number, got ${JSON.stringify(value)} — this limit will never trigger`);
76
+ }
77
+ }
78
+ if (typeof budget.maxWindowPct === "number" && budget.maxWindowPct > 100) {
79
+ warnings.push(`${where("budget.maxWindowPct")} is above 100 — a percentage of the context window cannot exceed 100`);
80
+ }
81
+ }
82
+ }
83
+ if (c.strategies !== undefined) {
84
+ if (!Array.isArray(c.strategies)) {
85
+ warnings.push(`${where("strategies")} must be an array — ignored`);
86
+ }
87
+ else {
88
+ for (const id of c.strategies) {
89
+ if (!KNOWN_STRATEGIES.has(String(id))) {
90
+ warnings.push(`${where("strategies")}: "${id}" is not a strategy — ignored (known: ${[...KNOWN_STRATEGIES].join(", ")})`);
91
+ }
92
+ }
93
+ }
94
+ }
95
+ for (const key of ["keepRecent", "maxToolResultTokens"]) {
96
+ const value = c[key];
97
+ if (value === undefined)
98
+ continue;
99
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
100
+ warnings.push(`${where(key)} must be a positive whole number, got ${JSON.stringify(value)} — ignored`);
101
+ }
102
+ }
103
+ if (c.routes !== undefined && !Array.isArray(c.routes)) {
104
+ warnings.push(`${where("routes")} must be an array — ignored`);
105
+ }
106
+ return warnings;
107
+ }
39
108
  export function loadConfig(startDir = process.cwd(), onWarn) {
40
109
  for (const path of candidatePaths(startDir)) {
41
110
  if (!existsSync(path))
42
111
  continue;
43
112
  try {
44
113
  const parsed = JSON.parse(readFileSync(path, "utf8"));
45
- if (parsed && typeof parsed === "object")
46
- return { config: parsed, path };
114
+ // Arrays are objects too, hence the explicit check.
115
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
116
+ const warnings = validateConfig(parsed, path);
117
+ for (const warning of warnings)
118
+ onWarn?.(warning);
119
+ return { config: parsed, path, warnings };
120
+ }
47
121
  onWarn?.(`${path}: expected a JSON object — ignoring`);
48
122
  }
49
123
  catch (e) {
package/dist/dashboard.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * the server reads local files and answers only the loopback interface.
8
8
  */
9
9
  import http from "node:http";
10
- import { readLedger } from "./ledger.js";
10
+ import { foldTotals, readLedger } from "./ledger.js";
11
11
  import { listSessions, parseSessionFile } from "./session.js";
12
12
  import { parseConversation } from "./parse.js";
13
13
  import { profileConversation } from "./profile.js";
@@ -28,6 +28,9 @@ async function fetchProxyStats(port) {
28
28
  }
29
29
  export async function collectDashboardData(proxyPort = 8787) {
30
30
  const ledger = readLedger();
31
+ // Totals folded in when the ledger rotated; excluded from the daily series,
32
+ // which describes individual days rather than a carried-forward sum.
33
+ const carried = foldTotals(ledger.filter((e) => e.ev === "rollup"));
31
34
  const checks = ledger.filter((e) => e.ev === "check" || e.ev === undefined);
32
35
  const optimizes = ledger.filter((e) => e.ev === "optimize");
33
36
  // Observed shrinkage: a session getting SMALLER between two deep checks is a
@@ -38,14 +41,14 @@ export async function collectDashboardData(proxyPort = 8787) {
38
41
  continue;
39
42
  perSession.set(c.sid, [...(perSession.get(c.sid) ?? []), c.tok]);
40
43
  }
41
- let shrinkage = 0;
44
+ let shrinkage = carried.shrinkage;
42
45
  for (const toks of perSession.values()) {
43
46
  for (let i = 1; i < toks.length; i++)
44
47
  if (toks[i] < toks[i - 1])
45
48
  shrinkage += toks[i - 1] - toks[i];
46
49
  }
47
- const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0);
48
- let usdSaved = 0;
50
+ const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.optimizeSaved;
51
+ let usdSaved = carried.optimizeUsd;
49
52
  for (const e of optimizes) {
50
53
  const pricing = pricingFor(e.model);
51
54
  if (pricing && e.saved)
@@ -93,8 +96,8 @@ export async function collectDashboardData(proxyPort = 8787) {
93
96
  totals: {
94
97
  tokensSaved: optimizeSaved + shrinkage + (proxy?.tokensSaved ?? 0),
95
98
  usdSaved: usdSaved + (proxy?.estUsdSaved ?? 0),
96
- checks: checks.length,
97
- warnings: checks.filter((c) => c.warn).length,
99
+ checks: checks.length + carried.checks,
100
+ warnings: checks.filter((c) => c.warn).length + carried.warnings,
98
101
  optimizeRuns: optimizes.length,
99
102
  },
100
103
  daily,
package/dist/doctor.js CHANGED
@@ -11,6 +11,7 @@ import { homedir, platform } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { ledgerPath, recordLedger } from "./ledger.js";
14
+ import { loadConfig } from "./config.js";
14
15
  function claudeDesktopConfigPath() {
15
16
  switch (platform()) {
16
17
  case "darwin": return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
@@ -120,6 +121,18 @@ export async function runDoctor() {
120
121
  catch {
121
122
  checks.push({ label: "Ledger", status: "fail", detail: `cannot write ${ledgerPath()}` });
122
123
  }
124
+ // Project config: a setting that is silently ignored looks exactly like the
125
+ // feature being broken, so name it here rather than leaving it to be guessed.
126
+ const loaded = loadConfig(process.cwd());
127
+ if (loaded.path) {
128
+ const warnings = loaded.warnings ?? [];
129
+ checks.push(warnings.length === 0
130
+ ? { label: "Project config", status: "ok", detail: `${loaded.path} — all settings understood` }
131
+ : { label: "Project config", status: "fail", detail: `${warnings.length} setting(s) will be ignored:\n` + warnings.map((w) => ` ${w}`).join("\n") });
132
+ }
133
+ else {
134
+ checks.push({ label: "Project config", status: "skip", detail: "no .contextdoctorrc (optional; create one with: context-doctor init <preset>)" });
135
+ }
123
136
  checks.push(await checkMcpHandshake());
124
137
  const mark = { ok: "✓", fail: "✗", skip: "–" };
125
138
  console.log("CONTEXT DOCTOR — self-check");
package/dist/hook.js CHANGED
@@ -10,7 +10,9 @@
10
10
  * Registered by `context-doctor install` under hooks.UserPromptSubmit in
11
11
  * ~/.claude/settings.json; removed by `context-doctor uninstall`.
12
12
  */
13
- import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
14
+ import { createHash } from "node:crypto";
15
+ import { join } from "node:path";
14
16
  import { recordLedger, statePath } from "./ledger.js";
15
17
  import { parseConversation } from "./parse.js";
16
18
  import { profileConversation } from "./profile.js";
@@ -43,6 +45,70 @@ const REGROWTH_FACTOR = 1.4;
43
45
  function minBytesForWarn(threshold) {
44
46
  return threshold * 4;
45
47
  }
48
+ /**
49
+ * State lives in one small file per session, not one shared map.
50
+ *
51
+ * The hook runs once per prompt in every Claude Code window, and people keep
52
+ * several open. With a shared JSON map, concurrent hooks each read the whole
53
+ * map and wrote it back, so the last writer erased everyone else: measured,
54
+ * 12 simultaneous sessions left 4 surviving entries. The cost of losing an
55
+ * entry is a repeated warning the regrowth gate exists to prevent, plus a full
56
+ * re-parse of a transcript that can be hundreds of megabytes.
57
+ *
58
+ * A process that only ever writes its own session's file cannot race another.
59
+ */
60
+ function stateDir() {
61
+ return statePath().replace(/\.json$/, "") + ".d";
62
+ }
63
+ function sessionStatePath(sessionId) {
64
+ // Session ids are usually uuids, but the fallback id is a filesystem path.
65
+ // Hashing keeps the filename valid whatever the id looks like.
66
+ return join(stateDir(), createHash("sha1").update(sessionId).digest("hex").slice(0, 16) + ".json");
67
+ }
68
+ function readSessionState(sessionId) {
69
+ try {
70
+ const raw = JSON.parse(readFileSync(sessionStatePath(sessionId), "utf8"));
71
+ if (typeof raw?.t === "number" && typeof raw?.b === "number")
72
+ return raw;
73
+ }
74
+ catch {
75
+ /* absent or half-written: treat as a first run */
76
+ }
77
+ // Migration: entries written by the shared-map versions are still useful.
78
+ try {
79
+ const legacy = JSON.parse(readFileSync(statePath(), "utf8"));
80
+ const entry = legacy[sessionId];
81
+ if (typeof entry === "number")
82
+ return { t: entry, b: 0 };
83
+ if (entry && typeof entry.t === "number")
84
+ return { t: entry.t, b: entry.b ?? 0 };
85
+ }
86
+ catch {
87
+ /* no legacy file */
88
+ }
89
+ return { t: 0, b: 0 };
90
+ }
91
+ /** Keep the directory from growing without bound as sessions come and go. */
92
+ const MAX_STATE_FILES = 200;
93
+ function writeSessionState(sessionId, state) {
94
+ const dir = stateDir();
95
+ mkdirSync(dir, { recursive: true });
96
+ writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
97
+ try {
98
+ const files = readdirSync(dir);
99
+ if (files.length <= MAX_STATE_FILES)
100
+ return;
101
+ const byAge = files
102
+ .map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs }))
103
+ .sort((a, b) => b.t - a.t)
104
+ .slice(MAX_STATE_FILES);
105
+ for (const { f } of byAge)
106
+ rmSync(join(dir, f), { force: true });
107
+ }
108
+ catch {
109
+ /* pruning is housekeeping, never worth failing a prompt over */
110
+ }
111
+ }
46
112
  async function readStdin() {
47
113
  const chunks = [];
48
114
  for await (const chunk of process.stdin)
@@ -68,16 +134,7 @@ export async function runHook() {
68
134
  // since the last full parse, nothing new can trigger — exit without the
69
135
  // expensive read. Heavy-but-quiet sessions cost one stat + tiny state read.
70
136
  const sessionId = input.session_id ?? transcriptPath;
71
- let state = {};
72
- try {
73
- state = JSON.parse(readFileSync(statePath(), "utf8"));
74
- }
75
- catch {
76
- /* first run */
77
- }
78
- const rawPrev = state[sessionId];
79
- // Migrate pre-0.3.5 numeric entries ({tokens only}) to the new shape.
80
- const prev = typeof rawPrev === "number" ? { t: rawPrev, b: 0 } : rawPrev ?? { t: 0, b: 0 };
137
+ const prev = readSessionState(sessionId);
81
138
  if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR)
82
139
  return;
83
140
  // Slow path (growth events only): full parse + profile.
@@ -91,9 +148,7 @@ export async function runHook() {
91
148
  const liveTokens = parsed.reportedInputTokens ?? profile.totalTokens;
92
149
  // Record this parse so the next prompts take fast path 2.
93
150
  const shouldWarn = liveTokens >= threshold && liveTokens >= prev.t * REGROWTH_FACTOR;
94
- const nextState = { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes };
95
- const entries = Object.entries({ ...state, [sessionId]: nextState });
96
- writeFileSync(statePath(), JSON.stringify(Object.fromEntries(entries.slice(-100))));
151
+ writeSessionState(sessionId, { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes });
97
152
  recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: liveTokens, warn: shouldWarn });
98
153
  if (!shouldWarn)
99
154
  return;
package/dist/impact.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * session cannot be re-run without it. The report says so instead of inventing
9
9
  * a number.
10
10
  */
11
- import { readLedger } from "./ledger.js";
11
+ import { foldTotals, readLedger } from "./ledger.js";
12
12
  import { listSessions, parseSessionFile } from "./session.js";
13
13
  import { parseConversation } from "./parse.js";
14
14
  import { profileConversation } from "./profile.js";
@@ -32,6 +32,9 @@ export async function buildImpactReport(proxyPort = 8787) {
32
32
  lines.push("CONTEXT DOCTOR — impact report");
33
33
  lines.push("═".repeat(56));
34
34
  const ledger = readLedger();
35
+ // Rotation folds dropped entries into a rollup. Counting it is what keeps
36
+ // these lifetime totals from going backwards once the cap is hit.
37
+ const carried = foldTotals(ledger.filter((e) => e.ev === "rollup"));
35
38
  const checks = ledger.filter((e) => e.ev === "check" || e.ev === undefined);
36
39
  const optimizes = ledger.filter((e) => e.ev === "optimize");
37
40
  // Observed per-session reductions: when a session SHRANK between two deep
@@ -55,9 +58,9 @@ export async function buildImpactReport(proxyPort = 8787) {
55
58
  }
56
59
  reductionBySession.set(sid, reduction);
57
60
  }
58
- const totalReduction = [...reductionBySession.values()].reduce((a, b) => a + b, 0);
61
+ const totalReduction = [...reductionBySession.values()].reduce((a, b) => a + b, 0) + carried.shrinkage;
59
62
  // Optimize-event savings, split by model family (claude / gpt / other).
60
- const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0);
63
+ const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.optimizeSaved;
61
64
  const savedByFamily = new Map();
62
65
  let optimizeUsd = 0;
63
66
  for (const e of optimizes) {
@@ -71,7 +74,7 @@ export async function buildImpactReport(proxyPort = 8787) {
71
74
  // Persisted checkpoints cover proxy runs that have since exited; the live
72
75
  // process reports whatever it has not checkpointed yet.
73
76
  const proxyEvents = ledger.filter((e) => e.ev === "proxy");
74
- const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0);
77
+ const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.proxySaved;
75
78
  const proxySaved = proxyHistoric + (proxy?.tokensSaved ?? 0);
76
79
  // -- Headline: what context-doctor has saved ----------------------------------
77
80
  const totalSaved = proxySaved + optimizeSaved + totalReduction;
@@ -100,8 +103,8 @@ export async function buildImpactReport(proxyPort = 8787) {
100
103
  lines.push("Hygiene activity (every-prompt hook)");
101
104
  lines.push("─".repeat(56));
102
105
  if (checks.length > 0) {
103
- const warnings = checks.filter((e) => e.warn).length;
104
- lines.push(`${checks.length} deep context checks across ${bySession.size} session(s); ${warnings} warning(s) delivered to the model.`);
106
+ const warnings = checks.filter((e) => e.warn).length + carried.warnings;
107
+ lines.push(`${checks.length + carried.checks} deep context checks across ${bySession.size} session(s); ${warnings} warning(s) delivered to the model.`);
105
108
  lines.push("(Prompt-level fast checks are not logged — they cost ~1ms and leave no trace by design.)");
106
109
  }
107
110
  else {
package/dist/ledger.d.ts CHANGED
@@ -8,10 +8,16 @@
8
8
  * (pre-0.3.6 hook entries have no `ev` field; treated as checks)
9
9
  * optimize — an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
10
10
  * proxy — proxy savings checkpoint {ev: "proxy", saved, usd?, requests?}
11
+ * rollup — totals folded in on rotation {ev: "rollup", ...carried sums}
12
+ *
13
+ * The ledger is capped, and everything it feeds is a LIFETIME total. Simply
14
+ * dropping old lines made those totals go backwards — measured: 1,692,000
15
+ * tokens saved became 501,000 the moment the cap was hit. So rotation folds
16
+ * what it drops into a single rollup entry instead of discarding it.
11
17
  */
12
18
  export interface LedgerEntry {
13
19
  ts: number;
14
- ev?: "check" | "optimize" | "proxy";
20
+ ev?: "check" | "optimize" | "proxy" | "rollup";
15
21
  sid?: string;
16
22
  tok?: number;
17
23
  warn?: boolean;
@@ -20,7 +26,30 @@ export interface LedgerEntry {
20
26
  requests?: number;
21
27
  saved?: number;
22
28
  model?: string;
29
+ /** rollup only: sums carried forward from entries rotation removed. */
30
+ carried?: CarriedTotals;
31
+ }
32
+ /** Everything the reports total up, preserved across ledger rotation. */
33
+ export interface CarriedTotals {
34
+ optimizeSaved: number;
35
+ optimizeUsd: number;
36
+ proxySaved: number;
37
+ proxyUsd: number;
38
+ proxyRequests: number;
39
+ checks: number;
40
+ warnings: number;
41
+ /** Session shrinkage observed between consecutive checks. */
42
+ shrinkage: number;
43
+ /** Timestamp of the oldest folded entry, so reports can say "since". */
44
+ since?: number;
23
45
  }
46
+ /**
47
+ * Reduce a set of entries to the totals the reports care about.
48
+ *
49
+ * Absorbs existing rollups, so folding stays correct across any number of
50
+ * rotations rather than only the first.
51
+ */
52
+ export declare function foldTotals(entries: LedgerEntry[]): CarriedTotals;
24
53
  export declare function statePath(): string;
25
54
  export declare function ledgerPath(): string;
26
55
  export declare function recordLedger(entry: Omit<LedgerEntry, "ts">): void;
package/dist/ledger.js CHANGED
@@ -8,10 +8,68 @@
8
8
  * (pre-0.3.6 hook entries have no `ev` field; treated as checks)
9
9
  * optimize — an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
10
10
  * proxy — proxy savings checkpoint {ev: "proxy", saved, usd?, requests?}
11
+ * rollup — totals folded in on rotation {ev: "rollup", ...carried sums}
12
+ *
13
+ * The ledger is capped, and everything it feeds is a LIFETIME total. Simply
14
+ * dropping old lines made those totals go backwards — measured: 1,692,000
15
+ * tokens saved became 501,000 the moment the cap was hit. So rotation folds
16
+ * what it drops into a single rollup entry instead of discarding it.
11
17
  */
12
18
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
13
19
  import { homedir } from "node:os";
14
20
  import { dirname, join } from "node:path";
21
+ const EMPTY_CARRIED = {
22
+ optimizeSaved: 0, optimizeUsd: 0, proxySaved: 0, proxyUsd: 0,
23
+ proxyRequests: 0, checks: 0, warnings: 0, shrinkage: 0,
24
+ };
25
+ /**
26
+ * Reduce a set of entries to the totals the reports care about.
27
+ *
28
+ * Absorbs existing rollups, so folding stays correct across any number of
29
+ * rotations rather than only the first.
30
+ */
31
+ export function foldTotals(entries) {
32
+ const out = { ...EMPTY_CARRIED };
33
+ const perSession = new Map();
34
+ for (const e of entries) {
35
+ if (e.ev === "rollup" && e.carried) {
36
+ out.optimizeSaved += e.carried.optimizeSaved;
37
+ out.optimizeUsd += e.carried.optimizeUsd;
38
+ out.proxySaved += e.carried.proxySaved;
39
+ out.proxyUsd += e.carried.proxyUsd;
40
+ out.proxyRequests += e.carried.proxyRequests;
41
+ out.checks += e.carried.checks;
42
+ out.warnings += e.carried.warnings;
43
+ out.shrinkage += e.carried.shrinkage;
44
+ if (e.carried.since)
45
+ out.since = Math.min(out.since ?? e.carried.since, e.carried.since);
46
+ continue;
47
+ }
48
+ out.since = Math.min(out.since ?? e.ts, e.ts);
49
+ if (e.ev === "optimize") {
50
+ out.optimizeSaved += e.saved ?? 0;
51
+ out.optimizeUsd += e.usd ?? 0;
52
+ }
53
+ else if (e.ev === "proxy") {
54
+ out.proxySaved += e.saved ?? 0;
55
+ out.proxyUsd += e.usd ?? 0;
56
+ out.proxyRequests += e.requests ?? 0;
57
+ }
58
+ else {
59
+ out.checks++;
60
+ if (e.warn)
61
+ out.warnings++;
62
+ if (e.sid && typeof e.tok === "number")
63
+ perSession.set(e.sid, [...(perSession.get(e.sid) ?? []), e.tok]);
64
+ }
65
+ }
66
+ for (const toks of perSession.values()) {
67
+ for (let i = 1; i < toks.length; i++)
68
+ if (toks[i] < toks[i - 1])
69
+ out.shrinkage += toks[i - 1] - toks[i];
70
+ }
71
+ return out;
72
+ }
15
73
  export function statePath() {
16
74
  return process.env.CONTEXT_DOCTOR_HOOK_STATE ?? join(homedir(), ".claude", ".context-doctor-hook-state.json");
17
75
  }
@@ -24,10 +82,24 @@ export function recordLedger(entry) {
24
82
  // Claude-Desktop-only machines have no ~/.claude — create it so their
25
83
  // optimize events count in `context-doctor report` too.
26
84
  mkdirSync(dirname(path), { recursive: true });
27
- // Cap growth: past ~256KB keep the most recent 500 entries.
85
+ // Cap growth: past ~256KB keep the most recent 500 entries — but fold the
86
+ // dropped ones into a rollup first, or every lifetime total in the reports
87
+ // silently shrinks the moment a heavy user crosses the cap.
28
88
  if (existsSync(path) && statSync(path).size > 256 * 1024) {
29
89
  const lines = readFileSync(path, "utf8").trimEnd().split("\n");
30
- writeFileSync(path, lines.slice(-500).join("\n") + "\n");
90
+ const parse = (line) => {
91
+ try {
92
+ return [JSON.parse(line)];
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ };
98
+ const kept = lines.slice(-500);
99
+ const dropped = lines.slice(0, -500).flatMap(parse);
100
+ const carried = foldTotals(dropped);
101
+ const rollup = { ts: Date.now(), ev: "rollup", carried };
102
+ writeFileSync(path, [JSON.stringify(rollup), ...kept].join("\n") + "\n");
31
103
  }
32
104
  appendFileSync(path, JSON.stringify({ ts: Date.now(), ...entry }) + "\n");
33
105
  }
package/dist/mcp.js CHANGED
@@ -37,7 +37,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
37
37
  * recommended pattern.
38
38
  */
39
39
  function createServer() {
40
- const server = new McpServer({ name: "context-doctor", version: "0.13.0" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.13.1" }, { instructions: SERVER_INSTRUCTIONS });
41
41
  server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown by category, largest messages, and actionable findings about wasted context (duplicates, oversized tool results, base64 blobs, cache-unfriendly ordering). Accepts OpenAI/Anthropic conversation JSON or raw text. Call this immediately whenever the user asks about token usage, context size, LLM cost, or latency — and proactively offer it once a conversation grows long or accumulates large pasted content.", {
42
42
  conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
43
43
  model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
package/dist/optimize.js CHANGED
@@ -182,11 +182,24 @@ export function optimizeConversation(input, options = {}) {
182
182
  }
183
183
  // -- dedupe: identical content beyond the first occurrence --------------------
184
184
  if (opts.strategies.includes("dedupe")) {
185
+ // The recent tail is what the model is actually answering. Replacing a
186
+ // message there with "identical to #0" is technically true and practically
187
+ // awful: through the proxy, a user who pastes the same document twice has
188
+ // their CURRENT question swapped for a pointer to a message ten turns back,
189
+ // and just sees a worse answer with no explanation. Older copies are fair
190
+ // game; the live turn is not.
191
+ const cutoff = stableCutoff(messages.length, opts.keepRecent);
185
192
  const seen = new Map();
186
193
  messages.forEach((m, i) => {
187
194
  const text = textOf(m.content);
188
195
  if (text.length < 300)
189
196
  return;
197
+ if (i >= cutoff) {
198
+ // Still record it, so a later duplicate can point back here.
199
+ if (!seen.has(hash(text)))
200
+ seen.set(hash(text), i);
201
+ return;
202
+ }
190
203
  const h = hash(text);
191
204
  const first = seen.get(h);
192
205
  if (first === undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude, GPT, Gemini, and any MCP-capable AI app.",
5
5
  "keywords": [
6
6
  "llm",
@@ -42,7 +42,7 @@
42
42
  "build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
43
43
  "prepublishOnly": "npm run build",
44
44
  "dev": "tsc --watch",
45
- "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/proxy-abort.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js dist/test/cache.test.js dist/test/session.test.js dist/test/accuracy.test.js dist/test/cache-stability.test.js"
45
+ "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/proxy-abort.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js dist/test/cache.test.js dist/test/session.test.js dist/test/accuracy.test.js dist/test/cache-stability.test.js dist/test/ledger.test.js"
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.0.0",