ruvector 0.2.30 → 0.2.32

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/bin/cli.js CHANGED
@@ -188,22 +188,68 @@ program
188
188
  const spinner = ora('Loading database...').start();
189
189
 
190
190
  try {
191
- // Read dimension from sidecar (avoids JSON-parsing binary redb)
191
+ // Read dimension + embedding provenance from sidecar (#508, ADR-210 D0).
192
+ // Sidecar JSON is untrusted on-disk input: malformed records are treated
193
+ // as absent (sanitizeDimension / sanitizeProvenanceSafe), never crash.
192
194
  let dimension = 384;
195
+ let storeProvenance = null;
193
196
  const metaPath = `${dbPath}.meta.json`;
194
197
  if (fs.existsSync(metaPath)) {
195
- try { dimension = JSON.parse(fs.readFileSync(metaPath, 'utf8')).dimension || 384; } catch (_) {}
198
+ try {
199
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
200
+ dimension = sanitizeDimension(meta.dimension, 384);
201
+ storeProvenance = sanitizeProvenanceSafe(meta.provenance);
202
+ } catch (_) {}
196
203
  }
197
204
 
198
205
  spinner.text = 'Reading vectors...';
199
206
  const data = JSON.parse(fs.readFileSync(file, 'utf8'));
200
- const vectors = Array.isArray(data) ? data : [data];
207
+ // Accept a plain array (raw vectors, no declared provenance) or the
208
+ // ADR-210 object form `{ provenance, vectors }` from embedding-path
209
+ // exporters. A malformed declared provenance is treated as undeclared
210
+ // (the dimension gate below still applies).
211
+ let declaredProvenance = null;
212
+ let vectors;
213
+ if (Array.isArray(data)) {
214
+ vectors = data;
215
+ } else if (data && Array.isArray(data.vectors)) {
216
+ vectors = data.vectors;
217
+ declaredProvenance = sanitizeProvenanceSafe(data.provenance);
218
+ } else {
219
+ vectors = [data];
220
+ }
221
+
222
+ // ADR-210 D0: a store stamped with embedding provenance refuses
223
+ // mismatched inserts — clear error naming both sides, no coercion.
224
+ if (storeProvenance) {
225
+ const provMod = loadProvenance();
226
+ const describe = provMod ? provMod.describeProvenance : (p) => JSON.stringify(p);
227
+ const badDim = vectors.find(v => v && Array.isArray(v.vector) && v.vector.length !== storeProvenance.dimension);
228
+ const provMismatch = declaredProvenance && provMod
229
+ ? provMod.compareProvenance(storeProvenance, declaredProvenance)
230
+ : [];
231
+ if (badDim || provMismatch.length > 0) {
232
+ const incoming = declaredProvenance
233
+ ? describe(declaredProvenance)
234
+ : `${badDim.vector.length}-dimensional vectors with undeclared provenance`;
235
+ spinner.fail(chalk.red(
236
+ `Insert refused (ADR-210): ${dbPath} records embedding provenance ${describe(storeProvenance)}, ` +
237
+ `but the incoming data is ${incoming}` +
238
+ (provMismatch.length ? ` (differs on: ${provMismatch.join(', ')})` : '') +
239
+ `. Mixed stores are never created — re-embed the data or the store.`
240
+ ));
241
+ process.exit(1);
242
+ }
243
+ }
201
244
 
202
245
  // New database: derive dimension from the data and write the sidecar
203
- // so later stats/search invocations open it correctly (#508).
246
+ // so later stats/search invocations open it correctly (#508). Declared
247
+ // provenance from the embedding path is stamped alongside (ADR-210 D0).
204
248
  if (!fs.existsSync(dbPath) && vectors.length > 0 && Array.isArray(vectors[0].vector)) {
205
249
  dimension = vectors[0].vector.length;
206
- try { fs.writeFileSync(metaPath, JSON.stringify({ dimension }, null, 2)); } catch (_) {}
250
+ const meta = { dimension };
251
+ if (declaredProvenance) meta.provenance = declaredProvenance;
252
+ try { fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2)); } catch (_) {}
207
253
  }
208
254
 
209
255
  // The native binding loads/persists through storagePath itself —
@@ -247,7 +293,7 @@ program
247
293
  let dimension = 384;
248
294
  const metaPath = `${dbPath}.meta.json`;
249
295
  if (fs.existsSync(metaPath)) {
250
- try { dimension = JSON.parse(fs.readFileSync(metaPath, 'utf8')).dimension || 384; } catch (_) {}
296
+ try { dimension = sanitizeDimension(JSON.parse(fs.readFileSync(metaPath, 'utf8')).dimension, 384); } catch (_) {}
251
297
  }
252
298
 
253
299
  if (!fs.existsSync(dbPath)) {
@@ -305,8 +351,8 @@ program
305
351
  if (fs.existsSync(metaPath)) {
306
352
  try {
307
353
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
308
- dimension = meta.dimension || dimension;
309
- metric = meta.metric || metric;
354
+ dimension = sanitizeDimension(meta.dimension, dimension);
355
+ metric = typeof meta.metric === 'string' ? meta.metric : metric;
310
356
  } catch (_) {}
311
357
  }
312
358
 
@@ -1829,6 +1875,96 @@ program
1829
1875
  console.log('');
1830
1876
  });
1831
1877
 
