byterover-cli 3.1.0 → 3.2.0

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 (137) hide show
  1. package/.env.production +4 -0
  2. package/README.md +17 -0
  3. package/dist/agent/infra/agent/agent-schemas.d.ts +8 -0
  4. package/dist/agent/infra/agent/agent-schemas.js +1 -0
  5. package/dist/agent/infra/sandbox/curate-service.js +14 -0
  6. package/dist/agent/infra/sandbox/sandbox-service.js +1 -0
  7. package/dist/agent/infra/sandbox/tools-sdk.d.ts +10 -0
  8. package/dist/agent/infra/sandbox/tools-sdk.js +9 -1
  9. package/dist/agent/infra/tools/implementations/search-knowledge-service.js +214 -101
  10. package/dist/agent/infra/tools/implementations/write-file-tool.d.ts +2 -1
  11. package/dist/agent/infra/tools/implementations/write-file-tool.js +16 -2
  12. package/dist/agent/infra/tools/tool-registry.js +1 -1
  13. package/dist/agent/infra/tools/write-guard.d.ts +11 -0
  14. package/dist/agent/infra/tools/write-guard.js +48 -0
  15. package/dist/agent/resources/prompts/system-prompt.yml +9 -0
  16. package/dist/agent/resources/tools/expand_knowledge.txt +4 -0
  17. package/dist/agent/resources/tools/search_knowledge.txt +11 -1
  18. package/dist/oclif/commands/curate/index.js +4 -3
  19. package/dist/oclif/commands/curate/view.js +2 -2
  20. package/dist/oclif/commands/main.js +13 -0
  21. package/dist/oclif/commands/query.js +4 -3
  22. package/dist/oclif/commands/source/add.d.ts +12 -0
  23. package/dist/oclif/commands/source/add.js +42 -0
  24. package/dist/oclif/commands/source/index.d.ts +6 -0
  25. package/dist/oclif/commands/source/index.js +8 -0
  26. package/dist/oclif/commands/source/list.d.ts +6 -0
  27. package/dist/oclif/commands/source/list.js +32 -0
  28. package/dist/oclif/commands/source/remove.d.ts +9 -0
  29. package/dist/oclif/commands/source/remove.js +33 -0
  30. package/dist/oclif/commands/status.d.ts +5 -1
  31. package/dist/oclif/commands/status.js +41 -6
  32. package/dist/oclif/commands/worktree/add.d.ts +12 -0
  33. package/dist/oclif/commands/worktree/add.js +44 -0
  34. package/dist/oclif/commands/worktree/index.d.ts +6 -0
  35. package/dist/oclif/commands/worktree/index.js +8 -0
  36. package/dist/oclif/commands/worktree/list.d.ts +6 -0
  37. package/dist/oclif/commands/worktree/list.js +28 -0
  38. package/dist/oclif/commands/worktree/remove.d.ts +9 -0
  39. package/dist/oclif/commands/worktree/remove.js +35 -0
  40. package/dist/oclif/hooks/init/validate-brv-config.js +4 -0
  41. package/dist/oclif/lib/daemon-client.d.ts +4 -2
  42. package/dist/oclif/lib/daemon-client.js +19 -3
  43. package/dist/server/constants.d.ts +6 -0
  44. package/dist/server/constants.js +8 -0
  45. package/dist/server/core/domain/client/client-info.d.ts +7 -0
  46. package/dist/server/core/domain/client/client-info.js +11 -0
  47. package/dist/server/core/domain/project/worktrees-schema.d.ts +29 -0
  48. package/dist/server/core/domain/project/worktrees-schema.js +17 -0
  49. package/dist/server/core/domain/source/source-operations.d.ts +31 -0
  50. package/dist/server/core/domain/source/source-operations.js +201 -0
  51. package/dist/server/core/domain/source/source-schema.d.ts +94 -0
  52. package/dist/server/core/domain/source/source-schema.js +121 -0
  53. package/dist/server/core/domain/transport/schemas.d.ts +8 -0
  54. package/dist/server/core/domain/transport/schemas.js +4 -0
  55. package/dist/server/core/domain/transport/task-info.d.ts +2 -0
  56. package/dist/server/core/interfaces/client/i-client-manager.d.ts +13 -0
  57. package/dist/server/core/interfaces/executor/i-curate-executor.d.ts +4 -0
  58. package/dist/server/core/interfaces/executor/i-folder-pack-executor.d.ts +7 -3
  59. package/dist/server/core/interfaces/executor/i-query-executor.d.ts +2 -0
  60. package/dist/server/infra/client/client-manager.d.ts +1 -0
  61. package/dist/server/infra/client/client-manager.js +16 -0
  62. package/dist/server/infra/daemon/agent-process.js +15 -5
  63. package/dist/server/infra/executor/curate-executor.js +4 -2
  64. package/dist/server/infra/executor/direct-search-responder.js +5 -1
  65. package/dist/server/infra/executor/folder-pack-executor.js +23 -12
  66. package/dist/server/infra/executor/query-executor.d.ts +23 -0
  67. package/dist/server/infra/executor/query-executor.js +115 -21
  68. package/dist/server/infra/mcp/mcp-mode-detector.d.ts +7 -5
  69. package/dist/server/infra/mcp/mcp-mode-detector.js +11 -18
  70. package/dist/server/infra/mcp/mcp-server.d.ts +1 -0
  71. package/dist/server/infra/mcp/mcp-server.js +11 -6
  72. package/dist/server/infra/mcp/tools/brv-curate-tool.d.ts +2 -1
  73. package/dist/server/infra/mcp/tools/brv-curate-tool.js +9 -16
  74. package/dist/server/infra/mcp/tools/brv-query-tool.d.ts +2 -1
  75. package/dist/server/infra/mcp/tools/brv-query-tool.js +9 -16
  76. package/dist/server/infra/mcp/tools/mcp-project-context.d.ts +11 -0
  77. package/dist/server/infra/mcp/tools/mcp-project-context.js +54 -0
  78. package/dist/server/infra/process/connection-coordinator.js +11 -0
  79. package/dist/server/infra/process/feature-handlers.js +4 -1
  80. package/dist/server/infra/process/task-router.d.ts +1 -0
  81. package/dist/server/infra/process/task-router.js +60 -5
  82. package/dist/server/infra/project/resolve-project.d.ts +106 -0
  83. package/dist/server/infra/project/resolve-project.js +473 -0
  84. package/dist/server/infra/transport/handlers/index.d.ts +4 -0
  85. package/dist/server/infra/transport/handlers/index.js +2 -0
  86. package/dist/server/infra/transport/handlers/source-handler.d.ts +12 -0
  87. package/dist/server/infra/transport/handlers/source-handler.js +37 -0
  88. package/dist/server/infra/transport/handlers/status-handler.js +55 -13
  89. package/dist/server/infra/transport/handlers/worktree-handler.d.ts +12 -0
  90. package/dist/server/infra/transport/handlers/worktree-handler.js +67 -0
  91. package/dist/server/infra/transport/transport-connector.d.ts +10 -4
  92. package/dist/server/infra/transport/transport-connector.js +2 -2
  93. package/dist/server/utils/path-utils.d.ts +5 -0
  94. package/dist/server/utils/path-utils.js +11 -1
  95. package/dist/shared/transport/events/client-events.d.ts +3 -0
  96. package/dist/shared/transport/events/client-events.js +3 -0
  97. package/dist/shared/transport/events/index.d.ts +13 -0
  98. package/dist/shared/transport/events/index.js +9 -0
  99. package/dist/shared/transport/events/source-events.d.ts +30 -0
  100. package/dist/shared/transport/events/source-events.js +5 -0
  101. package/dist/shared/transport/events/status-events.d.ts +5 -0
  102. package/dist/shared/transport/events/task-events.d.ts +4 -1
  103. package/dist/shared/transport/events/worktree-events.d.ts +31 -0
  104. package/dist/shared/transport/events/worktree-events.js +5 -0
  105. package/dist/shared/transport/types/dto.d.ts +19 -0
  106. package/dist/tui/features/commands/definitions/index.js +6 -0
  107. package/dist/tui/features/commands/definitions/source-add.d.ts +2 -0
  108. package/dist/tui/features/commands/definitions/source-add.js +48 -0
  109. package/dist/tui/features/commands/definitions/source-list.d.ts +2 -0
  110. package/dist/tui/features/commands/definitions/source-list.js +47 -0
  111. package/dist/tui/features/commands/definitions/source-remove.d.ts +2 -0
  112. package/dist/tui/features/commands/definitions/source-remove.js +38 -0
  113. package/dist/tui/features/commands/definitions/source.d.ts +2 -0
  114. package/dist/tui/features/commands/definitions/source.js +8 -0
  115. package/dist/tui/features/commands/definitions/worktree-add.d.ts +2 -0
  116. package/dist/tui/features/commands/definitions/worktree-add.js +35 -0
  117. package/dist/tui/features/commands/definitions/worktree-list.d.ts +2 -0
  118. package/dist/tui/features/commands/definitions/worktree-list.js +36 -0
  119. package/dist/tui/features/commands/definitions/worktree-remove.d.ts +2 -0
  120. package/dist/tui/features/commands/definitions/worktree-remove.js +33 -0
  121. package/dist/tui/features/commands/definitions/worktree.d.ts +2 -0
  122. package/dist/tui/features/commands/definitions/worktree.js +8 -0
  123. package/dist/tui/features/curate/api/create-curate-task.js +3 -1
  124. package/dist/tui/features/query/api/create-query-task.js +3 -1
  125. package/dist/tui/features/source/api/source-api.d.ts +4 -0
  126. package/dist/tui/features/source/api/source-api.js +22 -0
  127. package/dist/tui/features/status/api/get-status.js +2 -1
  128. package/dist/tui/features/status/utils/format-status.js +23 -1
  129. package/dist/tui/features/transport/components/transport-initializer.js +36 -1
  130. package/dist/tui/features/worktree/api/worktree-api.d.ts +4 -0
  131. package/dist/tui/features/worktree/api/worktree-api.js +22 -0
  132. package/dist/tui/repl-startup.d.ts +2 -0
  133. package/dist/tui/repl-startup.js +5 -3
  134. package/dist/tui/stores/transport-store.d.ts +6 -0
  135. package/dist/tui/stores/transport-store.js +6 -0
  136. package/oclif.manifest.json +418 -158
  137. package/package.json +1 -1
