@dzhechkov/harness-core 0.3.131 → 0.3.133

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/src/usage.ts CHANGED
@@ -28,7 +28,7 @@
28
28
  * @packageDocumentation
29
29
  */
30
30
 
31
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
31
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
32
32
  import { homedir } from 'node:os';
33
33
  import { join } from 'node:path';
34
34
 
@@ -38,6 +38,13 @@ const DEFAULT_WEEKLY_RESET_ANCHOR = 'Wed 08:59';
38
38
  // mtime prefilter slack (+1h) — guards against clock skew between the writer and this reader.
39
39
  const MTIME_SLACK_MS = HOUR_MS;
40
40
 
41
+ /**
42
+ * Per-token price ratios relative to base input, used to turn a raw token mix into INPUT-EQUIVALENT
43
+ * tokens. Without this the metric is ~90-99% cache-read and measures context size, not work.
44
+ * (Anthropic list pricing: 5m cache write 1.25x input, cache read 0.1x input, output 5x input.)
45
+ */
46
+ export const TOKEN_WEIGHTS = { input: 1, cacheWrite: 1.25, cacheWrite1h: 2, cacheRead: 0.1, output: 5 } as const;
47
+
41
48
  export const CLAUDE_USAGE_MODELS = ['fable', 'opus', 'sonnet', 'haiku'] as const;
42
49
  export type ClaudeUsageModel = (typeof CLAUDE_USAGE_MODELS)[number];
43
50
 
