diff --git a/src/agent/core/domain/swarm/types.ts b/src/agent/core/domain/swarm/types.ts index 37adaa691..986e0395f 100644 --- a/src/agent/core/domain/swarm/types.ts +++ b/src/agent/core/domain/swarm/types.ts @@ -19,7 +19,7 @@ export type ProviderType = (typeof PROVIDER_TYPES)[number] /** * Query type classification for routing decisions. */ -export type QueryType = 'creative' | 'factual' | 'personal' | 'relational' | 'temporal' +export type QueryType = 'factual' | 'personal' | 'relational' | 'temporal' /** * Local providers that require no network calls. @@ -105,7 +105,7 @@ export type QueryRequest = { after?: number before?: number } - /** Hint: factual, temporal, personal, creative, relational */ + /** Hint: factual, temporal, personal, relational */ type?: QueryType } @@ -172,7 +172,7 @@ export function createDefaultCapabilities(type: ProviderType): ProviderCapabilit semanticSearch: false, temporalQuery: false, userModeling: false, - writeSupported: true, + writeSupported: false, } } diff --git a/src/agent/infra/swarm/adapters/gbrain-adapter.ts b/src/agent/infra/swarm/adapters/gbrain-adapter.ts index 07e6e20d5..682b56254 100644 --- a/src/agent/infra/swarm/adapters/gbrain-adapter.ts +++ b/src/agent/infra/swarm/adapters/gbrain-adapter.ts @@ -57,7 +57,7 @@ export function resolveGBrainBin(options: GBrainAdapterOptions): {argsPrefix: st return {argsPrefix: [], command: options.gbrainBinPath} } - // 2. Check PATH + // 2. Check PATH (sync probe — runs lazily on first executor access, not at construction) try { execFileSync('gbrain', ['--version'], {encoding: 'utf8', stdio: 'pipe', timeout: 5000}) @@ -148,14 +148,23 @@ export class GBrainAdapter implements IMemoryProvider { } public readonly id = 'gbrain' public readonly type = 'gbrain' as const - private readonly executor: GBrainExecutor + private cachedExecutor?: GBrainExecutor + private readonly injectedExecutor?: GBrainExecutor + private readonly options: GBrainAdapterOptions private readonly repoPath: string private readonly searchMode: 'hybrid' | 'keyword' | 'vector' constructor(options: GBrainAdapterOptions, executor?: GBrainExecutor) { this.repoPath = options.repoPath this.searchMode = options.searchMode - this.executor = executor ?? createDefaultExecutor(resolveGBrainBin(options)) + this.options = options + this.injectedExecutor = executor + } + + private get executor(): GBrainExecutor { + if (this.injectedExecutor) return this.injectedExecutor + this.cachedExecutor ??= createDefaultExecutor(resolveGBrainBin(this.options)) + return this.cachedExecutor } public async delete(id: string): Promise { diff --git a/src/agent/infra/swarm/adapters/local-markdown-adapter.ts b/src/agent/infra/swarm/adapters/local-markdown-adapter.ts index 3db288ad2..1ff4d40bb 100644 --- a/src/agent/infra/swarm/adapters/local-markdown-adapter.ts +++ b/src/agent/infra/swarm/adapters/local-markdown-adapter.ts @@ -94,8 +94,9 @@ function buildIndexSignature(files: ScannedMarkdownFile[]): string { function resolveUniqueFilename(folderPath: string, preferredFilename: string): string { const baseName = preferredFilename.replace(/\.md$/u, '') + const MAX_SUFFIX = 10_000 let suffix = 0 - while (true) { + while (suffix <= MAX_SUFFIX) { const candidate = suffix === 0 ? `${baseName}.md` : `${baseName}-${suffix}.md` if (!existsSync(join(folderPath, candidate))) { return candidate @@ -103,6 +104,8 @@ function resolveUniqueFilename(folderPath: string, preferredFilename: string): s suffix++ } + + return `${baseName}-${Date.now()}.md` } type IndexedDoc = { @@ -186,8 +189,10 @@ export class LocalMarkdownAdapter implements IMemoryProvider { const maxResults = request.maxResults ?? 10 + if (!this.index) return [] + // T1/T2/T3: Precision-filtered search (stop words, AND-first, score floor, gap ratio) - const precisionResults = searchWithPrecision(this.index!, request.query, {maxResults}) + const precisionResults = searchWithPrecision(this.index, request.query, {maxResults}) if (precisionResults.length === 0) return [] // Collect direct matches diff --git a/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts b/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts index 5c50214ca..1dd0b36b7 100644 --- a/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts +++ b/src/agent/infra/swarm/adapters/memory-wiki-adapter.ts @@ -149,6 +149,7 @@ export class MemoryWikiAdapter implements IMemoryProvider { public async query(request: QueryRequest): Promise { this.ensureIndex() + // Safety net: ensureIndex() could leave this.index null if file reads fail if (!this.index) { return [] } @@ -204,21 +205,27 @@ export class MemoryWikiAdapter implements IMemoryProvider { const now = new Date().toISOString() // Resolve unique filename + const MAX_SUFFIX = 10_000 let filename = `${slug}.md` let filePath = join(dirPath, filename) let suffix = 1 - while (existsSync(filePath)) { + while (existsSync(filePath) && suffix <= MAX_SUFFIX) { filename = `${slug}-${suffix}.md` filePath = join(dirPath, filename) suffix++ } + if (existsSync(filePath)) { + filename = `${slug}-${Date.now()}.md` + filePath = join(dirPath, filename) + } + const pageId = `${pageType}.swarm.${slug}` const pageContent = [ '---', `pageType: ${pageType}`, `id: ${pageId}`, - `title: "${title}"`, + `title: ${JSON.stringify(title)}`, 'status: active', `updatedAt: "${now}"`, 'sourceType: swarm-curate', diff --git a/src/agent/infra/swarm/adapters/obsidian-adapter.ts b/src/agent/infra/swarm/adapters/obsidian-adapter.ts index 89a3274e0..cf5cc4787 100644 --- a/src/agent/infra/swarm/adapters/obsidian-adapter.ts +++ b/src/agent/infra/swarm/adapters/obsidian-adapter.ts @@ -149,8 +149,10 @@ export class ObsidianAdapter implements IMemoryProvider { const maxResults = request.maxResults ?? 10 + if (!this.index) return [] + // T1/T2/T3: Precision-filtered search (stop words, AND-first, score floor, gap ratio) - const precisionResults = searchWithPrecision(this.index!, request.query, {maxResults}) + const precisionResults = searchWithPrecision(this.index, request.query, {maxResults}) if (precisionResults.length === 0) return [] // Collect direct matches diff --git a/src/agent/infra/swarm/swarm-coordinator.ts b/src/agent/infra/swarm/swarm-coordinator.ts index 27ad76f78..ebf9d0125 100644 --- a/src/agent/infra/swarm/swarm-coordinator.ts +++ b/src/agent/infra/swarm/swarm-coordinator.ts @@ -23,7 +23,13 @@ export type BrvCurateResult = {data?: {logId?: string; taskId?: string}; error?: const execFileAsync = promisify(execFileCb) +const MAX_ARG_LENGTH = 200_000 // ~200KB safe for most OS arg limits + async function execBrvCurate(content: string): Promise { + if (content.length > MAX_ARG_LENGTH) { + throw new Error(`Content too large for CLI argument (${content.length} bytes, max ${MAX_ARG_LENGTH}). Use brv curate directly.`) + } + let stdout: string try { ;({stdout} = await execFileAsync('brv', ['curate', '--detach', '--format', 'json', content], { @@ -31,8 +37,12 @@ async function execBrvCurate(content: string): Promise { timeout: 30_000, })) } catch (error) { - const err = error as {message: string; stderr?: string} - throw new Error(err.stderr?.trim() || err.message) + if (error instanceof Error) { + const stderr = (error as NodeJS.ErrnoException & {stderr?: string}).stderr?.trim() + throw new Error(stderr ?? error.message) + } + + throw new Error(String(error)) } try { diff --git a/src/agent/infra/swarm/swarm-router.ts b/src/agent/infra/swarm/swarm-router.ts index 7ea5b655b..ca100e037 100644 --- a/src/agent/infra/swarm/swarm-router.ts +++ b/src/agent/infra/swarm/swarm-router.ts @@ -27,10 +27,9 @@ export function classifyQuery(query: string): QueryType { /** * Provider selection matrix per query type. * Honcho and Hindsight are temporarily disabled — adapters coming in Phase 3. - * When re-enabled, add 'honcho' to personal/creative and 'hindsight' to temporal/relational/creative. + * When re-enabled, add 'honcho' to personal and 'hindsight' to temporal/relational. */ const SELECTION_MATRIX: Record = { - creative: ['byterover', 'obsidian', 'local-markdown', 'gbrain', 'memory-wiki'], factual: ['byterover', 'obsidian', 'local-markdown', 'gbrain', 'memory-wiki'], personal: ['byterover', 'obsidian', 'local-markdown'], relational: ['byterover', 'obsidian', 'local-markdown', 'gbrain', 'memory-wiki'], diff --git a/src/agent/infra/swarm/validation/config-validator.ts b/src/agent/infra/swarm/validation/config-validator.ts index df472edd1..0f965c850 100644 --- a/src/agent/infra/swarm/validation/config-validator.ts +++ b/src/agent/infra/swarm/validation/config-validator.ts @@ -1,5 +1,8 @@ -import {execFileSync} from 'node:child_process' +import {execFile} from 'node:child_process' import {existsSync} from 'node:fs' +import {promisify} from 'node:util' + +const execFileAsync = promisify(execFile) import {join} from 'node:path' import type {SwarmConfig} from '../config/swarm-config-schema.js' @@ -151,10 +154,10 @@ function validateHindsight( /** * Validate gbrain provider config at runtime. */ -function validateGBrain( +async function validateGBrain( config: NonNullable, errors: ValidationIssue[] -): void { +): Promise { if (!existsSync(config.repoPath)) { errors.push({ field: 'repo_path', @@ -172,7 +175,7 @@ function validateGBrain( // Option A: gbrain globally installed try { - execFileSync('gbrain', ['--version'], {encoding: 'utf8', stdio: 'pipe', timeout: 5000}) + await execFileAsync('gbrain', ['--version'], {encoding: 'utf8', timeout: 5000}) gbrainReachable = true } catch { // Not in PATH @@ -189,7 +192,7 @@ function validateGBrain( if (scriptFound) { // Script exists — verify bun is available to run it try { - execFileSync('bun', ['--version'], {encoding: 'utf8', stdio: 'pipe', timeout: 5000}) + await execFileAsync('bun', ['--version'], {encoding: 'utf8', timeout: 5000}) gbrainReachable = true } catch { errors.push({ @@ -440,7 +443,7 @@ export async function validateSwarmProviders( } if (providers.gbrain?.enabled) { - validateGBrain(providers.gbrain, errors) + await validateGBrain(providers.gbrain, errors) } if (providers.memoryWiki?.enabled && !existsSync(providers.memoryWiki.vaultPath)) { diff --git a/src/oclif/commands/swarm/curate.ts b/src/oclif/commands/swarm/curate.ts index a8ba3ef4f..84720af11 100644 --- a/src/oclif/commands/swarm/curate.ts +++ b/src/oclif/commands/swarm/curate.ts @@ -73,10 +73,11 @@ public static description = 'Store knowledge in a swarm provider (GBrain, local this.exit(2) } } catch (error) { + const message = error instanceof Error ? error.message : String(error) if (isJson) { - this.log(JSON.stringify({error: (error as Error).message, success: false})) + this.log(JSON.stringify({error: message, success: false})) } else { - this.logToStderr(`Error: ${(error as Error).message}`) + this.logToStderr(`Error: ${message}`) this.exit(2) } } diff --git a/src/oclif/commands/swarm/query.ts b/src/oclif/commands/swarm/query.ts index bafbdf33b..e5add75b7 100644 --- a/src/oclif/commands/swarm/query.ts +++ b/src/oclif/commands/swarm/query.ts @@ -79,10 +79,11 @@ public static description = 'Query the memory swarm across all active providers' this.log(formatQueryResults(result, args.query)) } } catch (error) { + const message = error instanceof Error ? error.message : String(error) if (isJson) { - this.log(JSON.stringify({error: (error as Error).message, success: false})) + this.log(JSON.stringify({error: message, success: false})) } else { - this.logToStderr(`Error: ${(error as Error).message}`) + this.logToStderr(`Error: ${message}`) this.exit(2) } } diff --git a/test/unit/agent/core/domain/swarm/types.test.ts b/test/unit/agent/core/domain/swarm/types.test.ts index 25f4f7693..8254aa1f3 100644 --- a/test/unit/agent/core/domain/swarm/types.test.ts +++ b/test/unit/agent/core/domain/swarm/types.test.ts @@ -65,7 +65,7 @@ describe('Swarm Types', () => { expect(caps.graphTraversal).to.be.false expect(caps.temporalQuery).to.be.false expect(caps.userModeling).to.be.false - expect(caps.writeSupported).to.be.true + expect(caps.writeSupported).to.be.false expect(caps.localOnly).to.be.true expect(caps.avgLatencyMs).to.equal(50) })