byterover-cli 3.1.0 → 3.3.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 (158) hide show
  1. package/README.md +17 -0
  2. package/dist/agent/infra/agent/agent-schemas.d.ts +8 -0
  3. package/dist/agent/infra/agent/agent-schemas.js +1 -0
  4. package/dist/agent/infra/sandbox/curate-service.js +14 -0
  5. package/dist/agent/infra/sandbox/sandbox-service.js +1 -0
  6. package/dist/agent/infra/sandbox/tools-sdk.d.ts +10 -0
  7. package/dist/agent/infra/sandbox/tools-sdk.js +9 -1
  8. package/dist/agent/infra/tools/implementations/search-knowledge-service.js +226 -103
  9. package/dist/agent/infra/tools/implementations/write-file-tool.d.ts +2 -1
  10. package/dist/agent/infra/tools/implementations/write-file-tool.js +16 -2
  11. package/dist/agent/infra/tools/tool-registry.js +1 -1
  12. package/dist/agent/infra/tools/write-guard.d.ts +11 -0
  13. package/dist/agent/infra/tools/write-guard.js +48 -0
  14. package/dist/agent/resources/prompts/system-prompt.yml +9 -0
  15. package/dist/agent/resources/tools/expand_knowledge.txt +4 -0
  16. package/dist/agent/resources/tools/search_knowledge.txt +11 -1
  17. package/dist/oclif/commands/curate/index.d.ts +1 -0
  18. package/dist/oclif/commands/curate/index.js +19 -4
  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.d.ts +1 -0
  22. package/dist/oclif/commands/query.js +19 -4
  23. package/dist/oclif/commands/search.d.ts +20 -0
  24. package/dist/oclif/commands/search.js +186 -0
  25. package/dist/oclif/commands/source/add.d.ts +12 -0
  26. package/dist/oclif/commands/source/add.js +42 -0
  27. package/dist/oclif/commands/source/index.d.ts +6 -0
  28. package/dist/oclif/commands/source/index.js +8 -0
  29. package/dist/oclif/commands/source/list.d.ts +6 -0
  30. package/dist/oclif/commands/source/list.js +32 -0
  31. package/dist/oclif/commands/source/remove.d.ts +9 -0
  32. package/dist/oclif/commands/source/remove.js +33 -0
  33. package/dist/oclif/commands/status.d.ts +5 -1
  34. package/dist/oclif/commands/status.js +45 -6
  35. package/dist/oclif/commands/worktree/add.d.ts +12 -0
  36. package/dist/oclif/commands/worktree/add.js +44 -0
  37. package/dist/oclif/commands/worktree/index.d.ts +6 -0
  38. package/dist/oclif/commands/worktree/index.js +8 -0
  39. package/dist/oclif/commands/worktree/list.d.ts +6 -0
  40. package/dist/oclif/commands/worktree/list.js +28 -0
  41. package/dist/oclif/commands/worktree/remove.d.ts +9 -0
  42. package/dist/oclif/commands/worktree/remove.js +35 -0
  43. package/dist/oclif/hooks/init/validate-brv-config.js +4 -0
  44. package/dist/oclif/lib/daemon-client.d.ts +4 -2
  45. package/dist/oclif/lib/daemon-client.js +19 -4
  46. package/dist/oclif/lib/search-format.d.ts +10 -0
  47. package/dist/oclif/lib/search-format.js +25 -0
  48. package/dist/oclif/lib/task-client.d.ts +6 -0
  49. package/dist/oclif/lib/task-client.js +10 -3
  50. package/dist/server/constants.d.ts +7 -1
  51. package/dist/server/constants.js +10 -0
  52. package/dist/server/core/domain/client/client-info.d.ts +7 -0
  53. package/dist/server/core/domain/client/client-info.js +11 -0
  54. package/dist/server/core/domain/errors/task-error.d.ts +2 -2
  55. package/dist/server/core/domain/errors/task-error.js +5 -4
  56. package/dist/server/core/domain/project/worktrees-schema.d.ts +29 -0
  57. package/dist/server/core/domain/project/worktrees-schema.js +17 -0
  58. package/dist/server/core/domain/source/source-operations.d.ts +31 -0
  59. package/dist/server/core/domain/source/source-operations.js +201 -0
  60. package/dist/server/core/domain/source/source-schema.d.ts +94 -0
  61. package/dist/server/core/domain/source/source-schema.js +121 -0
  62. package/dist/server/core/domain/transport/schemas.d.ts +18 -10
  63. package/dist/server/core/domain/transport/schemas.js +7 -3
  64. package/dist/server/core/domain/transport/task-info.d.ts +2 -0
  65. package/dist/server/core/interfaces/client/i-client-manager.d.ts +13 -0
  66. package/dist/server/core/interfaces/executor/i-curate-executor.d.ts +4 -0
  67. package/dist/server/core/interfaces/executor/i-folder-pack-executor.d.ts +7 -3
  68. package/dist/server/core/interfaces/executor/i-query-executor.d.ts +2 -0
  69. package/dist/server/core/interfaces/executor/i-search-executor.d.ts +34 -0
  70. package/dist/server/core/interfaces/executor/i-search-executor.js +1 -0
  71. package/dist/server/core/interfaces/executor/index.d.ts +1 -0
  72. package/dist/server/core/interfaces/executor/index.js +1 -0
  73. package/dist/server/infra/client/client-manager.d.ts +1 -0
  74. package/dist/server/infra/client/client-manager.js +16 -0
  75. package/dist/server/infra/daemon/agent-process.js +35 -12
  76. package/dist/server/infra/executor/curate-executor.js +4 -2
  77. package/dist/server/infra/executor/direct-search-responder.js +5 -1
  78. package/dist/server/infra/executor/folder-pack-executor.js +23 -12
  79. package/dist/server/infra/executor/query-executor.d.ts +23 -0
  80. package/dist/server/infra/executor/query-executor.js +115 -21
  81. package/dist/server/infra/executor/search-executor.d.ts +17 -0
  82. package/dist/server/infra/executor/search-executor.js +30 -0
  83. package/dist/server/infra/mcp/mcp-mode-detector.d.ts +7 -5
  84. package/dist/server/infra/mcp/mcp-mode-detector.js +11 -18
  85. package/dist/server/infra/mcp/mcp-server.d.ts +1 -0
  86. package/dist/server/infra/mcp/mcp-server.js +11 -6
  87. package/dist/server/infra/mcp/tools/brv-curate-tool.d.ts +2 -1
  88. package/dist/server/infra/mcp/tools/brv-curate-tool.js +9 -16
  89. package/dist/server/infra/mcp/tools/brv-query-tool.d.ts +2 -1
  90. package/dist/server/infra/mcp/tools/brv-query-tool.js +9 -16
  91. package/dist/server/infra/mcp/tools/mcp-project-context.d.ts +11 -0
  92. package/dist/server/infra/mcp/tools/mcp-project-context.js +54 -0
  93. package/dist/server/infra/process/connection-coordinator.js +11 -0
  94. package/dist/server/infra/process/feature-handlers.js +4 -1
  95. package/dist/server/infra/process/task-router.d.ts +1 -0
  96. package/dist/server/infra/process/task-router.js +60 -5
  97. package/dist/server/infra/project/resolve-project.d.ts +106 -0
  98. package/dist/server/infra/project/resolve-project.js +473 -0
  99. package/dist/server/infra/transport/handlers/index.d.ts +4 -0
  100. package/dist/server/infra/transport/handlers/index.js +2 -0
  101. package/dist/server/infra/transport/handlers/pull-handler.js +3 -3
  102. package/dist/server/infra/transport/handlers/push-handler.js +3 -3
  103. package/dist/server/infra/transport/handlers/source-handler.d.ts +12 -0
  104. package/dist/server/infra/transport/handlers/source-handler.js +37 -0
  105. package/dist/server/infra/transport/handlers/status-handler.js +76 -27
  106. package/dist/server/infra/transport/handlers/worktree-handler.d.ts +12 -0
  107. package/dist/server/infra/transport/handlers/worktree-handler.js +67 -0
  108. package/dist/server/infra/transport/transport-connector.d.ts +10 -4
  109. package/dist/server/infra/transport/transport-connector.js +2 -2
  110. package/dist/server/templates/skill/SKILL.md +25 -5
  111. package/dist/server/utils/path-utils.d.ts +5 -0
  112. package/dist/server/utils/path-utils.js +11 -1
  113. package/dist/shared/transport/events/client-events.d.ts +3 -0
  114. package/dist/shared/transport/events/client-events.js +3 -0
  115. package/dist/shared/transport/events/index.d.ts +13 -0
  116. package/dist/shared/transport/events/index.js +9 -0
  117. package/dist/shared/transport/events/source-events.d.ts +30 -0
  118. package/dist/shared/transport/events/source-events.js +5 -0
  119. package/dist/shared/transport/events/status-events.d.ts +5 -0
  120. package/dist/shared/transport/events/task-events.d.ts +4 -1
  121. package/dist/shared/transport/events/worktree-events.d.ts +31 -0
  122. package/dist/shared/transport/events/worktree-events.js +5 -0
  123. package/dist/shared/transport/search-content.d.ts +28 -0
  124. package/dist/shared/transport/search-content.js +38 -0
  125. package/dist/shared/transport/types/dto.d.ts +20 -1
  126. package/dist/tui/features/commands/definitions/index.js +6 -0
  127. package/dist/tui/features/commands/definitions/source-add.d.ts +2 -0
  128. package/dist/tui/features/commands/definitions/source-add.js +48 -0
  129. package/dist/tui/features/commands/definitions/source-list.d.ts +2 -0
  130. package/dist/tui/features/commands/definitions/source-list.js +47 -0
  131. package/dist/tui/features/commands/definitions/source-remove.d.ts +2 -0
  132. package/dist/tui/features/commands/definitions/source-remove.js +38 -0
  133. package/dist/tui/features/commands/definitions/source.d.ts +2 -0
  134. package/dist/tui/features/commands/definitions/source.js +8 -0
  135. package/dist/tui/features/commands/definitions/worktree-add.d.ts +2 -0
  136. package/dist/tui/features/commands/definitions/worktree-add.js +35 -0
  137. package/dist/tui/features/commands/definitions/worktree-list.d.ts +2 -0
  138. package/dist/tui/features/commands/definitions/worktree-list.js +36 -0
  139. package/dist/tui/features/commands/definitions/worktree-remove.d.ts +2 -0
  140. package/dist/tui/features/commands/definitions/worktree-remove.js +33 -0
  141. package/dist/tui/features/commands/definitions/worktree.d.ts +2 -0
  142. package/dist/tui/features/commands/definitions/worktree.js +8 -0
  143. package/dist/tui/features/curate/api/create-curate-task.js +3 -1
  144. package/dist/tui/features/query/api/create-query-task.js +3 -1
  145. package/dist/tui/features/source/api/source-api.d.ts +4 -0
  146. package/dist/tui/features/source/api/source-api.js +22 -0
  147. package/dist/tui/features/status/api/get-status.js +2 -1
  148. package/dist/tui/features/status/utils/format-status.js +28 -1
  149. package/dist/tui/features/transport/components/transport-initializer.js +36 -1
  150. package/dist/tui/features/worktree/api/worktree-api.d.ts +4 -0
  151. package/dist/tui/features/worktree/api/worktree-api.js +22 -0
  152. package/dist/tui/repl-startup.d.ts +2 -0
  153. package/dist/tui/repl-startup.js +5 -3
  154. package/dist/tui/stores/transport-store.d.ts +6 -0
  155. package/dist/tui/stores/transport-store.js +6 -0
  156. package/dist/tui/utils/error-messages.js +2 -2
  157. package/oclif.manifest.json +380 -36
  158. 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
  })();
