@dzhechkov/harness-core 0.3.86 → 0.3.90

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.
@@ -16,6 +16,13 @@ import { existsSync, mkdirSync } from 'node:fs';
16
16
  import { join, dirname } from 'node:path';
17
17
  import { pathToFileURL } from 'node:url';
18
18
  import { createRequire } from 'node:module';
19
+ import {
20
+ currentEmbedManifest,
21
+ guardEmbedSpace,
22
+ readEmbedManifest,
23
+ resolveEmbedModel,
24
+ writeEmbedManifest,
25
+ } from './embedding-config.js';
19
26
 
20
27
  /** One record to index. `text` is stored as `approach` AND embedded (`${taskType}: ${text}`). */
21
28
  export interface AgentdbRow {
@@ -36,7 +43,10 @@ export interface AgentdbIndexResult {
36
43
  interface NativeDb {
37
44
  pragma: (s: string) => void;
38
45
  exec: (s: string) => void;
39
- prepare: (q: string) => { run: (...a: unknown[]) => { lastInsertRowid: number | bigint } };
46
+ prepare: (q: string) => {
47
+ run: (...a: unknown[]) => { lastInsertRowid: number | bigint };
48
+ get: (...a: unknown[]) => unknown;
49
+ };
40
50
  transaction: <T>(fn: (...a: unknown[]) => T) => (...a: unknown[]) => T;
41
51
  close: () => void;
42
52
  }
@@ -93,14 +103,24 @@ export async function indexPatternsToAgentdb(
93
103
  const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href)) as {
94
104
  EmbeddingService: new (o: object) => { initialize: () => Promise<void>; embed: (t: string) => Promise<Float32Array> };
95
105
  };
96
- const emb = new EmbeddingService({ model: 'Xenova/all-MiniLM-L6-v2', dimension: 384, provider: 'transformers' });
106
+ const model = resolveEmbedModel(projectRoot);
107
+ if ('error' in model) return { indexed: 0, error: model.error };
108
+ const emb = new EmbeddingService({ model: model.model, dimension: model.dim, provider: 'transformers' });
97
109
  await emb.initialize();
98
110
 
99
- const db = new Database(resolveAgentdbPath(projectRoot, opts.dbPath));
111
+ const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
112
+ const db = new Database(dbFile);
100
113
  try {
101
114
  db.pragma('journal_mode = WAL');
102
115
  db.pragma('busy_timeout = 5000'); // wait out a brief MCP-server write lock instead of failing
103
116
  db.exec(REASONING_BANK_SCHEMA);
117
+ const guard = guardEmbedSpace({
118
+ storePath: dbFile,
119
+ configured: model,
120
+ hasRows: embeddingRowCount(db) > 0,
121
+ reindexHint: 'dz vector reindex',
122
+ });
123
+ if (!guard.ok) return { indexed: 0, error: guard.error };
104
124
  const insPattern = db.prepare('INSERT INTO reasoning_patterns (task_type, approach, success_rate, uses, avg_reward, tags, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)');
105
125
  const insEmb = db.prepare('INSERT OR REPLACE INTO pattern_embeddings (pattern_id, embedding) VALUES (?, ?)');
106
126
  // Embeddings are async (can't run inside better-sqlite3's sync transaction) — compute them
@@ -119,7 +139,9 @@ export async function indexPatternsToAgentdb(
119
139
  }
120
140
  return prepared.length;
121
141
  });
122
- return { indexed: commit() as number };
142
+ const indexed = commit() as number;
143
+ writeEmbedManifest(dbFile, currentEmbedManifest(model, guard.manifest.version, 'agentdb'));
144
+ return { indexed };
123
145
  } finally {
124
146
  db.close();
125
147
  }
@@ -167,8 +189,8 @@ const DEPS_MISSING = 'agentdb/better-sqlite3 not installed in project (run: dz s
167
189
 
168
190
  /**
169
191
  * Resolve agentdb's `EmbeddingService` from the PROJECT (same dynamic-resolution discipline as
170
- * {@link indexPatternsToAgentdb}; model pinned to `Xenova/all-MiniLM-L6-v2` dim 384 every dz
171
- * call site MUST use this one embedder so query and row vectors stay in the same space).
192
+ * {@link indexPatternsToAgentdb}); every dz call site uses the same resolved model so query and row
193
+ * vectors stay in the same space.
172
194
  */
173
195
  export async function resolveAgentdbEmbedder(
174
196
  projectRoot: string,
@@ -184,7 +206,9 @@ export async function resolveAgentdbEmbedder(
184
206
  const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href)) as {
185
207
  EmbeddingService: new (o: object) => { initialize: () => Promise<void>; embed: (t: string) => Promise<Float32Array> };
186
208
  };
