@dzhechkov/harness-core 0.3.76 → 0.3.80

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,1458 @@
1
+ /**
2
+ * Vector Tier — the SEMANTIC half of the dz self-learning pattern store
3
+ * (`features/dz-rvf-vector-bridge`, ADR-001 "Option A extended").
4
+ *
5
+ * One PORT ({@link VectorEngine}) with two adapters behind it:
6
+ *
7
+ * - **AgentdbVectorEngine** (default) — reuses the `.dz/agentdb.db` ReasoningBank store the
8
+ * consolidate Option-C mirror already writes (`agentdb-index.ts`), so semantic recall reads
9
+ * the vectors that exist today. Zero new dependencies: `agentdb`/`better-sqlite3` are
10
+ * dynamically resolved from the PROJECT, never imported at module top level.
11
+ * - **RvfVectorEngine** (opt-in, `memory.vector.engine = "rvf"`) — the portable single-file
12
+ * VECTOR form (`.dz/memory/patterns.rvf` + `.idmap.json`/`.manifest.json` sidecars) via a
13
+ * lazily imported `@ruvector/rvf`. Never a `dependencies` entry (at most a documentation-only
14
+ * `peerDependenciesMeta`).
15
+ *
16
+ * HONEST-ERROR CONTRACT (load-bearing, Invariant I-1): every function in this module returns an
17
+ * honest `{ …, error?: string }` receipt and NEVER throws or hangs — engine absence, a failed
18
+ * embed, a locked DB, or a timeout all degrade to today's exact lexical behavior. The lexical
19
+ * store (`patterns.sqlite`/`patterns.json`) is the SOURCE OF TRUTH; the vector tier is a
20
+ * best-effort MIRROR that can be rebuilt from it at any time (`dz consolidate` backfill), and a
21
+ * vector hit whose lexical twin is gone is DROPPED, never resurrected (Invariant V-1).
22
+ *
23
+ * Constraint 7 (QR-10, field-diagnosis discipline): when lexical and vector counts diverge,
24
+ * **rule out local DB corruption before blaming the mirror** — check `dz doctor`'s store health
25
+ * first; the mirror's own divergence line (`dz vector status`) reports BOTH counts plus the
26
+ * `dz consolidate` backfill hint, and is informational, never an error.
27
+ *
28
+ * Both engine calls (read `search` AND write `upsert`/`listIds`) are wall-time bounded
29
+ * ({@link DEFAULT_VECTOR_TIMEOUT_MS}); a write-side timeout (e.g. a first-run embedding-model
30
+ * download) lands the batch in `.dz/mirror-pending.json` and never blocks `dz teach` (NC1).
31
+ *
32
+ * @packageDocumentation
33
+ */
34
+
35
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, renameSync, appendFileSync, copyFileSync } from 'node:fs';
36
+ import { basename, dirname, join } from 'node:path';
37
+ import { pathToFileURL } from 'node:url';
38
+ import { createRequire } from 'node:module';
39
+
40
+ import { isNoiseInsight } from '@dzhechkov/memory';
41
+ import type { DreamPattern, MemoryRecord } from '@dzhechkov/memory';
42
+
43
+ import {
44
+ recallPatterns,
45
+ recordToPattern,
46
+ patternRecordId,
47
+ patternIdentityOf,
48
+ dreamRecordId,
49
+ loadStoreRecords,
50
+ removePatternsByIds,
51
+ snapshotStore,
52
+ type PatternRecord,
53
+ type RecallHit,
54
+ } from './patterns.js';
55
+ import {
56
+ indexPatternsToAgentdb,
57
+ searchAgentdbPatterns,
58
+ listAgentdbDzIds,
59
+ resolveAgentdbEmbedder,
60
+ cosineSimilarity,
61
+ importVectorsToAgentdb,
62
+ } from './agentdb-index.js';
63
+
64
+ /* ------------------------------------------------------------------ */
65
+ /* Types (04_domain_model §3.4 / §4.1) */
66
+ /* ------------------------------------------------------------------ */
67
+
68
+ /** Which adapter sits behind the port. */
69
+ export type VectorEngineKind = 'agentdb' | 'rvf';
70
+
71
+ /** `memory.vector.engine` config modes (`.dz/config.json`). Absent/corrupt ⇒ `auto`. */
72
+ export type VectorEngineMode = 'auto' | 'agentdb' | 'rvf' | 'off';
73
+
74
+ /** One record on its way into the vector store (the ACL between dz records and engines). */
75
+ export interface VectorEntry {
76
+ /** Join key back to the lexical store — the canonical `MemoryRecord.id` (`teach:…`/`dream:…`). */
77
+ readonly dzId: string;
78
+ /** The text that gets embedded (as `${taskType}: ${text}`) and stored. */
79
+ readonly text: string;
80
+ /** REAL reward signal in [0,1] — never a fabricated 1.0. */
81
+ readonly score: number;
82
+ /** ReasoningBank task_type: `dz-teach` for taught patterns, `dz-learning` for consolidate dreams. */
83
+ readonly taskType: string;
84
+ readonly tags?: readonly string[] | undefined;
85
+ readonly metadata?: Record<string, unknown> | undefined;
86
+ }
87
+
88
+ /** One semantic search hit — a POINTER into the lexical store, never a pattern by itself. */
89
+ export interface VectorHit {
90
+ readonly dzId: string;
91
+ /** Cosine similarity (or engine-native score), higher = closer. */
92
+ readonly similarity: number;
93
+ readonly text?: string | undefined;
94
+ }
95
+
96
+ /** Honest outcome of a mirror attempt. Never thrown — always returned. */
97
+ export interface MirrorReceipt {
98
+ /** Entries newly written to the vector store this call. */
99
+ readonly mirrored: number;
100
+ /** Entries skipped by the noise gate or the dzId dedup (already mirrored — I-5). */
101
+ readonly skipped: number;
102
+ /** Entries parked in `.dz/mirror-pending.json` for the next consolidate to heal (I-3). */
103
+ readonly queued: number;
104
+ readonly engine?: VectorEngineKind | undefined;
105
+ readonly error?: string | undefined;
106
+ }
107
+
108
+ /** One precomputed vector to upsert by its content-addressed `dzId` (the `dz vector import` row). */
109
+ export interface ImportVectorRow {
110
+ readonly dzId: string;
111
+ readonly vector: Float32Array;
112
+ readonly text: string;
113
+ readonly taskType: string;
114
+ readonly score: number;
115
+ readonly metadata?: Record<string, unknown> | undefined;
116
+ }
117
+
118
+ /** The engine PORT — both adapters implement exactly this surface (04 §4.1). */
119
+ export interface VectorEngine {
120
+ readonly kind: VectorEngineKind;
121
+ upsert(entries: readonly VectorEntry[]): Promise<{ indexed: number; error?: string | undefined }>;
122
+ search(query: string, limit: number): Promise<{ hits: VectorHit[]; error?: string | undefined }>;
123
+ listIds(): Promise<{ ids: string[]; error?: string | undefined }>;
124
+ /** Portable single-file checkpoint (RVF adapter only — `dz vector export`). */
125
+ exportCheckpoint?(dest: string): Promise<{ error?: string | undefined }>;
126
+ /**
127
+ * Write precomputed `{ dzId, vector }` rows by id (`dz vector import`) — UPSERT-BY-dzId, never a
128
+ * blind whole-store overwrite. Optional (like {@link VectorEngine.exportCheckpoint}): an engine
129
+ * that cannot take a precomputed vector reports an honest reason; import degrades, never throws.
130
+ */
131
+ importVectors?(rows: readonly ImportVectorRow[]): Promise<{ imported: number; error?: string | undefined }>;
132
+ }
133
+
134
+ /** Outcome of {@link resolveVectorEngine}: an engine, or an honest reason why not. */
135
+ export interface ResolvedVectorEngine {
136
+ readonly engine?: VectorEngine | undefined;
137
+ readonly reason?: string | undefined;
138
+ }
139
+
140
+ /** Recall mode: `hybrid` (default), `semantic` (`--semantic`, 2× vector weight), `lexical` (`--no-semantic`). */
141
+ export type HybridRecallMode = 'hybrid' | 'semantic' | 'lexical';
142
+
143
+ /** One merged recall hit (RRF-scored). `pattern` ALWAYS comes from the lexical store (V-1). */
144
+ export interface HybridHit {
145
+ readonly pattern: PatternRecord;
146
+ readonly backend: RecallHit['backend'];
147
+ /** Reciprocal-rank-fusion score (ranking only — NOT the pattern's reward). */
148
+ readonly score: number;
149
+ }
150
+
151
+ /** Outcome of {@link recallHybrid}. With no engine this is content-identical to `recallPatterns`. */
152
+ export interface HybridRecall {
153
+ readonly hits: HybridHit[];
154
+ readonly lexicalBackend: 'sqlite' | 'json';
155
+ readonly vectorEngine: VectorEngineKind | 'none';
156
+ /** Why the vector tier did not participate (engine absent / disabled). */
157
+ readonly vectorReason?: string | undefined;
158
+ /** Engine was present but the search failed/timed out — lexical results returned instead. */
159
+ readonly vectorError?: string | undefined;
160
+ }
161
+
162
+ /** Field observability for `dz vector status` (I-2/I-5 in the field). */
163
+ export interface VectorTierStatus {
164
+ readonly mode: VectorEngineMode;
165
+ readonly kind?: VectorEngineKind | undefined;
166
+ readonly available: boolean;
167
+ readonly reason?: string | undefined;
168
+ readonly lexicalTotal: number;
169
+ readonly lexicalMirrorable: number;
170
+ readonly mirrored?: number | undefined;
171
+ readonly pending: number;
172
+ }
173
+
174
+ /** Wall-time bound applied to EVERY engine call, read and write legs alike (ADR R1 + NC1). */
175
+ export const DEFAULT_VECTOR_TIMEOUT_MS = 10_000;
176
+
177
+ /** The pinned local embedding model + dimension (agentdb's `EmbeddingService`; the RVF manifest space). */
178
+ const LOCAL_EMBED_MODEL = 'Xenova/all-MiniLM-L6-v2';
179
+ const LOCAL_EMBED_DIM = 384;
180
+
181
+ /** Default cosine cutoff for near-duplicate clustering (`--threshold` / config overrides). */
182
+ export const DEFAULT_HARMONIZE_THRESHOLD = 0.92;
183
+
184
+ /* ------------------------------------------------------------------ */
185
+ /* Harmonize + import types (dz-vector-harmonize-import 05 §2.1/§2.2) */
186
+ /* ------------------------------------------------------------------ */
187
+
188
+ /** One record in the harmonize pool — a lexical-store record mapped to its dzId + reward + ts. */
189
+ export interface HarmonizeItem {
190
+ readonly dzId: string;
191
+ readonly text: string;
192
+ readonly reward: number;
193
+ readonly ts: string;
194
+ readonly taskType: string;
195
+ }
196
+
197
+ /** One near-duplicate cluster: the surviving keeper + the members that would be / were dropped. */
198
+ export interface HarmonizeCluster {
199
+ readonly keep: { readonly dzId: string; readonly text: string; readonly reward: number; readonly ts: string };
200
+ readonly drops: readonly { readonly dzId: string; readonly text: string; readonly reward: number; readonly cos: number }[];
201
+ }
202
+
203
+ /** Outcome of {@link harmonizeVectorStore}. */
204
+ export interface HarmonizeReport {
205
+ readonly mode: 'dry-run' | 'apply';
206
+ /** Resolved engine kind, or `'none'` when there is no engine. */
207
+ readonly engine: string;
208
+ /** True when semantic dedup was unavailable and the store was harmonized by EXACT text only. */
209
+ readonly fellBackToExact: boolean;
210
+ readonly threshold: number;
211
+ readonly clusters: readonly HarmonizeCluster[];
212
+ /** Number of clusters (size ≥ 2) — one keeper survives per cluster. */
213
+ readonly kept: number;
214
+ /** Total non-keeper members (previewed in dry-run, removed on `--apply`). */
215
+ readonly dropped: number;
216
+ /** Singleton (non-duplicate) patterns — NEVER touched. */
217
+ readonly unique: number;
218
+ /** Backup path written before an `--apply` drop (restorable via `dz teach --from-json`). */
219
+ readonly backupPath?: string | undefined;
220
+ /** Honest reason on failure (e.g. a backup write failed and the drop was aborted). */
221
+ readonly error?: string | undefined;
222
+ }
223
+
224
+ /** Options for {@link harmonizeVectorStore}. */
225
+ export interface HarmonizeOptions extends VectorServiceOptions {
226
+ /** Perform the drop (default `false` — dry-run previews and writes nothing). */
227
+ readonly apply?: boolean | undefined;
228
+ /** Cosine cutoff in `(0, 1]`; overrides config + the {@link DEFAULT_HARMONIZE_THRESHOLD} default. */
229
+ readonly threshold?: number | undefined;
230
+ /**
231
+ * Inject an embedder (tests): a function ⇒ semantic path with these embeddings; `null` ⇒ force the
232
+ * exact-text fallback; `undefined` ⇒ resolve the project's agentdb embedder.
233
+ */
234
+ readonly embed?: ((text: string) => Promise<Float32Array>) | null | undefined;
235
+ }
236
+
237
+ /** Outcome of {@link importRvfCheckpoint}. */
238
+ export interface ImportReport {
239
+ /** Vectors upserted by dzId (new + replaced). */
240
+ readonly imported: number;
241
+ /** Source dzIds skipped because no local pattern exists (text must be imported first). */
242
+ readonly skippedOrphans: number;
243
+ /** Resolved target engine kind, or `'none'`. */
244
+ readonly engine: string;
245
+ /** The source `.rvf` path. */
246
+ readonly source: string;
247
+ readonly error?: string | undefined;
248
+ }
249
+
250
+ /** Options for {@link importRvfCheckpoint}. */
251
+ export interface ImportOptions extends VectorServiceOptions {
252
+ /** Inject the source `{ dzId, vector }` rows (tests) — bypasses the `.rvf`/idmap file reads. */
253
+ readonly sourceRows?: readonly { readonly dzId: string; readonly vector: Float32Array }[] | undefined;
254
+ /** Inject an embedder (tests) for the local-text re-embed; else the project's agentdb embedder. */
255
+ readonly embed?: ((text: string) => Promise<Float32Array>) | undefined;
256
+ }
257
+
258
+ /* ------------------------------------------------------------------ */
259
+ /* Timeout wrapper (both legs — NC1/QR-1) */
260
+ /* ------------------------------------------------------------------ */
261
+
262
+ /**
263
+ * Bound `promise` to `ms` wall-clock milliseconds. On timeout, resolve with `onTimeout()`
264
+ * instead — the underlying operation keeps running detached (its eventual write is later
265
+ * deduplicated by dzId), but the CALLER's latency is bounded. A rejection also resolves via
266
+ * `onTimeout()` (honest-error contract: this wrapper never throws). The timer is cleared /
267
+ * unref'd so a fast path never keeps the process alive.
268
+ */
269
+ export async function withVectorTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => T): Promise<T> {
270
+ let timer: ReturnType<typeof setTimeout> | undefined;
271
+ try {
272
+ return await Promise.race([
273
+ promise.catch(() => onTimeout()),
274
+ new Promise<T>((resolvePromise) => {
275
+ timer = setTimeout(() => resolvePromise(onTimeout()), ms);
276
+ timer.unref?.();
277
+ }),
278
+ ]);
279
+ } finally {
280
+ if (timer !== undefined) clearTimeout(timer);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Invoke an engine call so that BOTH a synchronous throw and an async rejection surface as an
286
+ * honest `onError(message)` value (never as the timeout fallback — a throw and a timeout are
287
+ * different diagnoses in the field). Pairs with {@link withVectorTimeout} at every call site.
288
+ */
289
+ function safeEngineCall<T>(fn: () => Promise<T>, onError: (message: string) => T): Promise<T> {
290
+ const msg = (err: unknown): string => (err instanceof Error ? err.message : String(err));
291
+ try {
292
+ return fn().then((v) => v, (err: unknown) => onError(msg(err)));
293
+ } catch (err) {
294
+ return Promise.resolve(onError(msg(err)));
295
+ }
296
+ }
297
+
298
+ /* ------------------------------------------------------------------ */
299
+ /* Noise gate + ACL mappers (I-6, V-3) */
300
+ /* ------------------------------------------------------------------ */
301
+
302
+ const TOOL_TELEMETRY_RE = /^Tool \S+ invoked during session$/;
303
+
304
+ /** Text that must never be embedded: bare-approval echoes + tool telemetry (V-3 / ADR-002). */
305
+ export function isVectorNoise(text: string): boolean {
306
+ return isNoiseInsight(text) || TOOL_TELEMETRY_RE.test(text);
307
+ }
308
+
309
+ /**
310
+ * ACL: taught {@link PatternRecord} → {@link VectorEntry}. Returns `undefined` for noise (the
311
+ * ingest gate — I-6). Score is the record's REAL reward, never a fabricated 1.0.
312
+ */
313
+ export function patternVectorEntry(p: PatternRecord, source = 'dz-teach'): VectorEntry | undefined {
314
+ if (isVectorNoise(p.pattern)) return undefined;
315
+ const dzId = patternRecordId(p);
316
+ return {
317
+ dzId,
318
+ text: p.pattern,
319
+ score: p.reward,
320
+ taskType: 'dz-teach',
321
+ tags: ['dz-teach', p.type],
322
+ metadata: { dzId, source, ts: p.ts, domain: p.domain },
323
+ };
324
+ }
325
+
326
+ /**
327
+ * ACL: harvested {@link DreamPattern} → {@link VectorEntry}. Byte-compatible with the
328
+ * pre-feature consolidate mirror rows (same task_type `dz-learning`, same tags, `dreamId`
329
+ * preserved in metadata — NFR-7); `dzId` is additive.
330
+ */
331
+ export function dreamVectorEntry(d: DreamPattern): VectorEntry | undefined {
332
+ if (isVectorNoise(d.insight)) return undefined;
333
+ const dzId = dreamRecordId(d);
334
+ return {
335
+ dzId,
336
+ text: d.insight,
337
+ score: d.score,
338
+ taskType: 'dz-learning',
339
+ tags: ['dz-consolidate', d.outcome],
340
+ metadata: { source: 'dz-consolidate', skillId: d.skillId, sessionFile: d.sessionFile, ts: d.timestamp, dreamId: dzId, dzId },
341
+ };
342
+ }
343
+
344
+ /** ACL: stored {@link MemoryRecord} → {@link VectorEntry} (the consolidate-backfill mapper). */
345
+ export function memoryRecordVectorEntry(r: MemoryRecord): VectorEntry | undefined {
346
+ if (isVectorNoise(r.text)) return undefined;
347
+ return {
348
+ dzId: r.id,
349
+ text: r.text,
350
+ score: r.score,
351
+ taskType: r.id.startsWith('dream:') ? 'dz-learning' : 'dz-teach',
352
+ tags: ['dz-backfill', r.outcome],
353
+ metadata: { dzId: r.id, source: r.metadata?.['source'] ?? 'dz-backfill', ts: r.timestamp, skillId: r.skillId },
354
+ };
355
+ }
356
+
357
+ /* ------------------------------------------------------------------ */
358
+ /* Config + engine resolution cascade (05 §2.1) */
359
+ /* ------------------------------------------------------------------ */
360
+
361
+ /** Read `memory.vector.engine` from `.dz/config.json`. Absent/corrupt ⇒ `auto` (never throws). */
362
+ export function readVectorEngineMode(projectRoot: string): VectorEngineMode {
363
+ try {
364
+ const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
365
+ memory?: { vector?: { engine?: string } };
366
+ };
367
+ const mode = cfg.memory?.vector?.engine;
368
+ return mode === 'off' || mode === 'agentdb' || mode === 'rvf' || mode === 'auto' ? mode : 'auto';
369
+ } catch {
370
+ return 'auto';
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Read `memory.vector.harmonizeThreshold` from `.dz/config.json`. Absent/corrupt/out-of-range ⇒
376
+ * {@link DEFAULT_HARMONIZE_THRESHOLD} (never throws). `--threshold` overrides this at the call site.
377
+ */
378
+ export function readHarmonizeThreshold(projectRoot: string): number {
379
+ try {
380
+ const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
381
+ memory?: { vector?: { harmonizeThreshold?: unknown } };
382
+ };
383
+ const t = cfg.memory?.vector?.harmonizeThreshold;
384
+ return typeof t === 'number' && t > 0 && t <= 1 ? t : DEFAULT_HARMONIZE_THRESHOLD;
385
+ } catch {
386
+ return DEFAULT_HARMONIZE_THRESHOLD;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Should `dz teach` attempt the best-effort vector mirror at all? True when the project opted
392
+ * into the agentdb memory backend (`memory.backend === 'agentdb'`, the same gate consolidate
393
+ * uses — D3) or explicitly configured a vector engine. A fresh, unconfigured project returns
394
+ * `false`, so its `dz teach` output stays byte-identical to the pre-feature baseline (AC-1).
395
+ */
396
+ export function vectorMirrorEnabled(projectRoot: string): boolean {
397
+ try {
398
+ const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
399
+ memory?: { backend?: string; vector?: { engine?: string } };
400
+ };
401
+ if (cfg.memory?.backend === 'agentdb') return true;
402
+ const engine = cfg.memory?.vector?.engine;
403
+ return engine === 'agentdb' || engine === 'rvf';
404
+ } catch {
405
+ return false;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Engine selection cascade: config mode → `require.resolve` probe (NO module load — a
411
+ * lexical-only project pays zero engine cost, NFR-5) → adapter or an honest reason.
412
+ * Never throws. `auto` prefers agentdb (it reads the vectors consolidate already wrote),
413
+ * falling through to rvf.
414
+ */
415
+ /**
416
+ * Is `pkgName` installed for this PROJECT? A pure filesystem probe: walk `node_modules` up the
417
+ * directory tree from `projectRoot` (the npm resolution chain) — deliberately NOT
418
+ * `require.resolve`, which also consults process-global paths (`NODE_PATH`/global folders) and
419
+ * would make a lexical-only project's engine availability depend on the HOST process instead
420
+ * of the project (the I-1 determinism leak). No module is loaded (NFR-5).
421
+ */
422
+ function isPackageInstalled(projectRoot: string, pkgName: string): boolean {
423
+ let dir = projectRoot;
424
+ for (;;) {
425
+ if (existsSync(join(dir, 'node_modules', pkgName, 'package.json'))) return true;
426
+ const parent = dirname(dir);
427
+ if (parent === dir) return false;
428
+ dir = parent;
429
+ }
430
+ }
431
+
432
+ export function resolveVectorEngine(projectRoot: string): ResolvedVectorEngine {
433
+ const mode = readVectorEngineMode(projectRoot);
434
+ if (mode === 'off') return { reason: 'vector tier disabled (memory.vector.engine = "off")' };
435
+ const canResolve = (id: string): boolean => isPackageInstalled(projectRoot, id);
436
+ if (mode === 'agentdb' || mode === 'auto') {
437
+ if (canResolve('agentdb') && canResolve('better-sqlite3')) return { engine: agentdbVectorEngine(projectRoot) };
438
+ if (mode === 'agentdb') {
439
+ return { reason: 'agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
440
+ }
441
+ }
442
+ if (mode === 'rvf' || mode === 'auto') {
443
+ if (canResolve('@ruvector/rvf')) return { engine: rvfVectorEngine(projectRoot) };
444
+ if (mode === 'rvf') {
445
+ return { reason: '@ruvector/rvf not installed in project (npm i @ruvector/rvf) — vector tier inactive' };
446
+ }
447
+ }
448
+ return { reason: 'no vector engine available: agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
449
+ }
450
+
451
+ /* ------------------------------------------------------------------ */
452
+ /* Pending-mirror queue (generalized — one file for every source) */
453
+ /* ------------------------------------------------------------------ */
454
+
455
+ /** Queue entry: a {@link VectorEntry} plus a legacy-compatible `insight` alias of `text`. */
456
+ type PendingEntry = VectorEntry & { readonly insight: string };
457
+
458
+ function pendingPath(projectRoot: string): string {
459
+ return join(projectRoot, '.dz', 'mirror-pending.json');
460
+ }
461
+
462
+ function toPending(e: VectorEntry): PendingEntry {
463
+ return { ...e, insight: e.text };
464
+ }
465
+
466
+ /** Legacy queue rows (pre-port `DreamPattern[]`) are converted on read — nothing is lost. */
467
+ function readVectorPending(projectRoot: string): PendingEntry[] {
468
+ try {
469
+ const arr = JSON.parse(readFileSync(pendingPath(projectRoot), 'utf-8')) as unknown[];
470
+ if (!Array.isArray(arr)) return [];
471
+ const out: PendingEntry[] = [];
472
+ for (const item of arr) {
473
+ if (typeof item !== 'object' || item === null) continue;
474
+ const rec = item as Record<string, unknown>;
475
+ if (typeof rec['dzId'] === 'string' && typeof rec['text'] === 'string') {
476
+ out.push(toPending(rec as unknown as VectorEntry));
477
+ } else if (typeof rec['insight'] === 'string' && typeof rec['timestamp'] === 'string') {
478
+ const entry = dreamVectorEntry(rec as unknown as DreamPattern);
479
+ if (entry !== undefined) out.push(toPending(entry));
480
+ }
481
+ }
482
+ return out;
483
+ } catch {
484
+ return [];
485
+ }
486
+ }
487
+
488
+ function writeVectorPending(projectRoot: string, entries: readonly PendingEntry[]): void {
489
+ try {
490
+ if (entries.length === 0) {
491
+ if (existsSync(pendingPath(projectRoot))) rmSync(pendingPath(projectRoot));
492
+ } else {
493
+ const path = pendingPath(projectRoot);
494
+ mkdirSync(dirname(path), { recursive: true });
495
+ // Atomic write: a concurrent teach/consolidate must never observe a torn file. Write to a
496
+ // temp sibling then rename() (atomic on POSIX). Recovery guarantee: even if a racing writer
497
+ // clobbers the queue, backfillVectorMirror re-derives the missing set from the lexical store,
498
+ // so a dropped entry is recovered on the next consolidate — no permanent loss.
499
+ const tmp = `${path}.tmp`;
500
+ writeFileSync(tmp, JSON.stringify(entries, null, 2));
501
+ renameSync(tmp, path);
502
+ }
503
+ } catch { /* best-effort */ }
504
+ }
505
+
506
+ /** Honest failure note next to the session telemetry (the detached SessionEnd path is silent). */
507
+ function logMirrorNote(projectRoot: string, error: string, pending: number): void {
508
+ try {
509
+ appendFileSync(
510
+ join(projectRoot, '.dz', 'sessions.jsonl'),
511
+ JSON.stringify({ event: 'mirror', ts: new Date().toISOString(), error, pending }) + '\n',
512
+ );
513
+ } catch { /* best-effort */ }
514
+ }
515
+
516
+ /* ------------------------------------------------------------------ */
517
+ /* VectorMirrorService — the ONE write seam (QR-6) */
518
+ /* ------------------------------------------------------------------ */
519
+
520
+ /** Options shared by the mirror/recall services. `engine: null` force-disables (tests). */
521
+ export interface VectorServiceOptions {
522
+ readonly engine?: VectorEngine | null | undefined;
523
+ readonly timeoutMs?: number | undefined;
524
+ }
525
+
526
+ function pickEngine(projectRoot: string, opts: VectorServiceOptions): ResolvedVectorEngine {
527
+ if (opts.engine === null) return { reason: 'vector engine disabled (injected)' };
528
+ if (opts.engine !== undefined) return { engine: opts.engine };
529
+ return resolveVectorEngine(projectRoot);
530
+ }
531
+
532
+ /**
533
+ * Mirror prepared {@link VectorEntry}s into the vector store — **the single write seam** that
534
+ * teach, `teach --from-json`, consolidate, and the backfill all route through (QR-6). The
535
+ * lexical write is ALWAYS already durable before this runs (I-3). Semantics:
536
+ *
537
+ * 1. noise-gate the entries (I-6), merge with the pending queue (dedup by dzId),
538
+ * 2. nothing to do ⇒ `{mirrored:0}` with NO error and no queue file,
539
+ * 3. engine absent ⇒ park the batch in the queue + honest reason (heals on the next consolidate),
540
+ * 4. dedup against `engine.listIds()` (I-5 idempotency — a re-mirror adds 0 rows),
541
+ * 5. time-bounded `engine.upsert` (NC1); failure/timeout ⇒ queue + `sessions.jsonl` note.
542
+ *
543
+ * NEVER throws; the caller's exit code is unaffected by any outcome here (I-1).
544
+ */
545
+ export async function mirrorEntriesToVector(
546
+ projectRoot: string,
547
+ entries: readonly VectorEntry[],
548
+ opts: VectorServiceOptions = {},
549
+ ): Promise<MirrorReceipt> {
550
+ try {
551
+ let skipped = 0;
552
+ const gated: VectorEntry[] = [];
553
+ for (const e of entries) {
554
+ if (isVectorNoise(e.text)) skipped += 1;
555
+ else gated.push(e);
556
+ }
557
+ const byId = new Map<string, PendingEntry>();
558
+ for (const e of [...readVectorPending(projectRoot), ...gated.map(toPending)]) {
559
+ if (!byId.has(e.dzId)) byId.set(e.dzId, e);
560
+ }
561
+ const batch = [...byId.values()];
562
+ if (batch.length === 0) return { mirrored: 0, skipped, queued: 0 };
563
+
564
+ const resolved = pickEngine(projectRoot, opts);
565
+ if (resolved.engine === undefined) {
566
+ writeVectorPending(projectRoot, batch);
567
+ const error = resolved.reason ?? 'no vector engine available';
568
+ logMirrorNote(projectRoot, error, batch.length);
569
+ return { mirrored: 0, skipped, queued: batch.length, error };
570
+ }
571
+ const engine = resolved.engine;
572
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
573
+
574
+ // I-5 idempotency: skip what the store already holds (best-effort, time-bounded).
575
+ let toSend: PendingEntry[] = batch;
576
+ const listed = await withVectorTimeout(
577
+ safeEngineCall(() => engine.listIds(), (m) => ({ ids: [] as string[], error: `vector listIds failed: ${m}` })),
578
+ timeoutMs,
579
+ () => ({ ids: [] as string[], error: 'vector listIds timed out' }),
580
+ );
581
+ if (listed.error === undefined) {
582
+ const have = new Set(listed.ids);
583
+ const before = toSend.length;
584
+ toSend = toSend.filter((e) => !have.has(e.dzId));
585
+ skipped += before - toSend.length;
586
+ }
587
+ if (toSend.length === 0) {
588
+ writeVectorPending(projectRoot, []);
589
+ return { mirrored: 0, skipped, queued: 0, engine: engine.kind };
590
+ }
591
+
592
+ const up = await withVectorTimeout(
593
+ safeEngineCall(
594
+ () => engine.upsert(toSend.map(({ insight: _insight, ...entry }) => entry)),
595
+ (m) => ({ indexed: 0, error: `vector mirror failed: ${m}` }),
596
+ ),
597
+ timeoutMs,
598
+ () => ({ indexed: 0, error: `vector mirror timed out after ${timeoutMs}ms (batch queued for the next consolidate)` }),
599
+ );
600
+ if (up.error !== undefined) {
601
+ writeVectorPending(projectRoot, toSend);
602
+ logMirrorNote(projectRoot, up.error, toSend.length);
603
+ return { mirrored: up.indexed, skipped, queued: toSend.length, engine: engine.kind, error: up.error };
604
+ }
605
+ writeVectorPending(projectRoot, []);
606
+ return { mirrored: up.indexed, skipped, queued: 0, engine: engine.kind };
607
+ } catch (err) {
608
+ // Belt-and-braces: the mirror must NEVER take the caller down (I-1/I-3).
609
+ return { mirrored: 0, skipped: 0, queued: 0, error: `mirror failed: ${err instanceof Error ? err.message : String(err)}` };
610
+ }
611
+ }
612
+
613
+ /** Convenience seam for taught patterns: ACL-map + delegate to {@link mirrorEntriesToVector}. */
614
+ export async function mirrorPatternsToVector(
615
+ projectRoot: string,
616
+ patterns: readonly PatternRecord[],
617
+ source = 'dz-teach',
618
+ opts: VectorServiceOptions = {},
619
+ ): Promise<MirrorReceipt> {
620
+ const entries: VectorEntry[] = [];
621
+ let gatedOut = 0;
622
+ for (const p of patterns) {
623
+ const e = patternVectorEntry(p, source);
624
+ if (e !== undefined) entries.push(e);
625
+ else gatedOut += 1; // noise never maps (I-6) — reported honestly as skipped
626
+ }
627
+ const receipt = await mirrorEntriesToVector(projectRoot, entries, opts);
628
+ return gatedOut === 0 ? receipt : { ...receipt, skipped: receipt.skipped + gatedOut };
629
+ }
630
+
631
+ /**
632
+ * Eventual consistency (FR-2): diff `lexical dzIds ∖ engine.listIds()` and mirror the missing
633
+ * set (bounded batch) + drain the pending queue. Run by `dz consolidate` after the watermark
634
+ * write, so a teach-time mirror failure heals on the next consolidate (AC-3). Engine absent ⇒
635
+ * silent no-op (the absent tier is a state, not an error).
636
+ */
637
+ export async function backfillVectorMirror(
638
+ projectRoot: string,
639
+ opts: VectorServiceOptions & { readonly batchLimit?: number | undefined } = {},
640
+ ): Promise<MirrorReceipt> {
641
+ try {
642
+ const resolved = pickEngine(projectRoot, opts);
643
+ if (resolved.engine === undefined) {
644
+ return { mirrored: 0, skipped: 0, queued: readVectorPending(projectRoot).length };
645
+ }
646
+ const engine = resolved.engine;
647
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
648
+ const listed = await withVectorTimeout(
649
+ safeEngineCall(() => engine.listIds(), (m) => ({ ids: [] as string[], error: `vector listIds failed: ${m}` })),
650
+ timeoutMs,
651
+ () => ({ ids: [] as string[], error: 'vector listIds timed out' }),
652
+ );
653
+ if (listed.error !== undefined) {
654
+ return { mirrored: 0, skipped: 0, queued: readVectorPending(projectRoot).length, engine: engine.kind, error: listed.error };
655
+ }
656
+ const have = new Set(listed.ids);
657
+ const limit = opts.batchLimit ?? 200;
658
+ const missing: VectorEntry[] = [];
659
+ for (const r of loadStoreRecords(projectRoot)) {
660
+ if (have.has(r.id)) continue;
661
+ const e = memoryRecordVectorEntry(r);
662
+ if (e === undefined) continue;
663
+ missing.push(e);
664
+ if (missing.length >= limit) break;
665
+ }
666
+ // The seam drains the pending queue too (it merges + dedups internally).
667
+ return mirrorEntriesToVector(projectRoot, missing, { engine, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
668
+ } catch (err) {
669
+ return { mirrored: 0, skipped: 0, queued: 0, error: `backfill failed: ${err instanceof Error ? err.message : String(err)}` };
670
+ }
671
+ }
672
+
673
+ /* ------------------------------------------------------------------ */
674
+ /* HybridRecallService — the read seam (05 §2.3) */
675
+ /* ------------------------------------------------------------------ */
676
+
677
+ /** One ranked pattern feeding the RRF merge (exported so the merge is unit-testable pure). */
678
+ export interface RankedPattern {
679
+ readonly id: string;
680
+ readonly pattern: PatternRecord;
681
+ readonly backend: RecallHit['backend'];
682
+ }
683
+
684
+ const RRF_K = 60;
685
+
686
+ /**
687
+ * Reciprocal Rank Fusion merge: `score(p) = Σ 1/(60 + rank)` over the lists containing `p`
688
+ * (semantic ranks weighted by `semanticWeight`). Dedup by id; `backend: 'both'` when a pattern
689
+ * appears in both lists. DETERMINISTIC (AC-6): ties break on id, so fixed inputs always yield
690
+ * the same ordering. Pure — no I/O.
691
+ */
692
+ export function mergeHybridHits(
693
+ lexical: readonly RankedPattern[],
694
+ semantic: readonly RankedPattern[],
695
+ opts: { readonly limit: number; readonly semanticWeight?: number | undefined },
696
+ ): HybridHit[] {
697
+ const weight = opts.semanticWeight ?? 1;
698
+ interface Acc { pattern: PatternRecord; lex?: RecallHit['backend']; sem: boolean; score: number }
699
+ const acc = new Map<string, Acc>();
700
+ lexical.forEach((h, rank) => {
701
+ const cur = acc.get(h.id) ?? { pattern: h.pattern, sem: false, score: 0 };
702
+ cur.lex = h.backend;
703
+ cur.score += 1 / (RRF_K + rank + 1);
704
+ acc.set(h.id, cur);
705
+ });
706
+ semantic.forEach((h, rank) => {
707
+ const cur = acc.get(h.id) ?? { pattern: h.pattern, sem: false, score: 0 };
708
+ cur.sem = true;
709
+ cur.score += weight / (RRF_K + rank + 1);
710
+ acc.set(h.id, cur);
711
+ });
712
+ return [...acc.entries()]
713
+ .sort((a, b) => b[1].score - a[1].score || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
714
+ .slice(0, opts.limit)
715
+ .map(([, v]) => ({
716
+ pattern: v.pattern,
717
+ backend: v.lex !== undefined && v.sem ? ('both' as const) : v.lex ?? ('vector' as const),
718
+ score: v.score,
719
+ }));
720
+ }
721
+
722
+ /**
723
+ * Hybrid recall (FR-3): lexical `recallPatterns` FIRST (always, sync, UNCHANGED — AC-5), then a
724
+ * time-bounded semantic leg merged via RRF. Degradation contract (I-1): with no engine — or on
725
+ * any engine error/timeout — the returned hits are CONTENT-IDENTICAL to plain `recallPatterns`
726
+ * output, with the honest `vectorReason`/`vectorError` alongside. A vector hit whose dzId no
727
+ * longer resolves in the lexical store is DROPPED (V-1 — pruned patterns never resurrect, QR-4).
728
+ */
729
+ export async function recallHybrid(
730
+ projectRoot: string,
731
+ query: string,
732
+ opts: VectorServiceOptions & { readonly limit?: number | undefined; readonly mode?: HybridRecallMode | undefined } = {},
733
+ ): Promise<HybridRecall> {
734
+ // Config-surface note (QE P3, benign by design): recall resolves the engine directly, while teach
735
+ // only mirrors when the memory backend is agentdb (or an engine is explicit). In the window where
736
+ // the agentdb deps are INSTALLED but `memory.backend` hasn't been switched, the semantic leg reads a
737
+ // store teach never populated → empty/foreign hits. That degrades honestly (orphan dzIds are dropped
738
+ // against the lexical store, V-1) and lexical results are always returned, so it never misleads — it
739
+ // only spends a bounded, cached read. Not gated on purpose: a read-only recall must not depend on the
740
+ // write-side backend flag.
741
+ const limit = opts.limit ?? 10;
742
+ const mode = opts.mode ?? 'hybrid';
743
+ const lexical = recallPatterns(projectRoot, query, limit);
744
+ const lexicalBackend: 'sqlite' | 'json' = lexical[0]?.backend === 'sqlite' ? 'sqlite' : 'json';
745
+ const lexicalOnly = (extra: Partial<Pick<HybridRecall, 'vectorEngine' | 'vectorReason' | 'vectorError'>>): HybridRecall => ({
746
+ hits: lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) })),
747
+ lexicalBackend,
748
+ vectorEngine: 'none',
749
+ ...extra,
750
+ });
751
+
752
+ if (mode === 'lexical') return lexicalOnly({});
753
+
754
+ let resolved: ResolvedVectorEngine;
755
+ try {
756
+ resolved = pickEngine(projectRoot, opts);
757
+ } catch (err) {
758
+ return lexicalOnly({ vectorReason: err instanceof Error ? err.message : String(err) });
759
+ }
760
+ if (resolved.engine === undefined) {
761
+ return lexicalOnly(resolved.reason !== undefined ? { vectorReason: resolved.reason } : {});
762
+ }
763
+ const engine = resolved.engine;
764
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
765
+
766
+ const sr = await withVectorTimeout(
767
+ safeEngineCall(
768
+ () => engine.search(query, limit * 2),
769
+ (m) => ({ hits: [] as VectorHit[], error: `vector search failed: ${m}` }),
770
+ ),
771
+ timeoutMs,
772
+ () => ({ hits: [] as VectorHit[], error: `vector search timed out after ${timeoutMs}ms` }),
773
+ );
774
+ if (sr.error !== undefined) {
775
+ return { ...lexicalOnly({}), vectorEngine: engine.kind, vectorError: sr.error };
776
+ }
777
+
778
+ // Resolve dzId → the FULL lexical record (source of truth). Orphans are dropped (V-1/QR-4).
779
+ let records: MemoryRecord[];
780
+ try {
781
+ records = loadStoreRecords(projectRoot);
782
+ } catch {
783
+ records = [];
784
+ }
785
+ const idToPattern = new Map<string, PatternRecord>();
786
+ const identityToId = new Map<string, string>();
787
+ for (const r of records) {
788
+ const p = recordToPattern(r);
789
+ idToPattern.set(r.id, p);
790
+ identityToId.set(patternIdentityOf(p), r.id);
791
+ }
792
+ const semantic: RankedPattern[] = [];
793
+ const seen = new Set<string>();
794
+ for (const h of sr.hits) {
795
+ if (seen.has(h.dzId)) continue;
796
+ const p = idToPattern.get(h.dzId);
797
+ if (p === undefined) continue; // vector-only orphan — the store pruned/expired it; NEVER resurrect
798
+ seen.add(h.dzId);
799
+ semantic.push({ id: h.dzId, pattern: p, backend: 'vector' });
800
+ }
801
+ const lex: RankedPattern[] = lexical.map((h) => ({
802
+ id: identityToId.get(patternIdentityOf(h.pattern)) ?? patternRecordId(h.pattern),
803
+ pattern: h.pattern,
804
+ backend: h.backend,
805
+ }));
806
+ const hits = mergeHybridHits(lex, semantic, { limit, semanticWeight: mode === 'semantic' ? 2 : 1 });
807
+ return { hits, lexicalBackend, vectorEngine: engine.kind };
808
+ }
809
+
810
+ /* ------------------------------------------------------------------ */
811
+ /* Status (dz vector status / dz doctor divergence line) */
812
+ /* ------------------------------------------------------------------ */
813
+
814
+ /** Field observability: engine availability + mirrored-vs-lexical counts + queue size. */
815
+ export async function vectorTierStatus(
816
+ projectRoot: string,
817
+ opts: VectorServiceOptions = {},
818
+ ): Promise<VectorTierStatus> {
819
+ const mode = readVectorEngineMode(projectRoot);
820
+ let records: MemoryRecord[];
821
+ try {
822
+ records = loadStoreRecords(projectRoot);
823
+ } catch {
824
+ records = [];
825
+ }
826
+ const lexicalMirrorable = records.filter((r) => !isVectorNoise(r.text)).length;
827
+ const pending = readVectorPending(projectRoot).length;
828
+ const resolved = pickEngine(projectRoot, opts);
829
+ if (resolved.engine === undefined) {
830
+ return {
831
+ mode,
832
+ available: false,
833
+ ...(resolved.reason !== undefined ? { reason: resolved.reason } : {}),
834
+ lexicalTotal: records.length,
835
+ lexicalMirrorable,
836
+ pending,
837
+ };
838
+ }
839
+ const engine = resolved.engine;
840
+ const listed = await withVectorTimeout(
841
+ safeEngineCall(() => engine.listIds(), (m) => ({ ids: [] as string[], error: `vector listIds failed: ${m}` })),
842
+ opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS,
843
+ () => ({ ids: [] as string[], error: 'vector listIds timed out' }),
844
+ );
845
+ return {
846
+ mode,
847
+ kind: engine.kind,
848
+ available: true,
849
+ ...(listed.error !== undefined ? { reason: listed.error } : {}),
850
+ lexicalTotal: records.length,
851
+ lexicalMirrorable,
852
+ mirrored: listed.error === undefined ? listed.ids.length : undefined,
853
+ pending,
854
+ };
855
+ }
856
+
857
+ /* ------------------------------------------------------------------ */
858
+ /* Harmonize — SEMANTIC dedup of the lexical store (05 §2.1) */
859
+ /* ------------------------------------------------------------------ */
860
+
861
+ /** Bounded, honest embed of one text — a throw/timeout surfaces as `{ error }`, never propagates. */
862
+ async function boundedEmbed(
863
+ embed: (text: string) => Promise<Float32Array>,
864
+ text: string,
865
+ timeoutMs: number,
866
+ ): Promise<Float32Array | { error: string }> {
867
+ return withVectorTimeout(
868
+ safeEngineCall<Float32Array | { error: string }>(() => embed(text), (m) => ({ error: m })),
869
+ timeoutMs,
870
+ () => ({ error: 'embed timed out' }),
871
+ );
872
+ }
873
+
874
+ /**
875
+ * Deterministic keeper INDEX within a near-dup cluster (a TOTAL order over fixed inputs — NFR-7):
876
+ * (1) highest reward → (2) longer / more-specific text → (3) newer `ts` → (4) `dzId` (stable
877
+ * final tiebreak). Pure — no I/O. The keeper survives; the other members are the drop set.
878
+ */
879
+ export function selectClusterKeeper(members: readonly HarmonizeItem[]): number {
880
+ let best = 0;
881
+ for (let i = 1; i < members.length; i += 1) {
882
+ if (isBetterKeeper(members[i]!, members[best]!)) best = i;
883
+ }
884
+ return best;
885
+ }
886
+
887
+ function isBetterKeeper(a: HarmonizeItem, b: HarmonizeItem): boolean {
888
+ if (a.reward !== b.reward) return a.reward > b.reward; // (1) highest reward
889
+ if (a.text.length !== b.text.length) return a.text.length > b.text.length; // (2) longer / more specific
890
+ if (a.ts !== b.ts) return a.ts > b.ts; // (3) newer
891
+ return a.dzId < b.dzId; // (4) stable, deterministic final tiebreak
892
+ }
893
+
894
+ /** Connected components over undirected `edges` (union-find) — transitive clusters (A~B,B~C ⇒ {A,B,C}). */
895
+ function connectedComponents(n: number, edges: readonly (readonly [number, number])[]): number[][] {
896
+ const parent = Array.from({ length: n }, (_, i) => i);
897
+ const find = (x: number): number => {
898
+ let r = x;
899
+ while (parent[r] !== r) r = parent[r]!;
900
+ while (parent[x] !== r) {
901
+ const next = parent[x]!;
902
+ parent[x] = r;
903
+ x = next;
904
+ }
905
+ return r;
906
+ };
907
+ for (const [a, b] of edges) {
908
+ const ra = find(a);
909
+ const rb = find(b);
910
+ if (ra !== rb) parent[ra] = rb;
911
+ }
912
+ const groups = new Map<number, number[]>();
913
+ for (let i = 0; i < n; i += 1) {
914
+ const r = find(i);
915
+ const g = groups.get(r);
916
+ if (g === undefined) groups.set(r, [i]);
917
+ else g.push(i);
918
+ }
919
+ return [...groups.values()];
920
+ }
921
+
922
+ /** Build a {@link HarmonizeCluster} from a component's item indices + keeper's cosine to each drop. */
923
+ function buildCluster(
924
+ items: readonly HarmonizeItem[],
925
+ indices: readonly number[],
926
+ cosToKeeper: (dropIdx: number, keeperIdx: number) => number,
927
+ ): HarmonizeCluster {
928
+ const members = indices.map((i) => items[i]!);
929
+ const keeperIdx = indices[selectClusterKeeper(members)]!;
930
+ const keeper = items[keeperIdx]!;
931
+ const drops = indices
932
+ .filter((i) => i !== keeperIdx)
933
+ .map((i) => ({ dzId: items[i]!.dzId, text: items[i]!.text, reward: items[i]!.reward, cos: cosToKeeper(i, keeperIdx) }));
934
+ return { keep: { dzId: keeper.dzId, text: keeper.text, reward: keeper.reward, ts: keeper.ts }, drops };
935
+ }
936
+
937
+ /** Semantic clusters: pairwise cosine ≥ θ (i<j) ⇒ union-find edge; components of size ≥ 2 are clusters. */
938
+ function semanticClusters(items: readonly HarmonizeItem[], vecs: readonly Float32Array[], threshold: number): HarmonizeCluster[] {
939
+ const n = items.length;
940
+ const edges: [number, number][] = [];
941
+ for (let i = 0; i < n; i += 1) {
942
+ for (let j = i + 1; j < n; j += 1) {
943
+ if (cosineSimilarity(vecs[i]!, vecs[j]!) >= threshold) edges.push([i, j]);
944
+ }
945
+ }
946
+ const clusters: HarmonizeCluster[] = [];
947
+ for (const comp of connectedComponents(n, edges)) {
948
+ if (comp.length < 2) continue;
949
+ clusters.push(buildCluster(items, comp, (d, k) => cosineSimilarity(vecs[d]!, vecs[k]!)));
950
+ }
951
+ return clusters;
952
+ }
953
+
954
+ /** Exact-text fallback: group by RAW pattern text (the identity `teach --from-json` dedups on); cos = 1.0. */
955
+ function exactClusters(items: readonly HarmonizeItem[]): HarmonizeCluster[] {
956
+ const byText = new Map<string, number[]>();
957
+ items.forEach((it, i) => {
958
+ const g = byText.get(it.text);
959
+ if (g === undefined) byText.set(it.text, [i]);
960
+ else g.push(i);
961
+ });
962
+ const clusters: HarmonizeCluster[] = [];
963
+ for (const indices of byText.values()) {
964
+ if (indices.length < 2) continue;
965
+ clusters.push(buildCluster(items, indices, () => 1.0));
966
+ }
967
+ return clusters;
968
+ }
969
+
970
+ /** Honest note next to the session telemetry (mirrors {@link logMirrorNote}). */
971
+ function logHarmonizeNote(projectRoot: string, info: { dropped: number; kept: number; engine: string; error?: string | undefined }): void {
972
+ try {
973
+ appendFileSync(
974
+ join(projectRoot, '.dz', 'sessions.jsonl'),
975
+ JSON.stringify({ event: 'harmonize', ts: new Date().toISOString(), ...info }) + '\n',
976
+ );
977
+ } catch { /* best-effort */ }
978
+ }
979
+
980
+ /**
981
+ * SEMANTIC dedup of the learned-pattern store (`dz vector harmonize` / `dz teach --harmonize`) —
982
+ * **NON-DESTRUCTIVE by contract**. Finds near-duplicate PAIRS via pairwise cosine over the embedder
983
+ * both adapters share (θ default {@link DEFAULT_HARMONIZE_THRESHOLD}), union-finds them into clusters,
984
+ * and within each cluster KEEPs the highest-signal member ({@link selectClusterKeeper}), dropping the
985
+ * rest. Modes:
986
+ *
987
+ * - **dry-run (default)**: previews the clusters and returns — writes NOTHING (the store is
988
+ * byte-identical after).
989
+ * - **`--apply`**: writes a restorable backup FIRST (`.dz/memory/patterns.pre-harmonize.json`); a
990
+ * failed backup ABORTS the drop (no partial mutation). Then removes the non-keepers from BOTH
991
+ * lexical tiers via {@link removePatternsByIds}. A UNIQUE (singleton) pattern is NEVER a drop.
992
+ *
993
+ * Degrades honestly: with no engine/embedder it falls back to EXACT-text dedup + a `fellBackToExact`
994
+ * note, exits without throwing (dry-run still writes nothing). Reversal: `dz teach --from-json <backup>`.
995
+ */
996
+ export async function harmonizeVectorStore(projectRoot: string, opts: HarmonizeOptions = {}): Promise<HarmonizeReport> {
997
+ const apply = opts.apply === true;
998
+ const threshold =
999
+ opts.threshold !== undefined && opts.threshold > 0 && opts.threshold <= 1
1000
+ ? opts.threshold
1001
+ : readHarmonizeThreshold(projectRoot);
1002
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
1003
+
1004
+ // 1. LOAD the pool from the lexical source of truth (id = dzId).
1005
+ let records: MemoryRecord[];
1006
+ try {
1007
+ records = loadStoreRecords(projectRoot);
1008
+ } catch {
1009
+ records = [];
1010
+ }
1011
+ const items: HarmonizeItem[] = records.map((r) => ({
1012
+ dzId: r.id,
1013
+ text: r.text,
1014
+ reward: r.score,
1015
+ ts: r.timestamp,
1016
+ taskType: r.id.startsWith('dream:') ? 'dz-learning' : 'dz-teach',
1017
+ }));
1018
+
1019
+ // 2. GATE: an embedder ⇒ SEMANTIC clustering; absence/failure ⇒ EXACT-text fallback (D4).
1020
+ let embed: ((text: string) => Promise<Float32Array>) | undefined;
1021
+ let engineKind = 'none';
1022
+ let fellBackToExact = false;
1023
+ if (opts.embed === null) {
1024
+ fellBackToExact = true;
1025
+ } else if (opts.embed !== undefined) {
1026
+ embed = opts.embed;
1027
+ engineKind = 'agentdb';
1028
+ } else {
1029
+ const resolved = pickEngine(projectRoot, opts);
1030
+ if (resolved.engine === undefined) {
1031
+ fellBackToExact = true;
1032
+ } else {
1033
+ engineKind = resolved.engine.kind;
1034
+ const emb = await resolveAgentdbEmbedder(projectRoot);
1035
+ if ('error' in emb) fellBackToExact = true;
1036
+ else embed = (t) => emb.embed(t);
1037
+ }
1038
+ }
1039
+
1040
+ // 3. CLUSTER (nothing to cluster ⇒ no clusters, everything unique).
1041
+ let clusters: HarmonizeCluster[] | undefined;
1042
+ if (items.length >= 2 && !fellBackToExact && embed !== undefined) {
1043
+ const vecs: Float32Array[] = [];
1044
+ let ok = true;
1045
+ for (const it of items) {
1046
+ const v = await boundedEmbed(embed, `${it.taskType}: ${it.text}`, timeoutMs);
1047
+ if (!(v instanceof Float32Array)) {
1048
+ ok = false;
1049
+ break;
1050
+ }
1051
+ vecs.push(v);
1052
+ }
1053
+ if (ok) clusters = semanticClusters(items, vecs, threshold);
1054
+ else fellBackToExact = true; // embed failed/timed out — fall back to exact
1055
+ }
1056
+ if (clusters === undefined) {
1057
+ fellBackToExact = fellBackToExact || embed === undefined;
1058
+ clusters = items.length >= 2 ? exactClusters(items) : [];
1059
+ }
1060
+
1061
+ // 4. TOTALS (a unique = a singleton; never a member of a drop set).
1062
+ const dropDzIds = new Set<string>();
1063
+ for (const c of clusters) for (const d of c.drops) dropDzIds.add(d.dzId);
1064
+ const kept = clusters.length;
1065
+ const dropped = dropDzIds.size;
1066
+ const unique = items.length - kept - dropped;
1067
+ const base: HarmonizeReport = {
1068
+ mode: apply ? 'apply' : 'dry-run',
1069
+ engine: engineKind,
1070
+ fellBackToExact,
1071
+ threshold,
1072
+ clusters,
1073
+ kept,
1074
+ dropped,
1075
+ unique,
1076
+ };
1077
+
1078
+ // 5a. DRY-RUN (default): return — ZERO writes (the store is byte-identical after).
1079
+ if (!apply) return base;
1080
+
1081
+ // 5b. --apply: BACKUP FIRST, then drop the non-keepers. Nothing to drop ⇒ no backup, no mutation.
1082
+ if (dropped === 0) {
1083
+ logHarmonizeNote(projectRoot, { dropped: 0, kept, engine: engineKind });
1084
+ return base;
1085
+ }
1086
+ const backupPath = join(projectRoot, '.dz', 'memory', 'patterns.pre-harmonize.json');
1087
+ const snap = snapshotStore(projectRoot, backupPath);
1088
+ if (snap.error !== undefined) {
1089
+ // Backup write failed ⇒ ABORT the drop (no partial mutation — the store is untouched).
1090
+ return { ...base, error: `backup failed — drop aborted: ${snap.error}` };
1091
+ }
1092
+ const removal = removePatternsByIds(projectRoot, dropDzIds);
1093
+ logHarmonizeNote(projectRoot, { dropped: removal.removed, kept, engine: engineKind, error: removal.error });
1094
+ return { ...base, backupPath, ...(removal.error !== undefined ? { error: removal.error } : {}) };
1095
+ }
1096
+
1097
+ /* ------------------------------------------------------------------ */
1098
+ /* Import — RVF checkpoint ingest, UPSERT-BY-dzId (05 §2.2) */
1099
+ /* ------------------------------------------------------------------ */
1100
+
1101
+ /**
1102
+ * Ingest an external `.rvf` checkpoint's vectors into THIS project's vector store, **UPSERT-BY-dzId,
1103
+ * NON-DESTRUCTIVE** (`dz vector import <file.rvf>`). The `.idmap.json` sidecar is the dzId authority
1104
+ * (the shipped `@ruvector/rvf` SDK exposes no vector read-out — see rUv `rvf-backend-blocker.md`), so
1105
+ * for each checkpoint dzId that exists in the LOCAL lexical store the vector is reproduced by
1106
+ * re-embedding the local text (D7 — under the manifest guard the same model over the same text yields
1107
+ * the checkpoint's vector) and upserted by dzId via {@link VectorEngine.importVectors}. dzIds absent
1108
+ * locally are ORPHANS — skipped + counted (their text must be imported first via `dz teach --from-json`).
1109
+ *
1110
+ * Non-destructive: only the imported dzIds are inserted/replaced; re-importing the same file adds 0
1111
+ * duplicates and deletes nothing. A model/dim manifest mismatch is REFUSED (no cross-space merge). All
1112
+ * failure modes return an honest `{ error }`, never a throw.
1113
+ */
1114
+ export async function importRvfCheckpoint(projectRoot: string, source: string, opts: ImportOptions = {}): Promise<ImportReport> {
1115
+ const fail = (error: string, engine = 'none'): ImportReport => ({ imported: 0, skippedOrphans: 0, engine, source, error });
1116
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
1117
+
1118
+ // 1. Source dzIds: the `.idmap.json` sidecar (dzId authority) — or injected rows (tests).
1119
+ const injected = new Map<string, Float32Array>();
1120
+ let sourceDzIds: string[];
1121
+ if (opts.sourceRows !== undefined) {
1122
+ for (const r of opts.sourceRows) injected.set(r.dzId, r.vector);
1123
+ sourceDzIds = [...injected.keys()];
1124
+ } else {
1125
+ if (!existsSync(source)) return fail(`no such file: ${source}`);
1126
+ const idmapPath = `${source}.idmap.json`;
1127
+ if (!existsSync(idmapPath)) {
1128
+ return fail(`missing sidecar ${basename(idmapPath)} — export writes it next to the .rvf (re-run: dz vector export)`);
1129
+ }
1130
+ let idmap: RvfIdmap;
1131
+ try {
1132
+ const parsed = JSON.parse(readFileSync(idmapPath, 'utf-8')) as RvfIdmap;
1133
+ idmap = typeof parsed === 'object' && parsed !== null && typeof parsed.slots === 'object' ? parsed : { version: 1, slots: {} };
1134
+ } catch {
1135
+ return fail(`unreadable idmap sidecar: ${basename(idmapPath)}`);
1136
+ }
1137
+ // Manifest guard (R-i1): refuse a foreign embedding model/dim — no silent cross-space merge.
1138
+ const manifestPath = `${source}.manifest.json`;
1139
+ if (existsSync(manifestPath)) {
1140
+ try {
1141
+ const m = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { model?: unknown; dim?: unknown };
1142
+ if ((typeof m.model === 'string' && m.model !== LOCAL_EMBED_MODEL) || (typeof m.dim === 'number' && m.dim !== LOCAL_EMBED_DIM)) {
1143
+ return fail(`manifest mismatch: checkpoint (${String(m.model)}/${String(m.dim)}) ≠ local (${LOCAL_EMBED_MODEL}/${LOCAL_EMBED_DIM}) — refusing a cross-embedding-space merge`);
1144
+ }
1145
+ } catch { /* unreadable manifest — tolerate; the idmap is the authority */ }
1146
+ }
1147
+ sourceDzIds = [...new Set(Object.values(idmap.slots))];
1148
+ }
1149
+
1150
+ // 2. Resolve the TARGET engine (agentdb default, or rvf if configured).
1151
+ const resolved = pickEngine(projectRoot, opts);
1152
+ if (resolved.engine === undefined) return fail(resolved.reason ?? 'no vector engine available');
1153
+ const engine = resolved.engine;
1154
+ if (engine.importVectors === undefined) {
1155
+ return { imported: 0, skippedOrphans: 0, engine: engine.kind, source, error: `the ${engine.kind} engine cannot import precomputed vectors` };
1156
+ }
1157
+
1158
+ // 3. ORPHAN GATE against the lexical source of truth.
1159
+ let records: MemoryRecord[];
1160
+ try {
1161
+ records = loadStoreRecords(projectRoot);
1162
+ } catch {
1163
+ records = [];
1164
+ }
1165
+ const byId = new Map<string, MemoryRecord>();
1166
+ for (const r of records) byId.set(r.id, r);
1167
+ let skippedOrphans = 0;
1168
+ const kept: { dzId: string; rec: MemoryRecord }[] = [];
1169
+ for (const dzId of sourceDzIds) {
1170
+ const rec = byId.get(dzId);
1171
+ if (rec === undefined) skippedOrphans += 1;
1172
+ else kept.push({ dzId, rec });
1173
+ }
1174
+ if (kept.length === 0) return { imported: 0, skippedOrphans, engine: engine.kind, source };
1175
+
1176
+ // 4. VECTOR per kept dzId: injected verbatim vector, else RE-EMBED the local text (D7).
1177
+ let embed = opts.embed;
1178
+ if (embed === undefined && kept.some((k) => !injected.has(k.dzId))) {
1179
+ const emb = await resolveAgentdbEmbedder(projectRoot);
1180
+ if ('error' in emb) return { imported: 0, skippedOrphans, engine: engine.kind, source, error: emb.error };
1181
+ embed = (t) => emb.embed(t);
1182
+ }
1183
+ const rows: ImportVectorRow[] = [];
1184
+ for (const { dzId, rec } of kept) {
1185
+ const taskType = dzId.startsWith('dream:') ? 'dz-learning' : 'dz-teach';
1186
+ let vector = injected.get(dzId);
1187
+ if (vector === undefined) {
1188
+ const v = await boundedEmbed(embed!, `${taskType}: ${rec.text}`, timeoutMs);
1189
+ if (!(v instanceof Float32Array)) {
1190
+ return { imported: 0, skippedOrphans, engine: engine.kind, source, error: `embed failed: ${(v as { error: string }).error}` };
1191
+ }
1192
+ vector = v;
1193
+ }
1194
+ rows.push({ dzId, vector, text: rec.text, taskType, score: rec.score, metadata: { dzId } });
1195
+ }
1196
+
1197
+ // 5. UPSERT-BY-dzId (re-import of the same dzIds REPLACEs in place — 0 new rows, nothing deleted).
1198
+ const up = await engine.importVectors(rows);
1199
+ if (up.error !== undefined) return { imported: up.imported, skippedOrphans, engine: engine.kind, source, error: up.error };
1200
+ return { imported: up.imported, skippedOrphans, engine: engine.kind, source };
1201
+ }
1202
+
1203
+ /* ------------------------------------------------------------------ */
1204
+ /* Adapter A (default): AgentdbVectorEngine */
1205
+ /* ------------------------------------------------------------------ */
1206
+
1207
+ /**
1208
+ * Option A: the `.dz/agentdb.db` ReasoningBank store. `upsert` delegates to the very same
1209
+ * {@link indexPatternsToAgentdb} rows the consolidate Option-C mirror writes today (schema
1210
+ * unchanged — the `agentdb-memory` MCP skill keeps reading them, NFR-7); `search`/`listIds`
1211
+ * are the new READONLY halves in `agentdb-index.ts`.
1212
+ */
1213
+ function agentdbVectorEngine(projectRoot: string): VectorEngine {
1214
+ return {
1215
+ kind: 'agentdb',
1216
+ async upsert(entries) {
1217
+ const r = await indexPatternsToAgentdb(
1218
+ projectRoot,
1219
+ entries.map((e) => ({
1220
+ taskType: e.taskType,
1221
+ text: e.text,
1222
+ score: e.score,
1223
+ ...(e.tags !== undefined ? { tags: e.tags } : {}),
1224
+ ...(e.metadata !== undefined ? { metadata: e.metadata } : {}),
1225
+ })),
1226
+ );
1227
+ return { indexed: r.indexed, ...(r.error !== undefined ? { error: r.error } : {}) };
1228
+ },
1229
+ async search(query, limit) {
1230
+ const r = await searchAgentdbPatterns(projectRoot, query, { limit });
1231
+ const hits: VectorHit[] = [];
1232
+ for (const h of r.hits) {
1233
+ if (h.dzId !== undefined) hits.push({ dzId: h.dzId, similarity: h.similarity, text: h.text });
1234
+ }
1235
+ return { hits, ...(r.error !== undefined ? { error: r.error } : {}) };
1236
+ },
1237
+ async listIds() {
1238
+ return listAgentdbDzIds(projectRoot);
1239
+ },
1240
+ async importVectors(rows) {
1241
+ return importVectorsToAgentdb(
1242
+ projectRoot,
1243
+ rows.map((r) => ({
1244
+ dzId: r.dzId,
1245
+ vector: r.vector,
1246
+ text: r.text,
1247
+ taskType: r.taskType,
1248
+ score: r.score,
1249
+ ...(r.metadata !== undefined ? { metadata: r.metadata } : {}),
1250
+ })),
1251
+ );
1252
+ },
1253
+ };
1254
+ }
1255
+
1256
+ /* ------------------------------------------------------------------ */
1257
+ /* Adapter B (opt-in): RvfVectorEngine */
1258
+ /* ------------------------------------------------------------------ */
1259
+
1260
+ interface RvfIdmap {
1261
+ version: 1;
1262
+ /** rvf slot/label → dzId. */
1263
+ slots: Record<string, string>;
1264
+ }
1265
+
1266
+ function rvfBase(projectRoot: string): string {
1267
+ return join(projectRoot, '.dz', 'memory', 'patterns.rvf');
1268
+ }
1269
+
1270
+ function readRvfIdmap(projectRoot: string): RvfIdmap {
1271
+ try {
1272
+ const parsed = JSON.parse(readFileSync(`${rvfBase(projectRoot)}.idmap.json`, 'utf-8')) as RvfIdmap;
1273
+ return typeof parsed === 'object' && parsed !== null && typeof parsed.slots === 'object' ? parsed : { version: 1, slots: {} };
1274
+ } catch {
1275
+ return { version: 1, slots: {} };
1276
+ }
1277
+ }
1278
+
1279
+ function writeRvfSidecars(projectRoot: string, idmap: RvfIdmap): void {
1280
+ const base = rvfBase(projectRoot);
1281
+ mkdirSync(dirname(base), { recursive: true });
1282
+ writeFileSync(`${base}.idmap.json`, JSON.stringify(idmap, null, 2));
1283
+ writeFileSync(`${base}.manifest.json`, JSON.stringify(
1284
+ { model: 'Xenova/all-MiniLM-L6-v2', dim: 384, engine: '@ruvector/rvf', version: 1 },
1285
+ null,
1286
+ 2,
1287
+ ));
1288
+ }
1289
+
1290
+ interface RvfStoreHandle {
1291
+ ingest: (id: string, vec: Float32Array) => Promise<unknown> | unknown;
1292
+ query: (vec: Float32Array, k: number) => Promise<unknown> | unknown;
1293
+ close?: (() => Promise<void> | void) | undefined;
1294
+ exportCheckpoint?: ((dest: string) => Promise<unknown> | unknown) | undefined;
1295
+ }
1296
+
1297
+ /**
1298
+ * Open a `@ruvector/rvf` store, pinned to the REAL published SDK surface (grounded in
1299
+ * ruvector/npm/packages/rvf/src/index.ts + a live linux-x64 smoke against @ruvector/rvf@0.2.3):
1300
+ * the canonical class is `RvfDatabase` with `create(path, { dimensions })` → `ingestBatch([{id,
1301
+ * vector}])` → `query(vector, k)` returning `[{ id, distance }]` → `close()`. A few tolerant
1302
+ * fallbacks (add/insert, search) keep older/alt shapes working; anything unrecognized returns an
1303
+ * HONEST error (the D8 no-go evidence), never a throw. NOTE: RVF stores the vector under the `id`
1304
+ * we pass (= the dzId), so no slot↔id mapping is needed — the query result's `id` IS the dzId.
1305
+ */
1306
+ export async function openRvfStore(mod: Record<string, unknown>, path: string, dimensions: number): Promise<RvfStoreHandle | { error: string }> {
1307
+ try {
1308
+ const dflt = mod['default'] as Record<string, unknown> | undefined;
1309
+ const cls = (mod['RvfDatabase'] ?? dflt?.['RvfDatabase'] ?? mod['RvfStore'] ?? mod['Store'] ?? dflt?.['RvfStore'] ?? dflt ?? mod) as {
1310
+ create?: (p: string, o: { dimensions?: number; dimension?: number }) => unknown;
1311
+ open?: (p: string, o: { dimensions?: number; dimension?: number }) => unknown;
1312
+ };
1313
+ let db: Record<string, unknown> | undefined;
1314
+ if (typeof cls.create === 'function') db = (await cls.create(path, { dimensions, dimension: dimensions })) as Record<string, unknown>;
1315
+ else if (typeof cls.open === 'function') db = (await cls.open(path, { dimensions, dimension: dimensions })) as Record<string, unknown>;
1316
+ else if (typeof cls === 'function') db = new (cls as unknown as new (p: string, o: { dimensions: number }) => Record<string, unknown>)(path, { dimensions });
1317
+ if (db === undefined) return { error: 'unsupported @ruvector/rvf API (no RvfDatabase.create/open/constructor) — record a D8 no-go' };
1318
+ const ingestBatch = (db['ingestBatch'] ?? db['ingest'] ?? db['add'] ?? db['insert']) as ((rows: Array<{ id: string; vector: Float32Array }>) => unknown) | undefined;
1319
+ const query = (db['query'] ?? db['search']) as ((vec: Float32Array, k: number) => unknown) | undefined;
1320
+ if (typeof ingestBatch !== 'function' || typeof query !== 'function') {
1321
+ return { error: 'unsupported @ruvector/rvf store surface (no ingestBatch/ingest + query/search) — record a D8 no-go' };
1322
+ }
1323
+ const close = db['close'];
1324
+ const exp = db['exportCheckpoint'] ?? db['export_checkpoint'] ?? db['checkpoint'];
1325
+ return {
1326
+ ingest: (id, vec) => ingestBatch.call(db, [{ id, vector: vec }]),
1327
+ query: (vec, k) => query.call(db, vec, k),
1328
+ close: typeof close === 'function' ? (close as () => void).bind(db) : undefined,
1329
+ exportCheckpoint: typeof exp === 'function' ? (exp as (d: string) => unknown).bind(db) : undefined,
1330
+ };
1331
+ } catch (err) {
1332
+ return { error: `@ruvector/rvf store open failed: ${err instanceof Error ? err.message : String(err)}` };
1333
+ }
1334
+ }
1335
+
1336
+ async function loadRvfModule(
1337
+ projectRoot: string,
1338
+ ): Promise<{ ok: true; mod: Record<string, unknown> } | { ok: false; error: string }> {
1339
+ try {
1340
+ const req = createRequire(join(projectRoot, 'package.json'));
1341
+ const mod = (await import(pathToFileURL(req.resolve('@ruvector/rvf')).href)) as Record<string, unknown>;
1342
+ return { ok: true, mod };
1343
+ } catch (err) {
1344
+ return { ok: false, error: `@ruvector/rvf failed to load: ${err instanceof Error ? err.message : String(err)}` };
1345
+ }
1346
+ }
1347
+
1348
+ /**
1349
+ * Option B: the portable single-file VECTOR form (`.rvf`, magic `0x52564653`) with
1350
+ * `.idmap.json` (slot ↔ dzId) and `.manifest.json` (model/dim — Constraint 5 staleness
1351
+ * detection) sidecars. Embeddings come from agentdb's `EmbeddingService` when resolvable —
1352
+ * with NEITHER embedder the engine degrades gracefully with an honest reason (05 §3.6).
1353
+ */
1354
+ function rvfVectorEngine(projectRoot: string): VectorEngine {
1355
+ const noEmbedder = 'rvf engine present but no embedder — install agentdb (dz setup --memory agentdb)';
1356
+ return {
1357
+ kind: 'rvf',
1358
+ async upsert(entries) {
1359
+ const emb = await resolveAgentdbEmbedder(projectRoot);
1360
+ if ('error' in emb) return { indexed: 0, error: noEmbedder };
1361
+ const loaded = await loadRvfModule(projectRoot);
1362
+ if (!loaded.ok) return { indexed: 0, error: loaded.error };
1363
+ const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), 384);
1364
+ if ('error' in store) return { indexed: 0, error: store.error };
1365
+ try {
1366
+ const idmap = readRvfIdmap(projectRoot);
1367
+ let indexed = 0;
1368
+ for (const e of entries) {
1369
+ const vec = await emb.embed(`${e.taskType}: ${e.text}`);
1370
+ await store.ingest(e.dzId, vec); // RVF stores the vector UNDER id = dzId (no slot mapping)
1371
+ idmap.slots[e.dzId] = e.dzId; // sidecar keeps the dzId set for listIds/observability
1372
+ indexed += 1;
1373
+ }
1374
+ await store.close?.();
1375
+ writeRvfSidecars(projectRoot, idmap);
1376
+ return { indexed };
1377
+ } catch (err) {
1378
+ return { indexed: 0, error: `rvf upsert failed: ${err instanceof Error ? err.message : String(err)}` };
1379
+ }
1380
+ },
1381
+ async search(query, limit) {
1382
+ const emb = await resolveAgentdbEmbedder(projectRoot);
1383
+ if ('error' in emb) return { hits: [], error: noEmbedder };
1384
+ const loaded = await loadRvfModule(projectRoot);
1385
+ if (!loaded.ok) return { hits: [], error: loaded.error };
1386
+ const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), 384);
1387
+ if ('error' in store) return { hits: [], error: store.error };
1388
+ try {
1389
+ const idmap = readRvfIdmap(projectRoot);
1390
+ const raw = await store.query(await emb.embed(query), limit);
1391
+ await store.close?.();
1392
+ const hits: VectorHit[] = [];
1393
+ if (Array.isArray(raw)) {
1394
+ for (const item of raw as Array<Record<string, unknown> | [unknown, unknown]>) {
1395
+ const id = Array.isArray(item) ? item[0] : item['id'] ?? item['slot'] ?? item['label'];
1396
+ const distance = Array.isArray(item) ? item[1] : item['distance'] ?? item['score'] ?? item['similarity'];
1397
+ // RVF returns the id we ingested (= dzId); the sidecar is a safety join for alt shapes.
1398
+ const dzId = idmap.slots[String(id)] ?? (typeof id === 'string' ? id : undefined);
1399
+ // distance: lower = closer → negate so higher = better (RRF ranks by position regardless).
1400
+ if (dzId !== undefined) hits.push({ dzId, similarity: typeof distance === 'number' ? -distance : 0 });
1401
+ }
1402
+ }
1403
+ return { hits };
1404
+ } catch (err) {
1405
+ return { hits: [], error: `rvf search failed: ${err instanceof Error ? err.message : String(err)}` };
1406
+ }
1407
+ },
1408
+ async listIds() {
1409
+ // Sidecar-only read — no SDK load needed for observability/dedup.
1410
+ return { ids: [...new Set(Object.values(readRvfIdmap(projectRoot).slots))] };
1411
+ },
1412
+ async importVectors(rows) {
1413
+ const loaded = await loadRvfModule(projectRoot);
1414
+ if (!loaded.ok) return { imported: 0, error: loaded.error };
1415
+ const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), 384);
1416
+ if ('error' in store) return { imported: 0, error: store.error };
1417
+ try {
1418
+ const idmap = readRvfIdmap(projectRoot);
1419
+ let imported = 0;
1420
+ for (const r of rows) {
1421
+ await store.ingest(r.dzId, r.vector); // RVF ingest is upsert-by-id (id = dzId) — no duplicates
1422
+ idmap.slots[r.dzId] = r.dzId;
1423
+ imported += 1;
1424
+ }
1425
+ await store.close?.();
1426
+ writeRvfSidecars(projectRoot, idmap);
1427
+ return { imported };
1428
+ } catch (err) {
1429
+ return { imported: 0, error: `rvf import failed: ${err instanceof Error ? err.message : String(err)}` };
1430
+ }
1431
+ },
1432
+ async exportCheckpoint(dest) {
1433
+ try {
1434
+ const base = rvfBase(projectRoot);
1435
+ if (!existsSync(base)) return { error: `no ${base} yet — teach/consolidate with the rvf engine first` };
1436
+ const loaded = await loadRvfModule(projectRoot);
1437
+ let exported = false;
1438
+ if (loaded.ok) {
1439
+ const store = await openRvfStore(loaded.mod, base, 384);
1440
+ if (!('error' in store) && store.exportCheckpoint !== undefined) {
1441
+ await store.exportCheckpoint(dest);
1442
+ await store.close?.();
1443
+ exported = true;
1444
+ } else if (!('error' in store)) {
1445
+ await store.close?.();
1446
+ }
1447
+ }
1448
+ if (!exported) copyFileSync(base, dest); // append-only format — a file copy IS a checkpoint
1449
+ for (const sidecar of ['.idmap.json', '.manifest.json']) {
1450
+ if (existsSync(`${base}${sidecar}`)) copyFileSync(`${base}${sidecar}`, `${dest}${sidecar}`);
1451
+ }
1452
+ return {};
1453
+ } catch (err) {
1454
+ return { error: `rvf export failed: ${err instanceof Error ? err.message : String(err)}` };
1455
+ }
1456
+ },
1457
+ };
1458
+ }