context-doctor 0.13.0 → 0.13.2
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 +10 -2
- package/dist/config.js +76 -2
- package/dist/cursor.js +6 -1
- package/dist/dashboard.js +9 -6
- package/dist/doctor.js +13 -0
- package/dist/hook.js +69 -14
- package/dist/impact.js +9 -6
- package/dist/ledger.d.ts +30 -1
- package/dist/ledger.js +74 -2
- package/dist/mcp.js +37 -2
- package/dist/optimize.js +58 -2
- package/dist/pricing.js +3 -0
- package/dist/session.js +44 -3
- package/dist/tokens.js +8 -0
- package/package.json +2 -2
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
46
|
-
|
|
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/cursor.js
CHANGED
|
@@ -114,7 +114,12 @@ export function listCursorChats(limit = 20) {
|
|
|
114
114
|
rows = queryRows(dbPath, "SELECT key, json_extract(value, '$.name') AS name, " +
|
|
115
115
|
"COALESCE(json_array_length(value, '$.fullConversationHeadersOnly'), " +
|
|
116
116
|
"json_array_length(value, '$.conversation'), 0) AS n " +
|
|
117
|
-
|
|
117
|
+
// json_valid is not optional: SQLite's JSON functions raise on
|
|
118
|
+
// malformed input, and one non-JSON row under a composerData: key
|
|
119
|
+
// aborts the WHOLE query. cursorDiskKV is a general-purpose store,
|
|
120
|
+
// so that row exists sooner or later — and the user then sees
|
|
121
|
+
// "No Cursor chats found" with every real chat sitting right there.
|
|
122
|
+
"FROM cursorDiskKV WHERE key LIKE 'composerData:%' AND json_valid(value)");
|
|
118
123
|
}
|
|
119
124
|
catch {
|
|
120
125
|
continue; // no composer table (older Cursor) or no SQLite — skip
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
40
|
+
const server = new McpServer({ name: "context-doctor", version: "0.13.2" }, { 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"),
|
|
@@ -120,6 +120,24 @@ const BEST_PRACTICES = {
|
|
|
120
120
|
"Use max_completion_tokens headroom math: input + output must fit the window together.",
|
|
121
121
|
],
|
|
122
122
|
};
|
|
123
|
+
/**
|
|
124
|
+
* Set a header on a Node request so every downstream reader sees it.
|
|
125
|
+
*
|
|
126
|
+
* `req.headers` is a parsed convenience copy; the MCP transport reconstructs a
|
|
127
|
+
* Web Request from `req.rawHeaders`, so a header written to only one of them is
|
|
128
|
+
* invisible to the other.
|
|
129
|
+
*/
|
|
130
|
+
function setHeader(req, name, value) {
|
|
131
|
+
req.headers[name] = value;
|
|
132
|
+
const raw = req.rawHeaders;
|
|
133
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
134
|
+
if (raw[i].toLowerCase() === name) {
|
|
135
|
+
raw[i + 1] = value;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
raw.push(name, value);
|
|
140
|
+
}
|
|
123
141
|
// -- Transport dispatch --------------------------------------------------------
|
|
124
142
|
// Default: stdio (Claude Desktop, Claude Code, Cursor spawn us as a child).
|
|
125
143
|
// --http [--port N] [--host H]: streamable-HTTP endpoint at /mcp for clients
|
|
@@ -154,9 +172,26 @@ if (argv.includes("--http")) {
|
|
|
154
172
|
res.end(JSON.stringify({ error: "Stateless server: POST /mcp only" }));
|
|
155
173
|
return;
|
|
156
174
|
}
|
|
175
|
+
// The streamable-HTTP spec says a client MUST accept both
|
|
176
|
+
// application/json and text/event-stream, and the SDK answers anything
|
|
177
|
+
// else with a 406. Plenty of real callers send only application/json, or
|
|
178
|
+
// `*/*`, or no Accept at all — and to them a 406 looks like the server
|
|
179
|
+
// being broken. Our replies are single JSON-RPC responses with nothing to
|
|
180
|
+
// stream, so those clients get a plain JSON body instead of a refusal.
|
|
181
|
+
const accept = String(req.headers.accept ?? "");
|
|
182
|
+
const askedForSse = accept.includes("text/event-stream");
|
|
183
|
+
if (!askedForSse || !accept.includes("application/json")) {
|
|
184
|
+
// The transport rebuilds the request from rawHeaders (via Hono), so
|
|
185
|
+
// setting req.headers alone changes nothing it will ever look at.
|
|
186
|
+
setHeader(req, "accept", "application/json, text/event-stream");
|
|
187
|
+
}
|
|
157
188
|
// Fresh server + transport per request (stateless — nothing shared).
|
|
158
189
|
const server = createServer();
|
|
159
|
-
const transport = new StreamableHTTPServerTransport({
|
|
190
|
+
const transport = new StreamableHTTPServerTransport({
|
|
191
|
+
sessionIdGenerator: undefined,
|
|
192
|
+
// A client that never asked for a stream gets plain JSON back.
|
|
193
|
+
enableJsonResponse: !askedForSse,
|
|
194
|
+
});
|
|
160
195
|
res.on("close", () => {
|
|
161
196
|
void transport.close();
|
|
162
197
|
void server.close();
|
package/dist/optimize.js
CHANGED
|
@@ -129,6 +129,34 @@ function truncateToTokens(text, maxTokens) {
|
|
|
129
129
|
const omitted = text.length - approxChars;
|
|
130
130
|
return `${head}\n…[context-doctor: trimmed ${omitted} chars of stale tool output]`;
|
|
131
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Remove tool_result blocks whose matching tool_use is not in the same slice.
|
|
134
|
+
*
|
|
135
|
+
* Anthropic and OpenAI both reject a conversation where a tool result refers to
|
|
136
|
+
* a call that is not present, so anything that drops earlier turns has to clean
|
|
137
|
+
* up after itself. A message emptied by this keeps a short note rather than
|
|
138
|
+
* becoming an empty content array, which is also rejected.
|
|
139
|
+
*/
|
|
140
|
+
function dropOrphanedToolResults(kept) {
|
|
141
|
+
const availableCalls = new Set();
|
|
142
|
+
for (const m of kept) {
|
|
143
|
+
if (!Array.isArray(m?.content))
|
|
144
|
+
continue;
|
|
145
|
+
for (const b of m.content)
|
|
146
|
+
if (b?.type === "tool_use" && b.id)
|
|
147
|
+
availableCalls.add(b.id);
|
|
148
|
+
}
|
|
149
|
+
for (const m of kept) {
|
|
150
|
+
if (!Array.isArray(m?.content))
|
|
151
|
+
continue;
|
|
152
|
+
const surviving = m.content.filter((b) => b?.type !== "tool_result" || (b.tool_use_id && availableCalls.has(b.tool_use_id)));
|
|
153
|
+
if (surviving.length === m.content.length)
|
|
154
|
+
continue;
|
|
155
|
+
m.content = surviving.length > 0
|
|
156
|
+
? surviving
|
|
157
|
+
: [{ type: "text", text: "[context-doctor: earlier tool result dropped with the pruned history]" }];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
132
160
|
function isToolResultMessage(m) {
|
|
133
161
|
if (m?.role === "tool")
|
|
134
162
|
return true;
|
|
@@ -182,11 +210,24 @@ export function optimizeConversation(input, options = {}) {
|
|
|
182
210
|
}
|
|
183
211
|
// -- dedupe: identical content beyond the first occurrence --------------------
|
|
184
212
|
if (opts.strategies.includes("dedupe")) {
|
|
213
|
+
// The recent tail is what the model is actually answering. Replacing a
|
|
214
|
+
// message there with "identical to #0" is technically true and practically
|
|
215
|
+
// awful: through the proxy, a user who pastes the same document twice has
|
|
216
|
+
// their CURRENT question swapped for a pointer to a message ten turns back,
|
|
217
|
+
// and just sees a worse answer with no explanation. Older copies are fair
|
|
218
|
+
// game; the live turn is not.
|
|
219
|
+
const cutoff = stableCutoff(messages.length, opts.keepRecent);
|
|
185
220
|
const seen = new Map();
|
|
186
221
|
messages.forEach((m, i) => {
|
|
187
222
|
const text = textOf(m.content);
|
|
188
223
|
if (text.length < 300)
|
|
189
224
|
return;
|
|
225
|
+
if (i >= cutoff) {
|
|
226
|
+
// Still record it, so a later duplicate can point back here.
|
|
227
|
+
if (!seen.has(hash(text)))
|
|
228
|
+
seen.set(hash(text), i);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
190
231
|
const h = hash(text);
|
|
191
232
|
const first = seen.get(h);
|
|
192
233
|
if (first === undefined) {
|
|
@@ -209,6 +250,11 @@ export function optimizeConversation(input, options = {}) {
|
|
|
209
250
|
if (before <= opts.maxToolResultTokens)
|
|
210
251
|
return;
|
|
211
252
|
const trimmed = truncateToTokens(text, opts.maxToolResultTokens);
|
|
253
|
+
// The truncation notice has a length of its own, so a result only just
|
|
254
|
+
// over the budget can come back LARGER than it went in. Measured on a
|
|
255
|
+
// real session: 2,941 tokens "optimized" to 2,947.
|
|
256
|
+
if (estimateTokens(trimmed) >= before)
|
|
257
|
+
return;
|
|
212
258
|
m.content = replaceText(m.content, trimmed);
|
|
213
259
|
applied.push({
|
|
214
260
|
strategy: "trim-tool-results",
|
|
@@ -233,8 +279,13 @@ export function optimizeConversation(input, options = {}) {
|
|
|
233
279
|
const before = estimateTokens(JSON.stringify(b.input));
|
|
234
280
|
if (before <= opts.maxToolResultTokens)
|
|
235
281
|
continue;
|
|
236
|
-
|
|
237
|
-
|
|
282
|
+
const trimmedInput = trimCallArguments(b.input, opts.maxToolResultTokens);
|
|
283
|
+
// Same trap as tool results: the marker can outweigh what it replaces.
|
|
284
|
+
const after = estimateTokens(JSON.stringify(trimmedInput));
|
|
285
|
+
if (after >= before)
|
|
286
|
+
continue;
|
|
287
|
+
b.input = trimmedInput;
|
|
288
|
+
saved += before - after;
|
|
238
289
|
}
|
|
239
290
|
}
|
|
240
291
|
// OpenAI shape: tool_calls[].function.arguments is a JSON string.
|
|
@@ -272,6 +323,11 @@ export function optimizeConversation(input, options = {}) {
|
|
|
272
323
|
// Boundary adjustment may leave too little tail to be worth keeping —
|
|
273
324
|
// in that case skip pruning entirely rather than gutting the conversation.
|
|
274
325
|
if (messages.length - keepFrom >= 2) {
|
|
326
|
+
// Advancing past LEADING tool results is not enough: a tool_result can
|
|
327
|
+
// sit deeper in the kept tail while its tool_use was pruned, and both
|
|
328
|
+
// APIs reject a conversation containing an orphan. Measured on a real
|
|
329
|
+
// 1,011-message session, which pruned to 7 messages with one orphan.
|
|
330
|
+
dropOrphanedToolResults(messages.slice(keepFrom));
|
|
275
331
|
const pruned = messages.slice(0, keepFrom);
|
|
276
332
|
const prunedTokens = pruned.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
|
|
277
333
|
// Digest: first ~200 chars of each pruned turn — enough for a host LLM to
|
package/dist/pricing.js
CHANGED
|
@@ -42,6 +42,9 @@ export function estimatedTtftSeconds(inputTokens) {
|
|
|
42
42
|
return inputTokens / 25_000;
|
|
43
43
|
}
|
|
44
44
|
export function formatUsd(amount) {
|
|
45
|
+
// Same reasoning as formatTokens: "$NaN" is worse than "$0.00".
|
|
46
|
+
if (!Number.isFinite(amount))
|
|
47
|
+
return "$0.00";
|
|
45
48
|
if (amount >= 1)
|
|
46
49
|
return `$${amount.toFixed(2)}`;
|
|
47
50
|
if (amount >= 0.01)
|
package/dist/session.js
CHANGED
|
@@ -12,6 +12,17 @@ import { readdirSync, readFileSync, statSync, existsSync, openSync, readSync, cl
|
|
|
12
12
|
import { StringDecoder } from "node:string_decoder";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
/**
|
|
16
|
+
* Read one usage field defensively.
|
|
17
|
+
*
|
|
18
|
+
* A numeric string plainly means that number, and discarding it would throw
|
|
19
|
+
* away ground truth and silently fall back to the heuristic — so it is parsed.
|
|
20
|
+
* Anything else unusable (objects, null, "abc", negatives) counts as nothing.
|
|
21
|
+
*/
|
|
22
|
+
function usageNumber(value) {
|
|
23
|
+
const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
|
|
24
|
+
return Number.isFinite(n) && n >= 0 ? Math.round(n) : 0;
|
|
25
|
+
}
|
|
15
26
|
function projectsDir() {
|
|
16
27
|
return join(homedir(), ".claude", "projects");
|
|
17
28
|
}
|
|
@@ -45,6 +56,21 @@ export function listSessions(limit = 20) {
|
|
|
45
56
|
* conversations.json is an array of conversations, each holding a `mapping`
|
|
46
57
|
* tree of nodes. We profile the most recently updated conversation.
|
|
47
58
|
*/
|
|
59
|
+
/**
|
|
60
|
+
* A stand-in for a non-text export part.
|
|
61
|
+
*
|
|
62
|
+
* The bytes are not in the export, so the exact token cost is unknowable; what
|
|
63
|
+
* matters is that the turn stops being invisible and keeps its place in the
|
|
64
|
+
* conversation.
|
|
65
|
+
*/
|
|
66
|
+
function chatGptAttachmentLabel(part) {
|
|
67
|
+
const kind = part?.content_type;
|
|
68
|
+
if (typeof kind === "string")
|
|
69
|
+
return `[${kind}]`;
|
|
70
|
+
if (part?.asset_pointer)
|
|
71
|
+
return "[image]";
|
|
72
|
+
return "[attachment]";
|
|
73
|
+
}
|
|
48
74
|
function parseChatGPTExport(data, path) {
|
|
49
75
|
const conversations = data
|
|
50
76
|
.filter((c) => c && typeof c.mapping === "object")
|
|
@@ -58,12 +84,21 @@ function parseChatGPTExport(data, path) {
|
|
|
58
84
|
if (!m?.author?.role || !["user", "assistant", "system"].includes(m.author.role))
|
|
59
85
|
return false;
|
|
60
86
|
const parts = m.content?.parts;
|
|
61
|
-
|
|
87
|
+
if (!Array.isArray(parts))
|
|
88
|
+
return false;
|
|
89
|
+
// A turn containing an image is exported as multimodal_text, with the
|
|
90
|
+
// picture as an object among the string parts. Requiring a non-empty
|
|
91
|
+
// string dropped those turns entirely, so an image-heavy conversation
|
|
92
|
+
// profiled as smaller than it is.
|
|
93
|
+
return parts.some((p) => (typeof p === "string" && p.length > 0) || (p && typeof p === "object"));
|
|
62
94
|
})
|
|
63
95
|
.sort((a, b) => (a.message.create_time ?? 0) - (b.message.create_time ?? 0));
|
|
64
96
|
const messages = nodes.map((n) => ({
|
|
65
97
|
role: n.message.author.role,
|
|
66
|
-
content: n.message.content.parts
|
|
98
|
+
content: n.message.content.parts
|
|
99
|
+
.map((p) => (typeof p === "string" ? p : chatGptAttachmentLabel(p)))
|
|
100
|
+
.filter((p) => p.length > 0)
|
|
101
|
+
.join("\n"),
|
|
67
102
|
}));
|
|
68
103
|
return {
|
|
69
104
|
conversationJson: JSON.stringify({ messages }),
|
|
@@ -170,7 +205,13 @@ export function parseSessionFile(path) {
|
|
|
170
205
|
model = message.model;
|
|
171
206
|
const usage = message.usage;
|
|
172
207
|
if (entry.type === "assistant" && usage) {
|
|
173
|
-
|
|
208
|
+
// Coerce, do not trust: a transcript whose usage numbers are STRINGS
|
|
209
|
+
// turned `1200 + 300` into "12003000" through JavaScript concatenation,
|
|
210
|
+
// an 8000x overstatement that drives the hook, the cost figures and the
|
|
211
|
+
// window percentage. Anything not a finite non-negative number is 0.
|
|
212
|
+
const total = usageNumber(usage.input_tokens) +
|
|
213
|
+
usageNumber(usage.cache_read_input_tokens) +
|
|
214
|
+
usageNumber(usage.cache_creation_input_tokens);
|
|
174
215
|
if (total > 0) {
|
|
175
216
|
reportedInputTokens = total;
|
|
176
217
|
usageSamples.push({ index: messages.length, input: total });
|
package/dist/tokens.js
CHANGED
|
@@ -50,6 +50,10 @@ function symbolDensity(text) {
|
|
|
50
50
|
return (symbols?.length ?? 0) / text.length;
|
|
51
51
|
}
|
|
52
52
|
export function estimateTokens(text) {
|
|
53
|
+
// Public API: callers outside this package pass whatever they have, and a
|
|
54
|
+
// TypeError from a token estimator is never the useful answer.
|
|
55
|
+
if (typeof text !== "string")
|
|
56
|
+
text = String(text ?? "");
|
|
53
57
|
if (!text)
|
|
54
58
|
return 0;
|
|
55
59
|
// Denser tokenization for code/JSON-like content, lighter for plain prose.
|
|
@@ -60,6 +64,10 @@ export function estimateTokens(text) {
|
|
|
60
64
|
/** Per-message structural overhead (role markers, delimiters) is roughly constant. */
|
|
61
65
|
export const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
62
66
|
export function formatTokens(n) {
|
|
67
|
+
// A NaN reaching a report renders literally as "NaN tokens"; show nothing
|
|
68
|
+
// rather than something false.
|
|
69
|
+
if (!Number.isFinite(n))
|
|
70
|
+
return "0";
|
|
63
71
|
if (n >= 1_000_000)
|
|
64
72
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
65
73
|
if (n >= 10_000)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.2",
|
|
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",
|