@devflow-tools/knowledge-engine 0.16.27 → 0.16.29

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.
Files changed (70) hide show
  1. package/dist/context7-fetcher.d.ts +2 -0
  2. package/dist/context7-fetcher.d.ts.map +1 -1
  3. package/dist/context7-fetcher.js +35 -2
  4. package/dist/context7-fetcher.js.map +1 -1
  5. package/dist/dependency-source-compiler.d.ts +24 -0
  6. package/dist/dependency-source-compiler.d.ts.map +1 -0
  7. package/dist/dependency-source-compiler.js +265 -0
  8. package/dist/dependency-source-compiler.js.map +1 -0
  9. package/dist/evidence-graph.d.ts +24 -0
  10. package/dist/evidence-graph.d.ts.map +1 -0
  11. package/dist/evidence-graph.js +82 -0
  12. package/dist/evidence-graph.js.map +1 -0
  13. package/dist/fetcher.d.ts +11 -0
  14. package/dist/fetcher.d.ts.map +1 -1
  15. package/dist/fetcher.js +12 -0
  16. package/dist/fetcher.js.map +1 -1
  17. package/dist/git-fetcher.d.ts +2 -0
  18. package/dist/git-fetcher.d.ts.map +1 -1
  19. package/dist/git-fetcher.js +47 -0
  20. package/dist/git-fetcher.js.map +1 -1
  21. package/dist/hybrid-search.d.ts +6 -2
  22. package/dist/hybrid-search.d.ts.map +1 -1
  23. package/dist/hybrid-search.js +192 -56
  24. package/dist/hybrid-search.js.map +1 -1
  25. package/dist/index.d.ts +14 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +9 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/knowledge-compiler.d.ts +48 -0
  30. package/dist/knowledge-compiler.d.ts.map +1 -0
  31. package/dist/knowledge-compiler.js +121 -0
  32. package/dist/knowledge-compiler.js.map +1 -0
  33. package/dist/knowledge-engine.d.ts +25 -1
  34. package/dist/knowledge-engine.d.ts.map +1 -1
  35. package/dist/knowledge-engine.js +499 -64
  36. package/dist/knowledge-engine.js.map +1 -1
  37. package/dist/knowledge-quality.d.ts +3 -0
  38. package/dist/knowledge-quality.d.ts.map +1 -1
  39. package/dist/knowledge-quality.js +14 -5
  40. package/dist/knowledge-quality.js.map +1 -1
  41. package/dist/knowledge-store.d.ts +61 -0
  42. package/dist/knowledge-store.d.ts.map +1 -1
  43. package/dist/knowledge-store.js +448 -1
  44. package/dist/knowledge-store.js.map +1 -1
  45. package/dist/markdown-converter.d.ts +1 -1
  46. package/dist/markdown-converter.js +1 -1
  47. package/dist/retrieval-scoring-profile.d.ts +20 -0
  48. package/dist/retrieval-scoring-profile.d.ts.map +1 -0
  49. package/dist/retrieval-scoring-profile.js +35 -0
  50. package/dist/retrieval-scoring-profile.js.map +1 -0
  51. package/dist/source-catalog.d.ts +57 -0
  52. package/dist/source-catalog.d.ts.map +1 -0
  53. package/dist/source-catalog.js +351 -0
  54. package/dist/source-catalog.js.map +1 -0
  55. package/dist/source-readiness.d.ts +7 -0
  56. package/dist/source-readiness.d.ts.map +1 -1
  57. package/dist/source-readiness.js +31 -2
  58. package/dist/source-readiness.js.map +1 -1
  59. package/dist/source-selector.d.ts +11 -0
  60. package/dist/source-selector.d.ts.map +1 -1
  61. package/dist/source-selector.js +160 -15
  62. package/dist/source-selector.js.map +1 -1
  63. package/dist/structural-markdown-chunker.d.ts +33 -0
  64. package/dist/structural-markdown-chunker.d.ts.map +1 -0
  65. package/dist/structural-markdown-chunker.js +328 -0
  66. package/dist/structural-markdown-chunker.js.map +1 -0
  67. package/dist/types.d.ts +1 -1
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js.map +1 -1
  70. package/package.json +5 -5
@@ -2,8 +2,8 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
2
2
  import { join } from "path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { tmpdir } from "node:os";
5
- import { fetchHtmlSmart, fetchHtmlBrowser, closeBrowser } from "./fetcher.js";
6
- import { htmlToMarkdown, KNOWLEDGE_CHUNK_POLICY_VERSION, sliceByHeadings } from "./markdown-converter.js";
5
+ import { fetchHtmlKnowledgeSource, closeBrowser } from "./fetcher.js";
6
+ import { KNOWLEDGE_CHUNK_POLICY_VERSION, sliceByHeadings } from "./markdown-converter.js";
7
7
  import { KnowledgeHybridSearch } from "./hybrid-search.js";
8
8
  import { KnowledgeStore } from "./knowledge-store.js";
9
9
  import { EmbeddingProvider } from "@devflow-tools/memory-engine";
@@ -11,22 +11,165 @@ import { BUILTIN_SOURCES } from "./types.js";
11
11
  import { fetchFromGitHub } from "./git-fetcher.js";
12
12
  import { fetchFromContext7 } from "./context7-fetcher.js";
13
13
  import { selectKnowledgeSources } from "./source-selector.js";
14
- import { estimateKnowledgeTokens as estimateTokens, resolveKnowledgeTitle } from "./result-quality.js";
14
+ import { estimateKnowledgeTokens as estimateTokens, lexicalRelevance, resolveKnowledgeTitle, } from "./result-quality.js";
15
15
  import { detectProjectKnowledgeVersions, expectedVersionForSource, isKnowledgeVersionCompatible, } from './source-readiness.js';
