byterover-cli 3.5.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.env.production +4 -6
  2. package/dist/agent/core/interfaces/i-cipher-agent.d.ts +1 -0
  3. package/dist/agent/infra/agent/cipher-agent.d.ts +1 -0
  4. package/dist/agent/infra/agent/cipher-agent.js +1 -0
  5. package/dist/oclif/commands/curate/view.js +5 -25
  6. package/dist/oclif/commands/dream.d.ts +18 -0
  7. package/dist/oclif/commands/dream.js +230 -0
  8. package/dist/oclif/commands/query-log/summary.d.ts +18 -0
  9. package/dist/oclif/commands/query-log/summary.js +75 -0
  10. package/dist/oclif/commands/query-log/view.d.ts +23 -0
  11. package/dist/oclif/commands/query-log/view.js +95 -0
  12. package/dist/oclif/lib/time-filter.d.ts +10 -0
  13. package/dist/oclif/lib/time-filter.js +21 -0
  14. package/dist/server/config/environment.d.ts +10 -3
  15. package/dist/server/config/environment.js +34 -15
  16. package/dist/server/constants.d.ts +5 -0
  17. package/dist/server/constants.js +7 -0
  18. package/dist/server/core/domain/entities/query-log-entry.d.ts +61 -0
  19. package/dist/server/core/domain/entities/query-log-entry.js +40 -0
  20. package/dist/server/core/domain/transport/schemas.d.ts +108 -7
  21. package/dist/server/core/domain/transport/schemas.js +34 -2
  22. package/dist/server/core/interfaces/executor/i-query-executor.d.ts +23 -2
  23. package/dist/server/core/interfaces/i-terminal.d.ts +3 -0
  24. package/dist/server/core/interfaces/i-terminal.js +1 -0
  25. package/dist/server/core/interfaces/storage/i-query-log-store.d.ts +23 -0
  26. package/dist/server/core/interfaces/storage/i-query-log-store.js +2 -0
  27. package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.d.ts +44 -0
  28. package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.js +1 -0
  29. package/dist/server/core/interfaces/usecase/i-query-log-use-case.d.ts +13 -0
  30. package/dist/server/core/interfaces/usecase/i-query-log-use-case.js +3 -0
  31. package/dist/server/infra/daemon/agent-process.js +79 -9
  32. package/dist/server/infra/daemon/brv-server.js +74 -5
  33. package/dist/server/infra/dream/dream-lock-service.d.ts +37 -0
  34. package/dist/server/infra/dream/dream-lock-service.js +88 -0
  35. package/dist/server/infra/dream/dream-log-schema.d.ts +966 -0
  36. package/dist/server/infra/dream/dream-log-schema.js +57 -0
  37. package/dist/server/infra/dream/dream-log-store.d.ts +55 -0
  38. package/dist/server/infra/dream/dream-log-store.js +141 -0
  39. package/dist/server/infra/dream/dream-response-schemas.d.ts +219 -0
  40. package/dist/server/infra/dream/dream-response-schemas.js +38 -0
  41. package/dist/server/infra/dream/dream-state-schema.d.ts +67 -0
  42. package/dist/server/infra/dream/dream-state-schema.js +23 -0
  43. package/dist/server/infra/dream/dream-state-service.d.ts +38 -0
  44. package/dist/server/infra/dream/dream-state-service.js +91 -0
  45. package/dist/server/infra/dream/dream-trigger.d.ts +46 -0
  46. package/dist/server/infra/dream/dream-trigger.js +65 -0
  47. package/dist/server/infra/dream/dream-undo.d.ts +38 -0
  48. package/dist/server/infra/dream/dream-undo.js +293 -0
  49. package/dist/server/infra/dream/operations/consolidate.d.ts +52 -0
  50. package/dist/server/infra/dream/operations/consolidate.js +514 -0
  51. package/dist/server/infra/dream/operations/prune.d.ts +45 -0
  52. package/dist/server/infra/dream/operations/prune.js +362 -0
  53. package/dist/server/infra/dream/operations/synthesize.d.ts +37 -0
  54. package/dist/server/infra/dream/operations/synthesize.js +278 -0
  55. package/dist/server/infra/dream/parse-dream-response.d.ts +11 -0
  56. package/dist/server/infra/dream/parse-dream-response.js +35 -0
  57. package/dist/server/infra/executor/curate-executor.js +10 -0
  58. package/dist/server/infra/executor/dream-executor.d.ts +97 -0
  59. package/dist/server/infra/executor/dream-executor.js +431 -0
  60. package/dist/server/infra/executor/query-executor.d.ts +2 -2
  61. package/dist/server/infra/executor/query-executor.js +92 -22
  62. package/dist/server/infra/process/feature-handlers.js +10 -6
  63. package/dist/server/infra/process/query-log-handler.d.ts +42 -0
  64. package/dist/server/infra/process/query-log-handler.js +150 -0
  65. package/dist/server/infra/process/task-router.d.ts +40 -0
  66. package/dist/server/infra/process/task-router.js +67 -9
  67. package/dist/server/infra/process/transport-handlers.d.ts +4 -0
  68. package/dist/server/infra/process/transport-handlers.js +1 -0
  69. package/dist/server/infra/storage/file-curate-log-store.js +1 -1
  70. package/dist/server/infra/storage/file-query-log-store.d.ts +81 -0
  71. package/dist/server/infra/storage/file-query-log-store.js +249 -0
  72. package/dist/server/infra/transport/handlers/config-handler.js +1 -1
  73. package/dist/server/infra/usecase/curate-log-use-case.js +7 -3
  74. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.d.ts +15 -0
  75. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.js +79 -0
  76. package/dist/server/infra/usecase/query-log-summary-use-case.d.ts +13 -0
  77. package/dist/server/infra/usecase/query-log-summary-use-case.js +217 -0
  78. package/dist/server/infra/usecase/query-log-use-case.d.ts +31 -0
  79. package/dist/server/infra/usecase/query-log-use-case.js +128 -0
  80. package/dist/server/utils/log-format-utils.d.ts +5 -0
  81. package/dist/server/utils/log-format-utils.js +23 -0
  82. package/dist/shared/transport/events/config-events.d.ts +1 -1
  83. package/oclif.manifest.json +258 -3
  84. package/package.json +1 -1
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Consolidate operation — merges, updates, and cross-references related context tree files.
3
+ *
4
+ * Flow:
5
+ * 1. Group changed files by domain (first path segment)
6
+ * 2. Per domain: find related files via BM25 search + path siblings
7
+ * 3. Per domain: LLM classifies file relationships → returns actions
8
+ * 4. Execute actions: MERGE (combine + delete source), TEMPORAL_UPDATE (rewrite),
9
+ * CROSS_REFERENCE (add related links in frontmatter), SKIP (no-op)
10
+ *
11
+ * Never throws — returns partial results on errors.
12
+ */
13
+ import { dump as yamlDump, load as yamlLoad } from 'js-yaml';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { access, mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
16
+ import { dirname, join } from 'node:path';
17
+ import { parseFrontmatterScoring } from '../../../core/domain/knowledge/markdown-writer.js';
18
+ import { ConsolidateResponseSchema } from '../dream-response-schemas.js';
19
+ import { parseDreamResponse } from '../parse-dream-response.js';
20
+ /**
21
+ * Run the consolidation operation on changed files.
22
+ * Returns DreamOperation results (never throws).
23
+ */
24
+ export async function consolidate(changedFiles, deps) {
25
+ // Cross-cycle: fold in pendingMerges written by the previous dream's Prune.
26
+ // Source files (if still on disk) join the changedFiles set so consolidate
27
+ // re-evaluates them; mergeTarget + reason surface to the LLM as a hint.
28
+ // pendingMerges is cleared unconditionally after this pass — consumed
29
+ // regardless of outcome, per notes/byterover-dream/6-dream-undo-and-cross-cycle.md.
30
+ const hints = await loadAndClearPendingMerges(deps, changedFiles);
31
+ if (changedFiles.length === 0)
32
+ return [];
33
+ // Step 1: Group by domain
34
+ const domainGroups = groupByDomain(changedFiles);
35
+ // Step 2-5: Process each domain sequentially to avoid concurrent file writes
36
+ const allResults = [];
37
+ for (const [domain, files] of domainGroups) {
38
+ if (deps.signal?.aborted)
39
+ break;
40
+ // eslint-disable-next-line no-await-in-loop
41
+ const domainOps = await processDomain(domain, files, deps, hints);
42
+ allResults.push(...domainOps);
43
+ }
44
+ return allResults;
45
+ }
46
+ /**
47
+ * Reads pendingMerges from state, mutates `changedFiles` to include any
48
+ * pending sourceFiles that still exist on disk, and clears the list.
49
+ * Returns the list for use as LLM prompt hints (may be empty).
50
+ *
51
+ * Two-phase access pattern (intentional):
52
+ * 1. unguarded `read()` to build hints — hints are non-binding LLM
53
+ * suggestions, so a slightly-stale snapshot here is acceptable. Avoids
54
+ * holding the per-file mutex across the file-existence checks below.
55
+ * 2. mutex-guarded `update()` to clear pendingMerges — must be atomic so a
56
+ * concurrent `incrementCurationCount` isn't overwritten by writing back
57
+ * from a stale snapshot.
58
+ *
59
+ * If a concurrent prune appends new entries between the two phases, those new
60
+ * entries are NOT cleared by this call — they remain for the next dream's
61
+ * consolidate to consume. That's correct behavior.
62
+ */
63
+ async function loadAndClearPendingMerges(deps, changedFiles) {
64
+ if (!deps.dreamStateService)
65
+ return [];
66
+ let state;
67
+ try {
68
+ state = await deps.dreamStateService.read();
69
+ }
70
+ catch {
71
+ // If the state file is unreadable we can't safely build hints; the
72
+ // matching `update()` below would also fail. Return early — the next
73
+ // dream will retry once the file is readable again.
74
+ return [];
75
+ }
76
+ const pending = state.pendingMerges ?? [];
77
+ if (pending.length === 0)
78
+ return [];
79
+ // Check all source files in parallel — independent fs stat calls.
80
+ const presenceChecks = await Promise.all(pending.map((entry) => fileExists(join(deps.contextTreeDir, entry.sourceFile))));
81
+ const existing = new Set(changedFiles);
82
+ const hints = [];
83
+ for (const [index, entry] of pending.entries()) {
84
+ if (!presenceChecks[index])
85
+ continue; // Stale suggestion — skip silently
86
+ hints.push(entry);
87
+ if (!existing.has(entry.sourceFile)) {
88
+ changedFiles.push(entry.sourceFile);
89
+ existing.add(entry.sourceFile);
90
+ }
91
+ }
92
+ try {
93
+ // Clear pendingMerges under the per-file mutex so a concurrent
94
+ // incrementCurationCount can't be lost by overwriting from a stale snapshot.
95
+ // The updater spreads the latest state, preserving any field a parallel
96
+ // writer just touched.
97
+ await deps.dreamStateService.update((latest) => ({ ...latest, pendingMerges: [] }));
98
+ }
99
+ catch {
100
+ // Fail-open: failure to clear pendingMerges is a minor bookkeeping issue,
101
+ // not a reason to block the dream.
102
+ }
103
+ return hints;
104
+ }
105
+ async function fileExists(absolutePath) {
106
+ try {
107
+ await access(absolutePath);
108
+ return true;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ async function processDomain(domain, files, deps, hints = []) {
115
+ const { agent, contextTreeDir, searchService, taskId } = deps;
116
+ const results = [];
117
+ let sessionId;
118
+ try {
119
+ sessionId = await agent.createTaskSession(taskId, 'dream-consolidate');
120
+ }
121
+ catch {
122
+ return []; // Session creation failed — skip domain
123
+ }
124
+ try {
125
+ // Step 2: Find related files for each changed file in domain
126
+ const fileContents = new Map();
127
+ const relatedPaths = new Set();
128
+ // Sequential: each file's search results may inform the next (shared fileContents map)
129
+ // eslint-disable-next-line no-await-in-loop
130
+ for (const file of files)
131
+ await loadFileAndRelated(file, domain, contextTreeDir, searchService, fileContents, relatedPaths);
132
+ // Also load sibling .md files from same directories
133
+ await loadSiblings(files, contextTreeDir, fileContents);
134
+ if (fileContents.size === 0)
135
+ return [];
136
+ // Step 3: LLM classification — cap payload to avoid exceeding model context limits
137
+ const filesPayload = capPayloadSize(Object.fromEntries(fileContents), files);
138
+ const prompt = buildPrompt(files, [...relatedPaths], filesPayload, hints);
139
+ const response = await agent.executeOnSession(sessionId, prompt, {
140
+ executionContext: { commandType: 'curate', maxIterations: 10 },
141
+ signal: deps.signal,
142
+ taskId,
143
+ });
144
+ const parsed = parseDreamResponse(response, ConsolidateResponseSchema);
145
+ if (!parsed)
146
+ return [];
147
+ // Step 4: Execute actions (sequential: MERGE deletes files that later actions may reference)
148
+ for (const action of parsed.actions) {
149
+ try {
150
+ // eslint-disable-next-line no-await-in-loop
151
+ const op = await executeAction(action, contextTreeDir, fileContents, deps.reviewBackupStore);
152
+ if (op)
153
+ results.push(op);
154
+ }
155
+ catch {
156
+ // Skip failed action, continue with others
157
+ }
158
+ }
159
+ }
160
+ catch {
161
+ // Skip failed domain — return whatever succeeded
162
+ }
163
+ finally {
164
+ await agent.deleteTaskSession(sessionId).catch(() => { });
165
+ }
166
+ return results;
167
+ }
168
+ async function atomicWrite(filePath, content) {
169
+ await mkdir(dirname(filePath), { recursive: true });
170
+ const tmpPath = `${filePath}.${randomUUID()}.tmp`;
171
+ await writeFile(tmpPath, content, 'utf8');
172
+ await rename(tmpPath, filePath);
173
+ }
174
+ /** Max total chars for LLM sandbox payload — matches curate task cap (MAX_CONTENT_PER_FILE × MAX_FILES). */
175
+ const MAX_PAYLOAD_CHARS = 200_000;
176
+ /**
177
+ * Cap the total payload size by evicting non-changed files (lowest relevance) when the
178
+ * combined content exceeds MAX_PAYLOAD_BYTES. Changed files are always kept.
179
+ */
180
+ function capPayloadSize(payload, changedFiles) {
181
+ const changedSet = new Set(changedFiles);
182
+ let totalSize = 0;
183
+ for (const content of Object.values(payload))
184
+ totalSize += content.length;
185
+ if (totalSize <= MAX_PAYLOAD_CHARS)
186
+ return payload;
187
+ // Keep changed files, evict non-changed (siblings/search results) until under cap
188
+ const result = {};
189
+ let currentSize = 0;
190
+ // Add changed files first (always kept)
191
+ for (const [path, content] of Object.entries(payload)) {
192
+ if (changedSet.has(path)) {
193
+ result[path] = content;
194
+ currentSize += content.length;
195
+ }
196
+ }
197
+ // Add non-changed files until cap reached
198
+ for (const [path, content] of Object.entries(payload)) {
199
+ if (!changedSet.has(path)) {
200
+ if (currentSize + content.length > MAX_PAYLOAD_CHARS)
201
+ continue;
202
+ result[path] = content;
203
+ currentSize += content.length;
204
+ }
205
+ }
206
+ return result;
207
+ }
208
+ /** Merge extra fields into existing YAML frontmatter, or prepend new frontmatter if none exists. */
209
+ function addFrontmatterFields(content, fields) {
210
+ if (content.startsWith('---\n') || content.startsWith('---\r\n')) {
211
+ const endIndex = content.indexOf('\n---\n', 4);
212
+ const endIndexCrlf = content.indexOf('\r\n---\r\n', 5);
213
+ const actualEnd = endIndex === -1 ? endIndexCrlf : endIndex;
214
+ if (actualEnd >= 0) {
215
+ const yamlBlock = content.slice(4, actualEnd);
216
+ const bodyStart = content.indexOf('\n', actualEnd + 1) + 1;
217
+ const body = content.slice(bodyStart);
218
+ try {
219
+ const parsed = yamlLoad(yamlBlock);
220
+ if (parsed && typeof parsed === 'object') {
221
+ const merged = { ...parsed, ...fields };
222
+ const newYaml = yamlDump(merged, { flowLevel: 2, lineWidth: -1, sortKeys: true }).trimEnd();
223
+ return `---\n${newYaml}\n---\n${body}`;
224
+ }
225
+ }
226
+ catch {
227
+ // YAML parse failure — prepend new frontmatter
228
+ }
229
+ }
230
+ }
231
+ // No valid frontmatter — prepend
232
+ const yaml = yamlDump(fields, { flowLevel: 2, lineWidth: -1, sortKeys: true }).trimEnd();
233
+ return `---\n${yaml}\n---\n${content}`;
234
+ }
235
+ // ── Helpers ──────────────────────────────────────────────────────────────────
236
+ function groupByDomain(files) {
237
+ const groups = new Map();
238
+ for (const file of files) {
239
+ const domain = file.split('/')[0];
240
+ const group = groups.get(domain) ?? [];
241
+ group.push(file);
242
+ groups.set(domain, group);
243
+ }
244
+ return groups;
245
+ }
246
+ async function loadFileAndRelated(file, domain, contextTreeDir, searchService, fileContents, relatedPaths) {
247
+ // Read changed file
248
+ try {
249
+ const content = await readFile(join(contextTreeDir, file), 'utf8');
250
+ fileContents.set(file, content);
251
+ }
252
+ catch {
253
+ return; // File missing — skip
254
+ }
255
+ // BM25 search for related files in same domain
256
+ try {
257
+ const query = extractSearchQuery(file, fileContents.get(file) ?? '');
258
+ const searchResults = await searchService.search(query, { limit: 5, scope: domain });
259
+ const newPaths = searchResults.results
260
+ .filter((r) => r.path !== file && !fileContents.has(r.path))
261
+ .map((r) => r.path);
262
+ for (const p of searchResults.results) {
263
+ if (p.path !== file)
264
+ relatedPaths.add(p.path);
265
+ }
266
+ const loaded = await Promise.all(newPaths.map(async (p) => {
267
+ try {
268
+ return { content: await readFile(join(contextTreeDir, p), 'utf8'), path: p };
269
+ }
270
+ catch {
271
+ return null;
272
+ }
273
+ }));
274
+ for (const item of loaded) {
275
+ if (item)
276
+ fileContents.set(item.path, item.content);
277
+ }
278
+ }
279
+ catch {
280
+ // Search failure — continue without related files
281
+ }
282
+ }
283
+ async function loadSiblings(files, contextTreeDir, fileContents) {
284
+ const dirs = [...new Set(files.map((f) => dirname(f)))];
285
+ const dirResults = await Promise.all(dirs.map(async (dir) => {
286
+ try {
287
+ const entries = await readdir(join(contextTreeDir, dir), { withFileTypes: true });
288
+ return entries
289
+ .filter((e) => e.isFile() && e.name.endsWith('.md') && !e.name.startsWith('_'))
290
+ .map((e) => join(dir, e.name));
291
+ }
292
+ catch {
293
+ return [];
294
+ }
295
+ }));
296
+ const allSiblings = dirResults.flat().filter((s) => !fileContents.has(s));
297
+ const loaded = await Promise.all(allSiblings.map(async (sibling) => {
298
+ try {
299
+ return { content: await readFile(join(contextTreeDir, sibling), 'utf8'), path: sibling };
300
+ }
301
+ catch {
302
+ return null;
303
+ }
304
+ }));
305
+ for (const item of loaded) {
306
+ if (item)
307
+ fileContents.set(item.path, item.content);
308
+ }
309
+ }
310
+ function extractSearchQuery(filePath, content) {
311
+ // Use filename (without extension) + first 100 words of content
312
+ const name = filePath.split('/').pop()?.replace(/\.md$/, '').replaceAll(/[-_]/g, ' ') ?? '';
313
+ const words = content.split(/\s+/).slice(0, 100).join(' ');
314
+ return `${name} ${words}`.trim();
315
+ }
316
+ function buildPrompt(changedFiles, relatedFiles, filesPayload, pendingMergeHints = []) {
317
+ const allFiles = Object.keys(filesPayload);
318
+ const marker = '━'.repeat(60);
319
+ const fileBlocks = allFiles
320
+ .map((path) => `\n${marker}\nPATH: ${path}\n${marker}\n${filesPayload[path]}`)
321
+ .join('\n');
322
+ const lines = [
323
+ 'You are consolidating a knowledge context tree. The full contents of every file are included below — read them directly, then classify relationships. Do NOT use code_exec.',
324
+ '',
325
+ `Changed files (recently curated): ${JSON.stringify(changedFiles)}`,
326
+ `Related files (found via search): ${JSON.stringify(relatedFiles)}`,
327
+ `All available files: ${JSON.stringify(allFiles)}`,
328
+ ];
329
+ // Surface prior-dream merge suggestions as non-binding hints. LLM may still classify SKIP.
330
+ const relevantHints = pendingMergeHints.filter((h) => allFiles.includes(h.sourceFile) || allFiles.includes(h.mergeTarget));
331
+ if (relevantHints.length > 0) {
332
+ lines.push('', 'Note: A previous analysis suggested these files may be merge candidates:');
333
+ for (const h of relevantHints) {
334
+ lines.push(`- ${h.sourceFile} → merge into ${h.mergeTarget} (reason: ${h.reason})`);
335
+ }
336
+ lines.push('Consider these suggestions but make your own judgment.');
337
+ }
338
+ lines.push('', 'File contents:', fileBlocks, '', 'For each pair/group of related files, classify the relationship and recommend an action:', '- MERGE: Files are redundant/overlapping → combine into one, specify outputFile and mergedContent', '- TEMPORAL_UPDATE: File has contradictory/outdated info → rewrite with temporal narrative, specify updatedContent', '- CROSS_REFERENCE: Files are complementary → add cross-references (no content changes needed)', '- SKIP: Files are genuinely unrelated → no action needed', '', 'Respond with JSON matching this schema:', '```', '{ "actions": [{ "type": "MERGE"|"TEMPORAL_UPDATE"|"CROSS_REFERENCE"|"SKIP", "files": ["path1", ...], "reason": "...", "confidence?": 0.0-1.0, "mergedContent?": "...", "outputFile?": "...", "updatedContent?": "..." }] }', '```', '', 'Rules:', '- Default to MERGE when files share >50% of content or cover the same topic. SKIP only when files are genuinely on unrelated topics.', '- Returning all SKIP when duplicates exist is a failure, not caution.', '- For MERGE, choose the richer/more complete file as outputFile. The mergedContent should preserve all unique details from both sources.', '- For TEMPORAL_UPDATE, preserve all facts and add temporal context. Include confidence (0-1) indicating certainty that the update is correct.', '- For CROSS_REFERENCE, just list the files — the system will add frontmatter links.', '- Preserve all diagrams, tables, code examples, and structured data verbatim.');
339
+ return lines.join('\n');
340
+ }
341
+ async function executeAction(action, contextTreeDir, fileContents, reviewBackupStore) {
342
+ switch (action.type) {
343
+ case 'CROSS_REFERENCE': {
344
+ return executeCrossReference(action, contextTreeDir, fileContents, reviewBackupStore);
345
+ }
346
+ case 'MERGE': {
347
+ return executeMerge(action, contextTreeDir, fileContents, reviewBackupStore);
348
+ }
349
+ case 'SKIP': {
350
+ return undefined;
351
+ }
352
+ case 'TEMPORAL_UPDATE': {
353
+ return executeTemporalUpdate(action, contextTreeDir, fileContents, reviewBackupStore);
354
+ }
355
+ }
356
+ }
357
+ async function executeMerge(action, contextTreeDir, fileContents, reviewBackupStore) {
358
+ const outputFile = action.outputFile ?? action.files[0];
359
+ if (!action.mergedContent) {
360
+ throw new Error(`MERGE action missing mergedContent for ${outputFile}`);
361
+ }
362
+ const { mergedContent } = action;
363
+ // Capture previous texts
364
+ const previousTexts = {};
365
+ for (const file of action.files) {
366
+ const content = fileContents.get(file);
367
+ if (content !== undefined) {
368
+ previousTexts[file] = content;
369
+ }
370
+ }
371
+ // Create review backups before destructive writes (MERGE always needs review)
372
+ if (reviewBackupStore) {
373
+ await Promise.all(Object.entries(previousTexts).map(([file, content]) => reviewBackupStore.save(file, content).catch(() => { })));
374
+ }
375
+ // Add consolidation metadata frontmatter, then write atomically
376
+ const sourceFiles = action.files.filter((f) => f !== outputFile);
377
+ /* eslint-disable camelcase */
378
+ const consolidationFm = {
379
+ consolidated_at: new Date().toISOString(),
380
+ consolidated_from: sourceFiles.map((f) => ({ date: new Date().toISOString(), path: f, reason: action.reason })),
381
+ };
382
+ /* eslint-enable camelcase */
383
+ const contentWithFm = addFrontmatterFields(mergedContent, consolidationFm);
384
+ await atomicWrite(join(contextTreeDir, outputFile), contentWithFm);
385
+ // Delete source files (except output target)
386
+ const toDelete = action.files.filter((f) => f !== outputFile);
387
+ await Promise.all(toDelete.map((f) => unlink(join(contextTreeDir, f)).catch(() => { })));
388
+ // Determine needsReview
389
+ const needsReview = determineNeedsReview('MERGE', action.files, fileContents);
390
+ return {
391
+ action: 'MERGE',
392
+ inputFiles: action.files,
393
+ needsReview,
394
+ outputFile,
395
+ previousTexts,
396
+ reason: action.reason,
397
+ type: 'CONSOLIDATE',
398
+ };
399
+ }
400
+ async function executeTemporalUpdate(action, contextTreeDir, fileContents, reviewBackupStore) {
401
+ const targetFile = action.files[0];
402
+ if (!action.updatedContent) {
403
+ throw new Error(`TEMPORAL_UPDATE action missing updatedContent for ${targetFile}`);
404
+ }
405
+ const { updatedContent } = action;
406
+ // Capture previous text
407
+ const previousTexts = {};
408
+ const original = fileContents.get(targetFile);
409
+ if (original !== undefined) {
410
+ previousTexts[targetFile] = original;
411
+ }
412
+ const needsReview = determineNeedsReview('TEMPORAL_UPDATE', action.files, fileContents, action.confidence);
413
+ // Create review backup only when the operation needs human review
414
+ if (reviewBackupStore && original !== undefined && needsReview) {
415
+ try {
416
+ await reviewBackupStore.save(targetFile, original);
417
+ }
418
+ catch {
419
+ // Best-effort: backup failure must not block update
420
+ }
421
+ }
422
+ // Add consolidation timestamp, then write atomically
423
+ // eslint-disable-next-line camelcase
424
+ const contentWithFm = addFrontmatterFields(updatedContent, { consolidated_at: new Date().toISOString() });
425
+ await atomicWrite(join(contextTreeDir, targetFile), contentWithFm);
426
+ return {
427
+ action: 'TEMPORAL_UPDATE',
428
+ inputFiles: action.files,
429
+ needsReview,
430
+ previousTexts,
431
+ reason: action.reason,
432
+ type: 'CONSOLIDATE',
433
+ };
434
+ }
435
+ async function executeCrossReference(action, contextTreeDir, fileContents, reviewBackupStore) {
436
+ const previousTexts = {};
437
+ for (const file of action.files) {
438
+ const content = fileContents.get(file);
439
+ if (content !== undefined) {
440
+ previousTexts[file] = content;
441
+ }
442
+ }
443
+ const needsReview = determineNeedsReview('CROSS_REFERENCE', action.files, fileContents);
444
+ if (needsReview && reviewBackupStore) {
445
+ await Promise.all(Object.entries(previousTexts).map(([file, content]) => reviewBackupStore.save(file, content).catch(() => { })));
446
+ }
447
+ // For each file, add the other files to its related frontmatter
448
+ await Promise.all(action.files.map((file) => {
449
+ const otherFiles = action.files.filter((f) => f !== file);
450
+ return addRelatedLinks(join(contextTreeDir, file), otherFiles);
451
+ }));
452
+ return {
453
+ action: 'CROSS_REFERENCE',
454
+ inputFiles: action.files,
455
+ needsReview,
456
+ previousTexts,
457
+ reason: action.reason,
458
+ type: 'CONSOLIDATE',
459
+ };
460
+ }
461
+ async function addRelatedLinks(filePath, relatedPaths) {
462
+ let content;
463
+ try {
464
+ content = await readFile(filePath, 'utf8');
465
+ }
466
+ catch {
467
+ return; // File missing — skip
468
+ }
469
+ // Parse existing frontmatter
470
+ if (content.startsWith('---\n') || content.startsWith('---\r\n')) {
471
+ const endIndex = content.indexOf('\n---\n', 4);
472
+ const endIndexCrlf = content.indexOf('\r\n---\r\n', 5);
473
+ const actualEnd = endIndex === -1 ? endIndexCrlf : endIndex;
474
+ if (actualEnd >= 0) {
475
+ const yamlBlock = content.slice(4, actualEnd);
476
+ const bodyStart = content.indexOf('\n', actualEnd + 1) + 1;
477
+ const body = content.slice(bodyStart);
478
+ try {
479
+ const parsed = yamlLoad(yamlBlock);
480
+ if (parsed && typeof parsed === 'object') {
481
+ const existing = Array.isArray(parsed.related) ? parsed.related : [];
482
+ parsed.related = [...new Set([...existing, ...relatedPaths])];
483
+ const newYaml = yamlDump(parsed, { flowLevel: 1, lineWidth: -1, sortKeys: true }).trimEnd();
484
+ await atomicWrite(filePath, `---\n${newYaml}\n---\n${body}`);
485
+ return;
486
+ }
487
+ }
488
+ catch {
489
+ // YAML parse failure — skip
490
+ }
491
+ }
492
+ }
493
+ // No existing frontmatter — add one with related field
494
+ const yaml = yamlDump({ related: relatedPaths }, { flowLevel: 1, lineWidth: -1, sortKeys: true }).trimEnd();
495
+ await atomicWrite(filePath, `---\n${yaml}\n---\n${content}`);
496
+ }
497
+ function determineNeedsReview(actionType, files, fileContents, confidence) {
498
+ // MERGE always needs review
499
+ if (actionType === 'MERGE')
500
+ return true;
501
+ // TEMPORAL_UPDATE: needs review when confidence is low or absent
502
+ if (actionType === 'TEMPORAL_UPDATE')
503
+ return (confidence ?? 0) < 0.7;
504
+ // CROSS_REFERENCE: only if any file has core maturity
505
+ for (const file of files) {
506
+ const content = fileContents.get(file);
507
+ if (content) {
508
+ const scoring = parseFrontmatterScoring(content);
509
+ if (scoring?.maturity === 'core')
510
+ return true;
511
+ }
512
+ }
513
+ return false;
514
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Prune operation — identifies and archives stale/low-value context tree files.
3
+ *
4
+ * Flow:
5
+ * 1. Find candidates via two signals:
6
+ * A) Archive service importance decay (draft files with importance < 35)
7
+ * B) Mtime staleness (draft: 60 days, validated: 120 days, core: never)
8
+ * 2. Merge + dedup candidates, cap at 20 (stalest first)
9
+ * 3. Single LLM call to review candidates (ARCHIVE / KEEP / MERGE_INTO)
10
+ * 4. Execute decisions: archive, bump mtime, or defer merge
11
+ *
12
+ * Never throws — returns empty array on errors.
13
+ */
14
+ import type { ICipherAgent } from '../../../../agent/core/interfaces/i-cipher-agent.js';
15
+ import type { DreamOperation } from '../dream-log-schema.js';
16
+ import type { DreamState } from '../dream-state-schema.js';
17
+ export type PruneDeps = {
18
+ agent: ICipherAgent;
19
+ archiveService: {
20
+ archiveEntry(relativePath: string, agent: ICipherAgent, directory?: string): Promise<{
21
+ fullPath: string;
22
+ originalPath: string;
23
+ stubPath: string;
24
+ }>;
25
+ findArchiveCandidates(directory?: string): Promise<string[]>;
26
+ };
27
+ contextTreeDir: string;
28
+ dreamLogId: string;
29
+ dreamStateService: {
30
+ read(): Promise<DreamState>;
31
+ update(updater: (state: DreamState) => DreamState): Promise<DreamState>;
32
+ write(state: DreamState): Promise<void>;
33
+ };
34
+ projectRoot: string;
35
+ reviewBackupStore?: {
36
+ save(relativePath: string, content: string): Promise<void>;
37
+ };
38
+ signal?: AbortSignal;
39
+ taskId: string;
40
+ };
41
+ /**
42
+ * Run pruning on the context tree.
43
+ * Returns DreamOperation results (never throws).
44
+ */
45
+ export declare function prune(deps: PruneDeps): Promise<DreamOperation[]>;