187
- const emb = new EmbeddingService({ model: 'Xenova/all-MiniLM-L6-v2', dimension: 384, provider: 'transformers' });
209
+ const model = resolveEmbedModel(projectRoot);
210
+ if ('error' in model) return { error: model.error };
211
+ const emb = new EmbeddingService({ model: model.model, dimension: model.dim, provider: 'transformers' });
188
212
  await emb.initialize();
189
213
  return { embed: (t: string) => emb.embed(t) };
190
214
  } catch (err) {
@@ -218,6 +242,11 @@ function hasDzTables(db: ReadonlyDb): boolean {
218
242
  return t('reasoning_patterns') && t('pattern_embeddings');
219
243
  }
220
244
 
245
+ function embeddingRowCount(db: { prepare: (q: string) => { get: (...a: unknown[]) => unknown } }): number {
246
+ const row = db.prepare('SELECT COUNT(*) AS n FROM pattern_embeddings').get() as { n?: unknown } | undefined;
247
+ return typeof row?.n === 'number' ? row.n : 0;
248
+ }
249
+
221
250
  /**
222
251
  * Cosine similarity in [-1, 1] over two embeddings. Exported (was file-private) so
223
252
  * `harmonizeVectorStore` scores near-duplicate pairs with the IDENTICAL math the semantic search
@@ -241,7 +270,7 @@ function dzIdOf(metadataJson: unknown): string | undefined {
241
270
  if (typeof metadataJson !== 'string' || metadataJson === '') return undefined;
242
271
  try {
243
272
  const meta = JSON.parse(metadataJson) as Record<string, unknown>;
244
- const id = meta['dzId'] ?? meta['dreamId'];
273
+ const id = meta['dzId'] ?? meta['dreamId'] ?? meta['kuId'] ?? meta['ku_id'];
245
274
  return typeof id === 'string' ? id : undefined;
246
275
  } catch {
247
276
  return undefined;
@@ -258,31 +287,41 @@ function dzIdOf(metadataJson: unknown): string | undefined {
258
287
  export async function searchAgentdbPatterns(
259
288
  projectRoot: string,
260
289
  query: string,
261
- opts: { limit?: number; dbPath?: string } = {},
290
+ opts: { limit?: number; dbPath?: string; taskTypes?: readonly string[]; reindexHint?: string } = {},
262
291
  ): Promise<AgentdbSearchResult> {
263
292
  const limit = Math.max(1, opts.limit ?? 10);
264
- const emb = await resolveAgentdbEmbedder(projectRoot);
265
- if ('error' in emb) return { hits: [], error: emb.error };
266
- let qvec: Float32Array;
267
- try {
268
- qvec = await emb.embed(query);
269
- } catch (err) {
270
- return { hits: [], error: `query embed failed: ${err instanceof Error ? err.message : String(err)}` };
271
- }
272
293
  const opened = openReadonly(projectRoot, opts.dbPath);
273
294
  if ('absent' in opened) return { hits: [] }; // nothing mirrored yet — zero semantic hits, honestly
274
295
  if ('error' in opened) return { hits: [], error: opened.error };
275
296
  const { db } = opened;
276
297
  try {
277
298
  if (!hasDzTables(db)) return { hits: [] };
278
- const placeholders = DZ_TASK_TYPES.map(() => '?').join(', ');
299
+ const taskTypes = opts.taskTypes ?? DZ_TASK_TYPES;
300
+ const placeholders = taskTypes.map(() => '?').join(', ');
279
301
  const rows = db
280
302
  .prepare(
281
303
  `SELECT p.id, p.approach, p.success_rate, p.metadata, e.embedding
282
304
  FROM reasoning_patterns p JOIN pattern_embeddings e ON e.pattern_id = p.id
283
305
  WHERE p.task_type IN (${placeholders})`,
284
306
  )
285
- .all(...DZ_TASK_TYPES) as Array<{ id: number; approach: string; success_rate: number; metadata: string | null; embedding: Buffer }>;
307
+ .all(...taskTypes) as Array<{ id: number; approach: string; success_rate: number; metadata: string | null; embedding: Buffer }>;
308
+ const model = resolveEmbedModel(projectRoot);
309
+ if ('error' in model) return { hits: [], error: model.error };
310
+ const guard = guardEmbedSpace({
311
+ storePath: resolveAgentdbPath(projectRoot, opts.dbPath),
312
+ configured: model,
313
+ hasRows: rows.length > 0,
314
+ reindexHint: opts.reindexHint ?? 'dz vector reindex',
315
+ });
316
+ if (!guard.ok) return { hits: [], error: guard.error };
317
+ const emb = await resolveAgentdbEmbedder(projectRoot);
318
+ if ('error' in emb) return { hits: [], error: emb.error };
319
+ let qvec: Float32Array;
320
+ try {
321
+ qvec = await emb.embed(query);
322
+ } catch (err) {
323
+ return { hits: [], error: `query embed failed: ${err instanceof Error ? err.message : String(err)}` };
324
+ }
286
325
  const scored: AgentdbSearchHit[] = rows.map((r) => {
287
326
  const vec = new Float32Array(r.embedding.buffer, r.embedding.byteOffset, Math.floor(r.embedding.byteLength / 4));
288
327
  return {
@@ -309,7 +348,7 @@ export async function searchAgentdbPatterns(
309
348
  */
310
349
  export async function listAgentdbDzIds(
311
350
  projectRoot: string,
312
- opts: { dbPath?: string } = {},
351
+ opts: { dbPath?: string; taskTypes?: readonly string[] } = {},
313
352
  ): Promise<{ ids: string[]; error?: string | undefined }> {
314
353
  const opened = openReadonly(projectRoot, opts.dbPath);
315
354
  if ('absent' in opened) return { ids: [] }; // empty mirror — everything is backfillable
@@ -317,10 +356,11 @@ export async function listAgentdbDzIds(
317
356
  const { db } = opened;
318
357
  try {
319
358
  if (!hasDzTables(db)) return { ids: [] };
320
- const placeholders = DZ_TASK_TYPES.map(() => '?').join(', ');
359
+ const taskTypes = opts.taskTypes ?? DZ_TASK_TYPES;
360
+ const placeholders = taskTypes.map(() => '?').join(', ');
321
361
  const rows = db
322
362
  .prepare(`SELECT metadata FROM reasoning_patterns WHERE task_type IN (${placeholders})`)
323
- .all(...DZ_TASK_TYPES) as Array<{ metadata: string | null }>;
363
+ .all(...taskTypes) as Array<{ metadata: string | null }>;
324
364
  const ids = new Set<string>();
325
365
  for (const r of rows) {
326
366
  const id = dzIdOf(r.metadata);
@@ -389,6 +429,8 @@ export async function importVectorsToAgentdb(
389
429
  }
390
430
  try {
391
431
  const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => UpsertDb };
432
+ const model = resolveEmbedModel(projectRoot);
433
+ if ('error' in model) return { imported: 0, error: model.error };
392
434
  const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
393
435
  mkdirSync(dirname(dbFile), { recursive: true }); // better-sqlite3 won't create the parent dir
394
436
  const db = new Database(dbFile);
@@ -396,6 +438,13 @@ export async function importVectorsToAgentdb(
396
438
  db.pragma('journal_mode = WAL');
397
439
  db.pragma('busy_timeout = 5000'); // wait out a brief MCP-server write lock instead of failing
398
440
  db.exec(REASONING_BANK_SCHEMA);
441
+ const guard = guardEmbedSpace({
442
+ storePath: dbFile,
443
+ configured: model,
444
+ hasRows: embeddingRowCount(db) > 0,
445
+ reindexHint: 'dz vector reindex',
446
+ });
447
+ if (!guard.ok) return { imported: 0, error: guard.error };
399
448
  const findByDzId = db.prepare("SELECT id FROM reasoning_patterns WHERE json_extract(metadata, '$.dzId') = ?");
400
449
  const insPattern = db.prepare('INSERT INTO reasoning_patterns (task_type, approach, success_rate, uses, avg_reward, tags, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)');
401
450
  const upsertEmb = db.prepare('INSERT OR REPLACE INTO pattern_embeddings (pattern_id, embedding) VALUES (?, ?)');
@@ -418,7 +467,9 @@ export async function importVectorsToAgentdb(
418
467
  }
419
468
  return imported;
420
469
  });
421
- return { imported: commit() };
470
+ const imported = commit();
471
+ writeEmbedManifest(dbFile, currentEmbedManifest(model, guard.manifest.version, 'agentdb'));
472
+ return { imported };
422
473
  } finally {
423
474
  db.close();
424
475
  }
@@ -426,3 +477,61 @@ export async function importVectorsToAgentdb(
426
477
  return { imported: 0, error: `import failed: ${err instanceof Error ? err.message : String(err)}` };
427
478
  }
428
479
  }
480
+
481
+ export async function reindexAgentdbRows(
482
+ projectRoot: string,
483
+ rows: readonly AgentdbRow[],
484
+ opts: { dbPath?: string; taskTypes?: readonly string[]; backupPath?: string } = {},
485
+ ): Promise<{ reembedded: number; model?: string; version?: number; backupPath?: string; error?: string }> {
486
+ const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
487
+ const backupPath = opts.backupPath ?? `${dbFile}.pre-reindex-${Date.now()}.bak`;
488
+ if (existsSync(dbFile)) {
489
+ try {
490
+ const { copyFileSync } = await import('node:fs');
491
+ copyFileSync(dbFile, backupPath);
492
+ if (existsSync(`${dbFile}.embed-manifest.json`)) {
493
+ copyFileSync(`${dbFile}.embed-manifest.json`, `${backupPath}.embed-manifest.json`);
494
+ }
495
+ } catch (err) {
496
+ return { reembedded: 0, backupPath, error: `snapshot failed — reindex aborted: ${err instanceof Error ? err.message : String(err)}` };
497
+ }
498
+ }
499
+ let sqliteUrl: string;
500
+ try {
501
+ const req = createRequire(join(projectRoot, 'package.json'));
502
+ sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
503
+ } catch {
504
+ return { reembedded: 0, backupPath, error: DEPS_MISSING };
505
+ }
506
+ const model = resolveEmbedModel(projectRoot);
507
+ if ('error' in model) return { reembedded: 0, backupPath, error: model.error };
508
+ const oldVersion = readEmbedManifest(dbFile)?.version ?? 1;
509
+ try {
510
+ const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => UpsertDb };
511
+ mkdirSync(dirname(dbFile), { recursive: true });
512
+ const db = new Database(dbFile);
513
+ try {
514
+ db.pragma('journal_mode = WAL');
515
+ db.pragma('busy_timeout = 5000');
516
+ db.exec(REASONING_BANK_SCHEMA);
517
+ const taskTypes = opts.taskTypes ?? DZ_TASK_TYPES;
518
+ const placeholders = taskTypes.map(() => '?').join(', ');
519
+ const delEmb = db.prepare(`DELETE FROM pattern_embeddings WHERE pattern_id IN (SELECT id FROM reasoning_patterns WHERE task_type IN (${placeholders}))`);
520
+ const delPat = db.prepare(`DELETE FROM reasoning_patterns WHERE task_type IN (${placeholders})`);
521
+ const tx = db.transaction(() => {
522
+ delEmb.run(...taskTypes);
523
+ delPat.run(...taskTypes);
524
+ });
525
+ tx();
526
+ } finally {
527
+ db.close();
528
+ }
529
+ const indexed = await indexPatternsToAgentdb(projectRoot, rows, { dbPath: dbFile });
530
+ if (indexed.error !== undefined) return { reembedded: 0, backupPath, error: indexed.error };
531
+ const version = Math.max(oldVersion + 1, 2);
532
+ writeEmbedManifest(dbFile, currentEmbedManifest(model, version, 'agentdb'));
533
+ return { reembedded: indexed.indexed, model: model.model, version, backupPath };
534
+ } catch (err) {
535
+ return { reembedded: 0, backupPath, error: `reindex failed: ${err instanceof Error ? err.message : String(err)}` };
536
+ }
537
+ }
package/src/brain.ts CHANGED
@@ -17,11 +17,13 @@
17
17
 
18
18
  import { join, dirname } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, type Dirent } from 'node:fs';
