@dzhechkov/harness-core 0.3.84 → 0.3.88

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/src/index.ts CHANGED
@@ -47,6 +47,7 @@ export {
47
47
  mergeHybridHits,
48
48
  recallHybrid,
49
49
  vectorTierStatus,
50
+ reindexVectorStore,
50
51
  harmonizeVectorStore,
51
52
  selectClusterKeeper,
52
53
  importRvfCheckpoint,
@@ -72,12 +73,15 @@ export type {
72
73
  HarmonizeOptions,
73
74
  ImportReport,
74
75
  ImportOptions,
76
+ ReindexVectorReport,
75
77
  } from './vector-tier.js';
76
78
  export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION } from './setup.js';
77
79
  export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStatePath } from './statusline.js';
78
80
  export type { StatuslineData, FeatureAdrState, WriteFeatureAdrStateInput } from './statusline.js';
79
- export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb } from './agentdb-index.js';
81
+ export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows } from './agentdb-index.js';
80
82
  export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
83
+ export { DEFAULT_EMBED_MODEL, LEGACY_EMBED_MODEL, DEFAULT_EMBED_DIM, KNOWN_EMBED_DIMS, resolveEmbedModel, readEmbedManifest, writeEmbedManifest, embedManifestPath, legacyEmbedManifest } from './embedding-config.js';
84
+ export type { EmbedModelConfig, EmbedModelSource, EmbedManifest } from './embedding-config.js';
81
85
  export { putBookKnowledge, queryBookKnowledge, bookKbPath } from './book-kb.js';
82
86
  export type { BookKU, BookKUHit } from './book-kb.js';
