@dzhechkov/harness-core 0.3.74 → 0.3.78

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,957 @@
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 { 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
+ type PatternRecord,
51
+ type RecallHit,
52
+ } from './patterns.js';
53
+ import { indexPatternsToAgentdb, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder } from './agentdb-index.js';
54
+
55
+ /* ------------------------------------------------------------------ */
56
+ /* Types (04_domain_model §3.4 / §4.1) */
57
+ /* ------------------------------------------------------------------ */
58
+
59
+ /** Which adapter sits behind the port. */
60
+ export type VectorEngineKind = 'agentdb' | 'rvf';
61
+
62
+ /** `memory.vector.engine` config modes (`.dz/config.json`). Absent/corrupt ⇒ `auto`. */
63
+ export type VectorEngineMode = 'auto' | 'agentdb' | 'rvf' | 'off';
64
+
65
+ /** One record on its way into the vector store (the ACL between dz records and engines). */
66
+ export interface VectorEntry {
67
+ /** Join key back to the lexical store — the canonical `MemoryRecord.id` (`teach:…`/`dream:…`). */
68
+ readonly dzId: string;
69
+ /** The text that gets embedded (as `${taskType}: ${text}`) and stored. */
70
+ readonly text: string;
71
+ /** REAL reward signal in [0,1] — never a fabricated 1.0. */
72
+ readonly score: number;
73
+ /** ReasoningBank task_type: `dz-teach` for taught patterns, `dz-learning` for consolidate dreams. */
74
+ readonly taskType: string;
75
+ readonly tags?: readonly string[] | undefined;
76
+ readonly metadata?: Record<string, unknown> | undefined;
77
+ }
78
+
79
+ /** One semantic search hit — a POINTER into the lexical store, never a pattern by itself. */
80
+ export interface VectorHit {
81
+ readonly dzId: string;
82
+ /** Cosine similarity (or engine-native score), higher = closer. */
83
+ readonly similarity: number;
84
+ readonly text?: string | undefined;
85
+ }
86
+
87
+ /** Honest outcome of a mirror attempt. Never thrown — always returned. */
88
+ export interface MirrorReceipt {
89
+ /** Entries newly written to the vector store this call. */
90
+ readonly mirrored: number;
91
+ /** Entries skipped by the noise gate or the dzId dedup (already mirrored — I-5). */
92
+ readonly skipped: number;
93
+ /** Entries parked in `.dz/mirror-pending.json` for the next consolidate to heal (I-3). */
94
+ readonly queued: number;
95
+ readonly engine?: VectorEngineKind | undefined;
96
+ readonly error?: string | undefined;
97
+ }
98
+
99
+ /** The engine PORT — both adapters implement exactly this surface (04 §4.1). */
100
+ export interface VectorEngine {
101
+ readonly kind: VectorEngineKind;
102
+ upsert(entries: readonly VectorEntry[]): Promise<{ indexed: number; error?: string | undefined }>;
103
+ search(query: string, limit: number): Promise<{ hits: VectorHit[]; error?: string | undefined }>;
104
+ listIds(): Promise<{ ids: string[]; error?: string | undefined }>;
105
+ /** Portable single-file checkpoint (RVF adapter only — `dz vector export`). */
106
+ exportCheckpoint?(dest: string): Promise<{ error?: string | undefined }>;
107
+ }
108
+
109
+ /** Outcome of {@link resolveVectorEngine}: an engine, or an honest reason why not. */
110
+ export interface ResolvedVectorEngine {
111
+ readonly engine?: VectorEngine | undefined;
112
+ readonly reason?: string | undefined;
113
+ }
114
+
115
+ /** Recall mode: `hybrid` (default), `semantic` (`--semantic`, 2× vector weight), `lexical` (`--no-semantic`). */
116
+ export type HybridRecallMode = 'hybrid' | 'semantic' | 'lexical';
117
+
118
+ /** One merged recall hit (RRF-scored). `pattern` ALWAYS comes from the lexical store (V-1). */
119
+ export interface HybridHit {
120
+ readonly pattern: PatternRecord;
121
+ readonly backend: RecallHit['backend'];
122
+ /** Reciprocal-rank-fusion score (ranking only — NOT the pattern's reward). */
123
+ readonly score: number;
124
+ }
125
+
126
+ /** Outcome of {@link recallHybrid}. With no engine this is content-identical to `recallPatterns`. */
127
+ export interface HybridRecall {
128
+ readonly hits: HybridHit[];
129
+ readonly lexicalBackend: 'sqlite' | 'json';
130
+ readonly vectorEngine: VectorEngineKind | 'none';
131
+ /** Why the vector tier did not participate (engine absent / disabled). */
132
+ readonly vectorReason?: string | undefined;
133
+ /** Engine was present but the search failed/timed out — lexical results returned instead. */
134
+ readonly vectorError?: string | undefined;
135
+ }
136
+
137
+ /** Field observability for `dz vector status` (I-2/I-5 in the field). */
138
+ export interface VectorTierStatus {
139
+ readonly mode: VectorEngineMode;
140
+ readonly kind?: VectorEngineKind | undefined;
141
+ readonly available: boolean;
142
+ readonly reason?: string | undefined;
143
+ readonly lexicalTotal: number;
144
+ readonly lexicalMirrorable: number;
145
+ readonly mirrored?: number | undefined;
146
+ readonly pending: number;
147
+ }
148
+
149
+ /** Wall-time bound applied to EVERY engine call, read and write legs alike (ADR R1 + NC1). */
150
+ export const DEFAULT_VECTOR_TIMEOUT_MS = 10_000;
151
+
152
+ /* ------------------------------------------------------------------ */
153
+ /* Timeout wrapper (both legs — NC1/QR-1) */
154
+ /* ------------------------------------------------------------------ */
155
+
156
+ /**
157
+ * Bound `promise` to `ms` wall-clock milliseconds. On timeout, resolve with `onTimeout()`
158
+ * instead — the underlying operation keeps running detached (its eventual write is later
159
+ * deduplicated by dzId), but the CALLER's latency is bounded. A rejection also resolves via
160
+ * `onTimeout()` (honest-error contract: this wrapper never throws). The timer is cleared /
161
+ * unref'd so a fast path never keeps the process alive.
162
+ */
163
+ export async function withVectorTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => T): Promise<T> {
164
+ let timer: ReturnType<typeof setTimeout> | undefined;
165
+ try {
166
+ return await Promise.race([
167
+ promise.catch(() => onTimeout()),
168
+ new Promise<T>((resolvePromise) => {
169
+ timer = setTimeout(() => resolvePromise(onTimeout()), ms);
170
+ timer.unref?.();
171
+ }),
172
+ ]);
173
+ } finally {
174
+ if (timer !== undefined) clearTimeout(timer);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Invoke an engine call so that BOTH a synchronous throw and an async rejection surface as an
180
+ * honest `onError(message)` value (never as the timeout fallback — a throw and a timeout are
181
+ * different diagnoses in the field). Pairs with {@link withVectorTimeout} at every call site.
182
+ */
183
+ function safeEngineCall<T>(fn: () => Promise<T>, onError: (message: string) => T): Promise<T> {
184
+ const msg = (err: unknown): string => (err instanceof Error ? err.message : String(err));
185
+ try {
186
+ return fn().then((v) => v, (err: unknown) => onError(msg(err)));
187
+ } catch (err) {
188
+ return Promise.resolve(onError(msg(err)));
189
+ }
190
+ }
191
+
192
+ /* ------------------------------------------------------------------ */
193
+ /* Noise gate + ACL mappers (I-6, V-3) */
194
+ /* ------------------------------------------------------------------ */
195
+
196
+ const TOOL_TELEMETRY_RE = /^Tool \S+ invoked during session$/;
197
+
198
+ /** Text that must never be embedded: bare-approval echoes + tool telemetry (V-3 / ADR-002). */
199
+ export function isVectorNoise(text: string): boolean {
200
+ return isNoiseInsight(text) || TOOL_TELEMETRY_RE.test(text);
201
+ }
202
+
203
+ /**
204
+ * ACL: taught {@link PatternRecord} → {@link VectorEntry}. Returns `undefined` for noise (the
205
+ * ingest gate — I-6). Score is the record's REAL reward, never a fabricated 1.0.
206
+ */
207
+ export function patternVectorEntry(p: PatternRecord, source = 'dz-teach'): VectorEntry | undefined {
208
+ if (isVectorNoise(p.pattern)) return undefined;
209
+ const dzId = patternRecordId(p);
210
+ return {
211
+ dzId,
212
+ text: p.pattern,
213
+ score: p.reward,
214
+ taskType: 'dz-teach',
215
+ tags: ['dz-teach', p.type],
216
+ metadata: { dzId, source, ts: p.ts, domain: p.domain },
217
+ };
218
+ }
219
+
220
+ /**
221
+ * ACL: harvested {@link DreamPattern} → {@link VectorEntry}. Byte-compatible with the
222
+ * pre-feature consolidate mirror rows (same task_type `dz-learning`, same tags, `dreamId`
223
+ * preserved in metadata — NFR-7); `dzId` is additive.
224
+ */
225
+ export function dreamVectorEntry(d: DreamPattern): VectorEntry | undefined {
226
+ if (isVectorNoise(d.insight)) return undefined;
227
+ const dzId = dreamRecordId(d);
228
+ return {
229
+ dzId,
230
+ text: d.insight,
231
+ score: d.score,
232
+ taskType: 'dz-learning',
233
+ tags: ['dz-consolidate', d.outcome],
234
+ metadata: { source: 'dz-consolidate', skillId: d.skillId, sessionFile: d.sessionFile, ts: d.timestamp, dreamId: dzId, dzId },
235
+ };
236
+ }
237
+
238
+ /** ACL: stored {@link MemoryRecord} → {@link VectorEntry} (the consolidate-backfill mapper). */
239
+ export function memoryRecordVectorEntry(r: MemoryRecord): VectorEntry | undefined {
240
+ if (isVectorNoise(r.text)) return undefined;
241
+ return {
242
+ dzId: r.id,
243
+ text: r.text,
244
+ score: r.score,
245
+ taskType: r.id.startsWith('dream:') ? 'dz-learning' : 'dz-teach',
246
+ tags: ['dz-backfill', r.outcome],
247
+ metadata: { dzId: r.id, source: r.metadata?.['source'] ?? 'dz-backfill', ts: r.timestamp, skillId: r.skillId },
248
+ };
249
+ }
250
+
251
+ /* ------------------------------------------------------------------ */
252
+ /* Config + engine resolution cascade (05 §2.1) */
253
+ /* ------------------------------------------------------------------ */
254
+
255
+ /** Read `memory.vector.engine` from `.dz/config.json`. Absent/corrupt ⇒ `auto` (never throws). */
256
+ export function readVectorEngineMode(projectRoot: string): VectorEngineMode {
257
+ try {
258
+ const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
259
+ memory?: { vector?: { engine?: string } };
260
+ };
261
+ const mode = cfg.memory?.vector?.engine;
262
+ return mode === 'off' || mode === 'agentdb' || mode === 'rvf' || mode === 'auto' ? mode : 'auto';
263
+ } catch {
264
+ return 'auto';
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Should `dz teach` attempt the best-effort vector mirror at all? True when the project opted
270
+ * into the agentdb memory backend (`memory.backend === 'agentdb'`, the same gate consolidate
271
+ * uses — D3) or explicitly configured a vector engine. A fresh, unconfigured project returns
272
+ * `false`, so its `dz teach` output stays byte-identical to the pre-feature baseline (AC-1).
273
+ */
274
+ export function vectorMirrorEnabled(projectRoot: string): boolean {
275
+ try {
276
+ const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
277
+ memory?: { backend?: string; vector?: { engine?: string } };
278
+ };
279
+ if (cfg.memory?.backend === 'agentdb') return true;
280
+ const engine = cfg.memory?.vector?.engine;
281
+ return engine === 'agentdb' || engine === 'rvf';
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Engine selection cascade: config mode → `require.resolve` probe (NO module load — a
289
+ * lexical-only project pays zero engine cost, NFR-5) → adapter or an honest reason.
290
+ * Never throws. `auto` prefers agentdb (it reads the vectors consolidate already wrote),
291
+ * falling through to rvf.
292
+ */
293
+ /**
294
+ * Is `pkgName` installed for this PROJECT? A pure filesystem probe: walk `node_modules` up the
295
+ * directory tree from `projectRoot` (the npm resolution chain) — deliberately NOT
296
+ * `require.resolve`, which also consults process-global paths (`NODE_PATH`/global folders) and
297
+ * would make a lexical-only project's engine availability depend on the HOST process instead
298
+ * of the project (the I-1 determinism leak). No module is loaded (NFR-5).
299
+ */
300
+ function isPackageInstalled(projectRoot: string, pkgName: string): boolean {
301
+ let dir = projectRoot;
302
+ for (;;) {
303
+ if (existsSync(join(dir, 'node_modules', pkgName, 'package.json'))) return true;
304
+ const parent = dirname(dir);
305
+ if (parent === dir) return false;
306
+ dir = parent;
307
+ }
308
+ }
309
+
310
+ export function resolveVectorEngine(projectRoot: string): ResolvedVectorEngine {
311
+ const mode = readVectorEngineMode(projectRoot);
312
+ if (mode === 'off') return { reason: 'vector tier disabled (memory.vector.engine = "off")' };
313
+ const canResolve = (id: string): boolean => isPackageInstalled(projectRoot, id);
314
+ if (mode === 'agentdb' || mode === 'auto') {
315
+ if (canResolve('agentdb') && canResolve('better-sqlite3')) return { engine: agentdbVectorEngine(projectRoot) };
316
+ if (mode === 'agentdb') {
317
+ return { reason: 'agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
318
+ }
319
+ }
320
+ if (mode === 'rvf' || mode === 'auto') {
321
+ if (canResolve('@ruvector/rvf')) return { engine: rvfVectorEngine(projectRoot) };
322
+ if (mode === 'rvf') {
323
+ return { reason: '@ruvector/rvf not installed in project (npm i @ruvector/rvf) — vector tier inactive' };
324
+ }
325
+ }
326
+ return { reason: 'no vector engine available: agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
327
+ }
328
+
329
+ /* ------------------------------------------------------------------ */
330
+ /* Pending-mirror queue (generalized — one file for every source) */
331
+ /* ------------------------------------------------------------------ */
332
+
333
+ /** Queue entry: a {@link VectorEntry} plus a legacy-compatible `insight` alias of `text`. */
334
+ type PendingEntry = VectorEntry & { readonly insight: string };
335
+
336
+ function pendingPath(projectRoot: string): string {
337
+ return join(projectRoot, '.dz', 'mirror-pending.json');
338
+ }
339
+
340
+ function toPending(e: VectorEntry): PendingEntry {
341
+ return { ...e, insight: e.text };
342
+ }
343
+
344
+ /** Legacy queue rows (pre-port `DreamPattern[]`) are converted on read — nothing is lost. */
345
+ function readVectorPending(projectRoot: string): PendingEntry[] {
346
+ try {
347
+ const arr = JSON.parse(readFileSync(pendingPath(projectRoot), 'utf-8')) as unknown[];
348
+ if (!Array.isArray(arr)) return [];
349
+ const out: PendingEntry[] = [];
350
+ for (const item of arr) {
351
+ if (typeof item !== 'object' || item === null) continue;
352
+ const rec = item as Record<string, unknown>;
353
+ if (typeof rec['dzId'] === 'string' && typeof rec['text'] === 'string') {
354
+ out.push(toPending(rec as unknown as VectorEntry));
355
+ } else if (typeof rec['insight'] === 'string' && typeof rec['timestamp'] === 'string') {
356
+ const entry = dreamVectorEntry(rec as unknown as DreamPattern);
357
+ if (entry !== undefined) out.push(toPending(entry));
358
+ }
359
+ }
360
+ return out;
361
+ } catch {
362
+ return [];
363
+ }
364
+ }
365
+
366
+ function writeVectorPending(projectRoot: string, entries: readonly PendingEntry[]): void {
367
+ try {
368
+ if (entries.length === 0) {
369
+ if (existsSync(pendingPath(projectRoot))) rmSync(pendingPath(projectRoot));
370
+ } else {
371
+ const path = pendingPath(projectRoot);
372
+ mkdirSync(dirname(path), { recursive: true });
373
+ // Atomic write: a concurrent teach/consolidate must never observe a torn file. Write to a
374
+ // temp sibling then rename() (atomic on POSIX). Recovery guarantee: even if a racing writer
375
+ // clobbers the queue, backfillVectorMirror re-derives the missing set from the lexical store,
376
+ // so a dropped entry is recovered on the next consolidate — no permanent loss.
377
+ const tmp = `${path}.tmp`;
378
+ writeFileSync(tmp, JSON.stringify(entries, null, 2));
379
+ renameSync(tmp, path);
380
+ }
381
+ } catch { /* best-effort */ }
382
+ }
383
+
384
+ /** Honest failure note next to the session telemetry (the detached SessionEnd path is silent). */
385
+ function logMirrorNote(projectRoot: string, error: string, pending: number): void {
386
+ try {
387
+ appendFileSync(
388
+ join(projectRoot, '.dz', 'sessions.jsonl'),
389
+ JSON.stringify({ event: 'mirror', ts: new Date().toISOString(), error, pending }) + '\n',
390
+ );
391
+ } catch { /* best-effort */ }
392
+ }
393
+
394
+ /* ------------------------------------------------------------------ */
395
+ /* VectorMirrorService — the ONE write seam (QR-6) */
396
+ /* ------------------------------------------------------------------ */
397
+
398
+ /** Options shared by the mirror/recall services. `engine: null` force-disables (tests). */
399
+ export interface VectorServiceOptions {
400
+ readonly engine?: VectorEngine | null | undefined;
401
+ readonly timeoutMs?: number | undefined;
402
+ }
403
+
404
+ function pickEngine(projectRoot: string, opts: VectorServiceOptions): ResolvedVectorEngine {
405
+ if (opts.engine === null) return { reason: 'vector engine disabled (injected)' };
406
+ if (opts.engine !== undefined) return { engine: opts.engine };
407
+ return resolveVectorEngine(projectRoot);
408
+ }
409
+
410
+ /**
411
+ * Mirror prepared {@link VectorEntry}s into the vector store — **the single write seam** that
412
+ * teach, `teach --from-json`, consolidate, and the backfill all route through (QR-6). The
413
+ * lexical write is ALWAYS already durable before this runs (I-3). Semantics:
414
+ *
415
+ * 1. noise-gate the entries (I-6), merge with the pending queue (dedup by dzId),
416
+ * 2. nothing to do ⇒ `{mirrored:0}` with NO error and no queue file,
417
+ * 3. engine absent ⇒ park the batch in the queue + honest reason (heals on the next consolidate),
418
+ * 4. dedup against `engine.listIds()` (I-5 idempotency — a re-mirror adds 0 rows),
419
+ * 5. time-bounded `engine.upsert` (NC1); failure/timeout ⇒ queue + `sessions.jsonl` note.
420
+ *
421
+ * NEVER throws; the caller's exit code is unaffected by any outcome here (I-1).
422
+ */
423
+ export async function mirrorEntriesToVector(
424
+ projectRoot: string,
425
+ entries: readonly VectorEntry[],
426
+ opts: VectorServiceOptions = {},
427
+ ): Promise<MirrorReceipt> {
428
+ try {
429
+ let skipped = 0;
430
+ const gated: VectorEntry[] = [];
431
+ for (const e of entries) {
432
+ if (isVectorNoise(e.text)) skipped += 1;
433
+ else gated.push(e);
434
+ }
435
+ const byId = new Map<string, PendingEntry>();
436
+ for (const e of [...readVectorPending(projectRoot), ...gated.map(toPending)]) {
437
+ if (!byId.has(e.dzId)) byId.set(e.dzId, e);
438
+ }
439
+ const batch = [...byId.values()];
440
+ if (batch.length === 0) return { mirrored: 0, skipped, queued: 0 };
441
+
442
+ const resolved = pickEngine(projectRoot, opts);
443
+ if (resolved.engine === undefined) {
444
+ writeVectorPending(projectRoot, batch);
445
+ const error = resolved.reason ?? 'no vector engine available';
446
+ logMirrorNote(projectRoot, error, batch.length);
447
+ return { mirrored: 0, skipped, queued: batch.length, error };
448
+ }
449
+ const engine = resolved.engine;
450
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
451
+
452
+ // I-5 idempotency: skip what the store already holds (best-effort, time-bounded).
453
+ let toSend: PendingEntry[] = batch;
454
+ const listed = await withVectorTimeout(
455
+ safeEngineCall(() => engine.listIds(), (m) => ({ ids: [] as string[], error: `vector listIds failed: ${m}` })),
456
+ timeoutMs,
457
+ () => ({ ids: [] as string[], error: 'vector listIds timed out' }),
458
+ );
459
+ if (listed.error === undefined) {
460
+ const have = new Set(listed.ids);
461
+ const before = toSend.length;
462
+ toSend = toSend.filter((e) => !have.has(e.dzId));
463
+ skipped += before - toSend.length;
464
+ }
465
+ if (toSend.length === 0) {
466
+ writeVectorPending(projectRoot, []);
467
+ return { mirrored: 0, skipped, queued: 0, engine: engine.kind };
468
+ }
469
+
470
+ const up = await withVectorTimeout(
471
+ safeEngineCall(
472
+ () => engine.upsert(toSend.map(({ insight: _insight, ...entry }) => entry)),
473
+ (m) => ({ indexed: 0, error: `vector mirror failed: ${m}` }),
474
+ ),
475
+ timeoutMs,
476
+ () => ({ indexed: 0, error: `vector mirror timed out after ${timeoutMs}ms (batch queued for the next consolidate)` }),
477
+ );
478
+ if (up.error !== undefined) {
479
+ writeVectorPending(projectRoot, toSend);
480
+ logMirrorNote(projectRoot, up.error, toSend.length);
481
+ return { mirrored: up.indexed, skipped, queued: toSend.length, engine: engine.kind, error: up.error };
482
+ }
483
+ writeVectorPending(projectRoot, []);
484
+ return { mirrored: up.indexed, skipped, queued: 0, engine: engine.kind };
485
+ } catch (err) {
486
+ // Belt-and-braces: the mirror must NEVER take the caller down (I-1/I-3).
487
+ return { mirrored: 0, skipped: 0, queued: 0, error: `mirror failed: ${err instanceof Error ? err.message : String(err)}` };
488
+ }
489
+ }
490
+
491
+ /** Convenience seam for taught patterns: ACL-map + delegate to {@link mirrorEntriesToVector}. */
492
+ export async function mirrorPatternsToVector(
493
+ projectRoot: string,
494
+ patterns: readonly PatternRecord[],
495
+ source = 'dz-teach',
496
+ opts: VectorServiceOptions = {},
497
+ ): Promise<MirrorReceipt> {
498
+ const entries: VectorEntry[] = [];
499
+ let gatedOut = 0;
500
+ for (const p of patterns) {
501
+ const e = patternVectorEntry(p, source);
502
+ if (e !== undefined) entries.push(e);
503
+ else gatedOut += 1; // noise never maps (I-6) — reported honestly as skipped
504
+ }
505
+ const receipt = await mirrorEntriesToVector(projectRoot, entries, opts);
506
+ return gatedOut === 0 ? receipt : { ...receipt, skipped: receipt.skipped + gatedOut };
507
+ }
508
+
509
+ /**
510
+ * Eventual consistency (FR-2): diff `lexical dzIds ∖ engine.listIds()` and mirror the missing
511
+ * set (bounded batch) + drain the pending queue. Run by `dz consolidate` after the watermark
512
+ * write, so a teach-time mirror failure heals on the next consolidate (AC-3). Engine absent ⇒
513
+ * silent no-op (the absent tier is a state, not an error).
514
+ */
515
+ export async function backfillVectorMirror(
516
+ projectRoot: string,
517
+ opts: VectorServiceOptions & { readonly batchLimit?: number | undefined } = {},
518
+ ): Promise<MirrorReceipt> {
519
+ try {
520
+ const resolved = pickEngine(projectRoot, opts);
521
+ if (resolved.engine === undefined) {
522
+ return { mirrored: 0, skipped: 0, queued: readVectorPending(projectRoot).length };
523
+ }
524
+ const engine = resolved.engine;
525
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
526
+ const listed = await withVectorTimeout(
527
+ safeEngineCall(() => engine.listIds(), (m) => ({ ids: [] as string[], error: `vector listIds failed: ${m}` })),
528
+ timeoutMs,
529
+ () => ({ ids: [] as string[], error: 'vector listIds timed out' }),
530
+ );
531
+ if (listed.error !== undefined) {
532
+ return { mirrored: 0, skipped: 0, queued: readVectorPending(projectRoot).length, engine: engine.kind, error: listed.error };
533
+ }
534
+ const have = new Set(listed.ids);
535
+ const limit = opts.batchLimit ?? 200;
536
+ const missing: VectorEntry[] = [];
537
+ for (const r of loadStoreRecords(projectRoot)) {
538
+ if (have.has(r.id)) continue;
539
+ const e = memoryRecordVectorEntry(r);
540
+ if (e === undefined) continue;
541
+ missing.push(e);
542
+ if (missing.length >= limit) break;
543
+ }
544
+ // The seam drains the pending queue too (it merges + dedups internally).
545
+ return mirrorEntriesToVector(projectRoot, missing, { engine, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
546
+ } catch (err) {
547
+ return { mirrored: 0, skipped: 0, queued: 0, error: `backfill failed: ${err instanceof Error ? err.message : String(err)}` };
548
+ }
549
+ }
550
+
551
+ /* ------------------------------------------------------------------ */
552
+ /* HybridRecallService — the read seam (05 §2.3) */
553
+ /* ------------------------------------------------------------------ */
554
+
555
+ /** One ranked pattern feeding the RRF merge (exported so the merge is unit-testable pure). */
556
+ export interface RankedPattern {
557
+ readonly id: string;
558
+ readonly pattern: PatternRecord;
559
+ readonly backend: RecallHit['backend'];
560
+ }
561
+
562
+ const RRF_K = 60;
563
+
564
+ /**
565
+ * Reciprocal Rank Fusion merge: `score(p) = Σ 1/(60 + rank)` over the lists containing `p`
566
+ * (semantic ranks weighted by `semanticWeight`). Dedup by id; `backend: 'both'` when a pattern
567
+ * appears in both lists. DETERMINISTIC (AC-6): ties break on id, so fixed inputs always yield
568
+ * the same ordering. Pure — no I/O.
569
+ */
570
+ export function mergeHybridHits(
571
+ lexical: readonly RankedPattern[],
572
+ semantic: readonly RankedPattern[],
573
+ opts: { readonly limit: number; readonly semanticWeight?: number | undefined },
574
+ ): HybridHit[] {
575
+ const weight = opts.semanticWeight ?? 1;
576
+ interface Acc { pattern: PatternRecord; lex?: RecallHit['backend']; sem: boolean; score: number }
577
+ const acc = new Map<string, Acc>();
578
+ lexical.forEach((h, rank) => {
579
+ const cur = acc.get(h.id) ?? { pattern: h.pattern, sem: false, score: 0 };
580
+ cur.lex = h.backend;
581
+ cur.score += 1 / (RRF_K + rank + 1);
582
+ acc.set(h.id, cur);
583
+ });
584
+ semantic.forEach((h, rank) => {
585
+ const cur = acc.get(h.id) ?? { pattern: h.pattern, sem: false, score: 0 };
586
+ cur.sem = true;
587
+ cur.score += weight / (RRF_K + rank + 1);
588
+ acc.set(h.id, cur);
589
+ });
590
+ return [...acc.entries()]
591
+ .sort((a, b) => b[1].score - a[1].score || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
592
+ .slice(0, opts.limit)
593
+ .map(([, v]) => ({
594
+ pattern: v.pattern,
595
+ backend: v.lex !== undefined && v.sem ? ('both' as const) : v.lex ?? ('vector' as const),
596
+ score: v.score,
597
+ }));
598
+ }
599
+
600
+ /**
601
+ * Hybrid recall (FR-3): lexical `recallPatterns` FIRST (always, sync, UNCHANGED — AC-5), then a
602
+ * time-bounded semantic leg merged via RRF. Degradation contract (I-1): with no engine — or on
603
+ * any engine error/timeout — the returned hits are CONTENT-IDENTICAL to plain `recallPatterns`
604
+ * output, with the honest `vectorReason`/`vectorError` alongside. A vector hit whose dzId no
605
+ * longer resolves in the lexical store is DROPPED (V-1 — pruned patterns never resurrect, QR-4).
606
+ */
607
+ export async function recallHybrid(
608
+ projectRoot: string,
609
+ query: string,
610
+ opts: VectorServiceOptions & { readonly limit?: number | undefined; readonly mode?: HybridRecallMode | undefined } = {},
611
+ ): Promise<HybridRecall> {
612
+ // Config-surface note (QE P3, benign by design): recall resolves the engine directly, while teach
613
+ // only mirrors when the memory backend is agentdb (or an engine is explicit). In the window where
614
+ // the agentdb deps are INSTALLED but `memory.backend` hasn't been switched, the semantic leg reads a
615
+ // store teach never populated → empty/foreign hits. That degrades honestly (orphan dzIds are dropped
616
+ // against the lexical store, V-1) and lexical results are always returned, so it never misleads — it
617
+ // only spends a bounded, cached read. Not gated on purpose: a read-only recall must not depend on the
618
+ // write-side backend flag.
619
+ const limit = opts.limit ?? 10;
620
+ const mode = opts.mode ?? 'hybrid';
621
+ const lexical = recallPatterns(projectRoot, query, limit);
622
+ const lexicalBackend: 'sqlite' | 'json' = lexical[0]?.backend === 'sqlite' ? 'sqlite' : 'json';
623
+ const lexicalOnly = (extra: Partial<Pick<HybridRecall, 'vectorEngine' | 'vectorReason' | 'vectorError'>>): HybridRecall => ({
624
+ hits: lexical.map((h, rank) => ({ pattern: h.pattern, backend: h.backend, score: 1 / (RRF_K + rank + 1) })),
625
+ lexicalBackend,
626
+ vectorEngine: 'none',
627
+ ...extra,
628
+ });
629
+
630
+ if (mode === 'lexical') return lexicalOnly({});
631
+
632
+ let resolved: ResolvedVectorEngine;
633
+ try {
634
+ resolved = pickEngine(projectRoot, opts);
635
+ } catch (err) {
636
+ return lexicalOnly({ vectorReason: err instanceof Error ? err.message : String(err) });
637
+ }
638
+ if (resolved.engine === undefined) {
639
+ return lexicalOnly(resolved.reason !== undefined ? { vectorReason: resolved.reason } : {});
640
+ }
641
+ const engine = resolved.engine;
642
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS;
643
+
644
+ const sr = await withVectorTimeout(
645
+ safeEngineCall(
646
+ () => engine.search(query, limit * 2),
647
+ (m) => ({ hits: [] as VectorHit[], error: `vector search failed: ${m}` }),
648
+ ),
649
+ timeoutMs,
650
+ () => ({ hits: [] as VectorHit[], error: `vector search timed out after ${timeoutMs}ms` }),
651
+ );
652
+ if (sr.error !== undefined) {
653
+ return { ...lexicalOnly({}), vectorEngine: engine.kind, vectorError: sr.error };
654
+ }
655
+
656
+ // Resolve dzId → the FULL lexical record (source of truth). Orphans are dropped (V-1/QR-4).
657
+ let records: MemoryRecord[];
658
+ try {
659
+ records = loadStoreRecords(projectRoot);
660
+ } catch {
661
+ records = [];
662
+ }
663
+ const idToPattern = new Map<string, PatternRecord>();
664
+ const identityToId = new Map<string, string>();
665
+ for (const r of records) {
666
+ const p = recordToPattern(r);
667
+ idToPattern.set(r.id, p);
668
+ identityToId.set(patternIdentityOf(p), r.id);
669
+ }
670
+ const semantic: RankedPattern[] = [];
671
+ const seen = new Set<string>();
672
+ for (const h of sr.hits) {
673
+ if (seen.has(h.dzId)) continue;
674
+ const p = idToPattern.get(h.dzId);
675
+ if (p === undefined) continue; // vector-only orphan — the store pruned/expired it; NEVER resurrect
676
+ seen.add(h.dzId);
677
+ semantic.push({ id: h.dzId, pattern: p, backend: 'vector' });
678
+ }
679
+ const lex: RankedPattern[] = lexical.map((h) => ({
680
+ id: identityToId.get(patternIdentityOf(h.pattern)) ?? patternRecordId(h.pattern),
681
+ pattern: h.pattern,
682
+ backend: h.backend,
683
+ }));
684
+ const hits = mergeHybridHits(lex, semantic, { limit, semanticWeight: mode === 'semantic' ? 2 : 1 });
685
+ return { hits, lexicalBackend, vectorEngine: engine.kind };
686
+ }
687
+
688
+ /* ------------------------------------------------------------------ */
689
+ /* Status (dz vector status / dz doctor divergence line) */
690
+ /* ------------------------------------------------------------------ */
691
+
692
+ /** Field observability: engine availability + mirrored-vs-lexical counts + queue size. */
693
+ export async function vectorTierStatus(
694
+ projectRoot: string,
695
+ opts: VectorServiceOptions = {},
696
+ ): Promise<VectorTierStatus> {
697
+ const mode = readVectorEngineMode(projectRoot);
698
+ let records: MemoryRecord[];
699
+ try {
700
+ records = loadStoreRecords(projectRoot);
701
+ } catch {
702
+ records = [];
703
+ }
704
+ const lexicalMirrorable = records.filter((r) => !isVectorNoise(r.text)).length;
705
+ const pending = readVectorPending(projectRoot).length;
706
+ const resolved = pickEngine(projectRoot, opts);
707
+ if (resolved.engine === undefined) {
708
+ return {
709
+ mode,
710
+ available: false,
711
+ ...(resolved.reason !== undefined ? { reason: resolved.reason } : {}),
712
+ lexicalTotal: records.length,
713
+ lexicalMirrorable,
714
+ pending,
715
+ };
716
+ }
717
+ const engine = resolved.engine;
718
+ const listed = await withVectorTimeout(
719
+ safeEngineCall(() => engine.listIds(), (m) => ({ ids: [] as string[], error: `vector listIds failed: ${m}` })),
720
+ opts.timeoutMs ?? DEFAULT_VECTOR_TIMEOUT_MS,
721
+ () => ({ ids: [] as string[], error: 'vector listIds timed out' }),
722
+ );
723
+ return {
724
+ mode,
725
+ kind: engine.kind,
726
+ available: true,
727
+ ...(listed.error !== undefined ? { reason: listed.error } : {}),
728
+ lexicalTotal: records.length,
729
+ lexicalMirrorable,
730
+ mirrored: listed.error === undefined ? listed.ids.length : undefined,
731
+ pending,
732
+ };
733
+ }
734
+
735
+ /* ------------------------------------------------------------------ */
736
+ /* Adapter A (default): AgentdbVectorEngine */
737
+ /* ------------------------------------------------------------------ */
738
+
739
+ /**
740
+ * Option A: the `.dz/agentdb.db` ReasoningBank store. `upsert` delegates to the very same
741
+ * {@link indexPatternsToAgentdb} rows the consolidate Option-C mirror writes today (schema
742
+ * unchanged — the `agentdb-memory` MCP skill keeps reading them, NFR-7); `search`/`listIds`
743
+ * are the new READONLY halves in `agentdb-index.ts`.
744
+ */
745
+ function agentdbVectorEngine(projectRoot: string): VectorEngine {
746
+ return {
747
+ kind: 'agentdb',
748
+ async upsert(entries) {
749
+ const r = await indexPatternsToAgentdb(
750
+ projectRoot,
751
+ entries.map((e) => ({
752
+ taskType: e.taskType,
753
+ text: e.text,
754
+ score: e.score,
755
+ ...(e.tags !== undefined ? { tags: e.tags } : {}),
756
+ ...(e.metadata !== undefined ? { metadata: e.metadata } : {}),
757
+ })),
758
+ );
759
+ return { indexed: r.indexed, ...(r.error !== undefined ? { error: r.error } : {}) };
760
+ },
761
+ async search(query, limit) {
762
+ const r = await searchAgentdbPatterns(projectRoot, query, { limit });
763
+ const hits: VectorHit[] = [];
764
+ for (const h of r.hits) {
765
+ if (h.dzId !== undefined) hits.push({ dzId: h.dzId, similarity: h.similarity, text: h.text });
766
+ }
767
+ return { hits, ...(r.error !== undefined ? { error: r.error } : {}) };
768
+ },
769
+ async listIds() {
770
+ return listAgentdbDzIds(projectRoot);
771
+ },
772
+ };
773
+ }
774
+
775
+ /* ------------------------------------------------------------------ */
776
+ /* Adapter B (opt-in): RvfVectorEngine */
777
+ /* ------------------------------------------------------------------ */
778
+
779
+ interface RvfIdmap {
780
+ version: 1;
781
+ /** rvf slot/label → dzId. */
782
+ slots: Record<string, string>;
783
+ }
784
+
785
+ function rvfBase(projectRoot: string): string {
786
+ return join(projectRoot, '.dz', 'memory', 'patterns.rvf');
787
+ }
788
+
789
+ function readRvfIdmap(projectRoot: string): RvfIdmap {
790
+ try {
791
+ const parsed = JSON.parse(readFileSync(`${rvfBase(projectRoot)}.idmap.json`, 'utf-8')) as RvfIdmap;
792
+ return typeof parsed === 'object' && parsed !== null && typeof parsed.slots === 'object' ? parsed : { version: 1, slots: {} };
793
+ } catch {
794
+ return { version: 1, slots: {} };
795
+ }
796
+ }
797
+
798
+ function writeRvfSidecars(projectRoot: string, idmap: RvfIdmap): void {
799
+ const base = rvfBase(projectRoot);
800
+ mkdirSync(dirname(base), { recursive: true });
801
+ writeFileSync(`${base}.idmap.json`, JSON.stringify(idmap, null, 2));
802
+ writeFileSync(`${base}.manifest.json`, JSON.stringify(
803
+ { model: 'Xenova/all-MiniLM-L6-v2', dim: 384, engine: '@ruvector/rvf', version: 1 },
804
+ null,
805
+ 2,
806
+ ));
807
+ }
808
+
809
+ interface RvfStoreHandle {
810
+ ingest: (id: string, vec: Float32Array) => Promise<unknown> | unknown;
811
+ query: (vec: Float32Array, k: number) => Promise<unknown> | unknown;
812
+ close?: (() => Promise<void> | void) | undefined;
813
+ exportCheckpoint?: ((dest: string) => Promise<unknown> | unknown) | undefined;
814
+ }
815
+
816
+ /**
817
+ * Open a `@ruvector/rvf` store, pinned to the REAL published SDK surface (grounded in
818
+ * ruvector/npm/packages/rvf/src/index.ts + a live linux-x64 smoke against @ruvector/rvf@0.2.3):
819
+ * the canonical class is `RvfDatabase` with `create(path, { dimensions })` → `ingestBatch([{id,
820
+ * vector}])` → `query(vector, k)` returning `[{ id, distance }]` → `close()`. A few tolerant
821
+ * fallbacks (add/insert, search) keep older/alt shapes working; anything unrecognized returns an
822
+ * HONEST error (the D8 no-go evidence), never a throw. NOTE: RVF stores the vector under the `id`
823
+ * we pass (= the dzId), so no slot↔id mapping is needed — the query result's `id` IS the dzId.
824
+ */
825
+ export async function openRvfStore(mod: Record<string, unknown>, path: string, dimensions: number): Promise<RvfStoreHandle | { error: string }> {
826
+ try {
827
+ const dflt = mod['default'] as Record<string, unknown> | undefined;
828
+ const cls = (mod['RvfDatabase'] ?? dflt?.['RvfDatabase'] ?? mod['RvfStore'] ?? mod['Store'] ?? dflt?.['RvfStore'] ?? dflt ?? mod) as {
829
+ create?: (p: string, o: { dimensions?: number; dimension?: number }) => unknown;
830
+ open?: (p: string, o: { dimensions?: number; dimension?: number }) => unknown;
831
+ };
832
+ let db: Record<string, unknown> | undefined;
833
+ if (typeof cls.create === 'function') db = (await cls.create(path, { dimensions, dimension: dimensions })) as Record<string, unknown>;
834
+ else if (typeof cls.open === 'function') db = (await cls.open(path, { dimensions, dimension: dimensions })) as Record<string, unknown>;
835
+ else if (typeof cls === 'function') db = new (cls as unknown as new (p: string, o: { dimensions: number }) => Record<string, unknown>)(path, { dimensions });
836
+ if (db === undefined) return { error: 'unsupported @ruvector/rvf API (no RvfDatabase.create/open/constructor) — record a D8 no-go' };
837
+ const ingestBatch = (db['ingestBatch'] ?? db['ingest'] ?? db['add'] ?? db['insert']) as ((rows: Array<{ id: string; vector: Float32Array }>) => unknown) | undefined;
838
+ const query = (db['query'] ?? db['search']) as ((vec: Float32Array, k: number) => unknown) | undefined;
839
+ if (typeof ingestBatch !== 'function' || typeof query !== 'function') {
840
+ return { error: 'unsupported @ruvector/rvf store surface (no ingestBatch/ingest + query/search) — record a D8 no-go' };
841
+ }
842
+ const close = db['close'];
843
+ const exp = db['exportCheckpoint'] ?? db['export_checkpoint'] ?? db['checkpoint'];
844
+ return {
845
+ ingest: (id, vec) => ingestBatch.call(db, [{ id, vector: vec }]),
846
+ query: (vec, k) => query.call(db, vec, k),
847
+ close: typeof close === 'function' ? (close as () => void).bind(db) : undefined,
848
+ exportCheckpoint: typeof exp === 'function' ? (exp as (d: string) => unknown).bind(db) : undefined,
849
+ };
850
+ } catch (err) {
851
+ return { error: `@ruvector/rvf store open failed: ${err instanceof Error ? err.message : String(err)}` };
852
+ }
853
+ }
854
+
855
+ async function loadRvfModule(
856
+ projectRoot: string,
857
+ ): Promise<{ ok: true; mod: Record<string, unknown> } | { ok: false; error: string }> {
858
+ try {
859
+ const req = createRequire(join(projectRoot, 'package.json'));
860
+ const mod = (await import(pathToFileURL(req.resolve('@ruvector/rvf')).href)) as Record<string, unknown>;
861
+ return { ok: true, mod };
862
+ } catch (err) {
863
+ return { ok: false, error: `@ruvector/rvf failed to load: ${err instanceof Error ? err.message : String(err)}` };
864
+ }
865
+ }
866
+
867
+ /**
868
+ * Option B: the portable single-file VECTOR form (`.rvf`, magic `0x52564653`) with
869
+ * `.idmap.json` (slot ↔ dzId) and `.manifest.json` (model/dim — Constraint 5 staleness
870
+ * detection) sidecars. Embeddings come from agentdb's `EmbeddingService` when resolvable —
871
+ * with NEITHER embedder the engine degrades gracefully with an honest reason (05 §3.6).
872
+ */
873
+ function rvfVectorEngine(projectRoot: string): VectorEngine {
874
+ const noEmbedder = 'rvf engine present but no embedder — install agentdb (dz setup --memory agentdb)';
875
+ return {
876
+ kind: 'rvf',
877
+ async upsert(entries) {
878
+ const emb = await resolveAgentdbEmbedder(projectRoot);
879
+ if ('error' in emb) return { indexed: 0, error: noEmbedder };
880
+ const loaded = await loadRvfModule(projectRoot);
881
+ if (!loaded.ok) return { indexed: 0, error: loaded.error };
882
+ const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), 384);
883
+ if ('error' in store) return { indexed: 0, error: store.error };
884
+ try {
885
+ const idmap = readRvfIdmap(projectRoot);
886
+ let indexed = 0;
887
+ for (const e of entries) {
888
+ const vec = await emb.embed(`${e.taskType}: ${e.text}`);
889
+ await store.ingest(e.dzId, vec); // RVF stores the vector UNDER id = dzId (no slot mapping)
890
+ idmap.slots[e.dzId] = e.dzId; // sidecar keeps the dzId set for listIds/observability
891
+ indexed += 1;
892
+ }
893
+ await store.close?.();
894
+ writeRvfSidecars(projectRoot, idmap);
895
+ return { indexed };
896
+ } catch (err) {
897
+ return { indexed: 0, error: `rvf upsert failed: ${err instanceof Error ? err.message : String(err)}` };
898
+ }
899
+ },
900
+ async search(query, limit) {
901
+ const emb = await resolveAgentdbEmbedder(projectRoot);
902
+ if ('error' in emb) return { hits: [], error: noEmbedder };
903
+ const loaded = await loadRvfModule(projectRoot);
904
+ if (!loaded.ok) return { hits: [], error: loaded.error };
905
+ const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), 384);
906
+ if ('error' in store) return { hits: [], error: store.error };
907
+ try {
908
+ const idmap = readRvfIdmap(projectRoot);
909
+ const raw = await store.query(await emb.embed(query), limit);
910
+ await store.close?.();
911
+ const hits: VectorHit[] = [];
912
+ if (Array.isArray(raw)) {
913
+ for (const item of raw as Array<Record<string, unknown> | [unknown, unknown]>) {
914
+ const id = Array.isArray(item) ? item[0] : item['id'] ?? item['slot'] ?? item['label'];
915
+ const distance = Array.isArray(item) ? item[1] : item['distance'] ?? item['score'] ?? item['similarity'];
916
+ // RVF returns the id we ingested (= dzId); the sidecar is a safety join for alt shapes.
917
+ const dzId = idmap.slots[String(id)] ?? (typeof id === 'string' ? id : undefined);
918
+ // distance: lower = closer → negate so higher = better (RRF ranks by position regardless).
919
+ if (dzId !== undefined) hits.push({ dzId, similarity: typeof distance === 'number' ? -distance : 0 });
920
+ }
921
+ }
922
+ return { hits };
923
+ } catch (err) {
924
+ return { hits: [], error: `rvf search failed: ${err instanceof Error ? err.message : String(err)}` };
925
+ }
926
+ },
927
+ async listIds() {
928
+ // Sidecar-only read — no SDK load needed for observability/dedup.
929
+ return { ids: [...new Set(Object.values(readRvfIdmap(projectRoot).slots))] };
930
+ },
931
+ async exportCheckpoint(dest) {
932
+ try {
933
+ const base = rvfBase(projectRoot);
934
+ if (!existsSync(base)) return { error: `no ${base} yet — teach/consolidate with the rvf engine first` };
935
+ const loaded = await loadRvfModule(projectRoot);
936
+ let exported = false;
937
+ if (loaded.ok) {
938
+ const store = await openRvfStore(loaded.mod, base, 384);
939
+ if (!('error' in store) && store.exportCheckpoint !== undefined) {
940
+ await store.exportCheckpoint(dest);
941
+ await store.close?.();
942
+ exported = true;
943
+ } else if (!('error' in store)) {
944
+ await store.close?.();
945
+ }
946
+ }
947
+ if (!exported) copyFileSync(base, dest); // append-only format — a file copy IS a checkpoint
948
+ for (const sidecar of ['.idmap.json', '.manifest.json']) {
949
+ if (existsSync(`${base}${sidecar}`)) copyFileSync(`${base}${sidecar}`, `${dest}${sidecar}`);
950
+ }
951
+ return {};
952
+ } catch (err) {
953
+ return { error: `rvf export failed: ${err instanceof Error ? err.message : String(err)}` };
954
+ }
955
+ },
956
+ };
957
+ }