@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.
- package/dist/agentdb-index.d.ts +16 -2
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +129 -22
- package/dist/agentdb-index.js.map +1 -1
- package/dist/brain.d.ts +23 -0
- package/dist/brain.d.ts.map +1 -1
- package/dist/brain.js +205 -9
- package/dist/brain.js.map +1 -1
- package/dist/embedding-config.d.ts +39 -0
- package/dist/embedding-config.d.ts.map +1 -0
- package/dist/embedding-config.js +93 -0
- package/dist/embedding-config.js.map +1 -0
- package/dist/feature-adr-routing.d.ts +125 -0
- package/dist/feature-adr-routing.d.ts.map +1 -0
- package/dist/feature-adr-routing.js +190 -0
- package/dist/feature-adr-routing.js.map +1 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/setup.js +1 -1
- package/dist/setup.js.map +1 -1
- package/dist/vector-tier.d.ts +9 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +75 -16
- package/dist/vector-tier.js.map +1 -1
- package/package.json +4 -4
- package/src/agentdb-index.ts +132 -23
- package/src/brain.ts +200 -8
- package/src/embedding-config.ts +117 -0
- package/src/feature-adr-routing.ts +220 -0
- package/src/index.ts +20 -1
- package/src/setup.ts +1 -1
- package/src/vector-tier.ts +78 -16
package/src/agentdb-index.ts
CHANGED
|
@@ -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) => {
|
|
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
|
|
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
|
|
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
|
-
|
|
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};
|
|
171
|
-
*
|
|
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
|
|
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
|
|
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(...
|
|
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
|
|
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(...
|
|
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
|
-
|
|
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
|
-
|
|
955
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|