1878
+ // =============================================================================
1879
+ // Tiny Dancer - cost-optimal FastGRNN model router (train + route)
1880
+ // =============================================================================
1881
+
1882
+ const tinyDancer = program
1883
+ .command('tiny-dancer')
1884
+ .alias('td')
1885
+ .description('Cost-optimal FastGRNN model router — train from a DRACO dataset and route with it (requires @ruvector/tiny-dancer)');
1886
+
1887
+ function loadTinyDancer() {
1888
+ try {
1889
+ return require('@ruvector/tiny-dancer');
1890
+ } catch (e) {
1891
+ console.error(chalk.red('\n This command requires @ruvector/tiny-dancer'));
1892
+ console.error(chalk.yellow(' Install it: npm install @ruvector/tiny-dancer'));
1893
+ console.error(chalk.dim(' (native router; ships for linux/macos/windows incl. musl + arm64)\n'));
1894
+ process.exit(1);
1895
+ }
1896
+ }
1897
+
1898
+ tinyDancer
1899
+ .command('train <draco>')
1900
+ .description('Train a FastGRNN router from a DRACO dataset (rows of {embedding, scores}) into a .safetensors model')
1901
+ .requiredOption('--out <path>', 'Output .safetensors model path')
1902
+ .option('--input-dim <n>', 'Embedding/feature dimension (default: inferred from the first row)')
1903
+ .option('--prices <json>', 'Price table as JSON or @file, e.g. \'{"haiku":1,"opus":15}\'')
1904
+ .option('--epochs <n>', 'Training epochs', '40')
1905
+ .option('--lr <n>', 'Learning rate', '0.05')
1906
+ .option('--hidden <n>', 'Hidden dimension', '12')
1907
+ .option('--tolerance <n>', 'Cheap-model "good enough" tolerance', '0.05')
1908
+ .action(async (draco, options) => {
1909
+ const td = loadTinyDancer();
1910
+ const parsed = JSON.parse(fs.readFileSync(draco, 'utf8'));
1911
+ const rows = Array.isArray(parsed) ? parsed : parsed.rows;
1912
+ const prices = options.prices
1913
+ ? JSON.parse(options.prices.startsWith('@') ? fs.readFileSync(options.prices.slice(1), 'utf8') : options.prices)
1914
+ : (parsed.prices || {});
1915
+ if (!Array.isArray(rows) || rows.length === 0) {
1916
+ console.error(chalk.red(' DRACO file must contain rows of { embedding, scores }')); process.exit(1);
1917
+ }
1918
+ if (!prices || Object.keys(prices).length === 0) {
1919
+ console.error(chalk.red(' Provide a price table via --prices or a "prices" field in the file')); process.exit(1);
1920
+ }
1921
+ const inputDim = options.inputDim ? parseInt(options.inputDim, 10) : (rows[0].embedding || []).length;
1922
+ console.log(chalk.cyan(`\n Training FastGRNN router: ${rows.length} rows, dim ${inputDim}`));
1923
+ const res = await td.trainRouter(rows, prices, {
1924
+ outputPath: options.out,
1925
+ inputDim,
1926
+ hiddenDim: parseInt(options.hidden, 10),
1927
+ epochs: parseInt(options.epochs, 10),
1928
+ learningRate: parseFloat(options.lr),
1929
+ tolerance: parseFloat(options.tolerance),
1930
+ });
1931
+ console.log(chalk.green(` ✓ trained: acc=${res.trainAccuracy.toFixed(3)} val=${res.valAccuracy.toFixed(3)} loss=${res.trainLoss.toFixed(4)}`));
1932
+ console.log(chalk.white(` ✓ saved: ${res.modelPath} (${res.modelBytes} bytes, ${res.epochsRun} epochs)`));
1933
+ console.log(chalk.gray(` Load it: new Router({ modelPath: '${res.modelPath}' })\n`));
1934
+ });
1935
+
1936
+ tinyDancer
1937
+ .command('score <model>')
1938
+ .description('Score a query embedding with a trained model. High = the cheap model is good enough (route cheap)')
1939
+ .requiredOption('--query <json>', 'Query embedding as a JSON array or @file (length must match the model input dim)')
1940
+ .option('--threshold <n>', 'Decision threshold for cheap-vs-strong', '0.5')
1941
+ .action(async (model, options) => {
1942
+ const td = loadTinyDancer();
1943
+ const embedding = JSON.parse(options.query.startsWith('@') ? fs.readFileSync(options.query.slice(1), 'utf8') : options.query);
1944
+ const s = await td.score(model, embedding);
1945
+ const threshold = parseFloat(options.threshold);
1946
+ console.log(chalk.cyan(`\n score = ${s.toFixed(4)}`));
1947
+ console.log(
1948
+ s >= threshold
1949
+ ? chalk.green(' → route to the CHEAP model (good enough)\n')
1950
+ : chalk.yellow(' → route to a STRONGER model\n')
1951
+ );
1952
+ });
1953
+
1954
+ tinyDancer
1955
+ .command('info')
1956
+ .description('Show tiny-dancer availability and version')
1957
+ .action(() => {
1958
+ try {
1959
+ const td = require('@ruvector/tiny-dancer');
1960
+ console.log(chalk.green(`\n @ruvector/tiny-dancer ${td.version()} — ${td.hello()}`));
1961
+ console.log(chalk.gray(' train: npx ruvector tiny-dancer train <draco.json> --out model.safetensors'));
1962
+ console.log(chalk.gray(' score: npx ruvector tiny-dancer score <model.safetensors> --query <embedding.json>\n'));
1963
+ } catch {
1964
+ console.log(chalk.yellow('\n @ruvector/tiny-dancer not installed. npm install @ruvector/tiny-dancer\n'));
1965
+ }
1966
+ });
1967
+
1832
1968
  // =============================================================================
1833
1969
  // Server Commands - HTTP/gRPC server
1834
1970
  // =============================================================================
@@ -1945,7 +2081,7 @@ program
1945
2081
  let dimension = 384;
1946
2082
  const metaPath = `${dbPath}.meta.json`;
1947
2083
  if (fs.existsSync(metaPath)) {
1948
- try { dimension = JSON.parse(fs.readFileSync(metaPath, 'utf8')).dimension || 384; } catch (_) {}
2084
+ try { dimension = sanitizeDimension(JSON.parse(fs.readFileSync(metaPath, 'utf8')).dimension, 384); } catch (_) {}
1949
2085
  }
1950
2086
  const db = new VectorDB({ dimensions: dimension, storagePath: dbPath });
1951
2087
  const count = await db.len();
@@ -2003,6 +2139,24 @@ program
2003
2139
 
2004
2140
  spinner.text = `Importing ${vectors.length} vectors...`;
2005
2141
  const dimension = vectors[0].vector.length;
