byterover-cli 3.0.1 → 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 (196) hide show
  1. package/.env.production +4 -0
  2. package/README.md +17 -0
  3. package/dist/agent/core/domain/tools/constants.d.ts +1 -0
  4. package/dist/agent/core/domain/tools/constants.js +1 -0
  5. package/dist/agent/core/interfaces/cipher-services.d.ts +8 -0
  6. package/dist/agent/core/interfaces/i-cipher-agent.d.ts +1 -0
  7. package/dist/agent/infra/agent/agent-error-codes.d.ts +0 -1
  8. package/dist/agent/infra/agent/agent-error-codes.js +0 -1
  9. package/dist/agent/infra/agent/agent-error.d.ts +0 -1
  10. package/dist/agent/infra/agent/agent-error.js +0 -1
  11. package/dist/agent/infra/agent/agent-schemas.d.ts +8 -0
  12. package/dist/agent/infra/agent/agent-schemas.js +1 -0
  13. package/dist/agent/infra/agent/agent-state-manager.d.ts +1 -3
  14. package/dist/agent/infra/agent/agent-state-manager.js +1 -3
  15. package/dist/agent/infra/agent/base-agent.d.ts +1 -1
  16. package/dist/agent/infra/agent/base-agent.js +1 -1
  17. package/dist/agent/infra/agent/cipher-agent.d.ts +15 -1
  18. package/dist/agent/infra/agent/cipher-agent.js +188 -3
  19. package/dist/agent/infra/agent/index.d.ts +1 -1
  20. package/dist/agent/infra/agent/index.js +1 -1
  21. package/dist/agent/infra/agent/service-initializer.d.ts +3 -3
  22. package/dist/agent/infra/agent/service-initializer.js +14 -8
  23. package/dist/agent/infra/agent/types.d.ts +0 -1
  24. package/dist/agent/infra/file-system/file-system-service.js +6 -5
  25. package/dist/agent/infra/folder-pack/folder-pack-service.d.ts +1 -0
  26. package/dist/agent/infra/folder-pack/folder-pack-service.js +29 -15
  27. package/dist/agent/infra/llm/providers/openai.js +12 -0
  28. package/dist/agent/infra/llm/stream-to-text.d.ts +7 -0
  29. package/dist/agent/infra/llm/stream-to-text.js +14 -0
  30. package/dist/agent/infra/map/abstract-generator.d.ts +22 -0
  31. package/dist/agent/infra/map/abstract-generator.js +67 -0
  32. package/dist/agent/infra/map/abstract-queue.d.ts +67 -0
  33. package/dist/agent/infra/map/abstract-queue.js +218 -0
  34. package/dist/agent/infra/memory/memory-deduplicator.d.ts +44 -0
  35. package/dist/agent/infra/memory/memory-deduplicator.js +88 -0
  36. package/dist/agent/infra/memory/memory-manager.d.ts +1 -0
  37. package/dist/agent/infra/memory/memory-manager.js +6 -5
  38. package/dist/agent/infra/sandbox/curate-service.d.ts +4 -2
  39. package/dist/agent/infra/sandbox/curate-service.js +20 -7
  40. package/dist/agent/infra/sandbox/local-sandbox.d.ts +5 -0
  41. package/dist/agent/infra/sandbox/local-sandbox.js +57 -1
  42. package/dist/agent/infra/sandbox/sandbox-service.js +1 -0
  43. package/dist/agent/infra/sandbox/tools-sdk.d.ts +13 -1
  44. package/dist/agent/infra/sandbox/tools-sdk.js +9 -1
  45. package/dist/agent/infra/session/session-compressor.d.ts +43 -0
  46. package/dist/agent/infra/session/session-compressor.js +296 -0
  47. package/dist/agent/infra/session/session-manager.d.ts +7 -0
  48. package/dist/agent/infra/session/session-manager.js +9 -0
  49. package/dist/agent/infra/tools/implementations/curate-tool.d.ts +3 -2
  50. package/dist/agent/infra/tools/implementations/curate-tool.js +54 -27
  51. package/dist/agent/infra/tools/implementations/expand-knowledge-tool.d.ts +3 -3
  52. package/dist/agent/infra/tools/implementations/expand-knowledge-tool.js +34 -7
  53. package/dist/agent/infra/tools/implementations/ingest-resource-tool.d.ts +17 -0
  54. package/dist/agent/infra/tools/implementations/ingest-resource-tool.js +224 -0
  55. package/dist/agent/infra/tools/implementations/memory-symbol-tree.d.ts +8 -0
  56. package/dist/agent/infra/tools/implementations/search-knowledge-service.d.ts +1 -1
  57. package/dist/agent/infra/tools/implementations/search-knowledge-service.js +392 -106
  58. package/dist/agent/infra/tools/implementations/search-knowledge-tool.js +2 -2
  59. package/dist/agent/infra/tools/implementations/write-file-tool.d.ts +2 -1
  60. package/dist/agent/infra/tools/implementations/write-file-tool.js +16 -2
  61. package/dist/agent/infra/tools/tool-provider.js +1 -0
  62. package/dist/agent/infra/tools/tool-registry.d.ts +3 -0
  63. package/dist/agent/infra/tools/tool-registry.js +16 -5
  64. package/dist/agent/infra/tools/write-guard.d.ts +11 -0
  65. package/dist/agent/infra/tools/write-guard.js +48 -0
  66. package/dist/agent/resources/prompts/system-prompt.yml +9 -0
  67. package/dist/agent/resources/tools/expand_knowledge.txt +4 -0
  68. package/dist/agent/resources/tools/search_knowledge.txt +11 -1
  69. package/dist/oclif/commands/curate/index.js +4 -3
  70. package/dist/oclif/commands/curate/view.js +2 -2
  71. package/dist/oclif/commands/main.js +13 -0
  72. package/dist/oclif/commands/query.js +4 -3
  73. package/dist/oclif/commands/source/add.d.ts +12 -0
  74. package/dist/oclif/commands/source/add.js +42 -0
  75. package/dist/oclif/commands/source/index.d.ts +6 -0
  76. package/dist/oclif/commands/source/index.js +8 -0
  77. package/dist/oclif/commands/source/list.d.ts +6 -0
  78. package/dist/oclif/commands/source/list.js +32 -0
  79. package/dist/oclif/commands/source/remove.d.ts +9 -0
  80. package/dist/oclif/commands/source/remove.js +33 -0
  81. package/dist/oclif/commands/status.d.ts +5 -1
  82. package/dist/oclif/commands/status.js +41 -6
  83. package/dist/oclif/commands/worktree/add.d.ts +12 -0
  84. package/dist/oclif/commands/worktree/add.js +44 -0
  85. package/dist/oclif/commands/worktree/index.d.ts +6 -0
  86. package/dist/oclif/commands/worktree/index.js +8 -0
  87. package/dist/oclif/commands/worktree/list.d.ts +6 -0
  88. package/dist/oclif/commands/worktree/list.js +28 -0
  89. package/dist/oclif/commands/worktree/remove.d.ts +9 -0
  90. package/dist/oclif/commands/worktree/remove.js +35 -0
  91. package/dist/oclif/hooks/init/validate-brv-config.js +4 -0
  92. package/dist/oclif/lib/daemon-client.d.ts +4 -2
  93. package/dist/oclif/lib/daemon-client.js +19 -3
  94. package/dist/server/constants.d.ts +8 -0
  95. package/dist/server/constants.js +10 -0
  96. package/dist/server/core/domain/client/client-info.d.ts +7 -0
  97. package/dist/server/core/domain/client/client-info.js +11 -0
  98. package/dist/server/core/domain/knowledge/memory-scoring.d.ts +3 -3
  99. package/dist/server/core/domain/knowledge/memory-scoring.js +5 -5
  100. package/dist/server/core/domain/knowledge/summary-types.d.ts +4 -0
  101. package/dist/server/core/domain/project/worktrees-schema.d.ts +29 -0
  102. package/dist/server/core/domain/project/worktrees-schema.js +17 -0
  103. package/dist/server/core/domain/source/source-operations.d.ts +31 -0
  104. package/dist/server/core/domain/source/source-operations.js +201 -0
  105. package/dist/server/core/domain/source/source-schema.d.ts +94 -0
  106. package/dist/server/core/domain/source/source-schema.js +121 -0
  107. package/dist/server/core/domain/transport/schemas.d.ts +18 -10
  108. package/dist/server/core/domain/transport/schemas.js +4 -0
  109. package/dist/server/core/domain/transport/task-info.d.ts +2 -0
  110. package/dist/server/core/interfaces/client/i-client-manager.d.ts +13 -0
  111. package/dist/server/core/interfaces/executor/i-curate-executor.d.ts +4 -0
  112. package/dist/server/core/interfaces/executor/i-folder-pack-executor.d.ts +7 -3
  113. package/dist/server/core/interfaces/executor/i-query-executor.d.ts +2 -0
  114. package/dist/server/infra/client/client-manager.d.ts +1 -0
  115. package/dist/server/infra/client/client-manager.js +16 -0
  116. package/dist/server/infra/context-tree/derived-artifact.js +5 -1
  117. package/dist/server/infra/context-tree/file-context-tree-manifest-service.d.ts +2 -1
  118. package/dist/server/infra/context-tree/file-context-tree-manifest-service.js +43 -7
  119. package/dist/server/infra/context-tree/file-context-tree-summary-service.js +20 -2
  120. package/dist/server/infra/daemon/agent-process.js +15 -5
  121. package/dist/server/infra/executor/curate-executor.js +6 -3
  122. package/dist/server/infra/executor/direct-search-responder.js +5 -1
  123. package/dist/server/infra/executor/folder-pack-executor.js +88 -7
  124. package/dist/server/infra/executor/query-executor.d.ts +23 -0
  125. package/dist/server/infra/executor/query-executor.js +125 -23
  126. package/dist/server/infra/mcp/mcp-mode-detector.d.ts +7 -5
  127. package/dist/server/infra/mcp/mcp-mode-detector.js +11 -18
  128. package/dist/server/infra/mcp/mcp-server.d.ts +1 -0
  129. package/dist/server/infra/mcp/mcp-server.js +11 -6
  130. package/dist/server/infra/mcp/tools/brv-curate-tool.d.ts +2 -1
  131. package/dist/server/infra/mcp/tools/brv-curate-tool.js +9 -16
  132. package/dist/server/infra/mcp/tools/brv-query-tool.d.ts +2 -1
  133. package/dist/server/infra/mcp/tools/brv-query-tool.js +9 -16
  134. package/dist/server/infra/mcp/tools/mcp-project-context.d.ts +11 -0
  135. package/dist/server/infra/mcp/tools/mcp-project-context.js +54 -0
  136. package/dist/server/infra/process/connection-coordinator.js +11 -0
  137. package/dist/server/infra/process/feature-handlers.js +4 -1
  138. package/dist/server/infra/process/task-router.d.ts +1 -0
  139. package/dist/server/infra/process/task-router.js +60 -5
  140. package/dist/server/infra/project/resolve-project.d.ts +106 -0
  141. package/dist/server/infra/project/resolve-project.js +473 -0
  142. package/dist/server/infra/transport/handlers/index.d.ts +4 -0
  143. package/dist/server/infra/transport/handlers/index.js +2 -0
  144. package/dist/server/infra/transport/handlers/source-handler.d.ts +12 -0
  145. package/dist/server/infra/transport/handlers/source-handler.js +37 -0
  146. package/dist/server/infra/transport/handlers/status-handler.js +65 -13
  147. package/dist/server/infra/transport/handlers/worktree-handler.d.ts +12 -0
  148. package/dist/server/infra/transport/handlers/worktree-handler.js +67 -0
  149. package/dist/server/infra/transport/transport-connector.d.ts +10 -4
  150. package/dist/server/infra/transport/transport-connector.js +2 -2
  151. package/dist/server/utils/curate-result-parser.d.ts +4 -4
  152. package/dist/server/utils/path-utils.d.ts +5 -0
  153. package/dist/server/utils/path-utils.js +11 -1
  154. package/dist/shared/transport/events/client-events.d.ts +3 -0
  155. package/dist/shared/transport/events/client-events.js +3 -0
  156. package/dist/shared/transport/events/index.d.ts +13 -0
  157. package/dist/shared/transport/events/index.js +9 -0
  158. package/dist/shared/transport/events/source-events.d.ts +30 -0
  159. package/dist/shared/transport/events/source-events.js +5 -0
  160. package/dist/shared/transport/events/status-events.d.ts +5 -0
  161. package/dist/shared/transport/events/task-events.d.ts +4 -1
  162. package/dist/shared/transport/events/worktree-events.d.ts +31 -0
  163. package/dist/shared/transport/events/worktree-events.js +5 -0
  164. package/dist/shared/transport/types/dto.d.ts +26 -0
  165. package/dist/tui/features/commands/definitions/index.js +6 -0
  166. package/dist/tui/features/commands/definitions/source-add.d.ts +2 -0
  167. package/dist/tui/features/commands/definitions/source-add.js +48 -0
  168. package/dist/tui/features/commands/definitions/source-list.d.ts +2 -0
  169. package/dist/tui/features/commands/definitions/source-list.js +47 -0
  170. package/dist/tui/features/commands/definitions/source-remove.d.ts +2 -0
  171. package/dist/tui/features/commands/definitions/source-remove.js +38 -0
  172. package/dist/tui/features/commands/definitions/source.d.ts +2 -0
  173. package/dist/tui/features/commands/definitions/source.js +8 -0
  174. package/dist/tui/features/commands/definitions/worktree-add.d.ts +2 -0
  175. package/dist/tui/features/commands/definitions/worktree-add.js +35 -0
  176. package/dist/tui/features/commands/definitions/worktree-list.d.ts +2 -0
  177. package/dist/tui/features/commands/definitions/worktree-list.js +36 -0
  178. package/dist/tui/features/commands/definitions/worktree-remove.d.ts +2 -0
  179. package/dist/tui/features/commands/definitions/worktree-remove.js +33 -0
  180. package/dist/tui/features/commands/definitions/worktree.d.ts +2 -0
  181. package/dist/tui/features/commands/definitions/worktree.js +8 -0
  182. package/dist/tui/features/curate/api/create-curate-task.js +3 -1
  183. package/dist/tui/features/query/api/create-query-task.js +3 -1
  184. package/dist/tui/features/source/api/source-api.d.ts +4 -0
  185. package/dist/tui/features/source/api/source-api.js +22 -0
  186. package/dist/tui/features/status/api/get-status.js +2 -1
  187. package/dist/tui/features/status/utils/format-status.js +23 -1
  188. package/dist/tui/features/transport/components/transport-initializer.js +36 -1
  189. package/dist/tui/features/worktree/api/worktree-api.d.ts +4 -0
  190. package/dist/tui/features/worktree/api/worktree-api.js +22 -0
  191. package/dist/tui/repl-startup.d.ts +2 -0
  192. package/dist/tui/repl-startup.js +5 -3
  193. package/dist/tui/stores/transport-store.d.ts +6 -0
  194. package/dist/tui/stores/transport-store.js +6 -0
  195. package/oclif.manifest.json +261 -1
  196. package/package.json +10 -4
