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.
- package/.env.production +4 -6
- package/dist/agent/core/interfaces/i-cipher-agent.d.ts +1 -0
- package/dist/agent/infra/agent/cipher-agent.d.ts +1 -0
- package/dist/agent/infra/agent/cipher-agent.js +1 -0
- package/dist/oclif/commands/curate/view.js +5 -25
- package/dist/oclif/commands/dream.d.ts +18 -0
- package/dist/oclif/commands/dream.js +230 -0
- package/dist/oclif/commands/query-log/summary.d.ts +18 -0
- package/dist/oclif/commands/query-log/summary.js +75 -0
- package/dist/oclif/commands/query-log/view.d.ts +23 -0
- package/dist/oclif/commands/query-log/view.js +95 -0
- package/dist/oclif/lib/time-filter.d.ts +10 -0
- package/dist/oclif/lib/time-filter.js +21 -0
- package/dist/server/config/environment.d.ts +10 -3
- package/dist/server/config/environment.js +34 -15
- package/dist/server/constants.d.ts +5 -0
- package/dist/server/constants.js +7 -0
- package/dist/server/core/domain/entities/query-log-entry.d.ts +61 -0
- package/dist/server/core/domain/entities/query-log-entry.js +40 -0
- package/dist/server/core/domain/transport/schemas.d.ts +108 -7
- package/dist/server/core/domain/transport/schemas.js +34 -2
- package/dist/server/core/interfaces/executor/i-query-executor.d.ts +23 -2
- package/dist/server/core/interfaces/i-terminal.d.ts +3 -0
- package/dist/server/core/interfaces/i-terminal.js +1 -0
- package/dist/server/core/interfaces/storage/i-query-log-store.d.ts +23 -0
- package/dist/server/core/interfaces/storage/i-query-log-store.js +2 -0
- package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.d.ts +44 -0
- package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.js +1 -0
- package/dist/server/core/interfaces/usecase/i-query-log-use-case.d.ts +13 -0
- package/dist/server/core/interfaces/usecase/i-query-log-use-case.js +3 -0
- package/dist/server/infra/daemon/agent-process.js +79 -9
- package/dist/server/infra/daemon/brv-server.js +74 -5
- package/dist/server/infra/dream/dream-lock-service.d.ts +37 -0
- package/dist/server/infra/dream/dream-lock-service.js +88 -0
- package/dist/server/infra/dream/dream-log-schema.d.ts +966 -0
- package/dist/server/infra/dream/dream-log-schema.js +57 -0
- package/dist/server/infra/dream/dream-log-store.d.ts +55 -0
- package/dist/server/infra/dream/dream-log-store.js +141 -0
- package/dist/server/infra/dream/dream-response-schemas.d.ts +219 -0
- package/dist/server/infra/dream/dream-response-schemas.js +38 -0
- package/dist/server/infra/dream/dream-state-schema.d.ts +67 -0
- package/dist/server/infra/dream/dream-state-schema.js +23 -0
- package/dist/server/infra/dream/dream-state-service.d.ts +38 -0
- package/dist/server/infra/dream/dream-state-service.js +91 -0
- package/dist/server/infra/dream/dream-trigger.d.ts +46 -0
- package/dist/server/infra/dream/dream-trigger.js +65 -0
- package/dist/server/infra/dream/dream-undo.d.ts +38 -0
- package/dist/server/infra/dream/dream-undo.js +293 -0
- package/dist/server/infra/dream/operations/consolidate.d.ts +52 -0
- package/dist/server/infra/dream/operations/consolidate.js +514 -0
- package/dist/server/infra/dream/operations/prune.d.ts +45 -0
- package/dist/server/infra/dream/operations/prune.js +362 -0
- package/dist/server/infra/dream/operations/synthesize.d.ts +37 -0
- package/dist/server/infra/dream/operations/synthesize.js +278 -0
- package/dist/server/infra/dream/parse-dream-response.d.ts +11 -0
- package/dist/server/infra/dream/parse-dream-response.js +35 -0
- package/dist/server/infra/executor/curate-executor.js +10 -0
- package/dist/server/infra/executor/dream-executor.d.ts +97 -0
- package/dist/server/infra/executor/dream-executor.js +431 -0
- package/dist/server/infra/executor/query-executor.d.ts +2 -2
- package/dist/server/infra/executor/query-executor.js +92 -22
- package/dist/server/infra/process/feature-handlers.js +10 -6
- package/dist/server/infra/process/query-log-handler.d.ts +42 -0
- package/dist/server/infra/process/query-log-handler.js +150 -0
- package/dist/server/infra/process/task-router.d.ts +40 -0
- package/dist/server/infra/process/task-router.js +67 -9
- package/dist/server/infra/process/transport-handlers.d.ts +4 -0
- package/dist/server/infra/process/transport-handlers.js +1 -0
- package/dist/server/infra/storage/file-curate-log-store.js +1 -1
- package/dist/server/infra/storage/file-query-log-store.d.ts +81 -0
- package/dist/server/infra/storage/file-query-log-store.js +249 -0
- package/dist/server/infra/transport/handlers/config-handler.js +1 -1
- package/dist/server/infra/usecase/curate-log-use-case.js +7 -3
- package/dist/server/infra/usecase/query-log-summary-narrative-formatter.d.ts +15 -0
- package/dist/server/infra/usecase/query-log-summary-narrative-formatter.js +79 -0
- package/dist/server/infra/usecase/query-log-summary-use-case.d.ts +13 -0
- package/dist/server/infra/usecase/query-log-summary-use-case.js +217 -0
- package/dist/server/infra/usecase/query-log-use-case.d.ts +31 -0
- package/dist/server/infra/usecase/query-log-use-case.js +128 -0
- package/dist/server/utils/log-format-utils.d.ts +5 -0
- package/dist/server/utils/log-format-utils.js +23 -0
- package/dist/shared/transport/events/config-events.d.ts +1 -1
- package/oclif.manifest.json +258 -3
- 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[]>;
|