@@ -287,6 +294,20 @@ interface Sample {
287
294
  * List every `*.jsonl` under `~/.claude/projects/<dir>/`, best-effort. Never throws — an
288
295
  * unreadable dir/file is skipped. Returns absolute paths + their `mtimeMs` (the prefilter lever).
289
296
  */
297
+ /** A hard bound: a pathological tree must degrade to a partial estimate, never to a hang or an OOM. */
298
+ const MAX_TRANSCRIPT_FILES = 20_000;
299
+
300
+ /** Only a REGULAR file is readable transcript data. lstat (not stat) so a symlink is never followed —
301
+ * a FIFO blocks readFileSync forever and a symlink to a huge file explodes memory. */
302
+ function regularFileMtime(p: string): number | null {
303
+ try {
304
+ const st = lstatSync(p);
305
+ return st.isFile() ? st.mtimeMs : null;
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+
290
311
  function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: number }> {
291
312
  const out: Array<{ path: string; mtimeMs: number }> = [];
292
313
  let dirs: string[];
@@ -307,13 +328,29 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
307
328
  continue;
308
329
  }
309
330
  for (const f of files) {
310
- if (!f.endsWith('.jsonl')) continue;
311
- const p = join(projDir, f);
312
- try {
313
- out.push({ path: p, mtimeMs: statSync(p).mtimeMs });
314
- } catch {
315
- // skip a file we cannot stat
331
+ // A session's SUBAGENT transcripts live one level deeper — `<session>/subagents/*.jsonl` — and
332
+ // carry real, non-duplicated usage that was silently excluded (MEASURED: 27 such files on this
333
+ // machine). Their tokens are spent exactly like the main loop's.
334
+ if (!f.endsWith('.jsonl')) {
335
+ const nested = join(projDir, f, 'subagents');
336
+ try {
337
+ if (!statSync(nested).isDirectory()) continue;
338
+ for (const sf of readdirSync(nested)) {
339
+ if (!sf.endsWith('.jsonl')) continue;
340
+ if (out.length >= MAX_TRANSCRIPT_FILES) break;
341
+ const sp = join(nested, sf);
342
+ const m = regularFileMtime(sp);
343
+ if (m !== null) out.push({ path: sp, mtimeMs: m });
344
+ }
345
+ } catch {
346
+ /* not a session dir — skip */
347
+ }
348
+ continue;
316
349
  }
350
+ if (out.length >= MAX_TRANSCRIPT_FILES) break;
351
+ const p = join(projDir, f);
352
+ const mt = regularFileMtime(p);
353
+ if (mt !== null) out.push({ path: p, mtimeMs: mt });
317
354
  }
318
355
  }
319
356
  return out;
@@ -344,6 +381,9 @@ function extractSamples(path: string, scanCutoff: number, into: Sample[], seen:
344
381
  usage?: {
345
382
  input_tokens?: unknown;
346
383
  cache_creation_input_tokens?: unknown;
384
+ /** TTL breakdown: 5m writes price at 1.25x, 1h writes at 2x. A nested-only record used to
385
+ * count as ZERO because only the flat field was read. */
386
+ cache_creation?: { ephemeral_5m_input_tokens?: unknown; ephemeral_1h_input_tokens?: unknown };
347
387
  cache_read_input_tokens?: unknown;
348
388
  output_tokens?: unknown;
349
389
  };
@@ -362,20 +402,33 @@ function extractSamples(path: string, scanCutoff: number, into: Sample[], seen:
362
402
  if (!isFinite(ts)) continue;
363
403
  if (ts < scanCutoff) continue;
364
404
  const n = (v: unknown): number => (typeof v === 'number' && isFinite(v) && v > 0 ? v : 0);
405
+ // COST-WEIGHTED, not a flat sum. A flat sum is 89-99.7% `cache_read` (MEASURED on this machine),
406
+ // which grows with CONVERSATION LENGTH rather than with work done — two sessions doing identical
407
+ // work differ by orders of magnitude, so no threshold over it can mean anything. These weights are
408
+ // the published per-token price ratios relative to base input, so the total is "input-equivalent
409
+ // tokens": a quantity that tracks consumption instead of context size.
410
+ // Prefer the TTL breakdown when present (5m 1.25x / 1h 2x); fall back to the flat field at the
411
+ // 5m rate. Reading only the flat field scored a nested-only record as ZERO.
412
+ const c5 = n(usage.cache_creation?.ephemeral_5m_input_tokens);
413
+ const c1h = n(usage.cache_creation?.ephemeral_1h_input_tokens);
414
+ const cacheWriteCost =
415
+ c5 + c1h > 0
416
+ ? c5 * TOKEN_WEIGHTS.cacheWrite + c1h * TOKEN_WEIGHTS.cacheWrite1h
417
+ : n(usage.cache_creation_input_tokens) * TOKEN_WEIGHTS.cacheWrite;
365
418
  const tokens =
366
- n(usage.input_tokens) +
367
- n(usage.cache_creation_input_tokens) +
368
- n(usage.cache_read_input_tokens) +
369
- n(usage.output_tokens);
419
+ n(usage.input_tokens) * TOKEN_WEIGHTS.input +
420
+ cacheWriteCost +
421
+ n(usage.cache_read_input_tokens) * TOKEN_WEIGHTS.cacheRead +
422
+ n(usage.output_tokens) * TOKEN_WEIGHTS.output;
370
423
  if (tokens <= 0) continue;
371
424
  // Dedup: streamed assistant messages repeat their usage object across chunks.
372
425
  const id = typeof rec.message?.id === 'string' ? rec.message.id : '';
373
426
  const reqId = typeof rec.requestId === 'string' ? rec.requestId : '';
374
- const key = id + ':' + reqId;
375
- if (id !== '' || reqId !== '') {
376
- if (seen.has(key)) continue;
377
- seen.add(key);
378
- }
427
+ // With no ids, fall back to a CONTENT key (timestamp + weighted total): the same record copied
428
+ // into both a main and a subagent transcript would otherwise be counted twice.
429
+ const key = id !== '' || reqId !== '' ? id + ':' + reqId : `anon:${ts}:${tokens}`;
430
+ if (seen.has(key)) continue;
431
+ seen.add(key);
379
432
  into.push({ ts, tokens, key, model: normalizeClaudeUsageModel(rec.message?.model ?? rec.model) });
380
433
  }
381
434
  }