@dzhechkov/harness-core 0.3.103 → 0.3.105

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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Pure recall-usage accounting for the dz APPLY leg.
3
+ *
4
+ * The live hook writes one JSONL event when a learned pattern is actually injected into a prompt.
5
+ * This module parses that append-only log, folds it into per-pattern usage stats, and compacts it
6
+ * into aggregate JSONL rows when it crosses a bounded size. It deliberately knows nothing about the
7
+ * filesystem; callers own reads/writes so the hook and statusline can keep their never-block rules.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ export const RECALL_USAGE_LOG_RELATIVE = '.dz/recall-usage.jsonl';
13
+ export const RECALL_USAGE_LOG_MAX_BYTES = 1_048_576;
14
+ export const RECALL_USAGE_COMPACT_TARGET_BYTES = Math.floor(RECALL_USAGE_LOG_MAX_BYTES * 0.75);
15
+
16
+ export interface RecallUsageReadRecord {
17
+ readonly dzId: string;
18
+ readonly score: number;
19
+ readonly ts: string;
20
+ }
21
+
22
+ export interface RecallUsageAggregateRecord {
23
+ readonly kind: 'aggregate';
24
+ readonly dzId: string;
25
+ readonly reads: number;
26
+ readonly firstReadAt: string;
27
+ readonly lastReadAt: string;
28
+ readonly maxScore: number;
29
+ readonly totalScore: number;
30
+ readonly compactedAt: string;
31
+ }
32
+
33
+ export type RecallUsageRecord = RecallUsageReadRecord | RecallUsageAggregateRecord;
34
+
35
+ export interface ParsedRecallUsageLog {
36
+ readonly records: readonly RecallUsageRecord[];
37
+ readonly validLines: number;
38
+ readonly invalidLines: number;
39
+ }
40
+
41
+ export interface RecallUsageStat {
42
+ readonly dzId: string;
43
+ readonly reads: number;
44
+ readonly firstReadAt: string;
45
+ readonly lastReadAt: string;
46
+ readonly maxScore: number;
47
+ readonly avgScore: number;
48
+ }
49
+
50
+ export interface RecallPatternUsageRef {
51
+ readonly dzId: string;
52
+ readonly pattern: string;
53
+ readonly domain?: string;
54
+ readonly reward?: number;
55
+ }
56
+
57
+ export interface RecallUsagePatternRow extends RecallPatternUsageRef {
58
+ readonly reads: number;
59
+ readonly firstReadAt?: string;
60
+ readonly lastReadAt?: string;
61
+ readonly maxScore?: number;
62
+ readonly avgScore?: number;
63
+ }
64
+
65
+ export interface RecallUsageReport {
66
+ readonly totalPatterns: number;
67
+ readonly usedPatterns: number;
68
+ readonly neverReadPatterns: number;
69
+ readonly totalReads: number;
70
+ readonly unknownReadPatterns: number;
71
+ readonly invalidLines: number;
72
+ readonly top: readonly RecallUsagePatternRow[];
73
+ readonly neverRead: readonly RecallUsagePatternRow[];
74
+ readonly unknown: readonly RecallUsageStat[];
75
+ readonly all: readonly RecallUsagePatternRow[];
76
+ }
77
+
78
+ interface Acc {
79
+ dzId: string;
80
+ reads: number;
81
+ firstReadAt: string;
82
+ firstMs: number;
83
+ lastReadAt: string;
84
+ lastMs: number;
85
+ maxScore: number;
86
+ totalScore: number;
87
+ }
88
+
89
+ export function formatRecallUsageRecord(input: {
90
+ readonly dzId?: unknown;
91
+ readonly score?: unknown;
92
+ readonly ts?: unknown;
93
+ }): string | undefined {
94
+ const rec = normalizeReadRecord(input);
95
+ return rec === undefined ? undefined : `${JSON.stringify(rec)}\n`;
96
+ }
97
+
98
+ export function parseRecallUsageLog(text: string): ParsedRecallUsageLog {
99
+ const records: RecallUsageRecord[] = [];
100
+ let invalidLines = 0;
101
+ for (const line of text.split('\n')) {
102
+ const trimmed = line.trim();
103
+ if (trimmed === '') continue;
104
+ try {
105
+ const parsed = JSON.parse(trimmed) as unknown;
106
+ const record = normalizeRecord(parsed);
107
+ if (record === undefined) {
108
+ invalidLines += 1;
109
+ } else {
110
+ records.push(record);
111
+ }
112
+ } catch {
113
+ invalidLines += 1;
114
+ }
115
+ }
116
+ return { records, validLines: records.length, invalidLines };
117
+ }
118
+
119
+ export function aggregateRecallUsage(records: readonly RecallUsageRecord[]): readonly RecallUsageStat[] {
120
+ const byId = new Map<string, Acc>();
121
+ for (const rec of records) {
122
+ if (isAggregate(rec)) {
123
+ mergeAggregate(byId, rec);
124
+ } else {
125
+ mergeRead(byId, rec);
126
+ }
127
+ }
128
+ return [...byId.values()]
129
+ .map((a) => ({
130
+ dzId: a.dzId,
131
+ reads: a.reads,
132
+ firstReadAt: a.firstReadAt,
133
+ lastReadAt: a.lastReadAt,
134
+ maxScore: a.maxScore,
135
+ avgScore: a.totalScore / a.reads,
136
+ }))
137
+ .sort(compareStats);
138
+ }
139
+
140
+ export function buildRecallUsageReport(
141
+ patterns: readonly RecallPatternUsageRef[],
142
+ parsed: ParsedRecallUsageLog,
143
+ ): RecallUsageReport {
144
+ const stats = aggregateRecallUsage(parsed.records);
145
+ const statsById = new Map(stats.map((s) => [s.dzId, s]));
146
+ const patternIds = new Set<string>();
147
+ const all: RecallUsagePatternRow[] = [];
148
+
149
+ for (const p of patterns) {
150
+ if (p.dzId.trim() === '') continue;
151
+ if (patternIds.has(p.dzId)) continue;
152
+ patternIds.add(p.dzId);
153
+ const stat = statsById.get(p.dzId);
154
+ const base = patternRef(p);
155
+ if (stat === undefined) {
156
+ all.push({ ...base, reads: 0 });
157
+ } else {
158
+ all.push({
159
+ ...base,
160
+ reads: stat.reads,
161
+ firstReadAt: stat.firstReadAt,
162
+ lastReadAt: stat.lastReadAt,
163
+ maxScore: stat.maxScore,
164
+ avgScore: stat.avgScore,
165
+ });
166
+ }
167
+ }
168
+
169
+ const top = all.filter((r) => r.reads > 0).sort(compareRows);
170
+ const neverRead = all.filter((r) => r.reads === 0).sort((a, b) => a.dzId.localeCompare(b.dzId));
171
+ const unknown = stats.filter((s) => !patternIds.has(s.dzId)).sort(compareStats);
172
+
173
+ return {
174
+ totalPatterns: all.length,
175
+ usedPatterns: top.length,
176
+ neverReadPatterns: neverRead.length,
177
+ totalReads: stats.reduce((sum, s) => sum + s.reads, 0),
178
+ unknownReadPatterns: unknown.length,
179
+ invalidLines: parsed.invalidLines,
180
+ top,
181
+ neverRead,
182
+ unknown,
183
+ all: all.sort(compareRows),
184
+ };
185
+ }
186
+
187
+ export function shouldCompactRecallUsageLogSize(
188
+ sizeBytes: number,
189
+ maxBytes: number = RECALL_USAGE_LOG_MAX_BYTES,
190
+ ): boolean {
191
+ return Number.isFinite(sizeBytes) && sizeBytes > validMax(maxBytes);
192
+ }
193
+
194
+ export function compactRecallUsageLog(
195
+ text: string,
196
+ opts: { readonly maxBytes?: number; readonly targetBytes?: number; readonly compactedAt?: string } = {},
197
+ ): string {
198
+ const maxBytes = validMax(opts.maxBytes ?? RECALL_USAGE_LOG_MAX_BYTES);
199
+ const targetBytes = validTarget(opts.targetBytes ?? Math.floor(maxBytes * 0.75), maxBytes);
200
+ 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));
203
+ let out = joinLines(lines);
204
+ if (byteLength(out) <= maxBytes) return out;
205
+
206
+ const kept: string[] = [];
207
+ let used = 0;
208
+ for (const line of lines) {
209
+ const cost = byteLength(`${line}\n`);
210
+ if (kept.length > 0 && used + cost > targetBytes) continue;
211
+ if (cost > maxBytes) continue;
212
+ if (used + cost <= maxBytes) {
213
+ kept.push(line);
214
+ used += cost;
215
+ }
216
+ }
217
+ out = joinLines(kept);
218
+ return byteLength(out) <= maxBytes ? out : '';
219
+ }
220
+
221
+ function normalizeRecord(value: unknown): RecallUsageRecord | undefined {
222
+ if (!isRecord(value)) return undefined;
223
+ if (value['kind'] === 'aggregate') return normalizeAggregateRecord(value);
224
+ return normalizeReadRecord(value);
225
+ }
226
+
227
+ function normalizeReadRecord(value: unknown): RecallUsageReadRecord | undefined {
228
+ if (!isRecord(value)) return undefined;
229
+ const dzId = value['dzId'];
230
+ const score = value['score'];
231
+ const ts = value['ts'];
232
+ if (typeof dzId !== 'string' || dzId.trim() === '') return undefined;
233
+ if (typeof score !== 'number' || !Number.isFinite(score)) return undefined;
234
+ if (!validTs(ts)) return undefined;
235
+ return { dzId: dzId.trim(), score, ts };
236
+ }
237
+
238
+ function normalizeAggregateRecord(value: Record<string, unknown>): RecallUsageAggregateRecord | undefined {
239
+ const dzId = value['dzId'];
240
+ const reads = value['reads'];
241
+ const firstReadAt = value['firstReadAt'];
242
+ const lastReadAt = value['lastReadAt'];
243
+ const maxScore = value['maxScore'];
244
+ const totalScore = value['totalScore'];
245
+ const compactedAt = value['compactedAt'];
246
+ if (typeof dzId !== 'string' || dzId.trim() === '') return undefined;
247
+ if (typeof reads !== 'number' || !Number.isInteger(reads) || reads <= 0) return undefined;
248
+ if (!validTs(firstReadAt) || !validTs(lastReadAt) || !validTs(compactedAt)) return undefined;
249
+ if (typeof maxScore !== 'number' || !Number.isFinite(maxScore)) return undefined;
250
+ if (typeof totalScore !== 'number' || !Number.isFinite(totalScore)) return undefined;
251
+ return { kind: 'aggregate', dzId: dzId.trim(), reads, firstReadAt, lastReadAt, maxScore, totalScore, compactedAt };
252
+ }
253
+
254
+ function mergeRead(byId: Map<string, Acc>, rec: RecallUsageReadRecord): void {
255
+ const ms = Date.parse(rec.ts);
256
+ const prev = byId.get(rec.dzId);
257
+ if (prev === undefined) {
258
+ byId.set(rec.dzId, {
259
+ dzId: rec.dzId,
260
+ reads: 1,
261
+ firstReadAt: rec.ts,
262
+ firstMs: ms,
263
+ lastReadAt: rec.ts,
264
+ lastMs: ms,
265
+ maxScore: rec.score,
266
+ totalScore: rec.score,
267
+ });
268
+ return;
269
+ }
270
+ prev.reads += 1;
271
+ prev.totalScore += rec.score;
272
+ prev.maxScore = Math.max(prev.maxScore, rec.score);
273
+ if (ms < prev.firstMs) {
274
+ prev.firstMs = ms;
275
+ prev.firstReadAt = rec.ts;
276
+ }
277
+ if (ms >= prev.lastMs) {
278
+ prev.lastMs = ms;
279
+ prev.lastReadAt = rec.ts;
280
+ }
281
+ }
282
+
283
+ function mergeAggregate(byId: Map<string, Acc>, rec: RecallUsageAggregateRecord): void {
284
+ const firstMs = Date.parse(rec.firstReadAt);
285
+ const lastMs = Date.parse(rec.lastReadAt);
286
+ const prev = byId.get(rec.dzId);
287
+ if (prev === undefined) {
288
+ byId.set(rec.dzId, {
289
+ dzId: rec.dzId,
290
+ reads: rec.reads,
291
+ firstReadAt: rec.firstReadAt,
292
+ firstMs,
293
+ lastReadAt: rec.lastReadAt,
294
+ lastMs,
295
+ maxScore: rec.maxScore,
296
+ totalScore: rec.totalScore,
297
+ });
298
+ return;
299
+ }
300
+ prev.reads += rec.reads;
301
+ prev.totalScore += rec.totalScore;
302
+ prev.maxScore = Math.max(prev.maxScore, rec.maxScore);
303
+ if (firstMs < prev.firstMs) {
304
+ prev.firstMs = firstMs;
305
+ prev.firstReadAt = rec.firstReadAt;
306
+ }
307
+ if (lastMs >= prev.lastMs) {
308
+ prev.lastMs = lastMs;
309
+ prev.lastReadAt = rec.lastReadAt;
310
+ }
311
+ }
312
+
313
+ function aggregateLine(stat: RecallUsageStat, compactedAt: string): string {
314
+ const rec: RecallUsageAggregateRecord = {
315
+ kind: 'aggregate',
316
+ dzId: stat.dzId,
317
+ reads: stat.reads,
318
+ firstReadAt: stat.firstReadAt,
319
+ lastReadAt: stat.lastReadAt,
320
+ maxScore: stat.maxScore,
321
+ totalScore: stat.avgScore * stat.reads,
322
+ compactedAt,
323
+ };
324
+ return JSON.stringify(rec);
325
+ }
326
+
327
+ function patternRef(p: RecallPatternUsageRef): RecallPatternUsageRef {
328
+ return {
329
+ dzId: p.dzId,
330
+ pattern: p.pattern,
331
+ ...(p.domain !== undefined ? { domain: p.domain } : {}),
332
+ ...(p.reward !== undefined ? { reward: p.reward } : {}),
333
+ };
334
+ }
335
+
336
+ function compareStats(a: RecallUsageStat, b: RecallUsageStat): number {
337
+ return b.reads - a.reads || Date.parse(b.lastReadAt) - Date.parse(a.lastReadAt) || a.dzId.localeCompare(b.dzId);
338
+ }
339
+
340
+ function compareRows(a: RecallUsagePatternRow, b: RecallUsagePatternRow): number {
341
+ const aLast = a.lastReadAt === undefined ? 0 : Date.parse(a.lastReadAt);
342
+ const bLast = b.lastReadAt === undefined ? 0 : Date.parse(b.lastReadAt);
343
+ return b.reads - a.reads || bLast - aLast || a.dzId.localeCompare(b.dzId);
344
+ }
345
+
346
+ function isAggregate(rec: RecallUsageRecord): rec is RecallUsageAggregateRecord {
347
+ return 'kind' in rec && rec.kind === 'aggregate';
348
+ }
349
+
350
+ function isRecord(value: unknown): value is Record<string, unknown> {
351
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
352
+ }
353
+
354
+ function validTs(value: unknown): value is string {
355
+ return typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Date.parse(value));
356
+ }
357
+
358
+ function validMax(value: number): number {
359
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : RECALL_USAGE_LOG_MAX_BYTES;
360
+ }
361
+
362
+ function validTarget(value: number, maxBytes: number): number {
363
+ if (!Number.isFinite(value) || value <= 0) return Math.floor(maxBytes * 0.75);
364
+ return Math.min(Math.floor(value), maxBytes);
365
+ }
366
+
367
+ function joinLines(lines: readonly string[]): string {
368
+ return lines.length === 0 ? '' : `${lines.join('\n')}\n`;
369
+ }
370
+
371
+ function byteLength(text: string): number {
372
+ return text.length;
373
+ }
package/src/statusline.ts CHANGED
@@ -19,6 +19,7 @@ import { dirname, join, resolve } from 'node:path';
19
19
  import { createRequire } from 'node:module';