83
87
  export {
@@ -91,6 +95,8 @@ export {
91
95
  promoteProjectToBrain,
92
96
  updateBrainSource,
93
97
  queryBrain,
98
+ searchBrainVectors,
99
+ reindexBrainVectors,
94
100
  rerankHits,
95
101
  groundPrompt,
96
102
  buildPrimer,
package/src/setup.ts CHANGED
@@ -271,7 +271,7 @@ function generateDzConfig(target: string, preset: string | undefined, backend: M
271
271
  mcpServer: 'agentdb',
272
272
  // Both the hook writer and the MCP server read/write THIS path (env AGENTDB_PATH).
273
273
  storePath: '.dz/agentdb.db',
274
- embeddingModel: 'Xenova/all-MiniLM-L6-v2',
274
+ embeddingModel: 'Xenova/paraphrase-multilingual-MiniLM-L12-v2',
275
275
  sessionHookWrites: true,
276
276
  } : undefined,
277
277
  },
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
24
- import { join, relative, dirname, basename } from 'node:path';
24
+ import { join, relative, resolve, dirname, basename } from 'node:path';
25
25
  import { createHash } from 'node:crypto';
26
26
 
27
27
  /** One shared skill whose copies byte-differ (`driftFiles > 0`). */
@@ -65,27 +65,46 @@ export interface SweepResult {
65
65
  readonly allowlisted: readonly DriftedSkill[];
66
66
  }
67
67
 
68
+ /**
69
+ * How the canonical was resolved for a {@link syncCanonicalSkill} run.
70
+ * - `from` — an explicit `--from <dir>` was supplied.
71
+ * - `skills-meta` — no `--from`; `skills-meta/<skill>` exists and was used.
72
+ * - `auto` — no explicit canonical; the WRITE path auto-detected the most-complete copy
73
+ * (opt-in `--auto` ONLY — never a bare default).
74
+ * - `none` — no canonical could be resolved. `--check` still runs a canonical-free peer
75
+ * comparison; a bare write refuses (safe-by-default).
76
+ */
77
+ export type CanonicalSource = 'from' | 'skills-meta' | 'auto' | 'none';
78
+
68
79
  /** Options for {@link syncCanonicalSkill}. */
69
80
  export interface SyncCanonicalOptions {
70
81
  /** Report drift only, write NOTHING (CI mode). */
71
82
  readonly check?: boolean;
72
83
  /** Override the canonical source dir (default: `skills-meta/<skill>`). */
73
84
  readonly from?: string;
85
+ /**
86
+ * Opt-in for the WRITE path ONLY: when no `--from`/`skills-meta` canonical exists, auto-detect the
87
+ * most-complete copy as canonical instead of refusing. A HEURISTIC — the CLI prints a loud warning
88
+ * naming the pick + the exact overwrite list. Never affects the read-only `check` path.
89
+ */
90
+ readonly auto?: boolean;
74
91
  }
75
92
 
76
- /** Result of a canonical-wins sync (or a `check:true` dry-run). */
93
+ /** Result of a canonical-wins sync (or a `check:true` dry-run / canonical-free peer check). */
77
94
  export interface SyncResult {
78
- /** Resolved canonical dir (abs). */
95
+ /** Resolved canonical dir (abs). Empty `''` in canonical-free peer / refuse modes. */
79
96
  readonly canonical: string;
80
- /** Whether the canonical source dir exists. `false` ⇒ nothing was done (CLI → non-zero exit). */
97
+ /** Whether a canonical source dir was resolved. `false` ⇒ `resolvedFrom === 'none'`. */
81
98
  readonly canonicalExists: boolean;
82
- /** # non-canonical copies found. */
99
+ /** How the canonical resolved: `from` | `skills-meta` | `auto` | `none`. */
100
+ readonly resolvedFrom: CanonicalSource;
101
+ /** # copies compared — non-canonical copies in resolved modes; ALL peers in canonical-free mode. */
83
102
  readonly copies: number;
84
- /** # copies overwritten (0 when `check:true`). */
103
+ /** # copies overwritten (0 when `check:true`, in peer mode, or when a bare write refuses). */
85
104
  readonly synced: number;
86
- /** # copies that differ from canonical. */
105
+ /** # copies/files that differ (from canonical in resolved modes; between peers in canonical-free mode). */
87
106
  readonly drifted: number;
88
- /** Abs paths of copies written (empty when `check:true` — proves no writes). */
107
+ /** Abs paths of copies written (empty when `check:true` / peer / refuse — proves no writes). */
89
108
  readonly wrote: readonly string[];
90
109
  }
91
110
 
@@ -115,6 +134,69 @@ function walk(dir: string): string[] {
115
134
  return out;
116
135
  }
117
136
 
137
+ /**
138
+ * Byte-compare a set of skill copies against EACH OTHER (canonical-free). Builds the union of
139
+ * relative file paths across every copy, hashes each `(copy × rel)`, and counts how many relative
140
+ * paths disagree (`driftFiles`) and how many `(copy × rel)` pairs are absent from a copy
141
+ * (`missingFiles`). `driftFiles > 0` ⇔ the copies are NOT byte-identical to one another.
142
+ *
143
+ * This is the exact per-skill comparison {@link sweepSkillDrift} performs; it is extracted verbatim
144
+ * so BOTH the CI sweep AND the canonical-free `sync-canonical --check` share one implementation and
145
+ * yield the same verdict. Behavior-preserving refactor — no numbers change.
146
+ */
147
+ function comparePeers(copies: readonly string[]): { driftFiles: number; missingFiles: number; totalFiles: number } {
148
+ const relFiles = new Set<string>();
149
+ for (const c of copies) for (const f of walk(c)) relFiles.add(relative(c, f));
150
+
151
+ let driftFiles = 0;
152
+ let missingFiles = 0;
153
+ for (const rel of relFiles) {
154
+ const hashes = new Set<string>();
155
+ for (const c of copies) {
156
+ const p = join(c, rel);
157
+ if (existsSync(p)) hashes.add(md5(p));
158
+ else {
159
+ missingFiles++;
160
+ hashes.add('__MISSING__');
161
+ }
162
+ }
163
+ if (hashes.size > 1) driftFiles++;
164
+ }
165
+ return { driftFiles, missingFiles, totalFiles: relFiles.size };
166
+ }
167
+
168
+ /**
169
+ * Deterministic auto-pick of a canonical from a set of copies: the copy with the MOST files wins,
170
+ * tie-broken lexicographically on sorted path (so the same drift always auto-picks the same
171
+ * canonical, across runs and machines). "Most complete" is a HEURISTIC, not a correctness oracle —
172
+ * hence it is only ever reached behind an explicit `--auto` opt-in plus a loud warning.
173
+ */
174
+ function pickMostComplete(copies: readonly string[]): string {
175
+ return [...copies].sort().reduce((best, c) => (walk(c).length > walk(best).length ? c : best));
176
+ }
177
+
178
+ /**
179
+ * Resolve WHICH dir is canonical for a sync/check run, and HOW it resolved. Precedence is identical
180
+ * for read and write paths:
181
+ * 1. `opts.from` → `'from'`
182
+ * 2. `skills-meta/<skill>` → `'skills-meta'`
183
+ * 3. `opts.auto` (write) → `'auto'` (most-complete copy)
184
+ * 4. otherwise → `'none'` (no canonical — `--check` compares copies to each other;
185
+ * a bare write refuses)
186
+ */
187
+ function resolveCanonical(
188
+ root: string,
189
+ skill: string,
190
+ copies: readonly string[],
191
+ opts: SyncCanonicalOptions,
192
+ ): { canonical: string | null; resolvedFrom: CanonicalSource } {
193
+ if (opts.from !== undefined) return { canonical: resolve(opts.from), resolvedFrom: 'from' };
194
+ const meta = join(root, 'packages/@dzhechkov/skills-meta', skill);
195
+ if (existsSync(meta)) return { canonical: meta, resolvedFrom: 'skills-meta' };
196
+ if (opts.auto === true && copies.length >= 1) return { canonical: pickMostComplete(copies), resolvedFrom: 'auto' };
197
+ return { canonical: null, resolvedFrom: 'none' };
198
+ }
199
+
118
200
  /**
119
201
  * Every skill dir (a dir containing `SKILL.md`) under `packages/` + `.claude/skills`, excluding
120
202
  * `node_modules` / `__pycache__`. Ported verbatim from `scripts/drift-sweep-skills.mjs`.
@@ -131,6 +213,10 @@ function findSkillDirs(root: string, scope: 'packages' | 'all' = 'all'): string[
131
213
  } catch {
132
214
  continue;
133
215
  }
216
+ // Drift-guard scope = SKILL.md-bearing dirs ONLY. `templates/docs/<name>/` mirrors carry
217
+ // skill-shaped NAMES but are rendered/derived documentation (no SKILL.md) and are therefore
218
+ // intentionally excluded — they are not skill definitions, so there is nothing to keep in sync.
219
+ // (Verified: no `packages/**/templates/docs/**` dir carries a SKILL.md. See ADR-001, D1.)
134
220
  if (entries.includes(SKILL_MANIFEST)) dirs.push(d);
135
221
  for (const e of entries) {
136
222
  if (IGNORED_ENTRIES.has(e)) continue;
@@ -173,31 +259,16 @@ export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepRes
173
259
  duplicated++;
174
260
  const copies = [...unsorted].sort();
175
261
 
176
- // Union of relative file paths across every copy.
177
- const relFiles = new Set<string>();
178
- for (const c of copies) for (const f of walk(c)) relFiles.add(relative(c, f));
179
-
180
- let driftFiles = 0;
181
- let missingFiles = 0;
182
- for (const rel of relFiles) {
183
- const hashes = new Set<string>();
184
- for (const c of copies) {
185
- const p = join(c, rel);
186
- if (existsSync(p)) hashes.add(md5(p));
187
- else {
188
- missingFiles++;
189
- hashes.add('__MISSING__');
190
- }
191
- }
192
- if (hashes.size > 1) driftFiles++;
193
- }
262
+ // Per-skill byte-comparison of every copy against each other (extracted to `comparePeers`
263
+ // so the canonical-free `sync-canonical --check` reuses the EXACT same logic).
264
+ const { driftFiles, missingFiles, totalFiles } = comparePeers(copies);
194
265
 
195
266
  if (driftFiles > 0) {
196
267
  const entry: DriftedSkill = {
197
268
  name,
198
269
  copies: copies.length,
199
270
  driftFiles,
200
- totalFiles: relFiles.size,
271
+ totalFiles,
201
272
  missingFiles,
202
273
  locations: copies,
203
274
  };
@@ -212,31 +283,51 @@ export function sweepSkillDrift(root: string, opts: SweepOptions = {}): SweepRes
212
283
  }
213
284
 
214
285
  /**
215
- * Heal one skill: treat `from ?? skills-meta/<skill>` as canonical and overwrite every other copy in
216
- * the monorepo, proving byte-identity. Pure port of `scripts/sync-canonical-skill.mjs`.
286
+ * Heal one skill: treat the resolved canonical (`--from` `skills-meta/<skill>` `--auto`
287
+ * most-complete copy) as authoritative and overwrite every other copy in the monorepo, proving
288
+ * byte-identity. Pure port of `scripts/sync-canonical-skill.mjs`, extended with a canonical-free path.
217
289
  *
218
290
  * `check:true` writes NOTHING (`wrote` stays empty) and only reports the drift count.
219
291
  * Default overwrites drifting copies; a subsequent {@link sweepSkillDrift} then reports 0 drift.
220
- * When the canonical dir does not exist, returns `canonicalExists:false` and does nothing (the CLI
221
- * turns that into a non-zero exit)this function never throws / never `process.exit`s.
292
+ *
293
+ * When NO canonical resolves (`resolvedFrom === 'none'` no `--from`, no `skills-meta`, no `--auto`):
294
+ * • `check:true` → CANONICAL-FREE peer check: `drifted` = # files that differ ACROSS the copies
295
+ * (byte-identical copies ⇒ `drifted === 0`). Reuses {@link comparePeers} — the exact sweep logic.
296
+ * • write (bare) → REFUSES: returns `wrote:[]`, `synced:0`, mutates NOTHING. Safe-by-default: the
297
+ * tool never guesses a canonical for a write, because a wrong pick destroys the good copy. The
298
+ * operator must pass `--from`, run `--check`, or opt in to `--auto` (which the CLI announces).
299
+ *
300
+ * This function never throws / never `process.exit`s — the CLI owns exit codes, printing, and the
301
+ * loud `--auto` warning.
222
302
  */
223
303
  export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanonicalOptions = {}): SyncResult {
224
304
  const check = opts.check === true;
225
- const canonical = opts.from ?? join(root, 'packages/@dzhechkov/skills-meta', skill);
226
305
 
227
- if (!existsSync(canonical)) {
228
- return { canonical, canonicalExists: false, copies: 0, synced: 0, drifted: 0, wrote: [] };
306
+ // The healer/checker operates on EXACTLY what the detector sees (same roots via findSkillDirs)
307
+ // otherwise a copy could be silently healed but never gated, or vice-versa.
308
+ const allCopies = findSkillDirs(root, 'all')
309
+ .filter((d) => basename(d) === skill)
310
+ .sort();
311
+
312
+ const { canonical, resolvedFrom } = resolveCanonical(root, skill, allCopies, opts);
313
+
314
+ // No canonical resolved → asymmetric read/write handling (ADR-001 D3/D4).
315
+ if (canonical === null) {
316
+ if (check) {
317
+ // CANONICAL-FREE peer check: are the copies byte-identical to EACH OTHER? (<2 ⇒ vacuously so.)
318
+ const drifted = allCopies.length < 2 ? 0 : comparePeers(allCopies).driftFiles;
319
+ return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted, wrote: [] };
320
+ }
321
+ // Bare WRITE with no resolvable canonical → REFUSE. Mutates nothing (`wrote:[]` proves it).
322
+ return { canonical: '', canonicalExists: false, resolvedFrom, copies: allCopies.length, synced: 0, drifted: 0, wrote: [] };
229
323
  }
230
324
 
231
325
  const canonFiles = walk(canonical)
232
326
  .map((p) => relative(canonical, p))
233
327
  .sort();
234
328
 
235
- // The healer heals EXACTLY what the detector sees (same roots via findSkillDirs) — otherwise a copy
236
- // could be silently healed but never gated, or vice-versa. Every <skill>/ dir except the canonical.
237
- const copies = findSkillDirs(root, 'all')
238
- .filter((d) => basename(d) === skill && relative(canonical, d) !== '')
239
- .sort();
329
+ // Every <skill>/ dir except the canonical itself.
330
+ const copies = allCopies.filter((d) => relative(canonical, d) !== '');
240
331
 
241
332
  let drifted = 0;
242
333
  let synced = 0;
@@ -269,5 +360,5 @@ export function syncCanonicalSkill(root: string, skill: string, opts: SyncCanoni
269
360
  wrote.push(copy);
270
361
  }
271
362
 
272
- return { canonical, canonicalExists: true, copies: copies.length, synced, drifted, wrote };
363
+ return { canonical, canonicalExists: true, resolvedFrom, copies: copies.length, synced, drifted, wrote };
273
364
  }
