diff --git a/package-lock.json b/package-lock.json index af1ed0914..efc2e9799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "byterover-cli", - "version": "2.5.2", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "byterover-cli", - "version": "2.5.2", + "version": "2.6.0", "bundleDependencies": [ "@campfirein/brv-transport-client" ], @@ -3603,7 +3603,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -3721,6 +3720,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3741,6 +3741,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3761,6 +3762,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3781,6 +3783,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3801,6 +3804,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3821,6 +3825,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3841,6 +3846,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3861,6 +3867,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3881,6 +3888,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3901,6 +3909,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3921,6 +3930,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -3995,7 +4005,6 @@ "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.5.4.tgz", "integrity": "sha512-78YYJls8+KG96tReyUsesKKIKqC0qbFSY1peUSrt0P2uGsrgAuU9axQ0iBQdhAlIwZDcTyaj+XXVQkz2kl/O0w==", "license": "MIT", - "peer": true, "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", @@ -4256,7 +4265,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4446,7 +4454,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5896,7 +5903,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5944,7 +5950,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6221,7 +6226,6 @@ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -6726,7 +6730,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6771,7 +6774,6 @@ "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.129.tgz", "integrity": "sha512-IARdFetNTedDfqpByNMm9p0oHj7JS+SpOrbgLdQdyCiDe70Xk07wnKP4Lub1ckCrxkhAxY3yxOHllGEjbpXgpQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@ai-sdk/gateway": "2.0.35", "@ai-sdk/provider": "2.0.1", @@ -7536,7 +7538,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -9310,7 +9311,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9866,7 +9866,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10420,7 +10419,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11667,7 +11665,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -11914,7 +11911,6 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.5.1.tgz", "integrity": "sha512-wF3j/DmkM8q5E+OtfdQhCRw8/0ahkc8CUTgEddxZzpEWPslu7YPL3t64MWRoI9m6upVGpfAg4ms2BBvxCdKRLQ==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", @@ -15700,8 +15696,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-0.0.1.tgz", "integrity": "sha512-fBWNLTBkxkLAhe1AzF1hyXEvuA+N+vV1WMP2D6iiMUblvmOt8Pp5t8zUcgvz7aYA1ldUdxDlgUse15dmcKjkNg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/rambda": { "version": "7.5.0", @@ -15773,7 +15768,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16984,7 +16978,6 @@ "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", @@ -18049,7 +18042,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18837,7 +18829,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "inBundle": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/agent/infra/sandbox/curate-service.ts b/src/agent/infra/sandbox/curate-service.ts index 6de3e926d..7c56d6da2 100644 --- a/src/agent/infra/sandbox/curate-service.ts +++ b/src/agent/infra/sandbox/curate-service.ts @@ -16,6 +16,7 @@ import type { } from '../../core/interfaces/i-curate-service.js' import {executeCurate} from '../tools/implementations/curate-tool.js' +import {validateWriteTarget} from '../tools/write-guard.js' /** * Default base path for knowledge storage. @@ -115,6 +116,20 @@ export class CurateService implements ICurateService { // files are written to the correct project directory, not process.cwd() const basePath = resolve(this.workingDirectory, rawBasePath) + // Write guard: block writes to linked context trees + const guardError = validateWriteTarget(basePath, this.workingDirectory) + if (guardError) { + return { + applied: operations.map((op) => ({ + message: guardError, + path: op.path, + status: 'failed' as const, + type: op.type, + })), + summary: {added: 0, deleted: 0, failed: operations.length, merged: 0, updated: 0}, + } + } + // Pre-validate operations to catch common mistakes early const validationFailures = validateOperations(operations) if (validationFailures.length > 0) { diff --git a/src/agent/infra/sandbox/sandbox-service.ts b/src/agent/infra/sandbox/sandbox-service.ts index dc842d3c1..d955533ad 100644 --- a/src/agent/infra/sandbox/sandbox-service.ts +++ b/src/agent/infra/sandbox/sandbox-service.ts @@ -248,6 +248,7 @@ export class SandboxService implements ISandboxService { curateService: this.curateService, fileSystem: this.fileSystem, parentSessionId: sessionId, + projectRoot: this.environmentContext?.workingDirectory, sandboxService: this, searchKnowledgeService: this.searchKnowledgeService, sessionManager: this.sessionManager, diff --git a/src/agent/infra/sandbox/tools-sdk.ts b/src/agent/infra/sandbox/tools-sdk.ts index 25810c0bb..5c3c6ef65 100644 --- a/src/agent/infra/sandbox/tools-sdk.ts +++ b/src/agent/infra/sandbox/tools-sdk.ts @@ -20,6 +20,7 @@ import type {SessionManager} from '../session/session-manager.js' import {ContextTreeStore} from '../map/context-tree-store.js' import {executeLlmMapMemory} from '../map/llm-map-memory.js' +import {validateWriteTarget} from '../tools/write-guard.js' import { chunk, type ChunkResult, @@ -112,6 +113,12 @@ export interface SearchKnowledgeResult { /** Top backlink source paths (max 3) */ relatedPaths?: string[] score: number + /** Alias of linked project (only for linked results) */ + sourceAlias?: string + /** Absolute path to linked context tree (only for linked results) */ + sourceContextTreeRoot?: string + /** Source classification */ + sourceType?: 'linked' | 'local' /** Symbol kind: 'domain' | 'topic' | 'subtopic' | 'context' | 'archive_stub' */ symbolKind?: string /** Resolved hierarchical path in the symbol tree */ @@ -144,7 +151,7 @@ export interface ToolsSDK { * @param options.maxIterations - Maximum agentic iterations (default: 5) * @returns Promise resolving to the sub-agent's final response */ - agentQuery(prompt: string, options?: { contextData?: Record; maxIterations?: number }): Promise + agentQuery(prompt: string, options?: {contextData?: Record; maxIterations?: number}): Promise /** * Execute curate operations on knowledge topics. @@ -169,7 +176,10 @@ export interface ToolsSDK { /** Group facts by subject, with fallback to category */ groupBySubject(facts: CurationFact[]): Record /** Parallel LLM extraction over chunked context. Curate mode only. */ - mapExtract(context: string, options: {chunkSize?: number; concurrency?: number; maxContextTokens?: number; prompt: string; taskId?: string}): Promise<{facts: CurationFact[]; failed: number; succeeded: number; total: number}> + mapExtract( + context: string, + options: {chunkSize?: number; concurrency?: number; maxContextTokens?: number; prompt: string; taskId?: string}, + ): Promise<{facts: CurationFact[]; failed: number; succeeded: number; total: number}> /** Combine Steps 0-2 into one call: metadata + history + preview + mode recommendation */ recon(context: string, meta: Record, history: Record): ReconResult /** Push entry into history and increment totalProcessed (intentionally mutating) */ @@ -248,6 +258,8 @@ export interface CreateToolsSDKOptions { fileSystem: IFileSystem /** Parent session ID for creating child sessions (required for agentQuery) */ parentSessionId?: string + /** Project root for write guard validation */ + projectRoot?: string /** Sandbox service for variable injection into child sessions (optional, enables contextData in agentQuery) */ sandboxService?: ISandboxService /** Search knowledge service */ @@ -266,10 +278,23 @@ export interface CreateToolsSDKOptions { * @returns ToolsSDK instance ready to be injected into sandbox context */ export function createToolsSDK(options: CreateToolsSDKOptions): ToolsSDK { - const {commandType, contentGenerator, curateService, fileSystem, parentSessionId, sandboxService, searchKnowledgeService, sessionManager} = options + const { + commandType, + contentGenerator, + curateService, + fileSystem, + parentSessionId, + projectRoot, + sandboxService, + searchKnowledgeService, + sessionManager, + } = options const isReadOnly = commandType === 'query' return { - async agentQuery(prompt: string, options?: { contextData?: Record; maxIterations?: number }): Promise { + async agentQuery( + prompt: string, + options?: {contextData?: Record; maxIterations?: number}, + ): Promise { if (!sessionManager || !parentSessionId) { throw new Error('agentQuery not available — no session manager configured') } @@ -305,12 +330,14 @@ export function createToolsSDK(options: CreateToolsSDKOptions): ToolsSDK { if (!curateService) { return { - applied: [{ - message: 'Curate service not available.', - path: '', - status: 'failed', - type: 'ADD', - }], + applied: [ + { + message: 'Curate service not available.', + path: '', + status: 'failed', + type: 'ADD', + }, + ], summary: { added: 0, deleted: 0, @@ -329,7 +356,10 @@ export function createToolsSDK(options: CreateToolsSDKOptions): ToolsSDK { dedup, detectMessageBoundaries, groupBySubject, - async mapExtract(context: string, options: {chunkSize?: number; concurrency?: number; maxContextTokens?: number; prompt: string; taskId?: string}): Promise<{facts: CurationFact[]; failed: number; succeeded: number; total: number}> { + async mapExtract( + context: string, + options: {chunkSize?: number; concurrency?: number; maxContextTokens?: number; prompt: string; taskId?: string}, + ): Promise<{facts: CurationFact[]; failed: number; succeeded: number; total: number}> { if (commandType !== 'curate') { throw new Error('mapExtract only available in curate mode') } @@ -363,9 +393,7 @@ export function createToolsSDK(options: CreateToolsSDKOptions): ToolsSDK { throw new Error(`mapExtract failed: all ${result.total} chunks failed extraction`) } - const facts = result.results - .filter((r): r is CurationFact[] => r !== null) - .flat() + const facts = result.results.filter((r): r is CurationFact[] => r !== null).flat() return {facts, failed: result.failed, succeeded: result.succeeded, total: result.total} }, @@ -434,6 +462,14 @@ export function createToolsSDK(options: CreateToolsSDKOptions): ToolsSDK { throw new Error('writeFile() is disabled in read-only (query) mode') } + // Write guard: block writes to linked context trees + if (projectRoot) { + const guardError = validateWriteTarget(filePath, projectRoot) + if (guardError) { + throw new Error(guardError) + } + } + return fileSystem.writeFile(filePath, content, { createDirs: options?.createDirs ?? false, }) diff --git a/src/agent/infra/tools/implementations/search-knowledge-service.ts b/src/agent/infra/tools/implementations/search-knowledge-service.ts index e89f14388..105d4cb1a 100644 --- a/src/agent/infra/tools/implementations/search-knowledge-service.ts +++ b/src/agent/infra/tools/implementations/search-knowledge-service.ts @@ -2,10 +2,22 @@ import MiniSearch from 'minisearch' import {join} from 'node:path' import {removeStopwords} from 'stopword' +import type { + KnowledgeSource, + LoadedKnowledgeSources, +} from '../../../../server/core/domain/knowledge/knowledge-source.js' import type {IFileSystem} from '../../../core/interfaces/i-file-system.js' import type {ISearchKnowledgeService, SearchKnowledgeResult} from '../../sandbox/tools-sdk.js' -import {BRV_DIR, CONTEXT_FILE_EXTENSION, CONTEXT_TREE_DIR, SUMMARY_INDEX_FILE} from '../../../../server/constants.js' +import { + BRV_DIR, + CONTEXT_FILE_EXTENSION, + CONTEXT_TREE_DIR, + KNOWLEDGE_LINK_LOCAL_SCORE_BOOST, + SUMMARY_INDEX_FILE, +} from '../../../../server/constants.js' +import {deriveSourceKey} from '../../../../server/core/domain/knowledge/knowledge-source.js' +import {loadKnowledgeSources} from '../../../../server/core/domain/knowledge/load-knowledge-sources.js' import { type FrontmatterScoring, parseFrontmatterScoring, @@ -18,9 +30,12 @@ import { determineTier, recordAccessHits, } from '../../../../server/core/domain/knowledge/memory-scoring.js' -import { isArchiveStub, isDerivedArtifact } from '../../../../server/infra/context-tree/derived-artifact.js' -import { parseArchiveStubFrontmatter, parseSummaryFrontmatter } from '../../../../server/infra/context-tree/summary-frontmatter.js' -import { isPathLikeQuery, matchMemoryPath, parseSymbolicQuery } from './memory-path-matcher.js' +import {isArchiveStub, isDerivedArtifact} from '../../../../server/infra/context-tree/derived-artifact.js' +import { + parseArchiveStubFrontmatter, + parseSummaryFrontmatter, +} from '../../../../server/infra/context-tree/summary-frontmatter.js' +import {isPathLikeQuery, matchMemoryPath, parseSymbolicQuery} from './memory-path-matcher.js' import { buildReferenceIndex, buildSymbolTree, @@ -37,7 +52,7 @@ const MAX_CONTEXT_TREE_FILES = 10_000 const DEFAULT_CACHE_TTL_MS = 5000 /** Bump when MINISEARCH_OPTIONS fields/boost change to invalidate cached indexes */ -const INDEX_SCHEMA_VERSION = 4 +const INDEX_SCHEMA_VERSION = 5 /** Only include results whose normalized score is at least this fraction of the top result's score */ const SCORE_GAP_RATIO = 0.75 @@ -84,6 +99,11 @@ interface IndexedDocument { mtime: number path: string scoring: FrontmatterScoring + sourceAlias?: string + sourceContextTreeRoot: string + sourceKey: string + sourceType: 'linked' | 'local' + symbolPath: string title: string } @@ -92,7 +112,11 @@ interface CachedIndex { documentMap: Map fileMtimes: Map index: MiniSearch + knowledgeSources: KnowledgeSource[] lastValidatedAt: number + linksFileMtime?: number + /** Maps symbol path → qualified document ID for scope filtering */ + pathToDocumentId: Map referenceIndex: ReferenceIndex schemaVersion: number /** _index.md files collected separately for symbol tree annotation */ @@ -141,6 +165,112 @@ export interface SearchOptions { scope?: string } +/** + * Returns a namespaced symbol path for linked sources: `[alias]:relativePath`. + * Local sources return bare `relativePath`. + */ +function getSymbolPath(source: KnowledgeSource | {type: 'local'}, relativePath: string): string { + if (source.type === 'linked' && 'alias' in source && source.alias) { + return `[${source.alias}]:${relativePath}` + } + + return relativePath +} + +/** + * Indexes documents from a single source (local or linked). + * Returns documents, file mtimes, and summary map for merging. + */ +async function indexSourceDocuments( + fileSystem: IFileSystem, + source: KnowledgeSource, + filesWithMtime: Array<{mtime: number; path: string}>, +): Promise<{ + documents: IndexedDocument[] + fileMtimes: Map + summaryMap: Map +}> { + const summaryFiles: Array<{mtime: number; path: string}> = [] + const indexableFiles: Array<{mtime: number; path: string}> = [] + + for (const file of filesWithMtime) { + const fileName = file.path.split('/').at(-1) ?? '' + if (fileName === SUMMARY_INDEX_FILE) { + summaryFiles.push(file) + } else if (!isDerivedArtifact(file.path)) { + indexableFiles.push(file) + } + } + + const documentPromises = indexableFiles.map(async ({mtime, path: filePath}) => { + try { + const fullPath = join(source.contextTreeRoot, filePath) + const {content} = await fileSystem.readFile(fullPath) + const title = extractTitle(content, filePath.replace(/\.md$/, '').split('/').pop() || filePath) + const scoring = parseFrontmatterScoring(content) ?? applyDefaultScoring() + const qualifiedId = `${source.sourceKey}::${filePath}` + const symbolPath = getSymbolPath(source, filePath) + + return { + content, + id: qualifiedId, + mtime, + path: filePath, + scoring, + sourceAlias: source.alias, + sourceContextTreeRoot: source.contextTreeRoot, + sourceKey: source.sourceKey, + sourceType: source.type, + symbolPath, + title, + } satisfies IndexedDocument + } catch { + return null + } + }) + + const summaryPromises = summaryFiles.map(async ({path: filePath}) => { + try { + const fullPath = join(source.contextTreeRoot, filePath) + const {content} = await fileSystem.readFile(fullPath) + const fm = parseSummaryFrontmatter(content) + if (!fm) return null + + const symbolPath = getSymbolPath(source, filePath) + return { + condensationOrder: fm.condensation_order, + path: symbolPath, + tokenCount: fm.token_count, + } satisfies SummaryDocLike + } catch { + return null + } + }) + + const [docResults, summaryResults] = await Promise.all([Promise.all(documentPromises), Promise.all(summaryPromises)]) + + const documents = docResults.filter((doc): doc is NonNullable => doc !== null) + + const fileMtimes = new Map() + const mtimeKeyPrefix = source.type === 'local' ? 'local::' : `${source.sourceKey}::` + for (const doc of documents) { + fileMtimes.set(`${mtimeKeyPrefix}${doc.path}`, doc.mtime) + } + + for (const sf of summaryFiles) { + fileMtimes.set(`${mtimeKeyPrefix}${sf.path}`, sf.mtime) + } + + const summaryMap = new Map() + for (const summary of summaryResults) { + if (summary) { + summaryMap.set(summary.path, summary) + } + } + + return {documents, fileMtimes, summaryMap} +} + function filterStopWords(query: string): string { const words = query.toLowerCase().split(/\s+/) const filtered = removeStopwords(words) @@ -319,14 +449,14 @@ async function findMarkdownFilesWithMtime( } } -function isCacheValid(cache: CachedIndex, currentFiles: Array<{mtime: number; path: string}>): boolean { - if (cache.fileMtimes.size !== currentFiles.length) { +function isCacheValid(cache: CachedIndex, currentFileMtimes: Map): boolean { + if (cache.fileMtimes.size !== currentFileMtimes.size) { return false } - for (const file of currentFiles) { - const cachedMtime = cache.fileMtimes.get(file.path) - if (cachedMtime === undefined || cachedMtime !== file.mtime) { + for (const [key, mtime] of currentFileMtimes) { + const cachedMtime = cache.fileMtimes.get(key) + if (cachedMtime === undefined || cachedMtime !== mtime) { return false } } @@ -337,118 +467,72 @@ function isCacheValid(cache: CachedIndex, currentFiles: Array<{mtime: number; pa async function buildFreshIndex( fileSystem: IFileSystem, contextTreePath: string, - filesWithMtime: Array<{mtime: number; path: string}>, + localFiles: Array<{mtime: number; path: string}>, + knowledgeSources: KnowledgeSource[], + linksFileMtime?: number, ): Promise { const now = Date.now() - if (filesWithMtime.length === 0) { - const index = new MiniSearch(MINISEARCH_OPTIONS) - return { - contextTreePath, - documentMap: new Map(), - fileMtimes: new Map(), - index, - lastValidatedAt: now, - referenceIndex: { backlinks: new Map(), forwardLinks: new Map() }, - schemaVersion: INDEX_SCHEMA_VERSION, - summaryMap: new Map(), - symbolTree: { root: [], symbolMap: new Map() }, - } + // Build local source descriptor + const localSource: KnowledgeSource = { + contextTreeRoot: contextTreePath, + sourceKey: deriveSourceKey(contextTreePath), + type: 'local', } - // Partition files: _index.md → summaryFiles, derived artifacts → skip, rest → indexable - const summaryFiles: Array<{mtime: number; path: string}> = [] - const indexableFiles: Array<{mtime: number; path: string}> = [] + // Index local documents + const localResult = await indexSourceDocuments(fileSystem, localSource, localFiles) - for (const file of filesWithMtime) { - const fileName = file.path.split('/').at(-1) ?? '' - if (fileName === SUMMARY_INDEX_FILE) { - summaryFiles.push(file) - } else if (!isDerivedArtifact(file.path)) { - // Includes regular .md files AND .stub.md files (stubs are searchable) - indexableFiles.push(file) - } - // .full.md and _manifest.json are skipped (isDerivedArtifact returns true) - } - - // Read indexable documents for BM25 index - const documentPromises = indexableFiles.map(async ({mtime, path: filePath}) => { - try { - const fullPath = join(contextTreePath, filePath) - const {content} = await fileSystem.readFile(fullPath) - const title = extractTitle(content, filePath.replace(/\.md$/, '').split('/').pop() || filePath) - const scoring = parseFrontmatterScoring(content) ?? applyDefaultScoring() - - return { - content, - id: filePath, - mtime, - path: filePath, - scoring, - title, - } - } catch { - return null - } - }) - - // Read _index.md files separately for summaryMap (not indexed in BM25) - const summaryPromises = summaryFiles.map(async ({ path: filePath }) => { - try { - const fullPath = join(contextTreePath, filePath) - const { content } = await fileSystem.readFile(fullPath) - const fm = parseSummaryFrontmatter(content) - if (!fm) return null - - return { - condensationOrder: fm.condensation_order, - path: filePath, - tokenCount: fm.token_count, - } satisfies SummaryDocLike - } catch { - return null - } - }) + // Index all linked sources in parallel + const linkedResults = await Promise.all( + knowledgeSources.map(async (source) => { + const files = await findMarkdownFilesWithMtime(fileSystem, source.contextTreeRoot) + const filtered = files.filter( + (f) => !isDerivedArtifact(f.path) || f.path.split('/').at(-1) === SUMMARY_INDEX_FILE, + ) + return indexSourceDocuments(fileSystem, source, filtered) + }), + ) - const [docResults, summaryResults] = await Promise.all([ - Promise.all(documentPromises), - Promise.all(summaryPromises), - ]) + // Merge all documents, fileMtimes, and summaryMaps + const allDocuments: IndexedDocument[] = [...localResult.documents] + const fileMtimes = new Map(localResult.fileMtimes) + const summaryMap = new Map(localResult.summaryMap) - const documents = docResults.filter((doc): doc is IndexedDocument => doc !== null) + for (const linked of linkedResults) { + allDocuments.push(...linked.documents) + for (const [k, v] of linked.fileMtimes) fileMtimes.set(k, v) + for (const [k, v] of linked.summaryMap) summaryMap.set(k, v) + } const documentMap = new Map() - const fileMtimes = new Map() - for (const doc of documents) { + const pathToDocumentId = new Map() + for (const doc of allDocuments) { documentMap.set(doc.id, doc) - fileMtimes.set(doc.path, doc.mtime) - } - - // Also track summary file mtimes for cache invalidation - for (const sf of summaryFiles) { - fileMtimes.set(sf.path, sf.mtime) - } - - const summaryMap = new Map() - for (const summary of summaryResults) { - if (summary) { - summaryMap.set(summary.path, summary) - } + pathToDocumentId.set(doc.symbolPath, doc.id) } const index = new MiniSearch(MINISEARCH_OPTIONS) - index.addAll(documents) + index.addAll(allDocuments) + + // Build symbolic structures using symbolPath for tree paths + // Create a view where doc.path is replaced by doc.symbolPath for tree construction + const treeDocMap = new Map(allDocuments.map((doc) => [doc.id, {...doc, path: doc.symbolPath}])) + const symbolTree = buildSymbolTree(treeDocMap, summaryMap) - // Build symbolic structures from the document map, with summary annotations - const symbolTree = buildSymbolTree(documentMap, summaryMap) - const referenceIndex = buildReferenceIndex(documentMap) + // Reference index only tracks local-to-local references + const localDocMap = new Map([...documentMap].filter(([, doc]) => doc.sourceType === 'local')) + const referenceIndex = buildReferenceIndex(localDocMap) return { contextTreePath, documentMap, fileMtimes, index, + knowledgeSources, lastValidatedAt: now, + linksFileMtime, + pathToDocumentId, referenceIndex, schemaVersion: INDEX_SCHEMA_VERSION, summaryMap, @@ -465,6 +549,7 @@ async function acquireIndex( fileSystem: IFileSystem, contextTreePath: string, ttlMs: number, + baseDirectory: string, onBeforeBuild?: (contextTreePath: string) => Promise, ): Promise { const now = Date.now() @@ -500,28 +585,67 @@ async function acquireIndex( documentMap: new Map(), fileMtimes: new Map(), index: emptyIndex, + knowledgeSources: [], lastValidatedAt: 0, - referenceIndex: { backlinks: new Map(), forwardLinks: new Map() }, + pathToDocumentId: new Map(), + referenceIndex: {backlinks: new Map(), forwardLinks: new Map()}, schemaVersion: INDEX_SCHEMA_VERSION, summaryMap: new Map(), - symbolTree: { root: [], symbolMap: new Map() }, + symbolTree: {root: [], symbolMap: new Map()}, } } } + // Load knowledge sources from workspaces.json + const loadedLinks: LoadedKnowledgeSources | null = loadKnowledgeSources(baseDirectory) + const knowledgeSources = loadedLinks?.sources ?? [] + const linksFileMtime = loadedLinks?.mtime + + // Check if links file changed — invalidate cache if so + if (state.cachedIndex && state.cachedIndex.linksFileMtime !== linksFileMtime) { + state.cachedIndex = undefined + } + const allFiles = await findMarkdownFilesWithMtime(fileSystem, contextTreePath) // Exclude non-indexable derived artifacts (.full.md) so that currentFiles // matches what buildFreshIndex tracks in fileMtimes. Without this filter, // isCacheValid() sees a size mismatch once archives exist, causing cache thrash. // _index.md is kept (tracked for summary staleness), .stub.md is kept (BM25 indexed). - const currentFiles = allFiles.filter((f) => !isDerivedArtifact(f.path) || f.path.split('/').at(-1) === SUMMARY_INDEX_FILE) + const localFiles = allFiles.filter( + (f) => !isDerivedArtifact(f.path) || f.path.split('/').at(-1) === SUMMARY_INDEX_FILE, + ) + + // Build qualified mtime map for local files + const currentFileMtimes = new Map() + for (const f of localFiles) { + currentFileMtimes.set(`local::${f.path}`, f.mtime) + } + + // Also glob linked context trees for cache validation + const linkedFileResults = await Promise.all( + knowledgeSources.map(async (source) => { + const linkedFiles = await findMarkdownFilesWithMtime(fileSystem, source.contextTreeRoot) + return { + files: linkedFiles.filter( + (f) => !isDerivedArtifact(f.path) || f.path.split('/').at(-1) === SUMMARY_INDEX_FILE, + ), + sourceKey: source.sourceKey, + } + }), + ) + + for (const {files, sourceKey} of linkedFileResults) { + for (const f of files) { + currentFileMtimes.set(`${sourceKey}::${f.path}`, f.mtime) + } + } // Re-check cache validity after getting file list (another call may have finished) if ( state.cachedIndex && state.cachedIndex.contextTreePath === contextTreePath && state.cachedIndex.schemaVersion === INDEX_SCHEMA_VERSION && - isCacheValid(state.cachedIndex, currentFiles) + isCacheValid(state.cachedIndex, currentFileMtimes) ) { // Update timestamp atomically by creating a new object const updatedCache: CachedIndex = { @@ -537,8 +661,8 @@ async function acquireIndex( await onBeforeBuild(contextTreePath) } - // Build fresh index - const freshIndex = await buildFreshIndex(fileSystem, contextTreePath, currentFiles) + // Build fresh index with all sources + const freshIndex = await buildFreshIndex(fileSystem, contextTreePath, localFiles, knowledgeSources, linksFileMtime) state.cachedIndex = freshIndex return freshIndex })() @@ -634,8 +758,13 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { const contextTreePath = join(this.baseDirectory, BRV_DIR, CONTEXT_TREE_DIR) // Acquire index with parallel-safe locking; flush pending access hits before any rebuild - const indexResult = await acquireIndex(this.state, this.fileSystem, contextTreePath, this.cacheTtlMs, (ctxPath) => - this.flushAccessHits(ctxPath), + const indexResult = await acquireIndex( + this.state, + this.fileSystem, + contextTreePath, + this.cacheTtlMs, + this.baseDirectory, + (ctxPath) => this.flushAccessHits(ctxPath), ) // Handle error case (context tree not initialized) @@ -643,7 +772,7 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { return indexResult.result } - const { documentMap, index, referenceIndex, symbolTree } = indexResult + const {documentMap, index, pathToDocumentId, referenceIndex, symbolTree} = indexResult if (documentMap.size === 0) { return { @@ -661,7 +790,14 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { // Symbolic path resolution: try path-based query first if (isPathLikeQuery(query, symbolTree)) { const symbolicResult = this.trySymbolicSearch( - query, symbolTree, referenceIndex, documentMap, index, limit, options, + query, + symbolTree, + referenceIndex, + documentMap, + pathToDocumentId, + index, + limit, + options, ) if (symbolicResult) { @@ -676,12 +812,30 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { // Run text-based MiniSearch (existing pipeline), optionally scoped to a subtree const textResult = this.runTextSearch( - effectiveQuery || query, documentMap, index, limit, effectiveScope, symbolTree, referenceIndex, options, + effectiveQuery || query, + documentMap, + index, + limit, + effectiveScope, + symbolTree, + referenceIndex, + options, + pathToDocumentId, ) // If scoped search returned nothing and we had a scope, fall back to global search if (textResult.results.length === 0 && effectiveScope && effectiveQuery) { - return this.runTextSearch(query, documentMap, index, limit, undefined, symbolTree, referenceIndex, options) + return this.runTextSearch( + query, + documentMap, + index, + limit, + undefined, + symbolTree, + referenceIndex, + options, + pathToDocumentId, + ) } return textResult @@ -729,7 +883,7 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { * For archive stubs, extracts points_to path into archiveFullPath. */ private enrichResult( - result: { excerpt: string; path: string; score: number; title: string }, + result: {excerpt: string; id?: string; path: string; score: number; title: string}, symbolTree: MemorySymbolTree, referenceIndex: ReferenceIndex, documentMap: Map, @@ -751,11 +905,24 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { } } + // Look up source metadata from document + const doc = documentMap.get(result.id ?? result.path) + const sourceType = doc?.sourceType + const sourceAlias = doc?.sourceAlias + const sourceContextTreeRoot = doc?.sourceType === 'linked' ? doc.sourceContextTreeRoot : undefined + + // Strip internal `id` field from output + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {id: _id, ...rest} = result + return { - ...result, - ...(archiveFullPath && { archiveFullPath }), + ...rest, + ...(archiveFullPath && {archiveFullPath}), backlinkCount: backlinks?.length ?? 0, relatedPaths: backlinks?.slice(0, 3), + ...(sourceAlias && {sourceAlias}), + ...(sourceContextTreeRoot && {sourceContextTreeRoot}), + ...(sourceType && {sourceType}), symbolKind, symbolPath: symbol?.path, } @@ -773,16 +940,27 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { symbolTree: MemorySymbolTree, referenceIndex: ReferenceIndex, options?: SearchOptions, + pathToDocumentId?: Map, ): SearchKnowledgeResult { const filteredQuery = filterStopWords(query) const filteredWords = filteredQuery.split(/\s+/).filter((w) => w.length >= 2) // Build scope filter if a subtree is specified - let scopeFilter: ((result: { id: string }) => boolean) | undefined + // Translate symbol paths → qualified doc IDs via pathToDocumentId + let scopeFilter: ((result: {id: string}) => boolean) | undefined if (scopePath) { - const subtreeIds = getSubtreeDocumentIds(symbolTree, scopePath) - if (subtreeIds.size > 0) { - scopeFilter = (result) => subtreeIds.has(result.id) + const subtreeSymPaths = getSubtreeDocumentIds(symbolTree, scopePath) + if (subtreeSymPaths.size > 0 && pathToDocumentId) { + const qualifiedIds = new Set() + for (const sp of subtreeSymPaths) { + const qid = pathToDocumentId.get(sp) + if (qid) qualifiedIds.add(qid) + } + + scopeFilter = (result) => qualifiedIds.has(result.id) + } else if (subtreeSymPaths.size > 0) { + // Fallback for no pathToDocumentId (shouldn't happen) + scopeFilter = (result) => subtreeSymPaths.has(result.id) } } @@ -790,20 +968,21 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { // If AND returns no results, fall back to OR to ensure no regression. let rawResults: Array<{id: string; queryTerms: string[]; score: number}> let andSearchFailed = false - const searchOpts = scopeFilter ? { filter: scopeFilter } : {} + const searchOpts = scopeFilter ? {filter: scopeFilter} : {} if (filteredWords.length >= 2) { - rawResults = index.search(filteredQuery, { combineWith: 'AND', ...searchOpts }) + rawResults = index.search(filteredQuery, {combineWith: 'AND', ...searchOpts}) if (rawResults.length === 0) { andSearchFailed = true - rawResults = index.search(filteredQuery, { combineWith: 'OR', ...searchOpts }) + rawResults = index.search(filteredQuery, {combineWith: 'OR', ...searchOpts}) } } else { - rawResults = index.search(filteredQuery, { combineWith: 'OR', ...searchOpts }) + rawResults = index.search(filteredQuery, {combineWith: 'OR', ...searchOpts}) } // Normalize BM25 scores to [0, 1) then blend with importance + recency via compound scoring. // Decay is computed lazily from file mtime — no disk writes during search. + // Local results get a slight boost to prefer local knowledge over linked. const now = Date.now() const searchResults = rawResults.map((r) => { const doc = documentMap.get(r.id) @@ -811,10 +990,16 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { const daysSince = doc ? Math.max(0, (now - doc.mtime) / 86_400_000) : 0 const decayed = applyDecay(scoring, daysSince) const bm25 = normalizeScore(r.score) + let finalScore = compoundScore(bm25, decayed.importance ?? 50, decayed.recency ?? 1, decayed.maturity ?? 'draft') + + // Boost local results slightly + if (doc?.sourceType === 'local') { + finalScore = Math.min(finalScore + KNOWLEDGE_LINK_LOCAL_SCORE_BOOST, 1) + } return { ...r, - score: compoundScore(bm25, decayed.importance ?? 50, decayed.recency ?? 1, decayed.maturity ?? 'draft'), + score: finalScore, } }) searchResults.sort((a, b) => b.score - a.score) @@ -865,11 +1050,14 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { const enriched = this.enrichResult( { excerpt: extractExcerpt(document.content, query), + id: document.id, path: document.path, score: Math.round(result.score * 100) / 100, title: document.title, }, - symbolTree, referenceIndex, documentMap, + symbolTree, + referenceIndex, + documentMap, ) // Apply kind/maturity filters if specified @@ -882,7 +1070,7 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { } if (options?.minMaturity && enriched.symbolKind) { - const tierRank: Record = { core: 3, draft: 1, validated: 2 } + const tierRank: Record = {core: 3, draft: 1, validated: 2} const symbol = symbolTree.symbolMap.get(document.path) const docMaturity = symbol?.metadata.maturity ?? 'draft' if ((tierRank[docMaturity] ?? 1) < (tierRank[options.minMaturity] ?? 1)) { @@ -919,6 +1107,7 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { symbolTree: MemorySymbolTree, referenceIndex: ReferenceIndex, documentMap: Map, + pathToDocumentId: Map, index: MiniSearch, limit: number, options?: SearchOptions, @@ -933,17 +1122,22 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { // If the matched symbol is a leaf Context, return it directly if (topMatch.kind === MemorySymbolKind.Context) { - const doc = documentMap.get(topMatch.path) + const docId = pathToDocumentId.get(topMatch.path) + const doc = docId ? documentMap.get(docId) : undefined if (!doc) { return null } const result = this.enrichResult( - { excerpt: extractExcerpt(doc.content, query), path: doc.path, score: 1, title: doc.title }, - symbolTree, referenceIndex, documentMap, + {excerpt: extractExcerpt(doc.content, query), id: doc.id, path: doc.path, score: 1, title: doc.title}, + symbolTree, + referenceIndex, + documentMap, ) - this.accumulateAccessHits([doc.path]) + if (doc.sourceType === 'local') { + this.accumulateAccessHits([doc.path]) + } return { message: `Found exact match: ${topMatch.path}`, @@ -959,27 +1153,44 @@ export class SearchKnowledgeService implements ISearchKnowledgeService { if (textPart) { // Scoped search: search text within the matched subtree - return this.runTextSearch(textPart, documentMap, index, limit, topMatch.path, symbolTree, referenceIndex, options) + return this.runTextSearch( + textPart, + documentMap, + index, + limit, + topMatch.path, + symbolTree, + referenceIndex, + options, + pathToDocumentId, + ) } // No text part — return all children of the matched node - const subtreeIds = getSubtreeDocumentIds(symbolTree, topMatch.path) + const subtreeSymbolPaths = getSubtreeDocumentIds(symbolTree, topMatch.path) const results: SearchKnowledgeResult['results'] = [] - for (const docId of subtreeIds) { + for (const symPath of subtreeSymbolPaths) { if (results.length >= limit) break - const doc = documentMap.get(docId) + const qualifiedId = pathToDocumentId.get(symPath) + const doc = qualifiedId ? documentMap.get(qualifiedId) : undefined if (!doc) continue - results.push(this.enrichResult( - { excerpt: extractExcerpt(doc.content, query), path: doc.path, score: 0.9, title: doc.title }, - symbolTree, referenceIndex, documentMap, - )) + results.push( + this.enrichResult( + {excerpt: extractExcerpt(doc.content, query), id: doc.id, path: doc.path, score: 0.9, title: doc.title}, + symbolTree, + referenceIndex, + documentMap, + ), + ) } - if (results.length > 0) { - this.accumulateAccessHits(results.map((r) => r.path)) + // Only accumulate access hits for local documents + const localPaths = results.filter((r) => !r.sourceType || r.sourceType === 'local').map((r) => r.path) + if (localPaths.length > 0) { + this.accumulateAccessHits(localPaths) } return { diff --git a/src/agent/infra/tools/implementations/write-file-tool.ts b/src/agent/infra/tools/implementations/write-file-tool.ts index 391b447ed..67bac0e03 100644 --- a/src/agent/infra/tools/implementations/write-file-tool.ts +++ b/src/agent/infra/tools/implementations/write-file-tool.ts @@ -1,11 +1,13 @@ import {z} from 'zod' +import type {EnvironmentContext} from '../../../core/domain/environment/types.js' import type {BufferEncoding} from '../../../core/domain/file-system/types.js' import type {Tool, ToolExecutionContext} from '../../../core/domain/tools/types.js' import type {IFileSystem} from '../../../core/interfaces/i-file-system.js' import {sanitizeFolderName} from '../../../../server/utils/file-helpers.js' import {ToolName} from '../../../core/domain/tools/constants.js' +import {validateWriteTarget} from '../write-guard.js' /** * Input schema for write file tool. @@ -41,14 +43,28 @@ type WriteFileInput = z.infer * @param fileSystemService - File system service dependency * @returns Configured write file tool */ -export function createWriteFileTool(fileSystemService: IFileSystem): Tool { +export function createWriteFileTool(fileSystemService: IFileSystem, environmentContext?: EnvironmentContext): Tool { return { description: 'Write content to a file. Overwrites existing files. Can optionally create parent directories.', async execute(input: unknown, _context?: ToolExecutionContext) { const {content, createDirs, encoding, filePath} = input as WriteFileInput + const sanitizedPath = sanitizeFolderName(filePath) + + // Write guard: block writes to linked context trees + if (environmentContext?.workingDirectory) { + const guardError = validateWriteTarget(sanitizedPath, environmentContext.workingDirectory) + if (guardError) { + return { + error: guardError, + path: sanitizedPath, + success: false, + } + } + } + // Call file system service - const result = await fileSystemService.writeFile(sanitizeFolderName(filePath), content, { + const result = await fileSystemService.writeFile(sanitizedPath, content, { createDirs, encoding: encoding as BufferEncoding, }) diff --git a/src/agent/infra/tools/tool-registry.ts b/src/agent/infra/tools/tool-registry.ts index 86a0c8eaf..ade193dc2 100644 --- a/src/agent/infra/tools/tool-registry.ts +++ b/src/agent/infra/tools/tool-registry.ts @@ -252,7 +252,7 @@ export const TOOL_REGISTRY: Record = { [ToolName.WRITE_FILE]: { descriptionFile: 'write_file', - factory: (services) => createWriteFileTool(getRequiredService(services.fileSystemService, 'fileSystemService')), + factory: (services) => createWriteFileTool(getRequiredService(services.fileSystemService, 'fileSystemService'), services.environmentContext), markers: [ToolMarker.Modification], requiredServices: ['fileSystemService'], }, diff --git a/src/agent/infra/tools/write-guard.ts b/src/agent/infra/tools/write-guard.ts new file mode 100644 index 000000000..2f2b41077 --- /dev/null +++ b/src/agent/infra/tools/write-guard.ts @@ -0,0 +1,73 @@ +import {existsSync, realpathSync} from 'node:fs' +import {basename, dirname, isAbsolute, join, relative, resolve} from 'node:path' + +import {BRV_DIR, CONTEXT_TREE_DIR} from '../../../server/constants.js' +import {loadKnowledgeSources} from '../../../server/core/domain/knowledge/load-knowledge-sources.js' + +/** + * Validates whether a write target is allowed. + * + * Returns `null` if the write is allowed, or an error message string if blocked. + * + * Rules: + * - Writes to the local `.brv/context-tree/` are allowed + * - Writes to any linked project's context tree are blocked + * - Writes outside the local context tree are blocked + */ +export function validateWriteTarget(targetPath: string, projectRoot: string): null | string { + if (!projectRoot) { + return null + } + + const localContextTree = resolve(projectRoot, BRV_DIR, CONTEXT_TREE_DIR) + + const canonicalTarget = tryRealpath(resolve(targetPath)) + const canonicalLocal = tryRealpath(localContextTree) + + // Allow writes within local context tree + if (isWithin(canonicalTarget, canonicalLocal)) { + return null + } + + // Block writes to any linked project's context tree + const loaded = loadKnowledgeSources(projectRoot) + for (const source of loaded?.sources ?? []) { + const canonicalLinkedRoot = tryRealpath(source.contextTreeRoot) + + if (isWithin(canonicalTarget, canonicalLinkedRoot)) { + return `Cannot write to knowledge-linked project "${source.alias ?? 'unknown'}". Linked context trees are read-only.` + } + } + + // Block writes outside local context tree + return `Cannot write outside local context tree: ${join(BRV_DIR, CONTEXT_TREE_DIR)}` +} + +/** + * Resolves symlinks in a path. If the path doesn't exist, + * walks up to the nearest existing ancestor and resolves that, + * then appends the remaining segments. Handles macOS /tmp → /private/tmp. + */ +function tryRealpath(p: string): string { + if (existsSync(p)) { + try { + return realpathSync(p) + } catch { + return p + } + } + + // Walk up to find the nearest existing ancestor + const parent = dirname(p) + if (parent === p) { + return p // root + } + + const resolvedParent = tryRealpath(parent) + return join(resolvedParent, basename(p)) +} + +function isWithin(target: string, parent: string): boolean { + const rel = relative(parent, target) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} diff --git a/src/agent/resources/prompts/system-prompt.yml b/src/agent/resources/prompts/system-prompt.yml index 441fee469..614238468 100644 --- a/src/agent/resources/prompts/system-prompt.yml +++ b/src/agent/resources/prompts/system-prompt.yml @@ -791,6 +791,17 @@ prompt: | - Mention topic name and subtopic if created - Do NOT include file paths in response (system displays them separately) + + This project may have knowledge links to other projects via `.brv/workspaces.json`. + When searching knowledge, results may include entries from linked projects: + - `sourceType: "linked"` results come from linked projects (read-only) + - `sourceType: "local"` results come from the current project + - Use `sourceContextTreeRoot` with `join(sourceContextTreeRoot, path)` to read linked content + - Local results receive a slight relevance boost + - You CANNOT curate, write, or modify linked project context trees — they are read-only + - Only the local `.brv/context-tree/` is writable + + Available tools: {{available_tools}} You are currently in autonomous mode. Execute all operations directly without delegation. excluded_tools: [] diff --git a/src/agent/resources/tools/expand_knowledge.txt b/src/agent/resources/tools/expand_knowledge.txt index 7214d8b65..8a4104f62 100644 --- a/src/agent/resources/tools/expand_knowledge.txt +++ b/src/agent/resources/tools/expand_knowledge.txt @@ -14,6 +14,10 @@ When searching the knowledge base, you may encounter results with `symbolKind: " - `fullContent`: Complete original content of the archived entry - `tokenCount`: Estimated token count of the full content +**Knowledge-linked results:** +- For linked results (`sourceType: "linked"`), the `stubPath` is relative to the linked context tree +- Use `sourceContextTreeRoot` from the search result to resolve the full path: `join(sourceContextTreeRoot, stubPath)` + **Example:** - Search returns: `{ path: "_archived/auth/jwt-tokens/refresh-flow.stub.md", symbolKind: "archive_stub" }` - Call: `expand_knowledge({ stubPath: "_archived/auth/jwt-tokens/refresh-flow.stub.md" })` diff --git a/src/agent/resources/tools/search_knowledge.txt b/src/agent/resources/tools/search_knowledge.txt index a435b4626..7b55f2590 100644 --- a/src/agent/resources/tools/search_knowledge.txt +++ b/src/agent/resources/tools/search_knowledge.txt @@ -17,14 +17,19 @@ This tool enables semantic/fuzzy search across all curated knowledge without nee - `title`: Topic title - `excerpt`: Relevant content snippet - `score`: Relevance score (higher is better) + - `sourceType`: `"local"` or `"linked"` — indicates source project + - `sourceAlias`: Alias of linked project (only for linked results) + - `sourceContextTreeRoot`: Absolute path to linked context tree (only for linked results) - `totalFound`: Total number of matches - `message`: Status message **Usage tips:** - Use descriptive queries: "authentication flow" works better than "auth" - Search is fuzzy: minor typos are tolerated -- Results are ranked by relevance to your query +- Results are ranked by relevance to your query — local results are boosted slightly - Use `read_file` on returned paths to view full content +- For linked results: use `join(sourceContextTreeRoot, path)` to read the full file +- Results may include read-only entries from linked projects — you cannot curate or modify linked context trees **Examples:** - Query: "API authentication" - finds topics about auth design diff --git a/src/oclif/commands/hub/install.ts b/src/oclif/commands/hub/install.ts index 9c237110f..48bf65252 100644 --- a/src/oclif/commands/hub/install.ts +++ b/src/oclif/commands/hub/install.ts @@ -3,6 +3,7 @@ import {Args, Command, Flags} from '@oclif/core' import {SKILL_CONNECTOR_CONFIGS} from '../../../server/infra/connectors/skill/skill-connector-config.js' import { HubEvents, + type HubInstallAllResponse, type HubInstallRequest, type HubInstallResponse, } from '../../../shared/transport/events/hub-events.js' @@ -12,15 +13,15 @@ import {writeJsonResponse} from '../../lib/json-response.js' export default class HubInstall extends Command { public static args = { id: Args.string({ - description: 'Entry ID to install', - required: true, + description: 'Entry ID to install (omit to install all from dependencies.json)', + required: false, }), } public static description = 'Install a skill or bundle from the hub' public static examples = [ '<%= config.bin %> hub install byterover-review --agent "Claude Code"', '<%= config.bin %> hub install typescript-kickstart', - '<%= config.bin %> hub install byterover-review --registry myco', + '<%= config.bin %> hub install # install all from dependencies.json', ] public static flags = { agent: Flags.string({ @@ -60,10 +61,44 @@ export default class HubInstall extends Command { const {args, flags} = await this.parse(HubInstall) const format = flags.format as 'json' | 'text' + await (args.id ? this.installSingle(args.id, flags, format) : this.installAll(format)) + } + + private async installAll(format: 'json' | 'text'): Promise { + try { + const result = await withDaemonRetry(async (client) => + client.requestWithAck(HubEvents.INSTALL_ALL), + ) + + if (format === 'json') { + writeJsonResponse({command: 'hub install', data: result, success: result.success}) + } else { + if (result.results.length > 0) { + for (const r of result.results) { + this.log(` ${r.success ? '✓' : '✗'} ${r.entryId}: ${r.message}`) + } + } + + this.log(result.message) + } + } catch (error) { + if (format === 'json') { + writeJsonResponse({command: 'hub install', data: {error: formatConnectionError(error)}, success: false}) + } else { + this.log(formatConnectionError(error)) + } + } + } + + private async installSingle( + entryId: string, + flags: {agent?: string; format: string; registry?: string; scope?: string}, + format: 'json' | 'text', + ): Promise { try { const result = await this.executeInstall({ agent: flags.agent, - entryId: args.id, + entryId, registry: flags.registry, scope: flags.scope as 'global' | 'project', }) diff --git a/src/oclif/commands/hub/uninstall.ts b/src/oclif/commands/hub/uninstall.ts new file mode 100644 index 000000000..16c045fde --- /dev/null +++ b/src/oclif/commands/hub/uninstall.ts @@ -0,0 +1,51 @@ +import {Args, Command, Flags} from '@oclif/core' + +import { + HubEvents, + type HubUninstallRequest, + type HubUninstallResponse, +} from '../../../shared/transport/events/hub-events.js' +import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' +import {writeJsonResponse} from '../../lib/json-response.js' + +export default class HubUninstall extends Command { + public static args = { + id: Args.string({ + description: 'ID of the bundle to uninstall', + required: true, + }), + } + public static description = 'Uninstall a bundle and remove it from dependencies' + public static examples = ['<%= config.bin %> hub uninstall react-patterns'] + public static flags = { + format: Flags.string({ + char: 'f', + default: 'text', + description: 'Output format', + options: ['text', 'json'], + }), + } + + public async run(): Promise { + const {args, flags} = await this.parse(HubUninstall) + const format = flags.format as 'json' | 'text' + + try { + const result = await withDaemonRetry(async (client) => + client.requestWithAck(HubEvents.UNINSTALL, {entryId: args.id}), + ) + + if (format === 'json') { + writeJsonResponse({command: 'hub uninstall', data: result, success: result.success}) + } else { + this.log(result.message) + } + } catch (error) { + if (format === 'json') { + writeJsonResponse({command: 'hub uninstall', data: {error: formatConnectionError(error)}, success: false}) + } else { + this.log(formatConnectionError(error)) + } + } + } +} diff --git a/src/oclif/commands/status.ts b/src/oclif/commands/status.ts index 0e96b0950..57a49a4dd 100644 --- a/src/oclif/commands/status.ts +++ b/src/oclif/commands/status.ts @@ -130,5 +130,22 @@ export default class Status extends Command { this.log('Context Tree: Unable to check status') } } + + // Knowledge workspaces + if (status.workspaces && status.workspaces.length > 0) { + this.log(`Workspaces: ${status.workspaces.length} linked`) + for (const ws of status.workspaces) { + this.log(` ${ws}`) + } + } + + // Hub dependencies + if (status.dependencies && Object.keys(status.dependencies).length > 0) { + const deps = Object.entries(status.dependencies) + this.log(`Dependencies: ${deps.length} installed`) + for (const [name, version] of deps) { + this.log(` ${name}@${version}`) + } + } } } diff --git a/src/oclif/commands/workspace/add.ts b/src/oclif/commands/workspace/add.ts new file mode 100644 index 000000000..e552aaadb --- /dev/null +++ b/src/oclif/commands/workspace/add.ts @@ -0,0 +1,38 @@ +import {Args, Command} from '@oclif/core' + +import { + type WorkspaceAddRequest, + WorkspaceEvents, + type WorkspaceOperationResponse, +} from '../../../shared/transport/events/workspace-events.js' +import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' + +export default class WorkspaceAdd extends Command { + public static args = { + path: Args.string({ + description: 'Path to the project to add as a workspace', + required: true, + }), + } + public static description = 'Add a project as a knowledge workspace' + public static examples = [ + '<%= config.bin %> workspace add ../shared-lib', + '<%= config.bin %> workspace add /absolute/path/to/project', + ] + + public async run(): Promise { + const {args} = await this.parse(WorkspaceAdd) + + try { + const result = await withDaemonRetry(async (client) => + client.requestWithAck(WorkspaceEvents.ADD, { + targetPath: args.path, + }), + ) + + this.log(result.message) + } catch (error) { + this.log(formatConnectionError(error)) + } + } +} diff --git a/src/oclif/commands/workspace/remove.ts b/src/oclif/commands/workspace/remove.ts new file mode 100644 index 000000000..7f5c2bc9a --- /dev/null +++ b/src/oclif/commands/workspace/remove.ts @@ -0,0 +1,40 @@ +import {Args, Command} from '@oclif/core' + +import { + WorkspaceEvents, + type WorkspaceOperationResponse, + type WorkspaceRemoveRequest, +} from '../../../shared/transport/events/workspace-events.js' +import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' + +export default class WorkspaceRemove extends Command { + public static args = { + path: Args.string({ + description: 'Path of the workspace to remove (relative or absolute)', + required: true, + }), + } + public static description = 'Remove a project from knowledge workspaces' + public static examples = [ + '<%= config.bin %> workspace remove ../shared-lib', + '<%= config.bin %> workspace remove /absolute/path/to/project', + ] + + public async run(): Promise { + const {args} = await this.parse(WorkspaceRemove) + + try { + const result = await withDaemonRetry( + async (client) => + client.requestWithAck( + WorkspaceEvents.REMOVE, + {path: args.path}, + ), + ) + + this.log(result.message) + } catch (error) { + this.log(formatConnectionError(error)) + } + } +} diff --git a/src/oclif/hooks/init/validate-brv-config.ts b/src/oclif/hooks/init/validate-brv-config.ts index 1ad87872c..b27006f56 100644 --- a/src/oclif/hooks/init/validate-brv-config.ts +++ b/src/oclif/hooks/init/validate-brv-config.ts @@ -6,6 +6,7 @@ import {dirname, join} from 'node:path' import type {IProjectConfigStore} from '../../../server/core/interfaces/storage/i-project-config-store.js' import {BRV_CONFIG_VERSION} from '../../../server/constants.js' +import {findProjectRoot} from '../../../server/core/domain/knowledge/find-project-root.js' import {ProjectConfigStore} from '../../../server/infra/config/file-config-store.js' import {ensureCurateViewPatched} from '../../../server/infra/connectors/shared/rule-segment-patcher.js' import {syncConfigToXdg} from '../../../server/utils/config-xdg-sync.js' @@ -74,37 +75,35 @@ export const validateBrvConfigVersion = async ( return } - const exists = await configStore.exists() - if (!exists) { + // Check cwd first (via config store), then walk up directory tree (git-like behavior) + const cwdExists = await configStore.exists() + const projectRoot = cwdExists ? process.cwd() : findProjectRoot(process.cwd()) + if (!projectRoot) { const message = commandId.startsWith('vc:') ? 'ByteRover version control not initialized. Run brv vc init first.' : 'fatal: not a brv project (or any of the parent directories): .brv' throw new Error(message) } - const config = await configStore.read() + const config = await configStore.read(projectRoot) if (!config) { throw new Error('fatal: corrupt or unreadable config: .brv/config.json') } - // ProjectConfigStore checks .brv/ at process.cwd() directly (no walk-up), - // so configStore.exists() returning true means process.cwd() IS the project root. - const cwd = process.cwd() - // Gate the connector-file patch behind a per-project marker file in the XDG data dir. // This keeps internal bookkeeping out of the user-facing .brv/config.json. - const marker = patchMarkerDeps ?? defaultPatchMarkerDeps(cwd) + const marker = patchMarkerDeps ?? defaultPatchMarkerDeps(projectRoot) const alreadyPatched = await marker.isPatched() if (!alreadyPatched) { const patchFn = patchMarkerDeps?.patchFn ?? ensureCurateViewPatched - await patchFn(cwd).catch(() => {}) + await patchFn(projectRoot).catch(() => {}) await marker.markPatched().catch(() => {}) } if (config.version !== BRV_CONFIG_VERSION) { const updated = config.withVersion(BRV_CONFIG_VERSION) - await configStore.write(updated) - await syncConfigToXdg(updated, cwd) + await configStore.write(updated, projectRoot) + await syncConfigToXdg(updated, projectRoot) } } diff --git a/src/server/constants.ts b/src/server/constants.ts index 8a97a2435..3aa401329 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -13,6 +13,12 @@ export const GLOBAL_DATA_DIR = 'brv' export const PROJECT = 'byterover' +// Knowledge sharing +export const WORKSPACES_FILE = 'workspaces.json' +export const DEPENDENCIES_FILE = 'dependencies.json' +export const BUNDLES_DIR = 'bundles' +export const KNOWLEDGE_LINK_LOCAL_SCORE_BOOST = 0.1 + // Context Tree directory structure constants export const CONTEXT_TREE_DIR = 'context-tree' export const CONTEXT_TREE_BACKUP_DIR = 'context-tree-backup' @@ -106,4 +112,5 @@ export const CONTEXT_TREE_GITIGNORE = `# Derived artifacts — do not track .snapshot.json _manifest.json _index.md +bundles ` diff --git a/src/server/core/domain/knowledge/dependencies-schema.ts b/src/server/core/domain/knowledge/dependencies-schema.ts new file mode 100644 index 000000000..6fc73ce20 --- /dev/null +++ b/src/server/core/domain/knowledge/dependencies-schema.ts @@ -0,0 +1,45 @@ +import {existsSync, readFileSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import {z} from 'zod' + +import {BRV_DIR, CONTEXT_TREE_DIR, DEPENDENCIES_FILE} from '../../../constants.js' + +const DependenciesFileSchema = z.record(z.string(), z.string()) + +/** + * Loads `.brv/context-tree/dependencies.json` from a project root. + * + * Returns null if the file does not exist. + * Returns empty object if the file is malformed or invalid. + */ +export function loadDependenciesFile(projectRoot: string): null | Record { + const filePath = join(projectRoot, BRV_DIR, CONTEXT_TREE_DIR, DEPENDENCIES_FILE) + + if (!existsSync(filePath)) { + return null + } + + let raw: unknown + try { + raw = JSON.parse(readFileSync(filePath, 'utf8')) + } catch { + console.warn(`Warning: ${filePath} contains invalid JSON — ignoring dependencies`) + return {} + } + + const result = DependenciesFileSchema.safeParse(raw) + if (!result.success) { + console.warn(`Warning: ${filePath} has invalid schema (expected {name: version} object) — ignoring dependencies`) + return {} + } + + return result.data +} + +/** + * Writes `.brv/context-tree/dependencies.json` with pretty JSON. + */ +export function writeDependenciesFile(projectRoot: string, deps: Record): void { + const filePath = join(projectRoot, BRV_DIR, CONTEXT_TREE_DIR, DEPENDENCIES_FILE) + writeFileSync(filePath, JSON.stringify(deps, null, 2) + '\n', 'utf8') +} diff --git a/src/server/core/domain/knowledge/find-project-root.ts b/src/server/core/domain/knowledge/find-project-root.ts new file mode 100644 index 000000000..24d5b9679 --- /dev/null +++ b/src/server/core/domain/knowledge/find-project-root.ts @@ -0,0 +1,39 @@ +import {existsSync} from 'node:fs' +import {dirname, join} from 'node:path' + +import {BRV_DIR, PROJECT_CONFIG_FILE} from '../../../constants.js' + +export interface FindProjectRootOptions { + /** Stop walking up at the nearest git root (.git directory). Default: false */ + stopAtGitRoot?: boolean +} + +/** + * Walks up the directory tree from `startDir` looking for `.brv/config.json`. + * Returns the first directory that contains `.brv/config.json`, or undefined if none found. + * + * Behaves like git — if you're in a subfolder, uses the nearest ancestor's `.brv/`. + */ +export function findProjectRoot(startDir: string, options?: FindProjectRootOptions): string | undefined { + let current = startDir + + while (true) { + const configPath = join(current, BRV_DIR, PROJECT_CONFIG_FILE) + if (existsSync(configPath)) { + return current + } + + // Stop at git root if requested (check AFTER .brv/ check so git root with .brv/ is found) + if (options?.stopAtGitRoot && existsSync(join(current, '.git'))) { + return undefined + } + + const parent = dirname(current) + if (parent === current) { + // Reached filesystem root + return undefined + } + + current = parent + } +} diff --git a/src/server/core/domain/knowledge/knowledge-source.ts b/src/server/core/domain/knowledge/knowledge-source.ts new file mode 100644 index 000000000..74ab7b4f7 --- /dev/null +++ b/src/server/core/domain/knowledge/knowledge-source.ts @@ -0,0 +1,21 @@ +import {createHash} from 'node:crypto' + +export interface KnowledgeSource { + alias?: string + contextTreeRoot: string + sourceKey: string + type: 'linked' | 'local' +} + +export interface LoadedKnowledgeSources { + mtime: number + sources: KnowledgeSource[] +} + +/** + * Derives a stable, short source key from a canonical path. + * Uses first 12 hex chars of SHA-256 to avoid alias-based collisions. + */ +export function deriveSourceKey(canonicalPath: string): string { + return createHash('sha256').update(canonicalPath).digest('hex').slice(0, 12) +} diff --git a/src/server/core/domain/knowledge/load-knowledge-sources.ts b/src/server/core/domain/knowledge/load-knowledge-sources.ts new file mode 100644 index 000000000..c8eed4bd6 --- /dev/null +++ b/src/server/core/domain/knowledge/load-knowledge-sources.ts @@ -0,0 +1,33 @@ +import {statSync} from 'node:fs' +import {join} from 'node:path' + +import type {LoadedKnowledgeSources} from './knowledge-source.js' + +import {BRV_DIR, WORKSPACES_FILE} from '../../../constants.js' +import {resolveWorkspaces} from './workspaces-resolver.js' +import {loadWorkspacesFile} from './workspaces-schema.js' + +/** + * Loads `.brv/workspaces.json` and resolves all workspace entries into KnowledgeSource[]. + * + * Returns null if `workspaces.json` does not exist. + * Tracks mtime of `workspaces.json` for cache invalidation. + */ +export function loadKnowledgeSources(projectRoot: string): LoadedKnowledgeSources | null { + const workspaces = loadWorkspacesFile(projectRoot) + if (workspaces === null) { + return null + } + + const filePath = join(projectRoot, BRV_DIR, WORKSPACES_FILE) + let mtime = 0 + try { + mtime = statSync(filePath).mtimeMs + } catch { + // File may have been deleted between load and stat + } + + const sources = resolveWorkspaces(projectRoot, workspaces) + + return {mtime, sources} +} diff --git a/src/server/core/domain/knowledge/workspaces-operations.ts b/src/server/core/domain/knowledge/workspaces-operations.ts new file mode 100644 index 000000000..31096d08b --- /dev/null +++ b/src/server/core/domain/knowledge/workspaces-operations.ts @@ -0,0 +1,122 @@ +import {existsSync, realpathSync} from 'node:fs' +import {join, relative} from 'node:path' + +import {BRV_DIR, PROJECT_CONFIG_FILE} from '../../../constants.js' +import {loadWorkspacesFile, writeWorkspacesFile} from './workspaces-schema.js' + +export interface OperationResult { + message: string + success: boolean +} + +/** + * Add a workspace entry to `.brv/workspaces.json`. + * Computes relative path from projectRoot to targetPath. + * Validates target is a brv project and prevents duplicates. + */ +export function addWorkspace(projectRoot: string, targetPath: string): OperationResult { + if (!existsSync(targetPath)) { + return {message: `Target path does not exist: ${targetPath}`, success: false} + } + + let canonicalProject: string + let canonicalTarget: string + try { + canonicalProject = realpathSync(projectRoot) + canonicalTarget = realpathSync(targetPath) + } catch { + return {message: `Cannot resolve paths`, success: false} + } + + // Self-link check + if (canonicalProject === canonicalTarget) { + return {message: 'Cannot link to self', success: false} + } + + // Validate target is a brv project + const targetConfig = join(canonicalTarget, BRV_DIR, PROJECT_CONFIG_FILE) + if (!existsSync(targetConfig)) { + return {message: `Target is not a ByteRover project (missing ${BRV_DIR}/${PROJECT_CONFIG_FILE})`, success: false} + } + + // Compute relative path + const relativePath = relative(canonicalProject, canonicalTarget) + + // Load existing or start fresh + const existing = loadWorkspacesFile(projectRoot) ?? [] + + // Dedup check — compare resolved paths + for (const entry of existing) { + try { + const resolved = realpathSync(join(canonicalProject, entry)) + if (resolved === canonicalTarget) { + return {message: `Workspace already linked: ${entry}`, success: false} + } + } catch { + // Broken entry — skip + } + } + + existing.push(relativePath) + writeWorkspacesFile(projectRoot, existing) + + return {message: `Added workspace: ${relativePath}`, success: true} +} + +/** + * Remove a workspace entry from `.brv/workspaces.json`. + * Matches by relative path string or by resolving absolute path. + */ +export function removeWorkspace(projectRoot: string, path: string): OperationResult { + const existing = loadWorkspacesFile(projectRoot) + if (!existing || existing.length === 0) { + return {message: 'No workspaces configured', success: false} + } + + let canonicalProject: string + try { + canonicalProject = realpathSync(projectRoot) + } catch { + return {message: 'Cannot resolve project root', success: false} + } + + // Try to resolve the input path to canonical form + let canonicalInput: string | undefined + try { + // If it's a relative path, resolve against project root + const resolved = join(canonicalProject, path) + if (existsSync(resolved)) { + canonicalInput = realpathSync(resolved) + } else if (existsSync(path)) { + canonicalInput = realpathSync(path) + } + } catch { + // Can't resolve — will try string match + } + + const idx = existing.findIndex((entry) => { + // Direct string match + if (entry === path) return true + + // Canonical path match + if (canonicalInput) { + try { + const resolved = realpathSync(join(canonicalProject, entry)) + return resolved === canonicalInput + } catch { + // Broken entry + } + } + + return false + }) + + if (idx === -1) { + return {message: `Workspace not found: ${path}`, success: false} + } + + const removed = existing.splice(idx, 1)[0] + writeWorkspacesFile(projectRoot, existing) + + return {message: `Removed workspace: ${removed}`, success: true} +} diff --git a/src/server/core/domain/knowledge/workspaces-resolver.ts b/src/server/core/domain/knowledge/workspaces-resolver.ts new file mode 100644 index 000000000..7aeabd5f2 --- /dev/null +++ b/src/server/core/domain/knowledge/workspaces-resolver.ts @@ -0,0 +1,101 @@ +import {existsSync, readdirSync, realpathSync} from 'node:fs' +import {basename, join, resolve} from 'node:path' + +import {BRV_DIR, CONTEXT_TREE_DIR, PROJECT_CONFIG_FILE} from '../../../constants.js' +import {deriveSourceKey, type KnowledgeSource} from './knowledge-source.js' + +/** + * Resolves workspace entries (relative paths or simple globs) into KnowledgeSource[]. + * + * Supports: + * - Relative paths: `../shared-lib` + * - Simple single-level globs: `packages/*` + * + * Each resolved directory must have `.brv/config.json` AND `.brv/context-tree/` to be included. + * Broken or invalid paths are silently skipped. + */ +export function resolveWorkspaces(projectRoot: string, workspaces: string[]): KnowledgeSource[] { + const sources: KnowledgeSource[] = [] + const seen = new Set() + + for (const entry of workspaces) { + const resolved = resolveEntry(projectRoot, entry) + for (const dir of resolved) { + const source = tryBuildSource(dir, seen) + if (source) { + sources.push(source) + } + } + } + + return sources +} + +function resolveEntry(projectRoot: string, entry: string): string[] { + // Only prefix/* globs are supported + if (entry.endsWith('/*')) { + return expandGlob(projectRoot, entry) + } + + // Warn on unsupported glob patterns that would silently resolve to a literal path + if (entry.includes('*')) { + console.warn(`Warning: unsupported glob pattern "${entry}" in workspaces.json — only "prefix/*" is supported`) + return [] + } + + return [resolve(projectRoot, entry)] +} + +function expandGlob(projectRoot: string, pattern: string): string[] { + const prefix = pattern.slice(0, -2) // strip /* + const parentDir = resolve(projectRoot, prefix) + + if (!existsSync(parentDir)) { + return [] + } + + try { + const entries = readdirSync(parentDir, {withFileTypes: true}) + return entries + .filter((e) => e.isDirectory()) + .map((e) => join(parentDir, e.name)) + } catch { + return [] + } +} + +function tryBuildSource(dir: string, seen: Set): KnowledgeSource | null { + if (!existsSync(dir)) { + return null + } + + let canonicalDir: string + try { + canonicalDir = realpathSync(dir) + } catch { + return null + } + + if (seen.has(canonicalDir)) { + return null + } + + const configPath = join(canonicalDir, BRV_DIR, PROJECT_CONFIG_FILE) + if (!existsSync(configPath)) { + return null + } + + const contextTreeRoot = join(canonicalDir, BRV_DIR, CONTEXT_TREE_DIR) + if (!existsSync(contextTreeRoot)) { + return null + } + + seen.add(canonicalDir) + + return { + alias: basename(canonicalDir), + contextTreeRoot, + sourceKey: deriveSourceKey(canonicalDir), + type: 'linked', + } +} diff --git a/src/server/core/domain/knowledge/workspaces-schema.ts b/src/server/core/domain/knowledge/workspaces-schema.ts new file mode 100644 index 000000000..ba8c770c6 --- /dev/null +++ b/src/server/core/domain/knowledge/workspaces-schema.ts @@ -0,0 +1,45 @@ +import {existsSync, readFileSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import {z} from 'zod' + +import {BRV_DIR, WORKSPACES_FILE} from '../../../constants.js' + +const WorkspacesFileSchema = z.array(z.string()) + +/** + * Loads `.brv/workspaces.json` from a project root. + * + * Returns null if the file does not exist. + * Returns empty array if the file is malformed or invalid. + */ +export function loadWorkspacesFile(projectRoot: string): null | string[] { + const filePath = join(projectRoot, BRV_DIR, WORKSPACES_FILE) + + if (!existsSync(filePath)) { + return null + } + + let raw: unknown + try { + raw = JSON.parse(readFileSync(filePath, 'utf8')) + } catch { + console.warn(`Warning: ${filePath} contains invalid JSON — ignoring workspaces`) + return [] + } + + const result = WorkspacesFileSchema.safeParse(raw) + if (!result.success) { + console.warn(`Warning: ${filePath} has invalid schema (expected string array) — ignoring workspaces`) + return [] + } + + return result.data +} + +/** + * Writes `.brv/workspaces.json` with pretty JSON. + */ +export function writeWorkspacesFile(projectRoot: string, workspaces: string[]): void { + const filePath = join(projectRoot, BRV_DIR, WORKSPACES_FILE) + writeFileSync(filePath, JSON.stringify(workspaces, null, 2) + '\n', 'utf8') +} diff --git a/src/server/infra/daemon/agent-process.ts b/src/server/infra/daemon/agent-process.ts index be49ea7b7..14596ca3b 100644 --- a/src/server/infra/daemon/agent-process.ts +++ b/src/server/infra/daemon/agent-process.ts @@ -238,6 +238,10 @@ async function start(): Promise { agentLog(`Provider: ${activeProvider}, Model: ${activeModel ?? 'default'}`) // 5. Create CipherAgent with lazy providers + transport client + // Note: linked context tree access is handled internally by search-knowledge-service + // (reads linked files directly via absolute paths during indexing). + // We do NOT add linked paths to allowedPaths because workspaces can change at runtime + // and allowedPaths is set once at startup. const envConfig = getCurrentConfig() const agentConfig = { apiBaseUrl: envConfig.llmApiBaseUrl, diff --git a/src/server/infra/executor/query-executor.ts b/src/server/infra/executor/query-executor.ts index 1c4af6c12..bccc9f0c6 100644 --- a/src/server/infra/executor/query-executor.ts +++ b/src/server/infra/executor/query-executor.ts @@ -6,6 +6,7 @@ import type { ISearchKnowledgeService, SearchKnowledgeResult } from '../../../ag import type { IQueryExecutor, QueryExecuteOptions } from '../../core/interfaces/executor/i-query-executor.js' import { BRV_DIR, CONTEXT_FILE_EXTENSION, CONTEXT_TREE_DIR } from '../../constants.js' +import { loadKnowledgeSources } from '../../core/domain/knowledge/load-knowledge-sources.js' import { isDerivedArtifact } from '../context-tree/derived-artifact.js' import { FileContextTreeManifestService } from '../context-tree/file-context-tree-manifest-service.js' import { @@ -233,9 +234,10 @@ export class QueryExecutor implements IQueryExecutor { if (highConfidenceResults.length === 0) return undefined - const sections = highConfidenceResults.map( - (r) => `### ${r.title}\n**Source**: .brv/context-tree/${r.path}\n\n${r.excerpt}`, - ) + const sections = highConfidenceResults.map((r) => { + const source = r.sourceAlias ? `[${r.sourceAlias}]:${r.path}` : `.brv/context-tree/${r.path}` + return `### ${r.title}\n**Source**: ${source}\n\n${r.excerpt}` + }) return sections.join('\n\n---\n\n') } @@ -348,6 +350,42 @@ ${responseFormat}` path: f.path, })) + // Include linked workspace files in fingerprint for cache invalidation + if (this.baseDirectory) { + const loaded = loadKnowledgeSources(this.baseDirectory) + if (loaded) { + // Include workspaces.json mtime + files.push({mtime: loaded.mtime, path: '__workspaces_mtime__'}) + + // Include linked context tree files + const linkedGlobResults = await Promise.all( + loaded.sources.map(async (source) => { + try { + const linkedGlob = await this.fileSystem!.globFiles(`**/*${CONTEXT_FILE_EXTENSION}`, { + cwd: source.contextTreeRoot, + includeMetadata: true, + maxResults: 10_000, + respectGitignore: false, + }) + + return linkedGlob.files + .filter((f) => !isDerivedArtifact(f.path)) + .map((f) => ({ + mtime: f.modified?.getTime() ?? 0, + path: `${source.sourceKey}::${f.path}`, + })) + } catch { + return [] + } + }), + ) + + for (const linkedFiles of linkedGlobResults) { + files.push(...linkedFiles) + } + } + } + const fingerprint = QueryResultCache.computeFingerprint(files) this.cachedFingerprint = { expiresAt: Date.now() + QueryExecutor.FINGERPRINT_CACHE_TTL_MS, diff --git a/src/server/infra/hub/hub-install-service.ts b/src/server/infra/hub/hub-install-service.ts index 3f688d7a5..03cdb673a 100644 --- a/src/server/infra/hub/hub-install-service.ts +++ b/src/server/infra/hub/hub-install-service.ts @@ -11,7 +11,8 @@ import type { import type {IFileService} from '../../core/interfaces/services/i-file-service.js' import type {SkillConnector} from '../connectors/skill/skill-connector.js' -import {BRV_DIR, CONTEXT_TREE_DIR} from '../../constants.js' +import {BRV_DIR, BUNDLES_DIR, CONTEXT_TREE_DIR} from '../../constants.js' +import {loadDependenciesFile, writeDependenciesFile} from '../../core/domain/knowledge/dependencies-schema.js' import {ProxyConfig} from '../http/proxy-config.js' import {buildAuthHeaders} from './hub-auth-headers.js' @@ -94,25 +95,34 @@ export class HubInstallService implements IHubInstallService { projectPath: string, auth?: HubInstallAuthParams, ): Promise<{installedFiles: string[]; installedPath: string; message: string}> { - const contextTreeDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR) + const bundleDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR, BUNDLES_DIR, entry.id) const contentFiles = this.getContentFiles(entry) if (contentFiles.length > 0) { - const firstFilePath = join(contextTreeDir, contentFiles[0].name) + const firstFilePath = join(bundleDir, contentFiles[0].name) if (await this.fileService.exists(firstFilePath)) { return { installedFiles: [], - installedPath: contextTreeDir, + installedPath: bundleDir, message: `${entry.name} is already installed in context tree`, } } } - const installedFiles = await this.downloadAndWrite(contentFiles, contextTreeDir, auth) + const installedFiles = await this.downloadAndWrite(contentFiles, bundleDir, auth) + + // Track in dependencies.json (best-effort — dir may not exist in test environments) + try { + const deps = loadDependenciesFile(projectPath) ?? {} + deps[entry.id] = entry.version + writeDependenciesFile(projectPath, deps) + } catch { + // Silently skip if dependencies.json cannot be written + } return { installedFiles, - installedPath: contextTreeDir, + installedPath: bundleDir, message: `Installed ${entry.name} bundle to context tree.`, } } diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index 7693d6a49..a932844db 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -63,6 +63,7 @@ import { SpaceHandler, StatusHandler, VcHandler, + WorkspaceHandler, } from '../transport/handlers/index.js' import {HttpUserService} from '../user/http-user-service.js' import {FileVcGitConfigStore} from '../vc/file-vc-git-config-store.js' @@ -255,6 +256,11 @@ export async function setupFeatureHandlers({ transport, }).setup() + new WorkspaceHandler({ + resolveProjectPath, + transport, + }).setup() + new InitHandler({ broadcastToProject, cogitPullService, diff --git a/src/server/infra/transport/handlers/hub-handler.ts b/src/server/infra/transport/handlers/hub-handler.ts index 8b29dd183..c40e096f3 100644 --- a/src/server/infra/transport/handlers/hub-handler.ts +++ b/src/server/infra/transport/handlers/hub-handler.ts @@ -1,3 +1,6 @@ +import {existsSync, rmSync} from 'node:fs' +import {join} from 'node:path' + import type {AuthScheme} from '../../../../shared/transport/types/auth-scheme.js' import type {HubEntryDTO} from '../../../../shared/transport/types/dto.js' import type {HubInstallAuthParams, IHubInstallService} from '../../../core/interfaces/hub/i-hub-install-service.js' @@ -5,10 +8,10 @@ import type {IHubKeychainStore} from '../../../core/interfaces/hub/i-hub-keychai import type {IHubRegistryConfigStore} from '../../../core/interfaces/hub/i-hub-registry-config-store.js' import type {IHubRegistryService} from '../../../core/interfaces/hub/i-hub-registry-service.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' -import type {ProjectPathResolver} from './handler-types.js' import { HubEvents, + type HubInstallAllResponse, type HubInstallRequest, type HubInstallResponse, type HubListResponse, @@ -18,10 +21,15 @@ import { type HubRegistryListResponse, type HubRegistryRemoveRequest, type HubRegistryRemoveResponse, + type HubUninstallRequest, + type HubUninstallResponse, } from '../../../../shared/transport/events/hub-events.js' +import {BRV_DIR, BUNDLES_DIR, CONTEXT_TREE_DIR} from '../../../constants.js' import {type Agent, isAgent} from '../../../core/domain/entities/agent.js' +import {loadDependenciesFile, writeDependenciesFile} from '../../../core/domain/knowledge/dependencies-schema.js' import {CompositeHubRegistryService} from '../../hub/composite-hub-registry-service.js' import {HubRegistryService} from '../../hub/hub-registry-service.js' +import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' const OFFICIAL_REGISTRY_NAME = 'official' @@ -81,6 +89,14 @@ export class HubHandler { ) this.transport.onRequest(HubEvents.REGISTRY_LIST, () => this.handleRegistryList()) + + this.transport.onRequest(HubEvents.INSTALL_ALL, (_data, clientId) => + this.handleInstallAll(clientId), + ) + + this.transport.onRequest(HubEvents.UNINSTALL, (data, clientId) => + this.handleUninstall(data, clientId), + ) } private async handleInstall(data: HubInstallRequest, clientId: string): Promise { @@ -124,6 +140,70 @@ export class HubHandler { } } + private async handleInstallAll(clientId: string): Promise { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + + const deps = loadDependenciesFile(projectPath) + if (!deps || Object.keys(deps).length === 0) { + return {message: 'No dependencies declared. Nothing to install.', results: [], success: true} + } + + const entries = Object.keys(deps) + + // Partition into already-installed and to-install + const toInstall: string[] = [] + const skipped: string[] = [] + for (const id of entries) { + const bundleDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR, BUNDLES_DIR, id) + if (existsSync(bundleDir)) { + skipped.push(id) + } else { + toInstall.push(id) + } + } + + // Build results — include skipped as already-installed entries + const results: HubInstallAllResponse['results'] = skipped.map((entryId) => ({ + entryId, + message: 'Already installed', + success: true, + })) + + if (toInstall.length > 0) { + const settled = await Promise.allSettled( + toInstall.map(async (entryId) => { + const installResult = await this.handleInstall({entryId}, clientId) + return {entryId, message: installResult.message, success: installResult.success} + }), + ) + + for (const [i, s] of settled.entries()) { + results.push( + s.status === 'fulfilled' + ? s.value + : { + entryId: toInstall[i], + message: s.reason instanceof Error ? s.reason.message : 'Unknown error', + success: false, + }, + ) + } + } + + const installed = results.filter((r) => r.success && r.message !== 'Already installed').length + const allSuccess = results.every((r) => r.success) + const summary = + toInstall.length === 0 + ? `All ${entries.length} dependencies already installed.` + : `Installed ${installed}/${toInstall.length} new (${skipped.length} already installed).` + + return { + message: summary, + results, + success: allSuccess, + } + } + private async handleList(): Promise { this.transport.broadcast(HubEvents.LIST_PROGRESS, {message: 'Fetching hub entries...', step: 'fetching'}) const {entries, version} = await this.hubRegistryService.getEntries() @@ -230,6 +310,27 @@ export class HubHandler { } } + private handleUninstall(data: HubUninstallRequest, clientId: string): HubUninstallResponse { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + + // Remove from dependencies.json + const deps = loadDependenciesFile(projectPath) + if (!deps || !(data.entryId in deps)) { + return {message: `Dependency "${data.entryId}" not found in dependencies.json`, success: false} + } + + delete deps[data.entryId] + writeDependenciesFile(projectPath, deps) + + // Delete installed files from bundles/ directory + const bundleDir = join(projectPath, BRV_DIR, CONTEXT_TREE_DIR, BUNDLES_DIR, data.entryId) + if (existsSync(bundleDir)) { + rmSync(bundleDir, {force: true, recursive: true}) + } + + return {message: `Uninstalled "${data.entryId}" and removed from dependencies.`, success: true} + } + private async performInstall( entry: HubEntryDTO, projectPath: string, diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index cb36910f2..44e3264df 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -30,3 +30,5 @@ export {StatusHandler} from './status-handler.js' export type {StatusHandlerDeps} from './status-handler.js' export {VcHandler} from './vc-handler.js' export type {IVcHandlerDeps} from './vc-handler.js' +export {WorkspaceHandler} from './workspace-handler.js' +export type {WorkspaceHandlerDeps} from './workspace-handler.js' diff --git a/src/server/infra/transport/handlers/status-handler.ts b/src/server/infra/transport/handlers/status-handler.ts index cbef570d0..4e77824f0 100644 --- a/src/server/infra/transport/handlers/status-handler.ts +++ b/src/server/infra/transport/handlers/status-handler.ts @@ -10,6 +10,8 @@ import type {ITransportServer} from '../../../core/interfaces/transport/i-transp import {StatusEvents, type StatusGetResponse} from '../../../../shared/transport/events/status-events.js' import {BRV_DIR, CONTEXT_TREE_DIR} from '../../../constants.js' +import {loadDependenciesFile} from '../../../core/domain/knowledge/dependencies-schema.js' +import {loadWorkspacesFile} from '../../../core/domain/knowledge/workspaces-schema.js' import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' /** Factory that creates a curate log store scoped to a project directory. */ @@ -127,6 +129,22 @@ export class StatusHandler { result.contextTreeStatus = 'unknown' } + // Knowledge workspaces + try { + const workspaces = loadWorkspacesFile(projectPath) + if (workspaces && workspaces.length > 0) { + result.workspaces = workspaces + } + } catch {} + + // Hub dependencies + try { + const deps = loadDependenciesFile(projectPath) + if (deps && Object.keys(deps).length > 0) { + result.dependencies = deps + } + } catch {} + // Pending review count (best-effort) try { const store = this.curateLogStoreFactory(projectPath) diff --git a/src/server/infra/transport/handlers/workspace-handler.ts b/src/server/infra/transport/handlers/workspace-handler.ts new file mode 100644 index 000000000..606259442 --- /dev/null +++ b/src/server/infra/transport/handlers/workspace-handler.ts @@ -0,0 +1,47 @@ +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import { + type WorkspaceAddRequest, + WorkspaceEvents, + type WorkspaceOperationResponse, + type WorkspaceRemoveRequest, +} from '../../../../shared/transport/events/workspace-events.js' +import {addWorkspace, removeWorkspace} from '../../../core/domain/knowledge/workspaces-operations.js' +import {type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' + +export interface WorkspaceHandlerDeps { + resolveProjectPath: ProjectPathResolver + transport: ITransportServer +} + +export class WorkspaceHandler { + private readonly resolveProjectPath: ProjectPathResolver + private readonly transport: ITransportServer + + constructor(deps: WorkspaceHandlerDeps) { + this.resolveProjectPath = deps.resolveProjectPath + this.transport = deps.transport + } + + setup(): void { + this.transport.onRequest( + WorkspaceEvents.ADD, + (data, clientId) => this.handleAdd(data, clientId), + ) + + this.transport.onRequest( + WorkspaceEvents.REMOVE, + (data, clientId) => this.handleRemove(data, clientId), + ) + } + + private handleAdd(data: WorkspaceAddRequest, clientId: string): WorkspaceOperationResponse { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + return addWorkspace(projectPath, data.targetPath) + } + + private handleRemove(data: WorkspaceRemoveRequest, clientId: string): WorkspaceOperationResponse { + const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + return removeWorkspace(projectPath, data.path) + } +} diff --git a/src/shared/transport/events/hub-events.ts b/src/shared/transport/events/hub-events.ts index 8f91cf50c..60793b7f4 100644 --- a/src/shared/transport/events/hub-events.ts +++ b/src/shared/transport/events/hub-events.ts @@ -3,6 +3,7 @@ import type {HubEntryDTO} from '../types/dto.js' export const HubEvents = { INSTALL: 'hub:install', + INSTALL_ALL: 'hub:install-all', LIST: 'hub:list', LIST_PROGRESS: 'hub:list:progress', REGISTRY_ADD: 'hub:registry:add', @@ -10,6 +11,7 @@ export const HubEvents = { REGISTRY_LIST: 'hub:registry:list', REGISTRY_LIST_PROGRESS: 'hub:registry:list:progress', REGISTRY_REMOVE: 'hub:registry:remove', + UNINSTALL: 'hub:uninstall', } as const export interface HubProgressEvent { @@ -36,6 +38,21 @@ export interface HubInstallResponse { success: boolean } +export interface HubInstallAllResponse { + message: string + results: Array<{entryId: string; message: string; success: boolean}> + success: boolean +} + +export interface HubUninstallRequest { + entryId: string +} + +export interface HubUninstallResponse { + message: string + success: boolean +} + // Registry management export interface HubRegistryAddRequest { diff --git a/src/shared/transport/events/index.ts b/src/shared/transport/events/index.ts index 70fc28838..419552b22 100644 --- a/src/shared/transport/events/index.ts +++ b/src/shared/transport/events/index.ts @@ -21,6 +21,7 @@ export * from './session-events.js' export * from './space-events.js' export * from './status-events.js' export * from './task-events.js' +export * from './workspace-events.js' // Utility exports import {AgentEvents} from './agent-events.js' @@ -42,6 +43,7 @@ import {SessionEvents} from './session-events.js' import {SpaceEvents} from './space-events.js' import {StatusEvents} from './status-events.js' import {TaskEvents} from './task-events.js' +import {WorkspaceEvents} from './workspace-events.js' /** * Array of all event group objects for iteration. @@ -67,6 +69,7 @@ export const AllEventGroups = [ SpaceEvents, StatusEvents, TaskEvents, + WorkspaceEvents, ] as const /** diff --git a/src/shared/transport/events/workspace-events.ts b/src/shared/transport/events/workspace-events.ts new file mode 100644 index 000000000..708952d3f --- /dev/null +++ b/src/shared/transport/events/workspace-events.ts @@ -0,0 +1,17 @@ +export const WorkspaceEvents = { + ADD: 'workspace:add', + REMOVE: 'workspace:remove', +} as const + +export interface WorkspaceAddRequest { + targetPath: string +} + +export interface WorkspaceRemoveRequest { + path: string +} + +export interface WorkspaceOperationResponse { + message: string + success: boolean +} diff --git a/src/shared/transport/types/dto.ts b/src/shared/transport/types/dto.ts index a000ce4dd..7b080eb09 100644 --- a/src/shared/transport/types/dto.ts +++ b/src/shared/transport/types/dto.ts @@ -152,6 +152,8 @@ export interface StatusDTO { contextTreeRelativeDir?: string contextTreeStatus: 'git_vc' | 'has_changes' | 'no_changes' | 'not_initialized' | 'unknown' currentDirectory: string + /** Installed hub bundle dependencies (name → version) */ + dependencies?: Record /** Number of files with pending HITL review (0 if none or unavailable). */ pendingReviewCount?: number /** URL to the local review UI (only set when pendingReviewCount > 0). */ @@ -159,4 +161,6 @@ export interface StatusDTO { spaceName?: string teamName?: string userEmail?: string + /** Configured knowledge workspaces (relative paths) */ + workspaces?: string[] } diff --git a/src/tui/features/commands/definitions/hub-uninstall.ts b/src/tui/features/commands/definitions/hub-uninstall.ts new file mode 100644 index 000000000..e143e99d7 --- /dev/null +++ b/src/tui/features/commands/definitions/hub-uninstall.ts @@ -0,0 +1,52 @@ +import type {SlashCommand} from '../../../types/commands.js' + +import { + HubEvents, + type HubUninstallRequest, + type HubUninstallResponse, +} from '../../../../shared/transport/events/hub-events.js' +import {useTransportStore} from '../../../stores/transport-store.js' + +export const hubUninstallCommand: SlashCommand = { + async action(_context, args) { + const entryId = args.trim() + if (!entryId) { + return { + content: 'Usage: /hub uninstall ', + messageType: 'error' as const, + type: 'message' as const, + } + } + + const {apiClient} = useTransportStore.getState() + if (!apiClient) { + return { + content: 'Not connected to daemon.', + messageType: 'error' as const, + type: 'message' as const, + } + } + + try { + const result = await apiClient.request( + HubEvents.UNINSTALL, + {entryId}, + ) + + return { + content: result.message, + messageType: result.success ? ('info' as const) : ('error' as const), + type: 'message' as const, + } + } catch (error) { + return { + content: `Failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + messageType: 'error' as const, + type: 'message' as const, + } + } + }, + args: [{description: 'Entry ID to uninstall', name: 'id', required: true}], + description: 'Uninstall a bundle and remove from dependencies', + name: 'uninstall', +} diff --git a/src/tui/features/commands/definitions/hub.ts b/src/tui/features/commands/definitions/hub.ts index 678b943ab..f4cc4ad14 100644 --- a/src/tui/features/commands/definitions/hub.ts +++ b/src/tui/features/commands/definitions/hub.ts @@ -2,9 +2,10 @@ import type {SlashCommand} from '../../../types/commands.js' import {hubListCommand} from './hub-list.js' import {hubRegistryCommand} from './hub-registry.js' +import {hubUninstallCommand} from './hub-uninstall.js' export const hubCommand: SlashCommand = { description: 'Browse and manage skills & bundles registry', name: 'hub', - subCommands: [hubListCommand, hubRegistryCommand], + subCommands: [hubListCommand, hubUninstallCommand, hubRegistryCommand], } diff --git a/src/tui/features/commands/definitions/index.ts b/src/tui/features/commands/definitions/index.ts index debd09ba0..a2d140a2d 100644 --- a/src/tui/features/commands/definitions/index.ts +++ b/src/tui/features/commands/definitions/index.ts @@ -17,6 +17,7 @@ import {resetCommand} from './reset.js' import {spaceCommand} from './space.js' import {statusCommand} from './status.js' import {vcCommand} from './vc.js' +import {workspaceCommand} from './workspace.js' /** * Load all REPL slash commands. @@ -34,6 +35,9 @@ export const load: () => SlashCommand[] = () => [ // Connectors management connectorsCommand, + // Knowledge workspaces + workspaceCommand, + // Hub - Registry hubCommand, diff --git a/src/tui/features/commands/definitions/workspace.ts b/src/tui/features/commands/definitions/workspace.ts new file mode 100644 index 000000000..b178e300e --- /dev/null +++ b/src/tui/features/commands/definitions/workspace.ts @@ -0,0 +1,103 @@ +import type {SlashCommand} from '../../../types/commands.js' + +import { + type WorkspaceAddRequest, + WorkspaceEvents, + type WorkspaceOperationResponse, + type WorkspaceRemoveRequest, +} from '../../../../shared/transport/events/workspace-events.js' +import {useTransportStore} from '../../../stores/transport-store.js' + +const workspaceAddCommand: SlashCommand = { + async action(_context, args) { + const targetPath = args.trim() + if (!targetPath) { + return { + content: 'Usage: /workspace add ', + messageType: 'error' as const, + type: 'message' as const, + } + } + + const {apiClient} = useTransportStore.getState() + if (!apiClient) { + return { + content: 'Not connected to daemon.', + messageType: 'error' as const, + type: 'message' as const, + } + } + + try { + const result = await apiClient.request( + WorkspaceEvents.ADD, + {targetPath}, + ) + + return { + content: result.message, + messageType: result.success ? ('info' as const) : ('error' as const), + type: 'message' as const, + } + } catch (error) { + return { + content: `Failed to add workspace: ${error instanceof Error ? error.message : 'Unknown error'}`, + messageType: 'error' as const, + type: 'message' as const, + } + } + }, + args: [{description: 'Path to the project to add', name: 'path', required: true}], + description: 'Add a project as a knowledge workspace', + name: 'add', +} + +const workspaceRemoveCommand: SlashCommand = { + async action(_context, args) { + const path = args.trim() + if (!path) { + return { + content: 'Usage: /workspace remove ', + messageType: 'error' as const, + type: 'message' as const, + } + } + + const {apiClient} = useTransportStore.getState() + if (!apiClient) { + return { + content: 'Not connected to daemon.', + messageType: 'error' as const, + type: 'message' as const, + } + } + + try { + const result = await apiClient.request( + WorkspaceEvents.REMOVE, + {path}, + ) + + return { + content: result.message, + messageType: result.success ? ('info' as const) : ('error' as const), + type: 'message' as const, + } + } catch (error) { + return { + content: `Failed to remove workspace: ${error instanceof Error ? error.message : 'Unknown error'}`, + messageType: 'error' as const, + type: 'message' as const, + } + } + }, + args: [{description: 'Path of the workspace to remove', name: 'path', required: true}], + description: 'Remove a project from knowledge workspaces', + name: 'remove', +} + +export const workspaceCommand: SlashCommand = { + description: 'Manage knowledge workspaces (add/remove linked projects)', + name: 'workspace', + subCommands: [workspaceAddCommand, workspaceRemoveCommand], +} diff --git a/src/tui/features/status/utils/format-status.ts b/src/tui/features/status/utils/format-status.ts index 8ddde7a68..30a08f065 100644 --- a/src/tui/features/status/utils/format-status.ts +++ b/src/tui/features/status/utils/format-status.ts @@ -81,6 +81,23 @@ export function formatStatus(status: StatusDTO, version?: string): string { } } + // Knowledge workspaces + if (status.workspaces && status.workspaces.length > 0) { + lines.push(`Workspaces: ${status.workspaces.length} linked`) + for (const ws of status.workspaces) { + lines.push(` ${ws}`) + } + } + + // Hub dependencies + if (status.dependencies && Object.keys(status.dependencies).length > 0) { + const deps = Object.entries(status.dependencies) + lines.push(`Dependencies: ${deps.length} installed`) + for (const [name, version] of deps) { + lines.push(` ${name}@${version}`) + } + } + if (status.pendingReviewCount && status.pendingReviewCount > 0) { const fileLabel = status.pendingReviewCount === 1 ? 'file' : 'files' lines.push( diff --git a/test/unit/agent/tools/write-guard.test.ts b/test/unit/agent/tools/write-guard.test.ts new file mode 100644 index 000000000..f6b2553d3 --- /dev/null +++ b/test/unit/agent/tools/write-guard.test.ts @@ -0,0 +1,85 @@ +import {expect} from 'chai' +import {mkdirSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {validateWriteTarget} from '../../../../src/agent/infra/tools/write-guard.js' + +function createBrvProject(dir: string): void { + mkdirSync(join(dir, '.brv', 'context-tree'), {recursive: true}) + writeFileSync(join(dir, '.brv', 'config.json'), JSON.stringify({createdAt: new Date().toISOString(), version: '0.0.1'})) +} + +describe('write-guard', () => { + let testDir: string + + beforeEach(() => { + testDir = join(tmpdir(), `brv-guard-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(testDir, {recursive: true}) + }) + + afterEach(() => { + rmSync(testDir, {force: true, recursive: true}) + }) + + describe('validateWriteTarget', () => { + it('should allow writes to local context tree', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + + const target = join(projectRoot, '.brv', 'context-tree', 'some-file.md') + const result = validateWriteTarget(target, projectRoot) + expect(result).to.be.null + }) + + it('should allow writes to nested paths within local context tree', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + + const target = join(projectRoot, '.brv', 'context-tree', 'sub', 'dir', 'file.md') + const result = validateWriteTarget(target, projectRoot) + expect(result).to.be.null + }) + + it('should block writes to a linked project context tree', () => { + const projectRoot = join(testDir, 'project') + const linkedProject = join(testDir, 'linked-lib') + createBrvProject(projectRoot) + createBrvProject(linkedProject) + + // Create workspaces.json linking to the other project + writeFileSync( + join(projectRoot, '.brv', 'workspaces.json'), + JSON.stringify(['../linked-lib']), + ) + + const target = join(linkedProject, '.brv', 'context-tree', 'file.md') + const result = validateWriteTarget(target, projectRoot) + expect(result).to.be.a('string') + expect(result).to.include('linked') + }) + + it('should block writes outside local context tree', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + + const target = join(testDir, 'somewhere-else', 'file.md') + const result = validateWriteTarget(target, projectRoot) + expect(result).to.be.a('string') + }) + + it('should return null if projectRoot is not provided', () => { + const result = validateWriteTarget('/some/path', '') + expect(result).to.be.null + }) + + it('should block writes to .brv/ outside context-tree', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + + const target = join(projectRoot, '.brv', 'config.json') + const result = validateWriteTarget(target, projectRoot) + expect(result).to.be.a('string') + }) + }) +}) diff --git a/test/unit/infra/hub/hub-install-service.test.ts b/test/unit/infra/hub/hub-install-service.test.ts index 516e37d28..fd756526a 100644 --- a/test/unit/infra/hub/hub-install-service.test.ts +++ b/test/unit/infra/hub/hub-install-service.test.ts @@ -210,7 +210,7 @@ describe('HubInstallService', () => { expect(result.message).to.include('context tree') expect(fileService.write.calledOnce).to.be.true - expect(fileService.write.calledWith('# Context', join(projectPath, '.brv/context-tree/context.md'), 'overwrite')) + expect(fileService.write.calledWith('# Context', join(projectPath, '.brv/context-tree/bundles/test-bundle/context.md'), 'overwrite')) .to.be.true }) @@ -243,10 +243,10 @@ describe('HubInstallService', () => { expect(result.installedFiles).to.have.lengthOf(2) expect( - fileService.write.calledWith('# Auth', join(projectPath, '.brv/context-tree/auth/context.md'), 'overwrite'), + fileService.write.calledWith('# Auth', join(projectPath, '.brv/context-tree/bundles/test-bundle/auth/context.md'), 'overwrite'), ).to.be.true expect( - fileService.write.calledWith('# Test', join(projectPath, '.brv/context-tree/test/context.md'), 'overwrite'), + fileService.write.calledWith('# Test', join(projectPath, '.brv/context-tree/bundles/test-bundle/test/context.md'), 'overwrite'), ).to.be.true }) @@ -279,7 +279,7 @@ describe('HubInstallService', () => { expect(result.installedFiles).to.have.lengthOf(1) expect(result.message).to.include('context tree') // Scope should not affect bundle install — still goes to context tree - expect(fileService.write.calledWith('# Context', join(projectPath, '.brv/context-tree/context.md'), 'overwrite')) + expect(fileService.write.calledWith('# Context', join(projectPath, '.brv/context-tree/bundles/test-bundle/context.md'), 'overwrite')) .to.be.true }) }) diff --git a/test/unit/server/knowledge/dependencies-schema.test.ts b/test/unit/server/knowledge/dependencies-schema.test.ts new file mode 100644 index 000000000..9e2202e39 --- /dev/null +++ b/test/unit/server/knowledge/dependencies-schema.test.ts @@ -0,0 +1,79 @@ +import {expect} from 'chai' +import {mkdirSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {loadDependenciesFile, writeDependenciesFile} from '../../../../src/server/core/domain/knowledge/dependencies-schema.js' + +describe('dependencies-schema', () => { + let testDir: string + + beforeEach(() => { + testDir = join(tmpdir(), `brv-deps-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(join(testDir, '.brv', 'context-tree'), {recursive: true}) + }) + + afterEach(() => { + rmSync(testDir, {force: true, recursive: true}) + }) + + describe('loadDependenciesFile', () => { + it('should return null when file does not exist', () => { + const result = loadDependenciesFile(testDir) + expect(result).to.be.null + }) + + it('should return empty object for empty JSON object', () => { + writeFileSync(join(testDir, '.brv', 'context-tree', 'dependencies.json'), '{}') + const result = loadDependenciesFile(testDir) + expect(result).to.deep.equal({}) + }) + + it('should load valid dependencies', () => { + const deps = {'api-standards': '2.3', 'react-patterns': '1.0'} + writeFileSync(join(testDir, '.brv', 'context-tree', 'dependencies.json'), JSON.stringify(deps)) + const result = loadDependenciesFile(testDir) + expect(result).to.deep.equal(deps) + }) + + it('should return empty object for malformed JSON', () => { + writeFileSync(join(testDir, '.brv', 'context-tree', 'dependencies.json'), 'not json') + const result = loadDependenciesFile(testDir) + expect(result).to.deep.equal({}) + }) + + it('should return empty object for invalid schema (array instead of object)', () => { + writeFileSync(join(testDir, '.brv', 'context-tree', 'dependencies.json'), '["foo"]') + const result = loadDependenciesFile(testDir) + expect(result).to.deep.equal({}) + }) + + it('should return empty object for invalid schema (non-string values)', () => { + writeFileSync(join(testDir, '.brv', 'context-tree', 'dependencies.json'), '{"foo": 123}') + const result = loadDependenciesFile(testDir) + expect(result).to.deep.equal({}) + }) + }) + + describe('writeDependenciesFile', () => { + it('should write dependencies that can be loaded back', () => { + const deps = {'api-standards': '2.3', 'react-patterns': '1.0'} + writeDependenciesFile(testDir, deps) + const result = loadDependenciesFile(testDir) + expect(result).to.deep.equal(deps) + }) + + it('should write empty object', () => { + writeDependenciesFile(testDir, {}) + const result = loadDependenciesFile(testDir) + expect(result).to.deep.equal({}) + }) + + it('should overwrite existing file', () => { + writeDependenciesFile(testDir, {'old': '1.0'}) + writeDependenciesFile(testDir, {'new': '2.0'}) + const result = loadDependenciesFile(testDir) + expect(result).to.deep.equal({'new': '2.0'}) + }) + }) +}) diff --git a/test/unit/server/knowledge/find-project-root.test.ts b/test/unit/server/knowledge/find-project-root.test.ts new file mode 100644 index 000000000..8b02bacbc --- /dev/null +++ b/test/unit/server/knowledge/find-project-root.test.ts @@ -0,0 +1,98 @@ +import {expect} from 'chai' +import {mkdirSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {findProjectRoot} from '../../../../src/server/core/domain/knowledge/find-project-root.js' + +function createBrvProject(dir: string): void { + mkdirSync(join(dir, '.brv'), {recursive: true}) + writeFileSync(join(dir, '.brv', 'config.json'), JSON.stringify({createdAt: new Date().toISOString(), version: '0.0.1'})) +} + +describe('findProjectRoot', () => { + let testDir: string + + beforeEach(() => { + testDir = join(tmpdir(), `brv-find-root-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(testDir, {recursive: true}) + }) + + afterEach(() => { + rmSync(testDir, {force: true, recursive: true}) + }) + + it('should find .brv/ in current directory', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + + const result = findProjectRoot(projectRoot) + expect(result).to.equal(projectRoot) + }) + + it('should walk up to find .brv/ in parent directory', () => { + const projectRoot = join(testDir, 'project') + const subfolder = join(projectRoot, 'subfolder1') + createBrvProject(projectRoot) + mkdirSync(subfolder, {recursive: true}) + + const result = findProjectRoot(subfolder) + expect(result).to.equal(projectRoot) + }) + + it('should walk up multiple levels', () => { + const projectRoot = join(testDir, 'project') + const deep = join(projectRoot, 'a', 'b', 'c') + createBrvProject(projectRoot) + mkdirSync(deep, {recursive: true}) + + const result = findProjectRoot(deep) + expect(result).to.equal(projectRoot) + }) + + it('should return undefined when no .brv/ found', () => { + const noProject = join(testDir, 'no-project', 'sub') + mkdirSync(noProject, {recursive: true}) + + const result = findProjectRoot(noProject) + expect(result).to.be.undefined + }) + + it('should stop at git root if provided', () => { + // Simulate: git root has no .brv/, parent above does + const above = join(testDir, 'above') + const gitRoot = join(above, 'git-repo') + const sub = join(gitRoot, 'sub') + createBrvProject(above) + mkdirSync(join(gitRoot, '.git'), {recursive: true}) + mkdirSync(sub, {recursive: true}) + + // Without stopAt, it would find above/.brv + // With stopAt=gitRoot, it should stop and return undefined + const result = findProjectRoot(sub, {stopAtGitRoot: true}) + expect(result).to.be.undefined + }) + + it('should find .brv/ at git root itself', () => { + const gitRoot = join(testDir, 'git-repo') + const sub = join(gitRoot, 'sub') + createBrvProject(gitRoot) + mkdirSync(join(gitRoot, '.git'), {recursive: true}) + mkdirSync(sub, {recursive: true}) + + const result = findProjectRoot(sub, {stopAtGitRoot: true}) + expect(result).to.equal(gitRoot) + }) + + it('should use nearest .brv/ when multiple exist', () => { + const outer = join(testDir, 'outer') + const inner = join(outer, 'inner') + const sub = join(inner, 'sub') + createBrvProject(outer) + createBrvProject(inner) + mkdirSync(sub, {recursive: true}) + + const result = findProjectRoot(sub) + expect(result).to.equal(inner) + }) +}) diff --git a/test/unit/server/knowledge/knowledge-source.test.ts b/test/unit/server/knowledge/knowledge-source.test.ts new file mode 100644 index 000000000..8444be848 --- /dev/null +++ b/test/unit/server/knowledge/knowledge-source.test.ts @@ -0,0 +1,31 @@ +import {expect} from 'chai' + +import {deriveSourceKey} from '../../../../src/server/core/domain/knowledge/knowledge-source.js' + +describe('knowledge-source', () => { + describe('deriveSourceKey', () => { + it('should return a 12-character hex string', () => { + const key = deriveSourceKey('/some/path') + expect(key).to.match(/^[0-9a-f]{12}$/) + }) + + it('should return the same key for the same path', () => { + const key1 = deriveSourceKey('/projects/shared-lib') + const key2 = deriveSourceKey('/projects/shared-lib') + expect(key1).to.equal(key2) + }) + + it('should return different keys for different paths', () => { + const key1 = deriveSourceKey('/projects/shared-lib') + const key2 = deriveSourceKey('/projects/api-client') + expect(key1).to.not.equal(key2) + }) + + it('should use SHA-256 first 12 hex chars', () => { + // SHA-256 of '/test' is known — just verify length and hex format + const key = deriveSourceKey('/test') + expect(key).to.have.lengthOf(12) + expect(key).to.match(/^[0-9a-f]+$/) + }) + }) +}) diff --git a/test/unit/server/knowledge/load-knowledge-sources.test.ts b/test/unit/server/knowledge/load-knowledge-sources.test.ts new file mode 100644 index 000000000..52c90b275 --- /dev/null +++ b/test/unit/server/knowledge/load-knowledge-sources.test.ts @@ -0,0 +1,96 @@ +import {expect} from 'chai' +import {mkdirSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {loadKnowledgeSources} from '../../../../src/server/core/domain/knowledge/load-knowledge-sources.js' + +function createBrvProject(dir: string): void { + mkdirSync(join(dir, '.brv', 'context-tree'), {recursive: true}) + writeFileSync(join(dir, '.brv', 'config.json'), JSON.stringify({createdAt: new Date().toISOString(), version: '0.0.1'})) +} + +describe('load-knowledge-sources', () => { + let testDir: string + + beforeEach(() => { + testDir = join(tmpdir(), `brv-load-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(testDir, {recursive: true}) + }) + + afterEach(() => { + rmSync(testDir, {force: true, recursive: true}) + }) + + describe('loadKnowledgeSources', () => { + it('should return null when workspaces.json does not exist', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + + const result = loadKnowledgeSources(projectRoot) + expect(result).to.be.null + }) + + it('should return empty sources for empty workspaces array', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + writeFileSync(join(projectRoot, '.brv', 'workspaces.json'), '[]') + + const result = loadKnowledgeSources(projectRoot) + expect(result).to.not.be.null + expect(result!.sources).to.deep.equal([]) + expect(result!.mtime).to.be.a('number') + }) + + it('should resolve workspaces into knowledge sources', () => { + const projectRoot = join(testDir, 'project') + const linkedLib = join(testDir, 'linked-lib') + createBrvProject(projectRoot) + createBrvProject(linkedLib) + + writeFileSync(join(projectRoot, '.brv', 'workspaces.json'), JSON.stringify(['../linked-lib'])) + + const result = loadKnowledgeSources(projectRoot) + expect(result).to.not.be.null + expect(result!.sources).to.have.lengthOf(1) + expect(result!.sources[0].type).to.equal('linked') + expect(result!.sources[0].alias).to.equal('linked-lib') + expect(result!.sources[0].contextTreeRoot).to.include('linked-lib') + }) + + it('should track mtime of workspaces.json', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + writeFileSync(join(projectRoot, '.brv', 'workspaces.json'), '[]') + + const result = loadKnowledgeSources(projectRoot) + expect(result!.mtime).to.be.greaterThan(0) + }) + + it('should return empty sources for malformed workspaces.json', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + writeFileSync(join(projectRoot, '.brv', 'workspaces.json'), 'not json') + + const result = loadKnowledgeSources(projectRoot) + expect(result).to.not.be.null + expect(result!.sources).to.deep.equal([]) + }) + + it('should skip broken workspace paths', () => { + const projectRoot = join(testDir, 'project') + const validLib = join(testDir, 'valid-lib') + createBrvProject(projectRoot) + createBrvProject(validLib) + + writeFileSync( + join(projectRoot, '.brv', 'workspaces.json'), + JSON.stringify(['../nonexistent', '../valid-lib']), + ) + + const result = loadKnowledgeSources(projectRoot) + expect(result!.sources).to.have.lengthOf(1) + expect(result!.sources[0].alias).to.equal('valid-lib') + }) + }) +}) diff --git a/test/unit/server/knowledge/workspaces-operations.test.ts b/test/unit/server/knowledge/workspaces-operations.test.ts new file mode 100644 index 000000000..545cbc9bb --- /dev/null +++ b/test/unit/server/knowledge/workspaces-operations.test.ts @@ -0,0 +1,155 @@ +import {expect} from 'chai' +import {mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {addWorkspace, removeWorkspace} from '../../../../src/server/core/domain/knowledge/workspaces-operations.js' + +function createBrvProject(dir: string): void { + mkdirSync(join(dir, '.brv', 'context-tree'), {recursive: true}) + writeFileSync(join(dir, '.brv', 'config.json'), JSON.stringify({createdAt: new Date().toISOString(), version: '0.0.1'})) +} + +describe('workspaces-operations', () => { + let testDir: string + + beforeEach(() => { + testDir = join(tmpdir(), `brv-ops-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(testDir, {recursive: true}) + }) + + afterEach(() => { + rmSync(testDir, {force: true, recursive: true}) + }) + + describe('addWorkspace', () => { + it('should add a valid workspace path', () => { + const projectRoot = join(testDir, 'project') + const target = join(testDir, 'target-lib') + createBrvProject(projectRoot) + createBrvProject(target) + + const result = addWorkspace(projectRoot, target) + expect(result.success).to.be.true + + const saved = JSON.parse(readFileSync(join(projectRoot, '.brv', 'workspaces.json'), 'utf8')) + expect(saved).to.be.an('array') + expect(saved).to.have.lengthOf(1) + }) + + it('should store relative paths', () => { + const projectRoot = join(testDir, 'project') + const target = join(testDir, 'target-lib') + createBrvProject(projectRoot) + createBrvProject(target) + + addWorkspace(projectRoot, target) + + const saved = JSON.parse(readFileSync(join(projectRoot, '.brv', 'workspaces.json'), 'utf8')) + expect(saved[0]).to.equal('../target-lib') + }) + + it('should fail for self-link', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + + const result = addWorkspace(projectRoot, projectRoot) + expect(result.success).to.be.false + expect(result.message).to.include('self') + }) + + it('should fail for non-brv target', () => { + const projectRoot = join(testDir, 'project') + const target = join(testDir, 'not-brv') + createBrvProject(projectRoot) + mkdirSync(target, {recursive: true}) + + const result = addWorkspace(projectRoot, target) + expect(result.success).to.be.false + expect(result.message).to.include('not a ByteRover project') + }) + + it('should fail for nonexistent target', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + + const result = addWorkspace(projectRoot, join(testDir, 'nonexistent')) + expect(result.success).to.be.false + }) + + it('should deduplicate existing entries', () => { + const projectRoot = join(testDir, 'project') + const target = join(testDir, 'target-lib') + createBrvProject(projectRoot) + createBrvProject(target) + + addWorkspace(projectRoot, target) + const result = addWorkspace(projectRoot, target) + expect(result.success).to.be.false + expect(result.message).to.include('already') + + const saved = JSON.parse(readFileSync(join(projectRoot, '.brv', 'workspaces.json'), 'utf8')) + expect(saved).to.have.lengthOf(1) + }) + + it('should append to existing workspaces', () => { + const projectRoot = join(testDir, 'project') + const target1 = join(testDir, 'lib1') + const target2 = join(testDir, 'lib2') + createBrvProject(projectRoot) + createBrvProject(target1) + createBrvProject(target2) + + addWorkspace(projectRoot, target1) + addWorkspace(projectRoot, target2) + + const saved = JSON.parse(readFileSync(join(projectRoot, '.brv', 'workspaces.json'), 'utf8')) + expect(saved).to.have.lengthOf(2) + }) + }) + + describe('removeWorkspace', () => { + it('should remove an existing workspace by path', () => { + const projectRoot = join(testDir, 'project') + const target = join(testDir, 'target-lib') + createBrvProject(projectRoot) + createBrvProject(target) + + addWorkspace(projectRoot, target) + const result = removeWorkspace(projectRoot, '../target-lib') + expect(result.success).to.be.true + + const saved = JSON.parse(readFileSync(join(projectRoot, '.brv', 'workspaces.json'), 'utf8')) + expect(saved).to.have.lengthOf(0) + }) + + it('should remove by absolute path', () => { + const projectRoot = join(testDir, 'project') + const target = join(testDir, 'target-lib') + createBrvProject(projectRoot) + createBrvProject(target) + + addWorkspace(projectRoot, target) + const result = removeWorkspace(projectRoot, target) + expect(result.success).to.be.true + }) + + it('should fail when workspace not found', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + writeFileSync(join(projectRoot, '.brv', 'workspaces.json'), '["../something"]') + + const result = removeWorkspace(projectRoot, '../nonexistent') + expect(result.success).to.be.false + expect(result.message).to.include('not found') + }) + + it('should fail when workspaces.json does not exist', () => { + const projectRoot = join(testDir, 'project') + createBrvProject(projectRoot) + + const result = removeWorkspace(projectRoot, '../something') + expect(result.success).to.be.false + }) + }) +}) diff --git a/test/unit/server/knowledge/workspaces-resolver.test.ts b/test/unit/server/knowledge/workspaces-resolver.test.ts new file mode 100644 index 000000000..6bee07f24 --- /dev/null +++ b/test/unit/server/knowledge/workspaces-resolver.test.ts @@ -0,0 +1,138 @@ +import {expect} from 'chai' +import {mkdirSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {resolveWorkspaces} from '../../../../src/server/core/domain/knowledge/workspaces-resolver.js' + +function createBrvProject(dir: string): void { + mkdirSync(join(dir, '.brv', 'context-tree'), {recursive: true}) + writeFileSync(join(dir, '.brv', 'config.json'), JSON.stringify({createdAt: new Date().toISOString(), version: '0.0.1'})) +} + +describe('workspaces-resolver', () => { + let testDir: string + + beforeEach(() => { + testDir = join(tmpdir(), `brv-resolver-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(testDir, {recursive: true}) + }) + + afterEach(() => { + rmSync(testDir, {force: true, recursive: true}) + }) + + describe('resolveWorkspaces', () => { + it('should return empty array for empty workspaces list', () => { + const result = resolveWorkspaces(testDir, []) + expect(result).to.deep.equal([]) + }) + + it('should resolve a valid relative path to a KnowledgeSource', () => { + const projectRoot = join(testDir, 'main-project') + const linkedProject = join(testDir, 'shared-lib') + createBrvProject(projectRoot) + createBrvProject(linkedProject) + + const result = resolveWorkspaces(projectRoot, ['../shared-lib']) + expect(result).to.have.lengthOf(1) + expect(result[0].type).to.equal('linked') + expect(result[0].contextTreeRoot).to.include('shared-lib') + expect(result[0].contextTreeRoot).to.include('context-tree') + expect(result[0].sourceKey).to.match(/^[0-9a-f]{12}$/) + }) + + it('should skip paths that do not exist', () => { + const projectRoot = join(testDir, 'main-project') + createBrvProject(projectRoot) + + const result = resolveWorkspaces(projectRoot, ['../nonexistent']) + expect(result).to.deep.equal([]) + }) + + it('should skip paths without .brv/config.json', () => { + const projectRoot = join(testDir, 'main-project') + const noConfig = join(testDir, 'no-config') + createBrvProject(projectRoot) + mkdirSync(noConfig, {recursive: true}) + + const result = resolveWorkspaces(projectRoot, ['../no-config']) + expect(result).to.deep.equal([]) + }) + + it('should skip paths without .brv/context-tree/', () => { + const projectRoot = join(testDir, 'main-project') + const noTree = join(testDir, 'no-tree') + createBrvProject(projectRoot) + mkdirSync(join(noTree, '.brv'), {recursive: true}) + writeFileSync(join(noTree, '.brv', 'config.json'), '{}') + + const result = resolveWorkspaces(projectRoot, ['../no-tree']) + expect(result).to.deep.equal([]) + }) + + it('should resolve multiple valid paths', () => { + const projectRoot = join(testDir, 'main-project') + const lib1 = join(testDir, 'lib1') + const lib2 = join(testDir, 'lib2') + createBrvProject(projectRoot) + createBrvProject(lib1) + createBrvProject(lib2) + + const result = resolveWorkspaces(projectRoot, ['../lib1', '../lib2']) + expect(result).to.have.lengthOf(2) + expect(result[0].sourceKey).to.not.equal(result[1].sourceKey) + }) + + it('should skip invalid paths and keep valid ones', () => { + const projectRoot = join(testDir, 'main-project') + const valid = join(testDir, 'valid') + createBrvProject(projectRoot) + createBrvProject(valid) + + const result = resolveWorkspaces(projectRoot, ['../nonexistent', '../valid']) + expect(result).to.have.lengthOf(1) + expect(result[0].contextTreeRoot).to.include('valid') + }) + + it('should resolve simple glob pattern (prefix/*)', () => { + const projectRoot = join(testDir, 'main-project') + createBrvProject(projectRoot) + + const packagesDir = join(testDir, 'main-project', 'packages') + mkdirSync(packagesDir, {recursive: true}) + + createBrvProject(join(packagesDir, 'pkg-a')) + createBrvProject(join(packagesDir, 'pkg-b')) + // non-brv dir should be skipped + mkdirSync(join(packagesDir, 'not-brv'), {recursive: true}) + + const result = resolveWorkspaces(projectRoot, ['packages/*']) + expect(result).to.have.lengthOf(2) + + const aliases = result.map((s) => s.alias).sort() + expect(aliases).to.deep.equal(['pkg-a', 'pkg-b']) + }) + + it('should use directory name as alias for relative paths', () => { + const projectRoot = join(testDir, 'main-project') + const linkedProject = join(testDir, 'my-shared-lib') + createBrvProject(projectRoot) + createBrvProject(linkedProject) + + const result = resolveWorkspaces(projectRoot, ['../my-shared-lib']) + expect(result).to.have.lengthOf(1) + expect(result[0].alias).to.equal('my-shared-lib') + }) + + it('should deduplicate paths that resolve to the same directory', () => { + const projectRoot = join(testDir, 'main-project') + const lib = join(testDir, 'lib') + createBrvProject(projectRoot) + createBrvProject(lib) + + const result = resolveWorkspaces(projectRoot, ['../lib', '../lib']) + expect(result).to.have.lengthOf(1) + }) + }) +}) diff --git a/test/unit/server/knowledge/workspaces-schema.test.ts b/test/unit/server/knowledge/workspaces-schema.test.ts new file mode 100644 index 000000000..b61306d24 --- /dev/null +++ b/test/unit/server/knowledge/workspaces-schema.test.ts @@ -0,0 +1,79 @@ +import {expect} from 'chai' +import {mkdirSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {loadWorkspacesFile, writeWorkspacesFile} from '../../../../src/server/core/domain/knowledge/workspaces-schema.js' + +describe('workspaces-schema', () => { + let testDir: string + + beforeEach(() => { + testDir = join(tmpdir(), `brv-ws-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(join(testDir, '.brv'), {recursive: true}) + }) + + afterEach(() => { + rmSync(testDir, {force: true, recursive: true}) + }) + + describe('loadWorkspacesFile', () => { + it('should return null when file does not exist', () => { + const result = loadWorkspacesFile(testDir) + expect(result).to.be.null + }) + + it('should return empty array for empty JSON array', () => { + writeFileSync(join(testDir, '.brv', 'workspaces.json'), '[]') + const result = loadWorkspacesFile(testDir) + expect(result).to.deep.equal([]) + }) + + it('should load valid workspaces', () => { + const workspaces = ['../shared-lib', '../api-client', 'packages/*'] + writeFileSync(join(testDir, '.brv', 'workspaces.json'), JSON.stringify(workspaces)) + const result = loadWorkspacesFile(testDir) + expect(result).to.deep.equal(workspaces) + }) + + it('should return empty array for malformed JSON', () => { + writeFileSync(join(testDir, '.brv', 'workspaces.json'), 'not json') + const result = loadWorkspacesFile(testDir) + expect(result).to.deep.equal([]) + }) + + it('should return empty array for invalid schema (object instead of array)', () => { + writeFileSync(join(testDir, '.brv', 'workspaces.json'), '{"foo": "bar"}') + const result = loadWorkspacesFile(testDir) + expect(result).to.deep.equal([]) + }) + + it('should return empty array for invalid schema (array of numbers)', () => { + writeFileSync(join(testDir, '.brv', 'workspaces.json'), '[1, 2, 3]') + const result = loadWorkspacesFile(testDir) + expect(result).to.deep.equal([]) + }) + }) + + describe('writeWorkspacesFile', () => { + it('should write workspaces that can be loaded back', () => { + const workspaces = ['../shared-lib', 'packages/*'] + writeWorkspacesFile(testDir, workspaces) + const result = loadWorkspacesFile(testDir) + expect(result).to.deep.equal(workspaces) + }) + + it('should write empty array', () => { + writeWorkspacesFile(testDir, []) + const result = loadWorkspacesFile(testDir) + expect(result).to.deep.equal([]) + }) + + it('should overwrite existing file', () => { + writeWorkspacesFile(testDir, ['../old']) + writeWorkspacesFile(testDir, ['../new']) + const result = loadWorkspacesFile(testDir) + expect(result).to.deep.equal(['../new']) + }) + }) +})