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.
- package/README.md +17 -0
- package/dist/agent/infra/agent/agent-schemas.d.ts +8 -0
- package/dist/agent/infra/agent/agent-schemas.js +1 -0
- package/dist/agent/infra/sandbox/curate-service.js +14 -0
- package/dist/agent/infra/sandbox/sandbox-service.js +1 -0
- package/dist/agent/infra/sandbox/tools-sdk.d.ts +10 -0
- package/dist/agent/infra/sandbox/tools-sdk.js +9 -1
- package/dist/agent/infra/tools/implementations/search-knowledge-service.js +226 -103
- package/dist/agent/infra/tools/implementations/write-file-tool.d.ts +2 -1
- package/dist/agent/infra/tools/implementations/write-file-tool.js +16 -2
- package/dist/agent/infra/tools/tool-registry.js +1 -1
- package/dist/agent/infra/tools/write-guard.d.ts +11 -0
- package/dist/agent/infra/tools/write-guard.js +48 -0
- package/dist/agent/resources/prompts/system-prompt.yml +9 -0
- package/dist/agent/resources/tools/expand_knowledge.txt +4 -0
- package/dist/agent/resources/tools/search_knowledge.txt +11 -1
- package/dist/oclif/commands/curate/index.d.ts +1 -0
- package/dist/oclif/commands/curate/index.js +19 -4
- package/dist/oclif/commands/curate/view.js +2 -2
- package/dist/oclif/commands/main.js +13 -0
- package/dist/oclif/commands/query.d.ts +1 -0
- package/dist/oclif/commands/query.js +19 -4
- package/dist/oclif/commands/search.d.ts +20 -0
- package/dist/oclif/commands/search.js +186 -0
- package/dist/oclif/commands/source/add.d.ts +12 -0
- package/dist/oclif/commands/source/add.js +42 -0
- package/dist/oclif/commands/source/index.d.ts +6 -0
- package/dist/oclif/commands/source/index.js +8 -0
- package/dist/oclif/commands/source/list.d.ts +6 -0
- package/dist/oclif/commands/source/list.js +32 -0
- package/dist/oclif/commands/source/remove.d.ts +9 -0
- package/dist/oclif/commands/source/remove.js +33 -0
- package/dist/oclif/commands/status.d.ts +5 -1
- package/dist/oclif/commands/status.js +45 -6
- package/dist/oclif/commands/worktree/add.d.ts +12 -0
- package/dist/oclif/commands/worktree/add.js +44 -0
- package/dist/oclif/commands/worktree/index.d.ts +6 -0
- package/dist/oclif/commands/worktree/index.js +8 -0
- package/dist/oclif/commands/worktree/list.d.ts +6 -0
- package/dist/oclif/commands/worktree/list.js +28 -0
- package/dist/oclif/commands/worktree/remove.d.ts +9 -0
- package/dist/oclif/commands/worktree/remove.js +35 -0
- package/dist/oclif/hooks/init/validate-brv-config.js +4 -0
- package/dist/oclif/lib/daemon-client.d.ts +4 -2
- package/dist/oclif/lib/daemon-client.js +19 -4
- package/dist/oclif/lib/search-format.d.ts +10 -0
- package/dist/oclif/lib/search-format.js +25 -0
- package/dist/oclif/lib/task-client.d.ts +6 -0
- package/dist/oclif/lib/task-client.js +10 -3
- package/dist/server/constants.d.ts +7 -1
- package/dist/server/constants.js +10 -0
- package/dist/server/core/domain/client/client-info.d.ts +7 -0
- package/dist/server/core/domain/client/client-info.js +11 -0
- package/dist/server/core/domain/errors/task-error.d.ts +2 -2
- package/dist/server/core/domain/errors/task-error.js +5 -4
- package/dist/server/core/domain/project/worktrees-schema.d.ts +29 -0
- package/dist/server/core/domain/project/worktrees-schema.js +17 -0
- package/dist/server/core/domain/source/source-operations.d.ts +31 -0
- package/dist/server/core/domain/source/source-operations.js +201 -0
- package/dist/server/core/domain/source/source-schema.d.ts +94 -0
- package/dist/server/core/domain/source/source-schema.js +121 -0
- package/dist/server/core/domain/transport/schemas.d.ts +18 -10
- package/dist/server/core/domain/transport/schemas.js +7 -3
- package/dist/server/core/domain/transport/task-info.d.ts +2 -0
- package/dist/server/core/interfaces/client/i-client-manager.d.ts +13 -0
- package/dist/server/core/interfaces/executor/i-curate-executor.d.ts +4 -0
- package/dist/server/core/interfaces/executor/i-folder-pack-executor.d.ts +7 -3
- package/dist/server/core/interfaces/executor/i-query-executor.d.ts +2 -0
- package/dist/server/core/interfaces/executor/i-search-executor.d.ts +34 -0
- package/dist/server/core/interfaces/executor/i-search-executor.js +1 -0
- package/dist/server/core/interfaces/executor/index.d.ts +1 -0
- package/dist/server/core/interfaces/executor/index.js +1 -0
- package/dist/server/infra/client/client-manager.d.ts +1 -0
- package/dist/server/infra/client/client-manager.js +16 -0
- package/dist/server/infra/daemon/agent-process.js +35 -12
- package/dist/server/infra/executor/curate-executor.js +4 -2
- package/dist/server/infra/executor/direct-search-responder.js +5 -1
- package/dist/server/infra/executor/folder-pack-executor.js +23 -12
- package/dist/server/infra/executor/query-executor.d.ts +23 -0
- package/dist/server/infra/executor/query-executor.js +115 -21
- package/dist/server/infra/executor/search-executor.d.ts +17 -0
- package/dist/server/infra/executor/search-executor.js +30 -0
- package/dist/server/infra/mcp/mcp-mode-detector.d.ts +7 -5
- package/dist/server/infra/mcp/mcp-mode-detector.js +11 -18
- package/dist/server/infra/mcp/mcp-server.d.ts +1 -0
- package/dist/server/infra/mcp/mcp-server.js +11 -6
- package/dist/server/infra/mcp/tools/brv-curate-tool.d.ts +2 -1
- package/dist/server/infra/mcp/tools/brv-curate-tool.js +9 -16
- package/dist/server/infra/mcp/tools/brv-query-tool.d.ts +2 -1
- package/dist/server/infra/mcp/tools/brv-query-tool.js +9 -16
- package/dist/server/infra/mcp/tools/mcp-project-context.d.ts +11 -0
- package/dist/server/infra/mcp/tools/mcp-project-context.js +54 -0
- package/dist/server/infra/process/connection-coordinator.js +11 -0
- package/dist/server/infra/process/feature-handlers.js +4 -1
- package/dist/server/infra/process/task-router.d.ts +1 -0
- package/dist/server/infra/process/task-router.js +60 -5
- package/dist/server/infra/project/resolve-project.d.ts +106 -0
- package/dist/server/infra/project/resolve-project.js +473 -0
- package/dist/server/infra/transport/handlers/index.d.ts +4 -0
- package/dist/server/infra/transport/handlers/index.js +2 -0
- package/dist/server/infra/transport/handlers/pull-handler.js +3 -3
- package/dist/server/infra/transport/handlers/push-handler.js +3 -3
- package/dist/server/infra/transport/handlers/source-handler.d.ts +12 -0
- package/dist/server/infra/transport/handlers/source-handler.js +37 -0
- package/dist/server/infra/transport/handlers/status-handler.js +76 -27
- package/dist/server/infra/transport/handlers/worktree-handler.d.ts +12 -0
- package/dist/server/infra/transport/handlers/worktree-handler.js +67 -0
- package/dist/server/infra/transport/transport-connector.d.ts +10 -4
- package/dist/server/infra/transport/transport-connector.js +2 -2
- package/dist/server/templates/skill/SKILL.md +25 -5
- package/dist/server/utils/path-utils.d.ts +5 -0
- package/dist/server/utils/path-utils.js +11 -1
- package/dist/shared/transport/events/client-events.d.ts +3 -0
- package/dist/shared/transport/events/client-events.js +3 -0
- package/dist/shared/transport/events/index.d.ts +13 -0
- package/dist/shared/transport/events/index.js +9 -0
- package/dist/shared/transport/events/source-events.d.ts +30 -0
- package/dist/shared/transport/events/source-events.js +5 -0
- package/dist/shared/transport/events/status-events.d.ts +5 -0
- package/dist/shared/transport/events/task-events.d.ts +4 -1
- package/dist/shared/transport/events/worktree-events.d.ts +31 -0
- package/dist/shared/transport/events/worktree-events.js +5 -0
- package/dist/shared/transport/search-content.d.ts +28 -0
- package/dist/shared/transport/search-content.js +38 -0
- package/dist/shared/transport/types/dto.d.ts +20 -1
- package/dist/tui/features/commands/definitions/index.js +6 -0
- package/dist/tui/features/commands/definitions/source-add.d.ts +2 -0
- package/dist/tui/features/commands/definitions/source-add.js +48 -0
- package/dist/tui/features/commands/definitions/source-list.d.ts +2 -0
- package/dist/tui/features/commands/definitions/source-list.js +47 -0
- package/dist/tui/features/commands/definitions/source-remove.d.ts +2 -0
- package/dist/tui/features/commands/definitions/source-remove.js +38 -0
- package/dist/tui/features/commands/definitions/source.d.ts +2 -0
- package/dist/tui/features/commands/definitions/source.js +8 -0
- package/dist/tui/features/commands/definitions/worktree-add.d.ts +2 -0
- package/dist/tui/features/commands/definitions/worktree-add.js +35 -0
- package/dist/tui/features/commands/definitions/worktree-list.d.ts +2 -0
- package/dist/tui/features/commands/definitions/worktree-list.js +36 -0
- package/dist/tui/features/commands/definitions/worktree-remove.d.ts +2 -0
- package/dist/tui/features/commands/definitions/worktree-remove.js +33 -0
- package/dist/tui/features/commands/definitions/worktree.d.ts +2 -0
- package/dist/tui/features/commands/definitions/worktree.js +8 -0
- package/dist/tui/features/curate/api/create-curate-task.js +3 -1
- package/dist/tui/features/query/api/create-query-task.js +3 -1
- package/dist/tui/features/source/api/source-api.d.ts +4 -0
- package/dist/tui/features/source/api/source-api.js +22 -0
- package/dist/tui/features/status/api/get-status.js +2 -1
- package/dist/tui/features/status/utils/format-status.js +28 -1
- package/dist/tui/features/transport/components/transport-initializer.js +36 -1
- package/dist/tui/features/worktree/api/worktree-api.d.ts +4 -0
- package/dist/tui/features/worktree/api/worktree-api.js +22 -0
- package/dist/tui/repl-startup.d.ts +2 -0
- package/dist/tui/repl-startup.js +5 -3
- package/dist/tui/stores/transport-store.d.ts +6 -0
- package/dist/tui/stores/transport-store.js +6 -0
- package/dist/tui/utils/error-messages.js +2 -2
- package/oclif.manifest.json +380 -36
- 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 =
|
|
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,
|
|
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,
|
|
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,
|
|
101
|
-
return getSummarySource(path, summaryMap,
|
|
108
|
+
function getSummaryAccessPath(path, summaryMap, symbolPathDocMap) {
|
|
109
|
+
return getSummarySource(path, summaryMap, symbolPathDocMap)?.path ?? `${path}/${SUMMARY_INDEX_FILE}`;
|
|
102
110
|
}
|
|
103
|
-
function getSummarySource(path, summaryMap,
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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(
|
|
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
|
-
|
|
327
|
+
const doc = {
|
|
328
328
|
content,
|
|
329
|
-
id:
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
472
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
681
|
-
|
|
682
|
-
|
|
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:
|
|
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,
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
: symbolTree.symbolMap.get(document.
|
|
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.
|
|
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,
|
|
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,
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
993
|
+
for (const symbolPath of subtreeIds) {
|
|
872
994
|
if (results.length >= limit)
|
|
873
995
|
break;
|
|
874
|
-
const
|
|
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) {
|