context-doctor 0.26.0 → 0.27.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/pack.js ADDED
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Pack: put only the parts of a document that matter into the context.
3
+ *
4
+ * Everything else in context-doctor slims a conversation after the fact. The
5
+ * cheapest token is the one that never goes in: a 60k-token manual pasted to
6
+ * answer one question costs 60k tokens on that message and on every message
7
+ * after it. Pack splits files into chunks along their own structure (markdown
8
+ * headings, top-level code declarations, paragraphs), ranks the chunks against
9
+ * a question with BM25, and returns the best ones that fit a token budget, with
10
+ * an id and line range on each so the model can ask for more.
11
+ *
12
+ * Offline and keyless like the rest of the tool: lexical ranking, no embeddings
13
+ * and no model calls. Lexical ranking misses pure paraphrase; the outline that
14
+ * comes with every result lets the model pick sections by heading when it does.
15
+ */
16
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
17
+ import { basename, extname, join, relative } from "node:path";
18
+ import { estimateTokens, formatTokens } from "./tokens.js";
19
+ import { EXTRACTABLE, extractText } from "./extract.js";
20
+ const DEFAULT_BUDGET = 4000;
21
+ const DEFAULT_CHUNK_TOKENS = 400;
22
+ /** Claude's tokenizer produces the most tokens per char; a budget in its terms never overruns another's. */
23
+ const DEFAULT_MODEL = "claude";
24
+ const CODE_EXT = new Set([
25
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift", ".c", ".h",
26
+ ".cc", ".cpp", ".hpp", ".cs", ".php", ".scala", ".sh", ".bash", ".zsh", ".sql", ".lua", ".dart", ".vue", ".svelte",
27
+ ]);
28
+ const MARKDOWN_EXT = new Set([".md", ".mdx", ".markdown", ".rst", ".adoc"]);
29
+ function kindOf(name, text) {
30
+ const ext = extname(name).toLowerCase();
31
+ if (MARKDOWN_EXT.has(ext))
32
+ return "markdown";
33
+ if (CODE_EXT.has(ext))
34
+ return "code";
35
+ if (/^#{1,6}\s+\S/m.test(text) && !/^#!/.test(text))
36
+ return "markdown";
37
+ return "prose";
38
+ }
39
+ /** A top-level declaration or block start in most languages: not indented, not a closer. */
40
+ const CODE_BOUNDARY = /^(?![\s})\]]|$)/;
41
+ const COMMENT_LINE = /^(\/\*\*?|\*\/?|\/\/|#(?!include|define|!)|"""|\'\'\'|--|@\w+)/;
42
+ /** Structural units: markdown sections and paragraphs, code blocks, prose paragraphs. */
43
+ function units(lines, kind) {
44
+ const out = [];
45
+ const path = [];
46
+ let heading = "";
47
+ let start = 0;
48
+ let hard = false;
49
+ let inFence = false;
50
+ const close = (end, nextHard) => {
51
+ if (end > start && lines.slice(start, end).some((l) => l.trim()))
52
+ out.push({ start, end, heading, hard });
53
+ start = end;
54
+ hard = nextHard;
55
+ };
56
+ for (let i = 0; i < lines.length; i++) {
57
+ const line = lines[i];
58
+ if (kind === "markdown") {
59
+ if (/^\s*(```|~~~)/.test(line))
60
+ inFence = !inFence;
61
+ const h = !inFence && /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
62
+ if (h) {
63
+ close(i, true);
64
+ const level = h[1].length;
65
+ path.length = level - 1;
66
+ path[level - 1] = h[2];
67
+ heading = path.filter(Boolean).join(" > ");
68
+ continue;
69
+ }
70
+ if (!inFence && !line.trim() && i + 1 < lines.length && lines[i + 1].trim())
71
+ close(i + 1, false);
72
+ }
73
+ else if (kind === "code") {
74
+ // A new unit at a top-level line that follows a blank line.
75
+ if (i > 0 && !lines[i - 1].trim() && line.trim() && CODE_BOUNDARY.test(line))
76
+ close(i, false);
77
+ }
78
+ else if (!line.trim() && i + 1 < lines.length && lines[i + 1].trim()) {
79
+ close(i + 1, false);
80
+ }
81
+ }
82
+ close(lines.length, false);
83
+ if (kind !== "markdown") {
84
+ for (const u of out) {
85
+ // Label code by its declaration, not the doc comment above it.
86
+ const body = lines.slice(u.start, u.end).map((l) => l.trim()).filter(Boolean);
87
+ const prose = body.map((l) => l.replace(COMMENT_LINE, "").trim()).find(Boolean);
88
+ const label = (kind === "code" && (body.find((l) => !COMMENT_LINE.test(l)) || prose)) || body[0] || "";
89
+ u.heading = label.slice(0, 80);
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+ /** Split one source into chunks of at most `maxTokens`, along its own structure. */
95
+ export function chunkSource(source, opts = {}) {
96
+ const maxTokens = Math.max(50, opts.maxChunkTokens ?? DEFAULT_CHUNK_TOKENS);
97
+ const model = opts.model ?? DEFAULT_MODEL;
98
+ const kind = kindOf(source.name, source.text);
99
+ // A line longer than a chunk (minified code, base64, one-line logs) is cut
100
+ // into pieces at whitespace where it can be; `lineNo` keeps line ranges true.
101
+ const lines = [];
102
+ const lineNo = [];
103
+ const maxChars = Math.floor(maxTokens * 2.4);
104
+ source.text.replace(/\r\n?/g, "\n").split("\n").forEach((line, n) => {
105
+ while (line.length > maxChars) {
106
+ const cut = line.lastIndexOf(" ", maxChars);
107
+ const at = cut > maxChars / 2 ? cut + 1 : maxChars;
108
+ lines.push(line.slice(0, at));
109
+ lineNo.push(n + 1);
110
+ line = line.slice(at);
111
+ }
112
+ lines.push(line);
113
+ lineNo.push(n + 1);
114
+ });
115
+ // Prefix sums of per-line estimates: re-estimating slices would be quadratic on a big log.
116
+ const prefix = [0];
117
+ for (const l of lines)
118
+ prefix.push(prefix[prefix.length - 1] + estimateTokens(l + "\n", model));
119
+ const tok = (a, b) => prefix[b] - prefix[a];
120
+ // Units over the limit are cut by lines; a single over-long line stays whole
121
+ // (cutting inside a line breaks words, code and tables alike).
122
+ const pieces = [];
123
+ for (const u of units(lines, kind)) {
124
+ if (tok(u.start, u.end) <= maxTokens) {
125
+ pieces.push(u);
126
+ continue;
127
+ }
128
+ let s = u.start;
129
+ for (let i = u.start + 1; i <= u.end; i++) {
130
+ if (i === u.end || tok(s, i + 1) > maxTokens) {
131
+ const first = s === u.start;
132
+ pieces.push({ start: s, end: i, heading: first ? u.heading : `${u.heading} (cont.)`, hard: first && u.hard });
133
+ s = i;
134
+ }
135
+ }
136
+ }
137
+ // Merge small neighbours up to the limit, never across a heading.
138
+ const merged = [];
139
+ for (const p of pieces) {
140
+ const last = merged[merged.length - 1];
141
+ if (last && !p.hard && last.heading === p.heading && tok(last.start, p.end) <= maxTokens)
142
+ last.end = p.end;
143
+ else if (last && !p.hard && kind !== "markdown" && tok(last.start, p.end) <= maxTokens)
144
+ last.end = p.end;
145
+ else
146
+ merged.push({ ...p });
147
+ }
148
+ return merged.map((u, n) => {
149
+ // Trim blank edges so line ranges point at content.
150
+ let a = u.start;
151
+ let b = u.end;
152
+ while (a < b && !lines[a].trim())
153
+ a++;
154
+ while (b > a && !lines[b - 1].trim())
155
+ b--;
156
+ const text = lines.slice(a, b).join("\n");
157
+ return {
158
+ id: `${source.name}#${n + 1}`,
159
+ source: source.name,
160
+ startLine: lineNo[a] ?? a + 1,
161
+ endLine: lineNo[b - 1] ?? b,
162
+ heading: u.heading,
163
+ text,
164
+ tokens: estimateTokens(text, model),
165
+ score: 0,
166
+ };
167
+ });
168
+ }
169
+ const STOPWORDS = new Set(("a an and are as at be but by can do does for from has have how i if in into is it its of on or our so that the their " +
170
+ "them then there these they this to was we were what when where which who why will with you your about should would could " +
171
+ "not no yes all any my me get set use using used").split(" "));
172
+ /** Lowercased terms: camelCase and snake_case split, stopwords dropped, plural -s folded. */
173
+ export function terms(text) {
174
+ const out = [];
175
+ const words = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^\p{L}\p{N}]+/u);
176
+ for (let w of words) {
177
+ if (w.length < 2 || STOPWORDS.has(w))
178
+ continue;
179
+ if (w.length > 4 && w.endsWith("ies"))
180
+ w = w.slice(0, -3) + "y";
181
+ else if (w.length > 3 && w.endsWith("s") && !w.endsWith("ss"))
182
+ w = w.slice(0, -1);
183
+ out.push(w);
184
+ }
185
+ return out;
186
+ }
187
+ /** BM25 (k1 1.2, b 0.75); heading terms count twice, as a heading says what a section is about. */
188
+ export function scoreChunks(chunks, query) {
189
+ const q = [...new Set(terms(query))];
190
+ if (q.length === 0 || chunks.length === 0)
191
+ return;
192
+ const docs = chunks.map((c) => {
193
+ const t = [...terms(c.text), ...terms(c.heading), ...terms(c.heading)];
194
+ const tf = new Map();
195
+ for (const w of t)
196
+ tf.set(w, (tf.get(w) ?? 0) + 1);
197
+ return { len: t.length, tf };
198
+ });
199
+ const avg = docs.reduce((s, d) => s + d.len, 0) / docs.length || 1;
200
+ const n = docs.length;
201
+ for (const term of q) {
202
+ const df = docs.filter((d) => d.tf.has(term)).length;
203
+ if (df === 0)
204
+ continue;
205
+ const idf = Math.log(1 + (n - df + 0.5) / (df + 0.5));
206
+ docs.forEach((d, i) => {
207
+ const f = d.tf.get(term) ?? 0;
208
+ if (f)
209
+ chunks[i].score += idf * ((f * 2.2) / (f + 1.2 * (1 - 0.75 + (0.75 * d.len) / avg)));
210
+ });
211
+ }
212
+ }
213
+ /** Chunk, rank and select. Pure: give it text, get the plan back. */
214
+ export function packContext(sources, opts = {}) {
215
+ const perSource = sources.map((s) => chunkSource(s, opts));
216
+ const query = opts.query?.trim() || undefined;
217
+ if (query)
218
+ scoreChunks(perSource.flat(), query);
219
+ return select(sources, perSource, query, opts);
220
+ }
221
+ /**
222
+ * packContext with optional semantic re-ranking through a local Ollama
223
+ * (`semantic: true`, or a model name). Falls back to keyword ranking, with
224
+ * the reason in `semanticNote`, when no embedding model is available.
225
+ */
226
+ export async function packContextSemantic(sources, opts = {}) {
227
+ const perSource = sources.map((s) => chunkSource(s, opts));
228
+ const query = opts.query?.trim() || undefined;
229
+ let semanticNote;
230
+ if (query) {
231
+ scoreChunks(perSource.flat(), query);
232
+ if (opts.semantic) {
233
+ const { semanticRerank } = await import("./embed.js");
234
+ semanticNote = (await semanticRerank(perSource.flat(), query, { model: typeof opts.semantic === "string" ? opts.semantic : undefined })).note;
235
+ }
236
+ }
237
+ return { ...select(sources, perSource, query, opts), semanticNote };
238
+ }
239
+ function select(sources, perSource, query, opts) {
240
+ const budget = Math.max(1, opts.budget ?? DEFAULT_BUDGET);
241
+ const chunks = perSource.flat();
242
+ const pick = [];
243
+ const missingIds = [];
244
+ let used = 0;
245
+ const take = (c) => {
246
+ if (pick.includes(c) || used + c.tokens > budget)
247
+ return;
248
+ pick.push(c);
249
+ used += c.tokens;
250
+ };
251
+ let mode = "outline";
252
+ if (opts.ids?.length) {
253
+ mode = "ids";
254
+ const byId = new Map(chunks.map((c) => [c.id, c]));
255
+ for (const id of opts.ids) {
256
+ const c = byId.get(id);
257
+ if (c)
258
+ take(c);
259
+ else
260
+ missingIds.push(id);
261
+ }
262
+ }
263
+ if (query) {
264
+ if (mode === "outline")
265
+ mode = "query";
266
+ for (const c of [...chunks].filter((c) => c.score > 0).sort((a, b) => b.score - a.score))
267
+ take(c);
268
+ }
269
+ const order = new Map(chunks.map((c, i) => [c, i]));
270
+ pick.sort((a, b) => order.get(a) - order.get(b));
271
+ return {
272
+ mode,
273
+ query,
274
+ budget,
275
+ totalTokens: chunks.reduce((s, c) => s + c.tokens, 0),
276
+ packedTokens: used,
277
+ selected: pick,
278
+ chunks,
279
+ missingIds,
280
+ sources: sources.map((s, i) => ({
281
+ name: s.name,
282
+ tokens: perSource[i].reduce((sum, c) => sum + c.tokens, 0),
283
+ chunks: perSource[i].length,
284
+ selected: perSource[i].filter((c) => pick.includes(c)).length,
285
+ })),
286
+ };
287
+ }
288
+ /** Outline lines: one per chunk, so a model can choose sections by heading. */
289
+ function outline(chunks, limit) {
290
+ const shown = chunks.slice(0, limit).map((c) => ` ${c.id} L${c.startLine}-${c.endLine} ~${formatTokens(c.tokens)} ${c.heading}`);
291
+ if (chunks.length > limit)
292
+ shown.push(` … ${chunks.length - limit} more`);
293
+ return shown;
294
+ }
295
+ /** Plain-text rendering for a model or a terminal. */
296
+ export function renderPack(r, { outlineLimit = 60 } = {}) {
297
+ const out = [];
298
+ const pct = r.totalTokens > 0 ? Math.round((r.packedTokens / r.totalTokens) * 100) : 0;
299
+ const names = r.sources.length === 1 ? r.sources[0].name : `${r.sources.length} files`;
300
+ if (r.mode === "outline") {
301
+ out.push(`OUTLINE of ${names}: ~${formatTokens(r.totalTokens)} tokens in ${r.chunks.length} chunks. Nothing packed: pass a query, or chunk ids from this list.`);
302
+ out.push(...outline(r.chunks, outlineLimit));
303
+ return out.join("\n");
304
+ }
305
+ out.push(`PACKED ~${formatTokens(r.packedTokens)} of ~${formatTokens(r.totalTokens)} tokens (${pct}%) from ${names}: ` +
306
+ `${r.selected.length} of ${r.chunks.length} chunks` +
307
+ (r.query ? ` for "${r.query}"` : "") +
308
+ `, budget ${formatTokens(r.budget)}.`);
309
+ if (r.missingIds.length)
310
+ out.push(`Unknown ids: ${r.missingIds.join(", ")}.`);
311
+ if (r.semanticNote)
312
+ out.push(`(${r.semanticNote})`);
313
+ if (r.selected.length === 0) {
314
+ out.push("No chunk matched. Choose from the outline by id, or rephrase with words the document would use.");
315
+ out.push(...outline(r.chunks, outlineLimit));
316
+ return out.join("\n");
317
+ }
318
+ for (const c of r.selected) {
319
+ out.push("", `── ${c.id} · L${c.startLine}-${c.endLine}${c.heading ? ` · ${c.heading}` : ""} · ~${formatTokens(c.tokens)}`, c.text);
320
+ }
321
+ const left = r.chunks.length - r.selected.length;
322
+ if (left > 0) {
323
+ // Name the best of what was left out, so asking for more is one call.
324
+ const next = r.chunks.filter((c) => c.score > 0 && !r.selected.includes(c)).sort((a, b) => b.score - a.score).slice(0, 5);
325
+ out.push("", `Not included: ${left} chunk(s). ` + (next.length ? `Next best: ${next.map((c) => `${c.id} (${c.heading || `L${c.startLine}`})`).join("; ")}.` : "Ask for the outline to see them."));
326
+ }
327
+ return out.join("\n");
328
+ }
329
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "target", "__pycache__", ".venv", "venv", "vendor"]);
330
+ export const PACK_LIMITS = { files: 500, fileBytes: 5 * 1024 * 1024, totalBytes: 50 * 1024 * 1024 };
331
+ /**
332
+ * Read files and directories (recursively, skipping build and dependency
333
+ * folders, binaries, and anything past the size limits) into sources.
334
+ * `skipped` says what was left out and why, so nothing vanishes silently.
335
+ */
336
+ export function readSources(paths, cwd = process.cwd()) {
337
+ const sources = [];
338
+ const skipped = [];
339
+ let bytes = 0;
340
+ const visit = (p, top) => {
341
+ if (sources.length >= PACK_LIMITS.files) {
342
+ if (top || skipped.length < 20)
343
+ skipped.push(`${p}: over ${PACK_LIMITS.files} files`);
344
+ return;
345
+ }
346
+ let st;
347
+ try {
348
+ st = statSync(p);
349
+ }
350
+ catch {
351
+ skipped.push(`${p}: not found`);
352
+ return;
353
+ }
354
+ if (st.isDirectory()) {
355
+ if (!top && (SKIP_DIRS.has(basename(p)) || basename(p).startsWith(".")))
356
+ return;
357
+ for (const e of readdirSync(p).sort())
358
+ visit(join(p, e), false);
359
+ return;
360
+ }
361
+ if (!st.isFile())
362
+ return;
363
+ if (st.size > PACK_LIMITS.fileBytes || bytes + st.size > PACK_LIMITS.totalBytes) {
364
+ skipped.push(`${p}: too large (${Math.round(st.size / 1024)} KB)`);
365
+ return;
366
+ }
367
+ const rel = relative(cwd, p);
368
+ const name = rel && !rel.startsWith("..") ? rel : p;
369
+ if (EXTRACTABLE.has(extname(p).toLowerCase())) {
370
+ const got = extractText(p);
371
+ if ("error" in got)
372
+ skipped.push(`${p}: ${got.error}`);
373
+ else {
374
+ bytes += st.size;
375
+ sources.push({ name, text: got.text });
376
+ }
377
+ return;
378
+ }
379
+ const buf = readFileSync(p);
380
+ if (buf.subarray(0, 8000).includes(0)) {
381
+ if (top)
382
+ skipped.push(`${p}: binary (pack reads text, PDF and Word/PowerPoint/ODT files)`);
383
+ return;
384
+ }
385
+ bytes += st.size;
386
+ sources.push({ name, text: buf.toString("utf8") });
387
+ };
388
+ for (const p of paths) {
389
+ if (!existsSync(p))
390
+ skipped.push(`${p}: not found`);
391
+ else
392
+ visit(p, true);
393
+ }
394
+ return { sources, skipped };
395
+ }
package/dist/proxy.d.ts CHANGED
@@ -45,9 +45,10 @@ export interface ProxyOptions extends OptimizeOptions {
45
45
  autopilotPauseFile?: string;
46
46
  anthropicUpstream?: string;
47
47
  openaiUpstream?: string;
48
+ googleUpstream?: string;
48
49
  }
