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,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[]>;
|