byterover-cli 3.5.0 → 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 (91) 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/mcp/mcp-server.js +3 -0
  63. package/dist/server/infra/mcp/tools/brv-curate-tool.js +3 -7
  64. package/dist/server/infra/mcp/tools/brv-query-tool.js +3 -7
  65. package/dist/server/infra/mcp/tools/index.d.ts +1 -0
  66. package/dist/server/infra/mcp/tools/index.js +1 -0
  67. package/dist/server/infra/mcp/tools/shared-schema.d.ts +3 -0
  68. package/dist/server/infra/mcp/tools/shared-schema.js +17 -0
  69. package/dist/server/infra/process/feature-handlers.js +10 -6
  70. package/dist/server/infra/process/query-log-handler.d.ts +42 -0
  71. package/dist/server/infra/process/query-log-handler.js +150 -0
  72. package/dist/server/infra/process/task-router.d.ts +40 -0
  73. package/dist/server/infra/process/task-router.js +67 -9
  74. package/dist/server/infra/process/transport-handlers.d.ts +4 -0
  75. package/dist/server/infra/process/transport-handlers.js +1 -0
  76. package/dist/server/infra/storage/file-curate-log-store.js +1 -1
  77. package/dist/server/infra/storage/file-query-log-store.d.ts +81 -0
  78. package/dist/server/infra/storage/file-query-log-store.js +249 -0
  79. package/dist/server/infra/transport/handlers/config-handler.js +1 -1
  80. package/dist/server/infra/usecase/curate-log-use-case.js +7 -3
  81. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.d.ts +15 -0
  82. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.js +79 -0
  83. package/dist/server/infra/usecase/query-log-summary-use-case.d.ts +13 -0
  84. package/dist/server/infra/usecase/query-log-summary-use-case.js +217 -0
  85. package/dist/server/infra/usecase/query-log-use-case.d.ts +31 -0
  86. package/dist/server/infra/usecase/query-log-use-case.js +128 -0
  87. package/dist/server/utils/log-format-utils.d.ts +5 -0
  88. package/dist/server/utils/log-format-utils.js +23 -0
  89. package/dist/shared/transport/events/config-events.d.ts +1 -1
  90. package/oclif.manifest.json +439 -184
  91. package/package.json +1 -1