20
20
 
21
21
  import { listBrain } from './brain.js';
22
+ import { RECALL_USAGE_LOG_RELATIVE, aggregateRecallUsage, parseRecallUsageLog } from './recall-usage.js';
22
23
 
23
24
  /**
24
25
  * Live learning state for one in-flight `/feature-adr` run — the per-run visibility panel
@@ -49,6 +50,8 @@ export interface FeatureAdrState {
49
50
  export interface StatuslineData {
50
51
  /** Count of learned patterns in the project's unified memory store. */
51
52
  readonly patterns: number;
53
+ /** Count of learned patterns that the live recall hook has actually injected at least once. */
54
+ readonly usedPatterns?: number;
52
55
  /** Number of sources registered in the durable cross-project knowledge brain. */
53
56
  readonly brainSources: number;
54
57
  /** Hours since the last `dz consolidate` run, if a watermark is present. */
@@ -138,6 +141,18 @@ function countLearnedPatterns(projectRoot: string): number {
138
141
  return countJsonlPatternsReadonly(projectRoot);
139
142
  }
140
143
 
144
+ function countUsedPatternsReadonly(projectRoot: string): number | undefined {
145
+ const path = join(projectRoot, RECALL_USAGE_LOG_RELATIVE);
146
+ if (!existsSync(path)) return undefined;
147
+ try {
148
+ const parsed = parseRecallUsageLog(readFileSync(path, 'utf-8'));
149
+ if (parsed.records.length === 0) return undefined;
150
+ return aggregateRecallUsage(parsed.records).length;
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ }
155
+
141
156
  /** Hours since the last consolidation, or `undefined` when never consolidated / unreadable. */
142
157
  function consolidatedAgeHours(projectRoot: string, now: number): number | undefined {
143
158
  const path = consolidateWatermarkPath(projectRoot);
@@ -268,6 +283,13 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
268
283
  brainSources = 0;
269
284
  }
270
285
 
286
+ let usedPatterns: number | undefined;
287
+ try {
288
+ usedPatterns = countUsedPatternsReadonly(root);
289
+ } catch {
290
+ usedPatterns = undefined;
291
+ }
292
+
271
293
  const ageH = consolidatedAgeHours(root, now);
272
294
 
273
295
  // Live /feature-adr panel — attached ONLY when a fresh run is in flight (readonly, never throws).
@@ -280,6 +302,7 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
280
302
 
281
303
  return {
282
304
  patterns,
305
+ ...(usedPatterns !== undefined ? { usedPatterns } : {}),
283
306
  brainSources,
284
307
  ...(ageH !== undefined ? { consolidatedAgeH: ageH } : {}),
285
308
  ...(featureAdr !== undefined ? { featureAdr } : {}),