context-doctor 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -25
- package/dist/autoclear.d.ts +58 -7
- package/dist/autoclear.js +155 -30
- package/dist/autopilot.d.ts +23 -3
- package/dist/autopilot.js +100 -29
- package/dist/cache.d.ts +6 -2
- package/dist/cache.js +48 -23
- package/dist/cacheplan.d.ts +5 -3
- package/dist/cacheplan.js +46 -8
- package/dist/calibration.d.ts +7 -2
- package/dist/calibration.js +14 -3
- package/dist/ci.js +25 -5
- package/dist/cli.js +61 -26
- package/dist/coldresume.d.ts +32 -7
- package/dist/coldresume.js +148 -34
- package/dist/compactquality.js +34 -12
- package/dist/compactwindow.js +59 -34
- package/dist/config.d.ts +6 -0
- package/dist/config.js +4 -1
- package/dist/cursor.d.ts +9 -0
- package/dist/cursor.js +53 -22
- package/dist/dashboard.d.ts +10 -0
- package/dist/dashboard.js +27 -2
- package/dist/diff.js +10 -3
- package/dist/doctor.js +133 -18
- package/dist/embed.d.ts +9 -0
- package/dist/embed.js +40 -5
- package/dist/experiment.d.ts +1 -0
- package/dist/experiment.js +6 -3
- package/dist/hook.js +23 -4
- package/dist/impact.d.ts +32 -0
- package/dist/impact.js +45 -6
- package/dist/install.d.ts +34 -2
- package/dist/install.js +223 -30
- package/dist/ledger.js +9 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +2 -2
- package/dist/mcpschema.d.ts +16 -1
- package/dist/mcpschema.js +95 -24
- package/dist/optimize.js +149 -64
- package/dist/overhead.js +57 -27
- package/dist/pack.d.ts +11 -0
- package/dist/pack.js +75 -12
- package/dist/parse.js +42 -18
- package/dist/pricing.d.ts +57 -5
- package/dist/pricing.js +94 -8
- package/dist/proxy.d.ts +13 -2
- package/dist/proxy.js +258 -43
- package/dist/reads.d.ts +1 -1
- package/dist/reads.js +61 -24
- package/dist/report.d.ts +10 -0
- package/dist/report.js +12 -4
- package/dist/savings.d.ts +20 -8
- package/dist/savings.js +79 -34
- package/dist/session.d.ts +37 -0
- package/dist/session.js +143 -6
- package/dist/split.js +6 -1
- package/dist/statusline.d.ts +3 -1
- package/dist/statusline.js +29 -17
- package/dist/subagentpay.d.ts +5 -2
- package/dist/subagentpay.js +50 -16
- package/dist/subagents.d.ts +7 -3
- package/dist/subagents.js +28 -8
- package/dist/tasks.d.ts +22 -7
- package/dist/tasks.js +47 -20
- package/dist/wrap.d.ts +6 -0
- package/dist/wrap.js +142 -6
- package/package.json +17 -2
package/dist/compactwindow.js
CHANGED
|
@@ -20,22 +20,31 @@
|
|
|
20
20
|
import { existsSync, readFileSync } from "node:fs";
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
-
import { forEachLine, listSessions } from "./session.js";
|
|
24
|
-
import { pricingFor } from "./pricing.js";
|
|
23
|
+
import { forEachLine, listSessions, replyCounter } from "./session.js";
|
|
24
|
+
import { cacheWriteSplit, pricingFor, writeMultiplier } from "./pricing.js";
|
|
25
|
+
import { realModel } from "./tokens.js";
|
|
25
26
|
import { readJson, writeJsonWithBackup } from "./install.js";
|
|
26
27
|
/** Claude Code compacts at about this share of the window (970k of 1M, measured). */
|
|
27
28
|
const TRIGGER_SHARE = 0.97;
|
|
28
29
|
/** Context right after a compaction: system prompt, tools and the summary (median 75,447, measured). */
|
|
29
30
|
const AFTER_COMPACT = 75_000;
|
|
30
|
-
/** Output tokens a compaction summary costs,
|
|
31
|
+
/** Output tokens a compaction summary costs, at the model's output price. */
|
|
31
32
|
const SUMMARY_OUTPUT = 20_000;
|
|
32
|
-
const OUTPUT_RATIO = 5;
|
|
33
33
|
const TTL_MS = 3_600_000;
|
|
34
34
|
export const MIN_WINDOW = 100_000;
|
|
35
35
|
export const MAX_WINDOW = 1_000_000;
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* `writeMult`: what this session pays to write a token to the cache,
|
|
38
|
+
* relative to input, from the 5-minute and 1-hour writes its usage records.
|
|
39
|
+
* The replay charges writes that never happened (a cold re-read, a fresh
|
|
40
|
+
* context after a simulated compaction); the harness would have written
|
|
41
|
+
* those at the TTL it writes everything else at, mostly 1 hour (2x) for
|
|
42
|
+
* Claude Code's main thread, not the 1.25x a constant assumed.
|
|
43
|
+
*/
|
|
44
|
+
function readRequests(path, runRows) {
|
|
37
45
|
const reqs = [];
|
|
38
|
-
let
|
|
46
|
+
let model, writes5m = 0, writes1h = 0, priced = false;
|
|
47
|
+
const sighting = replyCounter(runRows);
|
|
39
48
|
forEachLine(path, (line) => {
|
|
40
49
|
let e;
|
|
41
50
|
try {
|
|
@@ -47,55 +56,70 @@ function readRequests(path) {
|
|
|
47
56
|
if (!e || e.isSidechain)
|
|
48
57
|
return;
|
|
49
58
|
if (e.type === "system" && e.subtype === "compact_boundary") {
|
|
50
|
-
|
|
59
|
+
const copy = typeof e.uuid === "string" && !!runRows?.has(e.uuid);
|
|
60
|
+
if (typeof e.uuid === "string")
|
|
61
|
+
runRows?.add(e.uuid);
|
|
62
|
+
reqs.push({ compact: true, copy });
|
|
51
63
|
return;
|
|
52
64
|
}
|
|
53
|
-
if (e.type !== "assistant" || !e.message
|
|
65
|
+
if (e.type !== "assistant" || !e.message)
|
|
66
|
+
return;
|
|
67
|
+
const seen = sighting(e);
|
|
68
|
+
if (seen === "repeat")
|
|
54
69
|
return;
|
|
55
|
-
lastId = e.message.id;
|
|
56
70
|
const u = e.message.usage ?? {};
|
|
57
71
|
const prompt = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
58
72
|
const at = Date.parse(e.timestamp);
|
|
59
|
-
if (prompt > 0
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
if (!(prompt > 0) || !Number.isFinite(at))
|
|
74
|
+
return;
|
|
75
|
+
// Each request at its own model's price; "<synthetic>" rows carry no usage.
|
|
76
|
+
model = realModel(e.message.model) ?? model;
|
|
77
|
+
const p = pricingFor(model);
|
|
78
|
+
if (p)
|
|
79
|
+
priced = true;
|
|
80
|
+
const w = cacheWriteSplit(u);
|
|
81
|
+
writes5m += w.fiveMinute;
|
|
82
|
+
writes1h += w.oneHour;
|
|
83
|
+
reqs.push({ at, prompt, inPerM: p?.inputPerM ?? 0, readPerM: p?.cacheReadPerM ?? 0, outPerM: p?.outputPerM ?? 0, copy: seen === "copy" });
|
|
63
84
|
});
|
|
64
|
-
return {
|
|
85
|
+
return { reqs, writeMult: writeMultiplier(writes5m, writes1h), priced };
|
|
65
86
|
}
|
|
66
87
|
/**
|
|
67
88
|
* Replay one session twice: as it ran, and with compaction whenever the
|
|
68
89
|
* context reaches the window's trigger. Both arms are priced as the prompt
|
|
69
|
-
* cache bills
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
* cache bills, in USD at each request's own model (warm: the cache-read rate
|
|
91
|
+
* on what was there + a write for what is new; cold after an hour idle: a
|
|
92
|
+
* write for everything, writes at the session's own `writeMult`); each
|
|
93
|
+
* simulated compaction also pays its own request and summary.
|
|
94
|
+
*
|
|
74
95
|
* `reread`: tokens a session fetches again after a compaction because the
|
|
75
96
|
* summary dropped them (measured by `context-doctor compactions`). Each
|
|
76
97
|
* simulated compaction writes them to the cache and carries them onward.
|
|
77
98
|
*/
|
|
78
|
-
function replay(reqs, window, since, reread = 0) {
|
|
99
|
+
function replay(reqs, window, since, reread = 0, writeMult = 1.25) {
|
|
79
100
|
let base = 0, sim = 0, extra = 0, actual = 0;
|
|
80
101
|
let removed = 0, prevActual, prevSim, prevAt;
|
|
81
102
|
for (const r of reqs) {
|
|
82
|
-
if (
|
|
83
|
-
if (prevAt !== undefined && prevAt >= since)
|
|
103
|
+
if ("compact" in r) {
|
|
104
|
+
if (prevAt !== undefined && prevAt >= since && !r.copy)
|
|
84
105
|
actual++;
|
|
85
106
|
// A real compaction in the transcript: the simulated arm compacts there too.
|
|
86
|
-
if (prevAt !== undefined && prevAt >= since && removed === 0)
|
|
107
|
+
if (prevAt !== undefined && prevAt >= since && removed === 0 && !r.copy)
|
|
87
108
|
extra++;
|
|
88
109
|
removed = 0;
|
|
89
110
|
prevActual = prevSim = undefined;
|
|
90
111
|
continue;
|
|
91
112
|
}
|
|
92
|
-
|
|
113
|
+
// Requests before the window, or already counted from a forked copy,
|
|
114
|
+
// still shape both arms; only the rest are billed.
|
|
115
|
+
const counted = r.at >= since && !r.copy;
|
|
93
116
|
const cold = prevAt === undefined || r.at - prevAt > TTL_MS;
|
|
117
|
+
const write = writeMult * r.inPerM;
|
|
94
118
|
const cost = (ctx, prev) => {
|
|
95
119
|
if (cold)
|
|
96
|
-
return
|
|
120
|
+
return write * ctx;
|
|
97
121
|
const fresh = prev === undefined || ctx < prev ? ctx : ctx - prev;
|
|
98
|
-
return
|
|
122
|
+
return r.readPerM * (ctx - fresh) + write * fresh;
|
|
99
123
|
};
|
|
100
124
|
if (counted)
|
|
101
125
|
base += cost(r.prompt, prevActual);
|
|
@@ -109,7 +133,7 @@ function replay(reqs, window, since, reread = 0) {
|
|
|
109
133
|
let simPrev = prevSim;
|
|
110
134
|
if (ctx >= TRIGGER_SHARE * window) {
|
|
111
135
|
if (counted) {
|
|
112
|
-
sim +=
|
|
136
|
+
sim += r.readPerM * ctx + r.outPerM * SUMMARY_OUTPUT + write * reread;
|
|
113
137
|
extra++;
|
|
114
138
|
}
|
|
115
139
|
removed = r.prompt - AFTER_COMPACT - reread;
|
|
@@ -129,23 +153,24 @@ export function estimateCompactWindows(windows, days = 30, paths, rereadTokens =
|
|
|
129
153
|
const targets = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
|
|
130
154
|
const acc = windows.map((w) => ({ window: w, base: 0, sim: 0, extra: 0 }));
|
|
131
155
|
let sessions = 0, actualCompactions = 0;
|
|
156
|
+
// Shared by every transcript of the run, so a fork's copy counts once.
|
|
157
|
+
const runRows = new Set();
|
|
132
158
|
for (const p of targets) {
|
|
133
159
|
let parsed;
|
|
134
160
|
try {
|
|
135
|
-
parsed = readRequests(p);
|
|
161
|
+
parsed = readRequests(p, runRows);
|
|
136
162
|
}
|
|
137
163
|
catch {
|
|
138
164
|
continue;
|
|
139
165
|
}
|
|
140
|
-
|
|
141
|
-
if (!perM || parsed.reqs.filter((r) => r !== "compact" && r.at >= since).length < 10)
|
|
166
|
+
if (!parsed.priced || parsed.reqs.filter((r) => !("compact" in r) && r.at >= since && !r.copy).length < 10)
|
|
142
167
|
continue;
|
|
143
168
|
sessions++;
|
|
144
|
-
actualCompactions += replay(parsed.reqs, Number.MAX_SAFE_INTEGER, since).actual;
|
|
169
|
+
actualCompactions += replay(parsed.reqs, Number.MAX_SAFE_INTEGER, since, 0, parsed.writeMult).actual;
|
|
145
170
|
for (const a of acc) {
|
|
146
|
-
const r = replay(parsed.reqs, a.window, since, rereadTokens);
|
|
147
|
-
a.base +=
|
|
148
|
-
a.sim +=
|
|
171
|
+
const r = replay(parsed.reqs, a.window, since, rereadTokens, parsed.writeMult);
|
|
172
|
+
a.base += r.base / 1e6;
|
|
173
|
+
a.sim += r.sim / 1e6;
|
|
149
174
|
a.extra += r.extra;
|
|
150
175
|
}
|
|
151
176
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -43,6 +43,12 @@ export interface LoadedConfig {
|
|
|
43
43
|
path?: string;
|
|
44
44
|
/** Settings that will be silently ignored, if any. */
|
|
45
45
|
warnings?: string[];
|
|
46
|
+
/**
|
|
47
|
+
* Set when the rc file at `path` exists but cannot be used at all (not
|
|
48
|
+
* JSON, or not an object): none of its settings are in effect. `path` is
|
|
49
|
+
* still given, so a caller can name the file instead of reporting none.
|
|
50
|
+
*/
|
|
51
|
+
error?: string;
|
|
46
52
|
}
|
|
47
53
|
/**
|
|
48
54
|
* Report anything in an rc file that will be silently ignored.
|
package/dist/config.js
CHANGED
|
@@ -119,11 +119,14 @@ export function loadConfig(startDir = process.cwd(), onWarn) {
|
|
|
119
119
|
return { config: parsed, path, warnings };
|
|
120
120
|
}
|
|
121
121
|
onWarn?.(`${path}: expected a JSON object — ignoring`);
|
|
122
|
+
return { config: {}, path, error: "expected a JSON object" };
|
|
122
123
|
}
|
|
123
124
|
catch (e) {
|
|
124
125
|
onWarn?.(`${path}: ${e.message} — ignoring`);
|
|
126
|
+
// Returning no path made `doctor` report "no .contextdoctorrc" for a
|
|
127
|
+
// file that exists with a typo in it: the one case worth reporting.
|
|
128
|
+
return { config: {}, path, error: e.message };
|
|
125
129
|
}
|
|
126
|
-
return { config: {} };
|
|
127
130
|
}
|
|
128
131
|
return { config: {} };
|
|
129
132
|
}
|
package/dist/cursor.d.ts
CHANGED
|
@@ -23,6 +23,15 @@ export declare function findCursorDatabases(): string[];
|
|
|
23
23
|
interface Row {
|
|
24
24
|
[column: string]: string | number | null;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* This runtime cannot read SQLite at all: no `node:sqlite` and no sqlite3
|
|
28
|
+
* command. Distinct from one database failing, which listing skips, because
|
|
29
|
+
* it applies to every database: swallowing it per database showed "No Cursor
|
|
30
|
+
* chats found" with every chat still there (Node 20, and 22.5-22.12 without
|
|
31
|
+
* the flag, when sqlite3 is not on PATH).
|
|
32
|
+
*/
|
|
33
|
+
export declare class SqliteUnavailableError extends Error {
|
|
34
|
+
}
|
|
26
35
|
/**
|
|
27
36
|
* Run a read-only query against a Cursor database.
|
|
28
37
|
*
|
package/dist/cursor.js
CHANGED
|
@@ -65,6 +65,24 @@ export function findCursorDatabases() {
|
|
|
65
65
|
}
|
|
66
66
|
return dbs.sort((a, b) => b.mtime - a.mtime).map((d) => d.path);
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* This runtime cannot read SQLite at all: no `node:sqlite` and no sqlite3
|
|
70
|
+
* command. Distinct from one database failing, which listing skips, because
|
|
71
|
+
* it applies to every database: swallowing it per database showed "No Cursor
|
|
72
|
+
* chats found" with every chat still there (Node 20, and 22.5-22.12 without
|
|
73
|
+
* the flag, when sqlite3 is not on PATH).
|
|
74
|
+
*/
|
|
75
|
+
export class SqliteUnavailableError extends Error {
|
|
76
|
+
}
|
|
77
|
+
/** Node's built-in SQLite, when this runtime has it (22.13+, or 22.5+ with --experimental-sqlite). */
|
|
78
|
+
function nodeSqlite() {
|
|
79
|
+
try {
|
|
80
|
+
return require("node:sqlite");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
68
86
|
/**
|
|
69
87
|
* Run a read-only query against a Cursor database.
|
|
70
88
|
*
|
|
@@ -77,31 +95,40 @@ export function findCursorDatabases() {
|
|
|
77
95
|
* safe while Cursor is running.
|
|
78
96
|
*/
|
|
79
97
|
export function queryRows(dbPath, sql) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
98
|
+
// Preferred: Node's built-in SQLite (22.5+).
|
|
99
|
+
const sqlite = nodeSqlite();
|
|
100
|
+
let nodeSqliteErr;
|
|
101
|
+
if (sqlite) {
|
|
84
102
|
try {
|
|
85
|
-
|
|
103
|
+
const db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
|
|
104
|
+
try {
|
|
105
|
+
return db.prepare(sql).all();
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
db.close();
|
|
109
|
+
}
|
|
86
110
|
}
|
|
87
|
-
|
|
88
|
-
|
|
111
|
+
catch (e) {
|
|
112
|
+
nodeSqliteErr = e;
|
|
89
113
|
}
|
|
90
114
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
115
|
+
// Fallback: the sqlite3 CLI, if the user has it.
|
|
116
|
+
try {
|
|
117
|
+
const out = execFileSync("sqlite3", ["-readonly", "-json", dbPath, sql], {
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
120
|
+
stdio: ["ignore", "pipe", "ignore"], // a missing table is expected on some DBs
|
|
121
|
+
});
|
|
122
|
+
return out.trim() ? JSON.parse(out) : [];
|
|
123
|
+
}
|
|
124
|
+
catch (cliErr) {
|
|
125
|
+
// Neither backend exists: the same answer for every database, so say it once.
|
|
126
|
+
if (!sqlite && cliErr.code === "ENOENT") {
|
|
127
|
+
throw new SqliteUnavailableError("reading Cursor chats needs SQLite: Node 22.13+ (built-in; 22.5+ with --experimental-sqlite) or the sqlite3 command. " +
|
|
128
|
+
"Neither is available here.");
|
|
104
129
|
}
|
|
130
|
+
// A backend ran and this database failed (no such table, locked, corrupt).
|
|
131
|
+
throw new Error(`${dbPath}: ${(nodeSqliteErr ?? cliErr).message}`);
|
|
105
132
|
}
|
|
106
133
|
}
|
|
107
134
|
/** List Cursor chats, biggest first. */
|
|
@@ -121,8 +148,12 @@ export function listCursorChats(limit = 20) {
|
|
|
121
148
|
// "No Cursor chats found" with every real chat sitting right there.
|
|
122
149
|
"FROM cursorDiskKV WHERE key LIKE 'composerData:%' AND json_valid(value)");
|
|
123
150
|
}
|
|
124
|
-
catch {
|
|
125
|
-
|
|
151
|
+
catch (e) {
|
|
152
|
+
// No SQLite at all fails every database alike: report it (the CLI says
|
|
153
|
+
// what to install) instead of "No Cursor chats found".
|
|
154
|
+
if (e instanceof SqliteUnavailableError)
|
|
155
|
+
throw e;
|
|
156
|
+
continue; // no composer table (older Cursor), or this database is unreadable — skip
|
|
126
157
|
}
|
|
127
158
|
for (const row of rows) {
|
|
128
159
|
const count = Number(row.n ?? 0);
|
package/dist/dashboard.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface DashboardData {
|
|
|
29
29
|
model?: string;
|
|
30
30
|
}>;
|
|
31
31
|
proxy: {
|
|
32
|
+
startedAt?: string;
|
|
32
33
|
requests: number;
|
|
33
34
|
optimizedRequests: number;
|
|
34
35
|
tokensSaved: number;
|
|
@@ -41,6 +42,15 @@ export interface DashboardData {
|
|
|
41
42
|
} | null;
|
|
42
43
|
}
|
|
43
44
|
export declare function collectDashboardData(proxyPort?: number): Promise<DashboardData>;
|
|
45
|
+
/**
|
|
46
|
+
* Whether a request's Host header names this machine's loopback.
|
|
47
|
+
*
|
|
48
|
+
* Listening on 127.0.0.1 is not enough on its own: a web page can rebind its
|
|
49
|
+
* own hostname to 127.0.0.1 (DNS rebinding) and then read /api/data, session
|
|
50
|
+
* titles included, as same-origin. Such a request still carries the attacker's
|
|
51
|
+
* hostname in Host, so only loopback names are answered.
|
|
52
|
+
*/
|
|
53
|
+
export declare function isLoopbackHost(host: string | undefined, port: number): boolean;
|
|
44
54
|
export declare function startDashboard(opts?: {
|
|
45
55
|
port?: number;
|
|
46
56
|
proxyPort?: number;
|
package/dist/dashboard.js
CHANGED
|
@@ -13,6 +13,7 @@ import { parseConversation } from "./parse.js";
|
|
|
13
13
|
import { profileConversation } from "./profile.js";
|
|
14
14
|
import { inputCostUsd, pricingFor } from "./pricing.js";
|
|
15
15
|
import { checkBudget, loadConfig } from "./config.js";
|
|
16
|
+
import { proxySavings } from "./impact.js";
|
|
16
17
|
/** Sessions bigger than this are skipped so the page stays responsive. */
|
|
17
18
|
const MAX_SESSION_BYTES = 30 * 1024 * 1024;
|
|
18
19
|
async function fetchProxyStats(port) {
|
|
@@ -86,6 +87,9 @@ export async function collectDashboardData(proxyPort = 8787) {
|
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
const proxy = await fetchProxyStats(proxyPort);
|
|
90
|
+
// The same lifetime figure `report` prints: ledger checkpoints (so a stopped
|
|
91
|
+
// proxy's savings still show) plus what the live run has not checkpointed.
|
|
92
|
+
const proxySaved = proxySavings(ledger, proxy);
|
|
89
93
|
const loaded = loadConfig();
|
|
90
94
|
let budget = null;
|
|
91
95
|
if (loaded.path && loaded.config.budget && sessions.length > 0) {
|
|
@@ -98,9 +102,9 @@ export async function collectDashboardData(proxyPort = 8787) {
|
|
|
98
102
|
totals: {
|
|
99
103
|
// What context-doctor removed itself; compaction shrinkage is reported
|
|
100
104
|
// separately (it was counted here until 0.24, which overclaimed).
|
|
101
|
-
tokensSaved: optimizeSaved +
|
|
105
|
+
tokensSaved: optimizeSaved + proxySaved.tokens,
|
|
102
106
|
shrinkage,
|
|
103
|
-
usdSaved: usdSaved +
|
|
107
|
+
usdSaved: usdSaved + proxySaved.usd,
|
|
104
108
|
checks: checks.length + carried.checks,
|
|
105
109
|
warnings: checks.filter((c) => c.warn).length + carried.warnings,
|
|
106
110
|
optimizeRuns: optimizes.length,
|
|
@@ -111,9 +115,30 @@ export async function collectDashboardData(proxyPort = 8787) {
|
|
|
111
115
|
budget,
|
|
112
116
|
};
|
|
113
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether a request's Host header names this machine's loopback.
|
|
120
|
+
*
|
|
121
|
+
* Listening on 127.0.0.1 is not enough on its own: a web page can rebind its
|
|
122
|
+
* own hostname to 127.0.0.1 (DNS rebinding) and then read /api/data, session
|
|
123
|
+
* titles included, as same-origin. Such a request still carries the attacker's
|
|
124
|
+
* hostname in Host, so only loopback names are answered.
|
|
125
|
+
*/
|
|
126
|
+
export function isLoopbackHost(host, port) {
|
|
127
|
+
if (!host)
|
|
128
|
+
return false;
|
|
129
|
+
const h = host.toLowerCase();
|
|
130
|
+
return ["127.0.0.1", "localhost", "[::1]"].some((name) => h === name || h === `${name}:${port}`);
|
|
131
|
+
}
|
|
114
132
|
export function startDashboard(opts = {}) {
|
|
115
133
|
const port = opts.port ?? 8790;
|
|
116
134
|
const server = http.createServer(async (req, res) => {
|
|
135
|
+
// The bound port, not the requested one: port 0 picks a free port.
|
|
136
|
+
const boundPort = server.address()?.port ?? port;
|
|
137
|
+
if (!isLoopbackHost(req.headers.host, boundPort)) {
|
|
138
|
+
res.statusCode = 403;
|
|
139
|
+
res.end("context-doctor dashboard: open it as http://127.0.0.1 or http://localhost");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
117
142
|
try {
|
|
118
143
|
if ((req.url ?? "").startsWith("/api/data")) {
|
|
119
144
|
const data = await collectDashboardData(opts.proxyPort);
|
package/dist/diff.js
CHANGED
|
@@ -16,10 +16,17 @@ import { profileConversation } from "./profile.js";
|
|
|
16
16
|
import { parseSessionFile } from "./session.js";
|
|
17
17
|
import { formatTokens } from "./tokens.js";
|
|
18
18
|
import { formatUsd } from "./pricing.js";
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Load either a conversation file or a Claude Code / Codex / ChatGPT / Gemini
|
|
21
|
+
* transcript. The session reader goes first whatever the extension: ChatGPT's
|
|
22
|
+
* conversations.json and Gemini chats are .json, and read as a plain
|
|
23
|
+
* conversation they became one message of raw text. A file it finds no
|
|
24
|
+
* messages in is a plain conversation (OpenAI/Anthropic messages JSON), as in
|
|
25
|
+
* `watch`.
|
|
26
|
+
*/
|
|
20
27
|
function loadProfile(path, model) {
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
const session = parseSessionFile(path);
|
|
29
|
+
if (session.messageCount > 0) {
|
|
23
30
|
return profileConversation(parseConversation(session.conversationJson), model ?? session.model);
|
|
24
31
|
}
|
|
25
32
|
return profileConversation(parseConversation(readFileSync(path, "utf8")), model);
|