context-doctor 0.26.0 → 0.28.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/dist/split.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `context-doctor overhead split <file>`: turn a heavy memory file into a lean
3
+ * one plus a reference file the agent opens when a task needs it.
4
+ *
5
+ * A memory file (CLAUDE.md, AGENTS.md, an auto-memory index) is re-read on
6
+ * every request. Rules belong there; reference material (architecture notes,
7
+ * long examples, field lists) is needed on few requests. Sections over a size
8
+ * threshold, or made mostly of code, move to `<name>.reference.md` next to the
9
+ * file, and each leaves a one-line pointer naming the file and section: a
10
+ * plain path, not an @import, so it is read on demand instead of loaded.
11
+ *
12
+ * Shown as a plan first; written only with --write, after a backup. Nothing
13
+ * is summarized or dropped: every moved line is in the reference file.
14
+ */
15
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { basename, dirname, extname, join } from "node:path";
17
+ import { estimateTokens, formatTokens } from "./tokens.js";
18
+ import { formatUsd } from "./pricing.js";
19
+ const CLAUDE = "claude-opus-5";
20
+ /** Sections at the file's top heading level below the title (## in most memory files). */
21
+ function sections(text) {
22
+ const lines = text.split("\n");
23
+ const headingLevels = [];
24
+ let fence = false;
25
+ for (const l of lines) {
26
+ if (/^\s*(```|~~~)/.test(l))
27
+ fence = !fence;
28
+ const m = !fence && /^(#{1,6})\s+\S/.exec(l);
29
+ if (m)
30
+ headingLevels.push(m[1].length);
31
+ }
32
+ // Split at the shallowest level that occurs more than once (a lone H1 is the title).
33
+ const counts = new Map();
34
+ for (const h of headingLevels)
35
+ counts.set(h, (counts.get(h) ?? 0) + 1);
36
+ const level = [...counts.keys()].sort((a, b) => a - b).find((h) => counts.get(h) > 1) ?? 2;
37
+ const preamble = [];
38
+ const list = [];
39
+ fence = false;
40
+ for (const l of lines) {
41
+ if (/^\s*(```|~~~)/.test(l))
42
+ fence = !fence;
43
+ const m = !fence && /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(l);
44
+ if (m && m[1].length === level)
45
+ list.push({ heading: m[2], level, lines: [l] });
46
+ else if (list.length)
47
+ list[list.length - 1].lines.push(l);
48
+ else
49
+ preamble.push(l);
50
+ }
51
+ return { preamble, list, level };
52
+ }
53
+ function codeShare(text) {
54
+ let code = 0;
55
+ for (const m of text.matchAll(/```[\s\S]*?```/g))
56
+ code += m[0].length;
57
+ return text.length ? code / text.length : 0;
58
+ }
59
+ export function referencePathFor(path) {
60
+ const ext = extname(path) || ".md";
61
+ return join(dirname(path), `${basename(path, ext)}.reference${ext}`);
62
+ }
63
+ /** A section made mostly of link lines is an index: it is what lets the agent find everything else. */
64
+ function isIndex(lines) {
65
+ const items = lines.slice(1).filter((l) => l.trim());
66
+ return items.length > 0 && items.filter((l) => /\]\([^)]+\)/.test(l)).length / items.length >= 0.5;
67
+ }
68
+ /**
69
+ * Plan the split; pure apart from reading the file. An auto-memory MEMORY.md
70
+ * is meant to be an index (Claude Code loads it on every request and reads
71
+ * topic files on demand), so everything in it that is not an index moves,
72
+ * whatever its size.
73
+ */
74
+ export function planSplit(path, { minTokens = 400 } = {}) {
75
+ const text = readFileSync(path, "utf8");
76
+ const referencePath = referencePathFor(path);
77
+ const refName = basename(referencePath);
78
+ const memoryIndex = basename(path) === "MEMORY.md";
79
+ const { preamble, list } = sections(text);
80
+ const kept = [...preamble];
81
+ const moved = [];
82
+ const out = [];
83
+ let group = [];
84
+ // Consecutive moved sections share one pointer, so the lean file does not fill up with them.
85
+ const flush = () => {
86
+ if (group.length === 0)
87
+ return;
88
+ const names = group.map((g) => g.heading);
89
+ const tokens = group.reduce((t, g) => t + g.tokens, 0);
90
+ if (memoryIndex) {
91
+ kept.push(`- [${names.join("; ")}](${refName}) — moved out of the index (~${formatTokens(tokens)} tokens); read when a task involves them`, "");
92
+ }
93
+ else if (group.length === 1) {
94
+ kept.push(`## ${names[0]}`, `Moved to ${refName} § "${names[0]}" (~${formatTokens(tokens)} tokens). Read that section when a task involves it.`, "");
95
+ }
96
+ else {
97
+ kept.push(`Sections moved to ${refName} (~${formatTokens(tokens)} tokens): ${names.map((n) => `"${n}"`).join(", ")}. Read the one a task involves.`, "");
98
+ }
99
+ group = [];
100
+ };
101
+ for (const s of list) {
102
+ const body = s.lines.join("\n");
103
+ const tokens = estimateTokens(body, CLAUDE);
104
+ const reason = isIndex(s.lines)
105
+ ? undefined
106
+ : memoryIndex
107
+ ? "not index"
108
+ : tokens >= minTokens
109
+ ? "large"
110
+ : tokens >= 150 && codeShare(body) > 0.5
111
+ ? "code"
112
+ : undefined;
113
+ const section = { heading: s.heading, tokens, moved: !!reason, reason };
114
+ out.push(section);
115
+ if (!reason) {
116
+ flush();
117
+ kept.push(...s.lines);
118
+ continue;
119
+ }
120
+ moved.push(body.replace(/\n\s*$/, ""), "");
121
+ group.push(section);
122
+ }
123
+ flush();
124
+ const keptText = kept.join("\n").replace(/\n{3,}/g, "\n\n");
125
+ const header = `# Reference moved out of ${basename(path)}\n\nRead a section when a task needs it; ${basename(path)} points here by section name.\n\n`;
126
+ const movedText = moved.length ? header + moved.join("\n") : "";
127
+ return {
128
+ path,
129
+ referencePath,
130
+ sections: out,
131
+ keptText,
132
+ movedText,
133
+ tokensBefore: estimateTokens(text, CLAUDE),
134
+ tokensAfter: estimateTokens(keptText, CLAUDE),
135
+ };
136
+ }
137
+ /** Write the plan: back up the original, write the lean file, append to (or create) the reference file. */
138
+ export function applySplit(plan, now = Date.now()) {
139
+ const backup = `${plan.path}.context-doctor-backup-${now}`;
140
+ copyFileSync(plan.path, backup);
141
+ if (existsSync(plan.referencePath)) {
142
+ const prev = readFileSync(plan.referencePath, "utf8");
143
+ writeFileSync(plan.referencePath, prev.trimEnd() + "\n\n" + plan.movedText.replace(/^# Reference moved out of[^\n]*\n\n[^\n]*\n\n/, ""));
144
+ }
145
+ else {
146
+ writeFileSync(plan.referencePath, plan.movedText);
147
+ }
148
+ writeFileSync(plan.path, plan.keptText);
149
+ return { backup };
150
+ }
151
+ export function renderSplit(plan, usdPerKPerMonth) {
152
+ const out = [];
153
+ const saved = plan.tokensBefore - plan.tokensAfter;
154
+ out.push(`SPLIT PLAN for ${plan.path}`, "═".repeat(56));
155
+ if (!plan.sections.some((s) => s.moved)) {
156
+ out.push(`Nothing to move: no section reaches the threshold. ~${formatTokens(plan.tokensBefore)} tokens stay as they are.`);
157
+ return out.join("\n");
158
+ }
159
+ for (const s of plan.sections) {
160
+ out.push(` ${s.moved ? "→ move" : " keep"} ~${formatTokens(s.tokens).padStart(5)} ${s.heading}${s.reason === "code" ? " (mostly code)" : s.reason === "not index" ? " (not an index entry)" : ""}`);
161
+ }
162
+ const usd = usdPerKPerMonth ? `, ~${formatUsd((saved / 1000) * usdPerKPerMonth)}/month at your usage` : "";
163
+ out.push("", `Every request: ~${formatTokens(plan.tokensBefore)} → ~${formatTokens(plan.tokensAfter)} tokens (−${formatTokens(saved)}${usd}).`, `Moved sections go to ${plan.referencePath}, word for word; each leaves a pointer the agent follows when a task needs it.`, "Nothing is written yet. Add --write to apply (the original is backed up first).");
164
+ return out.join("\n");
165
+ }
package/dist/wrap.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Autopilot in process: `withContextDoctor(client)` for apps that cannot send
3
+ * their API traffic through the local proxy (serverless and edge functions,
4
+ * managed hosts, anything without a sidecar).
5
+ *
6
+ * import Anthropic from "@anthropic-ai/sdk";
7
+ * import { withContextDoctor } from "context-doctor";
8
+ * const client = withContextDoctor(new Anthropic());
9
+ *
10
+ * Works on the official Anthropic, OpenAI and Google Gen AI SDK clients (and
11
+ * anything shaped like them): messages.create/stream,
12
+ * chat.completions.create/stream/parse, responses.create/stream/parse (beta
13
+ * namespaces included) and models.generateContent/generateContentStream.
14
+ * Gemini chat sessions (ai.chats) keep their history inside the SDK, out of
15
+ * reach; use generateContent with your own history to get autopilot. Each request goes
16
+ * through the same AutoClearer the proxy uses: stale tool output is cleared
17
+ * only when the prompt cache is cold, so a request never costs more.
18
+ *
19
+ * The request is cloned before it is changed. Apps keep their history in the
20
+ * arrays they pass, and rewriting those in place would be the silent history
21
+ * rewriting this project rules out; only the copy that goes on the wire is
22
+ * slimmer.
23
+ *
24
+ * State lives in this process. Run one process per conversation stream (a
25
+ * server, a worker, an agent) and it behaves like the proxy. A conversation
26
+ * this instance has not seen is treated as warm, so a fresh serverless
27
+ * instance never clears blind; pass `statePath` on shared storage when many
28
+ * instances serve the same conversations.
29
+ */
30
+ import { AutoClearer, type AutoClearOptions, type AutoClearResult } from "./autoclear.js";
31
+ export interface WrapOptions extends AutoClearOptions {
32
+ /** Called after each request is prepared: what autopilot did, and the model. */
33
+ onRequest?: (info: AutoClearResult & {
34
+ method: string;
35
+ model?: string;
36
+ }) => void;
37
+ /** Share one clearer between several clients (default: one per wrapped client). */
38
+ clearer?: AutoClearer;
39
+ }
40
+ export interface WrapStats {
41
+ requests: number;
42
+ changed: number;
43
+ tokensRemoved: number;
44
+ }
45
+ /** Totals for a wrapped client: requests seen, how many autopilot slimmed, tokens not sent. */
46
+ export declare function contextDoctorStats(client: object): WrapStats | undefined;
47
+ export declare function withContextDoctor<T extends object>(client: T, options?: WrapOptions): T;
package/dist/wrap.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Autopilot in process: `withContextDoctor(client)` for apps that cannot send
3
+ * their API traffic through the local proxy (serverless and edge functions,
4
+ * managed hosts, anything without a sidecar).
5
+ *
6
+ * import Anthropic from "@anthropic-ai/sdk";
7
+ * import { withContextDoctor } from "context-doctor";
8
+ * const client = withContextDoctor(new Anthropic());
9
+ *
10
+ * Works on the official Anthropic, OpenAI and Google Gen AI SDK clients (and
11
+ * anything shaped like them): messages.create/stream,
12
+ * chat.completions.create/stream/parse, responses.create/stream/parse (beta
13
+ * namespaces included) and models.generateContent/generateContentStream.
14
+ * Gemini chat sessions (ai.chats) keep their history inside the SDK, out of
15
+ * reach; use generateContent with your own history to get autopilot. Each request goes
16
+ * through the same AutoClearer the proxy uses: stale tool output is cleared
17
+ * only when the prompt cache is cold, so a request never costs more.
18
+ *
19
+ * The request is cloned before it is changed. Apps keep their history in the
20
+ * arrays they pass, and rewriting those in place would be the silent history
21
+ * rewriting this project rules out; only the copy that goes on the wire is
22
+ * slimmer.
23
+ *
24
+ * State lives in this process. Run one process per conversation stream (a
25
+ * server, a worker, an agent) and it behaves like the proxy. A conversation
26
+ * this instance has not seen is treated as warm, so a fresh serverless
27
+ * instance never clears blind; pass `statePath` on shared storage when many
28
+ * instances serve the same conversations.
29
+ */
30
+ import { AutoClearer } from "./autoclear.js";
31
+ const WRAPPED = new Set([
32
+ "messages.create",
33
+ "messages.stream",
34
+ "beta.messages.create",
35
+ "beta.messages.stream",
36
+ "chat.completions.create",
37
+ "chat.completions.stream",
38
+ "chat.completions.parse",
39
+ "beta.chat.completions.create",
40
+ "beta.chat.completions.stream",
41
+ "beta.chat.completions.parse",
42
+ "responses.create",
43
+ "responses.stream",
44
+ "responses.parse",
45
+ // Google's @google/genai: ai.models.generateContent({ model, contents, config })
46
+ "models.generateContent",
47
+ "models.generateContentStream",
48
+ ]);
49
+ const PARENTS = new Set([...WRAPPED].flatMap((p) => p.split(".").slice(0, -1).map((_, i, a) => a.slice(0, i + 1).join("."))));
50
+ const STATS = Symbol.for("context-doctor.stats");
51
+ /** Totals for a wrapped client: requests seen, how many autopilot slimmed, tokens not sent. */
52
+ export function contextDoctorStats(client) {
53
+ return client[STATS];
54
+ }
55
+ export function withContextDoctor(client, options = {}) {
56
+ const { onRequest, clearer: shared, ...clearOpts } = options;
57
+ const clearer = shared ?? new AutoClearer({ unseenIsWarm: true, ...clearOpts });
58
+ const stats = { requests: 0, changed: 0, tokensRemoved: 0 };
59
+ const prepare = (method, params) => {
60
+ if (!params || typeof params !== "object" || Array.isArray(params))
61
+ return params;
62
+ // Copy the top level and deep-clone only the history, the one part
63
+ // autopilot rewrites: the rest can hold things that do not clone
64
+ // (an AbortSignal or callable tools in @google/genai's config).
65
+ stats.requests++;
66
+ const src = params;
67
+ const field = ["messages", "input", "contents"].find((k) => Array.isArray(src[k]));
68
+ if (!field)
69
+ return params;
70
+ let body;
71
+ try {
72
+ body = { ...src, [field]: structuredClone(src[field]) };
73
+ }
74
+ catch {
75
+ return params; // something uncloneable inside the history: leave the request alone
76
+ }
77
+ const result = clearer.apply(body);
78
+ if (result.changed) {
79
+ stats.changed++;
80
+ stats.tokensRemoved += result.tokensRemoved;
81
+ }
82
+ try {
83
+ onRequest?.({ ...result, method, model: typeof body.model === "string" ? body.model : undefined });
84
+ }
85
+ catch {
86
+ /* a logging callback must not fail the request */
87
+ }
88
+ return result.changed ? body : params;
89
+ };
90
+ const wrap = (target, path) => new Proxy(target, {
91
+ get(obj, prop, receiver) {
92
+ if (prop === STATS && path === "")
93
+ return stats;
94
+ const value = Reflect.get(obj, prop, receiver);
95
+ if (typeof prop !== "string")
96
+ return value;
97
+ const full = path ? `${path}.${prop}` : prop;
98
+ if (WRAPPED.has(full) && typeof value === "function") {
99
+ return function (params, ...rest) {
100
+ return value.call(obj, prepare(full, params), ...rest);
101
+ };
102
+ }
103
+ if (PARENTS.has(full) && value && typeof value === "object")
104
+ return wrap(value, full);
105
+ return value;
106
+ },
107
+ });
108
+ return wrap(client, "");
109
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.26.0",
4
- "description": "See what your Claude Code sessions cost and cut it: offers /compact when you return to a large session after the cache expired (every surface, desktop app included), and a cache-safe autopilot for terminal, IDE and API traffic. Token profiler, hooks, MCP tools and a Claude Code plugin.",
3
+ "version": "0.28.0",
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",
7
7
  "ai-agents",
@@ -28,9 +28,10 @@ Proactively (do not wait to be asked):
28
28
  If the `context-doctor` MCP tools are available:
29
29
  - `profile_context` — pass a conversation JSON (OpenAI or Anthropic format) or raw text; returns a token breakdown, largest messages, findings with estimated savings.
30
30
  - `optimize_context` — applies deterministic fixes (dedupe, trim stale tool results, strip base64; opt-in `trim-tool-calls` for big inline file writes and `prune-history`). When the result contains pruned-turn source material and asks for a summary, **you write that summary** (≤150 tokens, dense, factual) and place it where the stub indicates — this is how summarization works without any API key.
31
+ - `pack_context` — before reading a large document, log or folder to answer a question (not to edit it), pass `paths` and the `query`: it returns only the best-matching chunks within `max_tokens` (default 4000), each with an id and line range, plus the next-best ids. No query = an outline to pick from with `ids`.
31
32
  - `context_best_practices` — provider-specific checklist to share with the user.
32
33
 
33
- If the tools are not connected, the CLI does the same: `npx context-doctor analyze <file> --model <model>` and `npx context-doctor optimize <file>`. For always-on optimization of the user's own apps: `npx context-doctor proxy` then point `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` at it.
34
+ If the tools are not connected, the CLI does the same: `npx context-doctor analyze <file> --model <model>` and `npx context-doctor optimize <file>`, and `npx context-doctor pack <files> --query "<question>"` for big documents. For always-on optimization of the user's own apps: `npx context-doctor proxy` then point `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` at it.
34
35
 
35
36
  ## When the user pastes a conversation or asks about their token usage
36
37