@dzhechkov/harness-core 0.8.30 → 0.8.31
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/.dz-manifest.json +233 -93
- package/README.md +269 -0
- package/dist/agentdb-index.d.ts +45 -0
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +345 -85
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-reindex-marker.d.ts +170 -0
- package/dist/agentdb-reindex-marker.d.ts.map +1 -0
- package/dist/agentdb-reindex-marker.js +293 -0
- package/dist/agentdb-reindex-marker.js.map +1 -0
- package/dist/agentdb-snapshot-rotation.d.ts +169 -0
- package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
- package/dist/agentdb-snapshot-rotation.js +322 -0
- package/dist/agentdb-snapshot-rotation.js.map +1 -0
- package/dist/agentdb-snapshot.d.ts +126 -0
- package/dist/agentdb-snapshot.d.ts.map +1 -0
- package/dist/agentdb-snapshot.js +195 -0
- package/dist/agentdb-snapshot.js.map +1 -0
- package/dist/apply-leg.d.ts +202 -0
- package/dist/apply-leg.d.ts.map +1 -0
- package/dist/apply-leg.js +975 -0
- package/dist/apply-leg.js.map +1 -0
- package/dist/book-kb.d.ts.map +1 -1
- package/dist/book-kb.js +57 -1
- package/dist/book-kb.js.map +1 -1
- package/dist/brain.d.ts +3 -0
- package/dist/brain.d.ts.map +1 -1
- package/dist/brain.js +1 -1
- package/dist/brain.js.map +1 -1
- package/dist/cadence.d.ts +30 -1
- package/dist/cadence.d.ts.map +1 -1
- package/dist/cadence.js +66 -2
- package/dist/cadence.js.map +1 -1
- package/dist/guard.d.ts +14 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +36 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +23 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -5
- package/dist/index.js.map +1 -1
- package/dist/integration-probe-worker.d.ts.map +1 -1
- package/dist/integration-probe-worker.js +4 -6
- package/dist/integration-probe-worker.js.map +1 -1
- package/dist/integrations-verify.d.ts.map +1 -1
- package/dist/integrations-verify.js +3 -1
- package/dist/integrations-verify.js.map +1 -1
- package/dist/managed-hooks.d.ts +11 -2
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +33 -7
- package/dist/managed-hooks.js.map +1 -1
- package/dist/mutation-gate.d.ts +35 -1
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +49 -9
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +94 -0
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +23 -4
- package/dist/patterns.js.map +1 -1
- package/dist/repository-origin.d.ts +1 -1
- package/dist/repository-origin.d.ts.map +1 -1
- package/dist/repository-origin.js +7 -1
- package/dist/repository-origin.js.map +1 -1
- package/dist/round-exec.d.ts +41 -0
- package/dist/round-exec.d.ts.map +1 -0
- package/dist/round-exec.js +45 -0
- package/dist/round-exec.js.map +1 -0
- package/dist/round.d.ts +109 -0
- package/dist/round.d.ts.map +1 -0
- package/dist/round.js +148 -0
- package/dist/round.js.map +1 -0
- package/dist/run-records.d.ts +8 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +69 -3
- package/dist/run-records.js.map +1 -1
- package/dist/setup.d.ts +8 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +166 -3
- package/dist/setup.js.map +1 -1
- package/dist/sqlite-read-helpers.d.ts +60 -0
- package/dist/sqlite-read-helpers.d.ts.map +1 -0
- package/dist/sqlite-read-helpers.js +118 -0
- package/dist/sqlite-read-helpers.js.map +1 -0
- package/dist/statusline.d.ts +20 -3
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +40 -7
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +30 -0
- package/dist/store-counts.d.ts.map +1 -1
- package/dist/store-counts.js +189 -0
- package/dist/store-counts.js.map +1 -1
- package/dist/usage.d.ts +55 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +184 -33
- package/dist/usage.js.map +1 -1
- package/dist/vector-tier.d.ts +18 -1
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +26 -2
- package/dist/vector-tier.js.map +1 -1
- package/package.json +6 -6
- package/sbom.json +442 -92
- package/src/agentdb-index.ts +315 -32
- package/src/agentdb-reindex-marker.ts +330 -0
- package/src/agentdb-snapshot-rotation.ts +393 -0
- package/src/agentdb-snapshot.ts +222 -0
- package/src/apply-leg.ts +1046 -0
- package/src/book-kb.ts +64 -1
- package/src/brain.ts +11 -2
- package/src/cadence.ts +87 -2
- package/src/guard.ts +46 -0
- package/src/index.ts +58 -5
- package/src/integration-probe-worker.ts +4 -6
- package/src/integrations-verify.ts +3 -1
- package/src/managed-hooks.ts +32 -7
- package/src/mutation-gate.ts +87 -8
- package/src/operations.ts +89 -0
- package/src/patterns.ts +23 -4
- package/src/repository-origin.ts +7 -1
- package/src/round-exec.ts +85 -0
- package/src/round.ts +228 -0
- package/src/run-records.ts +77 -2
- package/src/setup.ts +198 -3
- package/src/sqlite-read-helpers.ts +128 -0
- package/src/statusline.ts +57 -9
- package/src/store-counts.ts +245 -1
- package/src/usage.ts +243 -29
- package/src/vector-tier.ts +45 -4
package/src/usage.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* @packageDocumentation
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
31
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
32
32
|
import { homedir } from 'node:os';
|
|
33
33
|
import { join } from 'node:path';
|
|
34
34
|
|
|
@@ -200,6 +200,173 @@ export interface UsageEstimate {
|
|
|
200
200
|
readonly estimatesNotForRouting?: { readonly sessionPct: number | null; readonly weeklyPct: number | null };
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
export interface SpendReport {
|
|
204
|
+
readonly days: ReadonlyArray<{
|
|
205
|
+
readonly date: string;
|
|
206
|
+
readonly weightedTokens: number;
|
|
207
|
+
readonly input: number;
|
|
208
|
+
readonly output: number;
|
|
209
|
+
readonly cacheRead: number;
|
|
210
|
+
readonly cacheWrite: number;
|
|
211
|
+
readonly events: number;
|
|
212
|
+
}>;
|
|
213
|
+
readonly total7d: {
|
|
214
|
+
readonly weightedTokens: number;
|
|
215
|
+
readonly input: number;
|
|
216
|
+
readonly output: number;
|
|
217
|
+
readonly cacheRead: number;
|
|
218
|
+
readonly cacheWrite: number;
|
|
219
|
+
readonly events: number;
|
|
220
|
+
};
|
|
221
|
+
readonly byModel: Readonly<Record<string, {
|
|
222
|
+
readonly weightedTokens: number;
|
|
223
|
+
readonly sharePct: number;
|
|
224
|
+
}>>;
|
|
225
|
+
/**
|
|
226
|
+
* Per-day model breakdown — same events, same `unknown` fallback as {@link byModel}, just not
|
|
227
|
+
* yet collapsed across the window. One entry per day in `days` (same order), so a caller can
|
|
228
|
+
* read "today by model" as `daysByModel.at(-1)`. FR-1/FR-2: `Σ daysByModel[i].models ===
|
|
229
|
+
* days[i].weightedTokens` for every day, checked by {@link spendInvariantViolations}.
|
|
230
|
+
*/
|
|
231
|
+
readonly daysByModel: ReadonlyArray<{
|
|
232
|
+
readonly date: string;
|
|
233
|
+
readonly models: Readonly<Record<string, number>>;
|
|
234
|
+
}>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
type ParsedSpendEvent = {
|
|
238
|
+
readonly ts: number;
|
|
239
|
+
readonly model: ClaudeUsageModel | null;
|
|
240
|
+
readonly raw: RawTokenMix;
|
|
241
|
+
readonly weightedTokens: number;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
245
|
+
|
|
246
|
+
function emptySpendTotal(): SpendReport['total7d'] {
|
|
247
|
+
return { weightedTokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, events: 0 };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Aggregate already-parsed transcript events into UTC calendar days. Pure: no fs, env, or clock reads. */
|
|
251
|
+
export function spendReport(
|
|
252
|
+
events: readonly ParsedSpendEvent[],
|
|
253
|
+
options: { readonly nowMs: number; readonly days: number },
|
|
254
|
+
): SpendReport {
|
|
255
|
+
if (!Number.isFinite(options.nowMs) || !Number.isInteger(options.days) || options.days <= 0) {
|
|
256
|
+
return { days: [], total7d: emptySpendTotal(), byModel: {}, daysByModel: [] };
|
|
257
|
+
}
|
|
258
|
+
const now = new Date(options.nowMs);
|
|
259
|
+
const todayStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
260
|
+
const startMs = todayStart - (options.days - 1) * DAY_MS;
|
|
261
|
+
const days = Array.from({ length: options.days }, (_, index) => ({
|
|
262
|
+
date: new Date(startMs + index * DAY_MS).toISOString().slice(0, 10),
|
|
263
|
+
...emptySpendTotal(),
|
|
264
|
+
}));
|
|
265
|
+
const daysByDate = new Map(days.map((day) => [day.date, day]));
|
|
266
|
+
const modelTotals = new Map<string, number>();
|
|
267
|
+
// Per-day model totals (FR-1) — same fallback-to-'unknown' rule as `modelTotals`, kept in a
|
|
268
|
+
// separate Map-of-Maps so the two aggregates stay independently derivable from the SAME loop
|
|
269
|
+
// (one pass over events, not two) and therefore can never drift apart by construction.
|
|
270
|
+
const dayModelTotals = new Map<string, Map<string, number>>(days.map((day) => [day.date, new Map()]));
|
|
271
|
+
|
|
272
|
+
for (const event of events) {
|
|
273
|
+
if (!Number.isFinite(event.ts) || event.ts < startMs || event.ts > options.nowMs) continue;
|
|
274
|
+
const date = new Date(event.ts).toISOString().slice(0, 10);
|
|
275
|
+
const day = daysByDate.get(date);
|
|
276
|
+
if (!day) continue;
|
|
277
|
+
day.weightedTokens += event.weightedTokens;
|
|
278
|
+
day.input += event.raw.input;
|
|
279
|
+
day.output += event.raw.output;
|
|
280
|
+
day.cacheRead += event.raw.cacheRead;
|
|
281
|
+
day.cacheWrite += event.raw.cacheWrite;
|
|
282
|
+
day.events += 1;
|
|
283
|
+
const model = event.model ?? 'unknown';
|
|
284
|
+
modelTotals.set(model, (modelTotals.get(model) ?? 0) + event.weightedTokens);
|
|
285
|
+
const dayModels = dayModelTotals.get(date);
|
|
286
|
+
if (dayModels) dayModels.set(model, (dayModels.get(model) ?? 0) + event.weightedTokens);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const total7d = days.reduce<SpendReport['total7d']>((total, day) => ({
|
|
290
|
+
weightedTokens: total.weightedTokens + day.weightedTokens,
|
|
291
|
+
input: total.input + day.input,
|
|
292
|
+
output: total.output + day.output,
|
|
293
|
+
cacheRead: total.cacheRead + day.cacheRead,
|
|
294
|
+
cacheWrite: total.cacheWrite + day.cacheWrite,
|
|
295
|
+
events: total.events + day.events,
|
|
296
|
+
}), emptySpendTotal());
|
|
297
|
+
const byModel: Record<string, { weightedTokens: number; sharePct: number }> = {};
|
|
298
|
+
for (const [model, weightedTokens] of [...modelTotals].sort(([a], [b]) => a.localeCompare(b))) {
|
|
299
|
+
byModel[model] = {
|
|
300
|
+
weightedTokens,
|
|
301
|
+
sharePct: total7d.weightedTokens > 0
|
|
302
|
+
? Math.round((weightedTokens / total7d.weightedTokens) * 1000) / 10
|
|
303
|
+
: 0,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const daysByModel = days.map((day) => {
|
|
307
|
+
const models: Record<string, number> = {};
|
|
308
|
+
const totals = dayModelTotals.get(day.date);
|
|
309
|
+
if (totals) {
|
|
310
|
+
for (const [model, weightedTokens] of [...totals].sort(([a], [b]) => a.localeCompare(b))) {
|
|
311
|
+
models[model] = weightedTokens;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { date: day.date, models };
|
|
315
|
+
});
|
|
316
|
+
return { days, total7d, byModel, daysByModel };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Fix-round-1 (Codex review, LOW #4): a mismatch is judged on a tolerance that SCALES with the
|
|
321
|
+
* magnitude being compared — `max(1e-6, 1e-9·max(|a|,|b|))`. A fixed absolute `1e-6` is too tight
|
|
322
|
+
* for large sums, where float addition accumulates noise proportional to magnitude, and it is
|
|
323
|
+
* blind to corruption: `Math.abs(NaN - x) > 1e-6` is `false`, so a NaN/Infinity sum used to read as
|
|
324
|
+
* "invariant holds" instead of the loud failure a corrupted report deserves.
|
|
325
|
+
*/
|
|
326
|
+
function invariantTolerance(a: number, b: number): number {
|
|
327
|
+
return Math.max(1e-6, 1e-9 * Math.max(Math.abs(a), Math.abs(b)));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** `true` when `a`/`b` cannot be meaningfully compared (either is `NaN` or `±Infinity`). */
|
|
331
|
+
function isNonFiniteMismatch(a: number, b: number): boolean {
|
|
332
|
+
return !Number.isFinite(a) || !Number.isFinite(b);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* FR-2 invariant checker: for every day, `Σ daysByModel[day].models === days[day].weightedTokens`;
|
|
337
|
+
* and `Σ byModel[*].weightedTokens === total7d.weightedTokens`. Pure, no fs/clock. Returns a
|
|
338
|
+
* human-readable violation per mismatch; an empty array means the invariant holds. The tolerance is
|
|
339
|
+
* relative ({@link invariantTolerance}), and a `NaN`/`Infinity` on either side of a comparison is
|
|
340
|
+
* ALWAYS a violation ({@link isNonFiniteMismatch}) — never a silent pass.
|
|
341
|
+
*/
|
|
342
|
+
export function spendInvariantViolations(report: SpendReport): string[] {
|
|
343
|
+
const violations: string[] = [];
|
|
344
|
+
const modelsByDate = new Map(report.daysByModel.map((entry) => [entry.date, entry.models]));
|
|
345
|
+
for (const day of report.days) {
|
|
346
|
+
const models = modelsByDate.get(day.date) ?? {};
|
|
347
|
+
const sum = Object.values(models).reduce((a, b) => a + b, 0);
|
|
348
|
+
const nonFinite = isNonFiniteMismatch(sum, day.weightedTokens);
|
|
349
|
+
if (nonFinite || Math.abs(sum - day.weightedTokens) > invariantTolerance(sum, day.weightedTokens)) {
|
|
350
|
+
violations.push(
|
|
351
|
+
`day ${day.date}: Σ daysByModel=${sum} !== days.weightedTokens=${day.weightedTokens}` +
|
|
352
|
+
(nonFinite ? ' (non-finite sum — NaN/Infinity corrupts the invariant)' : ''),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const byModelSum = Object.values(report.byModel).reduce((a, row) => a + row.weightedTokens, 0);
|
|
357
|
+
const byModelNonFinite = isNonFiniteMismatch(byModelSum, report.total7d.weightedTokens);
|
|
358
|
+
if (
|
|
359
|
+
byModelNonFinite ||
|
|
360
|
+
Math.abs(byModelSum - report.total7d.weightedTokens) > invariantTolerance(byModelSum, report.total7d.weightedTokens)
|
|
361
|
+
) {
|
|
362
|
+
violations.push(
|
|
363
|
+
`Σ byModel=${byModelSum} !== total7d.weightedTokens=${report.total7d.weightedTokens}` +
|
|
364
|
+
(byModelNonFinite ? ' (non-finite sum — NaN/Infinity corrupts the invariant)' : ''),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return violations;
|
|
368
|
+
}
|
|
369
|
+
|
|
203
370
|
export interface UsageCalibrationInput {
|
|
204
371
|
readonly sessionPct?: unknown;
|
|
205
372
|
readonly weeklyPct?: unknown;
|
|
@@ -359,23 +526,44 @@ export function normalizeClaudeUsageModelKey(raw: unknown): ClaudeUsageModel | n
|
|
|
359
526
|
return (CLAUDE_USAGE_MODELS as readonly string[]).includes(key) ? (key as ClaudeUsageModel) : null;
|
|
360
527
|
}
|
|
361
528
|
|
|
362
|
-
/**
|
|
363
|
-
function
|
|
529
|
+
/** Resolve one directory without revisiting a real path. Broken links and non-directories are skipped. */
|
|
530
|
+
function resolveDirectoryOnce(dir: string, visited: Set<string>): string | null {
|
|
531
|
+
try {
|
|
532
|
+
const entry = lstatSync(dir);
|
|
533
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) return null;
|
|
534
|
+
const realDir = realpathSync(dir);
|
|
535
|
+
if (entry.isSymbolicLink() && !statSync(realDir).isDirectory()) return null;
|
|
536
|
+
if (visited.has(realDir)) return null;
|
|
537
|
+
visited.add(realDir);
|
|
538
|
+
return realDir;
|
|
539
|
+
} catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Recursive .jsonl collector under a subagents tree — bounded depth and real-path deduplicated. */
|
|
545
|
+
function walkTranscriptTree(
|
|
546
|
+
dir: string,
|
|
547
|
+
depthLeft: number,
|
|
548
|
+
out: Array<{ path: string; mtimeMs: number }>,
|
|
549
|
+
visited: Set<string>,
|
|
550
|
+
): void {
|
|
364
551
|
if (depthLeft <= 0) return;
|
|
552
|
+
const realDir = resolveDirectoryOnce(dir, visited);
|
|
553
|
+
if (realDir === null) return;
|
|
365
554
|
let entries: string[];
|
|
366
555
|
try {
|
|
367
|
-
|
|
368
|
-
entries = readdirSync(dir);
|
|
556
|
+
entries = readdirSync(realDir);
|
|
369
557
|
} catch {
|
|
370
558
|
return;
|
|
371
559
|
}
|
|
372
560
|
for (const e of entries) {
|
|
373
|
-
const p = join(
|
|
561
|
+
const p = join(realDir, e);
|
|
374
562
|
if (e.endsWith('.jsonl')) {
|
|
375
563
|
const m = regularFileMtime(p);
|
|
376
564
|
if (m !== null) out.push({ path: p, mtimeMs: m });
|
|
377
565
|
} else {
|
|
378
|
-
walkTranscriptTree(p, depthLeft - 1, out);
|
|
566
|
+
walkTranscriptTree(p, depthLeft - 1, out, visited);
|
|
379
567
|
}
|
|
380
568
|
}
|
|
381
569
|
}
|
|
@@ -476,6 +664,7 @@ interface Sample {
|
|
|
476
664
|
readonly tokens: number;
|
|
477
665
|
readonly key: string; // dedup key: message.id + ':' + requestId
|
|
478
666
|
readonly model: ClaudeUsageModel | null;
|
|
667
|
+
readonly raw: RawTokenMix;
|
|
479
668
|
}
|
|
480
669
|
|
|
481
670
|
/**
|
|
@@ -498,6 +687,7 @@ function regularFileMtime(p: string): number | null {
|
|
|
498
687
|
|
|
499
688
|
function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: number }> {
|
|
500
689
|
const out: Array<{ path: string; mtimeMs: number }> = [];
|
|
690
|
+
const visitedDirectories = new Set<string>();
|
|
501
691
|
let dirs: string[];
|
|
502
692
|
try {
|
|
503
693
|
if (!existsSync(root)) return out;
|
|
@@ -507,12 +697,11 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
|
|
|
507
697
|
}
|
|
508
698
|
for (const d of dirs) {
|
|
509
699
|
const projDir = join(root, d);
|
|
700
|
+
const realProjDir = resolveDirectoryOnce(projDir, visitedDirectories);
|
|
701
|
+
if (realProjDir === null) continue;
|
|
510
702
|
let files: string[];
|
|
511
703
|
try {
|
|
512
|
-
|
|
513
|
-
const st = lstatSync(projDir);
|
|
514
|
-
if (!st.isDirectory()) continue;
|
|
515
|
-
files = readdirSync(projDir);
|
|
704
|
+
files = readdirSync(realProjDir);
|
|
516
705
|
} catch {
|
|
517
706
|
continue;
|
|
518
707
|
}
|
|
@@ -523,12 +712,12 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
|
|
|
523
712
|
// `subagents/workflows/wf_*/agent-*.jsonl` — one level deeper than the first fix reached —
|
|
524
713
|
// and that blind spot alone hid 283.62M weighted tokens across 551 files (MEASURED
|
|
525
714
|
// 2026-08-24, 7-day window, this machine). Depth 4 covers today's deepest layout plus one
|
|
526
|
-
// future level;
|
|
715
|
+
// future level; each real directory is visited once, so symlink aliases and loops stay bounded.
|
|
527
716
|
if (!f.endsWith('.jsonl')) {
|
|
528
|
-
walkTranscriptTree(join(
|
|
717
|
+
walkTranscriptTree(join(realProjDir, f, 'subagents'), 4, out, visitedDirectories);
|
|
529
718
|
continue;
|
|
530
719
|
}
|
|
531
|
-
const p = join(
|
|
720
|
+
const p = join(realProjDir, f);
|
|
532
721
|
const mt = regularFileMtime(p);
|
|
533
722
|
if (mt !== null) out.push({ path: p, mtimeMs: mt });
|
|
534
723
|
}
|
|
@@ -596,6 +785,7 @@ function extractSamples(path: string, scanCutoff: number, into: Sample[], seen:
|
|
|
596
785
|
// Prefer the TTL breakdown when present (5m 1.25x / 1h 2x); fall back to the flat field at the
|
|
597
786
|
// 5m rate. Reading only the flat field scored a nested-only record as ZERO.
|
|
598
787
|
// ONE estimator, shared with the per-stage cost ledger (feature `cost-ledger`, ADR-002).
|
|
788
|
+
const raw = rawTokenMixOf(usage);
|
|
599
789
|
const tokens = weightedTokensOf(usage);
|
|
600
790
|
if (tokens <= 0) continue;
|
|
601
791
|
// Dedup: streamed assistant messages repeat their usage object across chunks.
|
|
@@ -611,8 +801,45 @@ function extractSamples(path: string, scanCutoff: number, into: Sample[], seen:
|
|
|
611
801
|
: `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 ?? '')}`;
|
|
612
802
|
if (seen.has(key)) continue;
|
|
613
803
|
seen.add(key);
|
|
614
|
-
into.push({ ts, tokens, key, model: normalizeClaudeUsageModel(rec.message?.model ?? rec.model) });
|
|
804
|
+
into.push({ ts, tokens, key, model: normalizeClaudeUsageModel(rec.message?.model ?? rec.model), raw });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function scanSamples(scanCutoff: number): { samples: Sample[]; scanFileCount: number } {
|
|
809
|
+
const samples: Sample[] = [];
|
|
810
|
+
const seen = new Set<string>();
|
|
811
|
+
let scanFileCount = 0;
|
|
812
|
+
try {
|
|
813
|
+
const files = listTranscriptFiles(claudeProjectsRoot());
|
|
814
|
+
for (const file of files) {
|
|
815
|
+
if (file.mtimeMs < scanCutoff) continue;
|
|
816
|
+
scanFileCount += 1;
|
|
817
|
+
extractSamples(file.path, scanCutoff, samples, seen);
|
|
818
|
+
}
|
|
819
|
+
} catch {
|
|
820
|
+
// Best-effort transcript reads collapse to an empty report.
|
|
615
821
|
}
|
|
822
|
+
return { samples, scanFileCount };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Read local transcript events once, then delegate all aggregation to the pure spendReport. */
|
|
826
|
+
export function computeSpendReport(now?: number, days = 7): SpendReport {
|
|
827
|
+
const MAX_TIME = 8.64e15;
|
|
828
|
+
const nowMs = now === undefined || !Number.isFinite(now) || Math.abs(now) > MAX_TIME ? Date.now() : now;
|
|
829
|
+
const today = new Date(nowMs);
|
|
830
|
+
const todayStart = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
|
831
|
+
const validDays = Number.isInteger(days) && days > 0 ? days : 7;
|
|
832
|
+
const scanCutoff = todayStart - (validDays - 1) * DAY_MS - MTIME_SLACK_MS;
|
|
833
|
+
const { samples } = scanSamples(scanCutoff);
|
|
834
|
+
return spendReport(
|
|
835
|
+
samples.map((sample) => ({
|
|
836
|
+
ts: sample.ts,
|
|
837
|
+
model: sample.model,
|
|
838
|
+
raw: sample.raw,
|
|
839
|
+
weightedTokens: sample.tokens,
|
|
840
|
+
})),
|
|
841
|
+
{ nowMs, days: validDays },
|
|
842
|
+
);
|
|
616
843
|
}
|
|
617
844
|
|
|
618
845
|
function activeSessionBlock(
|
|
@@ -686,20 +913,7 @@ export function computeUsage(projectRoot: string, now?: number): UsageEstimate {
|
|
|
686
913
|
const weeklyScanCutoff = weeklyWindow?.startedAtMs ?? nowMs;
|
|
687
914
|
const scanCutoff = Math.min(sessionScanCutoff, weeklyScanCutoff) - MTIME_SLACK_MS;
|
|
688
915
|
|
|
689
|
-
const samples
|
|
690
|
-
const seen = new Set<string>();
|
|
691
|
-
let scanFileCount = 0;
|
|
692
|
-
try {
|
|
693
|
-
const files = listTranscriptFiles(claudeProjectsRoot());
|
|
694
|
-
for (const f of files) {
|
|
695
|
-
// mtime prefilter: a file last written before every relevant cutoff cannot contribute.
|
|
696
|
-
if (f.mtimeMs < scanCutoff) continue;
|
|
697
|
-
scanFileCount += 1;
|
|
698
|
-
extractSamples(f.path, scanCutoff, samples, seen);
|
|
699
|
-
}
|
|
700
|
-
} catch {
|
|
701
|
-
// total scan failure ⇒ fall through with empty samples (nulls), never throw
|
|
702
|
-
}
|
|
916
|
+
const { samples, scanFileCount } = scanSamples(scanCutoff);
|
|
703
917
|
|
|
704
918
|
let weeklyTokens = 0;
|
|
705
919
|
const weeklyTokensByModel: Partial<Record<ClaudeUsageModel, number>> = {};
|
package/src/vector-tier.ts
CHANGED
|
@@ -489,13 +489,39 @@ export function isVectorNoise(text: string): boolean {
|
|
|
489
489
|
return isNoiseInsight(text) || TOOL_TELEMETRY_RE.test(text);
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
/** One source of truth for records admitted to the vector mirror and its lexical comparison set. */
|
|
493
|
+
export function isMirrorableRecord(record: Pick<PatternRecord, 'pattern' | 'lessonForm'>): boolean {
|
|
494
|
+
return record.lessonForm !== 'class' && !isVectorNoise(record.pattern);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export interface MirrorQuarantineMetadata {
|
|
498
|
+
readonly qStatus?: 'quarantined';
|
|
499
|
+
readonly quarantinedAt?: string;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Pure lexical-record → vector-metadata projection used by every learned-pattern mirror writer. */
|
|
503
|
+
export function mirrorQuarantineOf(
|
|
504
|
+
record: Pick<PatternRecord, 'quarantined' | 'ts'> | Pick<MemoryRecord, 'metadata' | 'timestamp'>,
|
|
505
|
+
): MirrorQuarantineMetadata {
|
|
506
|
+
if ('timestamp' in record) {
|
|
507
|
+
const state = readQuarantineState(record as MemoryRecord);
|
|
508
|
+
return state.quarantined
|
|
509
|
+
? { qStatus: 'quarantined', ...(state.quarantinedAt === undefined ? {} : { quarantinedAt: state.quarantinedAt }) }
|
|
510
|
+
: {};
|
|
511
|
+
}
|
|
512
|
+
return record.quarantined === true
|
|
513
|
+
? { qStatus: 'quarantined', quarantinedAt: record.ts }
|
|
514
|
+
: {};
|
|
515
|
+
}
|
|
516
|
+
|
|
492
517
|
/**
|
|
493
518
|
* ACL: taught {@link PatternRecord} → {@link VectorEntry}. Returns `undefined` for noise (the
|
|
494
519
|
* ingest gate — I-6). Score is the record's REAL reward, never a fabricated 1.0.
|
|
495
520
|
*/
|
|
496
521
|
export function patternVectorEntry(p: PatternRecord, source = 'dz-teach', opts: { quarantined?: boolean } = {}): VectorEntry | undefined {
|
|
497
|
-
if (
|
|
522
|
+
if (!isMirrorableRecord(p)) return undefined;
|
|
498
523
|
const dzId = patternRecordId(p);
|
|
524
|
+
const quarantine = mirrorQuarantineOf(opts.quarantined === true ? { ...p, quarantined: true } : p);
|
|
499
525
|
return {
|
|
500
526
|
dzId,
|
|
501
527
|
text: p.pattern,
|
|
@@ -509,7 +535,7 @@ export function patternVectorEntry(p: PatternRecord, source = 'dz-teach', opts:
|
|
|
509
535
|
...(p.lessonForm !== undefined && p.lessonPairId !== undefined
|
|
510
536
|
? { lessonForm: p.lessonForm, lessonPairId: p.lessonPairId }
|
|
511
537
|
: {}),
|
|
512
|
-
...
|
|
538
|
+
...quarantine,
|
|
513
539
|
},
|
|
514
540
|
};
|
|
515
541
|
}
|
|
@@ -547,6 +573,7 @@ export function memoryRecordVectorEntry(r: MemoryRecord): VectorEntry | undefine
|
|
|
547
573
|
...(r.metadata?.['lessonForm'] === 'specific' && typeof r.metadata?.['lessonPairId'] === 'string'
|
|
548
574
|
? { lessonForm: 'specific', lessonPairId: r.metadata['lessonPairId'] }
|
|
549
575
|
: {}),
|
|
576
|
+
...mirrorQuarantineOf(r),
|
|
550
577
|
},
|
|
551
578
|
uses: state.uses,
|
|
552
579
|
avgReward: state.avgReward,
|
|
@@ -606,7 +633,16 @@ export type MirrorWriterState =
|
|
|
606
633
|
/** The config is readable and simply does not enable a mirror. */
|
|
607
634
|
| 'not-enabled'
|
|
608
635
|
/** The config explicitly turns the vector tier off. */
|
|
609
|
-
| 'engine-off'
|
|
636
|
+
| 'engine-off'
|
|
637
|
+
/**
|
|
638
|
+
* `.dz/config.json` has a TOP-LEVEL `backend` key (`{"backend":"agentdb"}`) instead of the real
|
|
639
|
+
* shape (`{"memory":{"backend":"agentdb"}}`) — issue #10 defect 6, AM-6 (feature
|
|
640
|
+
* `setup-installs-apply-leg`). `dz setup` never emits this shape (it always nests under
|
|
641
|
+
* `memory`), so this is a hand-written or foreign-tool-written config; distinguished from
|
|
642
|
+
* `not-enabled` because the reader typed the RIGHT intent in the WRONG place, and "no mirror
|
|
643
|
+
* configured" sends them to add a setting that is already there, just misplaced.
|
|
644
|
+
*/
|
|
645
|
+
| 'legacy-shape';
|
|
610
646
|
|
|
611
647
|
/**
|
|
612
648
|
* The mirror writer's state AND its real cause.
|
|
@@ -620,7 +656,7 @@ export type MirrorWriterState =
|
|
|
620
656
|
export function mirrorWriterReason(projectRoot: string): { enabled: boolean; state: MirrorWriterState } {
|
|
621
657
|
const path = join(projectRoot, '.dz', 'config.json');
|
|
622
658
|
if (!existsSync(path)) return { enabled: false, state: 'no-config' };
|
|
623
|
-
let cfg: { memory?: { backend?: string; vector?: { engine?: string } } };
|
|
659
|
+
let cfg: { backend?: string; memory?: { backend?: string; vector?: { engine?: string } } };
|
|
624
660
|
try {
|
|
625
661
|
cfg = JSON.parse(readFileSync(path, 'utf-8')) as typeof cfg;
|
|
626
662
|
} catch {
|
|
@@ -636,6 +672,10 @@ export function mirrorWriterReason(projectRoot: string): { enabled: boolean; sta
|
|
|
636
672
|
if (engine === 'off') return { enabled: false, state: 'engine-off' };
|
|
637
673
|
if (cfg.memory?.backend === 'agentdb') return { enabled: true, state: 'on' };
|
|
638
674
|
if (engine === 'agentdb' || engine === 'rvf') return { enabled: true, state: 'on' };
|
|
675
|
+
// Issue #10 defect 6 (AM-6): a TOP-LEVEL `backend` key is a real, readable intent to enable
|
|
676
|
+
// agentdb that this function used to silently ignore (it only ever looked under `memory`) —
|
|
677
|
+
// reported here as a NAMED cause, never folded into the generic `not-enabled` shrug.
|
|
678
|
+
if (cfg.backend === 'agentdb') return { enabled: false, state: 'legacy-shape' };
|
|
639
679
|
return { enabled: false, state: 'not-enabled' };
|
|
640
680
|
}
|
|
641
681
|
|
|
@@ -650,6 +690,7 @@ export function mirrorWriterExplanation(state: MirrorWriterState): string {
|
|
|
650
690
|
case 'config-unreadable': return '.dz/config.json exists but could not be read or parsed — fix the file, not the settings';
|
|
651
691
|
case 'engine-off': return '.dz/config.json sets memory.vector.engine = "off" — the tier is deliberately disabled';
|
|
652
692
|
case 'not-enabled': return '.dz/config.json enables no mirror (needs memory.backend=agentdb, or memory.vector.engine=agentdb|rvf) — teach is NOT queueing';
|
|
693
|
+
case 'legacy-shape': return '.dz/config.json uses a top-level backend key; the mirror reads memory.backend — run dz setup';
|
|
653
694
|
}
|
|
654
695
|
}
|
|
655
696
|
|