@dzhechkov/harness-core 0.3.78 → 0.3.82

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.
@@ -33,7 +33,7 @@
33
33
  */
34
34
 
35
35
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, renameSync, appendFileSync, copyFileSync } from 'node:fs';
36
- import { dirname, join } from 'node:path';
36
+ import { basename, dirname, join } from 'node:path';
37
37
  import { pathToFileURL } from 'node:url';
38
38
  import { createRequire } from 'node:module';
39
39
 
@@ -47,10 +47,19 @@ import {
47
47
  patternIdentityOf,
48
48
  dreamRecordId,
49
49
  loadStoreRecords,
50
+ removePatternsByIds,
51
+ snapshotStore,
50
52
  type PatternRecord,
51
53
  type RecallHit,
52
54
  } from './patterns.js';
53
- import { indexPatternsToAgentdb, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder } from './agentdb-index.js';
55
+ import {
56
+ indexPatternsToAgentdb,
57
+ searchAgentdbPatterns,
58
+ listAgentdbDzIds,
59
+ resolveAgentdbEmbedder,
60
+ cosineSimilarity,
61
+ importVectorsToAgentdb,
62
+ } from './agentdb-index.js';
54
63
 
55
64
  /* ------------------------------------------------------------------ */
56
65
  /* Types (04_domain_model §3.4 / §4.1) */
@@ -96,6 +105,16 @@ export interface MirrorReceipt {
96
105
  readonly error?: string | undefined;
97
106
  }
98
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
+
99
118
  /** The engine PORT — both adapters implement exactly this surface (04 §4.1). */
100
119
  export interface VectorEngine {
101
120
  readonly kind: VectorEngineKind;
@@ -104,6 +123,12 @@ export interface VectorEngine {
104
123
  listIds(): Promise<{ ids: string[]; error?: string | undefined }>;
105
124
  /** Portable single-file checkpoint (RVF adapter only — `dz vector export`). */
106
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 }>;
107
132
  }
108
133
 
109
134
  /** Outcome of {@link resolveVectorEngine}: an engine, or an honest reason why not. */
@@ -149,6 +174,87 @@ export interface VectorTierStatus {
149
174
  /** Wall-time bound applied to EVERY engine call, read and write legs alike (ADR R1 + NC1). */
150
175
  export const DEFAULT_VECTOR_TIMEOUT_MS = 10_000;
151
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
+
152
258
  /* ------------------------------------------------------------------ */
153
259
  /* Timeout wrapper (both legs — NC1/QR-1) */
154
260
  /* ------------------------------------------------------------------ */
@@ -265,6 +371,22 @@ export function readVectorEngineMode(projectRoot: string): VectorEngineMode {
265
371
  }
266
372
  }
267
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
+
268
390
  /**
269
391
  * Should `dz teach` attempt the best-effort vector mirror at all? True when the project opted
270
392
  * into the agentdb memory backend (`memory.backend === 'agentdb'`, the same gate consolidate
@@ -732,6 +854,352 @@ export async function vectorTierStatus(
732
854
  };
733
855
  }
734
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
+
735
1203
  /* ------------------------------------------------------------------ */
736
1204
  /* Adapter A (default): AgentdbVectorEngine */
737
1205
  /* ------------------------------------------------------------------ */
@@ -769,6 +1237,19 @@ function agentdbVectorEngine(projectRoot: string): VectorEngine {
769
1237
  async listIds() {
770
1238
  return listAgentdbDzIds(projectRoot);
771
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
+ },
772
1253
  };
773
1254
  }
774
1255
 
@@ -928,6 +1409,26 @@ function rvfVectorEngine(projectRoot: string): VectorEngine {
928
1409
  // Sidecar-only read — no SDK load needed for observability/dedup.
929
1410
  return { ids: [...new Set(Object.values(readRvfIdmap(projectRoot).slots))] };
930
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
+ },
931
1432
  async exportCheckpoint(dest) {
932
1433
  try {
933
1434
  const base = rvfBase(projectRoot);