@@ -2,17 +2,18 @@ import MiniSearch from 'minisearch';
2
2
  import { realpath } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { removeStopwords } from 'stopword';
5
- import { BRV_DIR, CONTEXT_FILE_EXTENSION, CONTEXT_TREE_DIR, OVERVIEW_EXTENSION, SUMMARY_INDEX_FILE } from '../../../../server/constants.js';
5
+ import { BRV_DIR, CONTEXT_FILE_EXTENSION, CONTEXT_TREE_DIR, OVERVIEW_EXTENSION, SHARED_SOURCE_LOCAL_SCORE_BOOST, SUMMARY_INDEX_FILE, } from '../../../../server/constants.js';
6
6
  import { parseFrontmatterScoring, updateScoringInContent, } from '../../../../server/core/domain/knowledge/markdown-writer.js';
7
7
  import { applyDecay, applyDefaultScoring, compoundScore, determineTier, recordAccessHits, } from '../../../../server/core/domain/knowledge/memory-scoring.js';
8
+ import { loadSources } from '../../../../server/core/domain/source/source-schema.js';
8
9
  import { isArchiveStub, isDerivedArtifact } from '../../../../server/infra/context-tree/derived-artifact.js';
9
- import { parseArchiveStubFrontmatter, parseSummaryFrontmatter } from '../../../../server/infra/context-tree/summary-frontmatter.js';
10
+ import { parseArchiveStubFrontmatter, parseSummaryFrontmatter, } from '../../../../server/infra/context-tree/summary-frontmatter.js';
10
11
  import { isPathLikeQuery, matchMemoryPath, parseSymbolicQuery } from './memory-path-matcher.js';
11
12
  import { buildReferenceIndex, buildSymbolTree, getSubtreeDocumentIds, getSymbolKindLabel, getSymbolOverview, MemorySymbolKind, } from './memory-symbol-tree.js';
12
13
  const MAX_CONTEXT_TREE_FILES = 10_000;
13
14
  const DEFAULT_CACHE_TTL_MS = 5000;
14
15
  /** Bump when MINISEARCH_OPTIONS fields/boost change to invalidate cached indexes */
15
- const INDEX_SCHEMA_VERSION = 4;
16
+ const INDEX_SCHEMA_VERSION = 5;
16
17
  /** Only include results whose normalized score is at least this fraction of the top result's score */
