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/README.md +101 -6
- package/dist/autoclear.d.ts +9 -2
- package/dist/autoclear.js +41 -3
- package/dist/ci.d.ts +39 -0
- package/dist/ci.js +144 -0
- package/dist/cli.js +117 -0
- package/dist/doctor.js +27 -0
- package/dist/embed.d.ts +30 -0
- package/dist/embed.js +127 -0
- package/dist/extract.d.ts +17 -0
- package/dist/extract.js +88 -0
- package/dist/gemini.d.ts +23 -0
- package/dist/gemini.js +192 -0
- package/dist/hook.js +3 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/install.d.ts +2 -0
- package/dist/install.js +32 -9
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +36 -8
- package/dist/mcpschema.d.ts +51 -0
- package/dist/mcpschema.js +125 -0
- package/dist/overhead.d.ts +76 -0
- package/dist/overhead.js +372 -0
- package/dist/pack.d.ts +105 -0
- package/dist/pack.js +395 -0
- package/dist/proxy.d.ts +8 -1
- package/dist/proxy.js +23 -6
- package/dist/session.js +4 -0
- package/dist/split.d.ts +44 -0
- package/dist/split.js +165 -0
- package/dist/wrap.d.ts +44 -0
- package/dist/wrap.js +96 -0
- package/package.json +2 -2
- package/skills/context-doctor/SKILL.md +2 -1
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 :
|
|
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)) {
|
package/dist/split.d.ts
ADDED
|
@@ -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;
|