2142
+
2143
+ // ADR-210 D0: refuse mismatched imports into a provenance-stamped store.
2144
+ const importMetaPath = `${dbPath}.meta.json`;
2145
+ if (fs.existsSync(importMetaPath)) {
2146
+ let targetProvenance = null;
2147
+ try { targetProvenance = sanitizeProvenanceSafe(JSON.parse(fs.readFileSync(importMetaPath, 'utf8')).provenance); } catch (_) {}
2148
+ if (targetProvenance && targetProvenance.dimension !== dimension) {
2149
+ const provMod = loadProvenance();
2150
+ const describe = provMod ? provMod.describeProvenance : (p) => JSON.stringify(p);
2151
+ spinner.fail(chalk.red(
2152
+ `Import refused (ADR-210): ${dbPath} records embedding provenance ${describe(targetProvenance)}, ` +
2153
+ `but the incoming data is ${dimension}-dimensional with undeclared provenance. ` +
2154
+ `Mixed stores are never created — re-embed the data or the store.`
2155
+ ));
2156
+ process.exit(1);
2157
+ }
2158
+ }
2159
+
2006
2160
  const db = new VectorDB({ dimensions: dimension, storagePath: dbPath });
2007
2161
  await db.insertBatch(vectors);
2008
2162
  const count = await db.len();
@@ -2825,6 +2979,44 @@ function loadIntelligenceEngine() {
2825
2979
  return IntelligenceEngine;
2826
2980
  }
2827
2981
 