17
18
  const SCORE_GAP_RATIO = 0.7;
18
19
  /** Minimum normalized score for the top result. Below this, the query is considered out-of-domain */
@@ -34,6 +35,12 @@ const CHUNK_OVERLAP_CHARS = 120;
34
35
  function normalizeScore(rawScore) {
35
36
  return rawScore / (1 + rawScore);
36
37
  }
38
+ function getSymbolPath(origin, relativePath) {
39
+ if (origin.origin === 'local') {
40
+ return relativePath;
41
+ }
42
+ return `[${origin.alias ?? origin.originKey}]:${relativePath}`;
43
+ }
37
44
  /**
38
45
  * Propagate BM25 scores upward to parent domain/topic nodes.
39
46
  *
@@ -44,10 +51,11 @@ function normalizeScore(rawScore) {
44
51
  * @param results - Already-enriched search results (gap-ratio filtered)
45
52
  * @param symbolTree - Symbol tree for parent-chain traversal
46
53
  * @param summaryMap - Map of _index.md file paths → SummaryDocLike (for excerpt/metadata)
54
+ * @param symbolPathDocMap - symbolPath → IndexedDocument lookup for context.md fallback
47
55
  * @param propagationFactor - Score multiplier per level up (default 0.55)
48
56
  * @returns New parent entries only — caller merges and re-sorts
49
57
  */
