byterover-cli 3.5.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.env.production +4 -6
  2. package/dist/agent/core/interfaces/i-cipher-agent.d.ts +1 -0
  3. package/dist/agent/infra/agent/cipher-agent.d.ts +1 -0
  4. package/dist/agent/infra/agent/cipher-agent.js +1 -0
  5. package/dist/oclif/commands/curate/view.js +5 -25
  6. package/dist/oclif/commands/dream.d.ts +18 -0
  7. package/dist/oclif/commands/dream.js +230 -0
  8. package/dist/oclif/commands/query-log/summary.d.ts +18 -0
  9. package/dist/oclif/commands/query-log/summary.js +75 -0
  10. package/dist/oclif/commands/query-log/view.d.ts +23 -0
  11. package/dist/oclif/commands/query-log/view.js +95 -0
  12. package/dist/oclif/lib/time-filter.d.ts +10 -0
  13. package/dist/oclif/lib/time-filter.js +21 -0
  14. package/dist/server/config/environment.d.ts +10 -3
  15. package/dist/server/config/environment.js +34 -15
  16. package/dist/server/constants.d.ts +5 -0
  17. package/dist/server/constants.js +7 -0
  18. package/dist/server/core/domain/entities/query-log-entry.d.ts +61 -0
  19. package/dist/server/core/domain/entities/query-log-entry.js +40 -0
  20. package/dist/server/core/domain/transport/schemas.d.ts +108 -7
  21. package/dist/server/core/domain/transport/schemas.js +34 -2
  22. package/dist/server/core/interfaces/executor/i-query-executor.d.ts +23 -2
  23. package/dist/server/core/interfaces/i-terminal.d.ts +3 -0
  24. package/dist/server/core/interfaces/i-terminal.js +1 -0
  25. package/dist/server/core/interfaces/storage/i-query-log-store.d.ts +23 -0
  26. package/dist/server/core/interfaces/storage/i-query-log-store.js +2 -0
  27. package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.d.ts +44 -0
  28. package/dist/server/core/interfaces/usecase/i-query-log-summary-use-case.js +1 -0
  29. package/dist/server/core/interfaces/usecase/i-query-log-use-case.d.ts +13 -0
  30. package/dist/server/core/interfaces/usecase/i-query-log-use-case.js +3 -0
  31. package/dist/server/infra/daemon/agent-process.js +79 -9
  32. package/dist/server/infra/daemon/brv-server.js +74 -5
  33. package/dist/server/infra/dream/dream-lock-service.d.ts +37 -0
  34. package/dist/server/infra/dream/dream-lock-service.js +88 -0
  35. package/dist/server/infra/dream/dream-log-schema.d.ts +966 -0
  36. package/dist/server/infra/dream/dream-log-schema.js +57 -0
  37. package/dist/server/infra/dream/dream-log-store.d.ts +55 -0
  38. package/dist/server/infra/dream/dream-log-store.js +141 -0
  39. package/dist/server/infra/dream/dream-response-schemas.d.ts +219 -0
  40. package/dist/server/infra/dream/dream-response-schemas.js +38 -0
  41. package/dist/server/infra/dream/dream-state-schema.d.ts +67 -0
  42. package/dist/server/infra/dream/dream-state-schema.js +23 -0
  43. package/dist/server/infra/dream/dream-state-service.d.ts +38 -0
  44. package/dist/server/infra/dream/dream-state-service.js +91 -0
  45. package/dist/server/infra/dream/dream-trigger.d.ts +46 -0
  46. package/dist/server/infra/dream/dream-trigger.js +65 -0
  47. package/dist/server/infra/dream/dream-undo.d.ts +38 -0
  48. package/dist/server/infra/dream/dream-undo.js +293 -0
  49. package/dist/server/infra/dream/operations/consolidate.d.ts +52 -0
  50. package/dist/server/infra/dream/operations/consolidate.js +514 -0
  51. package/dist/server/infra/dream/operations/prune.d.ts +45 -0
  52. package/dist/server/infra/dream/operations/prune.js +362 -0
  53. package/dist/server/infra/dream/operations/synthesize.d.ts +37 -0
  54. package/dist/server/infra/dream/operations/synthesize.js +278 -0
  55. package/dist/server/infra/dream/parse-dream-response.d.ts +11 -0
  56. package/dist/server/infra/dream/parse-dream-response.js +35 -0
  57. package/dist/server/infra/executor/curate-executor.js +10 -0
  58. package/dist/server/infra/executor/dream-executor.d.ts +97 -0
  59. package/dist/server/infra/executor/dream-executor.js +431 -0
  60. package/dist/server/infra/executor/query-executor.d.ts +2 -2
  61. package/dist/server/infra/executor/query-executor.js +92 -22
  62. package/dist/server/infra/process/feature-handlers.js +10 -6
  63. package/dist/server/infra/process/query-log-handler.d.ts +42 -0
  64. package/dist/server/infra/process/query-log-handler.js +150 -0
  65. package/dist/server/infra/process/task-router.d.ts +40 -0
  66. package/dist/server/infra/process/task-router.js +67 -9
  67. package/dist/server/infra/process/transport-handlers.d.ts +4 -0
  68. package/dist/server/infra/process/transport-handlers.js +1 -0
  69. package/dist/server/infra/storage/file-curate-log-store.js +1 -1
  70. package/dist/server/infra/storage/file-query-log-store.d.ts +81 -0
  71. package/dist/server/infra/storage/file-query-log-store.js +249 -0
  72. package/dist/server/infra/transport/handlers/config-handler.js +1 -1
  73. package/dist/server/infra/usecase/curate-log-use-case.js +7 -3
  74. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.d.ts +15 -0
  75. package/dist/server/infra/usecase/query-log-summary-narrative-formatter.js +79 -0
  76. package/dist/server/infra/usecase/query-log-summary-use-case.d.ts +13 -0
  77. package/dist/server/infra/usecase/query-log-summary-use-case.js +217 -0
  78. package/dist/server/infra/usecase/query-log-use-case.d.ts +31 -0
  79. package/dist/server/infra/usecase/query-log-use-case.js +128 -0
  80. package/dist/server/utils/log-format-utils.d.ts +5 -0
  81. package/dist/server/utils/log-format-utils.js +23 -0
  82. package/dist/shared/transport/events/config-events.d.ts +1 -1
  83. package/oclif.manifest.json +258 -3
  84. package/package.json +1 -1