21
21
  import { pathToFileURL } from 'node:url';
22
22
  import { createRequire } from 'node:module';
23
+ import { spawn } from 'node:child_process';
23
24
  import { putBookKnowledge, queryBookKnowledge, bookKbPath, type BookKU, type BookKUHit } from './book-kb.js';
24
- import { indexPatternsToAgentdb, type AgentdbRow } from './agentdb-index.js';
25
+ import { indexPatternsToAgentdb, searchAgentdbPatterns, reindexAgentdbRows, type AgentdbRow } from './agentdb-index.js';
26
+ import { resolveEmbedModel, type EmbedModelConfig } from './embedding-config.js';
25
27
 
26
28
  // ─────────────────────────────────────── Home & paths ───────────────────────────────────────
27
29
 
@@ -783,6 +785,8 @@ const RERANK_PRIOR_BONUS = 0.5;
783
785
 
784
786
  /** Over-fetch cap for the rerank pass — bounds work while leaving room for a `limit*3` window. */
785
787
  const RERANK_OVERFETCH_CAP = 200;
788
+ const GROUND_VECTOR_TIMEOUT_MS = 1_000;
789
+ const GROUND_VECTOR_SIMILARITY_FLOOR = 0.35;
786
790
 
787
791
  /**
788
792
  * A DETERMINISTIC lexical reranker (ADR-001 §11 P3 / G3) — lifts precision on the top-K without a
@@ -855,6 +859,180 @@ export async function queryBrain(opts: {
855
859
  return { hits: rerankHits(opts.query, res.hits, { limit: effLimit }) };
856
860
  }
857
861
 
862
+ async function bounded<T>(promise: Promise<T>, ms: number, fallback: () => T): Promise<T> {
863
+ let timer: ReturnType<typeof setTimeout> | undefined;
864
+ try {
865
+ return await Promise.race([
866
+ promise.catch(() => fallback()),
867
+ new Promise<T>((resolvePromise) => {
868
+ timer = setTimeout(() => resolvePromise(fallback()), ms);
869
+ timer.unref?.();
870
+ }),
871
+ ]);
872
+ } finally {
873
+ if (timer !== undefined) clearTimeout(timer);
874
+ }
875
+ }
876
+
877
+ const backgroundWarmups = new Set<string>();
878
+
879
+ function isFakeAgentdbForTests(depsRoot: string): boolean {
880
+ try {
881
+ const req = createRequire(join(depsRoot, 'package.json'));
882
+ const pkg = JSON.parse(readFileSync(req.resolve('agentdb/package.json'), 'utf-8')) as { version?: unknown };
883
+ return typeof pkg.version === 'string' && pkg.version.includes('test');
884
+ } catch {
885
+ return false;
886
+ }
887
+ }
888
+
889
+ function hasOnnxFile(dir: string, depth = 0, seen: { count: number } = { count: 0 }): boolean {
890
+ if (depth > 8 || seen.count > 2_000 || !existsSync(dir)) return false;
891
+ let entries: Dirent[];
892
+ try {
893
+ entries = readdirSync(dir, { withFileTypes: true });
894
+ } catch {
895
+ return false;
896
+ }
897
+ for (const entry of entries) {
898
+ seen.count += 1;
899
+ const p = join(dir, entry.name);
900
+ if (entry.isFile() && entry.name.endsWith('.onnx')) return true;
901
+ if (entry.isDirectory() && hasOnnxFile(p, depth + 1, seen)) return true;
902
+ if (seen.count > 2_000) return false;
903
+ }
904
+ return false;
905
+ }
906
+
907
+ function cacheRoots(): string[] {
908
+ const roots: string[] = [];
909
+ const transformers = process.env['TRANSFORMERS_CACHE'];
910
+ if (transformers !== undefined && transformers !== '') roots.push(transformers);
911
+ const hfHome = process.env['HF_HOME'];
912
+ if (hfHome !== undefined && hfHome !== '') roots.push(join(hfHome, 'hub'));
913
+ const xdg = process.env['XDG_CACHE_HOME'];
914
+ roots.push(join(xdg !== undefined && xdg !== '' ? xdg : join(homedir(), '.cache'), 'huggingface', 'hub'));
915
+ return [...new Set(roots)];
916
+ }
917
+
918
+ function isEmbedModelCached(depsRoot: string, model: EmbedModelConfig): boolean {
919
+ if (isFakeAgentdbForTests(depsRoot)) return true;
920
+ const modelDir = `models--${model.model.replace(/\//g, '--')}`;
921
+ for (const root of cacheRoots()) {
922
+ if (hasOnnxFile(join(root, modelDir)) || hasOnnxFile(root.endsWith(modelDir) ? root : join(root, 'hub', modelDir))) return true;
923
+ }
924
+ return false;
925
+ }
926
+
927
+ function warmEmbedModelInBackground(depsRoot: string, model: EmbedModelConfig): void {
928
+ const key = `${depsRoot}\0${model.model}`;
929
+ if (backgroundWarmups.has(key)) return;
930
+ backgroundWarmups.add(key);
931
+ setImmediate(() => {
932
+ const script = `
933
+ import { createRequire } from 'node:module';
934
+ import { dirname, join } from 'node:path';
935
+ import { pathToFileURL } from 'node:url';
936
+ const root = ${JSON.stringify(depsRoot)};
937
+ const model = ${JSON.stringify(model.model)};
938
+ const dim = ${JSON.stringify(model.dim)};
939
+ const req = createRequire(join(root, 'package.json'));
940
+ const agentdbDir = dirname(req.resolve('agentdb'));
941
+ const mod = await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href);
942
+ const emb = new mod.EmbeddingService({ model, dimension: dim, provider: 'transformers' });
943
+ await emb.initialize();
944
+ await emb.embed('warm embedding model cache');
945
+ `;
946
+ try {
947
+ const nodeBin = existsSync(process.execPath) ? process.execPath : (process.argv[0] ?? 'node');
948
+ const child = spawn(nodeBin, ['--input-type=module', '-e', script], {
949
+ cwd: depsRoot,
950
+ detached: true,
951
+ stdio: ['ignore', 'ignore', 'ignore'],
952
+ env: { ...process.env, DZ_EMBED_MODEL: model.model },
953
+ });
954
+ child.on('error', () => undefined);
955
+ child.unref();
956
+ } catch {
957
+ /* warmup is advisory; the foreground grounding path must never block or throw */
958
+ }
959
+ });
960
+ }
961
+
962
+ export async function searchBrainVectors(opts: {
963
+ query: string;
964
+ brainHome?: string;
965
+ depsRoot?: string;
966
+ source?: string;
967
+ limit?: number;
968
+ timeoutMs?: number;
969
+ }): Promise<{ hits: Array<BookKUHit & { similarity: number }>; error?: string }> {
970
+ const home = opts.brainHome ?? brainHome();
971
+ const depsRoot = opts.depsRoot ?? process.cwd();
972
+ const limit = Math.max(1, opts.limit ?? DEFAULT_QUERY_LIMIT);
973
+ const model = resolveEmbedModel(depsRoot);
974
+ if ('error' in model) return { hits: [], error: model.error };
975
+ if (!isEmbedModelCached(depsRoot, model)) {
976
+ // eslint-disable-next-line no-console
977
+ console.warn(`[dz brain] SKIP vector leg: embedding model cache is cold for ${model.model}; warming in background`);
978
+ warmEmbedModelInBackground(depsRoot, model);
979
+ return { hits: [], error: 'brain vector search skipped: embedding model cache cold' };
980
+ }
981
+ const run = async (): Promise<{ hits: Array<BookKUHit & { similarity: number }>; error?: string }> => {
982
+ const vec = await searchAgentdbPatterns(depsRoot, opts.query, {
983
+ dbPath: brainAgentdbPath(home),
984
+ taskTypes: ['book-knowledge'],
985
+ limit: limit * 3,
986
+ reindexHint: 'dz brain reindex',
987
+ });
988
+ if (vec.error !== undefined) return { hits: [], error: vec.error };
989
+ if (vec.hits.length === 0) return { hits: [] };
990
+ const read = readBookKus({ storePath: brainBooksPath(home), depsRoot, ...(opts.source !== undefined ? { source: opts.source } : {}) });
991
+ if (read.error !== undefined) return { hits: [], error: read.error };
992
+ const byKu = new Map(read.kus.map((ku) => [ku.kuId, ku]));
993
+ const hits: Array<BookKUHit & { similarity: number }> = [];
994
+ const seen = new Set<string>();
995
+ for (const h of vec.hits) {
996
+ if (h.dzId === undefined || seen.has(h.dzId)) continue;
997
+ const ku = byKu.get(h.dzId);
998
+ if (ku === undefined) continue;
999
+ seen.add(h.dzId);
1000
+ hits.push({
1001
+ book: ku.book,
1002
+ kuId: ku.kuId,
1003
+ type: ku.type,
1004
+ name: ku.name,
1005
+ problem: ku.problem,
1006
+ content: ku.content,
1007
+ ...(ku.chapter !== undefined ? { chapter: ku.chapter } : {}),
1008
+ ...(ku.pages !== undefined ? { pages: ku.pages } : {}),
1009
+ similarity: h.similarity,
1010
+ });
1011
+ if (hits.length >= limit) break;
1012
+ }
1013
+ return { hits };
1014
+ };
1015
+ return bounded(run(), opts.timeoutMs ?? GROUND_VECTOR_TIMEOUT_MS, () => ({ hits: [], error: 'brain vector search timed out' }));
1016
+ }
1017
+
1018
+ export async function reindexBrainVectors(opts: {
1019
+ brainHome?: string;
1020
+ depsRoot?: string;
1021
+ }): Promise<{ reembedded: number; model?: string; version?: number; backupPath?: string; error?: string }> {
1022
+ const home = opts.brainHome ?? brainHome();
1023
+ const depsRoot = opts.depsRoot ?? process.cwd();
1024
+ const read = readBookKus({ storePath: brainBooksPath(home), depsRoot });
1025
+ if (read.error !== undefined) return { reembedded: 0, error: read.error };
1026
+ const rows: AgentdbRow[] = read.kus.map((ku) => ({
1027
+ taskType: 'book-knowledge',
1028
+ text: `${ku.name}\n${ku.problem}\n${ku.content}`,
1029
+ score: 1.0,
1030
+ tags: ['book-knowledge', ku.book, ku.type],
1031
+ metadata: { source: 'book-kb', book: ku.book, kuId: ku.kuId, dzId: ku.kuId, corpusVersion: ku.corpusVersion },
1032
+ }));
1033
+ return reindexAgentdbRows(depsRoot, rows, { dbPath: brainAgentdbPath(home), taskTypes: ['book-knowledge'] });
1034
+ }
1035
+
858
1036
  // ──────────────────────────────────────── Grounding ─────────────────────────────────────────