50
- function propagateScoresToParents(results, symbolTree, summaryMap, documentMap, propagationFactor = 0.55) {
58
+ function propagateScoresToParents(results, symbolTree, summaryMap, symbolPathDocMap, propagationFactor = 0.55) {
51
59
  const boosts = new Map();
52
60
  for (const r of results) {
53
61
  const symbol = symbolTree.symbolMap.get(r.path);
@@ -65,7 +73,7 @@ function propagateScoresToParents(results, symbolTree, summaryMap, documentMap,
65
73
  for (const [parentPath, score] of boosts.entries()) {
66
74
  if (existingPaths.has(parentPath))
67
75
  continue;
68
- const doc = getSummarySource(parentPath, summaryMap, documentMap);
76
+ const doc = getSummarySource(parentPath, summaryMap, symbolPathDocMap);
69
77
  if (!doc)
70
78
  continue;
71
79
  // Propagate the strongest child BM25 signal upward, then apply the parent
@@ -97,10 +105,10 @@ const MINISEARCH_OPTIONS = {
97
105
  },
98
106
  storeFields: ['title', 'path'],
99
107
  };
100
- function getSummaryAccessPath(path, summaryMap, documentMap) {
101
- return getSummarySource(path, summaryMap, documentMap)?.path ?? `${path}/${SUMMARY_INDEX_FILE}`;
108
+ function getSummaryAccessPath(path, summaryMap, symbolPathDocMap) {
109
+ return getSummarySource(path, summaryMap, symbolPathDocMap)?.path ?? `${path}/${SUMMARY_INDEX_FILE}`;
102
110
  }
103
- function getSummarySource(path, summaryMap, documentMap) {
111
+ function getSummarySource(path, summaryMap, symbolPathDocMap) {
104
112
  const summaryDoc = summaryMap.get(`${path}/${SUMMARY_INDEX_FILE}`);
105
113
  if (summaryDoc) {
106
114
  return {
@@ -109,7 +117,9 @@ function getSummarySource(path, summaryMap, documentMap) {
109
117
  scoring: summaryDoc.scoring,
110
118
  };
111
119
  }
112
- const contextDoc = documentMap.get(`${path}/context.md`);
120
+ // Look up context.md via symbolPath-keyed map since documentMap keys are
121
+ // origin-qualified (e.g. 'local::path') but callers use symbol tree paths.
122
+ const contextDoc = symbolPathDocMap.get(`${path}/context.md`);
113
123
  if (contextDoc) {
114
124
  return {
115
125
  excerpt: extractExcerpt(contextDoc.content, contextDoc.title),
@@ -276,22 +286,12 @@ function isCacheValid(cache, currentFiles) {
276
286
  }
277
287
  return true;
278
288
  }
279
- async function buildFreshIndex(fileSystem, contextTreePath, filesWithMtime) {
280
- const now = Date.now();
281
- if (filesWithMtime.length === 0) {
282
- const index = new MiniSearch(MINISEARCH_OPTIONS);
283
- return {
284
- contextTreePath,
285
- documentMap: new Map(),
286
- fileMtimes: new Map(),
287
- index,
288
- lastValidatedAt: now,
289
- referenceIndex: { backlinks: new Map(), forwardLinks: new Map() },
290
- schemaVersion: INDEX_SCHEMA_VERSION,
291
- summaryMap: new Map(),
292
- symbolTree: { root: [], symbolMap: new Map() },
293
- };
294
- }
289
+ /**
290
+ * Read and index documents from a single context tree origin.
291
+ * Returns documents with origin-qualified IDs (<originKey>::<path>)
292
+ * and summary docs keyed by origin-qualified paths.
293
+ */
294
+ async function indexOriginDocuments(fileSystem, origin, filesWithMtime) {
295
295
  // Partition files: _index.md → summaryFiles, .overview.md → overviewFiles (for cache
296
296
  // invalidation + sibling detection), other derived artifacts → skip, rest → indexable
297
297
  const summaryFiles = [];
@@ -309,39 +309,45 @@ async function buildFreshIndex(fileSystem, contextTreePath, filesWithMtime) {
309
309
  overviewFiles.push(file);
310
310
  }
311
311
  else if (!isDerivedArtifact(file.path)) {
312
- // Includes regular .md files AND .stub.md files (stubs are searchable)
313
312
  indexableFiles.push(file);
314
313
  }
315
314
  // .full.md, .abstract.md, and _manifest.json are skipped (isDerivedArtifact returns true)
316
315
  }
317
- // Read indexable documents for BM25 index
318
316
  const documentPromises = indexableFiles.map(async ({ mtime, path: filePath }) => {
319
317
  try {
320
- const fullPath = join(contextTreePath, filePath);
318
+ const fullPath = join(origin.contextTreeRoot, filePath);
321
319
  const { content } = await fileSystem.readFile(fullPath);
322
320
  const title = extractTitle(content, filePath.replace(/\.md$/, '').split('/').pop() || filePath);
323
321
  const scoring = parseFrontmatterScoring(content) ?? applyDefaultScoring();
322
+ const qualifiedId = `${origin.originKey}::${filePath}`;
323
+ const symbolPath = getSymbolPath(origin, filePath);
324
324
  // Check if a .overview.md sibling exists (written by abstract generation queue)
325
325
  const overviewRelPath = filePath.replace(/\.md$/, OVERVIEW_EXTENSION);
326
326
  const overviewPath = knownPaths.has(overviewRelPath) ? overviewRelPath : undefined;
327
- return {
327
+ const doc = {
328
328
  content,
329
- id: filePath,
329
+ id: qualifiedId,
330
330
  mtime,
331
+ origin: origin.origin,
332
+ originContextTreeRoot: origin.contextTreeRoot,
333
+ originKey: origin.originKey,
331
334
  ...(overviewPath !== undefined && { overviewPath }),
332
335
  path: filePath,
333
336
  scoring,
337
+ symbolPath,
334
338
  title,
335
339
  };
340
+ if (origin.alias)
341
+ doc.originAlias = origin.alias;
342
+ return doc;
336
343
  }
337
344
  catch {
338
345
  return null;
339
346
  }
340
347
  });
341
- // Read _index.md files separately for summaryMap (not indexed in BM25)
342
348
  const summaryPromises = summaryFiles.map(async ({ path: filePath }) => {
343
349
  try {
344
- const fullPath = join(contextTreePath, filePath);
350
+ const fullPath = join(origin.contextTreeRoot, filePath);
345
351
  const { content } = await fileSystem.readFile(fullPath);
346
352
  const fm = parseSummaryFrontmatter(content);
347
353
  if (!fm)
@@ -354,7 +360,7 @@ async function buildFreshIndex(fileSystem, contextTreePath, filesWithMtime) {
354
360
  return {
355
361
  condensationOrder: fm.condensation_order,
356
362
  excerpt: stripMarkdownFrontmatter(content).slice(0, 400),
357
- path: filePath,
363
+ path: getSymbolPath(origin, filePath),
358
364
  scoring,
359
365
  tokenCount: fm.token_count,
360
366
  };
@@ -363,24 +369,18 @@ async function buildFreshIndex(fileSystem, contextTreePath, filesWithMtime) {
363
369
  return null;
364
370
  }
365
371
  });
366
- const [docResults, summaryResults] = await Promise.all([
367
- Promise.all(documentPromises),
368
- Promise.all(summaryPromises),
369
- ]);
372
+ const [docResults, summaryResults] = await Promise.all([Promise.all(documentPromises), Promise.all(summaryPromises)]);
370
373
  const documents = docResults.filter((doc) => doc !== null);
371
- const documentMap = new Map();
372
374
  const fileMtimes = new Map();
373
375
  for (const doc of documents) {
374
- documentMap.set(doc.id, doc);
375
- fileMtimes.set(doc.path, doc.mtime);
376
+ fileMtimes.set(doc.id, doc.mtime);
376
377
  }
377
- // Also track summary file mtimes for cache invalidation
378
378
  for (const sf of summaryFiles) {
379
- fileMtimes.set(sf.path, sf.mtime);
379
+ fileMtimes.set(`${origin.originKey}::${sf.path}`, sf.mtime);
380
380
  }
381
381
  // Track .overview.md mtimes so the cache invalidates when a new overview is written
382
382
  for (const ov of overviewFiles) {
383
- fileMtimes.set(ov.path, ov.mtime);
383
+ fileMtimes.set(`${origin.originKey}::${ov.path}`, ov.mtime);
384
384
  }
385
385
  const summaryMap = new Map();
386
386
  for (const summary of summaryResults) {
@@ -388,28 +388,87 @@ async function buildFreshIndex(fileSystem, contextTreePath, filesWithMtime) {
388
388
  summaryMap.set(summary.path, summary);
389
389
  }
390
390
  }
391
+ return { documents, fileMtimes, summaryMap };
392
+ }
393
+ async function buildFreshIndex(fileSystem, contextTreePath, localFiles, sharedOrigins, sourcesFileMtime) {
394
+ const now = Date.now();
395
+ // Build the local origin descriptor.
396
+ // Note: `originKey: 'local'` is a string sentinel — shared origins use a 12-char
397
+ // SHA-256 hex hash from `deriveOriginKey()`. Consumers comparing originKey should
398
+ // treat 'local' as a reserved literal, not a hash.
399
+ const localOrigin = {
400
+ contextTreeRoot: contextTreePath,
401
+ origin: 'local',
402
+ originKey: 'local',
403
+ };
404
+ // Index local documents
405
+ const localResult = await indexOriginDocuments(fileSystem, localOrigin, localFiles);
406
+ // Index shared origin documents in parallel
407
+ const sharedResults = await Promise.all(sharedOrigins.map(async (origin) => {
408
+ try {
409
+ const files = await findMarkdownFilesWithMtime(fileSystem, origin.contextTreeRoot);
410
+ const filtered = files.filter((f) => !isDerivedArtifact(f.path) || f.path.split('/').at(-1) === SUMMARY_INDEX_FILE);
411
+ return indexOriginDocuments(fileSystem, origin, filtered);
412
+ }
413
+ catch {
414
+ return { documents: [], fileMtimes: new Map(), summaryMap: new Map() };
415
+ }
416
+ }));
417
+ // Merge all documents, fileMtimes, and summaryMaps
418
+ const allDocuments = [...localResult.documents];
419
+ const fileMtimes = new Map(localResult.fileMtimes);
420
+ const summaryMap = new Map(localResult.summaryMap);
421
+ for (const result of sharedResults) {
422
+ allDocuments.push(...result.documents);
423
+ for (const [key, mtime] of result.fileMtimes) {
424
+ fileMtimes.set(key, mtime);
425
+ }
426
+ for (const [key, summary] of result.summaryMap) {
427
+ summaryMap.set(key, summary);
428
+ }
429
+ }
430
+ const documentMap = new Map();
431
+ const pathToDocumentId = new Map();
432
+ // Reverse lookup: symbolPath → document (for getSummarySource context.md fallback)
433
+ const symbolPathDocMap = new Map();
434
+ for (const doc of allDocuments) {
435
+ documentMap.set(doc.id, doc);
436
+ pathToDocumentId.set(doc.symbolPath, doc.id);
437
+ symbolPathDocMap.set(doc.symbolPath, doc);
438
+ }
391
439
  const index = new MiniSearch(MINISEARCH_OPTIONS);
392
- index.addAll(documents);
393
- // Build symbolic structures from the document map, with summary annotations
394
- const symbolTree = buildSymbolTree(documentMap, summaryMap);
395
- const referenceIndex = buildReferenceIndex(documentMap);
440
+ index.addAll(allDocuments);
441
+ const symbolDocumentMap = new Map();
442
+ for (const doc of allDocuments) {
443
+ symbolDocumentMap.set(doc.id, { ...doc, path: doc.symbolPath });
444
+ }
445
+ const symbolTree = buildSymbolTree(symbolDocumentMap, summaryMap);
446
+ // Reference index only uses local docs — cross-project references are not tracked
447
+ const referenceIndex = buildReferenceIndex(new Map(localResult.documents.map((doc) => [doc.id, doc])));
396
448
  return {
397
449
  contextTreePath,
398
450
  documentMap,
399
451
  fileMtimes,
400
452
  index,
401
453
  lastValidatedAt: now,
454
+ pathToDocumentId,
402
455
  referenceIndex,
403
456
  schemaVersion: INDEX_SCHEMA_VERSION,
457
+ sharedOrigins,
458
+ sourcesFileMtime,
404
459
  summaryMap,
460
+ symbolPathDocMap,
405
461
  symbolTree,
406
462
  };
407
463
  }
408
464
  /**
409
465
  * Acquires the search index, using cached data when valid or building a fresh index.
410
466
  * Uses promise-based locking to prevent duplicate builds during parallel execution.
467
+ *
468
+ * Self-loads knowledge sources from `.brv/sources.json` during each validation
469
+ * cycle, with mtime-based invalidation to detect source additions/removals.
411
470
  */
412
- async function acquireIndex(state, fileSystem, contextTreePath, ttlMs, onBeforeBuild) {
471
+ async function acquireIndex(state, fileSystem, contextTreePath, baseDirectory, ttlMs, onBeforeBuild) {
413
472
  const now = Date.now();
414
473
  // Fast path: TTL-based cache hit (no I/O needed)
415
474
  if (state.cachedIndex &&
@@ -425,6 +484,23 @@ async function acquireIndex(state, fileSystem, contextTreePath, ttlMs, onBeforeB
425
484
  }
426
485
  // Create and store the build promise SYNCHRONOUSLY before any await
427
486
  // This prevents race conditions where multiple parallel calls all start building
487
+ const emptyResult = () => {
488
+ const emptyIndex = new MiniSearch(MINISEARCH_OPTIONS);
489
+ return {
490
+ contextTreePath: '',
491
+ documentMap: new Map(),
492
+ fileMtimes: new Map(),
493
+ index: emptyIndex,
494
+ lastValidatedAt: 0,
495
+ pathToDocumentId: new Map(),
496
+ referenceIndex: { backlinks: new Map(), forwardLinks: new Map() },
497
+ schemaVersion: INDEX_SCHEMA_VERSION,
498
+ sharedOrigins: [],
499
+ summaryMap: new Map(),
500
+ symbolPathDocMap: new Map(),
501
+ symbolTree: { root: [], symbolMap: new Map() },
502
+ };
503
+ };
428
504
  const buildPromise = (async () => {
429
505
  // Check if context tree exists (only if no cache or different path)
430
506
  if (!state.cachedIndex || state.cachedIndex.contextTreePath !== contextTreePath) {
@@ -432,21 +508,13 @@ async function acquireIndex(state, fileSystem, contextTreePath, ttlMs, onBeforeB
432
508
  await fileSystem.listDirectory(contextTreePath);
433
509
  }
434
510
  catch {
435
- // Return empty index to signal error - caller will handle
436
- const emptyIndex = new MiniSearch(MINISEARCH_OPTIONS);
437
- return {
438
- contextTreePath: '',
439
- documentMap: new Map(),
440
- fileMtimes: new Map(),
441
- index: emptyIndex,
442
- lastValidatedAt: 0,
443
- referenceIndex: { backlinks: new Map(), forwardLinks: new Map() },
444
- schemaVersion: INDEX_SCHEMA_VERSION,
445
- summaryMap: new Map(),
446
- symbolTree: { root: [], symbolMap: new Map() },
447
- };
511
+ return emptyResult();
448
512
  }
449
513
  }
514
+ // Self-load knowledge sources — mtime-based invalidation
515
+ const loadedSources = loadSources(baseDirectory);
516
+ const sourcesFileMtime = loadedSources?.mtime;
517
+ const sharedOrigins = loadedSources?.origins ?? [];
450
518
  let allFiles = await findMarkdownFilesWithMtime(fileSystem, contextTreePath);
451
519
  // Exclude non-indexable derived artifacts (.full.md) so that currentFiles
452
520
  // matches what buildFreshIndex tracks in fileMtimes. Without this filter,
@@ -454,7 +522,7 @@ async function acquireIndex(state, fileSystem, contextTreePath, ttlMs, onBeforeB
454
522
  // _index.md is kept (tracked for summary staleness), .stub.md is kept (BM25 indexed).
455
523
  // Keep _index.md (summary tracking) and .overview.md (sibling detection for overviewPath).
456
524
  // .full.md, .abstract.md, and _manifest.json remain excluded.
457
- let currentFiles = allFiles.filter((f) => !isDerivedArtifact(f.path) ||
525
+ let localFiles = allFiles.filter((f) => !isDerivedArtifact(f.path) ||
458
526
  f.path.split('/').at(-1) === SUMMARY_INDEX_FILE ||
459
527
  f.path.endsWith(OVERVIEW_EXTENSION));
460
528
  // Flush pending access hits before reusing a stale-enough cache entry.
@@ -463,16 +531,32 @@ async function acquireIndex(state, fileSystem, contextTreePath, ttlMs, onBeforeB
463
531
  const wroteScoringUpdates = await onBeforeBuild(contextTreePath);
464
532
  if (wroteScoringUpdates) {
465
533
  allFiles = await findMarkdownFilesWithMtime(fileSystem, contextTreePath);
466
- currentFiles = allFiles.filter((f) => !isDerivedArtifact(f.path) ||
534
+ localFiles = allFiles.filter((f) => !isDerivedArtifact(f.path) ||
467
535
  f.path.split('/').at(-1) === SUMMARY_INDEX_FILE ||
468
536
  f.path.endsWith(OVERVIEW_EXTENSION));
469
537
  }
470
538
  }
471
- // Re-check cache validity after getting file list (another call may have finished)
472
- if (state.cachedIndex &&
539
+ // Qualify local file mtime keys with 'local::' prefix to match buildFreshIndex
540
+ const qualifiedLocalFiles = localFiles.map((f) => ({ mtime: f.mtime, path: `local::${f.path}` }));
541
+ // Glob shared origin files for cache validation (detect edits in shared projects)
542
+ const sharedFileArrays = await Promise.all(sharedOrigins.map(async (origin) => {
543
+ try {
544
+ const files = await findMarkdownFilesWithMtime(fileSystem, origin.contextTreeRoot);
545
+ const filtered = files.filter((f) => !isDerivedArtifact(f.path) || f.path.split('/').at(-1) === SUMMARY_INDEX_FILE);
546
+ return filtered.map((f) => ({ mtime: f.mtime, path: `${origin.originKey}::${f.path}` }));
547
+ }
548
+ catch {
549
+ return [];
550
+ }
551
+ }));
552
+ const allQualifiedFiles = [...qualifiedLocalFiles, ...sharedFileArrays.flat()];
553
+ // Re-check cache validity: local files + shared files + sources-file mtime must match
554
+ const sourcesFileChanged = state.cachedIndex?.sourcesFileMtime !== sourcesFileMtime;
555
+ if (!sourcesFileChanged &&
556
+ state.cachedIndex &&
473
557
  state.cachedIndex.contextTreePath === contextTreePath &&
474
558
  state.cachedIndex.schemaVersion === INDEX_SCHEMA_VERSION &&
475
- isCacheValid(state.cachedIndex, currentFiles)) {
559
+ isCacheValid(state.cachedIndex, allQualifiedFiles)) {
476
560
  // Update timestamp atomically by creating a new object
477
561
  const updatedCache = {
478
562
  ...state.cachedIndex,
@@ -481,8 +565,8 @@ async function acquireIndex(state, fileSystem, contextTreePath, ttlMs, onBeforeB
481
565
  state.cachedIndex = updatedCache;
482
566
  return updatedCache;
483
567
  }
484
- // Build fresh index
485
- const freshIndex = await buildFreshIndex(fileSystem, contextTreePath, currentFiles);
568
+ // Build fresh index with local + shared origins
569
+ const freshIndex = await buildFreshIndex(fileSystem, contextTreePath, localFiles, sharedOrigins, sourcesFileMtime);
486
570
  state.cachedIndex = freshIndex;
487
571
  return freshIndex;
488
572
  })();
@@ -568,12 +652,12 @@ export class SearchKnowledgeService {
568
652
  const resolvedBaseDirectory = await realpath(this.baseDirectory).catch(() => this.baseDirectory);
569
653
  const contextTreePath = join(resolvedBaseDirectory, BRV_DIR, CONTEXT_TREE_DIR);
570
654
  // Acquire index with parallel-safe locking; flush pending access hits before any rebuild
571
- const indexResult = await acquireIndex(this.state, this.fileSystem, contextTreePath, this.cacheTtlMs, (ctxPath) => this.flushAccessHits(ctxPath));
655
+ const indexResult = await acquireIndex(this.state, this.fileSystem, contextTreePath, this.baseDirectory, this.cacheTtlMs, (ctxPath) => this.flushAccessHits(ctxPath));
572
656
  // Handle error case (context tree not initialized)
573
657
  if ('error' in indexResult) {
574
658
  return indexResult.result;
575
659
  }
576
- const { documentMap, index, referenceIndex, summaryMap, symbolTree } = indexResult;
660
+ const { documentMap, index, pathToDocumentId, referenceIndex, summaryMap, symbolPathDocMap, symbolTree } = indexResult;
577
661
  if (documentMap.size === 0) {
578
662
  return {
579
663
  message: 'Context tree is empty. Use /curate to add knowledge.',
@@ -587,7 +671,7 @@ export class SearchKnowledgeService {
587
671
  }
588
672
  // Symbolic path resolution: try path-based query first
589
673
  if (isPathLikeQuery(query, symbolTree)) {
590
- const symbolicResult = this.trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, summaryMap, options);
674
+ const symbolicResult = this.trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, pathToDocumentId, summaryMap, symbolPathDocMap, options);
591
675
  if (symbolicResult) {
592
676
  return symbolicResult;
593
677
  }
@@ -597,10 +681,10 @@ export class SearchKnowledgeService {
597
681
  const effectiveScope = options?.scope ?? parsed.scopePath;
598
682
  const effectiveQuery = parsed.scopePath ? parsed.textQuery : query;
599
683
  // Run text-based MiniSearch (existing pipeline), optionally scoped to a subtree
600
- const textResult = this.runTextSearch(effectiveQuery || query, documentMap, index, limit, effectiveScope, symbolTree, referenceIndex, summaryMap, options);
684
+ const textResult = this.runTextSearch(effectiveQuery || query, documentMap, index, limit, effectiveScope, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options);
601
685
  // If scoped search returned nothing and we had a scope, fall back to global search
602
686
  if (textResult.results.length === 0 && effectiveScope && effectiveQuery) {
603
- return this.runTextSearch(query, documentMap, index, limit, undefined, symbolTree, referenceIndex, summaryMap, options);
687
+ return this.runTextSearch(query, documentMap, index, limit, undefined, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options);
604
688
  }
605
689
  return textResult;
606
690
  }
@@ -636,14 +720,15 @@ export class SearchKnowledgeService {
636
720
  * For archive stubs, extracts points_to path into archiveFullPath.
637
721
  */
638
722
  enrichResult(result, symbolTree, referenceIndex, documentMap) {
639
- const symbol = symbolTree.symbolMap.get(result.path);
723
+ const doc = documentMap.get(result.id);
724
+ const symbolPath = doc?.symbolPath ?? result.path;
725
+ const symbol = symbolTree.symbolMap.get(symbolPath);
640
726
  const backlinks = referenceIndex.backlinks.get(result.path);
641
727
  // Detect archive stubs and extract points_to for drill-down
642
728
  let archiveFullPath;
643
729
  let symbolKind = symbol ? getSymbolKindLabel(symbol.kind) : undefined;
644
730
  if (isArchiveStub(result.path)) {
645
731
  symbolKind = 'archive_stub';
646
- const doc = documentMap.get(result.path);
647
732
  if (doc) {
648
733
  const stubFm = parseArchiveStubFrontmatter(doc.content);
649
734
  if (stubFm) {
@@ -651,17 +736,26 @@ export class SearchKnowledgeService {
651
736
  }
652
737
  }
653
738
  }
654
- const doc = documentMap.get(result.path);
739
+ // Origin metadata for shared-source results
740
+ const origin = doc?.origin;
741
+ const originAlias = doc?.originAlias;
742
+ const originContextTreeRoot = doc?.origin === 'shared' ? doc.originContextTreeRoot : undefined;
655
743
  const overviewPath = doc?.overviewPath;
656
744
  const isContextSummary = doc?.path.endsWith('/context.md') || doc?.path === 'context.md';
657
745
  const summaryPath = isContextSummary
658
746
  ? doc?.path.slice(0, -'/context.md'.length) || doc?.path || result.path
659
747
  : result.path;
748
+ // Destructure to strip `id` from output — not part of SearchKnowledgeResult
749
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
750
+ const { id: _id, ...rest } = result;
660
751
  return {
661
- ...result,
752
+ ...rest,
662
753
  ...(archiveFullPath && { archiveFullPath }),
663
754
  ...(overviewPath && { overviewPath }),
664
755
  backlinkCount: backlinks?.length ?? 0,
756
+ ...(origin && { origin }),
757
+ ...(originAlias && { originAlias }),
758
+ ...(originContextTreeRoot && { originContextTreeRoot }),
665
759
  ...(isContextSummary && { path: summaryPath }),
666
760
  relatedPaths: backlinks?.slice(0, 3),
667
761
  symbolKind: isContextSummary ? 'summary' : symbolKind,
@@ -671,15 +765,22 @@ export class SearchKnowledgeService {
671
765
  /**
672
766
  * Run the standard text-based MiniSearch pipeline, optionally scoped to a subtree.
673
767
  */
674
- runTextSearch(query, documentMap, index, limit, scopePath, symbolTree, referenceIndex, summaryMap, options) {
768
+ runTextSearch(query, documentMap, index, limit, scopePath, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options) {
675
769
  const filteredQuery = filterStopWords(query);
676
770
  const filteredWords = filteredQuery.split(/\s+/).filter((w) => w.length >= 2);
677
771
  // Build scope filter if a subtree is specified
678
772
  let scopeFilter;
679
773
  if (scopePath) {
680
- const subtreeIds = getSubtreeDocumentIds(symbolTree, scopePath);
681
- if (subtreeIds.size > 0) {
682
- scopeFilter = (result) => subtreeIds.has(result.id);
774
+ const subtreePaths = getSubtreeDocumentIds(symbolTree, scopePath);
775
+ const subtreeQualifiedIds = new Set();
776
+ for (const symbolPath of subtreePaths) {
777
+ const docId = pathToDocumentId.get(symbolPath);
778
+ if (docId) {
779
+ subtreeQualifiedIds.add(docId);
780
+ }
781
+ }
782
+ if (subtreeQualifiedIds.size > 0) {
783
+ scopeFilter = (result) => subtreeQualifiedIds.has(result.id);
683
784
  }
684
785
  }
685
786
  // AND-first strategy: for multi-word queries, try AND for concentrated scores.
@@ -699,6 +800,7 @@ export class SearchKnowledgeService {
699
800
  }
700
801
  // Normalize BM25 scores to [0, 1) then blend with importance + recency via compound scoring.
701
802
  // Decay is computed lazily from file mtime — no disk writes during search.
803
+ // Local results get a configurable score boost to prefer local knowledge over shared.
702
804
  const now = Date.now();
703
805
  const searchResults = rawResults.map((r) => {
704
806
  const doc = documentMap.get(r.id);
@@ -706,10 +808,15 @@ export class SearchKnowledgeService {
706
808
  const daysSince = doc ? Math.max(0, (now - doc.mtime) / 86_400_000) : 0;
707
809
  const decayed = applyDecay(scoring, daysSince);
708
810
  const bm25 = normalizeScore(r.score);
811
+ let finalScore = compoundScore(bm25, decayed.importance ?? 50, decayed.recency ?? 1, decayed.maturity ?? 'draft');
812
+ // Local score boost: prefer local results over shared when scores are close
813
+ if (doc?.origin === 'local') {
814
+ finalScore = Math.min(finalScore + SHARED_SOURCE_LOCAL_SCORE_BOOST, 1);
815
+ }
709
816
  return {
710
817
  ...r,
711
818
  bm25Score: bm25,
712
- score: compoundScore(bm25, decayed.importance ?? 50, decayed.recency ?? 1, decayed.maturity ?? 'draft'),
819
+ score: finalScore,
713
820
  };
714
821
  });
715
822
  searchResults.sort((a, b) => b.score - a.score);
@@ -752,6 +859,7 @@ export class SearchKnowledgeService {
752
859
  if (document) {
753
860
  const enriched = this.enrichResult({
754
861
  excerpt: extractExcerpt(document.content, query),
862
+ id: result.id,
755
863
  path: document.path,
756
864
  score: Math.round(result.score * 100) / 100,
757
865
  title: document.title,
@@ -765,10 +873,10 @@ export class SearchKnowledgeService {
765
873
  }
766
874
  if (options?.minMaturity && enriched.symbolKind) {
767
875
  const docMaturity = enriched.symbolKind === 'summary'
768
- ? getSummarySource(enriched.path, summaryMap, documentMap)?.scoring?.maturity
769
- ?? symbolTree.symbolMap.get(enriched.path)?.metadata.maturity
770
- ?? 'draft'
771
- : symbolTree.symbolMap.get(document.path)?.metadata.maturity ?? 'draft';
876
+ ? (getSummarySource(enriched.path, summaryMap, symbolPathDocMap)?.scoring?.maturity ??
877
+ symbolTree.symbolMap.get(enriched.path)?.metadata.maturity ??
878
+ 'draft')
879
+ : (symbolTree.symbolMap.get(document.symbolPath)?.metadata.maturity ?? 'draft');
772
880
  if ((MATURITY_TIER_RANK[docMaturity] ?? 1) < (MATURITY_TIER_RANK[options.minMaturity] ?? 1)) {
773
881
  continue;
774
882
  }
@@ -776,14 +884,17 @@ export class SearchKnowledgeService {
776
884
  results.push(enriched);
777
885
  propagationInputs.push({
778
886
  bm25Score: result.bm25Score,
779
- path: document.path,
887
+ path: document.symbolPath,
780
888
  });
781
889
  }
782
890
  }
783
891
  }
784
892
  // Propagate scores upward to parent domain/topic nodes (hierarchical retrieval)
785
- const propagated = propagateScoresToParents(propagationInputs, symbolTree, summaryMap, documentMap);
893
+ const propagated = propagateScoresToParents(propagationInputs, symbolTree, summaryMap, symbolPathDocMap);
786
894
  for (const p of propagated) {
895
+ // Apply local score boost to propagated summaries so they stay competitive
896
+ // with boosted direct BM25 hits (the boost was already applied to direct hits above)
897
+ p.score = Math.min(p.score + SHARED_SOURCE_LOCAL_SCORE_BOOST, 1);
787
898
  if (scoreFloor !== undefined && p.score < scoreFloor)
788
899
  continue;
789
900
  if (options?.includeKinds && p.symbolKind && !options.includeKinds.includes(p.symbolKind))
@@ -791,7 +902,7 @@ export class SearchKnowledgeService {
791
902
  if (options?.excludeKinds && p.symbolKind && options.excludeKinds.includes(p.symbolKind))
792
903
  continue;
793
904
  if (options?.minMaturity && p.symbolKind === 'summary') {
794
- const summaryDoc = getSummarySource(p.path, summaryMap, documentMap);
905
+ const summaryDoc = getSummarySource(p.path, summaryMap, symbolPathDocMap);
795
906
  const summaryMaturity = summaryDoc?.scoring?.maturity ?? 'draft';
796
907
  if ((MATURITY_TIER_RANK[summaryMaturity] ?? 1) < (MATURITY_TIER_RANK[options.minMaturity] ?? 1))
797
908
  continue;
@@ -808,9 +919,7 @@ export class SearchKnowledgeService {
808
919
  // Synthetic 'summary' results carry folder-style paths (e.g. 'auth') that are not
809
920
  // real files; map them to their _index.md so flushAccessHits can read and update them.
810
921
  if (results.length > 0) {
811
- this.accumulateAccessHits(results.map((r) => (r.symbolKind === 'summary'
812
- ? getSummaryAccessPath(r.path, summaryMap, documentMap)
813
- : r.path)));
922
+ this.accumulateAccessHits(results.map((r) => r.symbolKind === 'summary' ? getSummaryAccessPath(r.path, summaryMap, symbolPathDocMap) : r.path));
814
923
  }
815
924
  return {
816
925
  message: results.length > 0
@@ -823,7 +932,7 @@ export class SearchKnowledgeService {
823
932
  /**
824
933
  * Try to resolve the query as a symbolic path. Returns null if no path match found.
825
934
  */
826
- trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, summaryMap, options) {
935
+ trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, pathToDocumentId, summaryMap, symbolPathDocMap, options) {
827
936
  const pathMatches = matchMemoryPath(symbolTree, query.split(/\s+/)[0].includes('/') ? query.split(/\s+/)[0] : query);
828
937
  if (pathMatches.length === 0) {
829
938
  return null;
@@ -831,12 +940,15 @@ export class SearchKnowledgeService {
831
940
  const topMatch = pathMatches[0].matchedSymbol;
832
941
  // If the matched symbol is a leaf Context, return it directly
833
942
  if (topMatch.kind === MemorySymbolKind.Context) {
834
- const doc = documentMap.get(topMatch.path);
943
+ const docId = pathToDocumentId.get(topMatch.path);
944
+ const doc = docId ? documentMap.get(docId) : undefined;
835
945
  if (!doc) {
836
946
  return null;
837
947
  }
838
- const result = this.enrichResult({ excerpt: extractExcerpt(doc.content, query), path: doc.path, score: 1, title: doc.title }, symbolTree, referenceIndex, documentMap);
839
- this.accumulateAccessHits([doc.path]);
948
+ const result = this.enrichResult({ excerpt: extractExcerpt(doc.content, query), id: doc.id, path: doc.path, score: 1, title: doc.title }, symbolTree, referenceIndex, documentMap);
949
+ if (doc.origin === 'local') {
950
+ this.accumulateAccessHits([doc.path]);
951
+ }
840
952
  return {
841
953
  message: `Found exact match: ${topMatch.path}`,
842
954
  results: [result],
@@ -849,13 +961,13 @@ export class SearchKnowledgeService {
849
961
  const textPart = query.slice(query.indexOf(pathPart) + pathPart.length).trim();
850
962
  if (textPart) {
851
963
  // Scoped search: search text within the matched subtree
852
- return this.runTextSearch(textPart, documentMap, index, limit, topMatch.path, symbolTree, referenceIndex, summaryMap, options);
964
+ return this.runTextSearch(textPart, documentMap, index, limit, topMatch.path, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options);
853
965
  }
854
966
  // No text part — return all children of the matched node
855
967
  const subtreeIds = getSubtreeDocumentIds(symbolTree, topMatch.path);
856
968
  const results = [];
857
969
  const accessHitPaths = [];
858
- const summaryDoc = getSummarySource(topMatch.path, summaryMap, documentMap);
970
+ const summaryDoc = getSummarySource(topMatch.path, summaryMap, symbolPathDocMap);
859
971
  if (summaryDoc) {
860
972
  results.push({
861
973
  backlinkCount: 0,
@@ -868,13 +980,14 @@ export class SearchKnowledgeService {
868
980
  });
869
981
  accessHitPaths.push(summaryDoc.path);
870
982
  }
871
- for (const docId of subtreeIds) {
983
+ for (const symbolPath of subtreeIds) {
872
984
  if (results.length >= limit)
873
985
  break;
874
- const doc = documentMap.get(docId);
986
+ const docId = pathToDocumentId.get(symbolPath);
987
+ const doc = docId ? documentMap.get(docId) : undefined;
875
988
  if (!doc)
876
989
  continue;
877
- results.push(this.enrichResult({ excerpt: extractExcerpt(doc.content, query), path: doc.path, score: 0.9, title: doc.title }, symbolTree, referenceIndex, documentMap));
990
+ results.push(this.enrichResult({ excerpt: extractExcerpt(doc.content, query), id: doc.id, path: doc.path, score: 0.9, title: doc.title }, symbolTree, referenceIndex, documentMap));
878
991
  accessHitPaths.push(doc.path);
879
992
  }
880
993
  if (accessHitPaths.length > 0) {