@dzhechkov/harness-core 0.3.50 → 0.3.54
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/brain.d.ts +146 -4
- package/dist/brain.d.ts.map +1 -1
- package/dist/brain.js +437 -78
- package/dist/brain.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/brain.ts +500 -86
- package/src/index.ts +8 -0
package/src/brain.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* @packageDocumentation
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { join } from 'node:path';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
20
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { pathToFileURL } from 'node:url';
|
|
@@ -56,6 +56,10 @@ export interface BrainSource {
|
|
|
56
56
|
readonly corpusVersion?: string;
|
|
57
57
|
readonly lang?: string;
|
|
58
58
|
readonly isbn?: string;
|
|
59
|
+
/** SPDX-ish license id — carried for repo sources (§8: refuse promoting unknown-license repos). */
|
|
60
|
+
readonly license?: string;
|
|
61
|
+
/** Relative path (from the brain home) to this source's capability card, e.g. `primers/<slug>.md`. */
|
|
62
|
+
readonly primer?: string;
|
|
59
63
|
readonly addedTs: string;
|
|
60
64
|
}
|
|
61
65
|
|
|
@@ -133,28 +137,58 @@ interface ProjRow {
|
|
|
133
137
|
metadata: string | null;
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
/** Reconstruct a {@link BookKU} from a raw `book_knowledge` row (shared by every reader below). */
|
|
141
|
+
function rowToKu(r: ProjRow): BookKU {
|
|
142
|
+
const pages = jsonPages(r.pages);
|
|
143
|
+
const meta = jsonObj(r.metadata);
|
|
144
|
+
return {
|
|
145
|
+
book: r.book,
|
|
146
|
+
kuId: r.ku_id,
|
|
147
|
+
corpusVersion: r.corpus_version,
|
|
148
|
+
type: r.type,
|
|
149
|
+
name: r.name,
|
|
150
|
+
problem: r.problem,
|
|
151
|
+
content: r.content,
|
|
152
|
+
...(r.chapter !== null ? { chapter: r.chapter } : {}),
|
|
153
|
+
...(pages !== undefined ? { pages } : {}),
|
|
154
|
+
...(Object.keys(meta).length > 0 ? { metadata: meta } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Read reconstructed KUs from ANY `books.sqlite`-format lexical store (a project store, the brain
|
|
160
|
+
* store, or a per-source slice) — the single shared reader behind {@link promoteProjectToBrain},
|
|
161
|
+
* {@link buildPrimer}, {@link exportBrainSlice}, and {@link importBrainSlice}. `source` narrows to
|
|
162
|
+
* one book; default reads all. Synchronous (native `better-sqlite3` via `createRequire`) and
|
|
163
|
+
* best-effort: an absent store or unresolved dependency returns an honest `error`, never throws.
|
|
164
|
+
*/
|
|
165
|
+
export function readBookKus(opts: {
|
|
166
|
+
storePath: string;
|
|
167
|
+
depsRoot?: string;
|
|
168
|
+
source?: string;
|
|
169
|
+
}): { kus: BookKU[]; error?: string } {
|
|
170
|
+
const depsRoot = opts.depsRoot ?? process.cwd();
|
|
171
|
+
if (!existsSync(opts.storePath)) return { kus: [], error: `no book store at ${opts.storePath}` };
|
|
172
|
+
let Database: new (p: string, o?: object) => NativeDb;
|
|
173
|
+
try {
|
|
174
|
+
const req = createRequire(join(depsRoot, 'package.json'));
|
|
175
|
+
Database = req('better-sqlite3') as new (p: string, o?: object) => NativeDb;
|
|
176
|
+
} catch {
|
|
177
|
+
return { kus: [], error: 'better-sqlite3 not installed (run: dz setup --memory agentdb)' };
|
|
178
|
+
}
|
|
144
179
|
try {
|
|
145
|
-
const
|
|
146
|
-
const db = new Database(projectDbPath, { readonly: true });
|
|
180
|
+
const db = new Database(opts.storePath, { readonly: true });
|
|
147
181
|
try {
|
|
148
182
|
const cols = 'book, ku_id, corpus_version, type, name, problem, content, chapter, pages, metadata';
|
|
149
|
-
const rows = (source !== undefined
|
|
150
|
-
? db.prepare(`SELECT ${cols} FROM book_knowledge WHERE book = ?`).all(source)
|
|
183
|
+
const rows = (opts.source !== undefined
|
|
184
|
+
? db.prepare(`SELECT ${cols} FROM book_knowledge WHERE book = ?`).all(opts.source)
|
|
151
185
|
: db.prepare(`SELECT ${cols} FROM book_knowledge`).all()) as ProjRow[];
|
|
152
|
-
return { rows };
|
|
186
|
+
return { kus: rows.map(rowToKu) };
|
|
153
187
|
} finally {
|
|
154
188
|
db.close();
|
|
155
189
|
}
|
|
156
190
|
} catch (err) {
|
|
157
|
-
return { error: `read
|
|
191
|
+
return { kus: [], error: `read book KB failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
158
192
|
}
|
|
159
193
|
}
|
|
160
194
|
|
|
@@ -220,108 +254,471 @@ function worthScore(meta: Record<string, unknown>): number {
|
|
|
220
254
|
}
|
|
221
255
|
}
|
|
222
256
|
|
|
257
|
+
// ──────────────────────────────────────── Primers ───────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/** Clamp + whitespace-collapse a one-line problem for the primer (deterministic). */
|
|
260
|
+
function primerLine(s: string, max = 120): string {
|
|
261
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
262
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1).trimEnd()}…`;
|
|
263
|
+
}
|
|
264
|
+
|
|
223
265
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* **Non-clobbering:** promoting the same book twice does not duplicate — the lexical upsert keys on
|
|
230
|
-
* `(book, ku_id, corpus_version)` and the vector rows are pre-deleted before re-index.
|
|
266
|
+
* Render a source's capability card (ADR-001 §5.4) from its registry entry + KUs — PURE and
|
|
267
|
+
* DETERMINISTIC (no clock, no query): a header (slug/kind/kuCount/corpusVersion/isbn/lang/license),
|
|
268
|
+
* a KU-TYPE histogram (count per type, count-desc then type-asc), and the top ~8 decision/framework
|
|
269
|
+
* KUs (name + one-line problem, page-anchored), ranked by declared `worth` then `kuId`.
|
|
231
270
|
*/
|
|
232
|
-
|
|
233
|
-
|
|
271
|
+
function buildPrimerMarkdown(source: BrainSource, kus: readonly BookKU[]): string {
|
|
272
|
+
const lines: string[] = [`# Primer: ${source.slug}`, ''];
|
|
273
|
+
lines.push(`- kind: ${source.kind}`);
|
|
274
|
+
lines.push(`- KUs: ${source.kuCount}`);
|
|
275
|
+
if (source.corpusVersion !== undefined && source.corpusVersion !== '') lines.push(`- corpusVersion: ${source.corpusVersion}`);
|
|
276
|
+
if (source.isbn !== undefined) lines.push(`- isbn: ${source.isbn}`);
|
|
277
|
+
if (source.lang !== undefined) lines.push(`- lang: ${source.lang}`);
|
|
278
|
+
if (source.license !== undefined) lines.push(`- license: ${source.license}`);
|
|
279
|
+
lines.push('');
|
|
280
|
+
|
|
281
|
+
// KU-type histogram — count per type, count-desc then type-asc (stable, deterministic).
|
|
282
|
+
const counts = new Map<string, number>();
|
|
283
|
+
for (const k of kus) counts.set(k.type, (counts.get(k.type) ?? 0) + 1);
|
|
284
|
+
const hist = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
285
|
+
lines.push('## KU types');
|
|
286
|
+
for (const [type, n] of hist) lines.push(`- ${type}: ${n}`);
|
|
287
|
+
lines.push('');
|
|
288
|
+
|
|
289
|
+
// Top decision-grade KUs — worth-desc then kuId-asc, first 8; page-anchored provenance. Match the
|
|
290
|
+
// ACTUAL KU type vocabulary (decision-framework / tradeoff-table / methodology / heuristic), not the
|
|
291
|
+
// legacy 'decision'/'framework' shorthands that never occur in real KUs (primer showed _none_).
|
|
292
|
+
const DECISION_TYPES = new Set(['decision-framework', 'tradeoff-table', 'methodology', 'heuristic', 'decision', 'framework']);
|
|
293
|
+
const top = kus
|
|
294
|
+
.filter((k) => DECISION_TYPES.has(k.type))
|
|
295
|
+
.sort((a, b) => worthScore(b.metadata ?? {}) - worthScore(a.metadata ?? {}) || a.kuId.localeCompare(b.kuId))
|
|
296
|
+
.slice(0, 8);
|
|
297
|
+
lines.push('## Top decision moments');
|
|
298
|
+
if (top.length === 0) {
|
|
299
|
+
lines.push('_none_');
|
|
300
|
+
} else {
|
|
301
|
+
top.forEach((k, i) => {
|
|
302
|
+
const ch = k.chapter !== undefined && k.chapter !== '' ? ` гл.${k.chapter}` : '';
|
|
303
|
+
const pg = k.pages !== undefined && k.pages.length > 0 ? ` с.${k.pages.join('–')}` : '';
|
|
304
|
+
lines.push(`${i + 1}. ${k.name} — ${primerLine(k.problem)}${ch}${pg}`);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
lines.push('');
|
|
308
|
+
return lines.join('\n');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Synthesize a {@link BrainSource} for a primer when the registry has no entry yet (metadata-derived). */
|
|
312
|
+
function sourceFromKus(slug: string, kus: readonly BookKU[]): BrainSource {
|
|
313
|
+
const meta0 = kus[0]?.metadata ?? {};
|
|
314
|
+
const corpusVersion = kus[0]?.corpusVersion ?? '';
|
|
315
|
+
return {
|
|
316
|
+
slug,
|
|
317
|
+
kind: 'book',
|
|
318
|
+
kuCount: kus.length,
|
|
319
|
+
addedTs: '',
|
|
320
|
+
...(corpusVersion !== '' ? { corpusVersion } : {}),
|
|
321
|
+
...(typeof meta0['lang'] === 'string' ? { lang: meta0['lang'] as string } : {}),
|
|
322
|
+
...(typeof meta0['isbn'] === 'string' ? { isbn: meta0['isbn'] as string } : {}),
|
|
323
|
+
...(typeof meta0['license'] === 'string' ? { license: meta0['license'] as string } : {}),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Build a source's capability card (ADR-001 §5.4) by reading its KUs from the brain lexical store
|
|
329
|
+
* ({@link readBookKus}) + its registry entry, then rendering {@link buildPrimerMarkdown}. Best-effort:
|
|
330
|
+
* a missing store/source returns an honest `error` with empty `markdown`, never throws.
|
|
331
|
+
*/
|
|
332
|
+
export async function buildPrimer(opts: {
|
|
333
|
+
brainHome?: string;
|
|
234
334
|
depsRoot?: string;
|
|
335
|
+
slug: string;
|
|
336
|
+
}): Promise<{ markdown: string; error?: string }> {
|
|
337
|
+
const home = opts.brainHome ?? brainHome();
|
|
338
|
+
const depsRoot = opts.depsRoot ?? process.cwd();
|
|
339
|
+
const read = readBookKus({ storePath: brainBooksPath(home), depsRoot, source: opts.slug });
|
|
340
|
+
if (read.error !== undefined) return { markdown: '', error: read.error };
|
|
341
|
+
if (read.kus.length === 0) return { markdown: '', error: `no KUs for source '${opts.slug}' in brain` };
|
|
342
|
+
const source = readRegistry(home).sources[opts.slug] ?? sourceFromKus(opts.slug, read.kus);
|
|
343
|
+
return { markdown: buildPrimerMarkdown(source, read.kus) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Write a source's primer to `<brainHome>/primers/<slug>.md` (mkdir). Best-effort; honest error. */
|
|
347
|
+
export async function writePrimer(opts: {
|
|
235
348
|
brainHome?: string;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}): Promise<{
|
|
349
|
+
depsRoot?: string;
|
|
350
|
+
slug: string;
|
|
351
|
+
}): Promise<{ path: string; error?: string }> {
|
|
239
352
|
const home = opts.brainHome ?? brainHome();
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
if (
|
|
353
|
+
const path = join(home, 'primers', `${opts.slug}.md`);
|
|
354
|
+
const built = await buildPrimer(opts);
|
|
355
|
+
if (built.error !== undefined) return { path, error: built.error };
|
|
356
|
+
try {
|
|
357
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
358
|
+
writeFileSync(path, built.markdown);
|
|
359
|
+
return { path };
|
|
360
|
+
} catch (err) {
|
|
361
|
+
return { path, error: `write primer failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
243
364
|
|
|
244
|
-
|
|
245
|
-
if ('error' in read) return { sources: [], kus: 0, error: read.error };
|
|
246
|
-
if (read.rows.length === 0) return { sources: [], kus: 0 };
|
|
365
|
+
// ──────────────────────────────────────── Mirror ────────────────────────────────────────────
|
|
247
366
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
367
|
+
/**
|
|
368
|
+
* The ONE shared "mirror KUs into the brain + register + primer" path behind
|
|
369
|
+
* {@link promoteProjectToBrain}, {@link importBrainSlice}, and {@link registerKusToBrain}. Groups
|
|
370
|
+
* the KUs by `book` and, per book: (1) lexical-mirrors via the idempotent {@link putBookKnowledge},
|
|
371
|
+
* (2) re-embeds via {@link indexPatternsToAgentdb} after pre-deleting the book's stale vectors, and
|
|
372
|
+
* (3) writes/refreshes the registry entry — then generates `primers/<slug>.md` best-effort (a primer
|
|
373
|
+
* failure never fails the mirror) and stores its relative path on the entry.
|
|
374
|
+
*
|
|
375
|
+
* **Non-clobbering:** mirroring the same book twice does not duplicate — the lexical upsert keys on
|
|
376
|
+
* `(book, ku_id, corpus_version)` and the vector rows are pre-deleted before re-index.
|
|
377
|
+
*/
|
|
378
|
+
async function mirrorKusToBrain(opts: {
|
|
379
|
+
home: string;
|
|
380
|
+
depsRoot: string;
|
|
381
|
+
kus: readonly BookKU[];
|
|
382
|
+
kind: 'book' | 'repo' | 'paper';
|
|
383
|
+
addedTs: string;
|
|
384
|
+
lang?: string;
|
|
385
|
+
license?: string;
|
|
386
|
+
}): Promise<{ sources: string[]; kus: number; error?: string }> {
|
|
387
|
+
const { home, depsRoot } = opts;
|
|
388
|
+
if (opts.kus.length === 0) return { sources: [], kus: 0 };
|
|
389
|
+
|
|
390
|
+
// Group KUs by book (each book is one mirror unit + one registry entry).
|
|
391
|
+
const byBook = new Map<string, BookKU[]>();
|
|
392
|
+
for (const k of opts.kus) {
|
|
393
|
+
const bucket = byBook.get(k.book);
|
|
394
|
+
if (bucket) bucket.push(k);
|
|
395
|
+
else byBook.set(k.book, [k]);
|
|
254
396
|
}
|
|
255
397
|
|
|
256
398
|
const reg = readRegistry(home);
|
|
257
399
|
const sources: Record<string, BrainSource> = { ...reg.sources };
|
|
258
|
-
const
|
|
400
|
+
const mirrored: string[] = [];
|
|
259
401
|
let totalKus = 0;
|
|
260
402
|
let firstError: string | undefined;
|
|
261
403
|
|
|
262
|
-
for (const [book,
|
|
404
|
+
for (const [book, kus] of byBook) {
|
|
263
405
|
// 1. Lexical mirror — reuse the idempotent upsert, pointed at the brain store.
|
|
264
|
-
const kus: BookKU[] = rows.map((r) => {
|
|
265
|
-
const pages = jsonPages(r.pages);
|
|
266
|
-
const meta = jsonObj(r.metadata);
|
|
267
|
-
return {
|
|
268
|
-
book: r.book,
|
|
269
|
-
kuId: r.ku_id,
|
|
270
|
-
corpusVersion: r.corpus_version,
|
|
271
|
-
type: r.type,
|
|
272
|
-
name: r.name,
|
|
273
|
-
problem: r.problem,
|
|
274
|
-
content: r.content,
|
|
275
|
-
...(r.chapter !== null ? { chapter: r.chapter } : {}),
|
|
276
|
-
...(pages !== undefined ? { pages } : {}),
|
|
277
|
-
...(Object.keys(meta).length > 0 ? { metadata: meta } : {}),
|
|
278
|
-
};
|
|
279
|
-
});
|
|
280
406
|
const lex = await putBookKnowledge(depsRoot, kus, { dbPath: brainBooksPath(home) });
|
|
281
407
|
if (lex.error !== undefined && firstError === undefined) firstError = lex.error;
|
|
282
408
|
|
|
283
409
|
// 2. Vector re-embed — pre-delete this book's stale vectors (insert-only primitive), then index.
|
|
284
410
|
await deleteBrainVectors(depsRoot, brainAgentdbPath(home), book);
|
|
285
|
-
const vecRows: AgentdbRow[] =
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
},
|
|
298
|
-
};
|
|
299
|
-
});
|
|
411
|
+
const vecRows: AgentdbRow[] = kus.map((k) => ({
|
|
412
|
+
taskType: 'book-knowledge',
|
|
413
|
+
text: `${k.name}: ${k.problem}`,
|
|
414
|
+
score: worthScore(k.metadata ?? {}),
|
|
415
|
+
tags: ['book', k.book, k.type],
|
|
416
|
+
metadata: {
|
|
417
|
+
chapter: k.chapter ?? null,
|
|
418
|
+
pages: k.pages ?? null,
|
|
419
|
+
ku_id: k.kuId,
|
|
420
|
+
corpus_version: k.corpusVersion,
|
|
421
|
+
},
|
|
422
|
+
}));
|
|
300
423
|
const vec = await indexPatternsToAgentdb(depsRoot, vecRows, { dbPath: brainAgentdbPath(home) });
|
|
301
424
|
if (vec.error !== undefined && firstError === undefined) firstError = vec.error;
|
|
302
425
|
|
|
303
|
-
// 3. Registry entry
|
|
304
|
-
const meta0 =
|
|
305
|
-
const corpusVersion =
|
|
306
|
-
const lang = typeof meta0['lang'] === 'string' ? (meta0['lang'] as string) : undefined;
|
|
426
|
+
// 3. Registry entry — corpus_version/lang/isbn/license from opts (explicit) then first KU's metadata.
|
|
427
|
+
const meta0 = kus[0]!.metadata ?? {};
|
|
428
|
+
const corpusVersion = kus[0]!.corpusVersion;
|
|
429
|
+
const lang = opts.lang ?? (typeof meta0['lang'] === 'string' ? (meta0['lang'] as string) : undefined);
|
|
307
430
|
const isbn = typeof meta0['isbn'] === 'string' ? (meta0['isbn'] as string) : undefined;
|
|
308
|
-
|
|
431
|
+
const license = opts.license ?? (typeof meta0['license'] === 'string' ? (meta0['license'] as string) : undefined);
|
|
432
|
+
const entry: BrainSource = {
|
|
309
433
|
slug: book,
|
|
310
|
-
kind:
|
|
311
|
-
kuCount:
|
|
434
|
+
kind: opts.kind,
|
|
435
|
+
kuCount: kus.length,
|
|
312
436
|
addedTs: opts.addedTs,
|
|
313
437
|
...(corpusVersion !== undefined && corpusVersion !== '' ? { corpusVersion } : {}),
|
|
314
438
|
...(lang !== undefined ? { lang } : {}),
|
|
315
439
|
...(isbn !== undefined ? { isbn } : {}),
|
|
440
|
+
...(license !== undefined ? { license } : {}),
|
|
316
441
|
};
|
|
317
|
-
|
|
318
|
-
|
|
442
|
+
|
|
443
|
+
// 4. Primer — best-effort: a primer failure must not fail the mirror. Store the relative path.
|
|
444
|
+
let primerRel: string | undefined;
|
|
445
|
+
try {
|
|
446
|
+
const rel = join('primers', `${book}.md`);
|
|
447
|
+
const primerPath = join(home, rel);
|
|
448
|
+
mkdirSync(dirname(primerPath), { recursive: true });
|
|
449
|
+
writeFileSync(primerPath, buildPrimerMarkdown(entry, kus));
|
|
450
|
+
primerRel = rel;
|
|
451
|
+
} catch {
|
|
452
|
+
/* primer is advisory — never fail the mirror on it */
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
sources[book] = primerRel !== undefined ? { ...entry, primer: primerRel } : entry;
|
|
456
|
+
mirrored.push(book);
|
|
457
|
+
totalKus += kus.length;
|
|
319
458
|
}
|
|
320
459
|
|
|
321
460
|
writeRegistry(home, { version: 1, sources });
|
|
322
461
|
return firstError !== undefined
|
|
323
|
-
? { sources:
|
|
324
|
-
: { sources:
|
|
462
|
+
? { sources: mirrored, kus: totalKus, error: firstError }
|
|
463
|
+
: { sources: mirrored, kus: totalKus };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ──────────────────────────────────────── Promote ───────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Promote a PROJECT's digitized book KB into the durable cross-project brain (ADR-001 P0
|
|
470
|
+
* `book-brain-register`). Reads the project's lexical `books.sqlite` ({@link readBookKus}) and mirrors
|
|
471
|
+
* every KU into the brain via the shared {@link mirrorKusToBrain} path (lexical + vector + registry +
|
|
472
|
+
* primer). `kind: 'book'`.
|
|
473
|
+
*
|
|
474
|
+
* **Non-clobbering:** promoting the same book twice does not duplicate — the lexical upsert keys on
|
|
475
|
+
* `(book, ku_id, corpus_version)` and the vector rows are pre-deleted before re-index.
|
|
476
|
+
*/
|
|
477
|
+
export async function promoteProjectToBrain(opts: {
|
|
478
|
+
projectRoot: string;
|
|
479
|
+
depsRoot?: string;
|
|
480
|
+
brainHome?: string;
|
|
481
|
+
source?: string;
|
|
482
|
+
addedTs: string;
|
|
483
|
+
}): Promise<{ sources: string[]; kus: number; error?: string }> {
|
|
484
|
+
const home = opts.brainHome ?? brainHome();
|
|
485
|
+
const depsRoot = opts.depsRoot ?? opts.projectRoot;
|
|
486
|
+
const projectDbPath = bookKbPath(opts.projectRoot);
|
|
487
|
+
if (!existsSync(projectDbPath)) return { sources: [], kus: 0, error: 'no project book KB' };
|
|
488
|
+
|
|
489
|
+
const read = readBookKus({ storePath: projectDbPath, depsRoot, ...(opts.source !== undefined ? { source: opts.source } : {}) });
|
|
490
|
+
if (read.error !== undefined) return { sources: [], kus: 0, error: read.error };
|
|
491
|
+
if (read.kus.length === 0) return { sources: [], kus: 0 };
|
|
492
|
+
|
|
493
|
+
return mirrorKusToBrain({ home, depsRoot, kus: read.kus, kind: 'book', addedTs: opts.addedTs });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ────────────────────────────────────────── Update ──────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* `dz brain update <slug>` backend (ADR-001 §11 P3 — non-destructive refresh). The project has
|
|
500
|
+
* re-ingested a source's book at a NEW `corpus_version`; this re-reads that source's CURRENT KUs
|
|
501
|
+
* from the PROJECT lexical store ({@link readBookKus} at the project `bookKbPath`, `source=slug`)
|
|
502
|
+
* and re-mirrors them into the brain via the shared {@link mirrorKusToBrain} path.
|
|
503
|
+
*
|
|
504
|
+
* **Non-clobbering + non-destructive:** the per-source stale-corpus eviction inside the reused
|
|
505
|
+
* `putBookKnowledge` upsert evicts THIS source's old-corpus rows and upserts the new ones, while
|
|
506
|
+
* OTHER sources are untouched. The primer is refreshed as part of the mirror. Reports the
|
|
507
|
+
* before/after KU counts (read directly from the brain, so `after` reflects the post-eviction set)
|
|
508
|
+
* and the new `corpusVersion`.
|
|
509
|
+
*
|
|
510
|
+
* Honest failure, no partial state: an unregistered source, or a project with no KUs for it, returns
|
|
511
|
+
* an `error` before any write happens.
|
|
512
|
+
*/
|
|
513
|
+
export async function updateBrainSource(opts: {
|
|
514
|
+
brainHome?: string;
|
|
515
|
+
depsRoot?: string;
|
|
516
|
+
slug: string;
|
|
517
|
+
projectRoot: string;
|
|
518
|
+
addedTs: string;
|
|
519
|
+
}): Promise<{ before: number; after: number; corpusVersion?: string; error?: string }> {
|
|
520
|
+
const home = opts.brainHome ?? brainHome();
|
|
521
|
+
const depsRoot = opts.depsRoot ?? opts.projectRoot;
|
|
522
|
+
|
|
523
|
+
// Gate 1: the source must already be in the registry (update refreshes, it does not add).
|
|
524
|
+
const existing = readRegistry(home).sources[opts.slug];
|
|
525
|
+
if (existing === undefined) {
|
|
526
|
+
return { before: 0, after: 0, error: `source '${opts.slug}' is not registered in the brain (use promote/register first)` };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// `before` = the source's CURRENT row count in the brain (read directly, not the cached registry
|
|
530
|
+
// count) so the delta reflects real state.
|
|
531
|
+
const beforeRead = readBookKus({ storePath: brainBooksPath(home), depsRoot, source: opts.slug });
|
|
532
|
+
const before = beforeRead.kus.length;
|
|
533
|
+
|
|
534
|
+
// Gate 2: the project must hold the re-ingested KUs for THIS source (no partial state on failure).
|
|
535
|
+
const projectDbPath = bookKbPath(opts.projectRoot);
|
|
536
|
+
if (!existsSync(projectDbPath)) {
|
|
537
|
+
return { before, after: before, error: 'no project book KB' };
|
|
538
|
+
}
|
|
539
|
+
const read = readBookKus({ storePath: projectDbPath, depsRoot, source: opts.slug });
|
|
540
|
+
if (read.error !== undefined) return { before, after: before, error: read.error };
|
|
541
|
+
if (read.kus.length === 0) {
|
|
542
|
+
return { before, after: before, error: `project has no KUs for source '${opts.slug}'` };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Re-mirror through the shared path — the stale-corpus eviction makes this refresh non-destructive
|
|
546
|
+
// for this source and non-clobbering for others; the primer is rewritten inside the mirror.
|
|
547
|
+
const mirror = await mirrorKusToBrain({
|
|
548
|
+
home,
|
|
549
|
+
depsRoot,
|
|
550
|
+
kus: read.kus,
|
|
551
|
+
kind: existing.kind,
|
|
552
|
+
addedTs: opts.addedTs,
|
|
553
|
+
...(existing.lang !== undefined ? { lang: existing.lang } : {}),
|
|
554
|
+
...(existing.license !== undefined ? { license: existing.license } : {}),
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// `after` from the brain itself — proves the eviction landed (no orphan old-corpus rows).
|
|
558
|
+
const afterRead = readBookKus({ storePath: brainBooksPath(home), depsRoot, source: opts.slug });
|
|
559
|
+
const after = afterRead.kus.length;
|
|
560
|
+
const newCorpus = read.kus[0]?.corpusVersion;
|
|
561
|
+
|
|
562
|
+
const out: { before: number; after: number; corpusVersion?: string; error?: string } = { before, after };
|
|
563
|
+
if (newCorpus !== undefined && newCorpus !== '') out.corpusVersion = newCorpus;
|
|
564
|
+
if (mirror.error !== undefined) out.error = mirror.error;
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ────────────────────────────────────── Slices / ingest ─────────────────────────────────────
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Export ONE source's KUs from the brain as a STANDALONE lexical-only `books.sqlite` slice at
|
|
572
|
+
* `outPath` (ADR-001 §8.1). Reads via {@link readBookKus} and writes via {@link putBookKnowledge} —
|
|
573
|
+
* lexical only; vectors re-embed on import. This is the portable, per-book shareable unit.
|
|
574
|
+
*/
|
|
575
|
+
export async function exportBrainSlice(opts: {
|
|
576
|
+
brainHome?: string;
|
|
577
|
+
depsRoot?: string;
|
|
578
|
+
slug: string;
|
|
579
|
+
outPath: string;
|
|
580
|
+
}): Promise<{ kuCount: number; error?: string }> {
|
|
581
|
+
const home = opts.brainHome ?? brainHome();
|
|
582
|
+
const depsRoot = opts.depsRoot ?? process.cwd();
|
|
583
|
+
const read = readBookKus({ storePath: brainBooksPath(home), depsRoot, source: opts.slug });
|
|
584
|
+
if (read.error !== undefined) return { kuCount: 0, error: read.error };
|
|
585
|
+
if (read.kus.length === 0) return { kuCount: 0, error: `no KUs for source '${opts.slug}' in brain` };
|
|
586
|
+
const put = await putBookKnowledge(depsRoot, read.kus, { dbPath: opts.outPath });
|
|
587
|
+
if (put.error !== undefined) return { kuCount: 0, error: put.error };
|
|
588
|
+
return { kuCount: read.kus.length };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Import a per-book slice ({@link exportBrainSlice} output, or a pack's `brain/<slug>.sqlite`) into
|
|
593
|
+
* the brain (ADR-001 §8.1). Reads the slice's KUs ({@link readBookKus}) and mirrors them via the same
|
|
594
|
+
* non-clobbering {@link mirrorKusToBrain} path as promote (upsert + re-embed + registry + primer).
|
|
595
|
+
*/
|
|
596
|
+
export async function importBrainSlice(opts: {
|
|
597
|
+
brainHome?: string;
|
|
598
|
+
depsRoot?: string;
|
|
599
|
+
slicePath: string;
|
|
600
|
+
addedTs: string;
|
|
601
|
+
}): Promise<{ sources: string[]; kus: number; error?: string }> {
|
|
602
|
+
const home = opts.brainHome ?? brainHome();
|
|
603
|
+
const depsRoot = opts.depsRoot ?? process.cwd();
|
|
604
|
+
const read = readBookKus({ storePath: opts.slicePath, depsRoot });
|
|
605
|
+
if (read.error !== undefined) return { sources: [], kus: 0, error: read.error };
|
|
606
|
+
if (read.kus.length === 0) return { sources: [], kus: 0 };
|
|
607
|
+
return mirrorKusToBrain({ home, depsRoot, kus: read.kus, kind: 'book', addedTs: opts.addedTs });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Register an array of already-shaped KUs (from a repo deep-walk, §6, or raw JSON) into the brain via
|
|
612
|
+
* the shared {@link mirrorKusToBrain} path — the CLI `--from-kus` backend. `slug` is authoritative:
|
|
613
|
+
* every KU is registered under it (so a repo's `book` field is normalized to the source slug). The
|
|
614
|
+
* registry entry carries `kind` and, for `repo`, `license`.
|
|
615
|
+
*/
|
|
616
|
+
/**
|
|
617
|
+
* SPDX ids the license gate treats as clearly-permissive (auto-pass for repo ingest). Anything else
|
|
618
|
+
* — including an absent license — is refused unless `override:true`. Kept deliberately small and
|
|
619
|
+
* conservative (ADR §8: repos carry their own licenses; the brain must not silently ingest
|
|
620
|
+
* unknown/incompatible source into a redistributable slice).
|
|
621
|
+
*/
|
|
622
|
+
export const PERMISSIVE_LICENSES: readonly string[] = [
|
|
623
|
+
'MIT', 'Apache-2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'ISC', 'Unlicense', 'CC0-1.0', '0BSD', 'MIT-0',
|
|
624
|
+
];
|
|
625
|
+
|
|
626
|
+
export async function registerKusToBrain(opts: {
|
|
627
|
+
brainHome?: string;
|
|
628
|
+
depsRoot?: string;
|
|
629
|
+
kus: readonly BookKU[];
|
|
630
|
+
slug: string;
|
|
631
|
+
kind: 'repo' | 'book' | 'paper';
|
|
632
|
+
addedTs: string;
|
|
633
|
+
lang?: string;
|
|
634
|
+
license?: string;
|
|
635
|
+
override?: boolean;
|
|
636
|
+
}): Promise<{ kus: number; error?: string }> {
|
|
637
|
+
const home = opts.brainHome ?? brainHome();
|
|
638
|
+
const depsRoot = opts.depsRoot ?? process.cwd();
|
|
639
|
+
if (opts.kus.length === 0) return { kus: 0 };
|
|
640
|
+
// License gate (ADR §8) — repo/paper sources must declare a permissive license, else refuse unless
|
|
641
|
+
// explicitly overridden. Books promoted from a digitized pack are exempt (own CP5 flow). No writes
|
|
642
|
+
// happen before this check, so a refusal never leaves a partial brain.
|
|
643
|
+
if (opts.kind !== 'book' && opts.override !== true) {
|
|
644
|
+
const lic = opts.license;
|
|
645
|
+
if (lic === undefined || lic === '') {
|
|
646
|
+
return { kus: 0, error: `refusing to ingest '${opts.slug}' (${opts.kind}) with no --license; pass a permissive SPDX id (${PERMISSIVE_LICENSES.join(', ')}) or --override` };
|
|
647
|
+
}
|
|
648
|
+
if (!PERMISSIVE_LICENSES.includes(lic)) {
|
|
649
|
+
return { kus: 0, error: `refusing to ingest '${opts.slug}': license '${lic}' is not in the permissive allow-list (${PERMISSIVE_LICENSES.join(', ')}); pass --override to ingest anyway` };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Normalize every KU under the source slug so the batch shares one `book` (putBookKnowledge's key).
|
|
653
|
+
const kus = opts.kus.map((k) => (k.book === opts.slug ? k : { ...k, book: opts.slug }));
|
|
654
|
+
const mirror = await mirrorKusToBrain({
|
|
655
|
+
home,
|
|
656
|
+
depsRoot,
|
|
657
|
+
kus,
|
|
658
|
+
kind: opts.kind,
|
|
659
|
+
addedTs: opts.addedTs,
|
|
660
|
+
...(opts.lang !== undefined ? { lang: opts.lang } : {}),
|
|
661
|
+
...(opts.license !== undefined ? { license: opts.license } : {}),
|
|
662
|
+
});
|
|
663
|
+
return mirror.error !== undefined ? { kus: mirror.kus, error: mirror.error } : { kus: mirror.kus };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ──────────────────────────────────────── Rerank ────────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
/** FTS5's default `limit` when none is passed (mirrors {@link queryBookKnowledge}). */
|
|
669
|
+
const DEFAULT_QUERY_LIMIT = 10;
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Field weights for the deterministic reranker (ADR-001 §11 P3 / G3): a query term matched in a
|
|
673
|
+
* KU's `name` outranks one only in its `problem`, which outranks one only in `content`. Because a
|
|
674
|
+
* term contributes its BEST field's weight once, summing over distinct terms folds together (a)
|
|
675
|
+
* term COVERAGE — more distinct query terms present ⇒ a higher sum — and (b) FIELD WEIGHT.
|
|
676
|
+
*/
|
|
677
|
+
const RERANK_FIELD_WEIGHT = { name: 3, problem: 2, content: 1 } as const;
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* KU types that carry a small, deterministic type PRIOR — a decision-grade unit
|
|
681
|
+
* (decision-framework / tradeoff-table / methodology) sits slightly above a bare `definition` when
|
|
682
|
+
* coverage + field weight tie. Kept below the smallest field-weight step (1) so it only breaks
|
|
683
|
+
* near-ties and never overrides a genuinely better term/field match.
|
|
684
|
+
*/
|
|
685
|
+
const RERANK_TYPE_PRIOR = new Set<string>(['decision-framework', 'tradeoff-table', 'methodology']);
|
|
686
|
+
const RERANK_PRIOR_BONUS = 0.5;
|
|
687
|
+
|
|
688
|
+
/** Over-fetch cap for the rerank pass — bounds work while leaving room for a `limit*3` window. */
|
|
689
|
+
const RERANK_OVERFETCH_CAP = 200;
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* A DETERMINISTIC lexical reranker (ADR-001 §11 P3 / G3) — lifts precision on the top-K without a
|
|
693
|
+
* model dependency, so the sync grounding path stays fast + offline. Scores each hit against the
|
|
694
|
+
* query's CONTENT TERMS (reusing {@link contentTerms}) by (a) term COVERAGE, (b) a FIELD WEIGHT
|
|
695
|
+
* (name > problem > content), and (c) a small type PRIOR, then returns the top-`limit` reordered.
|
|
696
|
+
* Fully deterministic: no clock, no random, stable tie-break by `kuId`.
|
|
697
|
+
*
|
|
698
|
+
* This is deliberately lexical: an ML cross-encoder reranker is OUT OF SCOPE for the sync path (it
|
|
699
|
+
* would make grounding slow + online + non-deterministic). A future model reranker is a drop-in
|
|
700
|
+
* swap BEHIND this same `(query, hits, opts) → hits` signature — callers never change.
|
|
701
|
+
*/
|
|
702
|
+
export function rerankHits(query: string, hits: BookKUHit[], opts?: { limit?: number }): BookKUHit[] {
|
|
703
|
+
const terms = contentTerms(query);
|
|
704
|
+
const scored = hits.map((hit, idx) => {
|
|
705
|
+
const name = hit.name.toLowerCase();
|
|
706
|
+
const problem = hit.problem.toLowerCase();
|
|
707
|
+
const content = hit.content.toLowerCase();
|
|
708
|
+
let score = 0;
|
|
709
|
+
for (const t of terms) {
|
|
710
|
+
// Each distinct term contributes its BEST field's weight once (coverage × field weight).
|
|
711
|
+
if (name.includes(t)) score += RERANK_FIELD_WEIGHT.name;
|
|
712
|
+
else if (problem.includes(t)) score += RERANK_FIELD_WEIGHT.problem;
|
|
713
|
+
else if (content.includes(t)) score += RERANK_FIELD_WEIGHT.content;
|
|
714
|
+
}
|
|
715
|
+
if (RERANK_TYPE_PRIOR.has(hit.type)) score += RERANK_PRIOR_BONUS;
|
|
716
|
+
return { hit, score, idx };
|
|
717
|
+
});
|
|
718
|
+
// Deterministic order: score desc, then stable tie-break by kuId (never by input position/clock).
|
|
719
|
+
scored.sort((a, b) => b.score - a.score || a.hit.kuId.localeCompare(b.hit.kuId));
|
|
720
|
+
const limit = opts?.limit ?? hits.length;
|
|
721
|
+
return scored.slice(0, limit).map((s) => s.hit);
|
|
325
722
|
}
|
|
326
723
|
|
|
327
724
|
// ───────────────────────────────────────── Query ────────────────────────────────────────────
|
|
@@ -329,6 +726,9 @@ export async function promoteProjectToBrain(opts: {
|
|
|
329
726
|
/**
|
|
330
727
|
* Cross-source lexical recall over the whole brain — a thin, brain-home-scoped wrapper over
|
|
331
728
|
* {@link queryBookKnowledge}. `source` narrows to one source; default is cross-source. Never throws.
|
|
729
|
+
*
|
|
730
|
+
* `rerank` (default **false** — pure FTS order stays the default so nothing regresses): when true,
|
|
731
|
+
* over-fetch (`limit*3`, capped) then {@link rerankHits} down to `limit` for on-point top-K.
|
|
332
732
|
*/
|
|
333
733
|
export async function queryBrain(opts: {
|
|
334
734
|
query: string;
|
|
@@ -337,14 +737,26 @@ export async function queryBrain(opts: {
|
|
|
337
737
|
source?: string;
|
|
338
738
|
limit?: number;
|
|
339
739
|
match?: 'all' | 'any';
|
|
740
|
+
rerank?: boolean;
|
|
340
741
|
}): Promise<{ hits: BookKUHit[]; error?: string }> {
|
|
341
742
|
const home = opts.brainHome ?? brainHome();
|
|
342
743
|
const depsRoot = opts.depsRoot ?? process.cwd();
|
|
744
|
+
const rerank = opts.rerank === true;
|
|
745
|
+
const effLimit = opts.limit ?? DEFAULT_QUERY_LIMIT;
|
|
746
|
+
|
|
343
747
|
const qopts: { dbPath: string; book?: string; limit?: number; match?: 'all' | 'any' } = { dbPath: brainBooksPath(home) };
|
|
344
748
|
if (opts.source !== undefined) qopts.book = opts.source;
|
|
345
|
-
if (
|
|
749
|
+
if (rerank) {
|
|
750
|
+
// Over-fetch a wider window so the reranker has real candidates to reorder, then trim to limit.
|
|
751
|
+
qopts.limit = Math.min(effLimit * 3, RERANK_OVERFETCH_CAP);
|
|
752
|
+
} else if (opts.limit !== undefined) {
|
|
753
|
+
qopts.limit = opts.limit;
|
|
754
|
+
}
|
|
346
755
|
if (opts.match !== undefined) qopts.match = opts.match;
|
|
347
|
-
|
|
756
|
+
|
|
757
|
+
const res = await queryBookKnowledge(depsRoot, opts.query, qopts);
|
|
758
|
+
if (!rerank || res.error !== undefined) return res;
|
|
759
|
+
return { hits: rerankHits(opts.query, res.hits, { limit: effLimit }) };
|
|
348
760
|
}
|
|
349
761
|
|
|
350
762
|
// ──────────────────────────────────────── Grounding ─────────────────────────────────────────
|
|
@@ -430,10 +842,12 @@ export async function groundPrompt(opts: {
|
|
|
430
842
|
// matching topical term surfaces KUs even when other prompt terms (a verb, filler) miss — the
|
|
431
843
|
// fix for real-prompt silence (AND required every term to co-occur). Rank ordering + top-K keep
|
|
432
844
|
// it precise; the stopword filter keeps generic prompts silent.
|
|
433
|
-
const query: { query: string; brainHome?: string; depsRoot?: string; source?: string; limit: number; match: 'any' } = {
|
|
845
|
+
const query: { query: string; brainHome?: string; depsRoot?: string; source?: string; limit: number; match: 'any'; rerank: true } = {
|
|
434
846
|
query: terms.join(' '),
|
|
435
847
|
limit: opts.k ?? 5,
|
|
436
848
|
match: 'any',
|
|
849
|
+
// Grounding wants the most ON-POINT citation first — pass through the deterministic reranker.
|
|
850
|
+
rerank: true,
|
|
437
851
|
};
|
|
438
852
|
if (opts.brainHome !== undefined) query.brainHome = opts.brainHome;
|
|
439
853
|
if (opts.depsRoot !== undefined) query.depsRoot = opts.depsRoot;
|