16
16
  import { evaluateKnowledgeQuality } from './knowledge-quality.js';
17
+ import { KnowledgeCompiler } from './knowledge-compiler.js';
18
+ import { SourceCatalog } from './source-catalog.js';
19
+ import { compileDependencySource } from './dependency-source-compiler.js';
20
+ import { expandKnowledgeEvidenceGraph } from './evidence-graph.js';
21
+ import { KNOWLEDGE_RETRIEVAL_SCORING_PROFILE } from './retrieval-scoring-profile.js';
17
22
  export const DEFAULT_KNOWLEDGE_TOKEN_BUDGET = 3000;
18
23
  export const estimateKnowledgeTokens = estimateTokens;
24
+ function normalizeActiveSkill(value) {
25
+ return value.normalize('NFKC').trim().replace(/^\/?devflow:/iu, '').toLocaleLowerCase('en-US');
26
+ }
27
+ function dependencyPackagesForMissingNeeds(needs, dependencies) {
28
+ const normalizedDependencies = new Map(Object.keys(dependencies).map(name => [name.toLocaleLowerCase('en-US'), name]));
29
+ const aliases = {
30
+ angular: '@angular/core',
31
+ nest: '@nestjs/core',
32
+ nextjs: 'next',
33
+ node: '@types/node',
34
+ 'node.js': '@types/node',
35
+ tailwind: 'tailwindcss',
36
+ };
37
+ return [...new Set(needs.flatMap(need => {
38
+ if (!need.entity)
39
+ return [];
40
+ const key = need.entity.normalize('NFKC').trim().toLocaleLowerCase('en-US');
41
+ const packageName = normalizedDependencies.get(key)
42
+ ?? normalizedDependencies.get(aliases[key] ?? '');
43
+ return packageName ? [packageName] : [];
44
+ }))];
45
+ }
46
+ function buildSafeFtsQuery(value) {
47
+ const terms = value.normalize('NFKC')
48
+ .match(/[\p{L}\p{N}_@.-]{2,}/gu)
49
+ ?.map(term => term.replace(/^\.+|\.+$/gu, '').replace(/"/gu, ''))
50
+ .filter(Boolean)
51
+ .slice(0, 24) ?? [];
52
+ return [...new Set(terms)].map(term => `"${term}"`).join(' OR ');
53
+ }
54
+ function dedupeKnowledgeEntries(entries) {
55
+ const seen = new Set();
56
+ return entries.filter(entry => {
57
+ if (seen.has(entry.id))
58
+ return false;
59
+ seen.add(entry.id);
60
+ return true;
61
+ });
62
+ }
63
+ function provenanceComplete(value) {
64
+ if (!value)
65
+ return false;
66
+ try {
67
+ const parsed = JSON.parse(value);
68
+ return parsed.immutable === true
69
+ && typeof parsed.sourceId === 'string'
70
+ && typeof parsed.sourceUri === 'string'
71
+ && typeof parsed.contentHash === 'string';
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ function authorityFromProvenance(value, source) {
78
+ if (value) {
79
+ try {
80
+ const parsed = JSON.parse(value);
81
+ if (parsed.sourceKind === 'official')
82
+ return 'official';
83
+ if (parsed.sourceKind === 'project')
84
+ return 'project';
85
+ if (parsed.sourceKind === 'plugin' || parsed.sourceKind === 'dependency')
86
+ return 'maintainer';
87
+ }
88
+ catch {
89
+ // Legacy chunks use source-name authority.
90
+ }
91
+ }
92
+ return /react|typescript|node|vue|angular|mdn|official/iu.test(source) ? 'official' : 'community';
93
+ }
94
+ function addNecessaryAdjacentEntries(input) {
95
+ const chunks = new Map(input.chunks.map(chunk => [chunk.id, chunk]));
96
+ const selected = new Set(input.candidates.map(candidate => candidate.id));
97
+ const additions = [];
98
+ const requiredSymbols = input.knowledgeNeeds.flatMap(need => need.symbol ? [normalizeLookup(need.symbol)] : []);
99
+ const requiredEntities = input.knowledgeNeeds.flatMap(need => need.entity ? [normalizeLookup(need.entity)] : []);
100
+ for (const candidate of input.candidates) {
101
+ if (input.candidates.length + additions.length >= input.limit)
102
+ break;
103
+ const chunk = chunks.get(candidate.id);
104
+ if (!chunk?.page_id)
105
+ continue;
106
+ const neighbors = input.chunks.filter(neighbor => neighbor.page_id === chunk.page_id
107
+ && Math.abs(neighbor.chunk_index - chunk.chunk_index) === 1
108
+ && !selected.has(neighbor.id));
109
+ for (const neighbor of neighbors) {
110
+ const symbols = parseJsonStrings(neighbor.symbols).map(normalizeLookup);
111
+ const entities = parseJsonStrings(neighbor.entities).map(normalizeLookup);
112
+ const preservesNeed = requiredSymbols.some(symbol => symbols.includes(symbol))
113
+ || requiredEntities.some(entity => entities.includes(entity) || normalizeLookup(neighbor.source).includes(entity));
114
+ if (!preservesNeed || lexicalRelevance(input.query, neighbor) < 0.12)
115
+ continue;
116
+ const excerpt = neighbor.content.trim();
117
+ if (!excerpt)
118
+ continue;
119
+ selected.add(neighbor.id);
120
+ additions.push({
121
+ id: neighbor.id,
122
+ source: neighbor.source,
123
+ title: resolveKnowledgeTitle({
124
+ headingPath: neighbor.heading_path, documentTitle: neighbor.document_title, content: neighbor.content,
125
+ source: neighbor.source, url: neighbor.url,
126
+ }) ?? neighbor.source,
127
+ content: excerpt,
128
+ excerpt,
129
+ applicability: ['adjacent:necessary', `page:${neighbor.page_id}`],
130
+ url: neighbor.url,
131
+ version: neighbor.version ?? undefined,
132
+ embedding: [],
133
+ score: Math.max(0.1, (candidate.score ?? 0.4) * 0.7),
134
+ });
135
+ break;
136
+ }
137
+ }
138
+ return [...input.candidates, ...additions]
139
+ .sort((left, right) => (right.scoreDetails?.finalScore ?? right.score ?? 0)
140
+ - (left.scoreDetails?.finalScore ?? left.score ?? 0))
141
+ .slice(0, input.limit);
142
+ }
143
+ function parseJsonStrings(value) {
144
+ if (!value)
145
+ return [];
146
+ try {
147
+ const parsed = JSON.parse(value);
148
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ }
154
+ function normalizeLookup(value) {
155
+ return value.normalize('NFKC').trim().toLocaleLowerCase('en-US');
156
+ }
19
157
  export class KnowledgeEngine {
20
158
  projectRoot;
21
159
  hybridSearch;
22
160
  store;
23
161
  embedder;
162
+ compiler;
163
+ catalog;
24
164
  compatibilityRejectionCount = 0;
25
165
  lowScoreRejectionCount = 0;
26
166
  constructor(opts) {
27
167
  this.projectRoot = opts.projectRoot;
28
168
  this.embedder = opts.embedder ?? new EmbeddingProvider();
29
169
  this.store = new KnowledgeStore(join(this.knowledgeDir, 'knowledge.db'));
170
+ this.compiler = new KnowledgeCompiler(this.store);
171
+ this.catalog = new SourceCatalog(this.store, this.projectRoot);
172
+ this.catalog.refreshProjectProfile();
30
173
  this.hybridSearch = new KnowledgeHybridSearch(this.store, this.embedder);
31
174
  for (const suffix of ['', '-wal', '-shm']) {
32
175
  rmSync(join(this.knowledgeDir, `vectors.db${suffix}`), { force: true });
@@ -42,6 +185,20 @@ export class KnowledgeEngine {
42
185
  const dir = this.pluginDir(source.name);
43
186
  mkdirSync(dir, { recursive: true });
44
187
  const manifestPath = join(dir, "manifest.json");
188
+ const sourceKind = source.pluginName === 'builtin' ? 'official' : 'plugin';
189
+ const sourceSchemaId = `schema:${source.name}:v1`;
190
+ this.catalog.registerSourceSchema({
191
+ id: sourceSchemaId,
192
+ sourceId: source.name,
193
+ sourceKind,
194
+ authority: sourceKind === 'official' ? 'official' : 'maintainer',
195
+ versionPolicy: source.version && source.version !== 'current'
196
+ ? { mode: 'exact_major', expectedVersion: source.version }
197
+ : { mode: 'unversioned' },
198
+ allowedEntities: [source.name],
199
+ supportedTaskKinds: ['overview', 'create', 'modify', 'debug', 'test', 'build', 'migrate', 'explain'],
200
+ refreshStrategy: 'on_demand',
201
+ });
45
202
  if (existsSync(manifestPath)) {
46
203
  const existing = JSON.parse(readFileSync(manifestPath, "utf-8"));
47
204
  // Merge URLs — don't overwrite existing URLs
@@ -55,6 +212,8 @@ export class KnowledgeEngine {
55
212
  sourceUrl: existing.sourceUrl,
56
213
  renderJs: source.renderJs ?? existing.renderJs ?? false,
57
214
  chunkPolicyVersion: existing.chunkPolicyVersion ?? 0,
215
+ sourceSchemaId,
216
+ sourceKind,
58
217
  }, null, 2), "utf-8");
59
218
  }
60
219
  else {
@@ -66,6 +225,8 @@ export class KnowledgeEngine {
66
225
  version: source.version,
67
226
  renderJs: source.renderJs ?? false,
68
227
  chunkPolicyVersion: KNOWLEDGE_CHUNK_POLICY_VERSION,
228
+ sourceSchemaId,
229
+ sourceKind,
69
230
  }, null, 2), "utf-8");
70
231
  }
71
232
  }
@@ -129,29 +290,19 @@ export class KnowledgeEngine {
129
290
  : [{ ...c, title: resolveKnowledgeTitle({ headingPath: c.headingPath, documentTitle: c.title, content: c.content, source: name, url: c.path ?? c.sourceUrl }) }];
130
291
  }).filter(c => Boolean(c.title));
131
292
  if (totalResult.length > 0) {
132
- // Store combined chunks
133
- const docId = `${name}:github-docs`;
134
- const fullContent = totalResult.map(c => c.content).join('\n\n');
135
- const contentHash = createHash('sha256').update(fullContent).digest('hex');
293
+ const fullContent = totalResult.map(c => `# ${c.title ?? name}\n\n${c.content}`).join('\n\n');
136
294
  const ghPath = Array.isArray(sourceConfig.githubDocsPath) ? sourceConfig.githubDocsPath[0] : sourceConfig.githubDocsPath;
137
- const doc = {
138
- id: docId, source: name, url: `https://github.com/${sourceConfig.githubRepo}/tree/main/${ghPath}`,
139
- title: `${name} docs (GitHub)`,
140
- version: sourceConfig.version,
141
- contentHash,
142
- };
143
- const sqliteChunks = totalResult.map((c, i) => {
144
- const chunkId = `${docId}:chunk-${String(i + 1).padStart(3, '0')}`;
145
- return {
146
- id: chunkId, docId, index: i,
147
- headingPath: c.headingPath ?? c.title ?? name,
148
- content: c.content,
149
- codeBlocks: c.codeBlocks,
150
- tokenCount: estimateKnowledgeTokens(c.content),
151
- };
152
- });
295
+ let compiled;
153
296
  try {
154
- await this.replaceDocument(doc, sqliteChunks, options.deferVectorBackfill);
297
+ compiled = await this.replaceCompiledSource({
298
+ source: name,
299
+ manifest,
300
+ sourceUri: `https://github.com/${sourceConfig.githubRepo}/tree/main/${ghPath}`,
301
+ title: `${name} docs (GitHub)`,
302
+ markdown: fullContent,
303
+ version: sourceConfig.version,
304
+ deferVectorBackfill: options.deferVectorBackfill,
305
+ });
155
306
  this.store.deactivateDocument(`${name}:local-policy-v${KNOWLEDGE_CHUNK_POLICY_VERSION}`);
156
307
  }
157
308
  catch (error) {
@@ -179,10 +330,10 @@ export class KnowledgeEngine {
179
330
  ...manifest,
180
331
  urls: manifest.urls,
181
332
  fetchedAt: Date.now(),
182
- chunkCount: totalResult.length,
333
+ chunkCount: compiled.chunks.length,
183
334
  chunkPolicyVersion: KNOWLEDGE_CHUNK_POLICY_VERSION,
184
335
  }, null, 2), "utf-8");
185
- return [{ source: name, added: totalResult.length, updated: 0, errors: [] }];
336
+ return [{ source: name, added: compiled.chunks.length, updated: 0, errors: [] }];
186
337
  }
187
338
  // L1+L2 empty — fall through to L3 HTML
188
339
  }
@@ -194,14 +345,19 @@ export class KnowledgeEngine {
194
345
  const errors = [];
195
346
  let added = 0;
196
347
  try {
197
- const html = manifest.renderJs
198
- ? await fetchHtmlBrowser(url)
199
- : await fetchHtmlSmart(url);
200
- const markdown = htmlToMarkdown(html);
201
- const chunks = sliceByHeadings(markdown);
348
+ const raw = await fetchHtmlKnowledgeSource(url, { renderJs: manifest.renderJs });
349
+ const compiled = await this.replaceCompiledSource({
350
+ source: name,
351
+ manifest,
352
+ sourceUri: raw.sourceUri,
353
+ markdown: raw.markdown,
354
+ version: manifest.version,
355
+ deferVectorBackfill: options.deferVectorBackfill,
356
+ });
357
+ const chunks = compiled.chunks;
202
358
  const chunkFiles = chunks.map((chunk) => ({
203
359
  id: `chunk-${String(nextChunkIndex++).padStart(3, "0")}`,
204
- title: chunk.title,
360
+ title: chunk.headingBreadcrumbs.at(-1) ?? compiled.page.title,
205
361
  section: new URL(url).pathname,
206
362
  content: chunk.content,
207
363
  sourceUrl: url,
@@ -210,26 +366,6 @@ export class KnowledgeEngine {
210
366
  for (const cf of chunkFiles) {
211
367
  writeFileSync(join(dir, `${cf.id}.json`), JSON.stringify(cf, null, 2), "utf-8");
212
368
  }
213
- // Also populate SQLite store
214
- const docId = `${name}:${new URL(url).pathname}`;
215
- const contentHash = createHash('sha256').update(markdown).digest('hex');
216
- const doc = {
217
- id: docId, source: name, url,
218
- title: chunks[0]?.title ?? name,
219
- version: manifest.version,
220
- contentHash,
221
- };
222
- const sqliteChunks = chunks.map((c, i) => {
223
- const chunkId = `${docId}:chunk-${String(i + 1).padStart(3, '0')}`;
224
- return {
225
- id: chunkId, docId, index: i,
226
- headingPath: c.headingPath ?? c.title,
227
- content: c.content,
228
- codeBlocks: c.codeBlocks,
229
- tokenCount: estimateKnowledgeTokens(c.content),
230
- };
231
- });
232
- await this.replaceDocument(doc, sqliteChunks, options.deferVectorBackfill);
233
369
  added = chunkFiles.length;
234
370
  totalChunkCount += added;
235
371
  }
@@ -331,6 +467,29 @@ export class KnowledgeEngine {
331
467
  }));
332
468
  this.store.replaceDocumentChunks({ doc, chunks });
333
469
  const documentsRetired = this.store.deactivateSourceDocuments(name, documentId);
470
+ const vectors = this.store.countEmbeddings(name);
471
+ const coverage = chunks.length > 0 ? vectors / chunks.length : 0;
472
+ this.catalog.activateSource({
473
+ schemaId: manifest.sourceSchemaId ?? `schema:${name}:v1`,
474
+ version,
475
+ contentHash: doc.contentHash,
476
+ readiness: coverage >= 0.95
477
+ ? {
478
+ status: 'ready', activeContentHash: doc.contentHash,
479
+ documents: 1, chunks: chunks.length, vectors, coverage,
480
+ lastSuccessfulRefresh: Date.now(), lastAttempt: Date.now(),
481
+ }
482
+ : {
483
+ status: 'degraded', activeContentHash: doc.contentHash,
484
+ documents: 1, chunks: chunks.length, vectors, coverage,
485
+ lastAttempt: Date.now(),
486
+ degradation: {
487
+ reason: 'vector_backfill_pending', category: 'vector_degraded', recoverable: true,
488
+ previousHealthyRevisionRetained: false,
489
+ },
490
+ },
491
+ entities: [name],
492
+ });
334
493
  this.clearChunkFiles(dir);
335
494
  for (const [index, chunk] of chunks.entries()) {
336
495
  const file = {
@@ -384,23 +543,153 @@ export class KnowledgeEngine {
384
543
  const embeddings = await this.embedder.embedBatch(texts);
385
544
  this.store.replaceDocument({ doc, chunks, embeddings });
386
545
  }
546
+ async replaceCompiledSource(input) {
547
+ const sourceSchemaId = input.manifest.sourceSchemaId ?? `schema:${input.source}:v1`;
548
+ const sourceKind = input.manifest.sourceKind ?? 'plugin';
549
+ if (!this.store.getSourceSchema(sourceSchemaId)) {
550
+ this.catalog.registerSourceSchema({
551
+ id: sourceSchemaId,
552
+ sourceId: input.source,
553
+ sourceKind,
554
+ authority: sourceKind === 'official' ? 'official' : 'maintainer',
555
+ versionPolicy: input.version && input.version !== 'current'
556
+ ? { mode: 'exact_major', expectedVersion: input.version }
557
+ : { mode: 'unversioned' },
558
+ allowedEntities: [input.source],
559
+ supportedTaskKinds: ['overview', 'create', 'modify', 'debug', 'test', 'build', 'migrate', 'explain'],
560
+ refreshStrategy: 'on_demand',
561
+ });
562
+ }
563
+ const inputHash = createHash('sha256').update(input.markdown).digest('hex');
564
+ const refresh = this.catalog.beginRefresh(input.source, inputHash);
565
+ try {
566
+ const compiled = this.compiler.compile({
567
+ projectRoot: this.projectRoot,
568
+ sourceId: input.source,
569
+ sourceSchemaId,
570
+ sourceKind,
571
+ sourceUri: input.sourceUri,
572
+ markdown: input.markdown,
573
+ title: input.title,
574
+ version: input.version,
575
+ });
576
+ let embeddings = [];
577
+ if (!input.deferVectorBackfill) {
578
+ const warmed = await this.embedder.warmup();
579
+ if (!warmed) {
580
+ throw this.embedder.getLastFailure?.()
581
+ ?? new Error(this.embedder.getLastError() ?? 'Knowledge embedding warmup failed');
582
+ }
583
+ embeddings = await this.embedder.embedBatch(compiled.chunks.map(chunk => chunk.embeddingText));
584
+ if (embeddings.length !== compiled.chunks.length) {
585
+ throw new Error(`Knowledge embedding count mismatch: ${embeddings.length}/${compiled.chunks.length}`);
586
+ }
587
+ }
588
+ this.store.replaceCompiledPage(compiled.page, compiled.chunks);
589
+ if (embeddings.length > 0) {
590
+ this.store.insertEmbeddingBatch(compiled.chunks.map((chunk, index) => ({
591
+ id: chunk.id,
592
+ embedding: embeddings[index],
593
+ })));
594
+ }
595
+ this.store.deactivateSourceDocuments(input.source, compiled.page.id);
596
+ const vectors = embeddings.length;
597
+ const coverage = compiled.chunks.length > 0 ? vectors / compiled.chunks.length : 0;
598
+ this.catalog.activateSource({
599
+ schemaId: sourceSchemaId,
600
+ version: input.version === 'current' ? undefined : input.version,
601
+ contentHash: compiled.page.contentHash,
602
+ readiness: coverage >= 0.95
603
+ ? {
604
+ status: 'ready', activeContentHash: compiled.page.contentHash,
605
+ documents: 1, chunks: compiled.chunks.length, vectors, coverage,
606
+ lastSuccessfulRefresh: Date.now(), lastAttempt: Date.now(),
607
+ }
608
+ : {
609
+ status: 'degraded', activeContentHash: compiled.page.contentHash,
610
+ documents: 1, chunks: compiled.chunks.length, vectors, coverage,
611
+ lastAttempt: Date.now(),
612
+ degradation: {
613
+ reason: 'vector_backfill_pending', category: 'vector_degraded', recoverable: true,
614
+ previousHealthyRevisionRetained: false,
615
+ },
616
+ },
617
+ entities: [input.source, ...compiled.page.entities],
618
+ symbols: compiled.page.symbols,
619
+ refreshJobId: refresh.id,
620
+ });
621
+ return compiled;
622
+ }
623
+ catch (error) {
624
+ this.catalog.failRefresh(refresh.id, error);
625
+ throw error;
626
+ }
627
+ }
628
+ compileDependency(packageName) {
629
+ return compileDependencySource({
630
+ projectRoot: this.projectRoot,
631
+ packageName,
632
+ store: this.store,
633
+ deferVectorBackfill: true,
634
+ });
635
+ }
636
+ async getProjectProfile() {
637
+ return this.store.getProjectProfile(this.projectRoot) ?? this.catalog.refreshProjectProfile();
638
+ }
639
+ async getSourceCatalog() {
640
+ return this.catalog.getCatalog();
641
+ }
642
+ async getCoverageGaps() {
643
+ return this.store.listCoverageGaps(this.projectRoot);
644
+ }
387
645
  async search(query, options) {
388
646
  return (await this.searchWithStatus(query, options)).entries;
389
647
  }
390
648
  async searchWithStatus(query, options) {
391
649
  const limit = options?.limit ?? 10;
392
- const availableSources = this.store.getDistinctSources();
393
- const selection = selectKnowledgeSources({
650
+ const activeSkill = options?.activeSkill ?? options?.projectSignals?.activeSkill;
651
+ const taskAware = Boolean(options?.action || options?.knowledgeNeeds?.length);
652
+ const projectProfile = this.catalog.refreshProjectProfile({
653
+ activeSkills: activeSkill ? [normalizeActiveSkill(activeSkill)] : [],
654
+ });
655
+ const selectionInput = () => ({
394
656
  query,
395
- availableSources,
657
+ availableSources: this.store.getDistinctSources(),
396
658
  explicitSources: options?.sources,
397
659
  projectRoot: this.projectRoot,
398
660
  projectSignals: {
399
661
  ...options?.projectSignals,
400
- activeSkill: options?.activeSkill ?? options?.projectSignals?.activeSkill,
662
+ activeSkill,
401
663
  relevantFiles: options?.relevantFiles ?? options?.projectSignals?.relevantFiles,
402
664
  },
665
+ projectProfile: taskAware ? projectProfile : null,
666
+ catalog: taskAware ? this.catalog.getCatalog() : null,
667
+ action: options?.action,
668
+ knowledgeNeeds: options?.knowledgeNeeds,
403
669
  });
670
+ let selection = selectKnowledgeSources(selectionInput());
671
+ const compileFailures = [];
672
+ for (const packageName of taskAware
673
+ ? dependencyPackagesForMissingNeeds(selection.missingNeeds, projectProfile.dependencies)
674
+ : []) {
675
+ try {
676
+ this.compileDependency(packageName);
677
+ }
678
+ catch (error) {
679
+ const reason = error instanceof Error ? error.message : String(error);
680
+ this.catalog.recordCoverageGap({
681
+ sourceId: `dependency:${packageName}`,
682
+ kind: 'compile_failure',
683
+ entity: packageName,
684
+ reason,
685
+ });
686
+ compileFailures.push({ sourceId: `dependency:${packageName}`, kind: 'compile_failure', entity: packageName, reason });
687
+ }
688
+ }
689
+ if (selection.missingNeeds.length > 0)
690
+ selection = selectKnowledgeSources(selectionInput());
691
+ for (const gap of selection.coverageGaps)
692
+ this.catalog.recordCoverageGap(gap);
404
693
  this.compatibilityRejectionCount += selection.rejectedSources.length;
405
694
  const sources = selection.sources;
406
695
  const budgetTokens = Math.max(0, options?.budgetTokens ?? DEFAULT_KNOWLEDGE_TOKEN_BUDGET);
@@ -409,17 +698,39 @@ export class KnowledgeEngine {
409
698
  const readinessWarnings = this.store.getSourceReadiness()
410
699
  .filter(row => sources.includes(row.source) && row.status !== 'ready')
411
700
  .map(row => `knowledge-source-degraded:${row.source}:${row.failure_category ?? 'unknown'}`);
701
+ const coverageWarnings = selection.coverageGaps.length > 0 ? ['knowledge_source_missing'] : [];
702
+ if (sources.length === 0) {
703
+ return {
704
+ entries: [],
705
+ warnings: [...coverageWarnings, ...readinessWarnings, 'knowledge_insufficient'],
706
+ budgetTokens,
707
+ tokenCount: 0,
708
+ truncatedCount: 0,
709
+ rejectedIncompatibleSources: selection.rejectedSources.length,
710
+ retrievalDiagnostics: {
711
+ selectedSources: [],
712
+ sourceSignals: selection.signals,
713
+ sourceRejections: selection.rejections,
714
+ coverageGaps: [...selection.coverageGaps, ...compileFailures],
715
+ compatibleChunks: 0,
716
+ hybridCandidates: 0,
717
+ qualityRejected: {},
718
+ },
719
+ };
720
+ }
412
721
  const allChunks = this.store.listChunks({ sources });
413
722
  const chunks = allChunks.filter(chunk => chunk.chunk_policy_version === KNOWLEDGE_CHUNK_POLICY_VERSION
414
723
  && isKnowledgeVersionCompatible(chunk.source, chunk.version, projectVersions));
415
724
  const rejectedByQualityGate = allChunks.length - chunks.length;
416
725
  if (chunks.length === 0) {
417
726
  return {
418
- entries: [], warnings: [...readinessWarnings, 'knowledge_insufficient'], budgetTokens, tokenCount: 0, truncatedCount: 0,
727
+ entries: [], warnings: [...coverageWarnings, ...readinessWarnings, 'knowledge_insufficient'], budgetTokens, tokenCount: 0, truncatedCount: 0,
419
728
  rejectedIncompatibleSources: selection.rejectedSources.length + rejectedByQualityGate,
420
729
  retrievalDiagnostics: {
421
730
  selectedSources: sources,
422
731
  sourceSignals: selection.signals,
732
+ sourceRejections: selection.rejections,
733
+ coverageGaps: [...selection.coverageGaps, ...compileFailures],
423
734
  compatibleChunks: 0,
424
735
  hybridCandidates: 0,
425
736
  qualityRejected: {},
@@ -445,12 +756,14 @@ export class KnowledgeEngine {
445
756
  const packed = this.packEntries(candidates, chunks, budgetTokens);
446
757
  return {
447
758
  ...packed,
448
- warnings: readinessWarnings,
759
+ warnings: [...coverageWarnings, ...readinessWarnings],
449
760
  budgetTokens,
450
761
  rejectedIncompatibleSources: selection.rejectedSources.length + rejectedByQualityGate,
451
762
  retrievalDiagnostics: {
452
763
  selectedSources: sources,
453
764
  sourceSignals: selection.signals,
765
+ sourceRejections: selection.rejections,
766
+ coverageGaps: [...selection.coverageGaps, ...compileFailures],
454
767
  compatibleChunks: chunks.length,
455
768
  hybridCandidates: candidates.length,
456
769
  qualityRejected: {},
@@ -462,19 +775,32 @@ export class KnowledgeEngine {
462
775
  bm25Results = this.store.searchBM25(query, limit * 3, sources);
463
776
  }
464
777
  catch {
465
- // Vector retrieval can still answer malformed FTS queries.
778
+ // Retry below with a bounded literal-term query.
779
+ }
780
+ if (bm25Results.length === 0) {
781
+ const safeQuery = buildSafeFtsQuery(query);
782
+ if (safeQuery) {
783
+ try {
784
+ bm25Results = this.store.searchBM25(safeQuery, limit * 3, sources);
785
+ }
786
+ catch {
787
+ // Vector retrieval can still answer when FTS cannot parse any bounded fallback.
788
+ }
789
+ }
466
790
  }
467
- const hybrid = await this.hybridSearch.searchWithStatus(query, chunks, bm25Results, limit, sources, options?.minScore);
791
+ const hybrid = await this.hybridSearch.searchWithStatus(query, chunks, bm25Results, limit, sources, options?.minScore, options?.knowledgeNeeds);
468
792
  this.lowScoreRejectionCount += hybrid.rejectedLowScore;
469
793
  const chunkMap = new Map(chunks.map(chunk => [chunk.id, chunk]));
470
794
  const qualityRejected = {};
471
- const candidates = hybrid.results.flatMap(result => {
795
+ const seedResults = hybrid.results.flatMap(result => {
472
796
  const chunk = chunkMap.get(result.id);
473
797
  const quality = evaluateKnowledgeQuality({
474
798
  query,
475
799
  chunk,
476
800
  retrievalScore: result.score,
477
801
  versionCompatible: isKnowledgeVersionCompatible(chunk.source, chunk.version, projectVersions),
802
+ action: options?.action,
803
+ knowledgeNeeds: options?.knowledgeNeeds,
478
804
  });
479
805
  if (!quality.eligible) {
480
806
  const reason = quality.reason ?? 'unknown';
@@ -493,40 +819,119 @@ export class KnowledgeEngine {
493
819
  version: chunk.version ?? undefined,
494
820
  embedding: [],
495
821
  score: Math.round(quality.score * 10000) / 10000,
822
+ scoreDetails: {
823
+ ...result.scoreDetails,
824
+ qualityScore: Math.round(quality.score * 10000) / 10000,
825
+ finalScore: Math.round((0.65 * result.scoreDetails.finalScore + 0.35 * quality.score) * 10000) / 10000,
826
+ versionCompatible: true,
827
+ },
496
828
  }];
497
829
  });
830
+ const graphExpansions = expandKnowledgeEvidenceGraph({
831
+ query,
832
+ seeds: seedResults.map(result => ({ id: result.id, score: result.scoreDetails?.finalScore ?? result.score ?? 0 })),
833
+ chunks,
834
+ knowledgeNeeds: options?.knowledgeNeeds,
835
+ limit: Math.max(0, limit - Math.min(limit, seedResults.length)),
836
+ });
837
+ const graphCandidates = graphExpansions.flatMap(expansion => {
838
+ const chunk = chunkMap.get(expansion.id);
839
+ if (!chunk)
840
+ return [];
841
+ const quality = evaluateKnowledgeQuality({
842
+ query,
843
+ chunk,
844
+ retrievalScore: expansion.score,
845
+ versionCompatible: isKnowledgeVersionCompatible(chunk.source, chunk.version, projectVersions),
846
+ action: options?.action,
847
+ knowledgeNeeds: options?.knowledgeNeeds,
848
+ });
849
+ if (!quality.eligible)
850
+ return [];
851
+ return [{
852
+ id: chunk.id,
853
+ source: chunk.source,
854
+ title: resolveKnowledgeTitle({
855
+ headingPath: chunk.heading_path, documentTitle: chunk.document_title, content: chunk.content,
856
+ source: chunk.source, url: chunk.url,
857
+ }) ?? chunk.source,
858
+ content: quality.excerpt,
859
+ excerpt: quality.excerpt,
860
+ matchingTerms: quality.matchingTerms,
861
+ applicability: [...quality.applicability, `graph:${expansion.relation}`],
862
+ url: chunk.url,
863
+ version: chunk.version ?? undefined,
864
+ embedding: [],
865
+ score: expansion.score,
866
+ scoreDetails: {
867
+ profileVersion: KNOWLEDGE_RETRIEVAL_SCORING_PROFILE.version,
868
+ channelRanks: {},
869
+ rawScore: expansion.score, normalizedScore: expansion.score, pageScore: expansion.score,
870
+ qualityScore: quality.score, finalScore: expansion.score,
871
+ authority: authorityFromProvenance(chunk.provenance, chunk.source),
872
+ versionCompatible: true, provenanceComplete: provenanceComplete(chunk.provenance),
873
+ graphRelation: {
874
+ seedId: expansion.seedId, type: expansion.relation, evidence: expansion.relationEvidence,
875
+ },
876
+ },
877
+ }];
878
+ });
879
+ const rankedCandidates = dedupeKnowledgeEntries([...seedResults, ...graphCandidates])
880
+ .sort((left, right) => (right.scoreDetails?.finalScore ?? right.score ?? 0)
881
+ - (left.scoreDetails?.finalScore ?? left.score ?? 0));
882
+ const candidates = addNecessaryAdjacentEntries({
883
+ candidates: rankedCandidates, chunks, query, knowledgeNeeds: options?.knowledgeNeeds ?? [], limit,
884
+ });
498
885
  const packed = this.packEntries(candidates, chunks, budgetTokens);
499
886
  return {
500
887
  ...packed,
501
- warnings: [...hybrid.warnings, ...readinessWarnings, ...(candidates.length === 0 ? ['knowledge_insufficient'] : [])],
888
+ warnings: [...hybrid.warnings, ...coverageWarnings, ...readinessWarnings, ...(candidates.length === 0 ? ['knowledge_insufficient'] : [])],
502
889
  budgetTokens,
503
890
  rejectedLowScore: hybrid.rejectedLowScore,
504
891
  rejectedIncompatibleSources: selection.rejectedSources.length + rejectedByQualityGate,
505
892
  retrievalDiagnostics: {
506
893
  selectedSources: sources,
507
894
  sourceSignals: selection.signals,
895
+ sourceRejections: selection.rejections,
896
+ coverageGaps: [...selection.coverageGaps, ...compileFailures],
508
897
  compatibleChunks: chunks.length,
509
898
  hybridCandidates: hybrid.results.length,
510
899
  qualityRejected,
900
+ channelCandidateCounts: hybrid.channelCandidateCounts,
901
+ graphExpansions: graphCandidates.length,
511
902
  },
512
903
  };
513
904
  }
514
905
  packEntries(candidates, chunks, budgetTokens) {
515
906
  const chunkMap = new Map(chunks.map(chunk => [chunk.id, chunk]));
516
907
  const entries = [];
908
+ const sourceTokens = new Map();
909
+ const pageTokens = new Map();
517
910
  let tokenCount = 0;
518
911
  let truncatedCount = 0;
519
912
  for (const entry of candidates) {
913
+ const chunk = chunkMap.get(entry.id);
520
914
  const entryTokens = (entry.excerpt
521
915
  ? estimateKnowledgeTokens(entry.content)
522
- : chunkMap.get(entry.id)?.token_count ?? estimateKnowledgeTokens(entry.content))
916
+ : chunk?.token_count ?? estimateKnowledgeTokens(entry.content))
523
917
  + estimateKnowledgeTokens(entry.title);
524
- if (tokenCount + entryTokens > budgetTokens) {
918
+ const structuredPage = chunk?.page_id ?? null;
919
+ const source = chunk?.source ?? entry.source;
920
+ const sourceLimit = Math.max(entryTokens, Math.floor(budgetTokens * 0.60));
921
+ const pageLimit = Math.max(entryTokens, Math.floor(budgetTokens * 0.40));
922
+ const overSourceBudget = Boolean(structuredPage)
923
+ && (sourceTokens.get(source) ?? 0) + entryTokens > sourceLimit;
924
+ const overPageBudget = Boolean(structuredPage)
925
+ && (pageTokens.get(structuredPage) ?? 0) + entryTokens > pageLimit;
926
+ if (tokenCount + entryTokens > budgetTokens || overSourceBudget || overPageBudget) {
525
927
  truncatedCount++;
526
928
  continue;
527
929
  }
528
930
  entries.push(entry);
529
931
  tokenCount += entryTokens;
932
+ sourceTokens.set(source, (sourceTokens.get(source) ?? 0) + entryTokens);
933
+ if (structuredPage)
934
+ pageTokens.set(structuredPage, (pageTokens.get(structuredPage) ?? 0) + entryTokens);
530
935
  }
531
936
  return { entries, tokenCount, truncatedCount };
532
937
  }
@@ -560,6 +965,36 @@ export class KnowledgeEngine {
560
965
  }
561
966
  const versions = detectProjectKnowledgeVersions(this.projectRoot);
562
967
  this.store.refreshSourceReadiness(Object.fromEntries(this.store.getDistinctSources().map(source => [source, expectedVersionForSource(source, versions)])));
968
+ for (const entry of this.catalog.getCatalog().sources) {
969
+ if (options.source && entry.sourceId !== options.source)
970
+ continue;
971
+ const chunks = this.store.countChunks(entry.sourceId);
972
+ const vectors = this.store.countEmbeddings(entry.sourceId);
973
+ const coverage = chunks > 0 ? vectors / chunks : 0;
974
+ this.catalog.activateSource({
975
+ schemaId: entry.schemaId,
976
+ version: entry.version,
977
+ contentHash: entry.contentHash,
978
+ readiness: coverage >= 0.95
979
+ ? {
980
+ status: 'ready', activeContentHash: entry.contentHash,
981
+ documents: entry.readiness.documents, chunks, vectors, coverage,
982
+ lastSuccessfulRefresh: Date.now(), lastAttempt: Date.now(),
983
+ }
984
+ : {
985
+ status: 'degraded', activeContentHash: entry.contentHash,
986
+ documents: entry.readiness.documents, chunks, vectors, coverage,
987
+ lastAttempt: Date.now(),
988
+ degradation: {
989
+ reason: 'vector_backfill_pending', category: 'vector_degraded', recoverable: true,
990
+ previousHealthyRevisionRetained: true,
991
+ },
992
+ },
993
+ entities: entry.entities,
994
+ symbols: entry.symbols,
995
+ taskKinds: entry.taskKinds,
996
+ });
997
+ }
563
998
  return {
564
999
  processed,
565
1000
  remaining: this.store.countChunks(options.source) - this.store.countEmbeddings(options.source),