@@ -59,7 +59,9 @@ import {
59
59
  resolveAgentdbEmbedder,
60
60
  cosineSimilarity,
61
61
  importVectorsToAgentdb,
62
+ reindexAgentdbRows,
62
63
  } from './agentdb-index.js';
64
+ import { currentEmbedManifest, guardEmbedSpace, DEFAULT_EMBED_DIM, resolveEmbedModel, type EmbedModelConfig } from './embedding-config.js';
63
65
 
64
66
  /* ------------------------------------------------------------------ */
65
67
  /* Types (04_domain_model §3.4 / §4.1) */
@@ -165,6 +167,7 @@ export interface VectorTierStatus {
165
167
  readonly kind?: VectorEngineKind | undefined;
166
168
  readonly available: boolean;
167
169
  readonly reason?: string | undefined;
170
+ readonly embeddingModel?: string | undefined;
168
171
  readonly lexicalTotal: number;
169
172
  readonly lexicalMirrorable: number;
170
173
  readonly mirrored?: number | undefined;
@@ -174,10 +177,6 @@ export interface VectorTierStatus {
174
177
  /** Wall-time bound applied to EVERY engine call, read and write legs alike (ADR R1 + NC1). */
175
178
  export const DEFAULT_VECTOR_TIMEOUT_MS = 10_000;
176
179
 
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
180
  /** Default cosine cutoff for near-duplicate clustering (`--threshold` / config overrides). */
182
181
  export const DEFAULT_HARMONIZE_THRESHOLD = 0.92;
183
182
 
@@ -255,6 +254,14 @@ export interface ImportOptions extends VectorServiceOptions {
255
254
  readonly embed?: ((text: string) => Promise<Float32Array>) | undefined;
256
255
  }
257
256
 
257
+ export interface ReindexVectorReport {
258
+ readonly reembedded: number;
259
+ readonly model?: string;
260
+ readonly version?: number;
261
+ readonly backupPath?: string;
262
+ readonly error?: string;
263
+ }
264
+
258
265
  /* ------------------------------------------------------------------ */
259
266
  /* Timeout wrapper (both legs — NC1/QR-1) */
260
267
  /* ------------------------------------------------------------------ */
@@ -817,6 +824,7 @@ export async function vectorTierStatus(
817
824
  opts: VectorServiceOptions = {},
818
825
  ): Promise<VectorTierStatus> {
819
826
  const mode = readVectorEngineMode(projectRoot);
827
+ const model = resolveEmbedModel(projectRoot);
820
828
  let records: MemoryRecord[];
821
829
  try {
822
830
  records = loadStoreRecords(projectRoot);
@@ -831,6 +839,7 @@ export async function vectorTierStatus(
831
839
  mode,
832
840
  available: false,
833
841
  ...(resolved.reason !== undefined ? { reason: resolved.reason } : {}),
842
+ ...(!('error' in model) ? { embeddingModel: model.model } : {}),
834
843
  lexicalTotal: records.length,
835
844
  lexicalMirrorable,
836
845
  pending,
@@ -847,6 +856,7 @@ export async function vectorTierStatus(
847
856
  kind: engine.kind,
848
857
  available: true,
849
858
  ...(listed.error !== undefined ? { reason: listed.error } : {}),
859
+ ...(!('error' in model) ? { embeddingModel: model.model } : {}),
850
860
  lexicalTotal: records.length,
851
861
  lexicalMirrorable,
852
862
  mirrored: listed.error === undefined ? listed.ids.length : undefined,
@@ -854,6 +864,31 @@ export async function vectorTierStatus(
854
864
  };
855
865
  }
856
866
 
867
+ export async function reindexVectorStore(projectRoot: string, opts: VectorServiceOptions = {}): Promise<ReindexVectorReport> {
868
+ const resolved = pickEngine(projectRoot, opts);
869
+ if (resolved.engine === undefined) return { reembedded: 0, error: resolved.reason ?? 'no vector engine available' };
870
+ if (resolved.engine.kind !== 'agentdb') {
871
+ return { reembedded: 0, error: 'dz vector reindex currently rewrites the agentdb learned-pattern mirror; switch memory.vector.engine to agentdb/auto' };
872
+ }
873
+ let records: MemoryRecord[];
874
+ try {
875
+ records = loadStoreRecords(projectRoot);
876
+ } catch {
877
+ records = [];
878
+ }
879
+ const rows = records
880
+ .map(memoryRecordVectorEntry)
881
+ .filter((r): r is VectorEntry => r !== undefined)
882
+ .map((r) => ({
883
+ taskType: r.taskType,
884
+ text: r.text,
885
+ score: r.score,
886
+ ...(r.tags !== undefined ? { tags: r.tags } : {}),
887
+ ...(r.metadata !== undefined ? { metadata: r.metadata } : {}),
888
+ }));
889
+ return reindexAgentdbRows(projectRoot, rows);
890
+ }
891
+
857
892
  /* ------------------------------------------------------------------ */
858
893
  /* Harmonize — SEMANTIC dedup of the lexical store (05 §2.1) */
859
894
  /* ------------------------------------------------------------------ */
@@ -1139,8 +1174,15 @@ export async function importRvfCheckpoint(projectRoot: string, source: string, o
1139
1174
  if (existsSync(manifestPath)) {
1140
1175
  try {
1141
1176
  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`);
1177
+ const configured = resolveEmbedModel(projectRoot);
1178
+ if ('error' in configured) return fail(configured.error);
1179
+ const manifest = {
1180
+ model: typeof m.model === 'string' ? m.model : configured.model,
1181
+ dim: typeof m.dim === 'number' ? m.dim : configured.dim,
1182
+ version: 1,
1183
+ };
1184
+ if (manifest.model !== configured.model || manifest.dim !== configured.dim) {
1185
+ return fail(`manifest mismatch: checkpoint (${String(manifest.model)}/${String(manifest.dim)}) ≠ local (${configured.model}/${configured.dim}) — run dz vector reindex; refusing a cross-embedding-space merge`);
1144
1186
  }
1145
1187
  } catch { /* unreadable manifest — tolerate; the idmap is the authority */ }
1146
1188
  }
@@ -1276,12 +1318,26 @@ function readRvfIdmap(projectRoot: string): RvfIdmap {
1276
1318
  }
1277
1319
  }
1278
1320
 
1279
- function writeRvfSidecars(projectRoot: string, idmap: RvfIdmap): void {
1321
+ function guardRvfEmbedSpace(projectRoot: string, idmap: RvfIdmap): { ok: true; configured: EmbedModelConfig; version: number } | { ok: false; error: string } {
1322
+ const configured = resolveEmbedModel(projectRoot);
1323
+ if ('error' in configured) return { ok: false, error: configured.error };
1324
+ const guard = guardEmbedSpace({
1325
+ storePath: rvfBase(projectRoot),
1326
+ configured,
1327
+ hasRows: Object.keys(idmap.slots).length > 0,
1328
+ reindexHint: 'dz vector reindex',
1329
+ });
1330
+ if (!guard.ok) return { ok: false, error: guard.error };
1331
+ return { ok: true, configured, version: guard.manifest.version };
1332
+ }
1333
+
1334
+ function writeRvfSidecars(projectRoot: string, idmap: RvfIdmap, configured: EmbedModelConfig, version: number): void {
1280
1335
  const base = rvfBase(projectRoot);
1281
1336
  mkdirSync(dirname(base), { recursive: true });
1282
1337
  writeFileSync(`${base}.idmap.json`, JSON.stringify(idmap, null, 2));
1338
+ const manifest = currentEmbedManifest(configured, version, '@ruvector/rvf');
1283
1339
  writeFileSync(`${base}.manifest.json`, JSON.stringify(
1284
- { model: 'Xenova/all-MiniLM-L6-v2', dim: 384, engine: '@ruvector/rvf', version: 1 },
1340
+ manifest,
1285
1341
  null,
1286
1342
  2,
1287
1343
  ));
@@ -1356,14 +1412,16 @@ function rvfVectorEngine(projectRoot: string): VectorEngine {
1356
1412
  return {
1357
1413
  kind: 'rvf',
1358
1414
  async upsert(entries) {
1415
+ const idmap = readRvfIdmap(projectRoot);
1416
+ const guard = guardRvfEmbedSpace(projectRoot, idmap);
1417
+ if (!guard.ok) return { indexed: 0, error: guard.error };
1359
1418
  const emb = await resolveAgentdbEmbedder(projectRoot);
1360
1419
  if ('error' in emb) return { indexed: 0, error: noEmbedder };
1361
1420
  const loaded = await loadRvfModule(projectRoot);
1362
1421
  if (!loaded.ok) return { indexed: 0, error: loaded.error };
1363
- const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), 384);
1422
+ const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), DEFAULT_EMBED_DIM);
1364
1423
  if ('error' in store) return { indexed: 0, error: store.error };
1365
1424
  try {
1366
- const idmap = readRvfIdmap(projectRoot);
1367
1425
  let indexed = 0;
1368
1426
  for (const e of entries) {
1369
1427
  const vec = await emb.embed(`${e.taskType}: ${e.text}`);
@@ -1372,7 +1430,7 @@ function rvfVectorEngine(projectRoot: string): VectorEngine {
1372
1430
  indexed += 1;
1373
1431
  }
1374
1432
  await store.close?.();
1375
- writeRvfSidecars(projectRoot, idmap);
1433
+ writeRvfSidecars(projectRoot, idmap, guard.configured, guard.version);
1376
1434
  return { indexed };
1377
1435
  } catch (err) {
1378
1436
  return { indexed: 0, error: `rvf upsert failed: ${err instanceof Error ? err.message : String(err)}` };
@@ -1381,12 +1439,14 @@ function rvfVectorEngine(projectRoot: string): VectorEngine {
1381
1439
  async search(query, limit) {
1382
1440
  const emb = await resolveAgentdbEmbedder(projectRoot);
1383
1441
  if ('error' in emb) return { hits: [], error: noEmbedder };
1442
+ const idmap = readRvfIdmap(projectRoot);
1443
+ const guard = guardRvfEmbedSpace(projectRoot, idmap);
1444
+ if (!guard.ok) return { hits: [], error: guard.error };
1384
1445
  const loaded = await loadRvfModule(projectRoot);
1385
1446
  if (!loaded.ok) return { hits: [], error: loaded.error };
1386
- const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), 384);
1447
+ const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), DEFAULT_EMBED_DIM);
1387
1448
  if ('error' in store) return { hits: [], error: store.error };
1388
1449
  try {
1389
- const idmap = readRvfIdmap(projectRoot);
1390
1450
  const raw = await store.query(await emb.embed(query), limit);
1391
1451
  await store.close?.();
1392
1452
  const hits: VectorHit[] = [];
@@ -1410,12 +1470,14 @@ function rvfVectorEngine(projectRoot: string): VectorEngine {
1410
1470
  return { ids: [...new Set(Object.values(readRvfIdmap(projectRoot).slots))] };
1411
1471
  },
1412
1472
  async importVectors(rows) {
1473
+ const idmap = readRvfIdmap(projectRoot);
1474
+ const guard = guardRvfEmbedSpace(projectRoot, idmap);
1475
+ if (!guard.ok) return { imported: 0, error: guard.error };
1413
1476
  const loaded = await loadRvfModule(projectRoot);
1414
1477
  if (!loaded.ok) return { imported: 0, error: loaded.error };
1415
- const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), 384);
1478
+ const store = await openRvfStore(loaded.mod, rvfBase(projectRoot), DEFAULT_EMBED_DIM);
1416
1479
  if ('error' in store) return { imported: 0, error: store.error };
1417
1480
  try {
1418
- const idmap = readRvfIdmap(projectRoot);
1419
1481
  let imported = 0;
1420
1482
  for (const r of rows) {
1421
1483
  await store.ingest(r.dzId, r.vector); // RVF ingest is upsert-by-id (id = dzId) — no duplicates
@@ -1423,7 +1485,7 @@ function rvfVectorEngine(projectRoot: string): VectorEngine {
1423
1485
  imported += 1;
1424
1486
  }
1425
1487
  await store.close?.();
1426
- writeRvfSidecars(projectRoot, idmap);
1488
+ writeRvfSidecars(projectRoot, idmap, guard.configured, guard.version);
1427
1489
  return { imported };
1428
1490
  } catch (err) {
1429
1491
  return { imported: 0, error: `rvf import failed: ${err instanceof Error ? err.message : String(err)}` };