context-doctor 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +25 -25
  2. package/dist/autoclear.d.ts +58 -7
  3. package/dist/autoclear.js +155 -30
  4. package/dist/autopilot.d.ts +23 -3
  5. package/dist/autopilot.js +100 -29
  6. package/dist/cache.d.ts +6 -2
  7. package/dist/cache.js +48 -23
  8. package/dist/cacheplan.d.ts +5 -3
  9. package/dist/cacheplan.js +46 -8
  10. package/dist/calibration.d.ts +7 -2
  11. package/dist/calibration.js +14 -3
  12. package/dist/ci.js +25 -5
  13. package/dist/cli.js +61 -26
  14. package/dist/coldresume.d.ts +32 -7
  15. package/dist/coldresume.js +148 -34
  16. package/dist/compactquality.js +34 -12
  17. package/dist/compactwindow.js +59 -34
  18. package/dist/config.d.ts +6 -0
  19. package/dist/config.js +4 -1
  20. package/dist/cursor.d.ts +9 -0
  21. package/dist/cursor.js +53 -22
  22. package/dist/dashboard.d.ts +10 -0
  23. package/dist/dashboard.js +27 -2
  24. package/dist/diff.js +10 -3
  25. package/dist/doctor.js +133 -18
  26. package/dist/embed.d.ts +9 -0
  27. package/dist/embed.js +40 -5
  28. package/dist/experiment.d.ts +1 -0
  29. package/dist/experiment.js +6 -3
  30. package/dist/hook.js +23 -4
  31. package/dist/impact.d.ts +32 -0
  32. package/dist/impact.js +45 -6
  33. package/dist/install.d.ts +34 -2
  34. package/dist/install.js +223 -30
  35. package/dist/ledger.js +9 -2
  36. package/dist/mcp.d.ts +1 -1
  37. package/dist/mcp.js +2 -2
  38. package/dist/mcpschema.d.ts +16 -1
  39. package/dist/mcpschema.js +95 -24
  40. package/dist/optimize.js +149 -64
  41. package/dist/overhead.js +57 -27
  42. package/dist/pack.d.ts +11 -0
  43. package/dist/pack.js +75 -12
  44. package/dist/parse.js +42 -18
  45. package/dist/pricing.d.ts +57 -5
  46. package/dist/pricing.js +94 -8
  47. package/dist/proxy.d.ts +13 -2
  48. package/dist/proxy.js +258 -43
  49. package/dist/reads.d.ts +1 -1
  50. package/dist/reads.js +61 -24
  51. package/dist/report.d.ts +10 -0
  52. package/dist/report.js +12 -4
  53. package/dist/savings.d.ts +20 -8
  54. package/dist/savings.js +79 -34
  55. package/dist/session.d.ts +37 -0
  56. package/dist/session.js +143 -6
  57. package/dist/split.js +6 -1
  58. package/dist/statusline.d.ts +3 -1
  59. package/dist/statusline.js +29 -17
  60. package/dist/subagentpay.d.ts +5 -2
  61. package/dist/subagentpay.js +50 -16
  62. package/dist/subagents.d.ts +7 -3
  63. package/dist/subagents.js +28 -8
  64. package/dist/tasks.d.ts +22 -7
  65. package/dist/tasks.js +47 -20
  66. package/dist/wrap.d.ts +6 -0
  67. package/dist/wrap.js +142 -6
  68. package/package.json +17 -2
package/dist/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.replace(/\s+/g, " ").trim()).digest("hex");
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
- * Replace the text of a message content value, preserving block structure.
133
- * tool_result blocks must keep their type and tool_use_id — the Anthropic API
134
- * rejects conversations where a tool_use has no matching tool_result — so the
135
- * replacement text goes INSIDE the first tool_result/text block rather than
136
- * replacing the block itself. Later text blocks are dropped; other block types
137
- * (tool_use, image) pass through untouched.
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 replaceText(content, newText) {
140
- if (typeof content === "string" || !Array.isArray(content))
141
- return newText;
142
- let placed = false;
143
- const out = content
144
- .map((b) => {
145
- if (b?.type === "tool_result") {
146
- const replaced = { ...b, content: placed ? "[removed]" : newText };
147
- placed = true;
148
- return replaced;
149
- }
150
- if (b?.type === "text") {
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 { ...b, text: newText };
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
- .filter(Boolean);
159
- if (!placed)
160
- out.push({ type: "text", text: newText });
161
- return out;
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.messages;
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
- const cleaned = stripBase64Blobs(text);
252
- const saved = before - tok(cleaned);
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 = replaceText(m.content, cleaned);
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
- const text = textOf(m.content);
271
- if (text.length < 300)
272
- return;
273
- if (i >= cutoff) {
274
- // Still record it, so a later duplicate can point back here.
275
- if (!seen.has(hash(text)))
276
- seen.set(hash(text), i);
277
- return;
278
- }
279
- const h = hash(text);
280
- const first = seen.get(h);
281
- if (first === undefined) {
282
- seen.set(h, i);
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
- const saved = tok(text);
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
- const text = textOf(m.content);
297
- const before = tok(text);
298
- if (before <= opts.maxToolResultTokens)
299
- return;
300
- const trimmed = truncateToTokens(text, opts.maxToolResultTokens, model);
301
- // The truncation notice has a length of its own, so a result only just
302
- // over the budget can come back LARGER than it went in. Measured on a
303
- // real session: 2,941 tokens "optimized" to 2,947.
304
- if (tok(trimmed) >= before)
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 = replaceText(m.content, trimmed);
371
+ m.content = content;
307
372
  applied.push({
308
373
  strategy: "trim-tool-results",
309
374
  messageIndex: i,
310
- tokensSaved: before - tok(trimmed),
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
- tc.function.arguments = truncateToTokens(args, opts.maxToolResultTokens, model);
356
- saved += before - tok(tc.function.arguments);
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
- if (opts.strategies.includes("prune-history") && messages.length > opts.keepRecent * 2) {
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(0, keepFrom);
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(0, keepFrom, stub);
484
+ messages.splice(lead, keepFrom - lead, stub);
400
485
  applied.push({
401
486
  strategy: "prune-history",
402
- messageIndex: 0,
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
- /** Claude Code: usage rows on assistant messages (one per API reply id). */
151
- function claudeUsage(path) {
152
- const out = { agent: "Claude Code", reqs: [] };
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 lastId;
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" && out.first === undefined && firstUser === undefined && !e.isMeta) {
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 || e.message.id === lastId)
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
- out.first ??= Math.max(0, prompt - estimateTokens(firstUser ?? "", CLAUDE));
180
- out.reqs.push({ at, model: e.message.model });
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
- /** Cache writes cost 1.25x input on Anthropic; OpenAI and Google charge no write premium. */
248
- function writeMultiplier(model) {
249
- return providerFor(model) === "anthropic" ? 1.25 : 1;
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, warm = 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
- if (prevAt === undefined || r.at - prevAt > TTL_MS)
285
+ const cold = prevAt === undefined || r.at - prevAt > TTL_MS;
286
+ if (cold)
263
287
  coldStarts++;
264
- else
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
- const usdPerKPerMonth = price
280
- ? ((1000 * (warm * price.cacheReadPerM + coldStarts * writeMultiplier(model) * price.inputPerM)) / 1e6) * (30 / days)
281
- : undefined;
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
- const at = cut > maxChars / 2 ? cut + 1 : maxChars;
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
- else
415
- visit(p, true);
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
  }