@@ -0,0 +1,91 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { AsyncMutex } from '../../../agent/infra/llm/context/async-mutex.js';
5
+ import { DreamStateSchema, EMPTY_DREAM_STATE } from './dream-state-schema.js';
6
+ const STATE_FILENAME = 'dream-state.json';
7
+ // Module-level mutex registry keyed by absolute state file path.
8
+ // The agent process can hold up to AGENT_MAX_CONCURRENT_TASKS concurrent curate tasks
9
+ // AND a dream task running concurrently, so read-modify-write on dream-state.json must
10
+ // be serialized across all writers — incrementCurationCount, dream-executor's step 7
11
+ // reset, and consolidate's pendingMerges clear all share this mutex via update().
12
+ // Independent DreamStateService instances pointing at the same file share a mutex.
13
+ //
14
+ // Note: this Map grows monotonically — one entry per unique absolute state-file
15
+ // path ever instantiated. In practice it is bounded by the number of registered
16
+ // projects in the agent process (typically single digits), so memory growth is
17
+ // negligible. If the daemon ever needs to support project unregister, evict
18
+ // entries here on unregister to keep the registry tight.
19
+ const stateMutexes = new Map();
20
+ function getStateMutex(stateFilePath) {
21
+ const key = resolve(stateFilePath);
22
+ let mutex = stateMutexes.get(key);
23
+ if (!mutex) {
24
+ mutex = new AsyncMutex();
25
+ stateMutexes.set(key, mutex);
26
+ }
27
+ return mutex;
28
+ }
29
+ /**
30
+ * File-based persistence for dream state.
31
+ *
32
+ * Reads return EMPTY_DREAM_STATE on missing/corrupt files (fail-open).
33
+ * Writes are atomic (tmp → rename) and validate with Zod before persisting.
34
+ */
35
+ export class DreamStateService {
36
+ stateFilePath;
37
+ constructor(opts) {
38
+ this.stateFilePath = join(opts.baseDir, STATE_FILENAME);
39
+ }
40
+ /**
41
+ * Read-modify-write under a per-file mutex. Serializes concurrent increments
42
+ * from parallel curate tasks within the same agent process so no updates are lost.
43
+ */
44
+ async incrementCurationCount() {
45
+ await this.update((state) => ({ ...state, curationsSinceDream: state.curationsSinceDream + 1 }));
46
+ }
47
+ async read() {
48
+ try {
49
+ const raw = await readFile(this.stateFilePath, 'utf8');
50
+ const parsed = DreamStateSchema.safeParse(JSON.parse(raw));
51
+ if (!parsed.success)
52
+ return { ...EMPTY_DREAM_STATE, pendingMerges: [] };
53
+ return parsed.data;
54
+ }
55
+ catch {
56
+ return { ...EMPTY_DREAM_STATE, pendingMerges: [] };
57
+ }
58
+ }
59
+ /**
60
+ * Generic read-modify-write under the same per-file mutex used by
61
+ * incrementCurationCount. All writers that mutate dream-state.json based on
62
+ * its current contents (e.g. dream-executor step 7's reset, consolidate's
63
+ * pendingMerges clear) MUST go through this method, otherwise concurrent
64
+ * increments can be silently overwritten.
65
+ */
66
+ async update(updater) {
67
+ const mutex = getStateMutex(this.stateFilePath);
68
+ return mutex.withLock(async () => {
69
+ const state = await this.read();
70
+ const next = updater(state);
71
+ await this.write(next);
72
+ return next;
73
+ });
74
+ }
75
+ /**
76
+ * Atomic write (tmp file → rename). Does NOT acquire the per-file mutex.
77
+ *
78
+ * Direct callers that perform a logical read-modify-write by pairing
79
+ * {@link read} + write bypass serialization and may lose updates from
80
+ * concurrent writers. Use {@link update} for any RMW that depends on the
81
+ * current state.
82
+ */
83
+ async write(state) {
84
+ DreamStateSchema.parse(state);
85
+ const dir = dirname(this.stateFilePath);
86
+ await mkdir(dir, { recursive: true });
87
+ const tmpPath = `${this.stateFilePath}.${randomUUID()}.tmp`;
88
+ await writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf8');
89
+ await rename(tmpPath, this.stateFilePath);
90
+ }
91
+ }
@@ -0,0 +1,46 @@
1
+ import type { DreamLockService } from './dream-lock-service.js';
2
+ import type { DreamStateService } from './dream-state-service.js';
3
+ type DreamTriggerDeps = {
4
+ dreamLockService: Pick<DreamLockService, 'tryAcquire'>;
5
+ dreamStateService: Pick<DreamStateService, 'read'>;
6
+ getQueueLength: (projectPath: string) => number;
7
+ };
8
+ type DreamTriggerOptions = {
9
+ minCurations?: number;
10
+ minHours?: number;
11
+ };
12
+ export type DreamEligibility = {
13
+ eligible: false;
14
+ reason: string;
15
+ } | {
16
+ eligible: true;
17
+ priorMtime: number;
18
+ };
19
+ type PreCheckResult = {
20
+ eligible: false;
21
+ reason: string;
22
+ } | {
23
+ eligible: true;
24
+ };
25
+ /**
26
+ * Four-gate trigger for dream eligibility.
27
+ *
28
+ * Gates 1-3 (time, activity, queue) are skipped with force=true.
29
+ * Gate 4 (lock) always runs — prevents concurrent dreams.
30
+ */
31
+ export declare class DreamTrigger {
32
+ private readonly deps;
33
+ private readonly options;
34
+ constructor(deps: DreamTriggerDeps, options?: DreamTriggerOptions);
35
+ /**
36
+ * Lightweight eligibility pre-check (gates 1-3 only, no lock).
37
+ *
38
+ * Used by the daemon to decide whether to dispatch a dream task
39
+ * without acquiring the PID-based lock (which must be acquired
40
+ * by the agent process that actually runs the dream).
41
+ */
42
+ checkEligibility(projectPath: string): Promise<PreCheckResult>;
43
+ tryStartDream(projectPath: string, force?: boolean): Promise<DreamEligibility>;
44
+ private checkGates1to3;
45
+ }
46
+ export {};
@@ -0,0 +1,65 @@
1
+ const DEFAULT_MIN_HOURS = 12;
2
+ const DEFAULT_MIN_CURATIONS = 3;
3
+ /**
4
+ * Four-gate trigger for dream eligibility.
5
+ *
6
+ * Gates 1-3 (time, activity, queue) are skipped with force=true.
7
+ * Gate 4 (lock) always runs — prevents concurrent dreams.
8
+ */
9
+ export class DreamTrigger {
10
+ deps;
11
+ options;
12
+ constructor(deps, options = {}) {
13
+ this.deps = deps;
14
+ this.options = options;
15
+ }
16
+ /**
17
+ * Lightweight eligibility pre-check (gates 1-3 only, no lock).
18
+ *
19
+ * Used by the daemon to decide whether to dispatch a dream task
20
+ * without acquiring the PID-based lock (which must be acquired
21
+ * by the agent process that actually runs the dream).
22
+ */
23
+ async checkEligibility(projectPath) {
24
+ return this.checkGates1to3(projectPath);
25
+ }
26
+ async tryStartDream(projectPath, force = false) {
27
+ if (!force) {
28
+ const preCheck = await this.checkGates1to3(projectPath);
29
+ if (!preCheck.eligible)
30
+ return preCheck;
31
+ }
32
+ // Gate 4: Lock (NEVER skipped, even with force)
33
+ const lockResult = await this.deps.dreamLockService.tryAcquire();
34
+ if (!lockResult.acquired) {
35
+ return { eligible: false, reason: 'Lock held by another dream process' };
36
+ }
37
+ return { eligible: true, priorMtime: lockResult.priorMtime };
38
+ }
39
+ async checkGates1to3(projectPath) {
40
+ const minHours = this.options.minHours ?? DEFAULT_MIN_HOURS;
41
+ const minCurations = this.options.minCurations ?? DEFAULT_MIN_CURATIONS;
42
+ // Gates 1+2: time and activity (share one file read)
43
+ const state = await this.deps.dreamStateService.read();
44
+ // Gate 1: Time
45
+ if (state.lastDreamAt !== null) {
46
+ const hoursSince = (Date.now() - new Date(state.lastDreamAt).getTime()) / (1000 * 60 * 60);
47
+ if (hoursSince < minHours) {
48
+ return { eligible: false, reason: `Too recent (${hoursSince.toFixed(1)}h < ${minHours}h)` };
49
+ }
50
+ }
51
+ // Gate 2: Activity
52
+ if (state.curationsSinceDream < minCurations) {
53
+ return {
54
+ eligible: false,
55
+ reason: `Not enough activity (${state.curationsSinceDream} < ${minCurations} curations)`,
56
+ };
57
+ }
58
+ // Gate 3: Queue
59
+ const queueLength = this.deps.getQueueLength(projectPath);
60
+ if (queueLength > 0) {
61
+ return { eligible: false, reason: `Queue not empty (${queueLength} tasks pending)` };
62
+ }
63
+ return { eligible: true };
64
+ }
65
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Dream Undo — reverts the last dream's file changes using previousTexts from the dream log.
3
+ *
4
+ * Runs directly from CLI (no daemon/agent needed). Pure file I/O.
5
+ * Only undoes the LAST dream — not a history stack.
6
+ */
7
+ import type { ICurateLogStore } from '../../core/interfaces/storage/i-curate-log-store.js';
8
+ import type { IReviewBackupStore } from '../../core/interfaces/storage/i-review-backup-store.js';
9
+ import type { DreamLogEntry } from './dream-log-schema.js';
10
+ import type { DreamState } from './dream-state-schema.js';
11
+ export type DreamUndoDeps = {
12
+ archiveService?: {
13
+ restoreEntry(stubPath: string, directory?: string): Promise<string>;
14
+ };
15
+ contextTreeDir: string;
16
+ curateLogStore?: Pick<ICurateLogStore, 'batchUpdateOperationReviewStatus' | 'list'>;
17
+ dreamLogStore: {
18
+ getById(id: string): Promise<DreamLogEntry | null>;
19
+ save(entry: DreamLogEntry): Promise<void>;
20
+ };
21
+ dreamStateService: {
22
+ read(): Promise<DreamState>;
23
+ write(state: DreamState): Promise<void>;
24
+ };
25
+ manifestService: {
26
+ buildManifest(dir?: string): Promise<unknown>;
27
+ };
28
+ projectRoot?: string;
29
+ reviewBackupStore?: Pick<IReviewBackupStore, 'delete'>;
30
+ };
31
+ export type DreamUndoResult = {
32
+ deletedFiles: string[];
33
+ dreamId: string;
34
+ errors: string[];
35
+ restoredArchives: string[];
36
+ restoredFiles: string[];
37
+ };
38
+ export declare function undoLastDream(deps: DreamUndoDeps): Promise<DreamUndoResult>;
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Dream Undo — reverts the last dream's file changes using previousTexts from the dream log.
3
+ *
4
+ * Runs directly from CLI (no daemon/agent needed). Pure file I/O.
5
+ * Only undoes the LAST dream — not a history stack.
6
+ */
7
+ import { mkdir, unlink, writeFile } from 'node:fs/promises';
8
+ import { dirname, resolve } from 'node:path';
9
+ import { isDescendantOf } from '../../utils/path-utils.js';
10
+ export async function undoLastDream(deps) {
11
+ const { contextTreeDir, dreamLogStore, dreamStateService, manifestService } = deps;
12
+ // ── Precondition checks ─────────────────────────────────────────────────
13
+ const state = await dreamStateService.read();
14
+ if (!state.lastDreamLogId) {
15
+ throw new Error('No dream to undo');
16
+ }
17
+ const log = await dreamLogStore.getById(state.lastDreamLogId);
18
+ if (!log) {
19
+ throw new Error(`Dream log not found: ${state.lastDreamLogId}`);
20
+ }
21
+ if (log.status === 'undone') {
22
+ throw new Error(`Dream already undone: ${state.lastDreamLogId}`);
23
+ }
24
+ if (log.status !== 'completed' && log.status !== 'partial') {
25
+ throw new Error(`Cannot undo dream with status: ${log.status}`);
26
+ }
27
+ // ── Reverse operations ──────────────────────────────────────────────────
28
+ const result = {
29
+ deletedFiles: [],
30
+ dreamId: log.id,
31
+ errors: [],
32
+ restoredArchives: [],
33
+ restoredFiles: [],
34
+ };
35
+ // Track pending merges to remove (for PRUNE/SUGGEST_MERGE)
36
+ const mergesToRemove = [];
37
+ const reversed = [...log.operations].reverse();
38
+ for (const op of reversed) {
39
+ try {
40
+ // eslint-disable-next-line no-await-in-loop
41
+ await undoOperation(op, { contextTreeDir, deps, mergesToRemove, result });
42
+ }
43
+ catch (error) {
44
+ result.errors.push(error instanceof Error ? error.message : String(error));
45
+ }
46
+ }
47
+ // ── Post-undo: clean up review entries and backups ──────────────────────
48
+ await cleanupReviewEntries(log, deps);
49
+ // ── Post-undo: rebuild manifest ─────────────────────────────────────────
50
+ try {
51
+ await manifestService.buildManifest();
52
+ }
53
+ catch (error) {
54
+ result.errors.push(`Manifest rebuild failed: ${error instanceof Error ? error.message : String(error)}`);
55
+ }
56
+ // ── Post-undo: mark log as undone ───────────────────────────────────────
57
+ const undoneLog = {
58
+ completedAt: log.completedAt,
59
+ id: log.id,
60
+ operations: log.operations,
61
+ startedAt: log.startedAt,
62
+ status: 'undone',
63
+ summary: log.summary,
64
+ taskId: log.taskId,
65
+ trigger: log.trigger,
66
+ undoneAt: Date.now(),
67
+ };
68
+ await dreamLogStore.save(undoneLog);
69
+ // ── Post-undo: rewind dream state ───────────────────────────────────────
70
+ let { pendingMerges } = state;
71
+ if (mergesToRemove.length > 0) {
72
+ pendingMerges = (pendingMerges ?? []).filter((pm) => !mergesToRemove.some((rm) => rm.sourceFile === pm.sourceFile && rm.mergeTarget === pm.mergeTarget));
73
+ }
74
+ // Undo runs in the CLI process. The in-process mutex guarding
75
+ // update() lives in the daemon, so using update() here wouldn't
76
+ // synchronize with a concurrent daemon-side incrementCurationCount anyway —
77
+ // write() is acceptable. If daemon-side undo is ever added, switch to
78
+ // update() to serialize with other writers in that process.
79
+ await dreamStateService.write({
80
+ ...state,
81
+ lastDreamAt: null,
82
+ pendingMerges,
83
+ totalDreams: Math.max(0, state.totalDreams - 1),
84
+ });
85
+ return result;
86
+ }
87
+ /** Unlink a file, ignoring ENOENT (already gone) but rethrowing other errors. */
88
+ async function unlinkSafe(filePath) {
89
+ try {
90
+ await unlink(filePath);
91
+ }
92
+ catch (error) {
93
+ if (error.code !== 'ENOENT')
94
+ throw error;
95
+ }
96
+ }
97
+ /** Resolve a relative path within contextTreeDir, rejecting traversal outside the tree. */
98
+ function safePath(contextTreeDir, relativePath) {
99
+ const full = resolve(contextTreeDir, relativePath);
100
+ if (!isDescendantOf(full, contextTreeDir)) {
101
+ throw new Error(`Path traversal blocked: ${relativePath}`);
102
+ }
103
+ return full;
104
+ }
105
+ async function undoOperation(op, ctx) {
106
+ switch (op.type) {
107
+ case 'CONSOLIDATE': {
108
+ await undoConsolidate(op, ctx.contextTreeDir, ctx.result);
109
+ break;
110
+ }
111
+ case 'PRUNE': {
112
+ await undoPrune(op, ctx);
113
+ break;
114
+ }
115
+ case 'SYNTHESIZE': {
116
+ await undoSynthesize(op, ctx.contextTreeDir, ctx.result);
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ async function undoConsolidate(op, contextTreeDir, result) {
122
+ switch (op.action) {
123
+ case 'CROSS_REFERENCE': {
124
+ if (!op.previousTexts || Object.keys(op.previousTexts).length === 0)
125
+ break;
126
+ for (const [filePath, content] of Object.entries(op.previousTexts)) {
127
+ const fullPath = safePath(contextTreeDir, filePath);
128
+ // eslint-disable-next-line no-await-in-loop
129
+ await mkdir(dirname(fullPath), { recursive: true });
130
+ // eslint-disable-next-line no-await-in-loop
131
+ await writeFile(fullPath, content, 'utf8');
132
+ result.restoredFiles.push(filePath);
133
+ }
134
+ break;
135
+ }
136
+ case 'MERGE': {
137
+ if (!op.previousTexts || Object.keys(op.previousTexts).length === 0) {
138
+ throw new Error(`Cannot undo MERGE: missing previousTexts for ${op.outputFile ?? op.inputFiles[0]}`);
139
+ }
140
+ // Restore all source files from previousTexts
141
+ for (const [filePath, content] of Object.entries(op.previousTexts)) {
142
+ const fullPath = safePath(contextTreeDir, filePath);
143
+ // eslint-disable-next-line no-await-in-loop
144
+ await mkdir(dirname(fullPath), { recursive: true });
145
+ // eslint-disable-next-line no-await-in-loop
146
+ await writeFile(fullPath, content, 'utf8');
147
+ result.restoredFiles.push(filePath);
148
+ }
149
+ // Delete merged output if it wasn't an original source
150
+ if (op.outputFile && !op.previousTexts[op.outputFile]) {
151
+ await unlinkSafe(safePath(contextTreeDir, op.outputFile));
152
+ result.deletedFiles.push(op.outputFile);
153
+ }
154
+ break;
155
+ }
156
+ case 'TEMPORAL_UPDATE': {
157
+ if (!op.previousTexts || Object.keys(op.previousTexts).length === 0) {
158
+ throw new Error(`Cannot undo TEMPORAL_UPDATE: missing previousTexts for ${op.inputFiles[0]}`);
159
+ }
160
+ for (const [filePath, content] of Object.entries(op.previousTexts)) {
161
+ const fullPath = safePath(contextTreeDir, filePath);
162
+ // eslint-disable-next-line no-await-in-loop
163
+ await mkdir(dirname(fullPath), { recursive: true });
164
+ // eslint-disable-next-line no-await-in-loop
165
+ await writeFile(fullPath, content, 'utf8');
166
+ result.restoredFiles.push(filePath);
167
+ }
168
+ break;
169
+ }
170
+ }
171
+ }
172
+ async function undoSynthesize(op, contextTreeDir, result) {
173
+ // UPDATE modified a pre-existing file — can't undo without previousTexts (not captured by SYNTHESIZE)
174
+ if (op.action === 'UPDATE') {
175
+ throw new Error(`Cannot undo SYNTHESIZE/UPDATE: previousTexts not captured for ${op.outputFile}`);
176
+ }
177
+ // CREATE — delete the synthesized file
178
+ await unlinkSafe(safePath(contextTreeDir, op.outputFile));
179
+ result.deletedFiles.push(op.outputFile);
180
+ }
181
+ function reviewTargetKey(target) {
182
+ return `${target.type}:${target.path}`;
183
+ }
184
+ function collectReviewTargets(operations) {
185
+ const targets = [];
186
+ for (const op of operations) {
187
+ if (!op.needsReview)
188
+ continue;
189
+ if (op.type === 'PRUNE' && op.action === 'ARCHIVE') {
190
+ targets.push({ path: op.file, type: 'DELETE' });
191
+ continue;
192
+ }
193
+ if (op.type === 'CONSOLIDATE' && op.action === 'MERGE') {
194
+ targets.push({ path: op.outputFile ?? op.inputFiles[0], type: 'MERGE' });
195
+ continue;
196
+ }
197
+ if (op.type === 'CONSOLIDATE') {
198
+ targets.push({ path: op.inputFiles[0], type: 'UPDATE' });
199
+ continue;
200
+ }
201
+ if (op.type === 'SYNTHESIZE') {
202
+ targets.push({ path: op.outputFile, type: op.action === 'CREATE' ? 'ADD' : 'UPDATE' });
203
+ }
204
+ }
205
+ return targets;
206
+ }
207
+ function buildReviewStatusUpdates(entry) {
208
+ return entry.operations
209
+ .map((op, operationIndex) => op.reviewStatus && op.reviewStatus !== 'rejected'
210
+ ? { operationIndex, reviewStatus: 'rejected' }
211
+ : null)
212
+ .filter((update) => update !== null);
213
+ }
214
+ function matchLegacyDreamReviewEntry(entries, operations) {
215
+ const expected = collectReviewTargets(operations).map((target) => reviewTargetKey(target)).sort();
216
+ if (expected.length === 0)
217
+ return [];
218
+ const matches = entries.filter((entry) => {
219
+ const actual = entry.operations.map((op) => reviewTargetKey({ path: op.path, type: op.type })).sort();
220
+ return actual.length === expected.length && actual.every((value, index) => value === expected[index]);
221
+ });
222
+ return matches.length === 1 ? matches : [];
223
+ }
224
+ async function cleanupReviewEntries(log, deps) {
225
+ const hasReviewOps = log.operations.some((op) => op.needsReview);
226
+ // Mark curate log entries from dream as rejected.
227
+ // Runs whenever the dream had any needsReview ops — even if previousTexts is absent
228
+ // (e.g. legacy CROSS_REFERENCE entries), so the pending review task is always cleaned up.
229
+ if (deps.curateLogStore && hasReviewOps) {
230
+ try {
231
+ const entries = await deps.curateLogStore.list({ status: ['completed'] });
232
+ const dreamEntries = entries.filter((entry) => entry.input.context === 'dream');
233
+ const matchedEntries = log.taskId
234
+ ? dreamEntries.filter((entry) => entry.taskId === log.taskId)
235
+ : matchLegacyDreamReviewEntry(dreamEntries, log.operations);
236
+ for (const entry of matchedEntries) {
237
+ const updates = buildReviewStatusUpdates(entry);
238
+ if (updates.length === 0)
239
+ continue;
240
+ // eslint-disable-next-line no-await-in-loop
241
+ await deps.curateLogStore.batchUpdateOperationReviewStatus(entry.id, updates);
242
+ }
243
+ }
244
+ catch {
245
+ // Fail-open: review cleanup must not block undo
246
+ }
247
+ }
248
+ // Delete review backups for affected files (collected from previousTexts keys,
249
+ // which mirror what the backup store received during the dream).
250
+ if (deps.reviewBackupStore) {
251
+ const reviewFilePaths = new Set();
252
+ for (const op of log.operations) {
253
+ if (!op.needsReview)
254
+ continue;
255
+ if (op.type === 'PRUNE')
256
+ reviewFilePaths.add(op.file);
257
+ if (op.type === 'CONSOLIDATE' && op.previousTexts) {
258
+ for (const file of Object.keys(op.previousTexts))
259
+ reviewFilePaths.add(file);
260
+ }
261
+ if (op.type === 'SYNTHESIZE')
262
+ reviewFilePaths.add(op.outputFile);
263
+ }
264
+ if (reviewFilePaths.size > 0) {
265
+ await Promise.all([...reviewFilePaths].map((file) => deps.reviewBackupStore.delete(file).catch(() => { })));
266
+ }
267
+ }
268
+ }
269
+ async function undoPrune(op, ctx) {
270
+ switch (op.action) {
271
+ case 'ARCHIVE': {
272
+ if (!ctx.deps.archiveService) {
273
+ throw new Error(`Cannot undo PRUNE/ARCHIVE: no archive service available for ${op.file}`);
274
+ }
275
+ if (!op.stubPath) {
276
+ throw new Error(`Cannot undo PRUNE/ARCHIVE: missing stubPath for ${op.file}`);
277
+ }
278
+ const restored = await ctx.deps.archiveService.restoreEntry(op.stubPath, ctx.deps.projectRoot);
279
+ ctx.result.restoredArchives.push(restored);
280
+ break;
281
+ }
282
+ case 'KEEP': {
283
+ // No-op — nothing was changed
284
+ break;
285
+ }
286
+ case 'SUGGEST_MERGE': {
287
+ if (op.mergeTarget) {
288
+ ctx.mergesToRemove.push({ mergeTarget: op.mergeTarget, sourceFile: op.file });
289
+ }
290
+ break;
291
+ }
292
+ }
293
+ }
@@ -0,0 +1,52 @@
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 type { ICipherAgent } from '../../../../agent/core/interfaces/i-cipher-agent.js';
14
+ import type { DreamOperation } from '../dream-log-schema.js';
15
+ import type { DreamState } from '../dream-state-schema.js';
16
+ export type ConsolidateDeps = {
17
+ agent: ICipherAgent;
18
+ contextTreeDir: string;
19
+ /**
20
+ * Optional. When present, pendingMerges from prior dreams (written by prune's
21
+ * SUGGEST_MERGE) are consumed at the start of consolidate: source files are
22
+ * added to changedFiles, their target/reason is passed to the LLM as a hint,
23
+ * and the pendingMerges list is cleared.
24
+ */
25
+ dreamStateService?: {
26
+ read(): Promise<DreamState>;
27
+ update(updater: (state: DreamState) => DreamState): Promise<DreamState>;
28
+ write(state: DreamState): Promise<void>;
29
+ };
30
+ reviewBackupStore?: {
31
+ save(relativePath: string, content: string): Promise<void>;
32
+ };
33
+ searchService: {
34
+ search(query: string, options?: {
35
+ limit?: number;
36
+ scope?: string;
37
+ }): Promise<{
38
+ results: Array<{
39
+ path: string;
40
+ score: number;
41
+ title: string;
42
+ }>;
43
+ }>;
44
+ };
45
+ signal?: AbortSignal;
46
+ taskId: string;
47
+ };
48
+ /**
49
+ * Run the consolidation operation on changed files.
50
+ * Returns DreamOperation results (never throws).
51
+ */
52
+ export declare function consolidate(changedFiles: string[], deps: ConsolidateDeps): Promise<DreamOperation[]>;