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.
- package/README.md +25 -25
- package/dist/autoclear.d.ts +58 -7
- package/dist/autoclear.js +155 -30
- package/dist/autopilot.d.ts +23 -3
- package/dist/autopilot.js +100 -29
- package/dist/cache.d.ts +6 -2
- package/dist/cache.js +48 -23
- package/dist/cacheplan.d.ts +5 -3
- package/dist/cacheplan.js +46 -8
- package/dist/calibration.d.ts +7 -2
- package/dist/calibration.js +14 -3
- package/dist/ci.js +25 -5
- package/dist/cli.js +61 -26
- package/dist/coldresume.d.ts +32 -7
- package/dist/coldresume.js +148 -34
- package/dist/compactquality.js +34 -12
- package/dist/compactwindow.js +59 -34
- package/dist/config.d.ts +6 -0
- package/dist/config.js +4 -1
- package/dist/cursor.d.ts +9 -0
- package/dist/cursor.js +53 -22
- package/dist/dashboard.d.ts +10 -0
- package/dist/dashboard.js +27 -2
- package/dist/diff.js +10 -3
- package/dist/doctor.js +133 -18
- package/dist/embed.d.ts +9 -0
- package/dist/embed.js +40 -5
- package/dist/experiment.d.ts +1 -0
- package/dist/experiment.js +6 -3
- package/dist/hook.js +23 -4
- package/dist/impact.d.ts +32 -0
- package/dist/impact.js +45 -6
- package/dist/install.d.ts +34 -2
- package/dist/install.js +223 -30
- package/dist/ledger.js +9 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +2 -2
- package/dist/mcpschema.d.ts +16 -1
- package/dist/mcpschema.js +95 -24
- package/dist/optimize.js +149 -64
- package/dist/overhead.js +57 -27
- package/dist/pack.d.ts +11 -0
- package/dist/pack.js +75 -12
- package/dist/parse.js +42 -18
- package/dist/pricing.d.ts +57 -5
- package/dist/pricing.js +94 -8
- package/dist/proxy.d.ts +13 -2
- package/dist/proxy.js +258 -43
- package/dist/reads.d.ts +1 -1
- package/dist/reads.js +61 -24
- package/dist/report.d.ts +10 -0
- package/dist/report.js +12 -4
- package/dist/savings.d.ts +20 -8
- package/dist/savings.js +79 -34
- package/dist/session.d.ts +37 -0
- package/dist/session.js +143 -6
- package/dist/split.js +6 -1
- package/dist/statusline.d.ts +3 -1
- package/dist/statusline.js +29 -17
- package/dist/subagentpay.d.ts +5 -2
- package/dist/subagentpay.js +50 -16
- package/dist/subagents.d.ts +7 -3
- package/dist/subagents.js +28 -8
- package/dist/tasks.d.ts +22 -7
- package/dist/tasks.js +47 -20
- package/dist/wrap.d.ts +6 -0
- package/dist/wrap.js +142 -6
- package/package.json +17 -2
package/dist/optimize.js
CHANGED
|
@@ -105,8 +105,13 @@ function stableCutoff(messageCount, keepRecent, step = TRIM_BOUNDARY_STEP) {
|
|
|
105
105
|
return raw;
|
|
106
106
|
return Math.floor(raw / step) * step;
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Exact content, ends trimmed. Whitespace inside is meaningful: two versions of
|
|
110
|
+
* a Python file that differ only in indentation are different programs, and
|
|
111
|
+
* calling the later one "identical" would delete the fix.
|
|
112
|
+
*/
|
|
108
113
|
function hash(text) {
|
|
109
|
-
return createHash("sha1").update(text.
|
|
114
|
+
return createHash("sha1").update(text.trim()).digest("hex");
|
|
110
115
|
}
|
|
111
116
|
/** Extract all text from a message content value (string or block array). */
|
|
112
117
|
function textOf(content) {
|
|
@@ -129,36 +134,74 @@ function textOf(content) {
|
|
|
129
134
|
.join("\n");
|
|
130
135
|
}
|
|
131
136
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
137
|
+
* Rewrite each piece of text in a message where it sits: string content, each
|
|
138
|
+
* text block, and the text of each tool_result block. Block order, types and
|
|
139
|
+
* tool_use_ids never change, so each of several parallel results stays with
|
|
140
|
+
* its own call and a note the user typed beside them stays as it was. A
|
|
141
|
+
* result's text parts are one piece (a result is trimmed as a whole) and its
|
|
142
|
+
* images stay. tool_use inputs and images are not text here; trim-tool-calls
|
|
143
|
+
* and stripInputBlobs handle inputs. An OpenAI `role: "tool"` message is a
|
|
144
|
+
* tool result throughout.
|
|
138
145
|
*/
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
function mapPieces(m, fn) {
|
|
147
|
+
const content = m?.content;
|
|
148
|
+
const own = m?.role === "tool" ? "tool_result" : "text";
|
|
149
|
+
const textParts = (parts, piece) => {
|
|
150
|
+
const texts = parts.filter((p) => p?.type === "text" && typeof p.text === "string");
|
|
151
|
+
if (texts.length === 0)
|
|
152
|
+
return parts;
|
|
153
|
+
const joined = texts.map((p) => p.text).join("\n");
|
|
154
|
+
const next = fn(joined, piece);
|
|
155
|
+
if (next === joined)
|
|
156
|
+
return parts;
|
|
157
|
+
// The rewritten text takes the first text part's place; other text parts
|
|
158
|
+
// were folded into it, everything else (images) keeps its position.
|
|
159
|
+
let placed = false;
|
|
160
|
+
return parts
|
|
161
|
+
.map((p) => {
|
|
162
|
+
if (!(p?.type === "text" && typeof p.text === "string"))
|
|
163
|
+
return p;
|
|
151
164
|
if (placed)
|
|
152
165
|
return null;
|
|
153
166
|
placed = true;
|
|
154
|
-
return { ...
|
|
167
|
+
return { ...p, text: next };
|
|
168
|
+
})
|
|
169
|
+
.filter((p) => p !== null);
|
|
170
|
+
};
|
|
171
|
+
if (typeof content === "string")
|
|
172
|
+
return fn(content, own);
|
|
173
|
+
if (!Array.isArray(content))
|
|
174
|
+
return content;
|
|
175
|
+
if (own === "tool_result")
|
|
176
|
+
return textParts(content, own);
|
|
177
|
+
return content.map((b) => {
|
|
178
|
+
if (b?.type === "text" && typeof b.text === "string") {
|
|
179
|
+
const t = fn(b.text, "text");
|
|
180
|
+
return t === b.text ? b : { ...b, text: t };
|
|
181
|
+
}
|
|
182
|
+
if (b?.type === "tool_result") {
|
|
183
|
+
if (typeof b.content === "string") {
|
|
184
|
+
const t = fn(b.content, "tool_result");
|
|
185
|
+
return t === b.content ? b : { ...b, content: t };
|
|
186
|
+
}
|
|
187
|
+
if (Array.isArray(b.content)) {
|
|
188
|
+
const parts = textParts(b.content, "tool_result");
|
|
189
|
+
return parts === b.content ? b : { ...b, content: parts };
|
|
190
|
+
}
|
|
155
191
|
}
|
|
156
192
|
return b;
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/** Strip base64 blobs from the string values of a tool call's input, at any depth. */
|
|
196
|
+
function stripInputBlobs(value) {
|
|
197
|
+
if (typeof value === "string")
|
|
198
|
+
return hasBase64Blob(value) ? stripBase64Blobs(value) : value;
|
|
199
|
+
if (Array.isArray(value))
|
|
200
|
+
return value.map(stripInputBlobs);
|
|
201
|
+
if (value && typeof value === "object") {
|
|
202
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, stripInputBlobs(v)]));
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
162
205
|
}
|
|
163
206
|
function truncateToTokens(text, maxTokens, model) {
|
|
164
207
|
const approxChars = Math.round(maxTokens * charsPerTokenFor(text, model));
|
|
@@ -219,7 +262,7 @@ export function optimizeConversation(input, options = {}) {
|
|
|
219
262
|
catch {
|
|
220
263
|
throw new Error("optimize requires a JSON conversation (message array, or object with a `messages` field)");
|
|
221
264
|
}
|
|
222
|
-
const messages = Array.isArray(data) ? data : data
|
|
265
|
+
const messages = Array.isArray(data) ? data : data?.messages;
|
|
223
266
|
if (!Array.isArray(messages)) {
|
|
224
267
|
throw new Error("No `messages` array found in input");
|
|
225
268
|
}
|
|
@@ -248,10 +291,13 @@ export function optimizeConversation(input, options = {}) {
|
|
|
248
291
|
if (!hasBase64Blob(text))
|
|
249
292
|
return;
|
|
250
293
|
const before = tok(text);
|
|
251
|
-
|
|
252
|
-
|
|
294
|
+
let content = mapPieces(m, (t) => (hasBase64Blob(t) ? stripBase64Blobs(t) : t));
|
|
295
|
+
if (Array.isArray(content)) {
|
|
296
|
+
content = content.map((b) => (b?.type === "tool_use" && b.input != null ? { ...b, input: stripInputBlobs(b.input) } : b));
|
|
297
|
+
}
|
|
298
|
+
const saved = before - tok(textOf(content));
|
|
253
299
|
if (saved > 50) {
|
|
254
|
-
m.content =
|
|
300
|
+
m.content = content;
|
|
255
301
|
applied.push({ strategy: "strip-base64", messageIndex: i, tokensSaved: saved, note: "Removed inline base64 data" });
|
|
256
302
|
}
|
|
257
303
|
});
|
|
@@ -265,26 +311,34 @@ export function optimizeConversation(input, options = {}) {
|
|
|
265
311
|
// and just sees a worse answer with no explanation. Older copies are fair
|
|
266
312
|
// game; the live turn is not.
|
|
267
313
|
const cutoff = stableCutoff(messages.length, opts.keepRecent, opts.trimBoundaryStep);
|
|
314
|
+
// Per piece (a text block or one tool's result), not per message: a whole
|
|
315
|
+
// message rarely repeats, and a message-level replacement had to flatten
|
|
316
|
+
// its blocks, which put one tool's output under another's id.
|
|
268
317
|
const seen = new Map();
|
|
269
318
|
messages.forEach((m, i) => {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
319
|
+
let saved = 0;
|
|
320
|
+
let firstOf;
|
|
321
|
+
const content = mapPieces(m, (t) => {
|
|
322
|
+
if (t.length < 300)
|
|
323
|
+
return t;
|
|
324
|
+
const h = hash(t);
|
|
325
|
+
const first = seen.get(h);
|
|
326
|
+
if (first === undefined) {
|
|
327
|
+
seen.set(h, i);
|
|
328
|
+
return t;
|
|
329
|
+
}
|
|
330
|
+
// Still recorded above, so a later duplicate can point back here.
|
|
331
|
+
if (i >= cutoff)
|
|
332
|
+
return t;
|
|
333
|
+
const pointer = `[context-doctor: identical to message #${first} — content removed]`;
|
|
334
|
+
saved += tok(t) - tok(pointer);
|
|
335
|
+
firstOf ??= first;
|
|
336
|
+
return pointer;
|
|
337
|
+
});
|
|
338
|
+
if (saved <= 0)
|
|
283
339
|
return;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
m.content = replaceText(m.content, `[context-doctor: identical to message #${first} — content removed]`);
|
|
287
|
-
applied.push({ strategy: "dedupe", messageIndex: i, tokensSaved: saved, note: `Duplicate of message #${first}` });
|
|
340
|
+
m.content = content;
|
|
341
|
+
applied.push({ strategy: "dedupe", messageIndex: i, tokensSaved: saved, note: `Duplicate of message #${firstOf}` });
|
|
288
342
|
});
|
|
289
343
|
}
|
|
290
344
|
// -- trim-tool-results: shrink stale tool output ------------------------------
|
|
@@ -293,21 +347,32 @@ export function optimizeConversation(input, options = {}) {
|
|
|
293
347
|
messages.forEach((m, i) => {
|
|
294
348
|
if (i >= cutoff || !isToolResultMessage(m))
|
|
295
349
|
return;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
350
|
+
// Each result on its own budget; text the user typed beside them is not
|
|
351
|
+
// tool output and stays whole.
|
|
352
|
+
let saved = 0;
|
|
353
|
+
const content = mapPieces(m, (t, piece) => {
|
|
354
|
+
if (piece !== "tool_result")
|
|
355
|
+
return t;
|
|
356
|
+
const before = tok(t);
|
|
357
|
+
if (before <= opts.maxToolResultTokens)
|
|
358
|
+
return t;
|
|
359
|
+
const trimmed = truncateToTokens(t, opts.maxToolResultTokens, model);
|
|
360
|
+
// The truncation notice has a length of its own, so a result only just
|
|
361
|
+
// over the budget can come back LARGER than it went in. Measured on a
|
|
362
|
+
// real session: 2,941 tokens "optimized" to 2,947.
|
|
363
|
+
const after = tok(trimmed);
|
|
364
|
+
if (after >= before)
|
|
365
|
+
return t;
|
|
366
|
+
saved += before - after;
|
|
367
|
+
return trimmed;
|
|
368
|
+
});
|
|
369
|
+
if (saved <= 0)
|
|
305
370
|
return;
|
|
306
|
-
m.content =
|
|
371
|
+
m.content = content;
|
|
307
372
|
applied.push({
|
|
308
373
|
strategy: "trim-tool-results",
|
|
309
374
|
messageIndex: i,
|
|
310
|
-
tokensSaved:
|
|
375
|
+
tokensSaved: saved,
|
|
311
376
|
note: "Stale tool result truncated",
|
|
312
377
|
});
|
|
313
378
|
});
|
|
@@ -345,6 +410,8 @@ export function optimizeConversation(input, options = {}) {
|
|
|
345
410
|
}
|
|
346
411
|
}
|
|
347
412
|
// OpenAI shape: tool_calls[].function.arguments is a JSON string.
|
|
413
|
+
// Cut inside the parsed arguments, as above: a JSON string cut in the
|
|
414
|
+
// middle is no longer JSON, and the next request would carry it as such.
|
|
348
415
|
for (const tc of m.tool_calls ?? []) {
|
|
349
416
|
const args = tc?.function?.arguments;
|
|
350
417
|
if (typeof args !== "string")
|
|
@@ -352,8 +419,21 @@ export function optimizeConversation(input, options = {}) {
|
|
|
352
419
|
const before = tok(args);
|
|
353
420
|
if (before <= opts.maxToolResultTokens)
|
|
354
421
|
continue;
|
|
355
|
-
|
|
356
|
-
|
|
422
|
+
let parsed;
|
|
423
|
+
try {
|
|
424
|
+
parsed = JSON.parse(args);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
430
|
+
continue;
|
|
431
|
+
const trimmedArgs = JSON.stringify(trimCallArguments(parsed, opts.maxToolResultTokens, model));
|
|
432
|
+
const after = tok(trimmedArgs);
|
|
433
|
+
if (after >= before)
|
|
434
|
+
continue;
|
|
435
|
+
tc.function.arguments = trimmedArgs;
|
|
436
|
+
saved += before - after;
|
|
357
437
|
}
|
|
358
438
|
if (saved > 0) {
|
|
359
439
|
applied.push({
|
|
@@ -368,7 +448,12 @@ export function optimizeConversation(input, options = {}) {
|
|
|
368
448
|
// -- prune-history: replace the older half with a stub ------------------------
|
|
369
449
|
// Opt-in only: it is lossy, so it is not in the default strategy set.
|
|
370
450
|
let prunedDigest;
|
|
371
|
-
|
|
451
|
+
// OpenAI-shaped requests carry the system prompt as leading system/developer
|
|
452
|
+
// messages; those are instructions, not history, and are never pruned.
|
|
453
|
+
let lead = 0;
|
|
454
|
+
while (lead < messages.length && (messages[lead]?.role === "system" || messages[lead]?.role === "developer"))
|
|
455
|
+
lead++;
|
|
456
|
+
if (opts.strategies.includes("prune-history") && messages.length - lead > opts.keepRecent * 2) {
|
|
372
457
|
let keepFrom = messages.length - opts.keepRecent;
|
|
373
458
|
// Never let the kept tail START with a tool result: its matching tool_use
|
|
374
459
|
// would be pruned away and both the Anthropic and OpenAI APIs reject
|
|
@@ -384,22 +469,22 @@ export function optimizeConversation(input, options = {}) {
|
|
|
384
469
|
// APIs reject a conversation containing an orphan. Measured on a real
|
|
385
470
|
// 1,011-message session, which pruned to 7 messages with one orphan.
|
|
386
471
|
dropOrphanedToolResults(messages.slice(keepFrom));
|
|
387
|
-
const pruned = messages.slice(
|
|
472
|
+
const pruned = messages.slice(lead, keepFrom);
|
|
388
473
|
const prunedTokens = pruned.reduce((s, m) => s + tok(textOf(m.content)), 0);
|
|
389
474
|
// Digest: first ~200 chars of each pruned turn — enough for a host LLM to
|
|
390
475
|
// write a faithful summary, small enough not to defeat the pruning.
|
|
391
476
|
prunedDigest = pruned
|
|
392
|
-
.map((m, i) => `[${i}:${m.role}] ${textOf(m.content).replace(/\s+/g, " ").slice(0, 200)}`)
|
|
477
|
+
.map((m, i) => `[${lead + i}:${m.role}] ${textOf(m.content).replace(/\s+/g, " ").slice(0, 200)}`)
|
|
393
478
|
.join("\n");
|
|
394
479
|
const stub = {
|
|
395
480
|
role: "user",
|
|
396
481
|
content: `[context-doctor: ${pruned.length} earlier messages (~${prunedTokens} tokens) pruned. ` +
|
|
397
482
|
`Replace this stub with an LLM-written summary of those turns for best results.]`,
|
|
398
483
|
};
|
|
399
|
-
messages.splice(
|
|
484
|
+
messages.splice(lead, keepFrom - lead, stub);
|
|
400
485
|
applied.push({
|
|
401
486
|
strategy: "prune-history",
|
|
402
|
-
messageIndex:
|
|
487
|
+
messageIndex: lead,
|
|
403
488
|
tokensSaved: prunedTokens - tok(stub.content),
|
|
404
489
|
note: `Pruned ${pruned.length} old messages`,
|
|
405
490
|
});
|
package/dist/overhead.js
CHANGED
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
20
|
-
import { forEachLine, listSessions } from "./session.js";
|
|
21
|
-
import { estimateTokens, formatTokens, providerFor } from "./tokens.js";
|
|
22
|
-
import { formatUsd, pricingFor } from "./pricing.js";
|
|
20
|
+
import { forEachLine, listSessions, replyCounter } from "./session.js";
|
|
21
|
+
import { estimateTokens, formatTokens, providerFor, realModel } from "./tokens.js";
|
|
22
|
+
import { cacheWriteSplit, formatUsd, pricingFor, writeMultiplier as ttlWriteMultiplier } from "./pricing.js";
|
|
23
23
|
import { isGeminiChat, readGeminiMessages } from "./gemini.js";
|
|
24
24
|
import { claudeMcpConfigs, mcpUsage, measureServer, toolPrefixName } from "./mcpschema.js";
|
|
25
25
|
const TTL_MS = 3_600_000;
|
|
@@ -147,11 +147,17 @@ function textOf(content) {
|
|
|
147
147
|
return "";
|
|
148
148
|
return content.map((b) => (typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "")).filter(Boolean).join("\n");
|
|
149
149
|
}
|
|
150
|
-
/**
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Claude Code: usage rows on assistant messages (one per API reply id).
|
|
152
|
+
* `runRows` is shared by every transcript of one report: a forked session
|
|
153
|
+
* begins with a verbatim copy of its parent's history, whose requests (and
|
|
154
|
+
* first request) the parent already counted.
|
|
155
|
+
*/
|
|
156
|
+
function claudeUsage(path, runRows) {
|
|
157
|
+
const out = { agent: "Claude Code", reqs: [], writes5m: 0, writes1h: 0 };
|
|
153
158
|
let firstUser;
|
|
154
|
-
let
|
|
159
|
+
let started = false;
|
|
160
|
+
const sighting = replyCounter(runRows);
|
|
155
161
|
forEachLine(path, (line) => {
|
|
156
162
|
let e;
|
|
157
163
|
try {
|
|
@@ -162,22 +168,32 @@ function claudeUsage(path) {
|
|
|
162
168
|
}
|
|
163
169
|
if (!e || e.isSidechain)
|
|
164
170
|
return;
|
|
165
|
-
if (e.type === "user" &&
|
|
171
|
+
if (e.type === "user" && !started && firstUser === undefined && !e.isMeta) {
|
|
166
172
|
const text = textOf(e.message?.content);
|
|
167
173
|
if (text)
|
|
168
174
|
firstUser = text;
|
|
169
175
|
return;
|
|
170
176
|
}
|
|
171
|
-
if (e.type !== "assistant" || !e.message?.usage
|
|
177
|
+
if (e.type !== "assistant" || !e.message?.usage)
|
|
178
|
+
return;
|
|
179
|
+
const seen = sighting(e);
|
|
180
|
+
if (seen === "repeat")
|
|
172
181
|
return;
|
|
173
|
-
lastId = e.message.id;
|
|
174
182
|
const u = e.message.usage;
|
|
175
183
|
const prompt = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
176
184
|
const at = Date.parse(e.timestamp);
|
|
177
185
|
if (!(prompt > 0) || !Number.isFinite(at))
|
|
178
186
|
return;
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
// A fork's first request is its parent's: not a second session start.
|
|
188
|
+
if (!started && seen === "new")
|
|
189
|
+
out.first = Math.max(0, prompt - estimateTokens(firstUser ?? "", CLAUDE));
|
|
190
|
+
started = true;
|
|
191
|
+
if (seen === "copy")
|
|
192
|
+
return;
|
|
193
|
+
const w = cacheWriteSplit(u);
|
|
194
|
+
out.writes5m += w.fiveMinute;
|
|
195
|
+
out.writes1h += w.oneHour;
|
|
196
|
+
out.reqs.push({ at, model: realModel(e.message.model) });
|
|
181
197
|
});
|
|
182
198
|
return out;
|
|
183
199
|
}
|
|
@@ -237,32 +253,39 @@ function geminiUsage(path) {
|
|
|
237
253
|
}
|
|
238
254
|
return out;
|
|
239
255
|
}
|
|
240
|
-
function usageOf(path) {
|
|
256
|
+
function usageOf(path, runRows) {
|
|
241
257
|
if (isGeminiChat(path))
|
|
242
258
|
return geminiUsage(path);
|
|
243
259
|
if (/[\\/]\.codex[\\/]/.test(path))
|
|
244
260
|
return codexUsage(path);
|
|
245
|
-
return claudeUsage(path);
|
|
261
|
+
return claudeUsage(path, runRows);
|
|
246
262
|
}
|
|
247
|
-
/**
|
|
248
|
-
|
|
249
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Cache writes cost 1.25x input on Anthropic at the 5-minute TTL and 2x at 1
|
|
265
|
+
* hour, which Claude Code's main thread mostly uses: the agent's own
|
|
266
|
+
* recorded mix decides. OpenAI and Google charge no write premium.
|
|
267
|
+
*/
|
|
268
|
+
function writeMultiplier(model, writes5m, writes1h) {
|
|
269
|
+
return providerFor(model) === "anthropic" ? ttlWriteMultiplier(writes5m, writes1h) : 1;
|
|
250
270
|
}
|
|
251
271
|
function aggregate(agent, sessions, since, days) {
|
|
252
272
|
const firsts = [];
|
|
253
|
-
let requests = 0, coldStarts = 0,
|
|
273
|
+
let requests = 0, coldStarts = 0, writes5m = 0, writes1h = 0;
|
|
254
274
|
const models = new Map();
|
|
275
|
+
const starts = [];
|
|
255
276
|
for (const s of sessions) {
|
|
256
277
|
if (s.first !== undefined && s.first > 1000)
|
|
257
278
|
firsts.push(s.first);
|
|
279
|
+
writes5m += s.writes5m ?? 0;
|
|
280
|
+
writes1h += s.writes1h ?? 0;
|
|
258
281
|
let prevAt;
|
|
259
282
|
for (const r of s.reqs) {
|
|
260
283
|
if (r.at >= since) {
|
|
261
284
|
requests++;
|
|
262
|
-
|
|
285
|
+
const cold = prevAt === undefined || r.at - prevAt > TTL_MS;
|
|
286
|
+
if (cold)
|
|
263
287
|
coldStarts++;
|
|
264
|
-
|
|
265
|
-
warm++;
|
|
288
|
+
starts.push({ cold, model: r.model });
|
|
266
289
|
if (r.model)
|
|
267
290
|
models.set(r.model, (models.get(r.model) ?? 0) + 1);
|
|
268
291
|
}
|
|
@@ -273,12 +296,18 @@ function aggregate(agent, sessions, since, days) {
|
|
|
273
296
|
return undefined;
|
|
274
297
|
firsts.sort((a, b) => a - b);
|
|
275
298
|
const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
276
|
-
const price = pricingFor(model);
|
|
277
299
|
// Each overhead token is a cache read on warm requests and a full-price
|
|
278
|
-
// write (with the provider's premium) after every cold start
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
300
|
+
// write (with the provider's premium) after every cold start, each request
|
|
301
|
+
// at its own model's price: one month can mix a $4 and a $10 model.
|
|
302
|
+
let usdPerK = 0, priced = 0;
|
|
303
|
+
for (const r of starts) {
|
|
304
|
+
const price = pricingFor(r.model ?? model);
|
|
305
|
+
if (!price)
|
|
306
|
+
continue;
|
|
307
|
+
priced++;
|
|
308
|
+
usdPerK += (1000 * (r.cold ? writeMultiplier(r.model ?? model, writes5m, writes1h) * price.inputPerM : price.cacheReadPerM)) / 1e6;
|
|
309
|
+
}
|
|
310
|
+
const usdPerKPerMonth = priced > 0 ? usdPerK * (30 / days) : undefined;
|
|
282
311
|
return {
|
|
283
312
|
agent,
|
|
284
313
|
sessions: firsts.length,
|
|
@@ -301,10 +330,11 @@ export function measureBaselines(days = 30, paths) {
|
|
|
301
330
|
const since = Date.now() - days * 86_400_000;
|
|
302
331
|
const files = paths ?? listSessions(10_000).filter((s) => s.modifiedAt.getTime() >= since).map((s) => s.path);
|
|
303
332
|
const byAgent = new Map();
|
|
333
|
+
const runRows = new Set();
|
|
304
334
|
for (const path of files) {
|
|
305
335
|
let u;
|
|
306
336
|
try {
|
|
307
|
-
u = usageOf(path);
|
|
337
|
+
u = usageOf(path, runRows);
|
|
308
338
|
}
|
|
309
339
|
catch {
|
|
310
340
|
continue;
|
package/dist/pack.d.ts
CHANGED
|
@@ -94,6 +94,17 @@ export declare const PACK_LIMITS: {
|
|
|
94
94
|
fileBytes: number;
|
|
95
95
|
totalBytes: number;
|
|
96
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* Read files and directories (recursively, skipping build and dependency
|
|
99
|
+
* folders, binaries, and anything past the size limits) into sources.
|
|
100
|
+
* `skipped` says what was left out and why, so nothing vanishes silently.
|
|
101
|
+
*
|
|
102
|
+
* A path named here is read even through a symlink; a symlink met inside a
|
|
103
|
+
* folder is not followed, as git does not follow one either. A link out of
|
|
104
|
+
* the folder would pack files nobody named (a tracked link to /etc/hosts, or
|
|
105
|
+
* to a secret beside the repo), a link to a parent loops, and what a link
|
|
106
|
+
* inside points at is read under its own name (or left out by .gitignore).
|
|
107
|
+
*/
|
|
97
108
|
export declare function readSources(paths: string[], cwd?: string): {
|
|
98
109
|
sources: PackSource[];
|
|
99
110
|
skipped: string[];
|
package/dist/pack.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* comes with every result lets the model pick sections by heading when it does.
|
|
15
15
|
*/
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
18
|
-
import { basename, extname, join, relative } from "node:path";
|
|
17
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
18
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
19
19
|
import { estimateTokens, formatTokens } from "./tokens.js";
|
|
20
20
|
import { EXTRACTABLE, extractText } from "./extract.js";
|
|
21
21
|
const DEFAULT_BUDGET = 4000;
|
|
@@ -92,6 +92,8 @@ function units(lines, kind) {
|
|
|
92
92
|
}
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
|
+
const isHigh = (c) => c >= 0xd800 && c <= 0xdbff;
|
|
96
|
+
const isLow = (c) => c >= 0xdc00 && c <= 0xdfff;
|
|
95
97
|
/** Split one source into chunks of at most `maxTokens`, along its own structure. */
|
|
96
98
|
export function chunkSource(source, opts = {}) {
|
|
97
99
|
const maxTokens = Math.max(50, opts.maxChunkTokens ?? DEFAULT_CHUNK_TOKENS);
|
|
@@ -105,7 +107,11 @@ export function chunkSource(source, opts = {}) {
|
|
|
105
107
|
source.text.replace(/\r\n?/g, "\n").split("\n").forEach((line, n) => {
|
|
106
108
|
while (line.length > maxChars) {
|
|
107
109
|
const cut = line.lastIndexOf(" ", maxChars);
|
|
108
|
-
|
|
110
|
+
let at = cut > maxChars / 2 ? cut + 1 : maxChars;
|
|
111
|
+
// Indexes are UTF-16 units: a cut between the two halves of an emoji or
|
|
112
|
+
// other astral character would leave a lone surrogate on each side.
|
|
113
|
+
if (isHigh(line.charCodeAt(at - 1)) && isLow(line.charCodeAt(at)))
|
|
114
|
+
at--;
|
|
109
115
|
lines.push(line.slice(0, at));
|
|
110
116
|
lineNo.push(n + 1);
|
|
111
117
|
line = line.slice(at);
|
|
@@ -329,11 +335,6 @@ export function renderPack(r, { outlineLimit = 60 } = {}) {
|
|
|
329
335
|
}
|
|
330
336
|
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "target", "__pycache__", ".venv", "venv", "vendor"]);
|
|
331
337
|
export const PACK_LIMITS = { files: 500, fileBytes: 5 * 1024 * 1024, totalBytes: 50 * 1024 * 1024 };
|
|
332
|
-
/**
|
|
333
|
-
* Read files and directories (recursively, skipping build and dependency
|
|
334
|
-
* folders, binaries, and anything past the size limits) into sources.
|
|
335
|
-
* `skipped` says what was left out and why, so nothing vanishes silently.
|
|
336
|
-
*/
|
|
337
338
|
/** Files git would consider part of the repository under `dir` (relative to it), or undefined outside git. */
|
|
338
339
|
function gitFiles(dir) {
|
|
339
340
|
try {
|
|
@@ -347,10 +348,37 @@ function gitFiles(dir) {
|
|
|
347
348
|
return undefined;
|
|
348
349
|
}
|
|
349
350
|
}
|
|
351
|
+
/** Git ignores something under `dir`: why a folder that has files can list none. */
|
|
352
|
+
function gitIgnoresSome(dir) {
|
|
353
|
+
// --directory names an ignored folder once instead of every file in it.
|
|
354
|
+
const r = spawnSync("git", ["-C", dir, "ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 30_000 });
|
|
355
|
+
return r.status === 0 && typeof r.stdout === "string" && r.stdout.length > 0;
|
|
356
|
+
}
|
|
357
|
+
/** `inner` is `root` itself or somewhere under it. */
|
|
358
|
+
function within(root, inner) {
|
|
359
|
+
const rel = relative(root, inner);
|
|
360
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel));
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Read files and directories (recursively, skipping build and dependency
|
|
364
|
+
* folders, binaries, and anything past the size limits) into sources.
|
|
365
|
+
* `skipped` says what was left out and why, so nothing vanishes silently.
|
|
366
|
+
*
|
|
367
|
+
* A path named here is read even through a symlink; a symlink met inside a
|
|
368
|
+
* folder is not followed, as git does not follow one either. A link out of
|
|
369
|
+
* the folder would pack files nobody named (a tracked link to /etc/hosts, or
|
|
370
|
+
* to a secret beside the repo), a link to a parent loops, and what a link
|
|
371
|
+
* inside points at is read under its own name (or left out by .gitignore).
|
|
372
|
+
*/
|
|
350
373
|
export function readSources(paths, cwd = process.cwd()) {
|
|
351
374
|
const sources = [];
|
|
352
375
|
const skipped = [];
|
|
353
376
|
let bytes = 0;
|
|
377
|
+
// Real paths already packed: a file reached twice ("." and "guide.md") is packed once, not twice under one id.
|
|
378
|
+
// Only packed ones: a binary passed over inside a folder and then named outright still gets its message.
|
|
379
|
+
const seen = new Set();
|
|
380
|
+
// Real location of the path being read: a link out of it is reported, not just dropped.
|
|
381
|
+
let root = "";
|
|
354
382
|
const visit = (p, top) => {
|
|
355
383
|
if (sources.length >= PACK_LIMITS.files) {
|
|
356
384
|
if (top || skipped.length < 20)
|
|
@@ -359,12 +387,24 @@ export function readSources(paths, cwd = process.cwd()) {
|
|
|
359
387
|
}
|
|
360
388
|
let st;
|
|
361
389
|
try {
|
|
362
|
-
st = statSync(p);
|
|
390
|
+
st = top ? statSync(p) : lstatSync(p);
|
|
363
391
|
}
|
|
364
392
|
catch {
|
|
365
393
|
skipped.push(`${p}: not found`);
|
|
366
394
|
return;
|
|
367
395
|
}
|
|
396
|
+
if (st.isSymbolicLink()) {
|
|
397
|
+
let target;
|
|
398
|
+
try {
|
|
399
|
+
target = realpathSync(p);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return; // dangling: nothing to read
|
|
403
|
+
}
|
|
404
|
+
if (!within(root, target))
|
|
405
|
+
skipped.push(`${p}: symlink pointing outside the folder (not followed)`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
368
408
|
if (st.isDirectory()) {
|
|
369
409
|
if (!top && (SKIP_DIRS.has(basename(p)) || basename(p).startsWith(".")))
|
|
370
410
|
return;
|
|
@@ -373,6 +413,11 @@ export function readSources(paths, cwd = process.cwd()) {
|
|
|
373
413
|
// data dumps and secrets stay out. Elsewhere, walk with SKIP_DIRS.
|
|
374
414
|
const listed = top ? gitFiles(p) : undefined;
|
|
375
415
|
if (listed) {
|
|
416
|
+
// A folder named outright that git lists nothing in is still not read
|
|
417
|
+
// (that is the .gitignore guarantee), but the user hears why.
|
|
418
|
+
if (listed.length === 0 && gitIgnoresSome(p)) {
|
|
419
|
+
skipped.push(`${p}: ignored by git (.gitignore); pack reads folders as git does, so ignored files stay out. Name a file to pack it anyway`);
|
|
420
|
+
}
|
|
376
421
|
for (const f of listed)
|
|
377
422
|
visit(join(p, f), false);
|
|
378
423
|
return;
|
|
@@ -383,6 +428,15 @@ export function readSources(paths, cwd = process.cwd()) {
|
|
|
383
428
|
}
|
|
384
429
|
if (!st.isFile())
|
|
385
430
|
return;
|
|
431
|
+
let real;
|
|
432
|
+
try {
|
|
433
|
+
real = realpathSync(p);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
real = resolve(p);
|
|
437
|
+
}
|
|
438
|
+
if (seen.has(real))
|
|
439
|
+
return;
|
|
386
440
|
if (st.size > PACK_LIMITS.fileBytes || bytes + st.size > PACK_LIMITS.totalBytes) {
|
|
387
441
|
skipped.push(`${p}: too large (${Math.round(st.size / 1024)} KB)`);
|
|
388
442
|
return;
|
|
@@ -395,6 +449,7 @@ export function readSources(paths, cwd = process.cwd()) {
|
|
|
395
449
|
skipped.push(`${p}: ${got.error}`);
|
|
396
450
|
else {
|
|
397
451
|
bytes += st.size;
|
|
452
|
+
seen.add(real);
|
|
398
453
|
sources.push({ name, text: got.text });
|
|
399
454
|
}
|
|
400
455
|
return;
|
|
@@ -406,13 +461,21 @@ export function readSources(paths, cwd = process.cwd()) {
|
|
|
406
461
|
return;
|
|
407
462
|
}
|
|
408
463
|
bytes += st.size;
|
|
464
|
+
seen.add(real);
|
|
409
465
|
sources.push({ name, text: buf.toString("utf8") });
|
|
410
466
|
};
|
|
411
467
|
for (const p of paths) {
|
|
412
|
-
if (!existsSync(p))
|
|
468
|
+
if (!existsSync(p)) {
|
|
413
469
|
skipped.push(`${p}: not found`);
|
|
414
|
-
|
|
415
|
-
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
root = realpathSync(p);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
root = resolve(p);
|
|
477
|
+
}
|
|
478
|
+
visit(p, true);
|
|
416
479
|
}
|
|
417
480
|
return { sources, skipped };
|
|
418
481
|
}
|