@devflow-tools/knowledge-engine 0.16.27 → 0.16.28

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 +446 -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
  }
@@ -384,23 +520,153 @@ export class KnowledgeEngine {
384
520
  const embeddings = await this.embedder.embedBatch(texts);
385
521
  this.store.replaceDocument({ doc, chunks, embeddings });
386
522
  }
523
+ async replaceCompiledSource(input) {
524
+ const sourceSchemaId = input.manifest.sourceSchemaId ?? `schema:${input.source}:v1`;
525
+ const sourceKind = input.manifest.sourceKind ?? 'plugin';
526
+ if (!this.store.getSourceSchema(sourceSchemaId)) {
527
+ this.catalog.registerSourceSchema({
528
+ id: sourceSchemaId,
529
+ sourceId: input.source,
530
+ sourceKind,
531
+ authority: sourceKind === 'official' ? 'official' : 'maintainer',
532
+ versionPolicy: input.version && input.version !== 'current'
533
+ ? { mode: 'exact_major', expectedVersion: input.version }
534
+ : { mode: 'unversioned' },
535
+ allowedEntities: [input.source],
536
+ supportedTaskKinds: ['overview', 'create', 'modify', 'debug', 'test', 'build', 'migrate', 'explain'],
537
+ refreshStrategy: 'on_demand',
538
+ });
539
+ }
540
+ const inputHash = createHash('sha256').update(input.markdown).digest('hex');
541
+ const refresh = this.catalog.beginRefresh(input.source, inputHash);
542
+ try {
543
+ const compiled = this.compiler.compile({
544
+ projectRoot: this.projectRoot,
545
+ sourceId: input.source,
546
+ sourceSchemaId,
547
+ sourceKind,
548
+ sourceUri: input.sourceUri,
549
+ markdown: input.markdown,
550
+ title: input.title,
551
+ version: input.version,
552
+ });
553
+ let embeddings = [];
554
+ if (!input.deferVectorBackfill) {
555
+ const warmed = await this.embedder.warmup();
556
+ if (!warmed) {
557
+ throw this.embedder.getLastFailure?.()
558
+ ?? new Error(this.embedder.getLastError() ?? 'Knowledge embedding warmup failed');
559
+ }
560
+ embeddings = await this.embedder.embedBatch(compiled.chunks.map(chunk => chunk.embeddingText));
561
+ if (embeddings.length !== compiled.chunks.length) {
562
+ throw new Error(`Knowledge embedding count mismatch: ${embeddings.length}/${compiled.chunks.length}`);
563
+ }
564
+ }
565
+ this.store.replaceCompiledPage(compiled.page, compiled.chunks);
566
+ if (embeddings.length > 0) {
567
+ this.store.insertEmbeddingBatch(compiled.chunks.map((chunk, index) => ({
568
+ id: chunk.id,
569
+ embedding: embeddings[index],
570
+ })));
571
+ }
572
+ this.store.deactivateSourceDocuments(input.source, compiled.page.id);
573
+ const vectors = embeddings.length;
574
+ const coverage = compiled.chunks.length > 0 ? vectors / compiled.chunks.length : 0;
575
+ this.catalog.activateSource({
576
+ schemaId: sourceSchemaId,
577
+ version: input.version === 'current' ? undefined : input.version,
578
+ contentHash: compiled.page.contentHash,
579
+ readiness: coverage >= 0.95
580
+ ? {
581
+ status: 'ready', activeContentHash: compiled.page.contentHash,
582
+ documents: 1, chunks: compiled.chunks.length, vectors, coverage,
583
+ lastSuccessfulRefresh: Date.now(), lastAttempt: Date.now(),
584
+ }
585
+ : {
586
+ status: 'degraded', activeContentHash: compiled.page.contentHash,
587
+ documents: 1, chunks: compiled.chunks.length, vectors, coverage,
588
+ lastAttempt: Date.now(),
589
+ degradation: {
590
+ reason: 'vector_backfill_pending', category: 'vector_degraded', recoverable: true,
591
+ previousHealthyRevisionRetained: false,
592
+ },
593
+ },
594
+ entities: [input.source, ...compiled.page.entities],
595
+ symbols: compiled.page.symbols,
596
+ refreshJobId: refresh.id,
597
+ });
598
+ return compiled;
599
+ }
600
+ catch (error) {
601
+ this.catalog.failRefresh(refresh.id, error);
602
+ throw error;
603
+ }
604
+ }
605
+ compileDependency(packageName) {
606
+ return compileDependencySource({
607
+ projectRoot: this.projectRoot,
608
+ packageName,
609
+ store: this.store,
610
+ deferVectorBackfill: true,
611
+ });
612
+ }
613
+ async getProjectProfile() {
614
+ return this.store.getProjectProfile(this.projectRoot) ?? this.catalog.refreshProjectProfile();
615
+ }
616
+ async getSourceCatalog() {
617
+ return this.catalog.getCatalog();
618
+ }
619
+ async getCoverageGaps() {
620
+ return this.store.listCoverageGaps(this.projectRoot);
621
+ }
387
622
  async search(query, options) {
388
623
  return (await this.searchWithStatus(query, options)).entries;
389
624
  }