@@ -565,15 +649,20 @@ export class SearchKnowledgeService {
565
649
  */
566
650
  async search(query, options) {
567
651
  const limit = options?.limit ?? 10;
652
+ // Normalize scope: strip trailing slashes so "project/" and "project" both work.
653
+ // The symbol tree stores paths without a trailing slash, and getSubtreeDocumentIds
654
+ // does an exact node lookup, so "project/" would otherwise miss the subtree entirely
655
+ // and silently fall back to global search via the block at the end of this method.
656
+ const normalizedScope = options?.scope?.trim().replace(/\/+$/, '') || undefined;
568
657
  const resolvedBaseDirectory = await realpath(this.baseDirectory).catch(() => this.baseDirectory);
569
658
  const contextTreePath = join(resolvedBaseDirectory, BRV_DIR, CONTEXT_TREE_DIR);
570
659
  // 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));
660
+ const indexResult = await acquireIndex(this.state, this.fileSystem, contextTreePath, this.baseDirectory, this.cacheTtlMs, (ctxPath) => this.flushAccessHits(ctxPath));
572
661
  // Handle error case (context tree not initialized)
573
662
  if ('error' in indexResult) {
574
663
  return indexResult.result;
575
664
  }
576
- const { documentMap, index, referenceIndex, summaryMap, symbolTree } = indexResult;
665
+ const { documentMap, index, pathToDocumentId, referenceIndex, summaryMap, symbolPathDocMap, symbolTree } = indexResult;
577
666
  if (documentMap.size === 0) {
578
667
  return {
579
668
  message: 'Context tree is empty. Use /curate to add knowledge.',
@@ -583,24 +672,29 @@ export class SearchKnowledgeService {
583
672
  }
584
673
  // Overview mode: return tree structure instead of search results
585
674
  if (options?.overview) {
586
- return this.buildOverviewResult(symbolTree, referenceIndex, options.scope, options.overviewDepth);
675
+ return this.buildOverviewResult(symbolTree, referenceIndex, normalizedScope, options.overviewDepth);
587
676
  }
588
677
  // Symbolic path resolution: try path-based query first
589
678
  if (isPathLikeQuery(query, symbolTree)) {
590
- const symbolicResult = this.trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, summaryMap, options);
679
+ const symbolicResult = this.trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, pathToDocumentId, summaryMap, symbolPathDocMap, options);
591
680
  if (symbolicResult) {
592
681
  return symbolicResult;
593
682
  }
594
683
  }
595
684
  // Parse query for potential scope prefix (e.g. "auth jwt refresh" → scope=auth, text="jwt refresh")
596
685
  const parsed = parseSymbolicQuery(query, symbolTree);
597
- const effectiveScope = options?.scope ?? parsed.scopePath;
686
+ // Strip trailing slashes from scope so "auth/" resolves to the same
687
+ // symbol-tree node as "auth". The symbol tree stores paths without
688
+ // trailing slashes; a mismatch causes getSubtreeDocumentIds() to
689
+ // return empty → unintended fallback to global search.
690
+ const rawScope = options?.scope?.trim().replace(/\/+$/, '');
691
+ const effectiveScope = (rawScope !== undefined && rawScope !== '' ? rawScope : undefined) ?? parsed.scopePath;
598
692
  const effectiveQuery = parsed.scopePath ? parsed.textQuery : query;
599
693
  // 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);
694
+ const textResult = this.runTextSearch(effectiveQuery || query, documentMap, index, limit, effectiveScope, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options);
601
695
  // If scoped search returned nothing and we had a scope, fall back to global search
602
696
  if (textResult.results.length === 0 && effectiveScope && effectiveQuery) {
603
- return this.runTextSearch(query, documentMap, index, limit, undefined, symbolTree, referenceIndex, summaryMap, options);
697
+ return this.runTextSearch(query, documentMap, index, limit, undefined, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options);
604
698
  }
605
699
  return textResult;
606
700
  }
@@ -636,14 +730,15 @@ export class SearchKnowledgeService {
636
730
  * For archive stubs, extracts points_to path into archiveFullPath.
637
731
  */
638
732
  enrichResult(result, symbolTree, referenceIndex, documentMap) {
639
- const symbol = symbolTree.symbolMap.get(result.path);
733
+ const doc = documentMap.get(result.id);
734
+ const symbolPath = doc?.symbolPath ?? result.path;
735
+ const symbol = symbolTree.symbolMap.get(symbolPath);
640
736
  const backlinks = referenceIndex.backlinks.get(result.path);
641
737
  // Detect archive stubs and extract points_to for drill-down
642
738
  let archiveFullPath;
643
739
  let symbolKind = symbol ? getSymbolKindLabel(symbol.kind) : undefined;
644
740
  if (isArchiveStub(result.path)) {
645
741
  symbolKind = 'archive_stub';
646
- const doc = documentMap.get(result.path);
647
742
  if (doc) {
648
743
  const stubFm = parseArchiveStubFrontmatter(doc.content);
649
744
  if (stubFm) {
@@ -651,17 +746,26 @@ export class SearchKnowledgeService {
651
746
  }
652
747
  }
653
748
  }
654
- const doc = documentMap.get(result.path);
749
+ // Origin metadata for shared-source results
750
+ const origin = doc?.origin;
751
+ const originAlias = doc?.originAlias;
752
+ const originContextTreeRoot = doc?.origin === 'shared' ? doc.originContextTreeRoot : undefined;
655
753
  const overviewPath = doc?.overviewPath;
656
754
  const isContextSummary = doc?.path.endsWith('/context.md') || doc?.path === 'context.md';
657
755
  const summaryPath = isContextSummary
658
756
  ? doc?.path.slice(0, -'/context.md'.length) || doc?.path || result.path
659
757
  : result.path;
758
+ // Destructure to strip `id` from output — not part of SearchKnowledgeResult
759
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
760
+ const { id: _id, ...rest } = result;
660
761
  return {
661
- ...result,
762
+ ...rest,
662
763
  ...(archiveFullPath && { archiveFullPath }),
663
764
  ...(overviewPath && { overviewPath }),
664
765
  backlinkCount: backlinks?.length ?? 0,
766
+ ...(origin && { origin }),
767
+ ...(originAlias && { originAlias }),
768
+ ...(originContextTreeRoot && { originContextTreeRoot }),
665
769
  ...(isContextSummary && { path: summaryPath }),
666
770
  relatedPaths: backlinks?.slice(0, 3),
667
771
  symbolKind: isContextSummary ? 'summary' : symbolKind,
@@ -671,15 +775,22 @@ export class SearchKnowledgeService {
671
775
  /**
672
776
  * Run the standard text-based MiniSearch pipeline, optionally scoped to a subtree.
673
777
  */
674
- runTextSearch(query, documentMap, index, limit, scopePath, symbolTree, referenceIndex, summaryMap, options) {
778
+ runTextSearch(query, documentMap, index, limit, scopePath, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options) {
675
779
  const filteredQuery = filterStopWords(query);
676
780
  const filteredWords = filteredQuery.split(/\s+/).filter((w) => w.length >= 2);
677
781
  // Build scope filter if a subtree is specified
678
782
  let scopeFilter;
679
783
  if (scopePath) {
680
- const subtreeIds = getSubtreeDocumentIds(symbolTree, scopePath);
681
- if (subtreeIds.size > 0) {
682
- scopeFilter = (result) => subtreeIds.has(result.id);
784
+ const subtreePaths = getSubtreeDocumentIds(symbolTree, scopePath);
785
+ const subtreeQualifiedIds = new Set();
786
+ for (const symbolPath of subtreePaths) {
787
+ const docId = pathToDocumentId.get(symbolPath);
788
+ if (docId) {
789
+ subtreeQualifiedIds.add(docId);
790
+ }
791
+ }
792
+ if (subtreeQualifiedIds.size > 0) {
793
+ scopeFilter = (result) => subtreeQualifiedIds.has(result.id);
683
794
  }
684
795
  }
685
796
  // AND-first strategy: for multi-word queries, try AND for concentrated scores.
@@ -699,6 +810,7 @@ export class SearchKnowledgeService {
699
810
  }
700
811
  // Normalize BM25 scores to [0, 1) then blend with importance + recency via compound scoring.
701
812
  // Decay is computed lazily from file mtime — no disk writes during search.
813
+ // Local results get a configurable score boost to prefer local knowledge over shared.
702
814
  const now = Date.now();
703
815
  const searchResults = rawResults.map((r) => {
704
816
  const doc = documentMap.get(r.id);
@@ -706,10 +818,15 @@ export class SearchKnowledgeService {
706
818
  const daysSince = doc ? Math.max(0, (now - doc.mtime) / 86_400_000) : 0;
707
819
  const decayed = applyDecay(scoring, daysSince);
708
820
  const bm25 = normalizeScore(r.score);
821
+ let finalScore = compoundScore(bm25, decayed.importance ?? 50, decayed.recency ?? 1, decayed.maturity ?? 'draft');
822
+ // Local score boost: prefer local results over shared when scores are close
823
+ if (doc?.origin === 'local') {
824
+ finalScore = Math.min(finalScore + SHARED_SOURCE_LOCAL_SCORE_BOOST, 1);
825
+ }
709
826
  return {
710
827
  ...r,
711
828
  bm25Score: bm25,
712
- score: compoundScore(bm25, decayed.importance ?? 50, decayed.recency ?? 1, decayed.maturity ?? 'draft'),
829
+ score: finalScore,
713
830
  };
714
831
  });
715
832
  searchResults.sort((a, b) => b.score - a.score);
@@ -752,6 +869,7 @@ export class SearchKnowledgeService {
752
869
  if (document) {
753
870
  const enriched = this.enrichResult({
754
871
  excerpt: extractExcerpt(document.content, query),
872
+ id: result.id,
755
873
  path: document.path,
756
874
  score: Math.round(result.score * 100) / 100,
757
875
  title: document.title,
@@ -765,10 +883,10 @@ export class SearchKnowledgeService {
765
883
  }
766
884
  if (options?.minMaturity && enriched.symbolKind) {
767
885
  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';
886
+ ? (getSummarySource(enriched.path, summaryMap, symbolPathDocMap)?.scoring?.maturity ??
887
+ symbolTree.symbolMap.get(enriched.path)?.metadata.maturity ??
888
+ 'draft')
889
+ : (symbolTree.symbolMap.get(document.symbolPath)?.metadata.maturity ?? 'draft');
772
890
  if ((MATURITY_TIER_RANK[docMaturity] ?? 1) < (MATURITY_TIER_RANK[options.minMaturity] ?? 1)) {
773
891
  continue;
774
892
  }
@@ -776,14 +894,17 @@ export class SearchKnowledgeService {
776
894
  results.push(enriched);
777
895
  propagationInputs.push({
778
896
  bm25Score: result.bm25Score,
779
- path: document.path,
897
+ path: document.symbolPath,
780
898
  });
781
899
  }
782
900
  }
783
901
  }
784
902
  // Propagate scores upward to parent domain/topic nodes (hierarchical retrieval)
785
- const propagated = propagateScoresToParents(propagationInputs, symbolTree, summaryMap, documentMap);
903
+ const propagated = propagateScoresToParents(propagationInputs, symbolTree, summaryMap, symbolPathDocMap);
786
904
  for (const p of propagated) {
905
+ // Apply local score boost to propagated summaries so they stay competitive
906
+ // with boosted direct BM25 hits (the boost was already applied to direct hits above)
907
+ p.score = Math.min(p.score + SHARED_SOURCE_LOCAL_SCORE_BOOST, 1);
787
908
  if (scoreFloor !== undefined && p.score < scoreFloor)
788
909
  continue;
789
910
  if (options?.includeKinds && p.symbolKind && !options.includeKinds.includes(p.symbolKind))
@@ -791,7 +912,7 @@ export class SearchKnowledgeService {
791
912
  if (options?.excludeKinds && p.symbolKind && options.excludeKinds.includes(p.symbolKind))
792
913
  continue;
793
914
  if (options?.minMaturity && p.symbolKind === 'summary') {
794
- const summaryDoc = getSummarySource(p.path, summaryMap, documentMap);
915
+ const summaryDoc = getSummarySource(p.path, summaryMap, symbolPathDocMap);
795
916
  const summaryMaturity = summaryDoc?.scoring?.maturity ?? 'draft';
796
917
  if ((MATURITY_TIER_RANK[summaryMaturity] ?? 1) < (MATURITY_TIER_RANK[options.minMaturity] ?? 1))
797
918
  continue;
@@ -808,9 +929,7 @@ export class SearchKnowledgeService {
808
929
  // Synthetic 'summary' results carry folder-style paths (e.g. 'auth') that are not
809
930
  // real files; map them to their _index.md so flushAccessHits can read and update them.
810
931
  if (results.length > 0) {
811
- this.accumulateAccessHits(results.map((r) => (r.symbolKind === 'summary'
812
- ? getSummaryAccessPath(r.path, summaryMap, documentMap)
813
- : r.path)));
932
+ this.accumulateAccessHits(results.map((r) => r.symbolKind === 'summary' ? getSummaryAccessPath(r.path, summaryMap, symbolPathDocMap) : r.path));
814
933
  }
815
934
  return {
816
935
  message: results.length > 0
@@ -823,7 +942,7 @@ export class SearchKnowledgeService {
823
942
  /**
824
943
  * Try to resolve the query as a symbolic path. Returns null if no path match found.
825
944
  */
826
- trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, summaryMap, options) {
945
+ trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, pathToDocumentId, summaryMap, symbolPathDocMap, options) {
827
946
  const pathMatches = matchMemoryPath(symbolTree, query.split(/\s+/)[0].includes('/') ? query.split(/\s+/)[0] : query);
828
947
  if (pathMatches.length === 0) {
829
948
  return null;
@@ -831,12 +950,15 @@ export class SearchKnowledgeService {
831
950
  const topMatch = pathMatches[0].matchedSymbol;
832
951
  // If the matched symbol is a leaf Context, return it directly
833
952
  if (topMatch.kind === MemorySymbolKind.Context) {
834
- const doc = documentMap.get(topMatch.path);
953
+ const docId = pathToDocumentId.get(topMatch.path);
954
+ const doc = docId ? documentMap.get(docId) : undefined;
835
955
  if (!doc) {
836
956
  return null;
837
957
  }
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]);
958
+ const result = this.enrichResult({ excerpt: extractExcerpt(doc.content, query), id: doc.id, path: doc.path, score: 1, title: doc.title }, symbolTree, referenceIndex, documentMap);
959
+ if (doc.origin === 'local') {
960
+ this.accumulateAccessHits([doc.path]);
961
+ }
840
962
  return {
841
963
  message: `Found exact match: ${topMatch.path}`,
842
964
  results: [result],
@@ -849,13 +971,13 @@ export class SearchKnowledgeService {
849
971
  const textPart = query.slice(query.indexOf(pathPart) + pathPart.length).trim();
850
972
  if (textPart) {
851
973
  // Scoped search: search text within the matched subtree
852
- return this.runTextSearch(textPart, documentMap, index, limit, topMatch.path, symbolTree, referenceIndex, summaryMap, options);
974
+ return this.runTextSearch(textPart, documentMap, index, limit, topMatch.path, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options);
853
975
  }
854
976
  // No text part — return all children of the matched node
855
977
  const subtreeIds = getSubtreeDocumentIds(symbolTree, topMatch.path);
856
978
  const results = [];
857
979
  const accessHitPaths = [];
858
- const summaryDoc = getSummarySource(topMatch.path, summaryMap, documentMap);
980
+ const summaryDoc = getSummarySource(topMatch.path, summaryMap, symbolPathDocMap);
859
981
  if (summaryDoc) {
860
982
  results.push({
861
983
  backlinkCount: 0,
@@ -868,13 +990,14 @@ export class SearchKnowledgeService {
868
990
  });
869
991
  accessHitPaths.push(summaryDoc.path);
870
992
  }
871
- for (const docId of subtreeIds) {
993
+ for (const symbolPath of subtreeIds) {
872
994
  if (results.length >= limit)
873
995
  break;
874
- const doc = documentMap.get(docId);
996
+ const docId = pathToDocumentId.get(symbolPath);
997
+ const doc = docId ? documentMap.get(docId) : undefined;
875
998
  if (!doc)
876
999
  continue;
877
- results.push(this.enrichResult({ excerpt: extractExcerpt(doc.content, query), path: doc.path, score: 0.9, title: doc.title }, symbolTree, referenceIndex, documentMap));
1000
+ 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
1001
  accessHitPaths.push(doc.path);
879
1002
  }
880
1003
  if (accessHitPaths.length > 0) {