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.
- package/.env.production +4 -0
- package/README.md +17 -0
- package/dist/agent/core/domain/tools/constants.d.ts +1 -0
- package/dist/agent/core/domain/tools/constants.js +1 -0
- package/dist/agent/core/interfaces/cipher-services.d.ts +8 -0
- package/dist/agent/core/interfaces/i-cipher-agent.d.ts +1 -0
- package/dist/agent/infra/agent/agent-error-codes.d.ts +0 -1
- package/dist/agent/infra/agent/agent-error-codes.js +0 -1
- package/dist/agent/infra/agent/agent-error.d.ts +0 -1
- package/dist/agent/infra/agent/agent-error.js +0 -1
- 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/agent/agent-state-manager.d.ts +1 -3
- package/dist/agent/infra/agent/agent-state-manager.js +1 -3
- package/dist/agent/infra/agent/base-agent.d.ts +1 -1
- package/dist/agent/infra/agent/base-agent.js +1 -1
- package/dist/agent/infra/agent/cipher-agent.d.ts +15 -1
- package/dist/agent/infra/agent/cipher-agent.js +188 -3
- package/dist/agent/infra/agent/index.d.ts +1 -1
- package/dist/agent/infra/agent/index.js +1 -1
- package/dist/agent/infra/agent/service-initializer.d.ts +3 -3
- package/dist/agent/infra/agent/service-initializer.js +14 -8
- package/dist/agent/infra/agent/types.d.ts +0 -1
- package/dist/agent/infra/file-system/file-system-service.js +6 -5
- package/dist/agent/infra/folder-pack/folder-pack-service.d.ts +1 -0
- package/dist/agent/infra/folder-pack/folder-pack-service.js +29 -15
- package/dist/agent/infra/llm/providers/openai.js +12 -0
- package/dist/agent/infra/llm/stream-to-text.d.ts +7 -0
- package/dist/agent/infra/llm/stream-to-text.js +14 -0
- package/dist/agent/infra/map/abstract-generator.d.ts +22 -0
- package/dist/agent/infra/map/abstract-generator.js +67 -0
- package/dist/agent/infra/map/abstract-queue.d.ts +67 -0
- package/dist/agent/infra/map/abstract-queue.js +218 -0
- package/dist/agent/infra/memory/memory-deduplicator.d.ts +44 -0
- package/dist/agent/infra/memory/memory-deduplicator.js +88 -0
- package/dist/agent/infra/memory/memory-manager.d.ts +1 -0
- package/dist/agent/infra/memory/memory-manager.js +6 -5
- package/dist/agent/infra/sandbox/curate-service.d.ts +4 -2
- package/dist/agent/infra/sandbox/curate-service.js +20 -7
- package/dist/agent/infra/sandbox/local-sandbox.d.ts +5 -0
- package/dist/agent/infra/sandbox/local-sandbox.js +57 -1
- package/dist/agent/infra/sandbox/sandbox-service.js +1 -0
- package/dist/agent/infra/sandbox/tools-sdk.d.ts +13 -1
- package/dist/agent/infra/sandbox/tools-sdk.js +9 -1
- package/dist/agent/infra/session/session-compressor.d.ts +43 -0
- package/dist/agent/infra/session/session-compressor.js +296 -0
- package/dist/agent/infra/session/session-manager.d.ts +7 -0
- package/dist/agent/infra/session/session-manager.js +9 -0
- package/dist/agent/infra/tools/implementations/curate-tool.d.ts +3 -2
- package/dist/agent/infra/tools/implementations/curate-tool.js +54 -27
- package/dist/agent/infra/tools/implementations/expand-knowledge-tool.d.ts +3 -3
- package/dist/agent/infra/tools/implementations/expand-knowledge-tool.js +34 -7
- package/dist/agent/infra/tools/implementations/ingest-resource-tool.d.ts +17 -0
- package/dist/agent/infra/tools/implementations/ingest-resource-tool.js +224 -0
- package/dist/agent/infra/tools/implementations/memory-symbol-tree.d.ts +8 -0
- package/dist/agent/infra/tools/implementations/search-knowledge-service.d.ts +1 -1
- package/dist/agent/infra/tools/implementations/search-knowledge-service.js +392 -106
- package/dist/agent/infra/tools/implementations/search-knowledge-tool.js +2 -2
- 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-provider.js +1 -0
- package/dist/agent/infra/tools/tool-registry.d.ts +3 -0
- package/dist/agent/infra/tools/tool-registry.js +16 -5
- 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.js +4 -3
- package/dist/oclif/commands/curate/view.js +2 -2
- package/dist/oclif/commands/main.js +13 -0
- package/dist/oclif/commands/query.js +4 -3
- 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 +41 -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 -3
- package/dist/server/constants.d.ts +8 -0
- 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/knowledge/memory-scoring.d.ts +3 -3
- package/dist/server/core/domain/knowledge/memory-scoring.js +5 -5
- package/dist/server/core/domain/knowledge/summary-types.d.ts +4 -0
- 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 +4 -0
- 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/infra/client/client-manager.d.ts +1 -0
- package/dist/server/infra/client/client-manager.js +16 -0
- package/dist/server/infra/context-tree/derived-artifact.js +5 -1
- package/dist/server/infra/context-tree/file-context-tree-manifest-service.d.ts +2 -1
- package/dist/server/infra/context-tree/file-context-tree-manifest-service.js +43 -7
- package/dist/server/infra/context-tree/file-context-tree-summary-service.js +20 -2
- package/dist/server/infra/daemon/agent-process.js +15 -5
- package/dist/server/infra/executor/curate-executor.js +6 -3
- package/dist/server/infra/executor/direct-search-responder.js +5 -1
- package/dist/server/infra/executor/folder-pack-executor.js +88 -7
- package/dist/server/infra/executor/query-executor.d.ts +23 -0
- package/dist/server/infra/executor/query-executor.js +125 -23
- 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/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 +65 -13
- 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/utils/curate-result-parser.d.ts +4 -4
- 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/types/dto.d.ts +26 -0
- 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 +23 -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/oclif.manifest.json +261 -1
- 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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
//
|
|
355
|
-
|
|
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,
|
|
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
|
-
//
|
|
368
|
-
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
708
|
-
this.accumulateAccessHits(
|
|
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.`,
|