@dzhechkov/harness-core 0.3.134 → 0.3.136

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.
@@ -12,13 +12,31 @@
12
12
  export const RECALL_USAGE_LOG_RELATIVE = '.dz/recall-usage.jsonl';
13
13
  export const RECALL_USAGE_LOG_MAX_BYTES = 1_048_576;
14
14
  export const RECALL_USAGE_COMPACT_TARGET_BYTES = Math.floor(RECALL_USAGE_LOG_MAX_BYTES * 0.75);
15
+ /** Newest query-bearing read rows survive compaction verbatim — they are the replay corpus. */
16
+ export const RECALL_USAGE_REPLAY_KEEP = 500;
15
17
 
16
18
  export interface RecallUsageReadRecord {
17
19
  readonly dzId: string;
18
20
  readonly score: number;
19
21
  readonly ts: string;
22
+ /**
23
+ * The PROMPT the lesson was injected into (truncated). Without it the log can say a lesson was
24
+ * used but never say FOR WHAT — which made cold-vs-warm replay unbuildable from 39 recorded
25
+ * events (MEASURED, compounding data inventory 2026-07-28). `.dz/` is git-ignored, so a truncated
26
+ * query stays on this machine.
27
+ */
28
+ readonly query?: string;
29
+ /** The session/run the injection happened in — lets replay group events into runs. */
30
+ readonly runId?: string;
31
+ /** One id per PROMPT (the hook writes one row per injected hit — up to 3 per prompt). */
32
+ readonly eventId?: string;
33
+ /** True when the stored query is a PREFIX of the real prompt — not replayable. */
34
+ readonly queryTruncated?: boolean;
20
35
  }
21
36
 
