context-doctor 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +25 -25
  2. package/dist/autoclear.d.ts +58 -7
  3. package/dist/autoclear.js +155 -30
  4. package/dist/autopilot.d.ts +23 -3
  5. package/dist/autopilot.js +100 -29
  6. package/dist/cache.d.ts +6 -2
  7. package/dist/cache.js +48 -23
  8. package/dist/cacheplan.d.ts +5 -3
  9. package/dist/cacheplan.js +46 -8
  10. package/dist/calibration.d.ts +7 -2
  11. package/dist/calibration.js +14 -3
  12. package/dist/ci.js +25 -5
  13. package/dist/cli.js +61 -26
  14. package/dist/coldresume.d.ts +32 -7
  15. package/dist/coldresume.js +148 -34
  16. package/dist/compactquality.js +34 -12
  17. package/dist/compactwindow.js +59 -34
  18. package/dist/config.d.ts +6 -0
  19. package/dist/config.js +4 -1
  20. package/dist/cursor.d.ts +9 -0
  21. package/dist/cursor.js +53 -22
  22. package/dist/dashboard.d.ts +10 -0
  23. package/dist/dashboard.js +27 -2
  24. package/dist/diff.js +10 -3
  25. package/dist/doctor.js +133 -18
  26. package/dist/embed.d.ts +9 -0
  27. package/dist/embed.js +40 -5
  28. package/dist/experiment.d.ts +1 -0
  29. package/dist/experiment.js +6 -3
  30. package/dist/hook.js +23 -4
  31. package/dist/impact.d.ts +32 -0
  32. package/dist/impact.js +45 -6
  33. package/dist/install.d.ts +34 -2
  34. package/dist/install.js +223 -30
  35. package/dist/ledger.js +9 -2
  36. package/dist/mcp.d.ts +1 -1
  37. package/dist/mcp.js +2 -2
  38. package/dist/mcpschema.d.ts +16 -1
  39. package/dist/mcpschema.js +95 -24
  40. package/dist/optimize.js +149 -64
  41. package/dist/overhead.js +57 -27
  42. package/dist/pack.d.ts +11 -0
  43. package/dist/pack.js +75 -12
  44. package/dist/parse.js +42 -18
  45. package/dist/pricing.d.ts +57 -5
  46. package/dist/pricing.js +94 -8
  47. package/dist/proxy.d.ts +13 -2
  48. package/dist/proxy.js +258 -43
  49. package/dist/reads.d.ts +1 -1
  50. package/dist/reads.js +61 -24
  51. package/dist/report.d.ts +10 -0
  52. package/dist/report.js +12 -4
  53. package/dist/savings.d.ts +20 -8
  54. package/dist/savings.js +79 -34
  55. package/dist/session.d.ts +37 -0
  56. package/dist/session.js +143 -6
  57. package/dist/split.js +6 -1
  58. package/dist/statusline.d.ts +3 -1
  59. package/dist/statusline.js +29 -17
  60. package/dist/subagentpay.d.ts +5 -2
  61. package/dist/subagentpay.js +50 -16
  62. package/dist/subagents.d.ts +7 -3
  63. package/dist/subagents.js +28 -8
  64. package/dist/tasks.d.ts +22 -7
  65. package/dist/tasks.js +47 -20
  66. package/dist/wrap.d.ts +6 -0
  67. package/dist/wrap.js +142 -6
  68. package/package.json +17 -2