49
50
  /** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
50
- export declare const PROXY_VERSION = "0.26.0";
51
+ export declare const PROXY_VERSION = "0.27.0";
51
52
  /**
52
53
  * Remove a leading `/t/<token>` from a request path, or return undefined when
53
54
  * the prefix is absent or the token differs. The comparison is constant time
@@ -61,8 +62,14 @@ export declare function stripToken(url: string, token: string): string | undefin
61
62
  * tokens. Until 0.24 those got a 404 and broke the feature for that client.
62
63
  * Unknown paths go to Anthropic when the request says it is one (Anthropic
63
64
  * clients always send anthropic-version, or x-api-key), else to OpenAI.
65
+ * Google's Gemini API (GOOGLE_GEMINI_BASE_URL) is recognised by its key
66
+ * header, its /v1beta paths, or a `model:method` path such as
67
+ * /v1/models/gemini-3-pro:generateContent.
64
68
  */
65
69
  export declare function upstreamFor(url: string, opts: ProxyOptions, headers?: http.IncomingHttpHeaders): string;
70
+ export declare function isGeminiRequest(url: string, headers?: http.IncomingHttpHeaders): boolean;
71
+ /** The model a Gemini request names in its path (…/models/<model>:generateContent). */
72
+ export declare function geminiModelFromUrl(url: string): string | undefined;
66
73
  export interface ProxyStats {
67
74
  startedAt: string;
68
75
  requests: number;
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.26.0";
23
+ export const PROXY_VERSION = "0.27.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"]);
@@ -48,16 +48,31 @@ export function stripToken(url, token) {
48
48
  * tokens. Until 0.24 those got a 404 and broke the feature for that client.
49
49
  * Unknown paths go to Anthropic when the request says it is one (Anthropic
50
50
  * clients always send anthropic-version, or x-api-key), else to OpenAI.
51
+ * Google's Gemini API (GOOGLE_GEMINI_BASE_URL) is recognised by its key
52
+ * header, its /v1beta paths, or a `model:method` path such as
53
+ * /v1/models/gemini-3-pro:generateContent.
51
54
  */
52
55
  export function upstreamFor(url, opts, headers = {}) {
53
56
  const anthropic = opts.anthropicUpstream ?? "https://api.anthropic.com";
54
57
  const openai = opts.openaiUpstream ?? "https://api.openai.com";
58
+ if (isGeminiRequest(url, headers))
59
+ return opts.googleUpstream ?? "https://generativelanguage.googleapis.com";
55
60
  if (url.startsWith("/v1/messages"))
56
61
  return anthropic;
57
62
  if (url.startsWith("/v1/chat/completions") || url.startsWith("/v1/responses") || url.startsWith("/v1/embeddings"))
58
63
  return openai;
59
64
  return headers["anthropic-version"] !== undefined || headers["x-api-key"] !== undefined ? anthropic : openai;
60
65
  }
66
+ export function isGeminiRequest(url, headers = {}) {
67
+ return (headers["x-goog-api-key"] !== undefined ||
68
+ /^\/(upload\/)?v1(beta|alpha)\d*\//.test(url) ||
69
+ /^\/v1\/[^?]*:(generateContent|streamGenerateContent|countTokens|embedContent|batchEmbedContents)/.test(url));
70
+ }
71
+ /** The model a Gemini request names in its path (…/models/<model>:generateContent). */
72
+ export function geminiModelFromUrl(url) {
73
+ return /\/models\/([^/:?]+):/.exec(url)?.[1];
74
+ }
75
+ const GEMINI_CONVERSATION = /\/models\/[^/:?]+:(generateContent|streamGenerateContent)(\?|$)/;
61
76
  /** Pull exact usage out of a response body — JSON or SSE, either provider. */
62
77
  function extractUsage(text) {
63
78
  const last = (re) => {
@@ -67,8 +82,8 @@ function extractUsage(text) {
67
82
  v = Number(m[1]);
68
83
  return v;
69
84
  };
70
- const input = Math.max(last(/"input_tokens"\s*:\s*(\d+)/g), last(/"prompt_tokens"\s*:\s*(\d+)/g));
71
- const output = Math.max(last(/"output_tokens"\s*:\s*(\d+)/g), last(/"completion_tokens"\s*:\s*(\d+)/g));
85
+ const input = Math.max(last(/"input_tokens"\s*:\s*(\d+)/g), last(/"prompt_tokens"\s*:\s*(\d+)/g), last(/"promptTokenCount"\s*:\s*(\d+)/g));
86
+ const output = Math.max(last(/"output_tokens"\s*:\s*(\d+)/g), last(/"completion_tokens"\s*:\s*(\d+)/g), last(/"candidatesTokenCount"\s*:\s*(\d+)/g));
72
87
  if (input < 0 && output < 0)
73
88
  return null;
74
89
  return { input: Math.max(input, 0), output: Math.max(output, 0) };
@@ -159,7 +174,8 @@ export function startProxy(opts = {}) {
159
174
  const isMeasurement = url.startsWith("/v1/messages/count_tokens");
160
175
  let note = "passthrough";
161
176
  if (opts.autopilot) {
162
- if (req.method === "POST" && body && !isMeasurement && /^\/v1\/(messages|chat\/completions|responses)(\?|$)/.test(url)) {
177
+ const gemini = GEMINI_CONVERSATION.test(url);
178
+ if (req.method === "POST" && body && !isMeasurement && (gemini || /^\/v1\/(messages|chat\/completions|responses)(\?|$)/.test(url))) {
163
179
  const ap = stats.autopilot;
164
180
  ap.paused = Boolean(opts.autopilotPauseFile && existsSync(opts.autopilotPauseFile));
165
181
  if (ap.paused) {
@@ -168,7 +184,8 @@ export function startProxy(opts = {}) {
168
184
  else {
169
185
  try {
170
186
  const parsed = JSON.parse(body);
171
- const r = clearer.apply(parsed);
187
+ const urlModel = gemini ? geminiModelFromUrl(url) : undefined;
188
+ const r = clearer.apply(parsed, Date.now(), urlModel);
172
189
  ap.requests++;
173
190
  ap.lastReason = r.reason;
174
191
  if (r.newlyCleared > 0) {
@@ -183,7 +200,7 @@ export function startProxy(opts = {}) {
183
200
  ap.tokensRemoved += r.tokensRemoved;
184
201
  stats.optimizedRequests++;
185
202
  stats.tokensSaved += r.tokensRemoved;
186
- const pricing = pricingFor(typeof parsed.model === "string" ? parsed.model : undefined);
203
+ const pricing = pricingFor(typeof parsed.model === "string" ? parsed.model : urlModel);
187
204
  // What autopilot removes would mostly have been cached reads: price it so.
188
205
  if (pricing)
189
206
  stats.estUsdSaved += (r.tokensRemoved / 1e6) * pricing.cacheReadPerM;
package/dist/session.js CHANGED
@@ -12,6 +12,7 @@ import { readdirSync, readFileSync, statSync, existsSync, openSync, readSync, cl
12
12
  import { StringDecoder } from "node:string_decoder";
13
13
  import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
+ import { isGeminiChat, listGeminiChats, parseGeminiChat } from "./gemini.js";
15
16
  /**
16
17
  * Read one usage field defensively.
17
18
  *
@@ -80,6 +81,7 @@ export function listSessions(limit = 20) {
80
81
  };
81
82
  walk(codexRoot, 0);
82
83
  }
84
+ sessions.push(...listGeminiChats());
83
85
  return sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime()).slice(0, limit);
84
86
  }
85
87
  /**
@@ -283,6 +285,8 @@ function asBlocks(content) {
283
285
  return content == null ? [] : [content];
284
286
  }
285
287
  export function parseSessionFile(path) {
288
+ if (isGeminiChat(path))
289
+ return parseGeminiChat(path);
286
290
  // ChatGPT exports are one big JSON array, not JSONL — and small enough to
287
291
  // read whole. Only peek first, so multi-hundred-MB JSONL is never slurped.
288
292
  if (startsWithArray(path)) {
@@ -0,0 +1,44 @@
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
+ export interface SplitSection {
16
+ heading: string;
17
+ tokens: number;
18
+ moved: boolean;
19
+ reason?: "large" | "code" | "not index";
20
+ }
21
+ export interface SplitPlan {
22
+ path: string;
23
+ referencePath: string;
24
+ sections: SplitSection[];
25
+ keptText: string;
26
+ movedText: string;
27
+ tokensBefore: number;
28
+ tokensAfter: number;
29
+ }
30
+ export declare function referencePathFor(path: string): string;
31
+ /**
32
+ * Plan the split; pure apart from reading the file. An auto-memory MEMORY.md
33
+ * is meant to be an index (Claude Code loads it on every request and reads
34
+ * topic files on demand), so everything in it that is not an index moves,
35
+ * whatever its size.
36
+ */
37
+ export declare function planSplit(path: string, { minTokens }?: {
38
+ minTokens?: number;
39
+ }): SplitPlan;
40
+ /** Write the plan: back up the original, write the lean file, append to (or create) the reference file. */
41
+ export declare function applySplit(plan: SplitPlan, now?: number): {
42
+ backup: string;
43
+ };
44
+ export declare function renderSplit(plan: SplitPlan, usdPerKPerMonth?: number): string;