859
1037
 
860
1038
  /**
@@ -951,8 +1129,22 @@ export async function groundPrompt(opts: {
951
1129
  const res = await queryBrain(query);
952
1130
 
953
1131
  // Gate 2: any lexical hit? On error or zero hits, stay silent (no noise, no block).
954
- if (res.error !== undefined) return silent;
955
- if (res.hits.length === 0) return silent;
1132
+ const lexicalHits = res.error === undefined ? res.hits : [];
1133
+ const semantic = await searchBrainVectors({
1134
+ query: terms.join(' '),
1135
+ limit: opts.k ?? 5,
1136
+ ...(opts.brainHome !== undefined ? { brainHome: opts.brainHome } : {}),
1137
+ ...(opts.depsRoot !== undefined ? { depsRoot: opts.depsRoot } : {}),
1138
+ ...(opts.source !== undefined ? { source: opts.source } : {}),
1139
+ timeoutMs: GROUND_VECTOR_TIMEOUT_MS,
1140
+ });
1141
+ const semanticHits = semantic.hits.filter((h) => h.similarity >= GROUND_VECTOR_SIMILARITY_FLOOR);
1142
+ if (lexicalHits.length === 0 && semanticHits.length === 0) return silent;
1143
+
1144
+ const mergedByKu = new Map<string, BookKUHit>();
1145
+ for (const h of lexicalHits) mergedByKu.set(h.kuId, h);
1146
+ for (const h of semanticHits) if (!mergedByKu.has(h.kuId)) mergedByKu.set(h.kuId, h);
1147
+ const merged = rerankHits(terms.join(' '), [...mergedByKu.values()], { limit: opts.k ?? 5 });
956
1148
 
957
1149
  // Gate 3 — COVERAGE (the OR balance): OR recall alone over-fires when a single common term
958
1150
  // ("today", "data") incidentally matches a KU. Require the retrieved hits to actually cover the
@@ -961,20 +1153,20 @@ export async function groundPrompt(opts: {
961
1153
  // off-topic prompt whose only matching word is incidental ("weather today" → only "today"
962
1154
  // matches) stays silent, while a real one ("репликацию single multi leader" → 4 covered) grounds.
963
1155
  if (terms.length >= 2) {
964
- const hay = res.hits.map((h) => `${h.name} ${h.problem} ${h.content}`.toLowerCase()).join(' ');
1156
+ const hay = lexicalHits.map((h) => `${h.name} ${h.problem} ${h.content}`.toLowerCase()).join(' ');
965
1157
  const covered = terms.filter((t) => hay.includes(t.toLowerCase())).length;
966
- if (covered < 2) return silent;
1158
+ if (covered < 2 && semanticHits.length === 0) return silent;
967
1159
  }
968
1160
 
969
1161
  // Build the GROUNDING DIRECTIVE block (§7.1): directive line + numbered citations.
970
1162
  const lines: string[] = [GROUNDING_DIRECTIVE, ''];
971
- res.hits.forEach((h, i) => {
1163
+ merged.forEach((h, i) => {
972
1164
  const ch = h.chapter !== undefined && h.chapter !== '' ? ` гл.${h.chapter}` : '';
973
1165
  const pg = h.pages !== undefined && h.pages.length > 0 ? ` с.${h.pages.join('–')}` : '';
974
1166
  const body = snippet(h.problem !== '' ? h.problem : h.content);
975
1167
  lines.push(`[K${i + 1}] ${h.book}${ch}${pg} — ${h.name}: ${body}`);
976
1168
  });
977
- return { emitted: true, block: lines.join('\n'), hitCount: res.hits.length };
1169
+ return { emitted: true, block: lines.join('\n'), hitCount: merged.length };
978
1170
  } catch {
979
1171
  // Grounding is advisory — a bug here must never surface to the user or block the prompt.
980
1172
  return silent;
@@ -0,0 +1,117 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ export type EmbedModelSource = 'env' | 'config' | 'default';
5
+
6
+ export interface EmbedModelConfig {
7
+ readonly model: string;
8
+ readonly dim: 384;
9
+ readonly source: EmbedModelSource;
10
+ }
11
+
12
+ export interface EmbedManifest {
13
+ readonly model: string;
14
+ readonly dim: 384;
15
+ readonly version: number;
16
+ readonly engine?: string;
17
+ }
18
+
19
+ export const DEFAULT_EMBED_MODEL = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
20
+ export const LEGACY_EMBED_MODEL = 'Xenova/all-MiniLM-L6-v2';
21
+ export const DEFAULT_EMBED_DIM = 384;
22
+
23
+ export const KNOWN_EMBED_DIMS: Readonly<Record<string, 384>> = {
24
+ [DEFAULT_EMBED_MODEL]: DEFAULT_EMBED_DIM,
25
+ [LEGACY_EMBED_MODEL]: DEFAULT_EMBED_DIM,
26
+ // e5 models require asymmetric "query:" / "passage:" prefixes at call sites to reach full quality.
27
+ 'Xenova/multilingual-e5-small': DEFAULT_EMBED_DIM,
28
+ 'Xenova/paraphrase-multilingual-mpnet-base-v2': DEFAULT_EMBED_DIM,
29
+ };
30
+
31
+ export function resolveEmbedModel(projectRoot: string): EmbedModelConfig | { error: string } {
32
+ const env = process.env['DZ_EMBED_MODEL'];
33
+ if (env !== undefined && env.trim() !== '') return modelConfig(env.trim(), 'env');
34
+ const cfgPath = join(projectRoot, '.dz', 'config.json');
35
+ if (existsSync(cfgPath)) {
36
+ try {
37
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')) as Record<string, unknown>;
38
+ const memory = cfg['memory'] as Record<string, unknown> | undefined;
39
+ const agentdb = memory?.['agentdb'] as Record<string, unknown> | undefined;
40
+ const embed = memory?.['embed'] as Record<string, unknown> | undefined;
41
+ const configured = agentdb?.['embeddingModel'] ?? embed?.['model'];
42
+ if (typeof configured === 'string' && configured.trim() !== '') return modelConfig(configured.trim(), 'config');
43
+ } catch {
44
+ /* corrupt config falls back to the default, matching the existing config-read discipline */
45
+ }
46
+ }
47
+ return modelConfig(DEFAULT_EMBED_MODEL, 'default');
48
+ }
49
+
50
+ function modelConfig(model: string, source: EmbedModelSource): EmbedModelConfig | { error: string } {
51
+ const dim = KNOWN_EMBED_DIMS[model];
52
+ if (dim === undefined) {
53
+ return { error: `unsupported embedding model '${model}' (known 384-dim models: ${Object.keys(KNOWN_EMBED_DIMS).join(', ')})` };
54
+ }
55
+ return { model, dim, source };
56
+ }
57
+
58
+ export function embedManifestPath(storePath: string): string {
59
+ return `${storePath}.embed-manifest.json`;
60
+ }
61
+
62
+ export function readEmbedManifest(storePath: string): EmbedManifest | undefined {
63
+ return readManifestFile(embedManifestPath(storePath));
64
+ }
65
+
66
+ function readManifestFile(p: string): EmbedManifest | undefined {
67
+ if (!existsSync(p)) return undefined;
68
+ try {
69
+ const m = JSON.parse(readFileSync(p, 'utf-8')) as Partial<EmbedManifest>;
70
+ if (typeof m.model !== 'string' || m.model === '') return undefined;
71
+ if (m.dim !== DEFAULT_EMBED_DIM) return undefined;
72
+ return { model: m.model, dim: DEFAULT_EMBED_DIM, version: typeof m.version === 'number' ? m.version : 1, ...(typeof m.engine === 'string' ? { engine: m.engine } : {}) };
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ function readStoreManifest(storePath: string): EmbedManifest | undefined {
79
+ return readEmbedManifest(storePath) ?? readManifestFile(`${storePath}.manifest.json`);
80
+ }
81
+
82
+ export function writeEmbedManifest(storePath: string, manifest: EmbedManifest): void {
83
+ const p = embedManifestPath(storePath);
84
+ mkdirSync(dirname(p), { recursive: true });
85
+ writeFileSync(p, `${JSON.stringify(manifest, null, 2)}\n`);
86
+ }
87
+
88
+ export function legacyEmbedManifest(): EmbedManifest {
89
+ return { model: LEGACY_EMBED_MODEL, dim: DEFAULT_EMBED_DIM, version: 1 };
90
+ }
91
+
92
+ export function currentEmbedManifest(configured: EmbedModelConfig, version = 1, engine?: string): EmbedManifest {
93
+ return { model: configured.model, dim: configured.dim, version, ...(engine !== undefined ? { engine } : {}) };
94
+ }
95
+
96
+ export function guardEmbedSpace(args: {
97
+ storePath: string;
98
+ configured: EmbedModelConfig;
99
+ hasRows: boolean;
100
+ reindexHint: string;
101
+ }): { ok: true; manifest: EmbedManifest } | { ok: false; error: string; manifest: EmbedManifest } {
102
+ const manifest = readStoreManifest(args.storePath)
103
+ ?? (args.hasRows ? legacyEmbedManifest() : currentEmbedManifest(args.configured));
104
+ if (manifest.model !== args.configured.model || manifest.dim !== args.configured.dim) {
105
+ return {
106
+ ok: false,
107
+ manifest,
108
+ error: `embedding model mismatch: index built with ${manifest.model}/${manifest.dim}, configured ${args.configured.model}/${args.configured.dim}; run ${args.reindexHint}`,
109
+ };
110
+ }
111
+ return { ok: true, manifest };
112
+ }
113
+
114
+ export function snapshotEmbedManifest(storePath: string, backupPath: string): void {
115
+ const p = embedManifestPath(storePath);
116
+ if (existsSync(p)) copyFileSync(p, backupPath);
117
+ }