37
+ /** Query text is capped so a pasted wall of text cannot bloat the log. */
38
+ export const RECALL_USAGE_QUERY_MAX_CHARS = 200;
39
+
22
40
  export interface RecallUsageAggregateRecord {
23
41
  readonly kind: 'aggregate';
24
42
  readonly dzId: string;
@@ -90,6 +108,10 @@ export function formatRecallUsageRecord(input: {
90
108
  readonly dzId?: unknown;
91
109
  readonly score?: unknown;
92
110
  readonly ts?: unknown;
111
+ readonly query?: unknown;
112
+ readonly runId?: unknown;
113
+ readonly eventId?: unknown;
114
+ readonly queryTruncated?: unknown;
93
115
  }): string | undefined {
94
116
  const rec = normalizeReadRecord(input);
95
117
  return rec === undefined ? undefined : `${JSON.stringify(rec)}\n`;
@@ -198,8 +220,16 @@ export function compactRecallUsageLog(
198
220
  const maxBytes = validMax(opts.maxBytes ?? RECALL_USAGE_LOG_MAX_BYTES);
199
221
  const targetBytes = validTarget(opts.targetBytes ?? Math.floor(maxBytes * 0.75), maxBytes);
200
222
  const compactedAt = validTs(opts.compactedAt) ? opts.compactedAt : new Date(0).toISOString();
201
- const stats = aggregateRecallUsage(parseRecallUsageLog(text).records);
202
- const lines = stats.map((s) => aggregateLine(s, compactedAt));
223
+ const parsed = parseRecallUsageLog(text).records;
224
+ const stats = aggregateRecallUsage(parsed);
225
+ // The query-bearing read rows are the ONLY inputs a cold-vs-warm replay has — aggregating them
226
+ // away silently reset the accruing corpus at the size threshold (Codex #4). Keep the NEWEST ones
227
+ // (bounded) verbatim alongside the aggregates.
228
+ const replayRows = parsed
229
+ .filter((r): r is RecallUsageReadRecord => !('kind' in r) && typeof (r as RecallUsageReadRecord).query === 'string')
230
+ .sort((a, b) => a.ts.localeCompare(b.ts))
231
+ .slice(-RECALL_USAGE_REPLAY_KEEP);
232
+ const lines = [...stats.map((s) => aggregateLine(s, compactedAt)), ...replayRows.map((r) => JSON.stringify(r))];
203
233
  let out = joinLines(lines);
204
234
  if (byteLength(out) <= maxBytes) return out;
205
235
 
@@ -232,7 +262,19 @@ function normalizeReadRecord(value: unknown): RecallUsageReadRecord | undefined
232
262
  if (typeof dzId !== 'string' || dzId.trim() === '') return undefined;
233
263
  if (typeof score !== 'number' || !Number.isFinite(score)) return undefined;
234
264
  if (!validTs(ts)) return undefined;
235
- return { dzId: dzId.trim(), score, ts };
265
+ const query = value['query'];
266
+ const runId = value['runId'];
267
+ return {
268
+ dzId: dzId.trim(),
269
+ score,
270
+ ts,
271
+ ...(typeof query === 'string' && query.trim() !== ''
272
+ ? { query: query.slice(0, RECALL_USAGE_QUERY_MAX_CHARS) }
273
+ : {}),
274
+ ...(typeof runId === 'string' && runId.trim() !== '' ? { runId: runId.trim() } : {}),
275
+ ...(typeof value['eventId'] === 'string' && (value['eventId'] as string).trim() !== '' ? { eventId: (value['eventId'] as string).trim() } : {}),
276
+ ...(value['queryTruncated'] === true ? { queryTruncated: true } : {}),
277
+ };
236
278
  }
237
279
 
238
280
  function normalizeAggregateRecord(value: Record<string, unknown>): RecallUsageAggregateRecord | undefined {
package/src/usage.ts CHANGED
@@ -321,7 +321,8 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
321
321
  const projDir = join(root, d);
322
322
  let files: string[];
323
323
  try {
324
- const st = statSync(projDir);
324
+ // lstat, not stat: a symlinked project directory would otherwise be walked (Codex #3).
325
+ const st = lstatSync(projDir);
325
326
  if (!st.isDirectory()) continue;
326
327
  files = readdirSync(projDir);
327
328
  } catch {
@@ -334,10 +335,9 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
334
335
  if (!f.endsWith('.jsonl')) {
335
336
  const nested = join(projDir, f, 'subagents');
336
337
  try {
337
- if (!statSync(nested).isDirectory()) continue;
338
+ if (!lstatSync(nested).isDirectory()) continue; // no symlinked session/subagent dirs
338
339
  for (const sf of readdirSync(nested)) {
339
340
  if (!sf.endsWith('.jsonl')) continue;
340
- if (out.length >= MAX_TRANSCRIPT_FILES) break;
341
341
  const sp = join(nested, sf);
342
342
  const m = regularFileMtime(sp);
343
343
  if (m !== null) out.push({ path: sp, mtimeMs: m });
@@ -347,12 +347,17 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
347
347
  }
348
348
  continue;
349
349
  }
350
- if (out.length >= MAX_TRANSCRIPT_FILES) break;
351
350
  const p = join(projDir, f);
352
351
  const mt = regularFileMtime(p);
353
352
  if (mt !== null) out.push({ path: p, mtimeMs: mt });
354
353
  }
355
354
  }
355
+ // Cap by RECENCY, not by enumeration order: capping as we walked could discard the very files that
356
+ // hold current usage while keeping ancient ones (Codex #1).
357
+ if (out.length > MAX_TRANSCRIPT_FILES) {
358
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
359
+ out.length = MAX_TRANSCRIPT_FILES;
360
+ }
356
361
  return out;
357
362
  }
358
363
 
@@ -426,7 +431,12 @@ function extractSamples(path: string, scanCutoff: number, into: Sample[], seen:
426
431
  const reqId = typeof rec.requestId === 'string' ? rec.requestId : '';
427
432
  // With no ids, fall back to a CONTENT key (timestamp + weighted total): the same record copied
428
433
  // into both a main and a subagent transcript would otherwise be counted twice.
429
- const key = id !== '' || reqId !== '' ? id + ':' + reqId : `anon:${ts}:${tokens}`;
434
+ // Include the raw vector + model: `{input:50}` and `{output:10}` both weigh 50, so a
435
+ // total-only key silently merged distinct records (Codex #4).
436
+ const key =
437
+ id !== '' || reqId !== ''
438
+ ? id + ':' + reqId
439
+ : `anon:${ts}:${n(usage.input_tokens)}:${n(usage.cache_creation_input_tokens)}:${n(usage.cache_read_input_tokens)}:${n(usage.output_tokens)}:${String(rec.message?.model ?? rec.model ?? '')}`;
430
440
  if (seen.has(key)) continue;
431
441
  seen.add(key);
432
442
  into.push({ ts, tokens, key, model: normalizeClaudeUsageModel(rec.message?.model ?? rec.model) });
@@ -487,6 +497,12 @@ function configuredModelLimits(
487
497
  * (all projects). `now` is injectable for tests.
488
498
  */
489
499
  export function computeUsage(projectRoot: string, now?: number): UsageEstimate {
500
+ // never-throw contract: a non-finite or out-of-range clock reached `toISOString()` and raised
501
+ // RangeError. Clamp to the valid Date range instead of crashing the statusline (Codex #5).
502
+ const MAX_TIME = 8.64e15;
503
+ if (now !== undefined && (!isFinite(now) || Math.abs(now) > MAX_TIME)) {
504
+ now = Date.now();
505
+ }
490
506
  const nowMs = typeof now === 'number' && isFinite(now) ? now : Date.now();
491
507
  const limits = readUsageLimits(projectRoot);
492
508
  const sessionBlockHours = limits.sessionBlockHours ?? DEFAULT_SESSION_BLOCK_HOURS;