@dzhechkov/harness-core 0.3.90 → 0.3.94

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.
Files changed (55) hide show
  1. package/dist/__tests__/golden-baseline.test.d.ts +2 -0
  2. package/dist/__tests__/golden-baseline.test.d.ts.map +1 -0
  3. package/dist/__tests__/golden-baseline.test.js +60 -0
  4. package/dist/__tests__/golden-baseline.test.js.map +1 -0
  5. package/dist/agentdb-index.d.ts +9 -0
  6. package/dist/agentdb-index.d.ts.map +1 -1
  7. package/dist/agentdb-index.js +44 -1
  8. package/dist/agentdb-index.js.map +1 -1
  9. package/dist/brain.d.ts +26 -0
  10. package/dist/brain.d.ts.map +1 -1
  11. package/dist/brain.js +105 -4
  12. package/dist/brain.js.map +1 -1
  13. package/dist/feature-adr-routing.d.ts +69 -0
  14. package/dist/feature-adr-routing.d.ts.map +1 -1
  15. package/dist/feature-adr-routing.js +79 -9
  16. package/dist/feature-adr-routing.js.map +1 -1
  17. package/dist/index.d.ts +12 -8
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -5
  20. package/dist/index.js.map +1 -1
  21. package/dist/learning-backend.d.ts +85 -0
  22. package/dist/learning-backend.d.ts.map +1 -0
  23. package/dist/learning-backend.js +133 -0
  24. package/dist/learning-backend.js.map +1 -0
  25. package/dist/patterns.d.ts +43 -0
  26. package/dist/patterns.d.ts.map +1 -1
  27. package/dist/patterns.js +0 -0
  28. package/dist/patterns.js.map +1 -1
  29. package/dist/recommend.d.ts.map +1 -1
  30. package/dist/recommend.js +17 -3
  31. package/dist/recommend.js.map +1 -1
  32. package/dist/statusline.d.ts +3 -0
  33. package/dist/statusline.d.ts.map +1 -1
  34. package/dist/statusline.js +2 -0
  35. package/dist/statusline.js.map +1 -1
  36. package/dist/usage.d.ts +70 -0
  37. package/dist/usage.d.ts.map +1 -0
  38. package/dist/usage.js +255 -0
  39. package/dist/usage.js.map +1 -0
  40. package/dist/vector-tier.d.ts +15 -0
  41. package/dist/vector-tier.d.ts.map +1 -1
  42. package/dist/vector-tier.js +101 -19
  43. package/dist/vector-tier.js.map +1 -1
  44. package/package.json +4 -4
  45. package/src/__tests__/golden-baseline.test.ts +67 -0
  46. package/src/agentdb-index.ts +52 -1
  47. package/src/brain.ts +127 -4
  48. package/src/feature-adr-routing.ts +131 -8
  49. package/src/index.ts +15 -4
  50. package/src/learning-backend.ts +202 -0
  51. package/src/patterns.ts +0 -0
  52. package/src/recommend.ts +19 -5
  53. package/src/statusline.ts +5 -0
  54. package/src/usage.ts +289 -0
  55. package/src/vector-tier.ts +116 -16
