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