@dzhechkov/harness-core 0.3.135 → 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.
@@ -0,0 +1,314 @@
1
+ /**
2
+ * `dz compounding` — does the learning loop actually PAY? (feature compounding, scout C2)
3
+ *
4
+ * Ported from rUv's darwin-mode (`security/compounding.ts`, `security/ablation.ts`,
5
+ * `bench/{stats,promotion}.ts`) with an honesty split the port map demanded:
6
+ * - the STATS machinery ports verbatim (seeded mulberry32, bootstrap lower-95, decidePromotion,
7
+ * the min-n >= 5 rule — darwin's own FDR calibration shows n=3 gives a 33% false-discovery rate);
8
+ * - darwin's MEASUREMENT legs do NOT port: its FP-drop leg ignores the passed corpus (a fixture),
9
+ * `withoutMemory` is hard-coded 0, and "warm" is injected state — theatrical, exactly what this
10
+ * repo's claim-check culture forbids. The measurements here are dz-native, over data that exists.
11
+ *
12
+ * The report NEVER fakes a verdict: a gate without enough samples says INSUFFICIENT_DATA — after the
13
+ * 2026-07-28 inventory found the apply-leg log dead for 19 days, "no data" is a finding, not a pass.
14
+ *
15
+ * Everything here is PURE: callers gather facts (files, store rows); this module only computes.
16
+ */
17
+
18
+ // ── Seeded statistics (verbatim-shape port from darwin-mode bench/stats.ts) ──
19
+
20
+ /** Deterministic PRNG — same seed, same stream, byte-identical reports. */
21
+ export function mulberry32(seed: number): () => number {
22
+ let a = seed >>> 0;
23
+ return () => {
24
+ a = (a + 0x6d2b79f5) >>> 0;
25
+ let t = a;
26
+ t = Math.imul(t ^ (t >>> 15), t | 1);
27
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
28
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
29
+ };
30
+ }
31
+
32
+ export const BOOTSTRAP_RESAMPLES = 5000;
33
+ /** Below this many samples PER ARM a comparison is noise: darwin's own FDR calibration measured a
34
+ * 0.332 empirical false-discovery rate at n=3. */
35
+ export const MIN_SAMPLES_PER_ARM = 5;
36
+
37
+ export interface BootstrapDelta {
38
+ readonly meanDelta: number;
39
+ /** 2.5th percentile of the resampled deltas — the promotion decision reads THIS, not the mean. */
40
+ readonly lower95: number;
41
+ readonly samples: number;
42
+ }
43
+
44
+ /** Paired bootstrap over per-item deltas (b[i] - a[i]). */
45
+ export function bootstrapDelta(a: readonly number[], b: readonly number[], seed = 42): BootstrapDelta | null {
46
+ // PAIRED means paired: unequal lengths silently truncated a decisive observation and promoted on
47
+ // the remainder; a sparse/NaN entry is not an observation at all (Codex #9).
48
+ if (a.length !== b.length || a.length === 0) return null;
49
+ if (![...a, ...b].every((x) => typeof x === 'number' && Number.isFinite(x))) return null;
50
+ const n = a.length;
51
+ const deltas: number[] = [];
52
+ for (let i = 0; i < n; i++) deltas.push((b[i] ?? 0) - (a[i] ?? 0));
53
+ const rand = mulberry32(seed);
54
+ const means: number[] = [];
55
+ for (let r = 0; r < BOOTSTRAP_RESAMPLES; r++) {
56
+ let sum = 0;
57
+ for (let i = 0; i < n; i++) sum += deltas[Math.floor(rand() * n)] ?? 0;
58
+ means.push(sum / n);
59
+ }
60
+ means.sort((x, y) => x - y);
61
+ const meanDelta = deltas.reduce((s, d) => s + d, 0) / n;
62
+ // Conservative nearest-rank percentile: ceil(B*p)-1. floor(B*p) sat one slot ABOVE the 2.5th
63
+ // percentile and could flip a reject into a promote at the boundary (Codex #10 — a defect darwin
64
+ // itself inherits; ported faithfully was still ported wrong).
65
+ const lower95 = means[Math.max(0, Math.ceil(BOOTSTRAP_RESAMPLES * 0.025) - 1)] ?? 0;
66
+ return { meanDelta, lower95, samples: n };
67
+ }
68
+
69
+ export type PromotionVerdict = 'promote' | 'reject' | 'insufficient-data';
70
+
71
+ /** Darwin's decision rule: a positive mean is not enough — the LOWER bound must clear zero. */
72
+ export function decidePromotion(delta: BootstrapDelta | null, minDelta = 0): PromotionVerdict {
73
+ // NaN samples compared false against the minimum and PROMOTED (Codex #9) — every field must be a
74
+ // real number and the count a real integer before any decision exists.
75
+ if (
76
+ delta === null ||
77
+ !Number.isInteger(delta.samples) ||
78
+ delta.samples < MIN_SAMPLES_PER_ARM ||
79
+ !Number.isFinite(delta.meanDelta) ||
80
+ !Number.isFinite(delta.lower95) ||
81
+ !Number.isFinite(minDelta)
82
+ ) {
83
+ return 'insufficient-data';
84
+ }
85
+ return delta.meanDelta > minDelta && delta.lower95 > 0 ? 'promote' : 'reject';
86
+ }
87
+
88
+ // ── The dz-native facts the CLI gathers ─────────────────────────────
89
+
90
+ export interface LessonRow {
91
+ readonly dzId: string;
92
+ readonly uses: number;
93
+ readonly quarantined: boolean;
94
+ readonly reward: number | null;
95
+ }
96
+
97
+ export interface UsageEvent {
98
+ readonly dzId: string;
99
+ readonly ts: string;
100
+ readonly query?: string;
101
+ readonly runId?: string;
102
+ /** One id per PROMPT: the hook writes one row per injected hit (up to 3 per prompt), and counting
103
+ * rows as independent replay pairs fabricated readiness (Codex #1). */
104
+ readonly eventId?: string;
105
+ /** A truncated query cannot reproduce the original recall — it must not count (Codex #3). */
106
+ readonly queryTruncated?: boolean;
107
+ }
108
+
109
+ export interface GuardEvent {
110
+ readonly ts: string;
111
+ readonly verdict: string;
112
+ readonly rules: readonly string[]; // violated rule ids
113
+ }
114
+
115
+ export interface CompoundingFacts {
116
+ readonly lessons: readonly LessonRow[];
117
+ readonly usage: readonly UsageEvent[];
118
+ readonly guard: readonly GuardEvent[];
119
+ readonly nowTs: string;
120
+ }
121
+
122
+ // ── The report ──────────────────────────────────────────────────────
123
+
124
+ export interface PoolPayoff {
125
+ readonly total: number;
126
+ /** Ever surfaced by the APPLY leg (hook injection) — the strict payoff bar. */
127
+ readonly injectedEver: number;
128
+ /** Touched by ANY recall path (store `uses` counter). */
129
+ readonly touchedEver: number;
130
+ readonly neverTouched: number;
131
+ readonly quarantined: number;
132
+ /** Fraction of the pool that is write-only under the strict bar. */
133
+ readonly writeOnlyRatio: number;
134
+ }
135
+
136
+ export interface GuardRuleTrajectory {
137
+ readonly rule: string;
138
+ readonly firstHalfViolations: number;
139
+ readonly secondHalfViolations: number;
140
+ readonly firstHalfAudits: number;
141
+ readonly secondHalfAudits: number;
142
+ /** Improvement is judged on the RATE (violations per audit), not raw counts: ten violations in a
143
+ * hundred early audits vs one in one late audit is a WORSENING, not progress (Codex #7). */
144
+ readonly improved: boolean;
145
+ }
146
+
147
+ export type ReadinessVerdict = 'ready' | 'insufficient-data';
148
+
149
+ export interface ReplayReadiness {
150
+ /** UNIQUE, untruncated prompt events — the pairs a cold-vs-warm replay needs. */
151
+ readonly replayablePairs: number;
152
+ readonly minNeeded: number;
153
+ /** READINESS only. `promote`/`reject` exist solely after a real cold/warm A-B has been run and
154
+ * bootstrapped — readiness must never look like a result (Codex #1). */
155
+ readonly verdict: ReadinessVerdict;
156
+ readonly note: string;
157
+ }
158
+
159
+ export interface InstrumentationHealth {
160
+ readonly lastUsageTs: string | null;
161
+ readonly gapDays: number | null;
162
+ /** True when the newest usage record is recent enough to trust the leg is alive. */
163
+ readonly applyLegLive: boolean;
164
+ }
165
+
166
+ export interface CompoundingReport {
167
+ readonly pool: PoolPayoff;
168
+ readonly guardTrajectory: readonly GuardRuleTrajectory[];
169
+ readonly replay: ReplayReadiness;
170
+ readonly instrumentation: InstrumentationHealth;
171
+ /** The one-line honest answer. */
172
+ readonly verdict: string;
173
+ }
174
+
175
+ const APPLY_LEG_STALE_DAYS = 7;
176
+
177
+ export function assembleCompoundingReport(facts: CompoundingFacts): CompoundingReport {
178
+ const { lessons, usage, guard } = facts;
179
+
180
+ // 1. Pool payoff — the "loops need all three legs" question, quantified.
181
+ const injectedIds = new Set(usage.map((u) => u.dzId));
182
+ const injectedEver = lessons.filter((l) => injectedIds.has(l.dzId)).length;
183
+ const touchedEver = lessons.filter((l) => l.uses > 0 || injectedIds.has(l.dzId)).length;
184
+ const total = lessons.length;
185
+ const pool: PoolPayoff = {
186
+ total,
187
+ injectedEver,
188
+ touchedEver,
189
+ neverTouched: total - touchedEver,
190
+ quarantined: lessons.filter((l) => l.quarantined).length,
191
+ writeOnlyRatio: total === 0 ? 0 : (total - injectedEver) / total,
192
+ };
193
+
194
+ // 2. Guard trajectory — do the same mistakes recur less over time? Split the record span in half
195
+ // by TIME (not by count: a busy afternoon must not masquerade as an era).
196
+ // Only events with a PARSEABLE timestamp participate; a span of zero has no halves (Codex #7).
197
+ const guardTimed = guard
198
+ .map((g) => ({ ...g, ms: Date.parse(g.ts) }))
199
+ .filter((g) => Number.isFinite(g.ms))
200
+ .sort((a, b) => a.ms - b.ms);
201
+ const trajectory: GuardRuleTrajectory[] = [];
202
+ const t0 = guardTimed.length > 0 ? guardTimed[0]!.ms : 0;
203
+ const t1 = guardTimed.length > 0 ? guardTimed[guardTimed.length - 1]!.ms : 0;
204
+ if (guardTimed.length >= 2 && t1 > t0) {
205
+ const mid = t0 + (t1 - t0) / 2;
206
+ let firstAudits = 0;
207
+ let secondAudits = 0;
208
+ for (const g of guardTimed) {
209
+ if (g.ms <= mid) firstAudits += 1;
210
+ else secondAudits += 1;
211
+ }
212
+ const perRule = new Map<string, { first: number; second: number }>();
213
+ for (const g of guardTimed) {
214
+ const inFirst = g.ms <= mid;
215
+ for (const rule of g.rules) {
216
+ const e = perRule.get(rule) ?? { first: 0, second: 0 };
217
+ if (inFirst) e.first += 1;
218
+ else e.second += 1;
219
+ perRule.set(rule, e);
220
+ }
221
+ }
222
+ // Both halves must contain OBSERVATIONS for a rate comparison to mean anything.
223
+ if (firstAudits > 0 && secondAudits > 0) {
224
+ for (const [rule, e] of [...perRule.entries()].sort()) {
225
+ const firstRate = e.first / firstAudits;
226
+ const secondRate = e.second / secondAudits;
227
+ trajectory.push({
228
+ rule,
229
+ firstHalfViolations: e.first,
230
+ secondHalfViolations: e.second,
231
+ firstHalfAudits: firstAudits,
232
+ secondHalfAudits: secondAudits,
233
+ improved: secondRate < firstRate,
234
+ });
235
+ }
236
+ }
237
+ }
238
+
239
+ // 3. Replay readiness — cold-vs-warm needs (query -> injected lesson) pairs. They were never
240
+ // recorded before 2026-07-28, so this gate REPORTS accrual instead of faking a verdict.
241
+ const replayKeys = new Set<string>();
242
+ for (const u of usage) {
243
+ if (typeof u.query !== 'string' || u.query.trim() === '') continue;
244
+ if (u.queryTruncated === true) continue; // a prefix is not the prompt
245
+ // one prompt = one pair, however many hits it injected
246
+ replayKeys.add(u.eventId ?? `${u.runId ?? ''}|${u.ts}|${u.query}`);
247
+ }
248
+ const replayablePairs = replayKeys.size;
249
+ const replay: ReplayReadiness = {
250
+ replayablePairs,
251
+ minNeeded: MIN_SAMPLES_PER_ARM,
252
+ verdict: replayablePairs >= MIN_SAMPLES_PER_ARM ? 'ready' : 'insufficient-data',
253
+ note:
254
+ replayablePairs >= MIN_SAMPLES_PER_ARM
255
+ ? `${replayablePairs} unique prompt event(s) recorded — a cold-vs-warm replay can now be RUN (readiness, not a result)`
256
+ : `${replayablePairs} unique prompt event(s); ${MIN_SAMPLES_PER_ARM} needed — queries are recorded as of 2026-07-28, data is accruing`,
257
+ };
258
+
259
+ // 4. Instrumentation health — "no data" must be a finding, never a silent pass.
260
+ // Liveness compares RAW milliseconds (a 7d23h gap floored to "7 days" read as live), rejects
261
+ // garbage timestamps, and treats a FUTURE timestamp beyond small clock skew as evidence of a
262
+ // broken clock, not of liveness (Codex #8).
263
+ const usageTimed = usage
264
+ .map((u) => ({ ts: u.ts, ms: Date.parse(u.ts) }))
265
+ .filter((u) => Number.isFinite(u.ms))
266
+ .sort((a, b) => a.ms - b.ms);
267
+ const lastUsage = usageTimed.length > 0 ? usageTimed[usageTimed.length - 1]! : null;
268
+ const nowMs = Date.parse(facts.nowTs);
269
+ const CLOCK_SKEW_MS = 60_000;
270
+ const gapMs = lastUsage && Number.isFinite(nowMs) ? nowMs - lastUsage.ms : null;
271
+ const gapValid = gapMs !== null && gapMs >= -CLOCK_SKEW_MS;
272
+ const instrumentation: InstrumentationHealth = {
273
+ lastUsageTs: lastUsage?.ts ?? null,
274
+ gapDays: gapValid ? Math.max(0, Math.floor(gapMs / 86_400_000)) : null,
275
+ applyLegLive: gapValid && gapMs <= APPLY_LEG_STALE_DAYS * 86_400_000,
276
+ };
277
+
278
+ const improvedRules = trajectory.filter((t) => t.improved).length;
279
+ const verdict = [
280
+ `pool: ${injectedEver}/${total} lessons ever injected (${Math.round(pool.writeOnlyRatio * 100)}% write-only under the strict bar)`,
281
+ trajectory.length > 0 ? `guard: ${improvedRules}/${trajectory.length} rules recur less in the later half` : 'guard: not enough history',
282
+ `cold-vs-warm: ${replay.verdict === 'insufficient-data' ? 'INSUFFICIENT DATA (accruing)' : 'READY to measure'}`,
283
+ instrumentation.applyLegLive ? 'apply leg: live' : 'apply leg: STALE — fix the instrumentation before trusting anything above',
284
+ ].join(' · ');
285
+
286
+ return { pool, guardTrajectory: trajectory, replay, instrumentation, verdict };
287
+ }
288
+
289
+ export function renderCompoundingReport(r: CompoundingReport): string {
290
+ const out: string[] = [];
291
+ out.push('dz compounding — does the learning loop pay? (honest report: gates without data say so)');
292
+ out.push('');
293
+ out.push(` POOL PAYOFF: ${r.pool.total} lessons · ${r.pool.injectedEver} ever injected by the apply leg · ${r.pool.touchedEver} touched by any recall · ${r.pool.neverTouched} never touched · ${r.pool.quarantined} quarantined`);
294
+ out.push(` write-only ratio (strict bar): ${(r.pool.writeOnlyRatio * 100).toFixed(0)}%`);
295
+ out.push('');
296
+ if (r.guardTrajectory.length > 0) {
297
+ out.push(' GUARD TRAJECTORY (violations, first half vs second half of the audit span):');
298
+ for (const t of r.guardTrajectory) {
299
+ out.push(` ${t.improved ? '↓' : '·'} ${t.rule}: ${t.firstHalfViolations} → ${t.secondHalfViolations}`);
300
+ }
301
+ } else {
302
+ out.push(' GUARD TRAJECTORY: not enough audit history to split');
303
+ }
304
+ out.push('');
305
+ out.push(` COLD-VS-WARM REPLAY: ${r.replay.note}`);
306
+ out.push(
307
+ ` INSTRUMENTATION: last apply-leg record ${r.instrumentation.lastUsageTs ?? 'never'}` +
308
+ (r.instrumentation.gapDays !== null ? ` (${r.instrumentation.gapDays}d ago)` : '') +
309
+ ` — ${r.instrumentation.applyLegLive ? 'live' : 'STALE'}`,
310
+ );
311
+ out.push('');
312
+ out.push(` VERDICT: ${r.verdict}`);
313
+ return out.join('\n');
314
+ }
package/src/index.ts CHANGED
@@ -321,3 +321,8 @@ export * from './delivery-check.js';
321
321
  // Skill-registration gate (feature skills-verify, ADR-001) — static layout scan + the deterministic