package/src/statusline.ts CHANGED
@@ -37,6 +37,8 @@ export interface FeatureAdrState {
37
37
  readonly recalled: number;
38
38
  /** How many NEW patterns this run STORED back into the pool. */
39
39
  readonly stored: number;
40
+ /** How many candidate lessons reinforced an existing pattern instead of writing a duplicate. */
41
+ readonly reinforced?: number;
40
42
  /** ISO timestamp of the write — drives the freshness window on the render path. */
41
43
  readonly ts: string;
42
44
  /** Optional run mode (e.g. "reference", "full-qe", "full-qe-extended"). */
@@ -181,6 +183,7 @@ export function readFeatureAdrState(projectRoot: string, now: number = Date.now(
181
183
  pool: num(parsed.pool),
182
184
  recalled: num(parsed.recalled),
183
185
  stored: num(parsed.stored),
186
+ ...(num(parsed.reinforced) > 0 ? { reinforced: num(parsed.reinforced) } : {}),
184
187
  ts: parsed.ts,
185
188
  ...(typeof parsed.mode === 'string' && parsed.mode.length > 0 ? { mode: parsed.mode } : {}),
186
189
  };
@@ -196,6 +199,7 @@ export interface WriteFeatureAdrStateInput {
196
199
  readonly step: string;
197
200
  readonly recalled: number;
198
201
  readonly stored: number;
202
+ readonly reinforced?: number;
199
203
  readonly mode?: string;
200
204
  }
201
205
 
@@ -225,6 +229,7 @@ export function writeFeatureAdrState(
225
229
  pool,
226
230
  recalled: Number.isFinite(input.recalled) ? input.recalled : 0,
227
231
  stored: Number.isFinite(input.stored) ? input.stored : 0,
232
+ ...(input.reinforced !== undefined && Number.isFinite(input.reinforced) ? { reinforced: input.reinforced } : {}),
228
233
  ts: new Date(now).toISOString(),
229
234
  ...(input.mode !== undefined && input.mode.length > 0 ? { mode: input.mode } : {}),
230
235
  };
package/src/usage.ts ADDED
@@ -0,0 +1,289 @@
1
+ /**
2
+ * `dz usage` data source — a READONLY, never-throw, best-effort estimate of Claude SESSION
3
+ * (active 5h-block) and WEEKLY (rolling 7d) token usage, aggregated from the local Claude Code
4
+ * transcript files under `~/.claude/projects/<munged>/<session>.jsonl`.
5
+ *
6
+ * ## Honest-uncertainty contract (LOAD-BEARING — NFR-4)
7
+ *
8
+ * These percentages are **ESTIMATES** derived by aggregating local transcript token counts against
9
+ * a USER-CONFIGURED limit. There is **no official usage API** being consulted — this is a fuel
10
+ * gauge built from the flow meter, not a tank sensor. Therefore:
11
+ *
12
+ * - Every payload carries `estimated: true` so a consumer can NEVER mistake it for ground truth.
13
+ * - When a limit is **unconfigured** (`.dz/config.json` has no `memory.usage.sessionTokenLimit` /
14
+ * `weeklyTokenLimit`), the corresponding `pct` is `null` — **unknown, never 0, never a guess**.
15
+ * - **Calibration protocol (observed-exhaustion):** when a real limit-hit occurs at an estimated
16
+ * `X%`, scale the configured limit by `X/100` so the next estimate lines up with reality. The
17
+ * 4 token fields are summed with equal weight; the relative weighting (cache-read tokens
18
+ * dominate real transcripts) is absorbed into the calibrated limit value.
19
+ *
20
+ * ## Statusline discipline (NFR-1)
21
+ *
22
+ * Modeled on {@link ./statusline.ts} and {@link ./vector-tier.ts}'s `readVectorEngineMode`:
23
+ * - **never-throw** — any error (missing `~/.claude/projects`, corrupt jsonl line, missing/corrupt
24
+ * config, garbled usage object) collapses to a value with `null` pcts, never an exception.
25
+ * - **readonly** — zero writes/appends/mkdir/unlink anywhere (the optional aggregate cache in
26
+ * arch §2.2(6) is DEFERRED — v1 ships with zero writes so READONLY is trivially true).
27
+ * - **<100ms steady-state** via an `mtime` prefilter: a file whose `mtime` is older than the weekly
28
+ * window cannot contribute and is skipped WITHOUT opening it; only files touched inside the
29
+ * session window are line-parsed for the active block.
30
+ * - **injectable clock** — `computeUsage(root, now?)` takes an optional `now` (ms epoch) so all
31
+ * window math is deterministic under test.
32
+ *
33
+ * @packageDocumentation
34
+ */
35
+
36
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
37
+ import { homedir } from 'node:os';
38
+ import { join } from 'node:path';
39
+
40
+ const HOUR_MS = 60 * 60 * 1000;
41
+ const SESSION_BLOCK_MS = 5 * HOUR_MS; // ccusage 5h-block
42
+ const WEEK_MS = 7 * 24 * HOUR_MS; // rolling 7-day weekly window
43
+ // mtime prefilter slack (+1h) — guards against clock skew between the writer and this reader
44
+ // (a file with a slightly-stale mtime that is actually in-window must not be dropped).
45
+ const MTIME_SLACK_MS = HOUR_MS;
46
+
47
+ /** Optional, plan-dependent calibration limits from `.dz/config.json`. Absent ⇒ pct is `null`. */
48
+ export interface UsageLimits {
49
+ readonly sessionTokenLimit?: number;
50
+ readonly weeklyTokenLimit?: number;
51
+ }
52
+
53
+ /** A never-throw usage estimate. `estimated` is ALWAYS `true` (honest-uncertainty marker). */
54
+ export interface UsageEstimate {
55
+ /** Active 5h-block token total (all projects). `0` when there is no active block. */
56
+ readonly sessionTokens: number;
57
+ /** Rolling 7d token total (all projects). */
58
+ readonly weeklyTokens: number;
59
+ /** `null` ⇔ `sessionTokenLimit` unconfigured (unknown — never 0). */
60
+ readonly sessionPct: number | null;
61
+ /** `null` ⇔ `weeklyTokenLimit` unconfigured (unknown — never 0). */
62
+ readonly weeklyPct: number | null;
63
+ /** ISO — active block start + 5h; `null` when there is no active block. */
64
+ readonly sessionResetsAt: string | null;
65
+ /** ISO — oldest in-window sample ts + 7d (estimate); `null` when the weekly window is empty. */
66
+ readonly weeklyResetsAt: string | null;
67
+ /** ALWAYS `true` — these are estimates from local aggregation, not an official API. */
68
+ readonly estimated: true;
69
+ }
70
+
71
+ /**
72
+ * The `~/.claude/projects` root (the account-wide transcript store). Overridable via
73
+ * `DZ_CLAUDE_PROJECTS_ROOT` — used by tests to point at a temp tree (and by any user who relocates
74
+ * the Claude home). Never throws.
75
+ */
76
+ function claudeProjectsRoot(): string {
77
+ const override = process.env['DZ_CLAUDE_PROJECTS_ROOT'];
78
+ if (typeof override === 'string' && override.length > 0) return override;
79
+ return join(homedir(), '.claude', 'projects');
80
+ }
81
+
82
+ /**
83
+ * Read `memory.usage.{sessionTokenLimit,weeklyTokenLimit}` from `<projectRoot>/.dz/config.json`.
84
+ * NEVER throws — absent/corrupt/partial config ⇒ `{}` (⇒ pct `null`). Mirrors the
85
+ * `readVectorEngineMode` never-throw shape exactly. `projectRoot` is the ONLY thing that scopes to
86
+ * a project; the MEASUREMENT below is account-wide (FR-1.6).
87
+ */
88
+ export function readUsageLimits(projectRoot: string): UsageLimits {
89
+ try {
90
+ const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
91
+ memory?: { usage?: { sessionTokenLimit?: unknown; weeklyTokenLimit?: unknown } };
92
+ };
93
+ const u = cfg.memory?.usage;
94
+ if (!u || typeof u !== 'object') return {};
95
+ const out: { sessionTokenLimit?: number; weeklyTokenLimit?: number } = {};
96
+ const s = u.sessionTokenLimit;
97
+ const w = u.weeklyTokenLimit;
98
+ if (typeof s === 'number' && isFinite(s) && s > 0) out.sessionTokenLimit = s;
99
+ if (typeof w === 'number' && isFinite(w) && w > 0) out.weeklyTokenLimit = w;
100
+ return out;
101
+ } catch {
102
+ return {};
103
+ }
104
+ }
105
+
106
+ /** One extracted transcript sample. */
107
+ interface Sample {
108
+ readonly ts: number; // ms epoch
109
+ readonly tokens: number;
110
+ readonly key: string; // dedup key: message.id + ':' + requestId
111
+ }
112
+
113
+ /**
114
+ * List every `*.jsonl` under `~/.claude/projects/<dir>/`, best-effort. Never throws — an
115
+ * unreadable dir/file is skipped. Returns absolute paths + their `mtimeMs` (the prefilter lever).
116
+ */
117
+ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: number }> {
118
+ const out: Array<{ path: string; mtimeMs: number }> = [];
119
+ let dirs: string[];
120
+ try {
121
+ if (!existsSync(root)) return out;
122
+ dirs = readdirSync(root);
123
+ } catch {
124
+ return out;
125
+ }
126
+ for (const d of dirs) {
127
+ const projDir = join(root, d);
128
+ let files: string[];
129
+ try {
130
+ const st = statSync(projDir);
131
+ if (!st.isDirectory()) continue;
132
+ files = readdirSync(projDir);
133
+ } catch {
134
+ continue;
135
+ }
136
+ for (const f of files) {
137
+ if (!f.endsWith('.jsonl')) continue;
138
+ const p = join(projDir, f);
139
+ try {
140
+ out.push({ path: p, mtimeMs: statSync(p).mtimeMs });
141
+ } catch {
142
+ // skip a file we can't stat
143
+ }
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * Extract usage samples from one transcript file. Never throws — a corrupt line is skipped
151
+ * (statusline discipline). A cheap `"usage"` substring pre-filter avoids `JSON.parse` on lines
152
+ * that cannot carry a token count. `weeklyCutoff` drops samples older than the rolling window.
153
+ */
154
+ function extractSamples(path: string, weeklyCutoff: number, into: Sample[], seen: Set<string>): void {
155
+ let raw: string;
156
+ try {
157
+ raw = readFileSync(path, 'utf-8');
158
+ } catch {
159
+ return;
160
+ }
161
+ const lines = raw.split('\n');
162
+ for (const line of lines) {
163
+ if (line.length === 0) continue;
164
+ if (line.indexOf('usage') === -1) continue; // cheap pre-filter before the parse
165
+ let rec: {
166
+ timestamp?: unknown;
167
+ requestId?: unknown;
168
+ message?: {
169
+ id?: unknown;
170
+ usage?: {
171
+ input_tokens?: unknown;
172
+ cache_creation_input_tokens?: unknown;
173
+ cache_read_input_tokens?: unknown;
174
+ output_tokens?: unknown;
175
+ };
176
+ };
177
+ };
178
+ try {
179
+ rec = JSON.parse(line);
180
+ } catch {
181
+ continue; // corrupt line — skip, never throw
182
+ }
183
+ const usage = rec.message?.usage;
184
+ if (!usage || typeof usage !== 'object') continue;
185
+ const tsRaw = rec.timestamp;
186
+ if (typeof tsRaw !== 'string' && typeof tsRaw !== 'number') continue;
187
+ const ts = typeof tsRaw === 'number' ? tsRaw : Date.parse(tsRaw);
188
+ if (!isFinite(ts)) continue;
189
+ if (ts < weeklyCutoff) continue; // outside the weekly window — cannot contribute
190
+ const n = (v: unknown): number => (typeof v === 'number' && isFinite(v) && v > 0 ? v : 0);
191
+ const tokens =
192
+ n(usage.input_tokens) +
193
+ n(usage.cache_creation_input_tokens) +
194
+ n(usage.cache_read_input_tokens) +
195
+ n(usage.output_tokens);
196
+ if (tokens <= 0) continue;
197
+ // Dedup: streamed assistant messages repeat their usage object across chunks.
198
+ const id = typeof rec.message?.id === 'string' ? rec.message.id : '';
199
+ const reqId = typeof rec.requestId === 'string' ? rec.requestId : '';
200
+ const key = id + ':' + reqId;
201
+ if (id !== '' || reqId !== '') {
202
+ if (seen.has(key)) continue;
203
+ seen.add(key);
204
+ }
205
+ into.push({ ts, tokens, key });
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Compute the SESSION active-block total using ccusage 5h-block semantics: walk samples in ts
211
+ * order; a block starts at the first sample after the previous block ends, FLOORED to the hour;
212
+ * `end = start + 5h`. The ACTIVE block is the one whose `[start, end)` contains `now`. No active
213
+ * block ⇒ `{ tokens: 0, resetsAt: null }`.
214
+ */
215
+ function activeBlock(samplesAsc: Sample[], now: number): { tokens: number; resetsAt: string | null } {
216
+ let blockStart = -1;
217
+ let blockEnd = -1;
218
+ let tokens = 0;
219
+ let activeStart = -1;
220
+ let activeTokens = 0;
221
+ for (const s of samplesAsc) {
222
+ if (blockStart === -1 || s.ts >= blockEnd) {
223
+ // close the previous block, open a new one floored to the hour
224
+ blockStart = Math.floor(s.ts / HOUR_MS) * HOUR_MS;
225
+ blockEnd = blockStart + SESSION_BLOCK_MS;
226
+ tokens = 0;
227
+ }
228
+ tokens += s.tokens;
229
+ if (now >= blockStart && now < blockEnd) {
230
+ activeStart = blockStart;
231
+ activeTokens = tokens;
232
+ }
233
+ }
234
+ if (activeStart === -1) return { tokens: 0, resetsAt: null };
235
+ return { tokens: activeTokens, resetsAt: new Date(activeStart + SESSION_BLOCK_MS).toISOString() };
236
+ }
237
+
238
+ /**
239
+ * Estimate SESSION + WEEKLY token usage from the local Claude transcript store. NEVER throws;
240
+ * READONLY; `<100ms` steady-state via the `mtime` prefilter. `projectRoot` scopes ONLY the config
241
+ * (limits) read — measurement is account-wide (all projects). `now` is injectable for tests.
242
+ */
243
+ export function computeUsage(projectRoot: string, now?: number): UsageEstimate {
244
+ const nowMs = typeof now === 'number' && isFinite(now) ? now : Date.now();
245
+ const limits = readUsageLimits(projectRoot);
246
+ const weeklyCutoff = nowMs - WEEK_MS - MTIME_SLACK_MS;
247
+
248
+ const samples: Sample[] = [];
249
+ const seen = new Set<string>();
250
+ try {
251
+ const files = listTranscriptFiles(claudeProjectsRoot());
252
+ for (const f of files) {
253
+ // mtime prefilter: a file last written before the weekly cutoff cannot hold in-window
254
+ // samples — skip it WITHOUT opening it (the <100ms lever).
255
+ if (f.mtimeMs < weeklyCutoff) continue;
256
+ extractSamples(f.path, weeklyCutoff, samples, seen);
257
+ }
258
+ } catch {
259
+ // total scan failure ⇒ fall through with empty samples (nulls), never throw
260
+ }
261
+
262
+ // Weekly total: every sample inside the exact 7d window (the +slack was only a prefilter guard).
263
+ const weeklyHardCutoff = nowMs - WEEK_MS;
264
+ let weeklyTokens = 0;
265
+ let oldestInWindow = -1;
266
+ for (const s of samples) {
267
+ if (s.ts < weeklyHardCutoff) continue;
268
+ weeklyTokens += s.tokens;
269
+ if (oldestInWindow === -1 || s.ts < oldestInWindow) oldestInWindow = s.ts;
270
+ }
271
+
272
+ const asc = samples.slice().sort((a, b) => a.ts - b.ts);
273
+ const block = activeBlock(asc, nowMs);
274
+
275
+ const pct = (tokens: number, limit?: number): number | null =>
276
+ typeof limit === 'number' && limit > 0 ? Math.round((100 * tokens) / limit) : null;
277
+
278
+ const weeklyResetsAt = oldestInWindow === -1 ? null : new Date(oldestInWindow + WEEK_MS).toISOString();
279
+
280
+ return {
281
+ sessionTokens: block.tokens,
282
+ weeklyTokens,
283
+ sessionPct: pct(block.tokens, limits.sessionTokenLimit),
284
+ weeklyPct: pct(weeklyTokens, limits.weeklyTokenLimit),
285
+ sessionResetsAt: block.resetsAt,
286
+ weeklyResetsAt,
287
+ estimated: true,
288
+ };
289
+ }
@@ -47,8 +47,11 @@ import {
47
47
  patternIdentityOf,
48
48
  dreamRecordId,
49
49
  loadStoreRecords,
50
+ readMemoryLearningConfig,
51
+ readReinforcementState,
50
52
  removePatternsByIds,
51
53
  snapshotStore,
54
+ updateReinforcementState,
52
55
  type PatternRecord,
53
56
  type RecallHit,
54
57
  } from './patterns.js';
@@ -62,6 +65,7 @@ import {
62
65
  reindexAgentdbRows,
63
66
  } from './agentdb-index.js';
64
67
  import { currentEmbedManifest, guardEmbedSpace, DEFAULT_EMBED_DIM, resolveEmbedModel, type EmbedModelConfig } from './embedding-config.js';
68
+ import { applyLearningSignals, resolveLearningBackend, type LearningSignalBackend } from './learning-backend.js';
65
69
 
66
70
  /* ------------------------------------------------------------------ */
67
71
  /* Types (04_domain_model §3.4 / §4.1) */
@@ -85,6 +89,8 @@ export interface VectorEntry {
85
89
  readonly taskType: string;
86
90
  readonly tags?: readonly string[] | undefined;
87
91
  readonly metadata?: Record<string, unknown> | undefined;
92
+ readonly uses?: number | undefined;
93
+ readonly avgReward?: number | undefined;
88
94
  }
89
95
 
90
96
  /** One semantic search hit — a POINTER into the lexical store, never a pattern by itself. */
@@ -179,6 +185,7 @@ export const DEFAULT_VECTOR_TIMEOUT_MS = 10_000;
179
185
 
180
186
  /** Default cosine cutoff for near-duplicate clustering (`--threshold` / config overrides). */
181
187
  export const DEFAULT_HARMONIZE_THRESHOLD = 0.92;
188
+ export const REINFORCE_RRF_CAP = (1 / (60 + 1)) - (1 / (60 + 4));
182
189
 
183
190
  /* ------------------------------------------------------------------ */
184
191
  /* Harmonize + import types (dz-vector-harmonize-import 05 §2.1/§2.2) */
@@ -262,6 +269,10 @@ export interface ReindexVectorReport {
262
269
  readonly error?: string;
263
270
  }
264
271
 
272
+ export type TeachGuardResult =
273
+ | { readonly action: 'teach'; readonly reason?: string }
274
+ | { readonly action: 'reinforce'; readonly dzId: string; readonly cosine: number };
275
+
265
276
  /* ------------------------------------------------------------------ */
266
277
  /* Timeout wrapper (both legs — NC1/QR-1) */
267
278
  /* ------------------------------------------------------------------ */
@@ -351,6 +362,7 @@ export function dreamVectorEntry(d: DreamPattern): VectorEntry | undefined {
351
362
  /** ACL: stored {@link MemoryRecord} → {@link VectorEntry} (the consolidate-backfill mapper). */
352
363
  export function memoryRecordVectorEntry(r: MemoryRecord): VectorEntry | undefined {
353
364
  if (isVectorNoise(r.text)) return undefined;
365
+ const state = readReinforcementState(r);
354
366
  return {
355
367
  dzId: r.id,
356
368
  text: r.text,
@@ -358,6 +370,8 @@ export function memoryRecordVectorEntry(r: MemoryRecord): VectorEntry | undefine
358
370
  taskType: r.id.startsWith('dream:') ? 'dz-learning' : 'dz-teach',
359
371
  tags: ['dz-backfill', r.outcome],
360
372
  metadata: { dzId: r.id, source: r.metadata?.['source'] ?? 'dz-backfill', ts: r.timestamp, skillId: r.skillId },
373
+ uses: state.uses,
374
+ avgReward: state.avgReward,
361
375
  };
362
376
  }
363
377
 
@@ -726,6 +740,14 @@ export function mergeHybridHits(
726
740
  }));
727
741
  }
728
742
 
743
+ function markRecallHits(projectRoot: string, backend: LearningSignalBackend, hits: readonly HybridHit[], idOf: (p: PatternRecord) => string): void {
744
+ const cfg = readMemoryLearningConfig(projectRoot);
745
+ if (cfg.backend === 'off' || cfg.onRecallHits === false) return;
746
+ const ts = new Date().toISOString();
747
+ for (const h of hits) backend.addSample({ dzId: idOf(h.pattern), kind: 'recall-hit', reward: h.pattern.reward, ts });
748
+ void backend.train().catch(() => undefined);
749
+ }
750
+
729
751
  /**
730
752
  * Hybrid recall (FR-3): lexical `recallPatterns` FIRST (always, sync, UNCHANGED — AC-5), then a
731
753
  * time-bounded semantic leg merged via RRF. Degradation contract (I-1): with no engine — or on
@@ -749,23 +771,50 @@ export async function recallHybrid(
749
771
  const mode = opts.mode ?? 'hybrid';
750
772
  const lexical = recallPatterns(projectRoot, query, limit);
751
773
  const lexicalBackend: 'sqlite' | 'json' = lexical[0]?.backend === 'sqlite' ? 'sqlite' : 'json';
774
+ const records = loadStoreRecords(projectRoot);
775
+ const idToRecord = new Map<string, MemoryRecord>();
776
+ const identityToId = new Map<string, string>();
777
+ for (const r of records) {
778
+ idToRecord.set(r.id, r);
779
+ identityToId.set(patternIdentityOf(recordToPattern(r)), r.id);
780
+ }
781
+ const idOf = (p: PatternRecord): string => identityToId.get(patternIdentityOf(p)) ?? patternRecordId(p);
782
+ const learning = resolveLearningBackend(projectRoot);
783
+ const enhance = (hits: readonly HybridHit[]): HybridHit[] => applyLearningSignals(
784
+ hits,
785
+ learning,
786
+ hits.map((h) => {
787
+ const dzId = idOf(h.pattern);
788
+ const rec = idToRecord.get(dzId);
789
+ return { dzId, score: h.score, reinforcement: rec !== undefined ? readReinforcementState(rec) : undefined };
790
+ }),
791
+ REINFORCE_RRF_CAP,
792
+ );
752
793
  const lexicalOnly = (extra: Partial<Pick<HybridRecall, 'vectorEngine' | 'vectorReason' | 'vectorError'>>): HybridRecall => ({
753
- hits: lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) })),
794
+ hits: enhance(lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) }))),
754
795
  lexicalBackend,
755
796
  vectorEngine: 'none',
756
797
  ...extra,
757
798
  });
758
799
 
759
- if (mode === 'lexical') return lexicalOnly({});
800
+ if (mode === 'lexical') {
801
+ const out = lexicalOnly({});
802
+ markRecallHits(projectRoot, learning, out.hits, idOf);
803
+ return out;
804
+ }
760
805
 
761
806
  let resolved: ResolvedVectorEngine;
762
807
  try {
763
808
  resolved = pickEngine(projectRoot, opts);
764
809
  } catch (err) {
765
- return lexicalOnly({ vectorReason: err instanceof Error ? err.message : String(err) });
810
+ const out = lexicalOnly({ vectorReason: err instanceof Error ? err.message : String(err) });
811
+ markRecallHits(projectRoot, learning, out.hits, idOf);
812
+ return out;
766
813
  }
767
814
  if (resolved.engine === undefined) {
768
- return lexicalOnly(resolved.reason !== undefined ? { vectorReason: resolved.reason } : {});
815
+ const out = lexicalOnly(resolved.reason !== undefined ? { vectorReason: resolved.reason } : {});
816
+ markRecallHits(projectRoot, learning, out.hits, idOf);
817
+ return out;
769
818
  }
770
819
  const engine = resolved.engine;
771
820
  const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
@@ -779,22 +828,16 @@ export async function recallHybrid(
779
828
  () => ({ hits: [] as VectorHit[], error: `vector search timed out after ${timeoutMs}ms` }),
780
829
  );
781
830
  if (sr.error !== undefined) {
782
- return { ...lexicalOnly({}), vectorEngine: engine.kind, vectorError: sr.error };
831
+ const out = { ...lexicalOnly({}), vectorEngine: engine.kind, vectorError: sr.error };
832
+ markRecallHits(projectRoot, learning, out.hits, idOf);
833
+ return out;
783
834
  }
784
835
 
785
836
  // Resolve dzId → the FULL lexical record (source of truth). Orphans are dropped (V-1/QR-4).
786
- let records: MemoryRecord[];
787
- try {
788
- records = loadStoreRecords(projectRoot);
789
- } catch {
790
- records = [];
791
- }
792
837
  const idToPattern = new Map<string, PatternRecord>();
793
- const identityToId = new Map<string, string>();
794
838
  for (const r of records) {
795
839
  const p = recordToPattern(r);
796
840
  idToPattern.set(r.id, p);
797
- identityToId.set(patternIdentityOf(p), r.id);
798
841
  }
799
842
  const semantic: RankedPattern[] = [];
800
843
  const seen = new Set<string>();
@@ -810,10 +853,34 @@ export async function recallHybrid(
810
853
  pattern: h.pattern,
811
854
  backend: h.backend,
812
855
  }));
813
- const hits = mergeHybridHits(lex, semantic, { limit, semanticWeight: mode === 'semantic' ? 2 : 1 });
856
+ const hits = enhance(mergeHybridHits(lex, semantic, { limit, semanticWeight: mode === 'semantic' ? 2 : 1 }));
857
+ markRecallHits(projectRoot, learning, hits, idOf);
814
858
  return { hits, lexicalBackend, vectorEngine: engine.kind };
815
859
  }
816
860
 
861
+ export async function teachGuard(
862
+ projectRoot: string,
863
+ text: string,
864
+ opts: VectorServiceOptions & { readonly reward?: number | undefined; readonly threshold?: number | undefined } = {},
865
+ ): Promise<TeachGuardResult> {
866
+ const cfg = readMemoryLearningConfig(projectRoot);
867
+ const threshold = Math.max(0.95, opts.threshold ?? cfg.reinforceThreshold);
868
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
869
+ const result = await withVectorTimeout(
870
+ searchAgentdbPatterns(projectRoot, text, { limit: 1 }),
871
+ timeoutMs,
872
+ () => ({ hits: [], error: `teach guard timed out after ${timeoutMs}ms` }),
873
+ );
874
+ if (result.error !== undefined) return { action: 'teach', reason: result.error };
875
+ const hit = result.hits[0];
876
+ if (hit === undefined || hit.dzId === undefined || hit.similarity < threshold) return { action: 'teach' };
877
+ const rec = loadStoreRecords(projectRoot).find((r) => r.id === hit.dzId);
878
+ if (opts.reward !== undefined && rec !== undefined && Math.abs(rec.score - opts.reward) > 0.000001) {
879
+ return { action: 'teach', reason: 'reward differs; preserving corrected-reward fork' };
880
+ }
881
+ return { action: 'reinforce', dzId: hit.dzId, cosine: hit.similarity };
882
+ }
883
+
817
884
  /* ------------------------------------------------------------------ */
818
885
  /* Status (dz vector status / dz doctor divergence line) */
819
886
  /* ------------------------------------------------------------------ */
@@ -963,8 +1030,15 @@ function buildCluster(
963
1030
  const members = indices.map((i) => items[i]!);
964
1031
  const keeperIdx = indices[selectClusterKeeper(members)]!;
965
1032
  const keeper = items[keeperIdx]!;
1033
+ // FORK-PRESERVATION CARVE-OUT (the ADR's #1 safety property — distinct-lesson-never-lost): a
1034
+ // SAME-TEXT member whose reward differs from the keeper's is a fork-on-corrected-reward
1035
+ // (patterns.ts:279-284 intent) — the CORRECTION must survive even when it is a DOWNGRADE
1036
+ // (keeper = max reward would otherwise delete it). Mirrors teachGuard's reward-diff ε. Scoped to
1037
+ // SAME TEXT only: semantically-similar-but-differently-worded near-dups still merge normally
1038
+ // (that IS harmonize's job; the keeper keeps max reward + provenance).
966
1039
  const drops = indices
967
1040
  .filter((i) => i !== keeperIdx)
1041
+ .filter((i) => !(items[i]!.text === keeper.text && Math.abs(items[i]!.reward - keeper.reward) > 0.000001))
968
1042
  .map((i) => ({ dzId: items[i]!.dzId, text: items[i]!.text, reward: items[i]!.reward, cos: cosToKeeper(i, keeperIdx) }));
969
1043
  return { keep: { dzId: keeper.dzId, text: keeper.text, reward: keeper.reward, ts: keeper.ts }, drops };
970
1044
  }
@@ -981,7 +1055,8 @@ function semanticClusters(items: readonly HarmonizeItem[], vecs: readonly Float3
981
1055
  const clusters: HarmonizeCluster[] = [];
982
1056
  for (const comp of connectedComponents(n, edges)) {
983
1057
  if (comp.length < 2) continue;
984
- clusters.push(buildCluster(items, comp, (d, k) => cosineSimilarity(vecs[d]!, vecs[k]!)));
1058
+ const c = buildCluster(items, comp, (d, k) => cosineSimilarity(vecs[d]!, vecs[k]!));
1059
+ if (c.drops.length > 0) clusters.push(c); // all members carved out as reward-forks ⇒ no-op cluster
985
1060
  }
986
1061
  return clusters;
987
1062
  }
@@ -997,7 +1072,8 @@ function exactClusters(items: readonly HarmonizeItem[]): HarmonizeCluster[] {
997
1072
  const clusters: HarmonizeCluster[] = [];
998
1073
  for (const indices of byText.values()) {
999
1074
  if (indices.length < 2) continue;
1000
- clusters.push(buildCluster(items, indices, () => 1.0));
1075
+ const c = buildCluster(items, indices, () => 1.0);
1076
+ if (c.drops.length > 0) clusters.push(c); // same-text reward-forks are preserved, not merged
1001
1077
  }
1002
1078
  return clusters;
1003
1079
  }
@@ -1124,6 +1200,28 @@ export async function harmonizeVectorStore(projectRoot: string, opts: HarmonizeO
1124
1200
  // Backup write failed ⇒ ABORT the drop (no partial mutation — the store is untouched).
1125
1201
  return { ...base, error: `backup failed — drop aborted: ${snap.error}` };
1126
1202
  }
1203
+ for (const c of clusters) {
1204
+ const keepRec = records.find((r) => r.id === c.keep.dzId);
1205
+ if (keepRec === undefined) continue;
1206
+ const keepState = readReinforcementState(keepRec);
1207
+ let uses = keepState.uses + c.drops.length;
1208
+ const rewards = [c.keep.reward];
1209
+ const mergedFrom = [...keepState.mergedFrom];
1210
+ for (const d of c.drops) {
1211
+ const rec = records.find((r) => r.id === d.dzId);
1212
+ const st = rec !== undefined ? readReinforcementState(rec) : undefined;
1213
+ uses += st?.uses ?? 0;
1214
+ rewards.push(d.reward);
1215
+ if (st !== undefined) mergedFrom.push(...st.mergedFrom);
1216
+ mergedFrom.push(d.dzId);
1217
+ }
1218
+ await updateReinforcementState(projectRoot, c.keep.dzId, {
1219
+ uses,
1220
+ lastUsedTs: new Date().toISOString(),
1221
+ avgReward: rewards.reduce((a, b) => a + b, 0) / rewards.length,
1222
+ mergedFrom,
1223
+ });
1224
+ }
1127
1225
  const removal = removePatternsByIds(projectRoot, dropDzIds);
1128
1226
  logHarmonizeNote(projectRoot, { dropped: removal.removed, kept, engine: engineKind, error: removal.error });
1129
1227
  return { ...base, backupPath, ...(removal.error !== undefined ? { error: removal.error } : {}) };
@@ -1264,6 +1362,8 @@ function agentdbVectorEngine(projectRoot: string): VectorEngine {
1264
1362
  score: e.score,
1265
1363
  ...(e.tags !== undefined ? { tags: e.tags } : {}),
1266
1364
  ...(e.metadata !== undefined ? { metadata: e.metadata } : {}),
1365
+ ...(e.uses !== undefined ? { uses: e.uses } : {}),
1366
+ ...(e.avgReward !== undefined ? { avgReward: e.avgReward } : {}),
1267
1367
  })),
1268
1368
  );
1269
1369
  return { indexed: r.indexed, ...(r.error !== undefined ? { error: r.error } : {}) };