2982
+ // ADR-210 D0: shared embedding-provenance invariant (compare/refuse logic,
2983
+ // legacy-default derivation, rollout-flag resolution). Lazy, same pattern as
2984
+ // the engine: when dist is missing the CLI degrades to pre-ADR-210 behavior.
2985
+ let provenanceMod = null;
2986
+ let provenanceLoadAttempted = false;
2987
+ function loadProvenance() {
2988
+ if (provenanceLoadAttempted) return provenanceMod;
2989
+ provenanceLoadAttempted = true;
2990
+ try {
2991
+ provenanceMod = require('../dist/core/embedding-provenance.js');
2992
+ } catch (e) {
2993
+ provenanceMod = null;
2994
+ }
2995
+ return provenanceMod;
2996
+ }
2997
+
2998
+ /**
2999
+ * Sanitize a provenance record read from disk (untrusted JSON, ADR-210
3000
+ * security pass): malformed records are treated as ABSENT (null), never
3001
+ * crash. Falls back to a minimal shape check when dist is missing.
3002
+ */
3003
+ function sanitizeProvenanceSafe(value) {
3004
+ const prov = loadProvenance();
3005
+ if (prov && typeof prov.sanitizeProvenance === 'function') {
3006
+ return prov.sanitizeProvenance(value);
3007
+ }
3008
+ return (
3009
+ value && typeof value === 'object' && !Array.isArray(value) &&
3010
+ typeof value.embedderKind === 'string' &&
3011
+ Number.isInteger(value.dimension) && value.dimension > 0 && value.dimension <= 65536
3012
+ ) ? value : null;
3013
+ }
3014
+
3015
+ /** Bound a dimension read from an untrusted sidecar to a sane integer. */
3016
+ function sanitizeDimension(value, fallback) {
3017
+ return (Number.isInteger(value) && value > 0 && value <= 65536) ? value : fallback;
3018
+ }
3019
+
2828
3020
  class Intelligence {
2829
3021
  constructor(options = {}) {
2830
3022
  this.intelPath = this.getIntelPath();
@@ -2955,16 +3147,25 @@ class Intelligence {
2955
3147
  try {
2956
3148
  if (fs.existsSync(this.intelPath)) {
2957
3149
  const data = JSON.parse(fs.readFileSync(this.intelPath, 'utf-8'));
2958
- // Merge with defaults to ensure all fields exist
3150
+ // Merge with defaults to ensure all fields exist. The file is
3151
+ // untrusted on-disk input (ADR-210 security pass): shape-check each
3152
+ // field so a hand-edited/corrupted store cannot crash later code
3153
+ // that iterates arrays or spreads objects.
3154
+ const asArray = (v, dflt) => (Array.isArray(v) ? v : dflt);
3155
+ const asObject = (v, dflt) => (v && typeof v === 'object' && !Array.isArray(v) ? v : dflt);
2959
3156
  return {
2960
- patterns: data.patterns || defaults.patterns,
2961
- memories: data.memories || defaults.memories,
2962
- trajectories: data.trajectories || defaults.trajectories,
2963
- errors: data.errors || defaults.errors,
2964
- file_sequences: data.file_sequences || defaults.file_sequences,
2965
- agents: data.agents || defaults.agents,
2966
- edges: data.edges || defaults.edges,
2967
- stats: { ...defaults.stats, ...(data.stats || {}) },
3157
+ patterns: asObject(data.patterns, defaults.patterns),
3158
+ memories: asArray(data.memories, defaults.memories),
3159
+ trajectories: asArray(data.trajectories, defaults.trajectories),
3160
+ errors: asObject(data.errors, defaults.errors),
3161
+ file_sequences: asArray(data.file_sequences, defaults.file_sequences),
3162
+ agents: asObject(data.agents, defaults.agents),
3163
+ edges: asArray(data.edges, defaults.edges),
3164
+ stats: { ...defaults.stats, ...asObject(data.stats, {}) },
3165
+ // ADR-210 D0: embedding provenance of stored memory vectors
3166
+ // (null = legacy store, read-only for vector writes until reembed).
3167
+ // Malformed records are treated as absent (sanitized, never crash).
3168
+ embeddingProvenance: sanitizeProvenanceSafe(data.embeddingProvenance),
2968
3169
  // Preserve in-flight trajectories so trajectory-end (run in a later
2969
3170
  // process) can find what trajectory-begin recorded (#517)
2970
3171
  activeTrajectories: data.activeTrajectories || {},
@@ -3037,11 +3238,145 @@ class Intelligence {
3037
3238
  return normA > 0 && normB > 0 ? dot / (normA * normB) : 0;
3038
3239
  }
3039
3240
 
3241
+ // ========================================================================
3242
+ // ADR-210 D0: embedding-provenance invariant for the intelligence store.
3243
+ // Every memory write records/validates { embedderKind, modelId, dimension,
3244
+ // normalize, prefixPolicy }; mismatched writes are refused, legacy stores
3245
+ // (memories without provenance) are read-only until `hooks reembed`.
3246
+ // ========================================================================
3247
+
3248
+ storedProvenance() { return this.data.embeddingProvenance || null; }
3249
+
3250
+ vectorMemoryCount() {
3251
+ return (this.data.memories || []).filter(m => Array.isArray(m.embedding) && m.embedding.length > 0).length;
3252
+ }
3253
+
3254
+ /** Store predates ADR-210 (has vectors but no provenance record). */
3255
+ isLegacyVectorStore() {
3256
+ return !this.storedProvenance() && this.vectorMemoryCount() > 0;
3257
+ }
3258
+
3259
+ /** Legacy default: hash, dimension inferred from the stored vectors. */
3260
+ inferredLegacyProvenance() {
3261
+ const prov = loadProvenance();
3262
+ const first = (this.data.memories || []).find(m => Array.isArray(m.embedding) && m.embedding.length > 0);
3263
+ const dim = first ? first.embedding.length : 256;
3264
+ if (prov) return prov.legacyHashProvenance(dim);
3265
+ return { embedderKind: 'hash', modelId: null, dimension: dim, normalize: false, prefixPolicy: 'none' };
3266
+ }
3267
+
3268
+ /** Provenance of an embedding produced by the wrapper's sync hash path. */
3269
+ syncWriteProvenance(embedding) {
3270
+ return { embedderKind: 'hash', modelId: null, dimension: embedding.length, normalize: true, prefixPolicy: 'none' };
3271
+ }
3272
+
3273
+ /**
3274
+ * Gate a vector write (throws on refusal). Stamps provenance on the first
3275
+ * write to a fresh store; refuses mismatched writes naming both sides;
3276
+ * legacy stores are read-only until re-embedded.
3277
+ */
3278
+ checkVectorWrite(active) {
3279
+ const prov = loadProvenance();
3280
+ if (!prov || !active) return; // enforcement needs the dist module
3281
+ if (this.isLegacyVectorStore()) {
3282
+ const legacy = this.inferredLegacyProvenance();
3283
+ const err = new Error(
3284
+ `Vector store ${this.intelPath} predates embedding provenance (ADR-210) and is read-only for vector writes. ` +
3285
+ `Stored vectors are treated as ${prov.describeProvenance(legacy)}; the active embedder is ` +
3286
+ `${prov.describeProvenance(active)}. Run 'ruvector hooks reembed' to re-embed and unlock it.`
3287
+ );
3288
+ err.code = 'ERR_LEGACY_STORE_READONLY';
3289
+ throw err;
3290
+ }
3291
+ const stored = this.storedProvenance();
3292
+ if (!stored) {
3293
+ this.data.embeddingProvenance = active;
3294
+ return;
3295
+ }
3296
+ prov.assertProvenanceMatch(stored, active, this.intelPath);
3297
+ }
3298
+
3299
+ /**
3300
+ * Non-throwing write gate honoring RUVECTOR_REEMBED (D5):
3301
+ * refuse (default) → rethrow; warn → skip the write with one stderr
3302
+ * warning per process; auto → handled by callers that can re-embed.
3303
+ * Returns { ok } or { ok: false, skipped: true }.
3304
+ */
3305
+ guardVectorWrite(active) {
3306
+ try {
3307
+ this.checkVectorWrite(active);
3308
+ return { ok: true };
3309
+ } catch (e) {
3310
+ const prov = loadProvenance();
3311
+ const policy = prov ? prov.resolveReembedPolicy() : 'refuse';
3312
+ if (policy === 'warn') {
3313
+ if (!Intelligence._reembedWarned) {
3314
+ Intelligence._reembedWarned = true;
3315
+ console.error(`ruvector: ${e.message} (RUVECTOR_REEMBED=warn: store stays read-only, write skipped)`);
3316
+ }
3317
+ return { ok: false, skipped: true, error: e.message };
3318
+ }
3319
+ // 'auto' without an async re-embed path behaves like refuse, with a hint.
3320
+ if (policy === 'auto') e.message += ` (RUVECTOR_REEMBED=auto: run 'ruvector hooks reembed' — in-place re-embedding needs the async path)`;
3321
+ throw e;
3322
+ }
3323
+ }
3324
+
3325
+ /**
3326
+ * Re-embed every stored memory with `embedFn` and stamp `provenance`.
3327
+ * Requires retained source text; memories without text must be dropped
3328
+ * explicitly (the command refuses otherwise — no fabricated vectors).
3329
+ *
3330
+ * ADR-210 D3: when `embedBatchFn` is provided and the store holds
3331
+ * `batchThreshold` (32) or more re-embeddable memories, the whole corpus
3332
+ * is embedded in one bulk call (the engine routes it through the bundled
3333
+ * parallel worker pool); smaller stores embed per-item via `embedFn`.
3334
+ */
3335
+ async reembedAll(embedFn, provenance, { dropMissing = false, embedBatchFn = null, batchThreshold = 32 } = {}) {
3336
+ const memories = Array.isArray(this.data.memories) ? this.data.memories : [];
3337
+ const kept = [];
3338
+ let dropped = 0;
3339
+ for (const m of memories) {
3340
+ if (m && typeof m.content === 'string' && m.content.length > 0) {
3341
+ kept.push(m);
3342
+ } else if (dropMissing) {
3343
+ dropped++;
3344
+ } else {
3345
+ throw new Error('memory without retained source text encountered; rerun with --drop-missing');
3346
+ }
3347
+ }
3348
+ let usedBulk = false;
3349
+ if (embedBatchFn && kept.length >= batchThreshold) {
3350
+ const vectors = await embedBatchFn(kept.map(m => m.content));
3351
+ if (!Array.isArray(vectors) || vectors.length !== kept.length) {
3352
+ throw new Error(`bulk embed returned ${vectors && vectors.length} vectors for ${kept.length} texts`);
3353
+ }
3354
+ for (let i = 0; i < kept.length; i++) kept[i].embedding = vectors[i];
3355
+ usedBulk = true;
3356
+ } else {
3357
+ for (const m of kept) m.embedding = await embedFn(m.content);
3358
+ }
3359
+ this.data.memories = kept;
3360
+ this.data.stats.total_memories = kept.length;
3361
+ this.data.embeddingProvenance = provenance;
3362
+ return { reembedded: kept.length, dropped, bulk: usedBulk };
3363
+ }
3364
+
3040
3365
  // Memory operations - use engine's VectorDB for semantic search
3041
3366
  async rememberAsync(memoryType, content, metadata = {}) {
3042
3367
  if (this.engine) {
3368
+ let entry = null;
3043
3369
  try {
3044
- const entry = await this.engine.remember(content, memoryType);
3370
+ entry = await this.engine.remember(content, memoryType);
3371
+ } catch {}
3372
+ if (entry) {
3373
+ // ADR-210 D0: validate provenance BEFORE persisting; provenance
3374
+ // refusals propagate (no silent fallback into a mixed store).
3375
+ const active = typeof this.engine.getActiveProvenance === 'function'
3376
+ ? this.engine.getActiveProvenance()
3377
+ : this.syncWriteProvenance(entry.embedding);
3378
+ const guard = this.guardVectorWrite(active);
3379
+ if (!guard.ok) return null;
3045
3380
  // Also store in legacy format for compatibility
3046
3381
  this.data.memories.push({
3047
3382
  id: entry.id,
@@ -3054,7 +3389,7 @@ class Intelligence {
3054
3389
  if (this.data.memories.length > 5000) this.data.memories.splice(0, 1000);
3055
3390
  this.data.stats.total_memories = this.data.memories.length;
3056
3391
  return entry.id;
3057
- } catch {}
3392
+ }
3058
3393
  }
3059
3394
  return this.remember(memoryType, content, metadata);
3060
3395
  }
@@ -3062,6 +3397,10 @@ class Intelligence {
3062
3397
  remember(memoryType, content, metadata = {}) {
3063
3398
  const id = `mem_${this.now()}`;
3064
3399
  const embedding = this.embed(content);
3400
+ // ADR-210 D0: refuse mismatched/legacy vector writes (throws), or skip
3401
+ // under RUVECTOR_REEMBED=warn (returns null).
3402
+ const guard = this.guardVectorWrite(this.syncWriteProvenance(embedding));
3403
+ if (!guard.ok) return null;
3065
3404
  this.data.memories.push({ id, memory_type: memoryType, content, embedding, metadata, timestamp: this.now() });
3066
3405
  if (this.data.memories.length > 5000) this.data.memories.splice(0, 1000);
3067
3406
  this.data.stats.total_memories = this.data.memories.length;
@@ -3075,10 +3414,52 @@ class Intelligence {
3075
3414
  return id;
3076
3415
  }
3077
3416
 
3417
+ /**
3418
+ * Best-effort remember for ambient learning hooks (post-edit/post-command):
3419
+ * a provenance refusal must not fail the hook — note it once and move on.
3420
+ */
3421
+ tryRemember(memoryType, content, metadata = {}) {
3422
+ try {
3423
+ return this.remember(memoryType, content, metadata);
3424
+ } catch (e) {
3425
+ if (!Intelligence._rememberSkipNoted) {
3426
+ Intelligence._rememberSkipNoted = true;
3427
+ console.error(chalk.dim(` (memory write skipped: ${e.message})`));
3428
+ }
3429
+ return null;
3430
+ }
3431
+ }
3432
+
3433
+ /**
3434
+ * ADR-210: reads stay allowed on legacy/mismatched stores, but similarity
3435
+ * against differently-embedded vectors is meaningless — say so once.
3436
+ */
3437
+ warnRecallProvenance(active) {
3438
+ const prov = loadProvenance();
3439
+ if (!prov || !active || Intelligence._recallWarned) return;
3440
+ let stored = this.storedProvenance();
3441
+ if (!stored && this.isLegacyVectorStore()) stored = this.inferredLegacyProvenance();
3442
+ if (!stored) return;
3443
+ const mismatches = prov.compareProvenance(stored, active);
3444
+ if (mismatches.length > 0) {
3445
+ Intelligence._recallWarned = true;
3446
+ console.error(
3447
+ `ruvector: recall quality degraded — stored vectors are ${prov.describeProvenance(stored)} ` +
3448
+ `but the query was embedded as ${prov.describeProvenance(active)} (differs on: ${mismatches.join(', ')}). ` +
3449
+ `Run 'ruvector hooks reembed' to fix.`
3450
+ );
3451
+ }
3452
+ }
3453
+
3078
3454
  async recallAsync(query, topK = 5) {
3079
3455
  if (this.engine) {
3080
3456
  try {
3081
3457
  const results = await this.engine.recall(query, topK);
3458
+ // After recall: embedAsync has settled, so getActiveProvenance() now
3459
+ // reflects the embedder that actually served the query.
3460
+ if (typeof this.engine.getActiveProvenance === 'function') {
3461
+ this.warnRecallProvenance(this.engine.getActiveProvenance());
3462
+ }
3082
3463
  // Return same format as sync recall() - direct memory objects
3083
3464
  return results.map(r => ({
3084
3465
  id: r.id,
@@ -3094,6 +3475,7 @@ class Intelligence {
3094
3475
 
3095
3476
  recall(query, topK) {
3096
3477
  const queryEmbed = this.embed(query);
3478
+ this.warnRecallProvenance(this.syncWriteProvenance(queryEmbed));
3097
3479
  return this.data.memories
3098
3480
  .map(m => ({ score: this.similarity(queryEmbed, m.embedding), memory: m }))
3099
3481
  .sort((a, b) => b.score - a.score).slice(0, topK).map(r => r.memory);
@@ -3369,6 +3751,8 @@ class Intelligence {
3369
3751
  sonaEnabled: engineStats.sonaEnabled,
3370
3752
  attentionEnabled: engineStats.attentionEnabled,
3371
3753
  embeddingDim: engineStats.memoryDimensions,
3754
+ // ADR-210 D1: which embedder actually serves embeds right now
3755
+ embedderKind: engineStats.embedderKind,
3372
3756
  totalMemories: engineStats.totalMemories,
3373
3757
  totalEpisodes: engineStats.totalEpisodes,
3374
3758
  trajectoriesRecorded: engineStats.trajectoriesRecorded,
@@ -4266,7 +4650,8 @@ hooksCmd.command('post-edit').description('Post-edit learning').argument('<file>
4266
4650
  const lastFile = intel.getLastEditedFile();
4267
4651
  if (lastFile && lastFile !== file) intel.recordFileSequence(lastFile, file);
4268
4652
  intel.learn(state, success ? 'successful-edit' : 'failed-edit', success ? 'completed' : 'failed', success ? 1.0 : -0.5);
4269
- intel.remember('edit', `${success ? 'successful' : 'failed'} edit of ${ext} in ${crate}`);
4653
+ // Best-effort: a provenance-locked store (ADR-210) must not fail the hook
4654
+ intel.tryRemember('edit', `${success ? 'successful' : 'failed'} edit of ${ext} in ${crate}`);
4270
4655
  intel.save();
4271
4656
  console.log(`📊 Learning recorded: ${success ? '✅' : '❌'} ${path.basename(file)}`);
4272
4657
  const test = intel.shouldTest(file);
@@ -4291,7 +4676,8 @@ hooksCmd.command('post-command').description('Post-command learning').argument('
4291
4676
  const success = opts.error ? false : (opts.success ?? true);
4292
4677
  const classification = intel.classifyCommand(cmd);
4293
4678
  intel.learn(`cmd_${classification.category}_${classification.subcategory}`, success ? 'success' : 'failure', success ? 'completed' : 'failed', success ? 0.8 : -0.3);
4294
- intel.remember('command', `${cmd} ${success ? 'succeeded' : 'failed'}`);
4679
+ // Best-effort: a provenance-locked store (ADR-210) must not fail the hook
4680
+ intel.tryRemember('command', `${cmd} ${success ? 'succeeded' : 'failed'}`);
4295
4681
  intel.save();
4296
4682
  console.log(`📊 Command ${success ? '✅' : '❌'} recorded`);
4297
4683
  });
@@ -4310,16 +4696,31 @@ hooksCmd.command('suggest-context').description('Suggest relevant context').acti
4310
4696
 
4311
4697
  hooksCmd.command('remember').description('Store in memory').requiredOption('-t, --type <type>', 'Memory type').option('--silent', 'Suppress output').option('--semantic', 'Use ONNX semantic embeddings (slower, better quality)').argument('<content...>', 'Content').action(async (content, opts) => {
4312
4698
  const intel = new Intelligence();
4313
- let id;
4314
- if (opts.semantic) {
4315
- // Use async ONNX embedding
4316
- id = await intel.rememberAsync(opts.type, content.join(' '));
4317
- } else {
4318
- id = intel.remember(opts.type, content.join(' '));
4319
- }
4320
- intel.save();
4321
- if (!opts.silent) {
4322
- console.log(JSON.stringify({ success: true, id, semantic: !!opts.semantic }));
4699
+ try {
4700
+ let id;
4701
+ if (opts.semantic) {
4702
+ // Use async ONNX embedding
4703
+ id = await intel.rememberAsync(opts.type, content.join(' '));
4704
+ } else {
4705
+ id = intel.remember(opts.type, content.join(' '));
4706
+ }
4707
+ if (id === null) {
4708
+ // RUVECTOR_REEMBED=warn: store is read-only, write skipped (ADR-210)
4709
+ if (!opts.silent) {
4710
+ console.log(JSON.stringify({ success: false, skipped: true, reason: 'store is read-only for vector writes (embedding provenance, ADR-210); run `ruvector hooks reembed`' }));
4711
+ }
4712
+ return;
4713
+ }
4714
+ intel.save();
4715
+ if (!opts.silent) {
4716
+ console.log(JSON.stringify({ success: true, id, semantic: !!opts.semantic }));
4717
+ }
4718
+ } catch (e) {
4719
+ // ADR-210 D0: mismatched/legacy vector writes are refused, not coerced.
4720
+ if (!opts.silent) {
4721
+ console.log(JSON.stringify({ success: false, error: e.message, code: e.code || 'ERR_EMBEDDING_PROVENANCE' }));
4722
+ }
4723
+ process.exitCode = 1;
4323
4724
  }
4324
4725
  });
4325
4726
 
@@ -4334,6 +4735,113 @@ hooksCmd.command('recall').description('Search memory').argument('<query...>', '
4334
4735
  console.log(JSON.stringify({ query: query.join(' '), semantic: !!opts.semantic, results: results.map(r => ({ type: r.memory_type || 'unknown', content: (r.content || '').slice(0, 200), timestamp: r.timestamp || '', score: r.score })) }, null, 2));
4335
4736
  });
4336
4737
 
4738
+ // ADR-210 D1: maintenance command — re-embed hash-era memories with the
4739
+ // active embedder and stamp embedding provenance, unlocking legacy stores.
4740
+ // Possible because hook memories retain their source text (`content`).
4741
+ hooksCmd.command('reembed')
4742
+ .description('Re-embed stored memories with the active embedder and stamp embedding provenance (ADR-210)')
4743
+ .option('--dry-run', 'Report what would change without writing')
4744
+ .option('--drop-missing', 'Drop memories that no longer retain source text')
4745
+ .action(async (opts) => {
4746
+ const provMod = loadProvenance();
4747
+ if (!provMod) {
4748
+ console.log(JSON.stringify({ success: false, error: 'embedding-provenance module unavailable (dist not built)' }));
4749
+ process.exitCode = 1;
4750
+ return;
4751
+ }
4752
+ const intel = new Intelligence({ skipEngine: true }); // embedder chosen explicitly below
4753
+ const memories = Array.isArray(intel.data.memories) ? intel.data.memories : [];
4754
+ const missing = memories.filter(m => !(m && typeof m.content === 'string' && m.content.length > 0)).length;
4755
+
4756
+ if (missing > 0 && !opts.dropMissing) {
4757
+ // Honest refusal: those vectors cannot be re-embedded (no source text),
4758
+ // and keeping them would recreate a mixed store.
4759
+ console.log(JSON.stringify({
4760
+ success: false,
4761
+ error: `${missing} of ${memories.length} memories have no retained source text and cannot be re-embedded`,
4762
+ hint: 'rerun with --drop-missing to discard them, or leave the store read-only for vector writes',
4763
+ }));
4764
+ process.exitCode = 1;
4765
+ return;
4766
+ }
4767
+
4768
+ // Pick the target embedder per RUVECTOR_EMBEDDER (D5).
4769
+ const selection = provMod.resolveEmbedderSelection();
4770
+ let embedFn;
4771
+ let embedBatchFn = null;
4772
+ let shutdownPool = null;
4773
+ let provenance;
4774
+ if (selection === 'hash') {
4775
+ // Deterministic, offline-safe: the wrapper's own hash embedder.
4776
+ embedFn = async (t) => intel.embed(t);
4777
+ provenance = { embedderKind: 'hash', modelId: null, dimension: intel.embed('probe').length, normalize: true, prefixPolicy: 'none' };
4778
+ } else {
4779
+ const EngineClass = loadIntelligenceEngine();
4780
+ if (!EngineClass) {
4781
+ console.log(JSON.stringify({ success: false, error: 'IntelligenceEngine unavailable (dist not built); cannot re-embed semantically' }));
4782
+ process.exitCode = 1;
4783
+ return;
4784
+ }
4785
+ let engine;
4786
+ try {
4787
+ engine = new EngineClass({ enableSona: false, enableAttention: false });
4788
+ } catch (e) {
4789
+ console.log(JSON.stringify({ success: false, error: e.message }));
4790
+ process.exitCode = 1;
4791
+ return;
4792
+ }
4793
+ const ready = typeof engine.awaitOnnx === 'function' ? await engine.awaitOnnx() : false;
4794
+ if (!ready) {
4795
+ // Honest failure: re-embedding with a fallback hash would defeat the
4796
+ // point. Tell the operator what to do instead of fabricating quality.
4797
+ console.log(JSON.stringify({
4798
+ success: false,
4799
+ error: `ONNX model could not be loaded (${engine.getOnnxInitError?.()?.message || 'offline?'}); semantic re-embedding is impossible right now`,
4800
+ hint: 'retry with network access, or force the hash embedder with RUVECTOR_EMBEDDER=hash',
4801
+ }));
4802
+ process.exitCode = 1;
4803
+ return;
4804
+ }
4805
+ embedFn = (t) => engine.embedAsync(t);
4806
+ // ADR-210 D3: 32+ memories re-embed in one bulk call through the
4807
+ // bundled parallel worker pool (parallel-fp32; see embedBulk for the
4808
+ // int8 status). The pool's worker threads keep the process alive, so
4809
+ // they are shut down once the bulk work completes.
4810
+ if (typeof engine.embedBatchAsync === 'function') {
4811
+ embedBatchFn = (texts) => engine.embedBatchAsync(texts);
4812
+ shutdownPool = () => (typeof engine.shutdownEmbedderPool === 'function' ? engine.shutdownEmbedderPool() : Promise.resolve());
4813
+ }
4814
+ provenance = engine.getActiveProvenance();
4815
+ }
4816
+
4817
+ if (opts.dryRun) {
4818
+ console.log(JSON.stringify({
4819
+ success: true,
4820
+ dryRun: true,
4821
+ wouldReembed: memories.length - missing,
4822
+ wouldDrop: opts.dropMissing ? missing : 0,
4823
+ targetProvenance: provenance,
4824
+ }));
4825
+ return;
4826
+ }
4827
+
4828
+ try {
4829
+ const startMs = Date.now();
4830
+ const result = await intel.reembedAll(embedFn, provenance, { dropMissing: !!opts.dropMissing, embedBatchFn });
4831
+ intel.save();
4832
+ let parallelWorkers = 0;
4833
+ try {
4834
+ parallelWorkers = require('../dist/core/onnx-embedder.js').getParallelWorkerCount();
4835
+ } catch (_) {}
4836
+ console.log(JSON.stringify({ success: true, ...result, parallelWorkers, elapsedMs: Date.now() - startMs, provenance }));
4837
+ } catch (e) {
4838
+ console.log(JSON.stringify({ success: false, error: e.message }));
4839
+ process.exitCode = 1;
4840
+ } finally {
4841
+ if (shutdownPool) await shutdownPool().catch(() => {});
4842
+ }
4843
+ });
4844
+
4337
4845
  hooksCmd.command('pre-compact').description('Pre-compact hook').option('--auto', 'Auto mode').action(() => {
4338
4846
  const intel = new Intelligence();
4339
4847
  intel.save();
@@ -9593,6 +10101,137 @@ const optimizeCmd = program.command('optimize')
9593
10101
  console.log('');
9594
10102
  });
9595
10103
 
10104
+ // =============================================================================
10105
+ // Harness Commands - unified "harness router" surface (ADR-256)
10106
+ // Borrows metaharness concepts using primitives ruvector already ships:
10107
+ // cost router (tiny-dancer) + semantic router + hooks routing + MCP + witness
10108
+ // Read-only status surface; degrades gracefully when optional deps are absent.
10109
+ // =============================================================================
10110
+
10111
+ function buildHarnessSurface() {
10112
+ const primitives = {};
10113
+
10114
+ // Cost-optimal model router — Tiny Dancer FastGRNN (ADR-252)
10115
+ try {
10116
+ const td = require('@ruvector/tiny-dancer');
10117
+ primitives.costRouter = {
10118
+ name: '@ruvector/tiny-dancer',
10119
+ role: 'cost-optimal model routing (cheap vs strong)',
10120
+ available: true,
10121
+ version: typeof td.version === 'function' ? td.version() : null,
10122
+ usage: 'npx ruvector tiny-dancer score <model> --query <embedding>',
10123
+ };
10124
+ } catch {
10125
+ primitives.costRouter = {
10126
+ name: '@ruvector/tiny-dancer',
10127
+ role: 'cost-optimal model routing (cheap vs strong)',
10128
+ available: false,
10129
+ install: 'npm install @ruvector/tiny-dancer',
10130
+ };
10131
+ }
10132
+
10133
+ // Semantic intent router — @ruvector/router / ruvector-router-core
10134
+ let semanticAvailable = false;
10135
+ try { require.resolve('@ruvector/router'); semanticAvailable = true; } catch { semanticAvailable = false; }
10136
+ primitives.semanticRouter = {
10137
+ name: '@ruvector/router',
10138
+ role: 'semantic intent routing',
10139
+ available: semanticAvailable,
10140
+ ...(semanticAvailable ? { usage: 'npx ruvector router --route "<text>"' } : { install: 'npm install @ruvector/router' }),
10141
+ };
10142
+
10143
+ // Multi-tier intelligence routing — bundled (ADR-026)
10144
+ primitives.hooksRouting = {
10145
+ name: 'hooks route',
10146
+ role: '3-tier task→agent/model routing (ADR-026)',
10147
+ available: true,
10148
+ usage: 'npx ruvector hooks route "<task>"',
10149
+ };
10150
+
10151
+ // Agentic tool surface — bundled MCP server (with ADR-256 default-deny policy)
10152
+ const mcpPath = path.join(__dirname, 'mcp-server.js');
10153
+ let mcpPolicy = { configured: false };
10154
+ try {
10155
+ const { buildToolPolicy } = require('./mcp-policy.js');
10156
+ const p = buildToolPolicy(process.env);
10157
+ mcpPolicy = {
10158
+ configured: p.configured,
10159
+ profile: p.profile || null,
10160
+ allow: p.allowSet ? p.allowSet.size : 0,
10161
+ deny: p.deny.size,
10162
+ };
10163
+ } catch { /* policy module optional */ }
10164
+ primitives.mcp = {
10165
+ name: 'mcp-server',
10166
+ role: 'agentic tool surface (Model Context Protocol)',
10167
+ available: fs.existsSync(mcpPath),
10168
+ usage: 'npx ruvector mcp start',
10169
+ policy: mcpPolicy,
10170
+ accessControl: mcpPolicy.configured ? 'default-deny (configured)' : 'allow-all (set RUVECTOR_MCP_ALLOW/PROFILE)',
10171
+ };
10172
+
10173
+ // Signed provenance — witness chain (ADR-103 / ADR-134)
10174
+ primitives.witness = {
10175
+ name: 'witness-chain',
10176
+ role: 'signed provenance / release signing (ADR-103, ADR-134)',
10177
+ available: true,
10178
+ };
10179
+
10180
+ // Memory + learning loops — SONA / ReasoningBank (stable namespace, ADR-256 step 3)
10181
+ primitives.memory = {
10182
+ name: 'sona+reasoningbank',
10183
+ role: 'persistent memory + self-learning loops',
10184
+ available: true,
10185
+ namespace: (process.env.RUVECTOR_MEMORY_NAMESPACE || 'ruvector').trim() || 'ruvector',
10186
+ };
10187
+
10188
+ const values = Object.values(primitives);
10189
+ return {
10190
+ adr: 'ADR-256',
10191
+ decision: 'borrow metaharness concepts using primitives ruvector already ships',
10192
+ primitives,
10193
+ summary: {
10194
+ available: values.filter((p) => p.available).length,
10195
+ total: values.length,
10196
+ },
10197
+ };
10198
+ }
10199
+
10200
+ const harnessCmd = program
10201
+ .command('harness')
10202
+ .description('Unified "harness router" surface — cost router + semantic router + hooks routing + MCP + witness (ADR-256)');
10203
+
10204
+ function printHarnessStatus(opts) {
10205
+ const surface = buildHarnessSurface();
10206
+ if (opts && opts.json) {
10207
+ console.log(JSON.stringify(surface, null, 2));
10208
+ return;
10209
+ }
10210
+ console.log(chalk.cyan('\n═══════════════════════════════════════════════════════════════'));
10211
+ console.log(chalk.cyan(' RuVector Harness Router (ADR-256)'));
10212
+ console.log(chalk.cyan('═══════════════════════════════════════════════════════════════\n'));
10213
+ console.log(chalk.gray(' ' + surface.decision + '\n'));
10214
+ for (const p of Object.values(surface.primitives)) {
10215
+ const badge = p.available ? chalk.green('● available') : chalk.yellow('○ optional ');
10216
+ console.log(` ${badge} ${chalk.white(p.name)}${p.version ? chalk.dim(' v' + p.version) : ''}`);
10217
+ console.log(` ${chalk.dim(p.role)}`);
10218
+ if (p.available && p.usage) console.log(` ${chalk.dim(p.usage)}`);
10219
+ if (!p.available && p.install) console.log(` ${chalk.dim('install: ' + p.install)}`);
10220
+ }
10221
+ console.log('');
10222
+ console.log(chalk.cyan(` ${surface.summary.available}/${surface.summary.total} primitives available\n`));
10223
+ }
10224
+
10225
+ harnessCmd
10226
+ .command('status')
10227
+ .alias('info')
10228
+ .description('Show the unified harness routing surface and primitive availability')
10229
+ .option('--json', 'Output as JSON')
10230
+ .action((opts) => printHarnessStatus(opts));
10231
+
10232
+ // Bare `ruvector harness` defaults to status
10233
+ harnessCmd.action(() => printHarnessStatus({}));
10234
+
9596
10235
  program.parse();
9597
10236
 
9598
10237