diff --git a/src/oclif/commands/dream.ts b/src/oclif/commands/dream.ts index 66a2c65bd..c764b2f8e 100644 --- a/src/oclif/commands/dream.ts +++ b/src/oclif/commands/dream.ts @@ -6,6 +6,7 @@ import {join} from 'node:path' import {BRV_DIR, CONTEXT_TREE_DIR} from '../../server/constants.js' import {type ProviderConfigResponse, TransportStateEventNames} from '../../server/core/domain/transport/schemas.js' +import {FileContextTreeArchiveService} from '../../server/infra/context-tree/file-context-tree-archive-service.js' import {FileContextTreeManifestService} from '../../server/infra/context-tree/file-context-tree-manifest-service.js' import {DreamLogStore} from '../../server/infra/dream/dream-log-store.js' import {DreamStateService} from '../../server/infra/dream/dream-state-service.js' @@ -139,10 +140,12 @@ export default class Dream extends Command { try { const result = await undoLastDream({ + archiveService: new FileContextTreeArchiveService(), contextTreeDir, dreamLogStore: new DreamLogStore({baseDir: brvDir}), dreamStateService: new DreamStateService({baseDir: brvDir}), manifestService: new FileContextTreeManifestService({baseDirectory: projectRoot}), + projectRoot, }) if (format === 'json') { diff --git a/src/server/infra/daemon/agent-process.ts b/src/server/infra/daemon/agent-process.ts index 003d9ffaa..9514d3999 100644 --- a/src/server/infra/daemon/agent-process.ts +++ b/src/server/infra/daemon/agent-process.ts @@ -46,6 +46,7 @@ import { TransportStateEventNames, TransportTaskEventNames, } from '../../core/domain/transport/schemas.js' +import {FileContextTreeArchiveService} from '../context-tree/file-context-tree-archive-service.js' import {DreamLockService} from '../dream/dream-lock-service.js' import {DreamLogStore} from '../dream/dream-log-store.js' import {DreamStateService} from '../dream/dream-state-service.js' @@ -516,6 +517,7 @@ async function executeTask( } const dreamExecutor = new DreamExecutor({ + archiveService: new FileContextTreeArchiveService(), curateLogStore: new FileCurateLogStore({baseDir: storagePath}), dreamLockService, dreamLogStore: new DreamLogStore({baseDir: brvDir}), diff --git a/src/server/infra/dream/dream-log-schema.ts b/src/server/infra/dream/dream-log-schema.ts index 6687b1cb4..e2ff83292 100644 --- a/src/server/infra/dream/dream-log-schema.ts +++ b/src/server/infra/dream/dream-log-schema.ts @@ -27,6 +27,7 @@ const PruneOperationSchema = z.object({ mergeTarget: z.string().optional(), needsReview: z.boolean(), reason: z.string(), + stubPath: z.string().optional(), type: z.literal('PRUNE'), }) diff --git a/src/server/infra/dream/dream-undo.ts b/src/server/infra/dream/dream-undo.ts index 3e93b9f80..6611ff8b1 100644 --- a/src/server/infra/dream/dream-undo.ts +++ b/src/server/infra/dream/dream-undo.ts @@ -23,6 +23,7 @@ export type DreamUndoDeps = { write(state: DreamState): Promise } manifestService: {buildManifest(dir?: string): Promise} + projectRoot?: string } export interface DreamUndoResult { @@ -241,7 +242,11 @@ async function undoPrune( throw new Error(`Cannot undo PRUNE/ARCHIVE: no archive service available for ${op.file}`) } - const restored = await ctx.deps.archiveService.restoreEntry(op.file, ctx.contextTreeDir) + if (!op.stubPath) { + throw new Error(`Cannot undo PRUNE/ARCHIVE: missing stubPath for ${op.file}`) + } + + const restored = await ctx.deps.archiveService.restoreEntry(op.stubPath, ctx.deps.projectRoot) ctx.result.restoredArchives.push(restored) break } diff --git a/src/server/infra/dream/operations/prune.ts b/src/server/infra/dream/operations/prune.ts new file mode 100644 index 000000000..dba2d4e5e --- /dev/null +++ b/src/server/infra/dream/operations/prune.ts @@ -0,0 +1,424 @@ +/** + * Prune operation — identifies and archives stale/low-value context tree files. + * + * Flow: + * 1. Find candidates via two signals: + * A) Archive service importance decay (draft files with importance < 35) + * B) Mtime staleness (draft: 60 days, validated: 120 days, core: never) + * 2. Merge + dedup candidates, cap at 20 (stalest first) + * 3. Single LLM call to review candidates (ARCHIVE / KEEP / MERGE_INTO) + * 4. Execute decisions: archive, bump mtime, or defer merge + * + * Never throws — returns empty array on errors. + */ + +import {readdir, readFile, stat, utimes} from 'node:fs/promises' +import {join} from 'node:path' + +import type {ICipherAgent} from '../../../../agent/core/interfaces/i-cipher-agent.js' +import type {DreamOperation} from '../dream-log-schema.js' +import type {PruneDecision} from '../dream-response-schemas.js' +import type {DreamState} from '../dream-state-schema.js' + +import {isExcludedFromSync} from '../../context-tree/derived-artifact.js' +import {toUnixPath} from '../../context-tree/path-utils.js' +import {PruneResponseSchema} from '../dream-response-schemas.js' +import {parseDreamResponse} from '../parse-dream-response.js' + +export type PruneDeps = { + agent: ICipherAgent + archiveService: { + archiveEntry(relativePath: string, agent: ICipherAgent, directory?: string): Promise<{fullPath: string; originalPath: string; stubPath: string}> + findArchiveCandidates(directory?: string): Promise + } + contextTreeDir: string + dreamLogId: string + dreamStateService: { + read(): Promise + write(state: DreamState): Promise + } + projectRoot: string + signal?: AbortSignal + taskId: string +} + +type CandidateInfo = { + daysSinceModified: number + importance: number + maturity: string + path: string + signal: 'both' | 'importance' | 'mtime' +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const MAX_CANDIDATES = 20 +const DRAFT_STALE_DAYS = 60 +const VALIDATED_STALE_DAYS = 120 + +/** + * Run pruning on the context tree. + * Returns DreamOperation results (never throws). + */ +export async function prune(deps: PruneDeps): Promise { + if (deps.signal?.aborted) return [] + + try { + // Step 1: Find candidates from both signals + const candidates = await findCandidates(deps) + if (candidates.length === 0) return [] + + // Step 2: LLM review + const decisions = await llmReview(candidates, deps) + if (decisions.length === 0) return [] + + // Step 3: Execute decisions + return await executeDecisions(decisions, candidates, deps) + } catch { + return [] + } +} + +// ── Step 1: Find candidates ──────────────────────────────────────────────── + +async function findCandidates(deps: PruneDeps): Promise { + const candidateMap = new Map() + const now = Date.now() + + // Signal A: archive service importance decay + try { + const importancePaths = await deps.archiveService.findArchiveCandidates(deps.projectRoot) + const infoResults = await Promise.all( + importancePaths.map(async (path) => ({info: await readCandidateInfo(deps.contextTreeDir, path, now), path})), + ) + for (const {info, path} of infoResults) { + if (info && info.maturity !== 'core') { + candidateMap.set(path, {...info, signal: 'importance'}) + } + } + } catch { + // Archive service failure — continue with Signal B only + } + + // Signal B: mtime staleness + try { + const stalePaths = await findStaleFiles(deps.contextTreeDir, now) + for (const {info, path} of stalePaths) { + if (candidateMap.has(path)) { + // Already found by Signal A — mark as both + const existing = candidateMap.get(path) + if (existing) candidateMap.set(path, {...existing, signal: 'both'}) + } else { + candidateMap.set(path, {...info, signal: 'mtime'}) + } + } + } catch { + // Walk failure — continue with whatever Signal A found + } + + // Cap at 20, stalest first + const candidates = [...candidateMap.values()] + candidates.sort((a, b) => b.daysSinceModified - a.daysSinceModified) + return candidates.slice(0, MAX_CANDIDATES) +} + +async function readCandidateInfo(contextTreeDir: string, relativePath: string, now: number): Promise { + try { + const fullPath = join(contextTreeDir, relativePath) + const content = await readFile(fullPath, 'utf8') + const fileStat = await stat(fullPath) + const daysSinceModified = (now - fileStat.mtimeMs) / MS_PER_DAY + + return { + daysSinceModified, + importance: extractImportance(content), + maturity: extractMaturity(content), + path: relativePath, + signal: 'importance', + } + } catch { + return undefined + } +} + +async function findStaleFiles(contextTreeDir: string, now: number): Promise> { + const results: Array<{info: CandidateInfo; path: string}> = [] + + await walkMdFiles(contextTreeDir, async (relativePath, fullPath) => { + try { + const content = await readFile(fullPath, 'utf8') + const maturity = extractMaturity(content) + + // core files NEVER pruned + if (maturity === 'core') return + + const threshold = maturity === 'validated' ? VALIDATED_STALE_DAYS : DRAFT_STALE_DAYS + const fileStat = await stat(fullPath) + const daysSinceModified = (now - fileStat.mtimeMs) / MS_PER_DAY + + if (daysSinceModified >= threshold) { + results.push({ + info: { + daysSinceModified, + importance: extractImportance(content), + maturity, + path: relativePath, + signal: 'mtime', + }, + path: relativePath, + }) + } + } catch { + // Skip unreadable files + } + }) + + return results +} + +/** Walk active .md files in the context tree, skipping _/. dirs, _ prefixed files, and derived artifacts. */ +async function walkMdFiles( + contextTreeDir: string, + callback: (relativePath: string, fullPath: string) => Promise, +): Promise { + async function walk(currentDir: string): Promise { + let entries: Array<{isDirectory(): boolean; isFile(): boolean; name: string}> + try { + entries = (await readdir(currentDir, {withFileTypes: true})).map((e) => ({ + isDirectory: () => e.isDirectory(), + isFile: () => e.isFile(), + name: String(e.name), + })) + } catch { + return + } + + /* eslint-disable no-await-in-loop */ + for (const entry of entries) { + const fullPath = join(currentDir, entry.name) + + if (entry.isDirectory()) { + if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue + await walk(fullPath) + } else if (entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('_')) { + const relativePath = toUnixPath(fullPath.slice(contextTreeDir.length + 1)) + if (isExcludedFromSync(relativePath)) continue + await callback(relativePath, fullPath) + } + } + /* eslint-enable no-await-in-loop */ + } + + await walk(contextTreeDir) +} + +// ── Step 2: LLM review ──────────────────────────────────────────────────── + +async function llmReview(candidates: CandidateInfo[], deps: PruneDeps): Promise { + const {agent, signal, taskId} = deps + + let sessionId: string + try { + sessionId = await agent.createTaskSession(taskId, 'dream-prune') + } catch { + return [] + } + + try { + // Build candidate payload for sandbox variable + const payload = await buildCandidatePayload(candidates, deps.contextTreeDir) + agent.setSandboxVariableOnSession(sessionId, '__dream_prune_candidates', payload) + + const totalFileCount = await countActiveFiles(deps.contextTreeDir) + const prompt = buildPrompt(candidates.length, totalFileCount, payload) + + const response = await agent.executeOnSession(sessionId, prompt, { + executionContext: {commandType: 'curate', maxIterations: 10}, + signal, + taskId, + }) + + const parsed = parseDreamResponse(response, PruneResponseSchema) + return parsed?.decisions ?? [] + } catch { + return [] + } finally { + await agent.deleteTaskSession(sessionId).catch(() => {}) + } +} + +async function buildCandidatePayload( + candidates: CandidateInfo[], + contextTreeDir: string, +): Promise> { + return Promise.all( + candidates.map(async (c) => { + let contentPreview = '' + try { + const content = await readFile(join(contextTreeDir, c.path), 'utf8') + contentPreview = content.slice(0, 500) + } catch { + // Skip + } + + return { + contentPreview, + daysSinceModified: Math.round(c.daysSinceModified), + importance: c.importance, + maturity: c.maturity, + path: c.path, + signal: c.signal, + } + }), + ) +} + +async function countActiveFiles(contextTreeDir: string): Promise { + let count = 0 + await walkMdFiles(contextTreeDir, async () => { count++ }) + return count +} + +function buildPrompt( + candidateCount: number, + totalFileCount: number, + payload: Array<{contentPreview: string; daysSinceModified: number; importance: number; maturity: string; path: string; signal: string}>, +): string { + const candidateLines = payload.map((c) => + `- **${c.path}** (maturity: ${c.maturity}, ${c.daysSinceModified}d old, importance: ${c.importance}, signal: ${c.signal})\n Preview: ${c.contentPreview.slice(0, 200).replaceAll('\n', ' ')}`, + ) + + return [ + 'You are reviewing files in a knowledge base for potential archival.', + 'These files have been flagged as potentially stale or low-value.', + '', + 'For each file, decide:', + '- ARCHIVE: Knowledge is stale, superseded, or no longer relevant. Safe to archive.', + '- KEEP: Knowledge is still useful despite its age. Do not archive.', + '- MERGE_INTO: Knowledge overlaps with another file and should be merged into it.', + '', + 'Rules:', + '- Be conservative. When in doubt, KEEP.', + '- Do NOT archive knowledge that is foundational or frequently cross-referenced.', + '- MERGE_INTO should only be used when the content clearly belongs in another specific file that you can name.', + '', + 'Context:', + `- The context tree currently contains ${totalFileCount} active files.`, + `- These ${candidateCount} files were flagged by staleness detection.`, + '', + 'Candidates:', + ...candidateLines, + '', + 'Respond IMMEDIATELY with JSON — do NOT use code_exec:', + '```', + '{ "decisions": [{ "file": "...", "decision": "ARCHIVE|KEEP|MERGE_INTO", "reason": "...", "mergeTarget": "path (only for MERGE_INTO)" }] }', + '```', + ].join('\n') +} + +// ── Step 3: Execute decisions ────────────────────────────────────────────── + +async function executeDecisions( + decisions: PruneDecision[], + candidates: CandidateInfo[], + deps: PruneDeps, +): Promise { + const candidateSet = new Set(candidates.map((c) => c.path)) + const results: DreamOperation[] = [] + + for (const decision of decisions) { + // Skip hallucinated paths — only process decisions for actual candidates + if (!candidateSet.has(decision.file)) continue + + try { + // eslint-disable-next-line no-await-in-loop + const op = await executeDecision(decision, deps) + if (op) results.push(op) + } catch { + // Skip failed decision — continue with others + } + } + + return results +} + +async function executeDecision(decision: PruneDecision, deps: PruneDeps): Promise { + switch (decision.decision) { + case 'ARCHIVE': { + const archiveResult = await deps.archiveService.archiveEntry(decision.file, deps.agent, deps.projectRoot) + return { + action: 'ARCHIVE', + file: decision.file, + needsReview: true, + reason: decision.reason, + stubPath: archiveResult.stubPath, + type: 'PRUNE', + } + } + + case 'KEEP': { + // Bump mtime to reset staleness clock + const absPath = join(deps.contextTreeDir, decision.file) + const now = new Date() + await utimes(absPath, now, now).catch(() => {}) + return { + action: 'KEEP', + file: decision.file, + needsReview: false, + reason: decision.reason, + type: 'PRUNE', + } + } + + case 'MERGE_INTO': { + if (!decision.mergeTarget) return undefined + + await writePendingMerge(decision, deps) + return { + action: 'SUGGEST_MERGE', + file: decision.file, + mergeTarget: decision.mergeTarget, + needsReview: false, + reason: decision.reason, + type: 'PRUNE', + } + } + + default: { + return undefined + } + } +} + +async function writePendingMerge(decision: PruneDecision, deps: PruneDeps): Promise { + if (!decision.mergeTarget) return + + const dreamState = await deps.dreamStateService.read() + const pendingMerges = dreamState.pendingMerges ?? [] + + // Dedup check + const alreadySuggested = pendingMerges.some( + (m) => m.sourceFile === decision.file && m.mergeTarget === decision.mergeTarget, + ) + if (alreadySuggested) return + + pendingMerges.push({ + mergeTarget: decision.mergeTarget, + reason: decision.reason, + sourceFile: decision.file, + suggestedByDreamId: deps.dreamLogId, + }) + + await deps.dreamStateService.write({...dreamState, pendingMerges}) +} + +// ── Frontmatter helpers ──────────────────────────────────────────────────── + +function extractMaturity(content: string): string { + const match = /^maturity:\s*['"]?(core|draft|validated)['"]?/m.exec(content) + return match?.[1] ?? 'draft' +} + +function extractImportance(content: string): number { + const match = /^importance:\s*(\d+(?:\.\d+)?)/m.exec(content) + return match ? Number.parseFloat(match[1]) : 50 +} diff --git a/src/server/infra/executor/dream-executor.ts b/src/server/infra/executor/dream-executor.ts index 090405dd4..05bfb3882 100644 --- a/src/server/infra/executor/dream-executor.ts +++ b/src/server/infra/executor/dream-executor.ts @@ -5,7 +5,7 @@ * 1. Capture pre-state snapshot * 2. Load dream state * 3. Find changed files since last dream (via curate log scanning) - * 4. Run operations (consolidate, synthesize; prune in ENG-2062) + * 4. Run operations (consolidate, synthesize, prune) * 5. Post-dream propagation (staleness + manifest rebuild) * 6. Write dream log * 7. Update dream state @@ -30,11 +30,16 @@ import {FileContextTreeSnapshotService} from '../context-tree/file-context-tree- import {FileContextTreeSummaryService} from '../context-tree/file-context-tree-summary-service.js' import {diffStates} from '../context-tree/snapshot-diff.js' import {consolidate, type ConsolidateDeps} from '../dream/operations/consolidate.js' +import {prune} from '../dream/operations/prune.js' import {synthesize} from '../dream/operations/synthesize.js' const DREAM_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes export type DreamExecutorDeps = { + archiveService: { + archiveEntry(relativePath: string, agent: ICipherAgent, directory?: string): Promise<{fullPath: string; originalPath: string; stubPath: string}> + findArchiveCandidates(directory?: string): Promise + } curateLogStore: { list(filters?: {after?: number; before?: number; limit?: number; status?: CurateLogStatus[]}): Promise } @@ -123,7 +128,17 @@ export class DreamExecutor { taskId: options.taskId, }) : [] - const allOperations: DreamOperation[] = [...consolidateResults, ...synthesizeResults] + const pruneResults = await prune({ + agent, + archiveService: this.deps.archiveService, + contextTreeDir, + dreamLogId: logId, + dreamStateService: this.deps.dreamStateService, + projectRoot, + signal: controller.signal, + taskId: options.taskId, + }) + const allOperations: DreamOperation[] = [...consolidateResults, ...synthesizeResults, ...pruneResults] // Step 5: Post-dream propagation (fail-open) if (preState) { @@ -154,13 +169,14 @@ export class DreamExecutor { } await this.deps.dreamLogStore.save(completedEntry) - // Step 7: Update dream state + // Step 7: Update dream state — re-read to preserve pendingMerges written by prune + const currentState = await this.deps.dreamStateService.read() await this.deps.dreamStateService.write({ - ...dreamState, + ...currentState, curationsSinceDream: 0, lastDreamAt: new Date().toISOString(), lastDreamLogId: logId, - totalDreams: dreamState.totalDreams + 1, + totalDreams: currentState.totalDreams + 1, }) succeeded = true diff --git a/test/unit/infra/dream/dream-undo.test.ts b/test/unit/infra/dream/dream-undo.test.ts index 153024f08..505e7d07c 100644 --- a/test/unit/infra/dream/dream-undo.test.ts +++ b/test/unit/infra/dream/dream-undo.test.ts @@ -291,7 +291,7 @@ describe('undoLastDream', () => { // ── PRUNE undo (forward-compatible) ─────────────────────────────────────── - it('undoes PRUNE/ARCHIVE: calls archiveService.restoreEntry', async () => { + it('undoes PRUNE/ARCHIVE: calls archiveService.restoreEntry with stubPath', async () => { const archiveService = {restoreEntry: stub().resolves('auth/old-doc.md')} dreamLogStore.getById.resolves(completedLog([{ @@ -299,12 +299,14 @@ describe('undoLastDream', () => { file: 'auth/old-doc.md', needsReview: false, reason: 'Stale', + stubPath: '_archived/auth/old-doc.stub.md', type: 'PRUNE', }])) const result = await undoLastDream({...deps, archiveService}) expect(archiveService.restoreEntry.calledOnce).to.be.true + expect(archiveService.restoreEntry.firstCall.args[0]).to.equal('_archived/auth/old-doc.stub.md') expect(result.restoredArchives).to.include('auth/old-doc.md') }) diff --git a/test/unit/infra/dream/operations/prune.test.ts b/test/unit/infra/dream/operations/prune.test.ts new file mode 100644 index 000000000..2f9a975e5 --- /dev/null +++ b/test/unit/infra/dream/operations/prune.test.ts @@ -0,0 +1,460 @@ +import {expect} from 'chai' +import {mkdir, stat, utimes, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {restore, type SinonStub, stub} from 'sinon' + +import type {ICipherAgent} from '../../../../../src/agent/core/interfaces/i-cipher-agent.js' +import type {DreamOperation} from '../../../../../src/server/infra/dream/dream-log-schema.js' +import type {DreamState} from '../../../../../src/server/infra/dream/dream-state-schema.js' + +import {EMPTY_DREAM_STATE} from '../../../../../src/server/infra/dream/dream-state-schema.js' +import {prune, type PruneDeps} from '../../../../../src/server/infra/dream/operations/prune.js' + +/** Helper: create a markdown file with optional frontmatter */ +async function createMdFile(dir: string, relativePath: string, body: string, frontmatter?: Record): Promise { + const fullPath = join(dir, relativePath) + await mkdir(join(fullPath, '..'), {recursive: true}) + let content = body + if (frontmatter) { + const {dump} = await import('js-yaml') + const yaml = dump(frontmatter, {flowLevel: 1, lineWidth: -1, sortKeys: true}).trimEnd() + content = `---\n${yaml}\n---\n${body}` + } + + await writeFile(fullPath, content, 'utf8') +} + +/** Set file mtime to N days ago */ +async function setMtimeDaysAgo(dir: string, relativePath: string, daysAgo: number): Promise { + const fullPath = join(dir, relativePath) + const pastMs = Date.now() - daysAgo * 24 * 60 * 60 * 1000 + const past = new Date(pastMs) + await utimes(fullPath, past, past) +} + +/** Build a canned LLM response */ +function llmResponse(decisions: Array<{decision: string; file: string; mergeTarget?: string; reason: string}>): string { + return '```json\n' + JSON.stringify({decisions}) + '\n```' +} + +/** Narrow DreamOperation to PRUNE variant */ +function asPrune(op: DreamOperation) { + expect(op.type).to.equal('PRUNE') + return op as Extract +} + +describe('prune', () => { + let ctxDir: string + let projectRoot: string + let agent: { + createTaskSession: SinonStub + deleteTaskSession: SinonStub + executeOnSession: SinonStub + setSandboxVariableOnSession: SinonStub + } + let archiveService: { + archiveEntry: SinonStub + findArchiveCandidates: SinonStub + } + let dreamStateService: { + read: SinonStub + write: SinonStub + } + let deps: PruneDeps + + beforeEach(async () => { + ctxDir = join(tmpdir(), `brv-prune-test-${Date.now()}`) + projectRoot = ctxDir // simplified for tests — prune uses ctxDir directly + await mkdir(ctxDir, {recursive: true}) + + agent = { + createTaskSession: stub().resolves('session-1'), + deleteTaskSession: stub().resolves(), + executeOnSession: stub().resolves(llmResponse([])), + setSandboxVariableOnSession: stub(), + } + + archiveService = { + archiveEntry: stub().resolves({fullPath: '_archived/test.full.md', originalPath: 'test.md', stubPath: '_archived/test.stub.md'}), + findArchiveCandidates: stub().resolves([]), + } + + dreamStateService = { + read: stub().resolves({...EMPTY_DREAM_STATE}), + write: stub().resolves(), + } + + deps = { + agent: agent as unknown as ICipherAgent, + archiveService, + contextTreeDir: ctxDir, + dreamLogId: 'drm-1', + dreamStateService, + projectRoot, + signal: undefined, + taskId: 'test-task', + } + }) + + afterEach(() => { + restore() + }) + + // ── Preconditions ───────────────────────────────────────────────────────── + + it('returns empty array when no candidates found', async () => { + const results = await prune(deps) + expect(results).to.deep.equal([]) + expect(agent.createTaskSession.called).to.be.false + }) + + it('respects abort signal', async () => { + const controller = new AbortController() + controller.abort() + + const results = await prune({...deps, signal: controller.signal}) + expect(results).to.deep.equal([]) + expect(agent.createTaskSession.called).to.be.false + }) + + // ── Signal A: archive service candidates ────────────────────────────────── + + it('finds candidates via archiveService (Signal A)', async () => { + await createMdFile(ctxDir, 'auth/old-tokens.md', '# Old tokens', {importance: 20, maturity: 'draft'}) + archiveService.findArchiveCandidates.resolves(['auth/old-tokens.md']) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'ARCHIVE', file: 'auth/old-tokens.md', reason: 'Stale draft'}, + ])) + + const results = await prune(deps) + expect(results).to.have.lengthOf(1) + expect(asPrune(results[0]).action).to.equal('ARCHIVE') + }) + + // ── Signal B: mtime staleness ───────────────────────────────────────────── + + it('finds stale draft files via mtime (Signal B, threshold 60 days)', async () => { + await createMdFile(ctxDir, 'api/old-draft.md', '# Old draft', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'api/old-draft.md', 61) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'KEEP', file: 'api/old-draft.md', reason: 'Still useful'}, + ])) + + const results = await prune(deps) + expect(results).to.have.lengthOf(1) + expect(asPrune(results[0]).action).to.equal('KEEP') + }) + + it('does NOT flag draft files under 60 days old', async () => { + await createMdFile(ctxDir, 'api/recent-draft.md', '# Recent draft', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'api/recent-draft.md', 59) + + const results = await prune(deps) + expect(results).to.deep.equal([]) + expect(agent.createTaskSession.called).to.be.false + }) + + it('finds stale validated files via mtime (threshold 120 days)', async () => { + await createMdFile(ctxDir, 'api/old-validated.md', '# Validated doc', {maturity: 'validated'}) + await setMtimeDaysAgo(ctxDir, 'api/old-validated.md', 121) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'KEEP', file: 'api/old-validated.md', reason: 'Still relevant'}, + ])) + + const results = await prune(deps) + expect(results).to.have.lengthOf(1) + }) + + it('does NOT flag validated files under 120 days old', async () => { + await createMdFile(ctxDir, 'api/recent-validated.md', '# Validated doc', {maturity: 'validated'}) + await setMtimeDaysAgo(ctxDir, 'api/recent-validated.md', 119) + + const results = await prune(deps) + expect(results).to.deep.equal([]) + }) + + it('NEVER flags core files regardless of age', async () => { + await createMdFile(ctxDir, 'auth/core-doc.md', '# Core knowledge', {maturity: 'core'}) + await setMtimeDaysAgo(ctxDir, 'auth/core-doc.md', 365) + + const results = await prune(deps) + expect(results).to.deep.equal([]) + expect(agent.createTaskSession.called).to.be.false + }) + + // ── Candidate cap ───────────────────────────────────────────────────────── + + it('caps candidates at 20 (stalest first)', async () => { + // Create 25 stale draft files + for (let i = 0; i < 25; i++) { + const name = `api/stale-${String(i).padStart(2, '0')}.md` + // eslint-disable-next-line no-await-in-loop + await createMdFile(ctxDir, name, `# Stale ${i}`, {maturity: 'draft'}) + // eslint-disable-next-line no-await-in-loop + await setMtimeDaysAgo(ctxDir, name, 70 + i) // 70–94 days old + } + + agent.executeOnSession.resolves(llmResponse([])) + + await prune(deps) + + // Should have called LLM — verify sandbox variable has at most 20 candidates + expect(agent.setSandboxVariableOnSession.calledOnce).to.be.true + const payload = agent.setSandboxVariableOnSession.firstCall.args[2] + expect(payload).to.be.an('array').with.lengthOf(20) + }) + + // ── LLM interaction ─────────────────────────────────────────────────────── + + it('creates session and cleans up on success', async () => { + await createMdFile(ctxDir, 'auth/old.md', '# Old', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/old.md', 61) + + agent.executeOnSession.resolves(llmResponse([])) + + await prune(deps) + + expect(agent.createTaskSession.calledOnce).to.be.true + expect(agent.deleteTaskSession.calledOnce).to.be.true + }) + + it('returns empty array on LLM failure', async () => { + await createMdFile(ctxDir, 'auth/old.md', '# Old', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/old.md', 61) + + agent.executeOnSession.rejects(new Error('LLM timeout')) + + const results = await prune(deps) + expect(results).to.deep.equal([]) + expect(agent.deleteTaskSession.calledOnce).to.be.true + }) + + it('skips LLM decision that references non-candidate file', async () => { + await createMdFile(ctxDir, 'auth/old.md', '# Old', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/old.md', 61) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'ARCHIVE', file: 'auth/nonexistent.md', reason: 'Hallucinated'}, + {decision: 'KEEP', file: 'auth/old.md', reason: 'Still useful'}, + ])) + + const results = await prune(deps) + expect(results).to.have.lengthOf(1) + expect(asPrune(results[0]).file).to.equal('auth/old.md') + }) + + // ── ARCHIVE decision ────────────────────────────────────────────────────── + + it('calls archiveService.archiveEntry and returns ARCHIVE op with needsReview=true', async () => { + await createMdFile(ctxDir, 'auth/stale.md', '# Stale doc', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/stale.md', 90) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'ARCHIVE', file: 'auth/stale.md', reason: 'No longer relevant'}, + ])) + + const results = await prune(deps) + expect(results).to.have.lengthOf(1) + + const op = asPrune(results[0]) + expect(op.action).to.equal('ARCHIVE') + expect(op.file).to.equal('auth/stale.md') + expect(op.reason).to.equal('No longer relevant') + expect(op.needsReview).to.be.true + expect(op.stubPath).to.equal('_archived/test.stub.md') + + expect(archiveService.archiveEntry.calledOnce).to.be.true + expect(archiveService.archiveEntry.firstCall.args[0]).to.equal('auth/stale.md') + }) + + it('continues processing when archiveService.archiveEntry throws', async () => { + await createMdFile(ctxDir, 'auth/fail.md', '# Fail', {maturity: 'draft'}) + await createMdFile(ctxDir, 'api/success.md', '# Success', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/fail.md', 90) + await setMtimeDaysAgo(ctxDir, 'api/success.md', 90) + + archiveService.archiveEntry.onFirstCall().rejects(new Error('Disk full')) + archiveService.archiveEntry.onSecondCall().resolves({fullPath: '_archived/api/success.full.md', originalPath: 'api/success.md', stubPath: '_archived/api/success.stub.md'}) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'ARCHIVE', file: 'auth/fail.md', reason: 'Stale'}, + {decision: 'ARCHIVE', file: 'api/success.md', reason: 'Also stale'}, + ])) + + const results = await prune(deps) + // First archive fails, second succeeds + expect(results).to.have.lengthOf(1) + expect(asPrune(results[0]).file).to.equal('api/success.md') + }) + + // ── KEEP decision ───────────────────────────────────────────────────────── + + it('bumps mtime on KEEP decision and returns op with needsReview=false', async () => { + await createMdFile(ctxDir, 'auth/useful.md', '# Useful', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/useful.md', 90) + + const beforeStat = await stat(join(ctxDir, 'auth/useful.md')) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'KEEP', file: 'auth/useful.md', reason: 'Still referenced'}, + ])) + + const results = await prune(deps) + expect(results).to.have.lengthOf(1) + + const op = asPrune(results[0]) + expect(op.action).to.equal('KEEP') + expect(op.file).to.equal('auth/useful.md') + expect(op.needsReview).to.be.false + + // mtime should be bumped to recent + const afterStat = await stat(join(ctxDir, 'auth/useful.md')) + expect(afterStat.mtimeMs).to.be.greaterThan(beforeStat.mtimeMs) + }) + + // ── MERGE_INTO decision ─────────────────────────────────────────────────── + + it('writes pendingMerges on MERGE_INTO decision', async () => { + await createMdFile(ctxDir, 'auth/overlap.md', '# Overlap', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/overlap.md', 90) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'MERGE_INTO', file: 'auth/overlap.md', mergeTarget: 'auth/main.md', reason: 'Content overlaps'}, + ])) + + const results = await prune(deps) + expect(results).to.have.lengthOf(1) + + const op = asPrune(results[0]) + expect(op.action).to.equal('SUGGEST_MERGE') + expect(op.file).to.equal('auth/overlap.md') + expect(op.mergeTarget).to.equal('auth/main.md') + expect(op.needsReview).to.be.false + + expect(dreamStateService.write.calledOnce).to.be.true + const writtenState = dreamStateService.write.firstCall.args[0] as DreamState + expect(writtenState.pendingMerges).to.have.lengthOf(1) + expect(writtenState.pendingMerges[0]).to.deep.include({ + mergeTarget: 'auth/main.md', + sourceFile: 'auth/overlap.md', + suggestedByDreamId: 'drm-1', + }) + }) + + it('does not duplicate existing pendingMerges entry', async () => { + await createMdFile(ctxDir, 'auth/overlap.md', '# Overlap', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/overlap.md', 90) + + // Pre-populate with same merge suggestion + dreamStateService.read.resolves({ + ...EMPTY_DREAM_STATE, + pendingMerges: [{ + mergeTarget: 'auth/main.md', + reason: 'Previous suggestion', + sourceFile: 'auth/overlap.md', + suggestedByDreamId: 'drm-0', + }], + }) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'MERGE_INTO', file: 'auth/overlap.md', mergeTarget: 'auth/main.md', reason: 'Still overlaps'}, + ])) + + const results = await prune(deps) + expect(results).to.have.lengthOf(1) + + // dreamStateService.write should NOT be called since no new merge was added + expect(dreamStateService.write.called).to.be.false + }) + + it('drops MERGE_INTO op when mergeTarget is absent', async () => { + await createMdFile(ctxDir, 'auth/overlap.md', '# Overlap', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/overlap.md', 90) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'MERGE_INTO', file: 'auth/overlap.md', reason: 'Missing target'}, + ])) + + const results = await prune(deps) + expect(results).to.deep.equal([]) + expect(dreamStateService.write.called).to.be.false + }) + + // ── Mixed decisions ─────────────────────────────────────────────────────── + + it('handles mixed ARCHIVE, KEEP, and MERGE_INTO in one pass', async () => { + await createMdFile(ctxDir, 'auth/stale.md', '# Stale', {maturity: 'draft'}) + await createMdFile(ctxDir, 'api/useful.md', '# Useful', {maturity: 'draft'}) + await createMdFile(ctxDir, 'infra/overlap.md', '# Overlap', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/stale.md', 90) + await setMtimeDaysAgo(ctxDir, 'api/useful.md', 90) + await setMtimeDaysAgo(ctxDir, 'infra/overlap.md', 90) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'ARCHIVE', file: 'auth/stale.md', reason: 'Outdated'}, + {decision: 'KEEP', file: 'api/useful.md', reason: 'Referenced often'}, + {decision: 'MERGE_INTO', file: 'infra/overlap.md', mergeTarget: 'infra/main.md', reason: 'Redundant'}, + ])) + + const results = await prune(deps) + expect(results).to.have.lengthOf(3) + + const actions = results.map((r) => asPrune(r).action) + expect(actions).to.include('ARCHIVE') + expect(actions).to.include('KEEP') + expect(actions).to.include('SUGGEST_MERGE') + }) + + // ── Dedup between signals ───────────────────────────────────────────────── + + it('deduplicates candidates found by both signals', async () => { + // File found by both Signal A (archiveService) and Signal B (mtime) + await createMdFile(ctxDir, 'auth/both-signals.md', '# Both', {importance: 20, maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/both-signals.md', 90) + archiveService.findArchiveCandidates.resolves(['auth/both-signals.md']) + + agent.executeOnSession.resolves(llmResponse([ + {decision: 'ARCHIVE', file: 'auth/both-signals.md', reason: 'Stale'}, + ])) + + await prune(deps) + + // Verify only sent once to LLM + const payload = agent.setSandboxVariableOnSession.firstCall.args[2] + const paths = payload.map((c: {path: string}) => c.path) + const occurrences = paths.filter((p: string) => p === 'auth/both-signals.md') + expect(occurrences).to.have.lengthOf(1) + }) + + // ── Excluded files ──────────────────────────────────────────────────────── + + it('skips _archived and derived artifact files', async () => { + await createMdFile(ctxDir, '_archived/auth/old.stub.md', '# Stub', {type: 'archive_stub'}) + await createMdFile(ctxDir, 'auth/_index.md', '# Summary', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, '_archived/auth/old.stub.md', 365) + await setMtimeDaysAgo(ctxDir, 'auth/_index.md', 365) + + const results = await prune(deps) + expect(results).to.deep.equal([]) + }) + + // ── Signal propagation ──────────────────────────────────────────────────── + + it('passes abort signal to executeOnSession', async () => { + await createMdFile(ctxDir, 'auth/old.md', '# Old', {maturity: 'draft'}) + await setMtimeDaysAgo(ctxDir, 'auth/old.md', 61) + + const controller = new AbortController() + agent.executeOnSession.resolves(llmResponse([])) + + await prune({...deps, signal: controller.signal}) + + expect(agent.executeOnSession.calledOnce).to.be.true + const options = agent.executeOnSession.firstCall.args[2] + expect(options).to.have.property('signal', controller.signal) + }) +}) diff --git a/test/unit/infra/executor/dream-executor.test.ts b/test/unit/infra/executor/dream-executor.test.ts index c3cbabf04..7e4b4d0d2 100644 --- a/test/unit/infra/executor/dream-executor.test.ts +++ b/test/unit/infra/executor/dream-executor.test.ts @@ -43,6 +43,7 @@ describe('DreamExecutor', () => { setSandboxVariableOnSession: stub(), } as unknown as ICipherAgent deps = { + archiveService: {archiveEntry: stub().resolves({fullPath: '', originalPath: '', stubPath: ''}), findArchiveCandidates: stub().resolves([])}, curateLogStore, dreamLockService, dreamLogStore,