390
625
  async searchWithStatus(query, options) {
391
626
  const limit = options?.limit ?? 10;
392
- const availableSources = this.store.getDistinctSources();
393
- const selection = selectKnowledgeSources({
627
+ const activeSkill = options?.activeSkill ?? options?.projectSignals?.activeSkill;
628
+ const taskAware = Boolean(options?.action || options?.knowledgeNeeds?.length);
629
+ const projectProfile = this.catalog.refreshProjectProfile({
630
+ activeSkills: activeSkill ? [normalizeActiveSkill(activeSkill)] : [],
631
+ });
632
+ const selectionInput = () => ({
394
633
  query,
395
- availableSources,
634
+ availableSources: this.store.getDistinctSources(),
396
635
  explicitSources: options?.sources,
397
636
  projectRoot: this.projectRoot,
398
637
  projectSignals: {
399
638
  ...options?.projectSignals,
400
- activeSkill: options?.activeSkill ?? options?.projectSignals?.activeSkill,
639
+ activeSkill,
401
640
  relevantFiles: options?.relevantFiles ?? options?.projectSignals?.relevantFiles,
402
641
  },
642
+ projectProfile: taskAware ? projectProfile : null,
643
+ catalog: taskAware ? this.catalog.getCatalog() : null,
644
+ action: options?.action,
645
+ knowledgeNeeds: options?.knowledgeNeeds,
403
646
  });
647
+ let selection = selectKnowledgeSources(selectionInput());
648
+ const compileFailures = [];
649
+ for (const packageName of taskAware
650
+ ? dependencyPackagesForMissingNeeds(selection.missingNeeds, projectProfile.dependencies)
651
+ : []) {
652
+ try {
653
+ this.compileDependency(packageName);
654
+ }
655
+ catch (error) {
656
+ const reason = error instanceof Error ? error.message : String(error);
657
+ this.catalog.recordCoverageGap({
658
+ sourceId: `dependency:${packageName}`,
659
+ kind: 'compile_failure',
660
+ entity: packageName,
661
+ reason,
662
+ });
663
+ compileFailures.push({ sourceId: `dependency:${packageName}`, kind: 'compile_failure', entity: packageName, reason });
664
+ }
665
+ }
666
+ if (selection.missingNeeds.length > 0)
667
+ selection = selectKnowledgeSources(selectionInput());
668
+ for (const gap of selection.coverageGaps)
669
+ this.catalog.recordCoverageGap(gap);
404
670
  this.compatibilityRejectionCount += selection.rejectedSources.length;
405
671
  const sources = selection.sources;
406
672
  const budgetTokens = Math.max(0, options?.budgetTokens ?? DEFAULT_KNOWLEDGE_TOKEN_BUDGET);
@@ -409,17 +675,39 @@ export class KnowledgeEngine {
409
675
  const readinessWarnings = this.store.getSourceReadiness()
410
676
  .filter(row => sources.includes(row.source) && row.status !== 'ready')
411
677
  .map(row => `knowledge-source-degraded:${row.source}:${row.failure_category ?? 'unknown'}`);
678
+ const coverageWarnings = selection.coverageGaps.length > 0 ? ['knowledge_source_missing'] : [];
679
+ if (sources.length === 0) {
680
+ return {
681
+ entries: [],
682
+ warnings: [...coverageWarnings, ...readinessWarnings, 'knowledge_insufficient'],
683
+ budgetTokens,
684
+ tokenCount: 0,
685
+ truncatedCount: 0,
686
+ rejectedIncompatibleSources: selection.rejectedSources.length,
687
+ retrievalDiagnostics: {
688
+ selectedSources: [],
689
+ sourceSignals: selection.signals,
690
+ sourceRejections: selection.rejections,
691
+ coverageGaps: [...selection.coverageGaps, ...compileFailures],
692
+ compatibleChunks: 0,
693
+ hybridCandidates: 0,
694
+ qualityRejected: {},
695
+ },
696
+ };
697
+ }
412
698
  const allChunks = this.store.listChunks({ sources });
413
699
  const chunks = allChunks.filter(chunk => chunk.chunk_policy_version === KNOWLEDGE_CHUNK_POLICY_VERSION
414
700
  && isKnowledgeVersionCompatible(chunk.source, chunk.version, projectVersions));
415
701
  const rejectedByQualityGate = allChunks.length - chunks.length;
416
702
  if (chunks.length === 0) {
417
703
  return {
418
- entries: [], warnings: [...readinessWarnings, 'knowledge_insufficient'], budgetTokens, tokenCount: 0, truncatedCount: 0,
704
+ entries: [], warnings: [...coverageWarnings, ...readinessWarnings, 'knowledge_insufficient'], budgetTokens, tokenCount: 0, truncatedCount: 0,
419
705
  rejectedIncompatibleSources: selection.rejectedSources.length + rejectedByQualityGate,
420
706
  retrievalDiagnostics: {
421
707
  selectedSources: sources,
422
708
  sourceSignals: selection.signals,
709
+ sourceRejections: selection.rejections,
710
+ coverageGaps: [...selection.coverageGaps, ...compileFailures],
423
711
  compatibleChunks: 0,
424
712
  hybridCandidates: 0,
425
713
  qualityRejected: {},
@@ -445,12 +733,14 @@ export class KnowledgeEngine {
445
733
  const packed = this.packEntries(candidates, chunks, budgetTokens);
446
734
  return {
447
735
  ...packed,
448
- warnings: readinessWarnings,
736
+ warnings: [...coverageWarnings, ...readinessWarnings],
449
737
  budgetTokens,
450
738
  rejectedIncompatibleSources: selection.rejectedSources.length + rejectedByQualityGate,
451
739
  retrievalDiagnostics: {
452
740
  selectedSources: sources,
453
741
  sourceSignals: selection.signals,
742
+ sourceRejections: selection.rejections,
743
+ coverageGaps: [...selection.coverageGaps, ...compileFailures],
454
744
  compatibleChunks: chunks.length,
455
745
  hybridCandidates: candidates.length,
456
746
  qualityRejected: {},
@@ -462,19 +752,32 @@ export class KnowledgeEngine {
462
752
  bm25Results = this.store.searchBM25(query, limit * 3, sources);
463
753
  }
464
754
  catch {
465
- // Vector retrieval can still answer malformed FTS queries.
755
+ // Retry below with a bounded literal-term query.
466
756
  }
467
- const hybrid = await this.hybridSearch.searchWithStatus(query, chunks, bm25Results, limit, sources, options?.minScore);
757
+ if (bm25Results.length === 0) {
758
+ const safeQuery = buildSafeFtsQuery(query);
759
+ if (safeQuery) {
760
+ try {
761
+ bm25Results = this.store.searchBM25(safeQuery, limit * 3, sources);
762
+ }
763
+ catch {
764
+ // Vector retrieval can still answer when FTS cannot parse any bounded fallback.
765
+ }
766
+ }
767
+ }
768
+ const hybrid = await this.hybridSearch.searchWithStatus(query, chunks, bm25Results, limit, sources, options?.minScore, options?.knowledgeNeeds);
468
769
  this.lowScoreRejectionCount += hybrid.rejectedLowScore;
469
770
  const chunkMap = new Map(chunks.map(chunk => [chunk.id, chunk]));
470
771
  const qualityRejected = {};
471
- const candidates = hybrid.results.flatMap(result => {
772
+ const seedResults = hybrid.results.flatMap(result => {
472
773
  const chunk = chunkMap.get(result.id);
473
774
  const quality = evaluateKnowledgeQuality({
474
775
  query,
475
776
  chunk,
476
777
  retrievalScore: result.score,
477
778
  versionCompatible: isKnowledgeVersionCompatible(chunk.source, chunk.version, projectVersions),
779
+ action: options?.action,
780
+ knowledgeNeeds: options?.knowledgeNeeds,
478
781
  });
479
782
  if (!quality.eligible) {
480
783
  const reason = quality.reason ?? 'unknown';
@@ -493,40 +796,119 @@ export class KnowledgeEngine {
493
796
  version: chunk.version ?? undefined,
494
797
  embedding: [],
495
798
  score: Math.round(quality.score * 10000) / 10000,
799
+ scoreDetails: {
800
+ ...result.scoreDetails,
801
+ qualityScore: Math.round(quality.score * 10000) / 10000,
802
+ finalScore: Math.round((0.65 * result.scoreDetails.finalScore + 0.35 * quality.score) * 10000) / 10000,
803
+ versionCompatible: true,
804
+ },
496
805
  }];
497
806
  });
807
+ const graphExpansions = expandKnowledgeEvidenceGraph({
808
+ query,
809
+ seeds: seedResults.map(result => ({ id: result.id, score: result.scoreDetails?.finalScore ?? result.score ?? 0 })),
810
+ chunks,
811
+ knowledgeNeeds: options?.knowledgeNeeds,
812
+ limit: Math.max(0, limit - Math.min(limit, seedResults.length)),
813
+ });
814
+ const graphCandidates = graphExpansions.flatMap(expansion => {
815
+ const chunk = chunkMap.get(expansion.id);
816
+ if (!chunk)
817
+ return [];
818
+ const quality = evaluateKnowledgeQuality({
819
+ query,
820
+ chunk,
821
+ retrievalScore: expansion.score,
822
+ versionCompatible: isKnowledgeVersionCompatible(chunk.source, chunk.version, projectVersions),
823
+ action: options?.action,
824
+ knowledgeNeeds: options?.knowledgeNeeds,
825
+ });
826
+ if (!quality.eligible)
827
+ return [];
828
+ return [{
829
+ id: chunk.id,
830
+ source: chunk.source,
831
+ title: resolveKnowledgeTitle({
832
+ headingPath: chunk.heading_path, documentTitle: chunk.document_title, content: chunk.content,
833
+ source: chunk.source, url: chunk.url,
834
+ }) ?? chunk.source,
835
+ content: quality.excerpt,
836
+ excerpt: quality.excerpt,
837
+ matchingTerms: quality.matchingTerms,
838
+ applicability: [...quality.applicability, `graph:${expansion.relation}`],
839
+ url: chunk.url,
840
+ version: chunk.version ?? undefined,
841
+ embedding: [],
842
+ score: expansion.score,
843
+ scoreDetails: {
844
+ profileVersion: KNOWLEDGE_RETRIEVAL_SCORING_PROFILE.version,
845
+ channelRanks: {},
846
+ rawScore: expansion.score, normalizedScore: expansion.score, pageScore: expansion.score,
847
+ qualityScore: quality.score, finalScore: expansion.score,
848
+ authority: authorityFromProvenance(chunk.provenance, chunk.source),
849
+ versionCompatible: true, provenanceComplete: provenanceComplete(chunk.provenance),
850
+ graphRelation: {
851
+ seedId: expansion.seedId, type: expansion.relation, evidence: expansion.relationEvidence,
852
+ },
853
+ },
854
+ }];
855
+ });
856
+ const rankedCandidates = dedupeKnowledgeEntries([...seedResults, ...graphCandidates])
857
+ .sort((left, right) => (right.scoreDetails?.finalScore ?? right.score ?? 0)
858
+ - (left.scoreDetails?.finalScore ?? left.score ?? 0));
859
+ const candidates = addNecessaryAdjacentEntries({
860
+ candidates: rankedCandidates, chunks, query, knowledgeNeeds: options?.knowledgeNeeds ?? [], limit,
861
+ });
498
862
  const packed = this.packEntries(candidates, chunks, budgetTokens);
499
863
  return {
500
864
  ...packed,
501
- warnings: [...hybrid.warnings, ...readinessWarnings, ...(candidates.length === 0 ? ['knowledge_insufficient'] : [])],
865
+ warnings: [...hybrid.warnings, ...coverageWarnings, ...readinessWarnings, ...(candidates.length === 0 ? ['knowledge_insufficient'] : [])],
502
866
  budgetTokens,
503
867
  rejectedLowScore: hybrid.rejectedLowScore,
504
868
  rejectedIncompatibleSources: selection.rejectedSources.length + rejectedByQualityGate,
505
869
  retrievalDiagnostics: {
506
870
  selectedSources: sources,
507
871
  sourceSignals: selection.signals,
872
+ sourceRejections: selection.rejections,
873
+ coverageGaps: [...selection.coverageGaps, ...compileFailures],
508
874
  compatibleChunks: chunks.length,
509
875
  hybridCandidates: hybrid.results.length,
510
876
  qualityRejected,
877
+ channelCandidateCounts: hybrid.channelCandidateCounts,
878
+ graphExpansions: graphCandidates.length,
511
879
  },
512
880
  };
513
881
  }
514
882
  packEntries(candidates, chunks, budgetTokens) {
515
883
  const chunkMap = new Map(chunks.map(chunk => [chunk.id, chunk]));
516
884
  const entries = [];
885
+ const sourceTokens = new Map();
886
+ const pageTokens = new Map();
517
887
  let tokenCount = 0;
518
888
  let truncatedCount = 0;
519
889
  for (const entry of candidates) {
890
+ const chunk = chunkMap.get(entry.id);
520
891
  const entryTokens = (entry.excerpt
521
892
  ? estimateKnowledgeTokens(entry.content)
522
- : chunkMap.get(entry.id)?.token_count ?? estimateKnowledgeTokens(entry.content))
893
+ : chunk?.token_count ?? estimateKnowledgeTokens(entry.content))
523
894
  + estimateKnowledgeTokens(entry.title);
524
- if (tokenCount + entryTokens > budgetTokens) {
895
+ const structuredPage = chunk?.page_id ?? null;
896
+ const source = chunk?.source ?? entry.source;
897
+ const sourceLimit = Math.max(entryTokens, Math.floor(budgetTokens * 0.60));
898
+ const pageLimit = Math.max(entryTokens, Math.floor(budgetTokens * 0.40));
899
+ const overSourceBudget = Boolean(structuredPage)
900
+ && (sourceTokens.get(source) ?? 0) + entryTokens > sourceLimit;
901
+ const overPageBudget = Boolean(structuredPage)
902
+ && (pageTokens.get(structuredPage) ?? 0) + entryTokens > pageLimit;
903
+ if (tokenCount + entryTokens > budgetTokens || overSourceBudget || overPageBudget) {
525
904
  truncatedCount++;
526
905
  continue;
527
906
  }
528
907
  entries.push(entry);
529
908
  tokenCount += entryTokens;
909
+ sourceTokens.set(source, (sourceTokens.get(source) ?? 0) + entryTokens);
910
+ if (structuredPage)
911
+ pageTokens.set(structuredPage, (pageTokens.get(structuredPage) ?? 0) + entryTokens);
530
912
  }
531
913
  return { entries, tokenCount, truncatedCount };
532
914
  }