322
322
  // `system/init` listing. Fail-closed: an unobservable registration is `inconclusive`, never `pass`.
323
323
  export * from './skills-verify.js';
324
+
325
+ // Learning-loop payoff measurement (feature compounding, scout C2) — seeded stats ported verbatim
326
+ // from darwin-mode; measurements are dz-native and NEVER fake a verdict (INSUFFICIENT_DATA is a
327
+ // finding, not a pass).
328
+ export * from './compounding.js';
package/src/operations.ts CHANGED
@@ -749,6 +749,30 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
749
749
  detail: `deployed writer v${deployed} < current v${AGENTDB_WRITER_VERSION} — re-run dz setup to upgrade (no --force needed)`,
750
750
  });
751
751
  }
752
+ // APPLY-LEG LIVENESS (2026-07-28): the recall hook injects lessons only while the embed daemon's
753
+ // socket is alive, and the daemon is started at SessionStart only — when it died mid-way through a
754
+ // long-lived session the whole apply leg went silently dark for 19 days (MEASURED:
755
+ // recall-usage.jsonl last record 2026-07-09 with the socket absent). A dead leg must be VISIBLE.
756
+ try {
757
+ const settingsPath = join(root, '.claude', 'settings.json');
758
+ if (existsSync(settingsPath)) {
759
+ const settingsText = readFileSync(settingsPath, 'utf-8');
760
+ const applyLegWired = settingsText.includes('recall-hook.cjs') && settingsText.includes('dz-embed-daemon.mjs');
761
+ if (applyLegWired) {
762
+ const sockAlive = existsSync(join(root, '.dz', 'embed.sock'));
763
+ checks.push({
764
+ name: 'apply-leg alive (embed daemon)',
765
+ ok: sockAlive,
766
+ detail: sockAlive
767
+ ? 'embed.sock present — recall injection can run'
768
+ : 'embed.sock ABSENT: the recall hook is wired but cannot inject (the hook self-heals on the next prompt; a persistent absence means the daemon cannot start)',
769
+ });
770
+ }
771
+ }
772
+ } catch {
773
+ /* doctor never throws on a diagnostic */
774
+ }
775
+
752
776
  // Version drift between the local agentdb copy and the .mcp.json pin (gap G7)
753
777
  try {
754
778
  const localVer = (JSON.parse(readFileSync(join(root, 'node_modules', 'agentdb', 'package.json'), 'utf-8')) as { version?: string }).version;
@@ -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 {