@@ -1,21 +1,23 @@
1
1
  import MiniSearch from 'minisearch';
2
+ import { realpath } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import { removeStopwords } from 'stopword';
4
- import { BRV_DIR, CONTEXT_FILE_EXTENSION, CONTEXT_TREE_DIR, 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';
5
6
  import { parseFrontmatterScoring, updateScoringInContent, } from '../../../../server/core/domain/knowledge/markdown-writer.js';
6
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';
7
9
  import { isArchiveStub, isDerivedArtifact } from '../../../../server/infra/context-tree/derived-artifact.js';
8
- import { parseArchiveStubFrontmatter, parseSummaryFrontmatter } from '../../../../server/infra/context-tree/summary-frontmatter.js';
10
+ import { parseArchiveStubFrontmatter, parseSummaryFrontmatter, } from '../../../../server/infra/context-tree/summary-frontmatter.js';
9
11
  import { isPathLikeQuery, matchMemoryPath, parseSymbolicQuery } from './memory-path-matcher.js';
10
12
  import { buildReferenceIndex, buildSymbolTree, getSubtreeDocumentIds, getSymbolKindLabel, getSymbolOverview, MemorySymbolKind, } from './memory-symbol-tree.js';
11
13
  const MAX_CONTEXT_TREE_FILES = 10_000;
12
14
  const DEFAULT_CACHE_TTL_MS = 5000;
13
15
  /** Bump when MINISEARCH_OPTIONS fields/boost change to invalidate cached indexes */
14
- const INDEX_SCHEMA_VERSION = 4;
16
+ const INDEX_SCHEMA_VERSION = 5;
15
17
  /** Only include results whose normalized score is at least this fraction of the top result's score */
16
- const SCORE_GAP_RATIO = 0.75;
18
+ const SCORE_GAP_RATIO = 0.7;
17
19
  /** Minimum normalized score for the top result. Below this, the query is considered out-of-domain */
18
- const MINIMUM_RELEVANCE_SCORE = 0.6;
20
+ const MINIMUM_RELEVANCE_SCORE = 0.45;
19
21
  /** Normalized score threshold above which results are trusted despite unmatched query terms */
20
22
  const UNMATCHED_TERM_SCORE_THRESHOLD = 0.85;
21
23
  /** Minimum query term length to consider "significant" for OOD term-based detection */
@@ -33,6 +35,66 @@ const CHUNK_OVERLAP_CHARS = 120;
33
35
  function normalizeScore(rawScore) {
34
36
  return rawScore / (1 + rawScore);
35
37
  }
38
+ function getSymbolPath(origin, relativePath) {
39
+ if (origin.origin === 'local') {
40
+ return relativePath;
41
+ }
42
+ return `[${origin.alias ?? origin.originKey}]:${relativePath}`;
43
+ }
44
+ /**
45
+ * Propagate BM25 scores upward to parent domain/topic nodes.
46
+ *
47
+ * For each matched result, walks the parent chain and computes a decayed boost
48
+ * (score * propagationFactor per level). New summary entries are added for
49
+ * parent nodes that have a _index.md in summaryMap but are not already in results.
50
+ *
51
+ * @param results - Already-enriched search results (gap-ratio filtered)
52
+ * @param symbolTree - Symbol tree for parent-chain traversal
53
+ * @param summaryMap - Map of _index.md file paths → SummaryDocLike (for excerpt/metadata)
54
+ * @param symbolPathDocMap - symbolPath → IndexedDocument lookup for context.md fallback
55
+ * @param propagationFactor - Score multiplier per level up (default 0.55)
56
+ * @returns New parent entries only — caller merges and re-sorts
57
+ */
58
+ function propagateScoresToParents(results, symbolTree, summaryMap, symbolPathDocMap, propagationFactor = 0.55) {
59
+ const boosts = new Map();
60
+ for (const r of results) {
61
+ const symbol = symbolTree.symbolMap.get(r.path);
62
+ let parent = symbol?.parent;
63
+ let factor = propagationFactor;
64
+ while (parent) {
65
+ const cur = boosts.get(parent.path) ?? 0;
66
+ boosts.set(parent.path, Math.max(cur, r.bm25Score * factor));
67
+ parent = parent.parent;
68
+ factor *= propagationFactor;
69
+ }
70
+ }
71
+ const existingPaths = new Set(results.map((r) => r.path));
72
+ const boosted = [];
73
+ for (const [parentPath, score] of boosts.entries()) {
74
+ if (existingPaths.has(parentPath))
75
+ continue;
76
+ const doc = getSummarySource(parentPath, summaryMap, symbolPathDocMap);
77
+ if (!doc)
78
+ continue;
79
+ // Propagate the strongest child BM25 signal upward, then apply the parent
80
+ // summary's own scoring exactly once. This avoids double-counting lifecycle
81
+ // weights that are already baked into child compound scores.
82
+ const finalScore = doc.scoring
83
+ ? compoundScore(score, doc.scoring.importance ?? 50, doc.scoring.recency ?? 0.5, doc.scoring.maturity ?? 'draft')
84
+ : score;
85
+ boosted.push({
86
+ backlinkCount: 0,
87
+ excerpt: doc.excerpt,
88
+ path: parentPath,
89
+ score: finalScore,
90
+ symbolKind: 'summary',
91
+ title: parentPath,
92
+ });
93
+ }
94
+ return boosted;
95
+ }
96
+ /** Numeric rank for maturity tiers — used for minMaturity filtering in both BM25 and propagated results. */
97
+ const MATURITY_TIER_RANK = { core: 3, draft: 1, validated: 2 };
36
98
  const MINISEARCH_OPTIONS = {
37
99
  fields: ['title', 'content', 'path'],
38
100
  idField: 'id',
@@ -43,6 +105,30 @@ const MINISEARCH_OPTIONS = {
43
105
  },
44
106
  storeFields: ['title', 'path'],
45
107
  };
108
+ function getSummaryAccessPath(path, summaryMap, symbolPathDocMap) {
109
+ return getSummarySource(path, summaryMap, symbolPathDocMap)?.path ?? `${path}/${SUMMARY_INDEX_FILE}`;
110
+ }
111
+ function getSummarySource(path, summaryMap, symbolPathDocMap) {
112
+ const summaryDoc = summaryMap.get(`${path}/${SUMMARY_INDEX_FILE}`);
113
+ if (summaryDoc) {
114
+ return {
115
+ excerpt: summaryDoc.excerpt ?? '',
116
+ path: summaryDoc.path,
117
+ scoring: summaryDoc.scoring,
118
+ };
119
+ }
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`);
123
+ if (contextDoc) {
124
+ return {
125
+ excerpt: extractExcerpt(contextDoc.content, contextDoc.title),
126
+ path: contextDoc.path,
127
+ scoring: contextDoc.scoring,
128
+ };
129
+ }
130
+ return undefined;
131
+ }
46
132
  function filterStopWords(query) {
47
133
  const words = query.toLowerCase().split(/\s+/);
48
134
  const filtered = removeStopwords(words);
@@ -162,6 +248,9 @@ function extractExcerpt(content, query, maxLength = 800) {
162
248
  }
163
249
  return excerpt || cleanContent.slice(0, maxLength) + (cleanContent.length > maxLength ? '...' : '');
164
250
  }
251
+ function stripMarkdownFrontmatter(content) {
252
+ return content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '').trim();
253
+ }
165
254
  async function findMarkdownFilesWithMtime(fileSystem, contextTreePath) {
166
255
  try {
167
256
  const globResult = await fileSystem.globFiles(`**/*${CONTEXT_FILE_EXTENSION}`, {
@@ -197,67 +286,82 @@ function isCacheValid(cache, currentFiles) {
197
286
  }
198
287
  return true;
199
288
  }
200
- async function buildFreshIndex(fileSystem, contextTreePath, filesWithMtime) {
201
- const now = Date.now();
202
- if (filesWithMtime.length === 0) {
203
- const index = new MiniSearch(MINISEARCH_OPTIONS);
204
- return {
205
- contextTreePath,
206
- documentMap: new Map(),
207
- fileMtimes: new Map(),
208
- index,
209
- lastValidatedAt: now,
210
- referenceIndex: { backlinks: new Map(), forwardLinks: new Map() },
211
- schemaVersion: INDEX_SCHEMA_VERSION,
212
- summaryMap: new Map(),
213
- symbolTree: { root: [], symbolMap: new Map() },
214
- };
215
- }
216
- // Partition files: _index.md → summaryFiles, derived artifacts → skip, rest → indexable
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
+ // Partition files: _index.md → summaryFiles, .overview.md → overviewFiles (for cache
296
+ // invalidation + sibling detection), other derived artifacts → skip, rest → indexable
217
297
  const summaryFiles = [];
298
+ const overviewFiles = [];
218
299
  const indexableFiles = [];
300
+ // Track all known paths for sibling detection (e.g. .overview.md presence check)
301
+ const knownPaths = new Set(filesWithMtime.map((f) => f.path));
219
302
  for (const file of filesWithMtime) {
220
303
  const fileName = file.path.split('/').at(-1) ?? '';
221
304
  if (fileName === SUMMARY_INDEX_FILE) {
222
305
  summaryFiles.push(file);
223
306
  }
307
+ else if (file.path.endsWith(OVERVIEW_EXTENSION)) {
308
+ // Track mtimes so cache invalidates when a new .overview.md appears; not BM25-indexed
309
+ overviewFiles.push(file);
310
+ }
224
311
  else if (!isDerivedArtifact(file.path)) {
225
- // Includes regular .md files AND .stub.md files (stubs are searchable)
226
312
  indexableFiles.push(file);
227
313
  }
228
- // .full.md and _manifest.json are skipped (isDerivedArtifact returns true)
314
+ // .full.md, .abstract.md, and _manifest.json are skipped (isDerivedArtifact returns true)
229
315
  }
230
- // Read indexable documents for BM25 index
231
316
  const documentPromises = indexableFiles.map(async ({ mtime, path: filePath }) => {
232
317
  try {
233
- const fullPath = join(contextTreePath, filePath);
318
+ const fullPath = join(origin.contextTreeRoot, filePath);
234
319
  const { content } = await fileSystem.readFile(fullPath);
235
320
  const title = extractTitle(content, filePath.replace(/\.md$/, '').split('/').pop() || filePath);
236
321
  const scoring = parseFrontmatterScoring(content) ?? applyDefaultScoring();
237
- return {
322
+ const qualifiedId = `${origin.originKey}::${filePath}`;
323
+ const symbolPath = getSymbolPath(origin, filePath);
324
+ // Check if a .overview.md sibling exists (written by abstract generation queue)
325
+ const overviewRelPath = filePath.replace(/\.md$/, OVERVIEW_EXTENSION);
326
+ const overviewPath = knownPaths.has(overviewRelPath) ? overviewRelPath : undefined;
327
+ const doc = {
238
328
  content,
239
- id: filePath,
329
+ id: qualifiedId,
240
330
  mtime,
331
+ origin: origin.origin,
332
+ originContextTreeRoot: origin.contextTreeRoot,
333
+ originKey: origin.originKey,
334
+ ...(overviewPath !== undefined && { overviewPath }),
241
335
  path: filePath,
242
336
  scoring,
337
+ symbolPath,
243
338
  title,
244
339
  };
340
+ if (origin.alias)
341
+ doc.originAlias = origin.alias;
342
+ return doc;
245
343
  }
246
344
  catch {
247
345
  return null;
248
346
  }
249
347
  });
250
- // Read _index.md files separately for summaryMap (not indexed in BM25)
251
348
  const summaryPromises = summaryFiles.map(async ({ path: filePath }) => {
252
349
  try {
253
- const fullPath = join(contextTreePath, filePath);
350
+ const fullPath = join(origin.contextTreeRoot, filePath);
254
351
  const { content } = await fileSystem.readFile(fullPath);
255
352
  const fm = parseSummaryFrontmatter(content);
256
353
  if (!fm)
257
354
  return null;
355
+ // Persist frontmatter scoring so propagateScoresToParents can apply hotness/tier boosts
356
+ const frontmatter = parseFrontmatterScoring(content);
357
+ const scoring = frontmatter
358
+ ? { importance: frontmatter.importance, maturity: frontmatter.maturity, recency: frontmatter.recency }
359
+ : undefined;
258
360
  return {
259
361
  condensationOrder: fm.condensation_order,
260
- path: filePath,
362
+ excerpt: stripMarkdownFrontmatter(content).slice(0, 400),
363
+ path: getSymbolPath(origin, filePath),
364
+ scoring,
261
365
  tokenCount: fm.token_count,
262
366
  };
263
367
  }
@@ -265,20 +369,18 @@ async function buildFreshIndex(fileSystem, contextTreePath, filesWithMtime) {
265
369
  return null;
266
370
  }
267
371
  });
268
- const [docResults, summaryResults] = await Promise.all([
269
- Promise.all(documentPromises),
270
- Promise.all(summaryPromises),
271
- ]);
372
+ const [docResults, summaryResults] = await Promise.all([Promise.all(documentPromises), Promise.all(summaryPromises)]);
272
373
  const documents = docResults.filter((doc) => doc !== null);
273
- const documentMap = new Map();
274
374
  const fileMtimes = new Map();
275
375
  for (const doc of documents) {
276
- documentMap.set(doc.id, doc);
277
- fileMtimes.set(doc.path, doc.mtime);
376
+ fileMtimes.set(doc.id, doc.mtime);
278
377
  }
279
- // Also track summary file mtimes for cache invalidation
280
378
  for (const sf of summaryFiles) {
281
- fileMtimes.set(sf.path, sf.mtime);
379
+ fileMtimes.set(`${origin.originKey}::${sf.path}`, sf.mtime);
380
+ }
381
+ // Track .overview.md mtimes so the cache invalidates when a new overview is written
382
+ for (const ov of overviewFiles) {
383
+ fileMtimes.set(`${origin.originKey}::${ov.path}`, ov.mtime);
282
384
  }
283
385
  const summaryMap = new Map();
284
386
  for (const summary of summaryResults) {
@@ -286,28 +388,87 @@ async function buildFreshIndex(fileSystem, contextTreePath, filesWithMtime) {
286
388
  summaryMap.set(summary.path, summary);
287
389
  }
288
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
+ }
289
439
  const index = new MiniSearch(MINISEARCH_OPTIONS);
290
- index.addAll(documents);
291
- // Build symbolic structures from the document map, with summary annotations
292
- const symbolTree = buildSymbolTree(documentMap, summaryMap);
293
- 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])));
294
448
  return {
295
449
  contextTreePath,
296
450
  documentMap,
297
451
  fileMtimes,
298
452
  index,
299
453
  lastValidatedAt: now,
454
+ pathToDocumentId,
300
455
  referenceIndex,
301
456
  schemaVersion: INDEX_SCHEMA_VERSION,
457
+ sharedOrigins,
458
+ sourcesFileMtime,
302
459
  summaryMap,
460
+ symbolPathDocMap,
303
461
  symbolTree,
304
462
  };
305
463
  }
306
464
  /**
307
465
  * Acquires the search index, using cached data when valid or building a fresh index.
308
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.
309
470
  */
310
- async function acquireIndex(state, fileSystem, contextTreePath, ttlMs, onBeforeBuild) {
471
+ async function acquireIndex(state, fileSystem, contextTreePath, baseDirectory, ttlMs, onBeforeBuild) {
311
472
  const now = Date.now();
312
473
  // Fast path: TTL-based cache hit (no I/O needed)
313
474
  if (state.cachedIndex &&
@@ -323,6 +484,23 @@ async function acquireIndex(state, fileSystem, contextTreePath, ttlMs, onBeforeB
323
484
  }
324
485
  // Create and store the build promise SYNCHRONOUSLY before any await
325
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
+ };
326
504
  const buildPromise = (async () => {
327
505
  // Check if context tree exists (only if no cache or different path)
328
506
  if (!state.cachedIndex || state.cachedIndex.contextTreePath !== contextTreePath) {
@@ -330,32 +508,55 @@ async function acquireIndex(state, fileSystem, contextTreePath, ttlMs, onBeforeB
330
508
  await fileSystem.listDirectory(contextTreePath);
331
509
  }
332
510
  catch {
333
- // Return empty index to signal error - caller will handle
334
- const emptyIndex = new MiniSearch(MINISEARCH_OPTIONS);
335
- return {
336
- contextTreePath: '',
337
- documentMap: new Map(),
338
- fileMtimes: new Map(),
339
- index: emptyIndex,
340
- lastValidatedAt: 0,
341
- referenceIndex: { backlinks: new Map(), forwardLinks: new Map() },
342
- schemaVersion: INDEX_SCHEMA_VERSION,
343
- summaryMap: new Map(),
344
- symbolTree: { root: [], symbolMap: new Map() },
345
- };
511
+ return emptyResult();
346
512
  }
347
513
  }
348
- const allFiles = await findMarkdownFilesWithMtime(fileSystem, contextTreePath);
514
+ // Self-load knowledge sources mtime-based invalidation
515
+ const loadedSources = loadSources(baseDirectory);
516
+ const sourcesFileMtime = loadedSources?.mtime;
517
+ const sharedOrigins = loadedSources?.origins ?? [];
518
+ let allFiles = await findMarkdownFilesWithMtime(fileSystem, contextTreePath);
349
519
  // Exclude non-indexable derived artifacts (.full.md) so that currentFiles
350
520
  // matches what buildFreshIndex tracks in fileMtimes. Without this filter,
351
521
  // isCacheValid() sees a size mismatch once archives exist, causing cache thrash.
352
522
  // _index.md is kept (tracked for summary staleness), .stub.md is kept (BM25 indexed).
353
- const currentFiles = allFiles.filter((f) => !isDerivedArtifact(f.path) || f.path.split('/').at(-1) === SUMMARY_INDEX_FILE);
354
- // Re-check cache validity after getting file list (another call may have finished)
355
- if (state.cachedIndex &&
523
+ // Keep _index.md (summary tracking) and .overview.md (sibling detection for overviewPath).
524
+ // .full.md, .abstract.md, and _manifest.json remain excluded.
525
+ let localFiles = allFiles.filter((f) => !isDerivedArtifact(f.path) ||
526
+ f.path.split('/').at(-1) === SUMMARY_INDEX_FILE ||
527
+ f.path.endsWith(OVERVIEW_EXTENSION));
528
+ // Flush pending access hits before reusing a stale-enough cache entry.
529
+ // The flush updates frontmatter on disk, so refresh mtimes before the cache-valid check.
530
+ if (onBeforeBuild) {
531
+ const wroteScoringUpdates = await onBeforeBuild(contextTreePath);
532
+ if (wroteScoringUpdates) {
533
+ allFiles = await findMarkdownFilesWithMtime(fileSystem, contextTreePath);
534
+ localFiles = allFiles.filter((f) => !isDerivedArtifact(f.path) ||
535
+ f.path.split('/').at(-1) === SUMMARY_INDEX_FILE ||
536
+ f.path.endsWith(OVERVIEW_EXTENSION));
537
+ }
538
+ }
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 &&
356
557
  state.cachedIndex.contextTreePath === contextTreePath &&
357
558
  state.cachedIndex.schemaVersion === INDEX_SCHEMA_VERSION &&
358
- isCacheValid(state.cachedIndex, currentFiles)) {
559
+ isCacheValid(state.cachedIndex, allQualifiedFiles)) {
359
560
  // Update timestamp atomically by creating a new object
360
561
  const updatedCache = {
361
562
  ...state.cachedIndex,
@@ -364,12 +565,8 @@ async function acquireIndex(state, fileSystem, contextTreePath, ttlMs, onBeforeB
364
565
  state.cachedIndex = updatedCache;
365
566
  return updatedCache;
366
567
  }
367
- // Flush pending access hits before building so updated scoring is picked up
368
- if (onBeforeBuild) {
369
- await onBeforeBuild(contextTreePath);
370
- }
371
- // Build fresh index
372
- 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);
373
570
  state.cachedIndex = freshIndex;
374
571
  return freshIndex;
375
572
  })();
@@ -420,7 +617,7 @@ export class SearchKnowledgeService {
420
617
  */
421
618
  async flushAccessHits(contextTreePath) {
422
619
  if (this.pendingAccessHits.size === 0) {
423
- return;
620
+ return false;
424
621
  }
425
622
  const hits = new Map(this.pendingAccessHits);
426
623
  this.pendingAccessHits.clear();
@@ -440,6 +637,7 @@ export class SearchKnowledgeService {
440
637
  }
441
638
  });
442
639
  await Promise.allSettled(tasks);
640
+ return true;
443
641
  }
444
642
  /**
445
643
  * Search the knowledge base for relevant topics.
@@ -451,14 +649,15 @@ export class SearchKnowledgeService {
451
649
  */
452
650
  async search(query, options) {
453
651
  const limit = options?.limit ?? 10;
454
- const contextTreePath = join(this.baseDirectory, BRV_DIR, CONTEXT_TREE_DIR);
652
+ const resolvedBaseDirectory = await realpath(this.baseDirectory).catch(() => this.baseDirectory);
653
+ const contextTreePath = join(resolvedBaseDirectory, BRV_DIR, CONTEXT_TREE_DIR);
455
654
  // Acquire index with parallel-safe locking; flush pending access hits before any rebuild
456
- 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));
457
656
  // Handle error case (context tree not initialized)
458
657
  if ('error' in indexResult) {
459
658
  return indexResult.result;
460
659
  }
461
- const { documentMap, index, referenceIndex, symbolTree } = indexResult;
660
+ const { documentMap, index, pathToDocumentId, referenceIndex, summaryMap, symbolPathDocMap, symbolTree } = indexResult;
462
661
  if (documentMap.size === 0) {
463
662
  return {
464
663
  message: 'Context tree is empty. Use /curate to add knowledge.',
@@ -472,7 +671,7 @@ export class SearchKnowledgeService {
472
671
  }
473
672
  // Symbolic path resolution: try path-based query first
474
673
  if (isPathLikeQuery(query, symbolTree)) {
475
- const symbolicResult = this.trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, options);
674
+ const symbolicResult = this.trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, pathToDocumentId, summaryMap, symbolPathDocMap, options);
476
675
  if (symbolicResult) {
477
676
  return symbolicResult;
478
677
  }
@@ -482,10 +681,10 @@ export class SearchKnowledgeService {
482
681
  const effectiveScope = options?.scope ?? parsed.scopePath;
483
682
  const effectiveQuery = parsed.scopePath ? parsed.textQuery : query;
484
683
  // Run text-based MiniSearch (existing pipeline), optionally scoped to a subtree
485
- const textResult = this.runTextSearch(effectiveQuery || query, documentMap, index, limit, effectiveScope, symbolTree, referenceIndex, options);
684
+ const textResult = this.runTextSearch(effectiveQuery || query, documentMap, index, limit, effectiveScope, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options);
486
685
  // If scoped search returned nothing and we had a scope, fall back to global search
487
686
  if (textResult.results.length === 0 && effectiveScope && effectiveQuery) {
488
- return this.runTextSearch(query, documentMap, index, limit, undefined, symbolTree, referenceIndex, options);
687
+ return this.runTextSearch(query, documentMap, index, limit, undefined, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options);
489
688
  }
490
689
  return textResult;
491
690
  }
@@ -521,14 +720,15 @@ export class SearchKnowledgeService {
521
720
  * For archive stubs, extracts points_to path into archiveFullPath.
522
721
  */
523
722
  enrichResult(result, symbolTree, referenceIndex, documentMap) {
524
- 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);
525
726
  const backlinks = referenceIndex.backlinks.get(result.path);
526
727
  // Detect archive stubs and extract points_to for drill-down
527
728
  let archiveFullPath;
528
729
  let symbolKind = symbol ? getSymbolKindLabel(symbol.kind) : undefined;
529
730
  if (isArchiveStub(result.path)) {
530
731
  symbolKind = 'archive_stub';
531
- const doc = documentMap.get(result.path);
532
732
  if (doc) {
533
733
  const stubFm = parseArchiveStubFrontmatter(doc.content);
534
734
  if (stubFm) {
@@ -536,27 +736,51 @@ export class SearchKnowledgeService {
536
736
  }
537
737
  }
538
738
  }
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;
743
+ const overviewPath = doc?.overviewPath;
744
+ const isContextSummary = doc?.path.endsWith('/context.md') || doc?.path === 'context.md';
745
+ const summaryPath = isContextSummary
746
+ ? doc?.path.slice(0, -'/context.md'.length) || doc?.path || result.path
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;
539
751
  return {
540
- ...result,
752
+ ...rest,
541
753
  ...(archiveFullPath && { archiveFullPath }),
754
+ ...(overviewPath && { overviewPath }),
542
755
  backlinkCount: backlinks?.length ?? 0,
756
+ ...(origin && { origin }),
757
+ ...(originAlias && { originAlias }),
758
+ ...(originContextTreeRoot && { originContextTreeRoot }),
759
+ ...(isContextSummary && { path: summaryPath }),
543
760
  relatedPaths: backlinks?.slice(0, 3),
544
- symbolKind,
545
- symbolPath: symbol?.path,
761
+ symbolKind: isContextSummary ? 'summary' : symbolKind,
762
+ symbolPath: isContextSummary ? summaryPath : symbol?.path,
546
763
  };
547
764
  }
548
765
  /**
549
766
  * Run the standard text-based MiniSearch pipeline, optionally scoped to a subtree.
550
767
  */
551
- runTextSearch(query, documentMap, index, limit, scopePath, symbolTree, referenceIndex, options) {
768
+ runTextSearch(query, documentMap, index, limit, scopePath, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options) {
552
769
  const filteredQuery = filterStopWords(query);
553
770
  const filteredWords = filteredQuery.split(/\s+/).filter((w) => w.length >= 2);
554
771
  // Build scope filter if a subtree is specified
555
772
  let scopeFilter;
556
773
  if (scopePath) {
557
- const subtreeIds = getSubtreeDocumentIds(symbolTree, scopePath);
558
- if (subtreeIds.size > 0) {
559
- 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);
560
784
  }
561
785
  }
562
786
  // AND-first strategy: for multi-word queries, try AND for concentrated scores.
@@ -576,6 +800,7 @@ export class SearchKnowledgeService {
576
800
  }
577
801
  // Normalize BM25 scores to [0, 1) then blend with importance + recency via compound scoring.
578
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.
579
804
  const now = Date.now();
580
805
  const searchResults = rawResults.map((r) => {
581
806
  const doc = documentMap.get(r.id);
@@ -583,13 +808,21 @@ export class SearchKnowledgeService {
583
808
  const daysSince = doc ? Math.max(0, (now - doc.mtime) / 86_400_000) : 0;
584
809
  const decayed = applyDecay(scoring, daysSince);
585
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
+ }
586
816
  return {
587
817
  ...r,
588
- score: compoundScore(bm25, decayed.importance ?? 50, decayed.recency ?? 1, decayed.maturity ?? 'draft'),
818
+ bm25Score: bm25,
819
+ score: finalScore,
589
820
  };
590
821
  });
591
822
  searchResults.sort((a, b) => b.score - a.score);
592
823
  const results = [];
824
+ const propagationInputs = [];
825
+ let scoreFloor;
593
826
  if (searchResults.length > 0) {
594
827
  // OOD detection: if the best result scores below the minimum floor,
595
828
  // the query has no meaningful match in the knowledge base.
@@ -615,7 +848,7 @@ export class SearchKnowledgeService {
615
848
  };
616
849
  }
617
850
  const topScore = searchResults[0].score;
618
- const scoreFloor = topScore * SCORE_GAP_RATIO;
851
+ scoreFloor = topScore * SCORE_GAP_RATIO;
619
852
  const resultLimit = Math.min(limit, searchResults.length);
620
853
  for (let i = 0; i < resultLimit; i++) {
621
854
  const result = searchResults[i];
@@ -626,6 +859,7 @@ export class SearchKnowledgeService {
626
859
  if (document) {
627
860
  const enriched = this.enrichResult({
628
861
  excerpt: extractExcerpt(document.content, query),
862
+ id: result.id,
629
863
  path: document.path,
630
864
  score: Math.round(result.score * 100) / 100,
631
865
  title: document.title,
@@ -638,22 +872,55 @@ export class SearchKnowledgeService {
638
872
  continue;
639
873
  }
640
874
  if (options?.minMaturity && enriched.symbolKind) {
641
- const tierRank = { core: 3, draft: 1, validated: 2 };
642
- const symbol = symbolTree.symbolMap.get(document.path);
643
- const docMaturity = symbol?.metadata.maturity ?? 'draft';
644
- if ((tierRank[docMaturity] ?? 1) < (tierRank[options.minMaturity] ?? 1)) {
875
+ const docMaturity = enriched.symbolKind === 'summary'
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');
880
+ if ((MATURITY_TIER_RANK[docMaturity] ?? 1) < (MATURITY_TIER_RANK[options.minMaturity] ?? 1)) {
645
881
  continue;
646
882
  }
647
883
  }
648
884
  results.push(enriched);
885
+ propagationInputs.push({
886
+ bm25Score: result.bm25Score,
887
+ path: document.symbolPath,
888
+ });
649
889
  }
650
890
  }
651
891
  }
652
- // Accumulate access hits for returned results (flushed during next index rebuild)
653
- // Disabled for benchmark: prevents feedback loop from distorting scores across queries
654
- // if (results.length > 0) {
655
- // this.accumulateAccessHits(results.map((r) => r.path))
656
- // }
892
+ // Propagate scores upward to parent domain/topic nodes (hierarchical retrieval)
893
+ const propagated = propagateScoresToParents(propagationInputs, symbolTree, summaryMap, symbolPathDocMap);
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);
898
+ if (scoreFloor !== undefined && p.score < scoreFloor)
899
+ continue;
900
+ if (options?.includeKinds && p.symbolKind && !options.includeKinds.includes(p.symbolKind))
901
+ continue;
902
+ if (options?.excludeKinds && p.symbolKind && options.excludeKinds.includes(p.symbolKind))
903
+ continue;
904
+ if (options?.minMaturity && p.symbolKind === 'summary') {
905
+ const summaryDoc = getSummarySource(p.path, summaryMap, symbolPathDocMap);
906
+ const summaryMaturity = summaryDoc?.scoring?.maturity ?? 'draft';
907
+ if ((MATURITY_TIER_RANK[summaryMaturity] ?? 1) < (MATURITY_TIER_RANK[options.minMaturity] ?? 1))
908
+ continue;
909
+ }
910
+ results.push(p);
911
+ }
912
+ if (propagated.length > 0) {
913
+ results.sort((a, b) => b.score - a.score);
914
+ // Trim back to the caller-requested limit after propagated entries are merged in.
915
+ if (results.length > limit)
916
+ results.splice(limit);
917
+ }
918
+ // Accumulate access hits for returned results (flushed during next index rebuild).
919
+ // Synthetic 'summary' results carry folder-style paths (e.g. 'auth') that are not
920
+ // real files; map them to their _index.md so flushAccessHits can read and update them.
921
+ if (results.length > 0) {
922
+ this.accumulateAccessHits(results.map((r) => r.symbolKind === 'summary' ? getSummaryAccessPath(r.path, summaryMap, symbolPathDocMap) : r.path));
923
+ }
657
924
  return {
658
925
  message: results.length > 0
659
926
  ? `Found ${searchResults.length} result(s). Use read_file to view full content.`
@@ -665,7 +932,7 @@ export class SearchKnowledgeService {
665
932
  /**
666
933
  * Try to resolve the query as a symbolic path. Returns null if no path match found.
667
934
  */
668
- trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, options) {
935
+ trySymbolicSearch(query, symbolTree, referenceIndex, documentMap, index, limit, pathToDocumentId, summaryMap, symbolPathDocMap, options) {
669
936
  const pathMatches = matchMemoryPath(symbolTree, query.split(/\s+/)[0].includes('/') ? query.split(/\s+/)[0] : query);
670
937
  if (pathMatches.length === 0) {
671
938
  return null;
@@ -673,12 +940,15 @@ export class SearchKnowledgeService {
673
940
  const topMatch = pathMatches[0].matchedSymbol;
674
941
  // If the matched symbol is a leaf Context, return it directly
675
942
  if (topMatch.kind === MemorySymbolKind.Context) {
676
- const doc = documentMap.get(topMatch.path);
943
+ const docId = pathToDocumentId.get(topMatch.path);
944
+ const doc = docId ? documentMap.get(docId) : undefined;
677
945
  if (!doc) {
678
946
  return null;
679
947
  }
680
- const result = this.enrichResult({ excerpt: extractExcerpt(doc.content, query), path: doc.path, score: 1, title: doc.title }, symbolTree, referenceIndex, documentMap);
681
- 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
+ }
682
952
  return {
683
953
  message: `Found exact match: ${topMatch.path}`,
684
954
  results: [result],
@@ -691,21 +961,37 @@ export class SearchKnowledgeService {
691
961
  const textPart = query.slice(query.indexOf(pathPart) + pathPart.length).trim();
692
962
  if (textPart) {
693
963
  // Scoped search: search text within the matched subtree
694
- return this.runTextSearch(textPart, documentMap, index, limit, topMatch.path, symbolTree, referenceIndex, options);
964
+ return this.runTextSearch(textPart, documentMap, index, limit, topMatch.path, pathToDocumentId, symbolTree, referenceIndex, summaryMap, symbolPathDocMap, options);
695
965
  }
696
966
  // No text part — return all children of the matched node
697
967
  const subtreeIds = getSubtreeDocumentIds(symbolTree, topMatch.path);
698
968
  const results = [];
699
- for (const docId of subtreeIds) {
969
+ const accessHitPaths = [];
970
+ const summaryDoc = getSummarySource(topMatch.path, summaryMap, symbolPathDocMap);
971
+ if (summaryDoc) {
972
+ results.push({
973
+ backlinkCount: 0,
974
+ excerpt: summaryDoc.excerpt,
975
+ path: topMatch.path,
976
+ score: 1,
977
+ symbolKind: 'summary',
978
+ symbolPath: topMatch.path,
979
+ title: topMatch.name,
980
+ });
981
+ accessHitPaths.push(summaryDoc.path);
982
+ }
983
+ for (const symbolPath of subtreeIds) {
700
984
  if (results.length >= limit)
701
985
  break;
702
- const doc = documentMap.get(docId);
986
+ const docId = pathToDocumentId.get(symbolPath);
987
+ const doc = docId ? documentMap.get(docId) : undefined;
703
988
  if (!doc)
704
989
  continue;
705
- 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));
991
+ accessHitPaths.push(doc.path);
706
992
  }
707
- if (results.length > 0) {
708
- this.accumulateAccessHits(results.map((r) => r.path));
993
+ if (accessHitPaths.length > 0) {
994
+ this.accumulateAccessHits(accessHitPaths);
709
995
  }
710
996
  return {
711
997
  message: `Found ${results.length} entries under ${topMatch.path}. Use read_file to view full content.`,