package/dist/parse.js CHANGED
@@ -51,6 +51,23 @@ function flattenContent(content) {
51
51
  hasBinary = true;
52
52
  text += `[${b.type}]`;
53
53
  break;
54
+ // OpenAI's media blocks (Chat Completions and Responses). Stringified,
55
+ // a 300 KB base64 data URL counted as ~75k text tokens and tripped the
56
+ // base64 finding; it is an attachment, counted like Anthropic's above.
57
+ case "image_url":
58
+ case "input_image":
59
+ hasBinary = true;
60
+ text += "[image]";
61
+ break;
62
+ case "input_audio":
63
+ hasBinary = true;
64
+ text += "[audio]";
65
+ break;
66
+ case "file":
67
+ case "input_file":
68
+ hasBinary = true;
69
+ text += "[file]";
70
+ break;
54
71
  case "thinking":
55
72
  // Count the thinking text but NOT the signature — it is an opaque
56
73
  // base64 blob the API requires, not something the user can trim.
@@ -71,7 +88,8 @@ function normalizeMessage(rawInput, index) {
71
88
  const raw = rawInput && typeof rawInput === "object" ? rawInput : {};
72
89
  const role = String(raw.role ?? "user");
73
90
  const flat = flattenContent(raw.content);
74
- let kind = flat.kind ?? (["system", "user", "assistant"].includes(role) ? role : "other");
91
+ // OpenAI's "developer" role is the system prompt under its newer name (o1 onward).
92
+ let kind = flat.kind ?? (role === "developer" ? "system" : ["system", "user", "assistant"].includes(role) ? role : "other");
75
93
  let toolName = flat.toolName;
76
94
  let text = flat.text;
77
95
  let toolCallText = flat.toolCallText;
@@ -93,30 +111,36 @@ function normalizeMessage(rawInput, index) {
93
111
  }
94
112
  return { index, role, kind, text, toolName, toolCallText, hasBinary: flat.hasBinary, isError: flat.isError };
95
113
  }
114
+ /** Raw text: the whole input as one user message, so profiling still works for raw prompts. */
115
+ function rawText(input, parseError) {
116
+ // If it LOOKS like JSON, the user handed us a broken conversation file and
117
+ // deserves to be told, not given a report about a single 9-token "message".
118
+ const head = input.trimStart()[0];
119
+ const parseWarning = input.trim() === ""
120
+ ? "Input is empty — nothing to profile."
121
+ : parseError && (head === "{" || head === "[")
122
+ ? `Input starts like JSON but does not parse (${parseError.message}). Profiling it as raw text, which is almost certainly not what you want.`
123
+ : undefined;
124
+ return {
125
+ sourceFormat: "text",
126
+ parseWarning,
127
+ // Empty input has no message: reporting "1 message, ~4 tokens" for it
128
+ // would be inventing content that is not there.
129
+ messages: input.trim() === "" ? [] : [{ index: 0, role: "user", kind: "user", text: input, hasBinary: false }],
130
+ };
131
+ }
96
132
  export function parseConversation(input) {
97
133
  let data;
98
134
  try {
99
135
  data = JSON.parse(input);
100
136
  }
101
137
  catch (e) {
102
- // Not JSON — treat the whole thing as one user message so profiling still
103
- // works for raw prompts. But if it LOOKS like JSON, the user handed us a
104
- // broken conversation file and deserves to be told, not given a report
105
- // about a single 9-token "message".
106
- const head = input.trimStart()[0];
107
- const parseWarning = input.trim() === ""
108
- ? "Input is empty — nothing to profile."
109
- : head === "{" || head === "["
110
- ? `Input starts like JSON but does not parse (${e.message}). Profiling it as raw text, which is almost certainly not what you want.`
111
- : undefined;
112
- return {
113
- sourceFormat: "text",
114
- parseWarning,
115
- // Empty input has no message: reporting "1 message, ~4 tokens" for it
116
- // would be inventing content that is not there.
117
- messages: input.trim() === "" ? [] : [{ index: 0, role: "user", kind: "user", text: input, hasBinary: false }],
118
- };
138
+ return rawText(input, e);
119
139
  }
140
+ // A prompt can happen to be valid JSON ("2024", a quoted sentence, null):
141
+ // that is still a raw prompt, not a conversation with no messages.
142
+ if (data === null || typeof data !== "object")
143
+ return rawText(input);
120
144
  if (Array.isArray(data)) {
121
145
  const looksLikeMessages = data.length === 0 || data.some((m) => m && typeof m === "object" && "role" in m);
122
146
  return {
package/dist/pricing.d.ts CHANGED
@@ -1,21 +1,73 @@
1
1
  /**
2
2
  * Per-model pricing for cost estimation, USD per million tokens.
3
3
  *
4
- * Anthropic prices are current as of mid-2026 (first-party API list rates).
5
- * OpenAI/Google figures are approximate public list prices and drift over
6
- * time — all cost output is labeled as an estimate. One table, one file,
7
- * easy to update: PRs welcome when prices change.
4
+ * Anthropic prices are first-party API list rates, checked against
5
+ * Anthropic's price list in October 2026. OpenAI/Google figures are
6
+ * approximate public list prices and drift over time — all cost output is
7
+ * labeled as an estimate. One table, one file, easy to update: PRs welcome
8
+ * when prices change.
8
9
  */
9
10
  export interface ModelPricing {
10
11
  /** USD per 1M input tokens. */
11
12
  inputPerM: number;
12
13
  /** USD per 1M output tokens (unused by the profiler, kept for reference). */
13
14
  outputPerM: number;
14
- /** USD per 1M cached input tokens read (≈10% of input for Claude/OpenAI). */
15
+ /** USD per 1M cached input tokens read (2.5-10% of input, depending on the Claude model). */
15
16
  cacheReadPerM: number;
16
17
  }
18
+ /** Anthropic cache writes, relative to the input price: 5-minute TTL and 1-hour TTL. */
19
+ export declare const CACHE_WRITE_5M = 1.25;
20
+ export declare const CACHE_WRITE_1H = 2;
17
21
  export declare function pricingFor(model?: string): ModelPricing | undefined;
18
22
  export declare function inputCostUsd(tokens: number, pricing: ModelPricing): number;
23
+ /** `usage` as the API returns it and Claude Code records it on each reply. */
24
+ export interface RecordedUsage {
25
+ input_tokens?: unknown;
26
+ cache_creation_input_tokens?: unknown;
27
+ cache_read_input_tokens?: unknown;
28
+ output_tokens?: unknown;
29
+ /** The cache writes split by TTL; absent in older transcripts. */
30
+ cache_creation?: {
31
+ ephemeral_5m_input_tokens?: unknown;
32
+ ephemeral_1h_input_tokens?: unknown;
33
+ } | null;
34
+ }
35
+ export interface UsageCost {
36
+ /** Uncached input. */
37
+ inputUsd: number;
38
+ /** Cache writes, each TTL at its own price. */
39
+ writeUsd: number;
40
+ readUsd: number;
41
+ outputUsd: number;
42
+ /** input + writes + reads: what the prompt cost. */
43
+ promptUsd: number;
44
+ totalUsd: number;
45
+ }
46
+ /**
47
+ * A request's cache writes by TTL. Claude Code's main thread writes almost
48
+ * everything at the 1-hour TTL (2x input) and subagents at 5 minutes
49
+ * (1.25x), so a single 1.25x multiplier underpriced most writes by 37%.
50
+ * Usage recorded without the split counts as 5-minute writes, the API's
51
+ * default TTL.
52
+ */
53
+ export declare function cacheWriteSplit(usage: RecordedUsage | undefined | null): {
54
+ fiveMinute: number;
55
+ oneHour: number;
56
+ };
57
+ /**
58
+ * The write price, relative to input, of a mix of 5-minute and 1-hour
59
+ * writes. Simulations that charge a write that never happened (a cold
60
+ * re-read, a compaction's fresh context) use the session's own mix, since
61
+ * that is the TTL its harness would have written at; 1.25x when nothing was
62
+ * written to learn from.
63
+ */
64
+ export declare function writeMultiplier(fiveMinute: number, oneHour: number): number;
65
+ /**
66
+ * What one recorded request cost: uncached input, cache writes priced by
67
+ * TTL, cache reads, output. The one place recorded usage is priced, so every
68
+ * report agrees with the others and with the bill.
69
+ */
70
+ export declare function usageCost(usage: RecordedUsage | undefined | null, pricing: ModelPricing): UsageCost;
19
71
  /**
20
72
  * Rough time-to-first-token impact of input size. Prompt processing on major
21
73
  * providers runs on the order of tens of thousands of tokens per second;
package/dist/pricing.js CHANGED
@@ -1,16 +1,55 @@
1
1
  /**
2
2
  * Per-model pricing for cost estimation, USD per million tokens.
3
3
  *
4
- * Anthropic prices are current as of mid-2026 (first-party API list rates).
5
- * OpenAI/Google figures are approximate public list prices and drift over
6
- * time — all cost output is labeled as an estimate. One table, one file,
7
- * easy to update: PRs welcome when prices change.
4
+ * Anthropic prices are first-party API list rates, checked against
5
+ * Anthropic's price list in October 2026. OpenAI/Google figures are
6
+ * approximate public list prices and drift over time — all cost output is
7
+ * labeled as an estimate. One table, one file, easy to update: PRs welcome
8
+ * when prices change.
8
9
  */
10
+ /** Anthropic cache writes, relative to the input price: 5-minute TTL and 1-hour TTL. */
11
+ export const CACHE_WRITE_5M = 1.25;
12
+ export const CACHE_WRITE_1H = 2;
13
+ const rate = (inputPerM, outputPerM, cacheReadPerM) => ({ inputPerM, outputPerM, cacheReadPerM });
14
+ /**
15
+ * One Claude version, whatever wraps the id: a date (-20251101, @20251101),
16
+ * "[1m]", a Bedrock prefix. Prices differ between a major and its minor
17
+ * releases (Opus 5 is $5 in, Opus 5.5 is $4), so neither may match the
18
+ * other: a minor is one or two digits after the major, a date suffix is
19
+ * eight, and that is what the lookaheads tell apart.
20
+ */
21
+ function claude(families, major, minor) {
22
+ return minor === undefined
23
+ ? new RegExp(`claude-(?:${families})-${major}(?![.\\d]|-\\d{1,2}(?!\\d))`, "i")
24
+ : new RegExp(`claude-(?:${families})-${major}[-.]${minor}(?!\\d)`, "i");
25
+ }
9
26
  const PRICING = [
10
- [/claude.*fable|claude.*mythos/i, { inputPerM: 10, outputPerM: 50, cacheReadPerM: 1 }],
11
- [/claude.*opus/i, { inputPerM: 5, outputPerM: 25, cacheReadPerM: 0.5 }],
12
- [/claude.*sonnet/i, { inputPerM: 3, outputPerM: 15, cacheReadPerM: 0.3 }],
13
- [/claude.*haiku/i, { inputPerM: 1, outputPerM: 5, cacheReadPerM: 0.1 }],
27
+ // Specific Claude versions first: the family rows below are only a
28
+ // fallback for ids this table does not know yet.
29
+ [claude("fable|mythos", 5, 1), rate(10, 50, 0.25)],
30
+ [claude("fable|mythos", 5), rate(10, 50, 1)],
31
+ [claude("opus", 5, 5), rate(4, 20, 0.2)],
32
+ [claude("opus", 5), rate(5, 25, 0.5)],
33
+ [claude("opus", 4, 8), rate(5, 25, 0.5)],
34
+ [claude("opus", 4, 7), rate(5, 25, 0.5)],
35
+ [claude("opus", 4, 6), rate(5, 25, 0.5)],
36
+ [claude("opus", 4, 5), rate(5, 25, 0.5)],
37
+ [claude("opus", 4, 1), rate(15, 75, 1.5)],
38
+ [claude("opus", 4), rate(15, 75, 1.5)],
39
+ [/claude-3-opus/i, rate(15, 75, 1.5)],
40
+ [claude("sonnet", 5, 5), rate(2, 10, 0.2)],
41
+ [claude("sonnet", 5), rate(2, 10, 0.2)],
42
+ // Haiku 5.5's rates hold for prompts up to 100k tokens; longer prompts
43
+ // bill higher, a tier this per-token table does not model.
44
+ [claude("haiku", 5, 5), rate(0.1, 0.5, 0.01)],
45
+ [claude("haiku", 4, 5), rate(1, 5, 0.1)],
46
+ [/claude-3-5-haiku/i, rate(0.8, 4, 0.08)],
47
+ [/claude-3-haiku/i, rate(0.25, 1.25, 0.03)],
48
+ // Sonnet 4.6, 4.5, 4 and the 3.x Sonnets are all $3 / $15 / $0.30: the family row.
49
+ [/claude.*fable|claude.*mythos/i, rate(10, 50, 1)],
50
+ [/claude.*opus/i, rate(5, 25, 0.5)],
51
+ [/claude.*sonnet/i, rate(3, 15, 0.3)],
52
+ [/claude.*haiku/i, rate(1, 5, 0.1)],
14
53
  [/gpt-5.*(mini|nano)/i, { inputPerM: 0.25, outputPerM: 2, cacheReadPerM: 0.025 }],
15
54
  [/gpt-5/i, { inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 }],
16
55
  [/gpt-4o-mini/i, { inputPerM: 0.15, outputPerM: 0.6, cacheReadPerM: 0.075 }],
@@ -32,6 +71,53 @@ export function pricingFor(model) {
32
71
  export function inputCostUsd(tokens, pricing) {
33
72
  return (tokens / 1_000_000) * pricing.inputPerM;
34
73
  }
74
+ /** A token count from recorded usage: numbers and numeric strings, anything else is 0. */
75
+ function count(v) {
76
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : Number.NaN;
77
+ return Number.isFinite(n) && n > 0 ? n : 0;
78
+ }
79
+ /**
80
+ * A request's cache writes by TTL. Claude Code's main thread writes almost
81
+ * everything at the 1-hour TTL (2x input) and subagents at 5 minutes
82
+ * (1.25x), so a single 1.25x multiplier underpriced most writes by 37%.
83
+ * Usage recorded without the split counts as 5-minute writes, the API's
84
+ * default TTL.
85
+ */
86
+ export function cacheWriteSplit(usage) {
87
+ const total = count(usage?.cache_creation_input_tokens);
88
+ const split = usage?.cache_creation;
89
+ if (!split || typeof split !== "object")
90
+ return { fiveMinute: total, oneHour: 0 };
91
+ const all = total || count(split.ephemeral_5m_input_tokens) + count(split.ephemeral_1h_input_tokens);
92
+ const oneHour = Math.min(all, count(split.ephemeral_1h_input_tokens));
93
+ return { fiveMinute: all - oneHour, oneHour };
94
+ }
95
+ /**
96
+ * The write price, relative to input, of a mix of 5-minute and 1-hour
97
+ * writes. Simulations that charge a write that never happened (a cold
98
+ * re-read, a compaction's fresh context) use the session's own mix, since
99
+ * that is the TTL its harness would have written at; 1.25x when nothing was
100
+ * written to learn from.
101
+ */
102
+ export function writeMultiplier(fiveMinute, oneHour) {
103
+ const total = fiveMinute + oneHour;
104
+ return total > 0 ? (fiveMinute * CACHE_WRITE_5M + oneHour * CACHE_WRITE_1H) / total : CACHE_WRITE_5M;
105
+ }
106
+ /**
107
+ * What one recorded request cost: uncached input, cache writes priced by
108
+ * TTL, cache reads, output. The one place recorded usage is priced, so every
109
+ * report agrees with the others and with the bill.
110
+ */
111
+ export function usageCost(usage, pricing) {
112
+ const { fiveMinute, oneHour } = cacheWriteSplit(usage);
113
+ const perM = (tokens, usd) => (tokens * usd) / 1_000_000;
114
+ const inputUsd = perM(count(usage?.input_tokens), pricing.inputPerM);
115
+ const writeUsd = perM(fiveMinute * CACHE_WRITE_5M + oneHour * CACHE_WRITE_1H, pricing.inputPerM);
116
+ const readUsd = perM(count(usage?.cache_read_input_tokens), pricing.cacheReadPerM);
117
+ const outputUsd = perM(count(usage?.output_tokens), pricing.outputPerM);
118
+ const promptUsd = inputUsd + writeUsd + readUsd;
119
+ return { inputUsd, writeUsd, readUsd, outputUsd, promptUsd, totalUsd: promptUsd + outputUsd };
120
+ }
35
121
  /**
36
122
  * Rough time-to-first-token impact of input size. Prompt processing on major
37
123
  * providers runs on the order of tens of thousands of tokens per second;
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.31.0";
51
+ export declare const PROXY_VERSION = "0.32.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
@@ -61,7 +61,12 @@ export declare function stripToken(url: string, token: string): string | undefin
61
61
  * Code under autopilot, an SDK) also lists models, uploads files, counts
62
62
  * tokens. Until 0.24 those got a 404 and broke the feature for that client.
63
63
  * Unknown paths go to Anthropic when the request says it is one (Anthropic
64
- * clients always send anthropic-version, or x-api-key), else to OpenAI.
64
+ * clients always send anthropic-version, or x-api-key; OAuth calls send
65
+ * anthropic-beta; Anthropic keys and tokens start sk-ant-), else to OpenAI.
66
+ * Under autopilot the default flips: Claude Code is the client it is
67
+ * installed for, and a Bearer-only request from it (its OAuth token) sent to
68
+ * OpenAI would hand an Anthropic credential to another company. There only a
69
+ * request carrying an OpenAI-style key (sk-..., not sk-ant-...) goes to OpenAI.
65
70
  * Google's Gemini API (GOOGLE_GEMINI_BASE_URL) is recognised by its key
66
71
  * header, its /v1beta paths, or a `model:method` path such as
67
72
  * /v1/models/gemini-3-pro:generateContent.
@@ -108,4 +113,10 @@ export interface RouteConfig {
108
113
  keepRecent?: number;
109
114
  maxToolResultTokens?: number;
110
115
  }
116
+ /**
117
+ * Which account a request belongs to, as a salted hash of its credential: the
118
+ * proxy remembers per account whether the API refuses edited history, and
119
+ * never keeps the key itself.
120
+ */
121
+ export declare function accountKey(headers: http.IncomingHttpHeaders): string;
111
122
  export declare function startProxy(opts?: ProxyOptions): http.Server;