context-doctor 0.29.0 → 0.30.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 +3 -0
- package/dist/autopilot.d.ts +4 -0
- package/dist/autopilot.js +43 -2
- package/dist/cacheplan.d.ts +45 -0
- package/dist/cacheplan.js +180 -0
- package/dist/cli.js +36 -0
- package/dist/dashboard.js +2 -1
- package/dist/doctor.js +22 -10
- package/dist/impact.js +2 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp.js +1 -1
- package/dist/proxy.d.ts +1 -1
- package/dist/proxy.js +1 -1
- package/dist/reads.js +2 -3
- package/dist/session.js +2 -2
- package/dist/subagentpay.d.ts +46 -0
- package/dist/subagentpay.js +227 -0
- package/dist/tasks.js +2 -2
- package/dist/tokens.d.ts +7 -0
- package/dist/tokens.js +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ Pick where you work. Each takes about a minute and is fully reversible.
|
|
|
35
35
|
- `overhead`: what every request re-reads before your message (system prompt, tools, MCP servers, CLAUDE.md, rules, memory), each file priced per month.
|
|
36
36
|
- `tasks`: cost per commit, per project, from each session's recorded usage and its own `git commit` calls.
|
|
37
37
|
- `reads`: the files read into session after session, and whether a CLAUDE.md summary or `pack` would pay.
|
|
38
|
+
- `subagents`: whether each subagent was cheaper than doing the same work in the parent session, and by how much.
|
|
39
|
+
- `cache-plan <request.json>`: where an Anthropic API request should carry `cache_control`, and what it saves over N calls; `--out` writes it applied.
|
|
38
40
|
- `session`, `watch`, `analyze`, `report`, `dashboard`: token breakdown and wasted-context findings for any session, conversation file or agent trace.
|
|
39
41
|
|
|
40
42
|
**Keep waste out of the context**
|
|
@@ -177,6 +179,7 @@ MCP servers are part of the same overhead: each one's tool definitions ride on e
|
|
|
177
179
|
|
|
178
180
|
## What's new
|
|
179
181
|
|
|
182
|
+
- **0.30 Where the money goes, and a self-healing service**: `context-doctor subagents` compares each subagent's bill with the same work done in the parent session; `cache-plan` places Anthropic `cache_control` breakpoints in an API request and prices them. Fixes from a local audit: the autopilot service now restarts itself onto a newly installed version, `doctor` no longer writes fake hook checks into the activity ledger, and `<synthetic>` placeholder messages no longer hide a session's model.
|
|
180
183
|
- **0.29 Python, and where the work goes**: a dependency-free Python package (`python/`) with `pack_documents` for LangChain and LlamaIndex results, `pack_context`, `estimate_tokens` and `profile_messages`, held byte for byte to the TypeScript by parity tests. `context-doctor reads` shows the files read into session after session; `context-doctor tasks` shows cost per commit per project, from your own transcripts.
|
|
181
184
|
- **0.28 Wider reach**: `/context-doctor:overhead` and `/context-doctor:pack` in the Claude Code plugin; `pack` reads a folder inside a git repository the way git does, so `.gitignore`'d build output and secrets stay out; `withContextDoctor` wraps Google's `@google/genai` client too; `overhead` measures the fixed start-of-session cost of Codex and Gemini CLI sessions as well as Claude Code's, priced with each provider's cache rules.
|
|
182
185
|
- **0.27 Keep waste out**: `pack` and the `pack_context` MCP tool put only the chunks of big files (text, code, PDF, Word, PowerPoint) a question needs into the context, within a budget; `overhead` measures and prices the front matter every request re-reads (system prompt, tools, CLAUDE.md, rules, memory, AGENTS.md, Cursor rules, GEMINI.md). MCP servers sized and their use counted; `overhead split` turns a heavy memory file into a lean one plus a reference file. `withContextDoctor(client)` runs autopilot inside an Anthropic or OpenAI SDK client, for apps with no proxy. Gemini joins: `install` wires Gemini CLI's MCP server and every-prompt hook, `session` and `watch` read its chats, and the proxy and autopilot carry Google's Gemini API. A GitHub Action (and `context-doctor ci`) reports memory-file growth on every pull request and gates on a budget. Re-verified on every surface: 229 tests on macOS, Linux and Windows, plus MCP, OpenAI and Gemini end-to-end smoke runs, now in CI, MCP over stdio and HTTP, the OpenAI proxy, and a live `doctor`.
|
package/dist/autopilot.d.ts
CHANGED
|
@@ -33,6 +33,10 @@ export declare function serviceCommand(node: string, cli: string, port: number,
|
|
|
33
33
|
export declare function launchdPlist(args: string[], log: string): string;
|
|
34
34
|
export declare function systemdUnit(args: string[], log: string): string;
|
|
35
35
|
export declare function windowsTaskCommand(args: string[]): string;
|
|
36
|
+
/** Version of the package a CLI path belongs to (dist/cli.js -> ../package.json). */
|
|
37
|
+
export declare function installedVersion(cli: string): string | undefined;
|
|
38
|
+
/** Restart the installed background service onto the current build (launchd, systemd). */
|
|
39
|
+
export declare function restartService(): boolean;
|
|
36
40
|
export declare function startDetached(args: string[], log: string): void;
|
|
37
41
|
export interface Health {
|
|
38
42
|
ok: boolean;
|
package/dist/autopilot.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* backend, not an API base URL).
|
|
20
20
|
*/
|
|
21
21
|
import { execFileSync, spawn } from "node:child_process";
|
|
22
|
-
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { homedir, platform } from "node:os";
|
|
24
24
|
import { dirname, join } from "node:path";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
@@ -151,6 +151,38 @@ function removeService() {
|
|
|
151
151
|
tryRun("schtasks", ["/Delete", "/F", "/TN", "context-doctor-proxy"]);
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
+
/** Version of the package a CLI path belongs to (dist/cli.js -> ../package.json). */
|
|
155
|
+
export function installedVersion(cli) {
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(readFileSync(join(dirname(cli), "..", "package.json"), "utf8")).version;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function restartDue(paths) {
|
|
164
|
+
const marker = join(dirname(paths.log), ".context-doctor-proxy-restart");
|
|
165
|
+
try {
|
|
166
|
+
if (existsSync(marker) && Date.now() - statSync(marker).mtimeMs < 3_600_000)
|
|
167
|
+
return false;
|
|
168
|
+
mkdirSync(dirname(marker), { recursive: true });
|
|
169
|
+
writeFileSync(marker, String(Date.now()));
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Restart the installed background service onto the current build (launchd, systemd). */
|
|
177
|
+
export function restartService() {
|
|
178
|
+
const os = platform();
|
|
179
|
+
const sp = servicePaths();
|
|
180
|
+
if (os === "darwin" && existsSync(sp.plist))
|
|
181
|
+
return tryRun("launchctl", ["kickstart", "-k", `gui/${uid()}/${LABEL}`]);
|
|
182
|
+
if (os === "linux" && existsSync(sp.unit))
|
|
183
|
+
return tryRun("systemctl", ["--user", "restart", "context-doctor-proxy.service"]);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
154
186
|
export function startDetached(args, log) {
|
|
155
187
|
mkdirSync(dirname(log), { recursive: true });
|
|
156
188
|
const out = openSync(log, "a");
|
|
@@ -393,8 +425,17 @@ export async function ensureProxyUp(paths = autopilotPaths()) {
|
|
|
393
425
|
const cfg = readConfig(paths);
|
|
394
426
|
if (!cfg)
|
|
395
427
|
return;
|
|
396
|
-
|
|
428
|
+
const h = await health(cfg.port, 300);
|
|
429
|
+
if (h.ok) {
|
|
430
|
+
// A service keeps running the code it started with: after an update it
|
|
431
|
+
// answers fine but is still the old version (seen: 0.25 serving for days
|
|
432
|
+
// after 0.29 was installed). Restart it onto the installed build, at most
|
|
433
|
+
// once an hour so an install that cannot fix it never loops.
|
|
434
|
+
const installed = typeof cfg.cli === "string" ? installedVersion(cfg.cli) : undefined;
|
|
435
|
+
if (installed && h.version && h.version !== installed && restartDue(paths))
|
|
436
|
+
restartService();
|
|
397
437
|
return;
|
|
438
|
+
}
|
|
398
439
|
if (typeof cfg.node !== "string" || typeof cfg.cli !== "string" || !existsSync(cfg.node) || !existsSync(cfg.cli))
|
|
399
440
|
return;
|
|
400
441
|
startDetached(serviceCommand(cfg.node, cfg.cli, cfg.port, paths), paths.log);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor cache-plan <request.json>`: where an API app should put
|
|
3
|
+
* Anthropic `cache_control` breakpoints, and what that saves.
|
|
4
|
+
*
|
|
5
|
+
* A request is cached as a prefix in a fixed order: tools, then system, then
|
|
6
|
+
* messages. A breakpoint caches everything up to and including the block it
|
|
7
|
+
* sits on, a prefix under the model's minimum (1,024 tokens; 2,048 on Haiku)
|
|
8
|
+
* is not cached at all, a request may carry at most 4 breakpoints, and the
|
|
9
|
+
* cache looks back at most 20 blocks from a breakpoint for an earlier hit.
|
|
10
|
+
* The planner places breakpoints at the ends of the stable parts (last tool,
|
|
11
|
+
* last system block) and on the last block of the conversation, adds a
|
|
12
|
+
* middle anchor when the conversation is longer than the 20-block lookback,
|
|
13
|
+
* prices the request sent N times with and without the plan, and with
|
|
14
|
+
* --out saves the request with the breakpoints in place.
|
|
15
|
+
*
|
|
16
|
+
* Pricing assumes the same request is sent N times (an agent loop's steps, a
|
|
17
|
+
* shared system prompt and document set); cache writes cost 1.25x input
|
|
18
|
+
* (2x with ttl "1h"), reads 0.1x.
|
|
19
|
+
*/
|
|
20
|
+
export interface Breakpoint {
|
|
21
|
+
/** Where: "tools[3]", "system[1]", "messages[12].content[0]". */
|
|
22
|
+
at: string;
|
|
23
|
+
/** Prefix tokens cached up to and including this point. */
|
|
24
|
+
prefixTokens: number;
|
|
25
|
+
why: string;
|
|
26
|
+
}
|
|
27
|
+
export interface CachePlan {
|
|
28
|
+
provider: "anthropic" | "openai" | "unknown";
|
|
29
|
+
model?: string;
|
|
30
|
+
totalTokens: number;
|
|
31
|
+
minCacheable: number;
|
|
32
|
+
existing: string[];
|
|
33
|
+
plan: Breakpoint[];
|
|
34
|
+
/** Tokens before the last breakpoint of the plan: what later calls read from cache. */
|
|
35
|
+
cachedTokens: number;
|
|
36
|
+
calls: number;
|
|
37
|
+
usdWithout?: number;
|
|
38
|
+
usdWith?: number;
|
|
39
|
+
notes: string[];
|
|
40
|
+
}
|
|
41
|
+
export declare function planCache(request: unknown, calls?: number, modelOverride?: string): {
|
|
42
|
+
plan: CachePlan;
|
|
43
|
+
apply: () => Record<string, any>;
|
|
44
|
+
};
|
|
45
|
+
export declare function renderCachePlan(p: CachePlan): string;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor cache-plan <request.json>`: where an API app should put
|
|
3
|
+
* Anthropic `cache_control` breakpoints, and what that saves.
|
|
4
|
+
*
|
|
5
|
+
* A request is cached as a prefix in a fixed order: tools, then system, then
|
|
6
|
+
* messages. A breakpoint caches everything up to and including the block it
|
|
7
|
+
* sits on, a prefix under the model's minimum (1,024 tokens; 2,048 on Haiku)
|
|
8
|
+
* is not cached at all, a request may carry at most 4 breakpoints, and the
|
|
9
|
+
* cache looks back at most 20 blocks from a breakpoint for an earlier hit.
|
|
10
|
+
* The planner places breakpoints at the ends of the stable parts (last tool,
|
|
11
|
+
* last system block) and on the last block of the conversation, adds a
|
|
12
|
+
* middle anchor when the conversation is longer than the 20-block lookback,
|
|
13
|
+
* prices the request sent N times with and without the plan, and with
|
|
14
|
+
* --out saves the request with the breakpoints in place.
|
|
15
|
+
*
|
|
16
|
+
* Pricing assumes the same request is sent N times (an agent loop's steps, a
|
|
17
|
+
* shared system prompt and document set); cache writes cost 1.25x input
|
|
18
|
+
* (2x with ttl "1h"), reads 0.1x.
|
|
19
|
+
*/
|
|
20
|
+
import { estimateTokens, formatTokens } from "./tokens.js";
|
|
21
|
+
import { formatUsd, pricingFor } from "./pricing.js";
|
|
22
|
+
const LOOKBACK_BLOCKS = 20;
|
|
23
|
+
const MAX_BREAKPOINTS = 4;
|
|
24
|
+
function blocksOf(req, model) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const mark = (obj) => (on) => {
|
|
27
|
+
if (on)
|
|
28
|
+
obj.cache_control = obj.cache_control ?? { type: "ephemeral" };
|
|
29
|
+
else
|
|
30
|
+
delete obj.cache_control;
|
|
31
|
+
};
|
|
32
|
+
const tok = (x) => estimateTokens(typeof x === "string" ? x : JSON.stringify(x ?? ""), model);
|
|
33
|
+
(Array.isArray(req.tools) ? req.tools : []).forEach((t, i) => {
|
|
34
|
+
out.push({ at: `tools[${i}]`, tokens: tok(t), section: "tools", hasBreakpoint: Boolean(t?.cache_control), set: mark(t) });
|
|
35
|
+
});
|
|
36
|
+
if (typeof req.system === "string" && req.system) {
|
|
37
|
+
// A string system prompt cannot carry cache_control: it becomes one text block when planned.
|
|
38
|
+
const holder = { type: "text", text: req.system };
|
|
39
|
+
out.push({
|
|
40
|
+
at: "system[0]",
|
|
41
|
+
tokens: tok(req.system),
|
|
42
|
+
section: "system",
|
|
43
|
+
hasBreakpoint: false,
|
|
44
|
+
set: (on) => {
|
|
45
|
+
mark(holder)(on);
|
|
46
|
+
req.system = on ? [holder] : req.system;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else if (Array.isArray(req.system)) {
|
|
51
|
+
req.system.forEach((b, i) => out.push({ at: `system[${i}]`, tokens: tok(b?.text ?? b), section: "system", hasBreakpoint: Boolean(b?.cache_control), set: mark(b) }));
|
|
52
|
+
}
|
|
53
|
+
(Array.isArray(req.messages) ? req.messages : []).forEach((m, i) => {
|
|
54
|
+
if (typeof m?.content === "string") {
|
|
55
|
+
const holder = { type: "text", text: m.content };
|
|
56
|
+
out.push({
|
|
57
|
+
at: `messages[${i}].content[0]`,
|
|
58
|
+
tokens: tok(m.content) + 4,
|
|
59
|
+
section: "messages",
|
|
60
|
+
hasBreakpoint: false,
|
|
61
|
+
set: (on) => {
|
|
62
|
+
mark(holder)(on);
|
|
63
|
+
if (on)
|
|
64
|
+
m.content = [holder];
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else if (Array.isArray(m?.content)) {
|
|
69
|
+
m.content.forEach((b, j) => {
|
|
70
|
+
const isImage = b?.type === "image";
|
|
71
|
+
out.push({ at: `messages[${i}].content[${j}]`, tokens: isImage ? 1600 : tok(b?.text ?? b?.content ?? b) + (j === 0 ? 4 : 0), section: "messages", hasBreakpoint: Boolean(b?.cache_control), set: mark(b) });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
export function planCache(request, calls = 10, modelOverride) {
|
|
78
|
+
const req = (typeof request === "string" ? JSON.parse(request) : request);
|
|
79
|
+
const model = modelOverride ?? (typeof req.model === "string" ? req.model : undefined);
|
|
80
|
+
const notes = [];
|
|
81
|
+
const isOpenAI = Array.isArray(req.input) || (Array.isArray(req.messages) && req.messages.some((m) => m?.role === "system" || m?.role === "developer" || m?.role === "tool"));
|
|
82
|
+
if (isOpenAI || /gpt|^o\d/i.test(model ?? "")) {
|
|
83
|
+
const text = JSON.stringify(req.tools ?? "") + JSON.stringify(req.messages ?? req.input ?? "");
|
|
84
|
+
notes.push("OpenAI caches automatically: any request prefix over 1,024 tokens that matches an earlier one is billed at the cached rate, with no markers to place.", "What you control is order: keep tools, instructions and reference documents first and identical between calls, and put per-call content last.", "Set `prompt_cache_key` to a stable value per conversation or tenant so requests that share a prefix land on the same cache.");
|
|
85
|
+
return {
|
|
86
|
+
plan: { provider: "openai", model, totalTokens: estimateTokens(text, model), minCacheable: 1024, existing: [], plan: [], cachedTokens: 0, calls, notes },
|
|
87
|
+
apply: () => req,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const blocks = blocksOf(req, model);
|
|
91
|
+
const total = blocks.reduce((s, b) => s + b.tokens, 0);
|
|
92
|
+
const minCacheable = /haiku/i.test(model ?? "") ? 2048 : 1024;
|
|
93
|
+
const prefixAt = (i) => blocks.slice(0, i + 1).reduce((s, b) => s + b.tokens, 0);
|
|
94
|
+
const lastIndex = (section) => blocks.map((b) => b.section).lastIndexOf(section);
|
|
95
|
+
const picks = [];
|
|
96
|
+
const pick = (i, why) => {
|
|
97
|
+
if (i < 0 || picks.some((p) => p.i === i) || prefixAt(i) < minCacheable)
|
|
98
|
+
return;
|
|
99
|
+
picks.push({ i, why });
|
|
100
|
+
};
|
|
101
|
+
const lastMsg = lastIndex("messages");
|
|
102
|
+
pick(lastIndex("tools"), "end of the tool definitions: stable across every call");
|
|
103
|
+
pick(lastIndex("system"), "end of the system prompt: stable across every call");
|
|
104
|
+
// The conversation boundary moves forward each turn; a breakpoint on the last block caches the whole history for the next call.
|
|
105
|
+
pick(lastMsg, "last block of the conversation: the next call reads everything before it from cache");
|
|
106
|
+
// Beyond 20 blocks back from the moving breakpoint the cache stops looking; anchor the middle.
|
|
107
|
+
const firstMsg = blocks.findIndex((b) => b.section === "messages");
|
|
108
|
+
if (firstMsg >= 0 && lastMsg - firstMsg > LOOKBACK_BLOCKS && picks.length < MAX_BREAKPOINTS) {
|
|
109
|
+
pick(lastMsg - LOOKBACK_BLOCKS, `anchor ${LOOKBACK_BLOCKS} blocks back: the cache only looks back ${LOOKBACK_BLOCKS} blocks from a breakpoint`);
|
|
110
|
+
}
|
|
111
|
+
picks.sort((a, b) => a.i - b.i);
|
|
112
|
+
while (picks.length > MAX_BREAKPOINTS)
|
|
113
|
+
picks.splice(1, 1);
|
|
114
|
+
const existing = blocks.filter((b) => b.hasBreakpoint).map((b) => b.at);
|
|
115
|
+
if (existing.length > MAX_BREAKPOINTS)
|
|
116
|
+
notes.push(`The request carries ${existing.length} breakpoints; the API rejects more than ${MAX_BREAKPOINTS}.`);
|
|
117
|
+
if (total < minCacheable)
|
|
118
|
+
notes.push(`The whole request is ~${formatTokens(total)} tokens, under the ${formatTokens(minCacheable)} minimum ${model ?? "this model"} caches: caching cannot help until it grows.`);
|
|
119
|
+
const cachedTokens = picks.length ? prefixAt(picks[picks.length - 1].i) : 0;
|
|
120
|
+
const price = pricingFor(model);
|
|
121
|
+
const longTtl = /"ttl"\s*:\s*"1h"/.test(JSON.stringify(req));
|
|
122
|
+
const writeMult = longTtl ? 2 : 1.25;
|
|
123
|
+
let usdWithout, usdWith;
|
|
124
|
+
if (price) {
|
|
125
|
+
const p = price.inputPerM / 1e6;
|
|
126
|
+
usdWithout = calls * total * p;
|
|
127
|
+
const rest = total - cachedTokens;
|
|
128
|
+
usdWith = cachedTokens * writeMult * p + rest * p + (calls - 1) * (cachedTokens * 0.1 * p + rest * p);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
plan: {
|
|
132
|
+
provider: "anthropic",
|
|
133
|
+
model,
|
|
134
|
+
totalTokens: total,
|
|
135
|
+
minCacheable,
|
|
136
|
+
existing,
|
|
137
|
+
plan: picks.map((p) => ({ at: blocks[p.i].at, prefixTokens: prefixAt(p.i), why: p.why })),
|
|
138
|
+
cachedTokens,
|
|
139
|
+
calls,
|
|
140
|
+
usdWithout,
|
|
141
|
+
usdWith,
|
|
142
|
+
notes,
|
|
143
|
+
},
|
|
144
|
+
apply: () => {
|
|
145
|
+
for (const b of blocks)
|
|
146
|
+
b.set(false);
|
|
147
|
+
for (const p of picks)
|
|
148
|
+
blocks[p.i].set(true);
|
|
149
|
+
return req;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export function renderCachePlan(p) {
|
|
154
|
+
const out = ["CACHE PLAN", "═".repeat(56)];
|
|
155
|
+
out.push(`${p.provider === "openai" ? "OpenAI" : "Anthropic"} request${p.model ? ` for ${p.model}` : ""}: ~${formatTokens(p.totalTokens)} tokens.`);
|
|
156
|
+
if (p.provider === "openai") {
|
|
157
|
+
out.push("", ...p.notes.map((n) => `• ${n}`));
|
|
158
|
+
return out.join("\n");
|
|
159
|
+
}
|
|
160
|
+
out.push(`Breakpoints now: ${p.existing.length ? p.existing.join(", ") : "none"}.`, "");
|
|
161
|
+
if (p.plan.length === 0) {
|
|
162
|
+
out.push("Nothing to place: no stable prefix reaches the caching minimum.");
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
out.push(`Place cache_control on (${p.plan.length} of ${MAX_BREAKPOINTS} allowed):`);
|
|
166
|
+
for (const b of p.plan)
|
|
167
|
+
out.push(` ${b.at.padEnd(26)} prefix ~${formatTokens(b.prefixTokens).padStart(6)} ${b.why}`);
|
|
168
|
+
}
|
|
169
|
+
if (p.usdWithout !== undefined && p.usdWith !== undefined && p.plan.length) {
|
|
170
|
+
const saved = p.usdWithout - p.usdWith;
|
|
171
|
+
out.push("", `Sent ${p.calls} times: ${formatUsd(p.usdWithout)} of input without caching, ${formatUsd(p.usdWith)} with this plan (${saved >= 0 ? "saves" : "costs"} ${formatUsd(Math.abs(saved))}, ${Math.round((Math.abs(saved) / p.usdWithout) * 100)}%).`, `(~${formatTokens(p.cachedTokens)} tokens read at 0.1x after the first call, which writes them once at the cache-write rate.)`);
|
|
172
|
+
}
|
|
173
|
+
if (p.notes.length)
|
|
174
|
+
out.push("", ...p.notes.map((n) => `! ${n}`));
|
|
175
|
+
if (p.usdWithout === undefined && p.plan.length)
|
|
176
|
+
out.push("", "Pass --model (e.g. claude-sonnet-5) to price it.");
|
|
177
|
+
if (p.plan.length)
|
|
178
|
+
out.push("", "--out <file> saves the request with exactly these breakpoints.");
|
|
179
|
+
return out.join("\n");
|
|
180
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -47,6 +47,8 @@ import { applySplit, planSplit, renderSplit } from "./split.js";
|
|
|
47
47
|
import { ciReport, renderCiMarkdown, renderCiText } from "./ci.js";
|
|
48
48
|
import { renderReads, repeatedReads } from "./reads.js";
|
|
49
49
|
import { costPerTask, renderTasks } from "./tasks.js";
|
|
50
|
+
import { planCache, renderCachePlan } from "./cacheplan.js";
|
|
51
|
+
import { renderPayback, subagentPayback } from "./subagentpay.js";
|
|
50
52
|
const HELP = `context-doctor — profile and optimize LLM context windows
|
|
51
53
|
|
|
52
54
|
Usage:
|
|
@@ -104,6 +106,11 @@ Usage:
|
|
|
104
106
|
context-doctor ci [--base <ref>] [--max-tokens n] [--max-increase n] [--markdown]
|
|
105
107
|
For pull requests: memory files (CLAUDE.md, AGENTS.md,
|
|
106
108
|
GEMINI.md, rules) at HEAD vs base; exit 1 over budget
|
|
109
|
+
context-doctor cache-plan <request.json> [--calls n] [--out file]
|
|
110
|
+
Where an Anthropic request should carry cache_control,
|
|
111
|
+
what it saves over n calls; --out writes it applied
|
|
112
|
+
context-doctor subagents [--days n] Which subagents paid for themselves: their bill vs
|
|
113
|
+
what carrying their reads would have cost the parent
|
|
107
114
|
context-doctor tasks [--days n] Cost per commit, per project: session cost at list
|
|
108
115
|
price vs commits made during the sessions
|
|
109
116
|
context-doctor reads [--days n] [--limit n] Files read into session after session: reads, tokens,
|
|
@@ -290,6 +297,9 @@ function parseArgs(argv) {
|
|
|
290
297
|
case "--write":
|
|
291
298
|
args.write = true;
|
|
292
299
|
break;
|
|
300
|
+
case "--calls":
|
|
301
|
+
args.calls = numArg("--calls", argv[++i], { min: 1, integer: true });
|
|
302
|
+
break;
|
|
293
303
|
case "--semantic":
|
|
294
304
|
args.semantic = args.semantic || true;
|
|
295
305
|
break;
|
|
@@ -717,6 +727,32 @@ async function main() {
|
|
|
717
727
|
});
|
|
718
728
|
return; // server keeps the process alive
|
|
719
729
|
}
|
|
730
|
+
if (args.command === "subagents") {
|
|
731
|
+
const report = subagentPayback(args.days ?? 30);
|
|
732
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : renderPayback(report, args.limit ?? 8));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (args.command === "cache-plan") {
|
|
736
|
+
const file = args.positionals?.[0];
|
|
737
|
+
if (!file) {
|
|
738
|
+
console.error("usage: context-doctor cache-plan <request.json|-> [--calls 10] [--out planned.json]");
|
|
739
|
+
process.exit(1);
|
|
740
|
+
}
|
|
741
|
+
let result;
|
|
742
|
+
try {
|
|
743
|
+
result = planCache(readInput(file), args.calls ?? 10, args.model);
|
|
744
|
+
}
|
|
745
|
+
catch (e) {
|
|
746
|
+
console.error(`Could not read a request from ${file}: ${e.message}`);
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
console.log(args.json ? JSON.stringify(result.plan, null, 2) : renderCachePlan(result.plan));
|
|
750
|
+
if (args.out) {
|
|
751
|
+
writeFileSync(args.out, JSON.stringify(result.apply(), null, 2));
|
|
752
|
+
console.error(`context-doctor: request with the planned breakpoints written to ${args.out}`);
|
|
753
|
+
}
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
720
756
|
if (args.command === "tasks") {
|
|
721
757
|
const report = costPerTask(args.days ?? 30);
|
|
722
758
|
console.log(args.json ? JSON.stringify(report, null, 2) : renderTasks(report, args.limit ?? 12));
|
package/dist/dashboard.js
CHANGED
|
@@ -31,7 +31,8 @@ export async function collectDashboardData(proxyPort = 8787) {
|
|
|
31
31
|
// Totals folded in when the ledger rotated; excluded from the daily series,
|
|
32
32
|
// which describes individual days rather than a carried-forward sum.
|
|
33
33
|
const carried = foldTotals(ledger.filter((e) => e.ev === "rollup"));
|
|
34
|
-
|
|
34
|
+
// "doctor-probe" entries came from `doctor` (before 0.30), not from the hook.
|
|
35
|
+
const checks = ledger.filter((e) => (e.ev === "check" || e.ev === undefined) && e.sid !== "doctor-probe");
|
|
35
36
|
const optimizes = ledger.filter((e) => e.ev === "optimize");
|
|
36
37
|
// Observed shrinkage: a session getting SMALLER between two deep checks is a
|
|
37
38
|
// real reduction, so it counts alongside explicit optimize runs.
|
package/dist/doctor.js
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
* check, so "it doesn't work" becomes a single pasteable diagnosis. Always
|
|
6
6
|
* exits 0 — absence of an app is a note, not a failure.
|
|
7
7
|
*/
|
|
8
|
-
import { autopilotPaths, health, proxyUrl, recentSurfaces } from "./autopilot.js";
|
|
8
|
+
import { autopilotPaths, health, installedVersion, proxyUrl, recentSurfaces } from "./autopilot.js";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { accessSync, constants as fsConstants, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
11
11
|
import { homedir, platform } from "node:os";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
|
-
import { ledgerPath
|
|
14
|
+
import { ledgerPath } from "./ledger.js";
|
|
15
15
|
import { loadConfig } from "./config.js";
|
|
16
16
|
import { geminiCliPresent, geminiSettingsPath } from "./install.js";
|
|
17
17
|
function claudeDesktopConfigPath() {
|
|
@@ -234,10 +234,14 @@ export async function runDoctor() {
|
|
|
234
234
|
checks.push(existsSync(skillPath)
|
|
235
235
|
? { label: "Agent Skill", status: "ok", detail: skillPath }
|
|
236
236
|
: { label: "Agent Skill", status: "skip", detail: "not installed (run context-doctor install on a Claude Code machine)" });
|
|
237
|
-
// Ledger writable
|
|
237
|
+
// Ledger writable. Tested without writing an entry: recordLedger swallows
|
|
238
|
+
// errors (so this could never fail), and the entry it wrote counted as a
|
|
239
|
+
// hook check in every report.
|
|
238
240
|
try {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
const lp = ledgerPath();
|
|
242
|
+
mkdirSync(dirname(lp), { recursive: true });
|
|
243
|
+
accessSync(existsSync(lp) ? lp : dirname(lp), fsConstants.W_OK);
|
|
244
|
+
checks.push({ label: "Ledger", status: "ok", detail: `writable at ${lp}` });
|
|
241
245
|
}
|
|
242
246
|
catch {
|
|
243
247
|
checks.push({ label: "Ledger", status: "fail", detail: `cannot write ${ledgerPath()}` });
|
|
@@ -264,6 +268,7 @@ export async function runDoctor() {
|
|
|
264
268
|
}
|
|
265
269
|
else {
|
|
266
270
|
const cfg = JSON.parse(readFileSync(paths.config, "utf8"));
|
|
271
|
+
const installed = typeof cfg.cli === "string" ? installedVersion(cfg.cli) : undefined;
|
|
267
272
|
const h = await health(cfg.port);
|
|
268
273
|
let routed = false;
|
|
269
274
|
try {
|
|
@@ -283,9 +288,11 @@ export async function runDoctor() {
|
|
|
283
288
|
const desktopOnly = known.length > 0 && known.every((ep) => ep.startsWith("claude-desktop"));
|
|
284
289
|
checks.push(h.ok && h.autopilot && routed && seen === 0 && desktopOnly
|
|
285
290
|
? { label: "Autopilot", status: "warn", detail: "running, but it has seen 0 requests: your recent Claude Code sessions all ran in the desktop app, which ignores settings.json's API address. Autopilot only reaches terminal and IDE sessions; for desktop ones, see `context-doctor compact-window`" }
|
|
286
|
-
: h.ok && h.autopilot && routed
|
|
287
|
-
? { label: "Autopilot", status: "
|
|
288
|
-
:
|
|
291
|
+
: h.ok && h.autopilot && routed && h.version && installed && h.version !== installed
|
|
292
|
+
? { label: "Autopilot", status: "warn", detail: `proxy is running v${h.version} but v${installed} is installed; the next Claude Code prompt restarts it, or run: context-doctor autopilot on` }
|
|
293
|
+
: h.ok && h.autopilot && routed
|
|
294
|
+
? { label: "Autopilot", status: "ok", detail: `proxy up on ${proxyUrl(cfg.port)}, Claude Code routed through it${seen >= 0 ? ` (${seen} requests since start)` : ""}${paused ? " (PAUSED: passthrough)" : ""}` }
|
|
295
|
+
: { label: "Autopilot", status: "fail", detail: !h.ok ? `proxy not answering on ${proxyUrl(cfg.port)}: the next Claude Code prompt restarts it; or run: context-doctor autopilot on` : "settings.json no longer routes Claude Code to the proxy: run context-doctor autopilot on" });
|
|
289
296
|
}
|
|
290
297
|
}
|
|
291
298
|
const mark = { ok: "✓", warn: "!", fail: "✗", skip: "–" };
|
|
@@ -295,6 +302,11 @@ export async function runDoctor() {
|
|
|
295
302
|
console.log(`${mark[c.status]} ${c.label.padEnd(22)} ${c.detail}`);
|
|
296
303
|
}
|
|
297
304
|
const fails = checks.filter((c) => c.status === "fail");
|
|
305
|
+
const warns = checks.filter((c) => c.status === "warn");
|
|
298
306
|
console.log("");
|
|
299
|
-
console.log(fails.length
|
|
307
|
+
console.log(fails.length > 0
|
|
308
|
+
? `${fails.length} issue(s) found — fixes suggested above.`
|
|
309
|
+
: warns.length > 0
|
|
310
|
+
? `No problems; ${warns.length} note(s) marked ! above.`
|
|
311
|
+
: "All good.");
|
|
300
312
|
}
|
package/dist/impact.js
CHANGED
|
@@ -36,7 +36,8 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
36
36
|
// Rotation folds dropped entries into a rollup. Counting it is what keeps
|
|
37
37
|
// these lifetime totals from going backwards once the cap is hit.
|
|
38
38
|
const carried = foldTotals(ledger.filter((e) => e.ev === "rollup"));
|
|
39
|
-
|
|
39
|
+
// "doctor-probe" entries came from `doctor` (before 0.30), not from the hook.
|
|
40
|
+
const checks = ledger.filter((e) => (e.ev === "check" || e.ev === undefined) && e.sid !== "doctor-probe");
|
|
40
41
|
const optimizes = ledger.filter((e) => e.ev === "optimize");
|
|
41
42
|
// Observed per-session reductions: when a session SHRANK between two deep
|
|
42
43
|
// checks (compaction/cleanup after a warning), that drop is measured fact.
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/mcp.js
CHANGED
|
@@ -52,7 +52,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
|
|
|
52
52
|
* recommended pattern.
|
|
53
53
|
*/
|
|
54
54
|
function createServer({ fileAccess = true } = {}) {
|
|
55
|
-
const server = new McpServer({ name: "context-doctor", version: "0.
|
|
55
|
+
const server = new McpServer({ name: "context-doctor", version: "0.30.0" }, { instructions: SERVER_INSTRUCTIONS });
|
|
56
56
|
server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~120 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
|
|
57
57
|
conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
|
|
58
58
|
sketch: z.object({
|
package/dist/proxy.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface ProxyOptions extends OptimizeOptions {
|
|
|
48
48
|
googleUpstream?: string;
|
|
49
49
|
}
|
|
50
50
|
/** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
|
|
51
|
-
export declare const PROXY_VERSION = "0.
|
|
51
|
+
export declare const PROXY_VERSION = "0.30.0";
|
|
52
52
|
/**
|
|
53
53
|
* Remove a leading `/t/<token>` from a request path, or return undefined when
|
|
54
54
|
* the prefix is absent or the token differs. The comparison is constant time
|
package/dist/proxy.js
CHANGED
|
@@ -20,7 +20,7 @@ import { formatTokens, CHARS_PER_TOKEN, providerFor } from "./tokens.js";
|
|
|
20
20
|
import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
|
|
21
21
|
import { recordLedger } from "./ledger.js";
|
|
22
22
|
/** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
|
|
23
|
-
export const PROXY_VERSION = "0.
|
|
23
|
+
export const PROXY_VERSION = "0.30.0";
|
|
24
24
|
/** Connection-level headers that must not be forwarded. */
|
|
25
25
|
const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
|
|
26
26
|
const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
|
package/dist/reads.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* is cheaper left as a read; `pack` can cut it to the part a question needs).
|
|
12
12
|
*/
|
|
13
13
|
import { forEachLine, listSessions } from "./session.js";
|
|
14
|
-
import { estimateTokens, formatTokens } from "./tokens.js";
|
|
14
|
+
import { estimateTokens, formatTokens, realModel } from "./tokens.js";
|
|
15
15
|
import { formatUsd, pricingFor } from "./pricing.js";
|
|
16
16
|
const CLAUDE = "claude-opus-5";
|
|
17
17
|
/** An image in a tool result bills as an image (~1.6k tokens for a full-size screenshot), not as its base64. */
|
|
@@ -56,8 +56,7 @@ function sessionReads(path) {
|
|
|
56
56
|
if (!Array.isArray(content))
|
|
57
57
|
return;
|
|
58
58
|
if (e.type === "assistant") {
|
|
59
|
-
|
|
60
|
-
model = e.message.model;
|
|
59
|
+
model = realModel(e.message.model) ?? model;
|
|
61
60
|
for (const b of content) {
|
|
62
61
|
if (b?.type === "tool_use" && b.name === "Read" && typeof b.input?.file_path === "string")
|
|
63
62
|
pending.set(b.id, b.input.file_path);
|
package/dist/session.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* where `message` is in Anthropic Messages format. Everything else
|
|
9
9
|
* (titles, mode changes, hook records) is metadata and skipped.
|
|
10
10
|
*/
|
|
11
|
+
import { realModel } from "./tokens.js";
|
|
11
12
|
import { readdirSync, readFileSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
12
13
|
import { StringDecoder } from "node:string_decoder";
|
|
13
14
|
import { homedir } from "node:os";
|
|
@@ -371,8 +372,7 @@ export function parseSessionFile(path) {
|
|
|
371
372
|
const message = entry.message;
|
|
372
373
|
if (!message.role || message.content == null)
|
|
373
374
|
return;
|
|
374
|
-
|
|
375
|
-
model = message.model;
|
|
375
|
+
model = realModel(message.model) ?? model;
|
|
376
376
|
const usage = message.usage;
|
|
377
377
|
if (entry.type === "assistant" && usage) {
|
|
378
378
|
// Coerce, do not trust: a transcript whose usage numbers are STRINGS
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor subagents`: which subagent tasks pay for themselves.
|
|
3
|
+
*
|
|
4
|
+
* The work costs money either way; the question is where it is cheaper. Done
|
|
5
|
+
* in a subagent it costs the subagent's bill. Done in the parent, the same
|
|
6
|
+
* steps would have run (at the parent's model price) with the parent's whole
|
|
7
|
+
* context re-read on every step, and what they read would have stayed in the
|
|
8
|
+
* parent for its later requests. Per subagent, all measured:
|
|
9
|
+
*
|
|
10
|
+
* in a subagent = its bill
|
|
11
|
+
* in the parent = (bill - its own startup context) x parent/subagent price
|
|
12
|
+
* + parent context at launch x steps x 0.1 x parent input price
|
|
13
|
+
* + (read - returned) x (1.25 + 0.1 x later parent requests) x parent input price
|
|
14
|
+
*
|
|
15
|
+
* "Read" is the tool output the subagent pulled in; "returned" is what the
|
|
16
|
+
* parent received (tool result, or a background agent's task notification);
|
|
17
|
+
* "later requests" run to the next compaction. Measured from transcripts and
|
|
18
|
+
* subagents/<agent>.meta.json; nothing is sent anywhere.
|
|
19
|
+
*
|
|
20
|
+
* It ignores what a subagent buys besides context (parallel time, a clean
|
|
21
|
+
* window for a hard task) and what the parent would have spent on its own tool
|
|
22
|
+
* calls, so "did not pay" means "did not pay in context", not "was a mistake".
|
|
23
|
+
*/
|
|
24
|
+
export interface SubagentPayback {
|
|
25
|
+
id: string;
|
|
26
|
+
type: string;
|
|
27
|
+
task: string;
|
|
28
|
+
costUsd: number;
|
|
29
|
+
/** What the same work would have cost in the parent. */
|
|
30
|
+
inParentUsd: number;
|
|
31
|
+
steps: number;
|
|
32
|
+
parentContextTokens: number;
|
|
33
|
+
readTokens: number;
|
|
34
|
+
returnedTokens: number;
|
|
35
|
+
laterRequests: number;
|
|
36
|
+
/** What carrying (read - returned) would have cost the parent. */
|
|
37
|
+
avoidedUsd: number;
|
|
38
|
+
netUsd: number;
|
|
39
|
+
}
|
|
40
|
+
export interface PaybackReport {
|
|
41
|
+
days: number;
|
|
42
|
+
sessions: number;
|
|
43
|
+
subagents: SubagentPayback[];
|
|
44
|
+
}
|
|
45
|
+
export declare function subagentPayback(days?: number, paths?: string[]): PaybackReport;
|
|
46
|
+
export declare function renderPayback(r: PaybackReport, top?: number): string;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor subagents`: which subagent tasks pay for themselves.
|
|
3
|
+
*
|
|
4
|
+
* The work costs money either way; the question is where it is cheaper. Done
|
|
5
|
+
* in a subagent it costs the subagent's bill. Done in the parent, the same
|
|
6
|
+
* steps would have run (at the parent's model price) with the parent's whole
|
|
7
|
+
* context re-read on every step, and what they read would have stayed in the
|
|
8
|
+
* parent for its later requests. Per subagent, all measured:
|
|
9
|
+
*
|
|
10
|
+
* in a subagent = its bill
|
|
11
|
+
* in the parent = (bill - its own startup context) x parent/subagent price
|
|
12
|
+
* + parent context at launch x steps x 0.1 x parent input price
|
|
13
|
+
* + (read - returned) x (1.25 + 0.1 x later parent requests) x parent input price
|
|
14
|
+
*
|
|
15
|
+
* "Read" is the tool output the subagent pulled in; "returned" is what the
|
|
16
|
+
* parent received (tool result, or a background agent's task notification);
|
|
17
|
+
* "later requests" run to the next compaction. Measured from transcripts and
|
|
18
|
+
* subagents/<agent>.meta.json; nothing is sent anywhere.
|
|
19
|
+
*
|
|
20
|
+
* It ignores what a subagent buys besides context (parallel time, a clean
|
|
21
|
+
* window for a hard task) and what the parent would have spent on its own tool
|
|
22
|
+
* calls, so "did not pay" means "did not pay in context", not "was a mistake".
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
25
|
+
import { basename, join } from "node:path";
|
|
26
|
+
import { forEachLine, listSessions } from "./session.js";
|
|
27
|
+
import { estimateTokens, formatTokens, realModel } from "./tokens.js";
|
|
28
|
+
import { formatUsd, pricingFor } from "./pricing.js";
|
|
29
|
+
const CLAUDE = "claude-opus-5";
|
|
30
|
+
const IMAGE_TOKENS = 1600;
|
|
31
|
+
function blockTokens(content) {
|
|
32
|
+
if (typeof content === "string")
|
|
33
|
+
return estimateTokens(content, CLAUDE);
|
|
34
|
+
if (!Array.isArray(content))
|
|
35
|
+
return estimateTokens(JSON.stringify(content ?? ""), CLAUDE);
|
|
36
|
+
let n = 0;
|
|
37
|
+
for (const p of content) {
|
|
38
|
+
if (p?.type === "image")
|
|
39
|
+
n += IMAGE_TOKENS;
|
|
40
|
+
else if (typeof p?.text === "string")
|
|
41
|
+
n += estimateTokens(p.text, CLAUDE);
|
|
42
|
+
else
|
|
43
|
+
n += estimateTokens(JSON.stringify(p ?? ""), CLAUDE);
|
|
44
|
+
}
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
/** A subagent's bill (usage priced as the cache bills it), steps, startup context, start time and the tool output it read. */
|
|
48
|
+
function scanSubagent(path) {
|
|
49
|
+
let costUsd = 0, readTokens = 0, task = "", lastId, model;
|
|
50
|
+
let steps = 0, startup = 0, start = Infinity;
|
|
51
|
+
forEachLine(path, (line) => {
|
|
52
|
+
let e;
|
|
53
|
+
try {
|
|
54
|
+
e = JSON.parse(line);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const content = e?.message?.content;
|
|
60
|
+
if (e?.type === "user") {
|
|
61
|
+
if (!task && typeof content === "string")
|
|
62
|
+
task = content;
|
|
63
|
+
if (Array.isArray(content))
|
|
64
|
+
for (const b of content)
|
|
65
|
+
if (b?.type === "tool_result")
|
|
66
|
+
readTokens += blockTokens(b.content);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (e?.type !== "assistant" || !e.message?.usage || e.message.id === lastId)
|
|
70
|
+
return;
|
|
71
|
+
lastId = e.message.id;
|
|
72
|
+
model = realModel(e.message.model) ?? model;
|
|
73
|
+
const u = e.message.usage;
|
|
74
|
+
const n = (k) => (typeof u[k] === "number" && u[k] > 0 ? u[k] : 0);
|
|
75
|
+
const at = Date.parse(e.timestamp);
|
|
76
|
+
if (Number.isFinite(at))
|
|
77
|
+
start = Math.min(start, at);
|
|
78
|
+
if (steps === 0)
|
|
79
|
+
startup = n("input_tokens") + n("cache_creation_input_tokens") + n("cache_read_input_tokens");
|
|
80
|
+
steps++;
|
|
81
|
+
const price = pricingFor(e.message.model ?? model);
|
|
82
|
+
if (!price)
|
|
83
|
+
return;
|
|
84
|
+
costUsd += (n("input_tokens") * price.inputPerM + n("cache_creation_input_tokens") * price.inputPerM * 1.25 +
|
|
85
|
+
n("cache_read_input_tokens") * price.cacheReadPerM + n("output_tokens") * price.outputPerM) / 1e6;
|
|
86
|
+
});
|
|
87
|
+
return { costUsd, readTokens, task: task.replace(/\s+/g, " ").slice(0, 90), steps, startup, start, model };
|
|
88
|
+
}
|
|
89
|
+
function scanParent(path, ids) {
|
|
90
|
+
const view = { requests: [], prompts: [], compactions: [], returns: new Map() };
|
|
91
|
+
let lastId;
|
|
92
|
+
forEachLine(path, (line) => {
|
|
93
|
+
const hits = [...ids].filter((id) => line.includes(id));
|
|
94
|
+
const isUsage = line.includes('"usage"');
|
|
95
|
+
const isCompact = line.includes('"compact_boundary"');
|
|
96
|
+
if (!hits.length && !isUsage && !isCompact)
|
|
97
|
+
return;
|
|
98
|
+
let e;
|
|
99
|
+
try {
|
|
100
|
+
e = JSON.parse(line);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const at = Date.parse(e?.timestamp);
|
|
106
|
+
if (isCompact && e?.subtype === "compact_boundary" && Number.isFinite(at))
|
|
107
|
+
view.compactions.push(at);
|
|
108
|
+
// Placeholder messages Claude Code writes itself ("<synthetic>") are not API requests.
|
|
109
|
+
if (e?.type === "assistant" && !e.isSidechain && e.message?.usage && e.message.id !== lastId && realModel(e.message.model)) {
|
|
110
|
+
lastId = e.message.id;
|
|
111
|
+
if (Number.isFinite(at)) {
|
|
112
|
+
const u = e.message.usage;
|
|
113
|
+
view.requests.push(at);
|
|
114
|
+
view.prompts.push((u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0));
|
|
115
|
+
}
|
|
116
|
+
view.model = realModel(e.message.model) ?? view.model;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!hits.length || !Number.isFinite(at) || e?.type === "assistant")
|
|
120
|
+
return;
|
|
121
|
+
// Background agents report through a task notification; foreground ones through the tool result.
|
|
122
|
+
const content = e?.message?.content ?? e?.content;
|
|
123
|
+
const tokens = blockTokens(Array.isArray(content) ? content.map((b) => (b?.type === "tool_result" ? { text: typeof b.content === "string" ? b.content : JSON.stringify(b.content) } : b)) : content);
|
|
124
|
+
for (const id of hits) {
|
|
125
|
+
const prev = view.returns.get(id);
|
|
126
|
+
if (!prev || tokens > prev.tokens)
|
|
127
|
+
view.returns.set(id, { tokens, at });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return view;
|
|
131
|
+
}
|
|
132
|
+
export function subagentPayback(days = 30, paths) {
|
|
133
|
+
const since = Date.now() - days * 86_400_000;
|
|
134
|
+
const sessions = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
|
|
135
|
+
const out = [];
|
|
136
|
+
let withSubagents = 0;
|
|
137
|
+
for (const parent of sessions) {
|
|
138
|
+
const dir = join(parent.replace(/\.jsonl$/, ""), "subagents");
|
|
139
|
+
if (!existsSync(dir))
|
|
140
|
+
continue;
|
|
141
|
+
const agents = readdirSync(dir).filter((f) => /^agent-.+\.jsonl$/.test(f));
|
|
142
|
+
if (!agents.length)
|
|
143
|
+
continue;
|
|
144
|
+
withSubagents++;
|
|
145
|
+
const metas = agents.map((f) => {
|
|
146
|
+
const id = basename(f, ".jsonl").replace(/^agent-/, "");
|
|
147
|
+
let meta = {};
|
|
148
|
+
try {
|
|
149
|
+
meta = JSON.parse(readFileSync(join(dir, `agent-${id}.meta.json`), "utf8"));
|
|
150
|
+
}
|
|
151
|
+
catch { /* older Claude Code: no meta */ }
|
|
152
|
+
return { id, file: join(dir, f), meta };
|
|
153
|
+
});
|
|
154
|
+
const ids = new Set(metas.flatMap((m) => [m.id, m.meta.toolUseId].filter((x) => Boolean(x))));
|
|
155
|
+
const view = scanParent(parent, ids);
|
|
156
|
+
const price = pricingFor(view.model);
|
|
157
|
+
for (const m of metas) {
|
|
158
|
+
const s = scanSubagent(m.file);
|
|
159
|
+
const ret = [m.id, m.meta.toolUseId].map((k) => (k ? view.returns.get(k) : undefined)).filter(Boolean).sort((a, b) => b.tokens - a.tokens)[0];
|
|
160
|
+
const returnedTokens = ret?.tokens ?? 0;
|
|
161
|
+
const from = ret?.at ?? Infinity;
|
|
162
|
+
const until = Math.min(...view.compactions.filter((c) => c > from), Infinity);
|
|
163
|
+
const laterRequests = view.requests.filter((t) => t > from && t < until).length;
|
|
164
|
+
const carried = Math.max(0, s.readTokens - returnedTokens);
|
|
165
|
+
// The parent's context when the subagent started: its last request before then.
|
|
166
|
+
let parentContextTokens = 0;
|
|
167
|
+
for (let i = 0; i < view.requests.length && view.requests[i] <= s.start; i++)
|
|
168
|
+
parentContextTokens = view.prompts[i];
|
|
169
|
+
const sub = pricingFor(s.model) ?? price;
|
|
170
|
+
let inParentUsd = s.costUsd, avoidedUsd = 0;
|
|
171
|
+
if (price && sub) {
|
|
172
|
+
const startupUsd = (s.startup * (1.25 + 0.1 * Math.max(0, s.steps - 1)) * sub.inputPerM) / 1e6;
|
|
173
|
+
const work = Math.max(0, s.costUsd - startupUsd) * (price.inputPerM / sub.inputPerM);
|
|
174
|
+
const rideAlong = (parentContextTokens * s.steps * price.cacheReadPerM) / 1e6;
|
|
175
|
+
avoidedUsd = (carried * (1.25 + 0.1 * laterRequests) * price.inputPerM) / 1e6;
|
|
176
|
+
inParentUsd = work + rideAlong + avoidedUsd;
|
|
177
|
+
}
|
|
178
|
+
out.push({
|
|
179
|
+
id: m.id,
|
|
180
|
+
type: m.meta.agentType ?? "unknown",
|
|
181
|
+
task: m.meta.description ?? s.task,
|
|
182
|
+
costUsd: s.costUsd,
|
|
183
|
+
inParentUsd,
|
|
184
|
+
steps: s.steps,
|
|
185
|
+
parentContextTokens,
|
|
186
|
+
readTokens: s.readTokens,
|
|
187
|
+
returnedTokens,
|
|
188
|
+
laterRequests,
|
|
189
|
+
avoidedUsd,
|
|
190
|
+
netUsd: inParentUsd - s.costUsd,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { days, sessions: withSubagents, subagents: out };
|
|
195
|
+
}
|
|
196
|
+
export function renderPayback(r, top = 8) {
|
|
197
|
+
const out = ["DO SUBAGENTS PAY FOR THEMSELVES?", "═".repeat(56)];
|
|
198
|
+
const all = r.subagents;
|
|
199
|
+
if (!all.length)
|
|
200
|
+
return [...out, `No subagents in the last ${r.days} days.`].join("\n");
|
|
201
|
+
const cost = all.reduce((s, a) => s + a.costUsd, 0);
|
|
202
|
+
const inParent = all.reduce((s, a) => s + a.inParentUsd, 0);
|
|
203
|
+
const paid = all.filter((a) => a.netUsd > 0);
|
|
204
|
+
out.push(`Last ${r.days} days: ${all.length} subagents in ${r.sessions} sessions cost ${formatUsd(cost)} at list price.`, `The same work done in the parent would have cost ~${formatUsd(inParent)}: ${paid.length} of ${all.length} were cheaper as subagents, net ${inParent - cost >= 0 ? "+" : "−"}${formatUsd(Math.abs(inParent - cost))}.`, "");
|
|
205
|
+
const byType = new Map();
|
|
206
|
+
for (const a of all) {
|
|
207
|
+
const t = byType.get(a.type) ?? { n: 0, cost: 0, net: 0, paid: 0 };
|
|
208
|
+
t.n++;
|
|
209
|
+
t.cost += a.costUsd;
|
|
210
|
+
t.net += a.netUsd;
|
|
211
|
+
if (a.netUsd > 0)
|
|
212
|
+
t.paid++;
|
|
213
|
+
byType.set(a.type, t);
|
|
214
|
+
}
|
|
215
|
+
out.push(" by type count cost paid off net");
|
|
216
|
+
for (const [type, t] of [...byType.entries()].sort((a, b) => b[1].cost - a[1].cost)) {
|
|
217
|
+
out.push(` ${type.slice(0, 26).padEnd(26)} ${String(t.n).padStart(6)} ${formatUsd(t.cost).padStart(9)} ${`${t.paid}/${t.n}`.padStart(10)} ${t.net >= 0 ? "+" : "−"}${formatUsd(Math.abs(t.net))}`);
|
|
218
|
+
}
|
|
219
|
+
const worst = [...all].sort((a, b) => a.netUsd - b.netUsd).slice(0, top).filter((a) => a.netUsd < 0);
|
|
220
|
+
if (worst.length) {
|
|
221
|
+
out.push("", "Would have been cheaper in the parent:");
|
|
222
|
+
for (const a of worst)
|
|
223
|
+
out.push(` −${formatUsd(-a.netUsd).padEnd(8)} ${String(a.steps).padStart(3)} steps, parent at ~${formatTokens(a.parentContextTokens)}, read ~${formatTokens(a.readTokens)} ${a.task}`);
|
|
224
|
+
}
|
|
225
|
+
out.push("", "Counts context only: a subagent can still be worth it for parallel time or a clean window on a hard task.", "Subagents pay most when the parent is already large (every step there re-reads it) and least for short tasks from a small parent.");
|
|
226
|
+
return out.join("\n");
|
|
227
|
+
}
|
package/dist/tasks.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* history showed hundreds). Commits made by hand are not counted, nor is
|
|
17
17
|
* subagent traffic.
|
|
18
18
|
*/
|
|
19
|
+
import { realModel } from "./tokens.js";
|
|
19
20
|
import { basename } from "node:path";
|
|
20
21
|
import { forEachLine, listSessions } from "./session.js";
|
|
21
22
|
import { formatUsd, pricingFor } from "./pricing.js";
|
|
@@ -65,8 +66,7 @@ export function sessionCost(path) {
|
|
|
65
66
|
start = Math.min(start, at);
|
|
66
67
|
end = Math.max(end, at);
|
|
67
68
|
}
|
|
68
|
-
|
|
69
|
-
model = e.message.model;
|
|
69
|
+
model = realModel(e.message.model) ?? model;
|
|
70
70
|
const price = pricingFor(e.message.model ?? model);
|
|
71
71
|
if (!price)
|
|
72
72
|
return;
|
package/dist/tokens.d.ts
CHANGED
|
@@ -47,3 +47,10 @@ export declare function estimateTokens(text: string, model?: string): number;
|
|
|
47
47
|
/** Per-message structural overhead (role markers, delimiters) is roughly constant. */
|
|
48
48
|
export declare const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
49
49
|
export declare function formatTokens(n: number): string;
|
|
50
|
+
/**
|
|
51
|
+
* A transcript's model field, unless it is a placeholder: Claude Code writes
|
|
52
|
+
* "<synthetic>" on messages it generates itself (errors, interruptions), and
|
|
53
|
+
* taking the last model seen made a session's model unknown to pricing and
|
|
54
|
+
* window math whenever one came last.
|
|
55
|
+
*/
|
|
56
|
+
export declare function realModel(model: unknown): string | undefined;
|
package/dist/tokens.js
CHANGED
|
@@ -114,3 +114,12 @@ export function formatTokens(n) {
|
|
|
114
114
|
return `${(n / 1000).toFixed(1)}k`;
|
|
115
115
|
return String(n);
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* A transcript's model field, unless it is a placeholder: Claude Code writes
|
|
119
|
+
* "<synthetic>" on messages it generates itself (errors, interruptions), and
|
|
120
|
+
* taking the last model seen made a session's model unknown to pricing and
|
|
121
|
+
* window math whenever one came last.
|
|
122
|
+
*/
|
|
123
|
+
export function realModel(model) {
|
|
124
|
+
return typeof model === "string" && model && !model.startsWith("<") ? model : undefined;
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Cut your Claude Code bill, measured on your own sessions: `npx context-doctor savings` shows what each lever saves, then the hook, autopilot and an earlier auto-compact window do it. Also Cursor, Codex, Claude Desktop and any Anthropic or OpenAI API app. MCP server and Claude Code plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|