@@ -0,0 +1,362 @@
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 { readdir, readFile, stat, utimes } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { isExcludedFromSync } from '../../context-tree/derived-artifact.js';
17
+ import { toUnixPath } from '../../context-tree/path-utils.js';
18
+ import { PruneResponseSchema } from '../dream-response-schemas.js';
19
+ import { parseDreamResponse } from '../parse-dream-response.js';
20
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
21
+ const MAX_CANDIDATES = 20;
22
+ const DRAFT_STALE_DAYS = 60;
23
+ const VALIDATED_STALE_DAYS = 120;
24
+ /**
25
+ * Run pruning on the context tree.
26
+ * Returns DreamOperation results (never throws).
27
+ */
28
+ export async function prune(deps) {
29
+ if (deps.signal?.aborted)
30
+ return [];
31
+ try {
32
+ // Step 1: Find candidates from both signals
33
+ const candidates = await findCandidates(deps);
34
+ if (candidates.length === 0)
35
+ return [];
36
+ // Step 2: LLM review
37
+ const decisions = await llmReview(candidates, deps);
38
+ if (decisions.length === 0)
39
+ return [];
40
+ // Step 3: Execute decisions
41
+ return await executeDecisions(decisions, candidates, deps);
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ // ── Step 1: Find candidates ────────────────────────────────────────────────
48
+ async function findCandidates(deps) {
49
+ const candidateMap = new Map();
50
+ const now = Date.now();
51
+ // Signal A: archive service importance decay
52
+ try {
53
+ const importancePaths = await deps.archiveService.findArchiveCandidates(deps.projectRoot);
54
+ const infoResults = await Promise.all(importancePaths.map(async (path) => ({ info: await readCandidateInfo(deps.contextTreeDir, path, now), path })));
55
+ for (const { info, path } of infoResults) {
56
+ if (info && info.maturity !== 'core') {
57
+ candidateMap.set(path, { ...info, signal: 'importance' });
58
+ }
59
+ }
60
+ }
61
+ catch {
62
+ // Archive service failure — continue with Signal B only
63
+ }
64
+ // Signal B: mtime staleness
65
+ try {
66
+ const stalePaths = await findStaleFiles(deps.contextTreeDir, now);
67
+ for (const { info, path } of stalePaths) {
68
+ if (candidateMap.has(path)) {
69
+ // Already found by Signal A — mark as both
70
+ const existing = candidateMap.get(path);
71
+ if (existing)
72
+ candidateMap.set(path, { ...existing, signal: 'both' });
73
+ }
74
+ else {
75
+ candidateMap.set(path, { ...info, signal: 'mtime' });
76
+ }
77
+ }
78
+ }
79
+ catch {
80
+ // Walk failure — continue with whatever Signal A found
81
+ }
82
+ // Cap at 20, stalest first
83
+ const candidates = [...candidateMap.values()];
84
+ candidates.sort((a, b) => b.daysSinceModified - a.daysSinceModified);
85
+ return candidates.slice(0, MAX_CANDIDATES);
86
+ }
87
+ async function readCandidateInfo(contextTreeDir, relativePath, now) {
88
+ try {
89
+ const fullPath = join(contextTreeDir, relativePath);
90
+ const content = await readFile(fullPath, 'utf8');
91
+ const fileStat = await stat(fullPath);
92
+ const daysSinceModified = (now - fileStat.mtimeMs) / MS_PER_DAY;
93
+ return {
94
+ daysSinceModified,
95
+ importance: extractImportance(content),
96
+ maturity: extractMaturity(content),
97
+ path: relativePath,
98
+ signal: 'importance',
99
+ };
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ async function findStaleFiles(contextTreeDir, now) {
106
+ const results = [];
107
+ await walkMdFiles(contextTreeDir, async (relativePath, fullPath) => {
108
+ try {
109
+ const content = await readFile(fullPath, 'utf8');
110
+ const maturity = extractMaturity(content);
111
+ // core files NEVER pruned
112
+ if (maturity === 'core')
113
+ return;
114
+ const threshold = maturity === 'validated' ? VALIDATED_STALE_DAYS : DRAFT_STALE_DAYS;
115
+ const fileStat = await stat(fullPath);
116
+ const daysSinceModified = (now - fileStat.mtimeMs) / MS_PER_DAY;
117
+ if (daysSinceModified >= threshold) {
118
+ results.push({
119
+ info: {
120
+ daysSinceModified,
121
+ importance: extractImportance(content),
122
+ maturity,
123
+ path: relativePath,
124
+ signal: 'mtime',
125
+ },
126
+ path: relativePath,
127
+ });
128
+ }
129
+ }
130
+ catch {
131
+ // Skip unreadable files
132
+ }
133
+ });
134
+ return results;
135
+ }
136
+ /** Walk active .md files in the context tree, skipping _/. dirs, _ prefixed files, and derived artifacts. */
137
+ async function walkMdFiles(contextTreeDir, callback) {
138
+ async function walk(currentDir) {
139
+ let entries;
140
+ try {
141
+ entries = (await readdir(currentDir, { withFileTypes: true })).map((e) => ({
142
+ isDirectory: () => e.isDirectory(),
143
+ isFile: () => e.isFile(),
144
+ name: String(e.name),
145
+ }));
146
+ }
147
+ catch {
148
+ return;
149
+ }
150
+ /* eslint-disable no-await-in-loop */
151
+ for (const entry of entries) {
152
+ const fullPath = join(currentDir, entry.name);
153
+ if (entry.isDirectory()) {
154
+ if (entry.name.startsWith('_') || entry.name.startsWith('.'))
155
+ continue;
156
+ await walk(fullPath);
157
+ }
158
+ else if (entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('_')) {
159
+ const relativePath = toUnixPath(fullPath.slice(contextTreeDir.length + 1));
160
+ if (isExcludedFromSync(relativePath))
161
+ continue;
162
+ await callback(relativePath, fullPath);
163
+ }
164
+ }
165
+ /* eslint-enable no-await-in-loop */
166
+ }
167
+ await walk(contextTreeDir);
168
+ }
169
+ // ── Step 2: LLM review ────────────────────────────────────────────────────
170
+ async function llmReview(candidates, deps) {
171
+ const { agent, signal, taskId } = deps;
172
+ let sessionId;
173
+ try {
174
+ sessionId = await agent.createTaskSession(taskId, 'dream-prune');
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ try {
180
+ // Build candidate payload (content preview inlined directly in the prompt)
181
+ const payload = await buildCandidatePayload(candidates, deps.contextTreeDir);
182
+ const totalFileCount = await countActiveFiles(deps.contextTreeDir);
183
+ const prompt = buildPrompt(candidates.length, totalFileCount, payload);
184
+ const response = await agent.executeOnSession(sessionId, prompt, {
185
+ executionContext: { commandType: 'curate', maxIterations: 10 },
186
+ signal,
187
+ taskId,
188
+ });
189
+ const parsed = parseDreamResponse(response, PruneResponseSchema);
190
+ return parsed?.decisions ?? [];
191
+ }
192
+ catch {
193
+ return [];
194
+ }
195
+ finally {
196
+ await agent.deleteTaskSession(sessionId).catch(() => { });
197
+ }
198
+ }
199
+ async function buildCandidatePayload(candidates, contextTreeDir) {
200
+ return Promise.all(candidates.map(async (c) => {
201
+ let contentPreview = '';
202
+ try {
203
+ const content = await readFile(join(contextTreeDir, c.path), 'utf8');
204
+ contentPreview = content.slice(0, 1000);
205
+ }
206
+ catch {
207
+ // Skip
208
+ }
209
+ return {
210
+ contentPreview,
211
+ daysSinceModified: Math.round(c.daysSinceModified),
212
+ importance: c.importance,
213
+ maturity: c.maturity,
214
+ path: c.path,
215
+ signal: c.signal,
216
+ };
217
+ }));
218
+ }
219
+ async function countActiveFiles(contextTreeDir) {
220
+ let count = 0;
221
+ await walkMdFiles(contextTreeDir, async () => { count++; });
222
+ return count;
223
+ }
224
+ function buildPrompt(candidateCount, totalFileCount, payload) {
225
+ const marker = '━'.repeat(60);
226
+ const candidateBlocks = payload.map((c) => `\n${marker}\nPATH: ${c.path}\nmaturity: ${c.maturity} | ${c.daysSinceModified}d old | importance: ${c.importance} | signal: ${c.signal}\n${marker}\n${c.contentPreview}`);
227
+ return [
228
+ 'You are reviewing files in a knowledge base for potential archival.',
229
+ 'These files were flagged as potentially stale or low-value based on metadata signals.',
230
+ '',
231
+ 'For each file, decide:',
232
+ '- ARCHIVE: File content is a placeholder, TODO, explicitly superseded, or has no actionable information.',
233
+ '- KEEP: File has real, actionable knowledge even if older.',
234
+ '- MERGE_INTO: Content clearly belongs in another specific file.',
235
+ '',
236
+ 'Rules:',
237
+ '- A draft file with importance < 35 whose body is a placeholder/TODO/"safe to delete" SHOULD be archived.',
238
+ '- If the body explicitly says the content is obsolete, superseded, or never-filled-in, ARCHIVE.',
239
+ '- Default to KEEP only when content is useful but stale, not when content is genuinely worthless.',
240
+ '- MERGE_INTO should only be used when the content clearly belongs in another specific file that you can name.',
241
+ '',
242
+ 'Context:',
243
+ `- The context tree currently contains ${totalFileCount} active files.`,
244
+ `- These ${candidateCount} files were flagged by staleness detection.`,
245
+ '',
246
+ 'Candidates (full previews below):',
247
+ ...candidateBlocks,
248
+ '',
249
+ 'Respond IMMEDIATELY with JSON — do NOT use code_exec:',
250
+ '```',
251
+ '{ "decisions": [{ "file": "...", "decision": "ARCHIVE|KEEP|MERGE_INTO", "reason": "...", "mergeTarget": "path (only for MERGE_INTO)" }] }',
252
+ '```',
253
+ ].join('\n');
254
+ }
255
+ // ── Step 3: Execute decisions ──────────────────────────────────────────────
256
+ async function executeDecisions(decisions, candidates, deps) {
257
+ const candidateSet = new Set(candidates.map((c) => c.path));
258
+ const results = [];
259
+ for (const decision of decisions) {
260
+ // Skip hallucinated paths — only process decisions for actual candidates
261
+ if (!candidateSet.has(decision.file))
262
+ continue;
263
+ try {
264
+ // eslint-disable-next-line no-await-in-loop
265
+ const op = await executeDecision(decision, deps);
266
+ if (op)
267
+ results.push(op);
268
+ }
269
+ catch {
270
+ // Skip failed decision — continue with others
271
+ }
272
+ }
273
+ return results;
274
+ }
275
+ async function executeDecision(decision, deps) {
276
+ switch (decision.decision) {
277
+ case 'ARCHIVE': {
278
+ // Create review backup before destructive archive (read content → save to review-backups/)
279
+ if (deps.reviewBackupStore) {
280
+ try {
281
+ const content = await readFile(join(deps.contextTreeDir, decision.file), 'utf8');
282
+ await deps.reviewBackupStore.save(decision.file, content);
283
+ }
284
+ catch {
285
+ // Best-effort: backup failure must not block archive
286
+ }
287
+ }
288
+ const archiveResult = await deps.archiveService.archiveEntry(decision.file, deps.agent, deps.projectRoot);
289
+ return {
290
+ action: 'ARCHIVE',
291
+ file: decision.file,
292
+ needsReview: true,
293
+ reason: decision.reason,
294
+ stubPath: archiveResult.stubPath,
295
+ type: 'PRUNE',
296
+ };
297
+ }
298
+ case 'KEEP': {
299
+ // Bump mtime to reset staleness clock
300
+ const absPath = join(deps.contextTreeDir, decision.file);
301
+ const now = new Date();
302
+ await utimes(absPath, now, now).catch(() => { });
303
+ return {
304
+ action: 'KEEP',
305
+ file: decision.file,
306
+ needsReview: false,
307
+ reason: decision.reason,
308
+ type: 'PRUNE',
309
+ };
310
+ }
311
+ case 'MERGE_INTO': {
312
+ if (!decision.mergeTarget)
313
+ return undefined;
314
+ await writePendingMerge(decision, deps);
315
+ return {
316
+ action: 'SUGGEST_MERGE',
317
+ file: decision.file,
318
+ mergeTarget: decision.mergeTarget,
319
+ needsReview: false,
320
+ reason: decision.reason,
321
+ type: 'PRUNE',
322
+ };
323
+ }
324
+ default: {
325
+ return undefined;
326
+ }
327
+ }
328
+ }
329
+ async function writePendingMerge(decision, deps) {
330
+ if (!decision.mergeTarget)
331
+ return;
332
+ const { mergeTarget } = decision;
333
+ // Use update() instead of read()+write() so a concurrent
334
+ // incrementCurationCount isn't overwritten by a stale spread.
335
+ await deps.dreamStateService.update((state) => {
336
+ const pendingMerges = state.pendingMerges ?? [];
337
+ const alreadySuggested = pendingMerges.some((m) => m.sourceFile === decision.file && m.mergeTarget === mergeTarget);
338
+ if (alreadySuggested)
339
+ return state;
340
+ return {
341
+ ...state,
342
+ pendingMerges: [
343
+ ...pendingMerges,
344
+ {
345
+ mergeTarget,
346
+ reason: decision.reason,
347
+ sourceFile: decision.file,
348
+ suggestedByDreamId: deps.dreamLogId,
349
+ },
350
+ ],
351
+ };
352
+ });
353
+ }
354
+ // ── Frontmatter helpers ────────────────────────────────────────────────────
355
+ function extractMaturity(content) {
356
+ const match = /^maturity:\s*['"]?(core|draft|validated)['"]?/m.exec(content);
357
+ return match?.[1] ?? 'draft';
358
+ }
359
+ function extractImportance(content) {
360
+ const match = /^importance:\s*(\d+(?:\.\d+)?)/m.exec(content);
361
+ return match ? Number.parseFloat(match[1]) : 50;
362
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Synthesize operation — detects cross-domain patterns from domain summaries.
3
+ *
4
+ * Flow:
5
+ * 1. Collect domain summaries from _index.md files
6
+ * 2. Collect existing synthesis files (to avoid duplicates)
7
+ * 3. Single LLM call for cross-domain analysis
8
+ * 4. Deduplicate candidates against existing files via BM25
9
+ * 5. Write new synthesis files as regular draft context entries
10
+ *
11
+ * Never throws — returns empty array 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
+ export type SynthesizeDeps = {
16
+ agent: ICipherAgent;
17
+ contextTreeDir: string;
18
+ searchService: {
19
+ search(query: string, options?: {
20
+ limit?: number;
21
+ scope?: string;
22
+ }): Promise<{
23
+ results: Array<{
24
+ path: string;
25
+ score: number;
26
+ title: string;
27
+ }>;
28
+ }>;
29
+ };
30
+ signal?: AbortSignal;
31
+ taskId: string;
32
+ };
33
+ /**
34
+ * Run synthesis on the context tree.
35
+ * Returns DreamOperation results (never throws).
36
+ */
37
+ export declare function synthesize(deps: SynthesizeDeps): Promise<DreamOperation[]>;
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Synthesize operation — detects cross-domain patterns from domain summaries.
3
+ *
4
+ * Flow:
5
+ * 1. Collect domain summaries from _index.md files
6
+ * 2. Collect existing synthesis files (to avoid duplicates)
7
+ * 3. Single LLM call for cross-domain analysis
8
+ * 4. Deduplicate candidates against existing files via BM25
9
+ * 5. Write new synthesis files as regular draft context entries
10
+ *
11
+ * Never throws — returns empty array 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, writeFile } from 'node:fs/promises';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { isDescendantOf } from '../../../utils/path-utils.js';
18
+ import { SynthesizeResponseSchema } from '../dream-response-schemas.js';
19
+ import { parseDreamResponse } from '../parse-dream-response.js';
20
+ const DEDUP_THRESHOLD = 0.5;
21
+ /**
22
+ * Run synthesis on the context tree.
23
+ * Returns DreamOperation results (never throws).
24
+ */
25
+ export async function synthesize(deps) {
26
+ const { agent, contextTreeDir, searchService, taskId } = deps;
27
+ if (deps.signal?.aborted)
28
+ return [];
29
+ // Step 1: Collect domain summaries
30
+ const domains = await collectDomainSummaries(contextTreeDir);
31
+ if (domains.length < 2)
32
+ return [];
33
+ // Step 2: Collect existing synthesis files
34
+ const existingSyntheses = await collectExistingSyntheses(contextTreeDir, domains);
35
+ // Step 3: LLM cross-domain analysis
36
+ let sessionId;
37
+ try {
38
+ sessionId = await agent.createTaskSession(taskId, 'dream-synthesize');
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ try {
44
+ const prompt = buildPrompt(domains, existingSyntheses);
45
+ const response = await agent.executeOnSession(sessionId, prompt, {
46
+ executionContext: { commandType: 'curate', maxIterations: 10 },
47
+ signal: deps.signal,
48
+ taskId,
49
+ });
50
+ const parsed = parseDreamResponse(response, SynthesizeResponseSchema);
51
+ if (!parsed || parsed.syntheses.length === 0)
52
+ return [];
53
+ // Step 4: Deduplicate against existing synthesis files only — the whole tree
54
+ // will naturally score high since synthesis derives from domain summaries
55
+ const novel = [];
56
+ for (const candidate of parsed.syntheses) {
57
+ // eslint-disable-next-line no-await-in-loop
58
+ const isDuplicate = await isDuplicateCandidate(candidate, existingSyntheses, searchService);
59
+ if (!isDuplicate)
60
+ novel.push(candidate);
61
+ }
62
+ if (novel.length === 0)
63
+ return [];
64
+ // Step 5: Write synthesis files (per-candidate error handling to preserve partial results)
65
+ const results = [];
66
+ for (const candidate of novel) {
67
+ try {
68
+ // eslint-disable-next-line no-await-in-loop
69
+ const op = await writeSynthesisFile(candidate, contextTreeDir);
70
+ if (op)
71
+ results.push(op);
72
+ }
73
+ catch {
74
+ // Skip failed candidate — don't discard already-written results
75
+ }
76
+ }
77
+ return results;
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ finally {
83
+ await agent.deleteTaskSession(sessionId).catch(() => { });
84
+ }
85
+ }
86
+ // ── Helpers ──────────────────────────────────────────────────────────────────
87
+ async function collectDomainSummaries(contextTreeDir) {
88
+ let dirNames;
89
+ try {
90
+ const entries = await readdir(contextTreeDir, { withFileTypes: true });
91
+ dirNames = entries
92
+ .filter((e) => e.isDirectory())
93
+ .map((e) => String(e.name))
94
+ .filter((n) => !n.startsWith('_') && !n.startsWith('.'));
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ const loaded = await Promise.all(dirNames.map(async (name) => {
100
+ try {
101
+ const content = await readFile(join(contextTreeDir, name, '_index.md'), 'utf8');
102
+ return { content, name };
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }));
108
+ return loaded.filter((item) => item !== null);
109
+ }
110
+ async function collectExistingSyntheses(contextTreeDir, domains) {
111
+ const syntheses = [];
112
+ const domainResults = await Promise.all(domains.map(async (domain) => {
113
+ const domainDir = join(contextTreeDir, domain.name);
114
+ let files;
115
+ try {
116
+ const entries = await readdir(domainDir);
117
+ files = entries.filter((f) => f.endsWith('.md') && !f.startsWith('_'));
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ const found = [];
123
+ const checks = files.map(async (file) => {
124
+ try {
125
+ const content = await readFile(join(domainDir, file), 'utf8');
126
+ const fm = parseFrontmatterType(content);
127
+ if (fm === 'synthesis') {
128
+ return `${domain.name}/${file}`;
129
+ }
130
+ }
131
+ catch {
132
+ // skip
133
+ }
134
+ return null;
135
+ });
136
+ const results = await Promise.all(checks);
137
+ for (const r of results) {
138
+ if (r)
139
+ found.push(r);
140
+ }
141
+ return found;
142
+ }));
143
+ for (const paths of domainResults)
144
+ syntheses.push(...paths);
145
+ return syntheses;
146
+ }
147
+ /** Extract the `type` field from YAML frontmatter, or undefined. */
148
+ function parseFrontmatterType(content) {
149
+ if (!content.startsWith('---\n') && !content.startsWith('---\r\n'))
150
+ return undefined;
151
+ const endIndex = content.indexOf('\n---\n', 4);
152
+ const endIndexCrlf = content.indexOf('\r\n---\r\n', 5);
153
+ const actualEnd = endIndex === -1 ? endIndexCrlf : endIndex;
154
+ if (actualEnd < 0)
155
+ return undefined;
156
+ try {
157
+ const yamlBlock = content.slice(4, actualEnd);
158
+ const raw = yamlLoad(yamlBlock);
159
+ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw) && 'type' in raw && typeof raw.type === 'string') {
160
+ return raw.type;
161
+ }
162
+ }
163
+ catch {
164
+ // Invalid YAML
165
+ }
166
+ return undefined;
167
+ }
168
+ async function isDuplicateCandidate(candidate, existingSyntheses, searchService) {
169
+ if (existingSyntheses.length === 0)
170
+ return false;
171
+ try {
172
+ const query = `${candidate.title} ${candidate.claim}`;
173
+ const results = await searchService.search(query, { limit: 5 });
174
+ // Only consider matches against existing synthesis files — the whole tree
175
+ // will naturally score high since synthesis derives from domain summaries
176
+ const synthesisMatch = results.results.find((r) => existingSyntheses.includes(r.path));
177
+ const topScore = synthesisMatch?.score ?? 0;
178
+ return topScore >= DEDUP_THRESHOLD;
179
+ }
180
+ catch {
181
+ return false; // Search failure → assume novel
182
+ }
183
+ }
184
+ async function writeSynthesisFile(candidate, contextTreeDir) {
185
+ const slug = slugify(candidate.title);
186
+ const relativePath = `${candidate.placement}/${slug}.md`;
187
+ const absPath = resolve(contextTreeDir, relativePath);
188
+ // Guard against LLM-supplied path traversal (e.g. placement = "../../etc")
189
+ if (!isDescendantOf(absPath, contextTreeDir)) {
190
+ return undefined;
191
+ }
192
+ // Name collision check
193
+ try {
194
+ await access(absPath);
195
+ return undefined; // File exists — skip
196
+ }
197
+ catch {
198
+ // ENOENT — good, proceed
199
+ }
200
+ const sources = candidate.evidence.map((e) => `${e.domain}/_index.md`);
201
+ /* eslint-disable camelcase */
202
+ const frontmatter = {
203
+ confidence: candidate.confidence,
204
+ maturity: 'draft',
205
+ sources,
206
+ synthesized_at: new Date().toISOString(),
207
+ type: 'synthesis',
208
+ };
209
+ /* eslint-enable camelcase */
210
+ const yaml = yamlDump(frontmatter, { lineWidth: -1, sortKeys: true }).trimEnd();
211
+ const body = [
212
+ `# ${candidate.title}`,
213
+ '',
214
+ candidate.claim,
215
+ '',
216
+ '## Evidence',
217
+ '',
218
+ ...candidate.evidence.map((e) => `- **${e.domain}**: ${e.fact}`),
219
+ '',
220
+ ].join('\n');
221
+ const content = `---\n${yaml}\n---\n\n${body}`;
222
+ await atomicWrite(absPath, content);
223
+ return {
224
+ action: 'CREATE',
225
+ confidence: candidate.confidence,
226
+ needsReview: candidate.confidence < 0.7,
227
+ outputFile: relativePath,
228
+ sources,
229
+ type: 'SYNTHESIZE',
230
+ };
231
+ }
232
+ function slugify(title) {
233
+ return title
234
+ .toLowerCase()
235
+ .replaceAll(/[^a-z0-9]+/g, '-')
236
+ .replaceAll(/^-|-$/g, '')
237
+ .slice(0, 80);
238
+ }
239
+ async function atomicWrite(filePath, content) {
240
+ await mkdir(dirname(filePath), { recursive: true });
241
+ const tmpPath = `${filePath}.${randomUUID()}.tmp`;
242
+ await writeFile(tmpPath, content, 'utf8');
243
+ await rename(tmpPath, filePath);
244
+ }
245
+ function buildPrompt(domains, existingSyntheses) {
246
+ const existingList = existingSyntheses.length > 0
247
+ ? `Existing synthesis files (do NOT recreate these):\n${existingSyntheses.map((s) => `- ${s}`).join('\n')}`
248
+ : 'No existing synthesis files.';
249
+ const marker = '━'.repeat(60);
250
+ const domainBlocks = domains
251
+ .map((d) => `\n${marker}\nDOMAIN: ${d.name}\n${marker}\n${d.content}`)
252
+ .join('\n');
253
+ return [
254
+ 'You are analyzing a knowledge base organized into domains. The full _index.md content for every domain is included below — read them directly. Do NOT use code_exec.',
255
+ '',
256
+ `Domains: ${domains.map((d) => d.name).join(', ')}`,
257
+ '',
258
+ existingList,
259
+ '',
260
+ 'Domain summaries:',
261
+ domainBlocks,
262
+ '',
263
+ 'Your job is to find cross-cutting patterns — concepts, concerns, or conflicts that span multiple domains.',
264
+ '',
265
+ 'Rules:',
266
+ '- Report genuinely useful insights that a developer would benefit from knowing.',
267
+ '- Any named abstraction, component, or concept that appears in 2+ domains is worth synthesizing.',
268
+ '- Do NOT report trivial or obvious connections (e.g., "both domains use TypeScript").',
269
+ '- Each synthesis must reference at least 2 domains with specific evidence.',
270
+ '- For "placement", choose the domain where this insight is MOST actionable.',
271
+ '- If nothing meaningful is found, return an empty array. That is fine — but missing a clear cross-domain pattern is a failure.',
272
+ '',
273
+ 'Respond with JSON:',
274
+ '```',
275
+ '{ "syntheses": [{ "title": "...", "claim": "...", "evidence": [{"domain": "...", "fact": "..."}], "confidence": 0.0-1.0, "placement": "..." }] }',
276
+ '```',
277
+ ].join('\n');
278
+ }
@@ -0,0 +1,11 @@
1
+ import type { z } from 'zod';
2
+ /**
3
+ * Extract and validate a JSON response from LLM output.
4
+ *
5
+ * Tries two strategies in order:
6
+ * 1. JSON inside a ```json code fence (first match, non-greedy)
7
+ * 2. Raw JSON (first { to last })
8
+ *
9
+ * Returns null if no valid JSON matching the schema is found.
10
+ */
11
+ export declare function parseDreamResponse<T>(response: string, schema: z.ZodType<T>): null | T;