@dreb/coding-agent 2.5.2 → 2.6.1
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/CHANGELOG.md +1 -0
- package/README.md +1 -0
- package/dist/core/dream.d.ts +46 -0
- package/dist/core/dream.d.ts.map +1 -0
- package/dist/core/dream.js +587 -0
- package/dist/core/dream.js.map +1 -0
- package/dist/core/settings-manager.d.ts +5 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +17 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/slash-commands.d.ts.map +1 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/core/slash-commands.js.map +1 -1
- package/dist/core/tools/path-utils.d.ts.map +1 -1
- package/dist/core/tools/path-utils.js +8 -1
- package/dist/core/tools/path-utils.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +2 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +149 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
- Added skill system enhancements: `argument-hint` frontmatter field shown in `/` menu autocomplete, `user-invocable` field to hide skills from the `/` menu while keeping them available to the model, `disable-model-invocation` field to restrict skills to user-only invocation, and a dedicated `skill` tool for model-invocable skill execution with full content substitution (`$ARGUMENTS`, `$0`..`$N`, `$@`, `${@:N}`, `${DREB_SKILL_DIR}`, `${DREB_SESSION_ID}`) ([#7](https://github.com/aebrer/dreb/issues/7))
|
|
20
20
|
- Added `sessionDir` setting support in global and project `settings.json` so session storage can be configured without passing `--session-dir` on every invocation ([#2598](https://github.com/badlogic/pi-mono/pull/2598) by [@smcllns](https://github.com/smcllns))
|
|
21
|
+
- Added `/dream` memory consolidation command — backs up all memory directories, merges duplicates, scans session history for unrecorded patterns, prunes stale entries, and validates links. Uses tar.gz archives with retention policy (keep last 10), lockfile-based concurrency protection, and a 10-step LLM pipeline with explicit backup verification. Configurable archive path via `dream.archivePath` setting. ([#99](https://github.com/aebrer/dreb/issues/99))
|
|
21
22
|
|
|
22
23
|
### Fixed
|
|
23
24
|
|
package/README.md
CHANGED
|
@@ -161,6 +161,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
|
|
|
161
161
|
| `/fork` | Create a new session from the current branch |
|
|
162
162
|
| `/compact [prompt]` | Manually compact context, optional custom instructions |
|
|
163
163
|
| `/copy` | Copy last assistant message to clipboard |
|
|
164
|
+
| `/dream` | Consolidate and prune memories — backs up, merges duplicates, scans sessions for patterns |
|
|
164
165
|
| `/export [file]` | Export session to HTML file |
|
|
165
166
|
| `/buddy` | Terminal companion — hatch, pet, reroll, set model, or hide. See [docs/buddy.md](docs/buddy.md) |
|
|
166
167
|
| `/reload` | Reload keybindings, extensions, skills, prompts, and context files (themes hot-reload automatically) |
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SettingsManager } from "./settings-manager.js";
|
|
2
|
+
export interface DreamContext {
|
|
3
|
+
archivePath: string;
|
|
4
|
+
lastRunTimestamp: string | null;
|
|
5
|
+
globalMemoryDir: string;
|
|
6
|
+
projectMemoryDirs: string[];
|
|
7
|
+
claudeMemoryDirs: string[];
|
|
8
|
+
sessionsDir: string;
|
|
9
|
+
}
|
|
10
|
+
export interface BackupResult {
|
|
11
|
+
backupPath: string;
|
|
12
|
+
timestamp: string;
|
|
13
|
+
fileCount: number;
|
|
14
|
+
totalSize: number;
|
|
15
|
+
verified: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface LinkValidationResult {
|
|
18
|
+
valid: boolean;
|
|
19
|
+
brokenLinks: Array<{
|
|
20
|
+
memoryDir: string;
|
|
21
|
+
indexFile: string;
|
|
22
|
+
pointer: string;
|
|
23
|
+
target: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export type DreamCommand = {
|
|
27
|
+
type: "run";
|
|
28
|
+
} | {
|
|
29
|
+
type: "setBackup";
|
|
30
|
+
path: string;
|
|
31
|
+
} | {
|
|
32
|
+
type: "showBackup";
|
|
33
|
+
};
|
|
34
|
+
export declare function parseDreamCommand(text: string): DreamCommand;
|
|
35
|
+
export declare function discoverAllProjectMemoryDirs(overrideSessionsDir?: string): string[];
|
|
36
|
+
export declare function resolveDreamContext(settingsManager: SettingsManager, overrideGlobalMemDir?: string): Promise<DreamContext>;
|
|
37
|
+
export declare function validateArchivePath(archivePath: string, memoryDirs: string[]): void;
|
|
38
|
+
export declare function validateMemoryLinks(memoryDirs: string[]): LinkValidationResult;
|
|
39
|
+
/** Convert a filesystem path to a collision-resistant directory name for backup staging. */
|
|
40
|
+
export declare function safeDirName(path: string): string;
|
|
41
|
+
export declare function performDreamBackup(context: DreamContext): Promise<BackupResult>;
|
|
42
|
+
export declare function buildDreamPrompt(context: DreamContext, backupResult: BackupResult): string;
|
|
43
|
+
export declare function acquireDreamLock(overrideMemDir?: string): Promise<() => void>;
|
|
44
|
+
export declare function pruneOldBackups(archivePath: string, keepCount?: number): Promise<void>;
|
|
45
|
+
export declare function cleanupDreamTmpDirs(memoryDirs: string[]): void;
|
|
46
|
+
//# sourceMappingURL=dream.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dream.d.ts","sourceRoot":"","sources":["../../src/core/dream.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAM7D,MAAM,WAAW,YAAY;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,eAAe,EAAE,MAAM,CAAC;IACxB,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACpC,KAAK,EAAE,OAAO,CAAC;IACf,WAAW,EAAE,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC9F;AAED,MAAM,MAAM,YAAY,GAAG;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,YAAY,CAAA;CAAE,CAAC;AAiB1G,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAkB5D;AAqCD,wBAAgB,4BAA4B,CAAC,mBAAmB,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CA2CnF;AA+BD,wBAAsB,mBAAmB,CACxC,eAAe,EAAE,eAAe,EAChC,oBAAoB,CAAC,EAAE,MAAM,GAC3B,OAAO,CAAC,YAAY,CAAC,CAkCvB;AAMD,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,IAAI,CA8BnF;AAED,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,oBAAoB,CA8B9E;AAMD,4FAA4F;AAC5F,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AAmCD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA8FrF;AAYD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,GAAG,MAAM,CAuI1F;AAMD,wBAAsB,gBAAgB,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAqDnF;AAMD,wBAAsB,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,GAAE,MAA2B,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBhH;AAMD,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,IAAI,CAW9D","sourcesContent":["import { execFile } from \"node:child_process\";\nimport { randomUUID } from \"node:crypto\";\nimport {\n\tcloseSync,\n\texistsSync,\n\tmkdirSync,\n\topenSync,\n\treaddirSync,\n\treadFileSync,\n\treadSync,\n\trmSync,\n\tstatSync,\n\tunlinkSync,\n\twriteFileSync,\n} from \"node:fs\";\nimport { cp, readdir, stat } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, isAbsolute, join, resolve } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport lockfile from \"proper-lockfile\";\nimport { getSessionsDir } from \"../config.js\";\nimport type { SettingsManager } from \"./settings-manager.js\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface DreamContext {\n\tarchivePath: string;\n\tlastRunTimestamp: string | null;\n\tglobalMemoryDir: string;\n\tprojectMemoryDirs: string[];\n\tclaudeMemoryDirs: string[];\n\tsessionsDir: string;\n}\n\nexport interface BackupResult {\n\tbackupPath: string;\n\ttimestamp: string;\n\tfileCount: number;\n\ttotalSize: number;\n\tverified: boolean;\n}\n\nexport interface LinkValidationResult {\n\tvalid: boolean;\n\tbrokenLinks: Array<{ memoryDir: string; indexFile: string; pointer: string; target: string }>;\n}\n\nexport type DreamCommand = { type: \"run\" } | { type: \"setBackup\"; path: string } | { type: \"showBackup\" };\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DREAM_LAST_RUN_FILE = \".dream-last-run\";\nconst DREAM_LOCK_FILE = \".dream.lock\";\nconst DREAM_TMP_DIR = \".dream-tmp\";\nconst BACKUP_PREFIX = \"dream-backup-\";\nconst DEFAULT_KEEP_COUNT = 10;\nconst LARGE_LISTING_THRESHOLD = 10_000;\n\n// =============================================================================\n// Command Parsing\n// =============================================================================\n\nexport function parseDreamCommand(text: string): DreamCommand {\n\tconst stripped = text.replace(/^\\/dream\\s*/, \"\").trim();\n\n\tif (!stripped) {\n\t\treturn { type: \"run\" };\n\t}\n\n\tif (stripped === \"backup\") {\n\t\treturn { type: \"showBackup\" };\n\t}\n\n\tconst backupMatch = stripped.match(/^backup\\s+(.+)$/);\n\tif (backupMatch) {\n\t\treturn { type: \"setBackup\", path: backupMatch[1].trim() };\n\t}\n\n\t// Unknown subcommand — treat as a plain run\n\treturn { type: \"run\" };\n}\n\n// =============================================================================\n// Discovery\n// =============================================================================\n\n/**\n * Read the `cwd` field from the JSONL session header (first line) of a file.\n * Returns `undefined` if the file cannot be read or the header is invalid.\n * Uses synchronous low-level reads to avoid loading entire files.\n */\nfunction readSessionCwd(filePath: string): string | undefined {\n\tlet fd: number | undefined;\n\ttry {\n\t\tfd = openSync(filePath, \"r\");\n\t\tconst buffer = Buffer.alloc(4096);\n\t\tconst bytesRead = readSync(fd, buffer, 0, 4096, 0);\n\t\tconst firstLine = buffer.toString(\"utf8\", 0, bytesRead).split(\"\\n\")[0];\n\t\tif (!firstLine) return undefined;\n\t\tconst header = JSON.parse(firstLine);\n\t\tif (header.type === \"session\" && typeof header.cwd === \"string\") {\n\t\t\treturn header.cwd;\n\t\t}\n\t} catch {\n\t\t// Corrupt or unreadable — skip\n\t} finally {\n\t\tif (fd !== undefined) {\n\t\t\ttry {\n\t\t\t\tcloseSync(fd);\n\t\t\t} catch {\n\t\t\t\t// Best-effort close\n\t\t\t}\n\t\t}\n\t}\n\treturn undefined;\n}\n\nexport function discoverAllProjectMemoryDirs(overrideSessionsDir?: string): string[] {\n\tconst sessionsDir = overrideSessionsDir ?? getSessionsDir();\n\tif (!existsSync(sessionsDir)) return [];\n\n\tlet dirEntries: string[];\n\ttry {\n\t\tdirEntries = readdirSync(sessionsDir);\n\t} catch {\n\t\treturn [];\n\t}\n\n\tconst memoryDirs: string[] = [];\n\tconst seen = new Set<string>();\n\n\tfor (const name of dirEntries) {\n\t\tif (!name.startsWith(\"--\") || !name.endsWith(\"--\")) continue;\n\n\t\tconst sessionSubDir = join(sessionsDir, name);\n\n\t\t// Find any .jsonl file in this session directory\n\t\tlet jsonlFiles: string[];\n\t\ttry {\n\t\t\tjsonlFiles = readdirSync(sessionSubDir).filter((f) => f.endsWith(\".jsonl\"));\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tif (jsonlFiles.length === 0) continue;\n\n\t\tlet cwd: string | undefined;\n\t\tfor (const f of jsonlFiles) {\n\t\t\tcwd = readSessionCwd(join(sessionSubDir, f));\n\t\t\tif (cwd) break;\n\t\t}\n\t\tif (!cwd) continue;\n\n\t\tconst memDir = join(cwd, \".dreb\", \"memory\");\n\t\tif (!seen.has(memDir) && existsSync(memDir)) {\n\t\t\tseen.add(memDir);\n\t\t\tmemoryDirs.push(memDir);\n\t\t}\n\t}\n\n\treturn memoryDirs;\n}\n\n/**\n * Discover claude compatibility memory directories (read-only imports).\n * Scans ~/.claude/projects/ for subdirectories containing a memory/ folder.\n */\nfunction discoverClaudeMemoryDirs(): string[] {\n\tconst claudeProjectsDir = join(homedir(), \".claude\", \"projects\");\n\tif (!existsSync(claudeProjectsDir)) return [];\n\n\tlet dirEntries: string[];\n\ttry {\n\t\tdirEntries = readdirSync(claudeProjectsDir);\n\t} catch {\n\t\treturn [];\n\t}\n\n\tconst memoryDirs: string[] = [];\n\tfor (const name of dirEntries) {\n\t\tconst memDir = join(claudeProjectsDir, name, \"memory\");\n\t\tif (existsSync(memDir)) {\n\t\t\tmemoryDirs.push(memDir);\n\t\t}\n\t}\n\treturn memoryDirs;\n}\n\n// =============================================================================\n// Context Resolution\n// =============================================================================\n\nexport async function resolveDreamContext(\n\tsettingsManager: SettingsManager,\n\toverrideGlobalMemDir?: string,\n): Promise<DreamContext> {\n\t// Archive path from settings, or default\n\tconst archivePath = settingsManager.getDreamArchivePath();\n\n\t// Global memory dir\n\tconst globalMemoryDir = overrideGlobalMemDir ?? join(homedir(), \".dreb\", \"memory\");\n\n\t// Last run timestamp\n\tlet lastRunTimestamp: string | null = null;\n\tconst markerPath = join(globalMemoryDir, DREAM_LAST_RUN_FILE);\n\ttry {\n\t\tif (existsSync(markerPath)) {\n\t\t\tconst content = readFileSync(markerPath, \"utf-8\").trim();\n\t\t\tif (content) {\n\t\t\t\tlastRunTimestamp = content;\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// Marker unreadable — treat as first run\n\t}\n\n\t// Discover memory directories\n\tconst projectMemoryDirs = discoverAllProjectMemoryDirs();\n\tconst claudeMemoryDirs = discoverClaudeMemoryDirs();\n\tconst sessionsDir = getSessionsDir();\n\n\treturn {\n\t\tarchivePath,\n\t\tlastRunTimestamp,\n\t\tglobalMemoryDir,\n\t\tprojectMemoryDirs,\n\t\tclaudeMemoryDirs,\n\t\tsessionsDir,\n\t};\n}\n\n// =============================================================================\n// Validation\n// =============================================================================\n\nexport function validateArchivePath(archivePath: string, memoryDirs: string[]): void {\n\tif (!archivePath || !archivePath.trim()) {\n\t\tthrow new Error(\"Dream archive path cannot be empty\");\n\t}\n\n\tif (!isAbsolute(archivePath)) {\n\t\tthrow new Error(`Dream archive path must be absolute, got: ${archivePath}`);\n\t}\n\n\tconst normalized = resolve(archivePath);\n\n\tfor (const memDir of memoryDirs) {\n\t\tconst normalizedMem = resolve(memDir);\n\n\t\t// Archive is inside a memory dir\n\t\tif (normalized.startsWith(`${normalizedMem}/`) || normalized === normalizedMem) {\n\t\t\tthrow new Error(\n\t\t\t\t`Dream archive path \"${archivePath}\" overlaps with memory directory \"${memDir}\". ` +\n\t\t\t\t\t\"The archive must be outside all memory directories.\",\n\t\t\t);\n\t\t}\n\n\t\t// Memory dir is inside the archive\n\t\tif (normalizedMem.startsWith(`${normalized}/`) || normalizedMem === normalized) {\n\t\t\tthrow new Error(\n\t\t\t\t`Memory directory \"${memDir}\" is inside the dream archive path \"${archivePath}\". ` +\n\t\t\t\t\t\"The archive must be outside all memory directories.\",\n\t\t\t);\n\t\t}\n\t}\n}\n\nexport function validateMemoryLinks(memoryDirs: string[]): LinkValidationResult {\n\tconst brokenLinks: LinkValidationResult[\"brokenLinks\"] = [];\n\tconst linkPattern = /\\[([^\\]]+)\\]\\(([^)]+)\\)/g;\n\n\tfor (const memDir of memoryDirs) {\n\t\tconst indexFile = join(memDir, \"MEMORY.md\");\n\t\tif (!existsSync(indexFile)) continue;\n\n\t\tlet content: string;\n\t\ttry {\n\t\t\tcontent = readFileSync(indexFile, \"utf-8\");\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\n\t\tfor (const match of content.matchAll(linkPattern)) {\n\t\t\tconst pointer = match[0];\n\t\t\tconst target = match[2];\n\n\t\t\t// Skip external URLs\n\t\t\tif (target.startsWith(\"http://\") || target.startsWith(\"https://\")) continue;\n\n\t\t\tconst targetPath = join(memDir, target);\n\t\t\tif (!existsSync(targetPath)) {\n\t\t\t\tbrokenLinks.push({ memoryDir: memDir, indexFile, pointer, target });\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { valid: brokenLinks.length === 0, brokenLinks };\n}\n\n// =============================================================================\n// Backup\n// =============================================================================\n\n/** Convert a filesystem path to a collision-resistant directory name for backup staging. */\nexport function safeDirName(path: string): string {\n\treturn encodeURIComponent(path).replace(/%2F/gi, \"_\");\n}\n\n/** Recursively count files and total size in a directory. */\nasync function countFilesAndSize(dir: string): Promise<{ fileCount: number; totalSize: number }> {\n\tlet fileCount = 0;\n\tlet totalSize = 0;\n\n\tasync function walk(current: string): Promise<void> {\n\t\tlet entries: import(\"node:fs\").Dirent[];\n\t\ttry {\n\t\t\tentries = await readdir(current, { withFileTypes: true });\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const entry of entries) {\n\t\t\tconst fullPath = join(current, entry.name);\n\t\t\tif (entry.isDirectory()) {\n\t\t\t\tawait walk(fullPath);\n\t\t\t} else if (entry.isFile()) {\n\t\t\t\ttry {\n\t\t\t\t\tconst st = await stat(fullPath);\n\t\t\t\t\tfileCount++;\n\t\t\t\t\ttotalSize += st.size;\n\t\t\t\t} catch {\n\t\t\t\t\t// File disappeared between readdir and stat — skip\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tawait walk(dir);\n\treturn { fileCount, totalSize };\n}\n\nexport async function performDreamBackup(context: DreamContext): Promise<BackupResult> {\n\tconst timestamp = `${new Date().toISOString().replace(/[:.]/g, \"-\")}_${randomUUID().slice(0, 8)}`;\n\tconst archiveName = `${BACKUP_PREFIX}${timestamp}`;\n\tconst backupPath = join(context.archivePath, `${archiveName}.tar.gz`);\n\tconst stagingDir = join(context.archivePath, `.staging-${timestamp}`);\n\n\t// Ensure archive directory exists\n\ttry {\n\t\tmkdirSync(context.archivePath, { recursive: true });\n\t} catch (error) {\n\t\tthrow new Error(\n\t\t\t`Failed to create archive directory \"${context.archivePath}\": ${error instanceof Error ? error.message : String(error)}`,\n\t\t);\n\t}\n\n\t// Build list of source directories\n\tconst allSourceDirs: Array<{ dir: string; stagingTarget: string }> = [];\n\n\t// Global memory\n\tif (existsSync(context.globalMemoryDir)) {\n\t\tallSourceDirs.push({ dir: context.globalMemoryDir, stagingTarget: join(stagingDir, \"global\") });\n\t}\n\n\t// Project memories\n\tfor (const dir of context.projectMemoryDirs) {\n\t\tif (existsSync(dir)) {\n\t\t\tallSourceDirs.push({\n\t\t\t\tdir,\n\t\t\t\tstagingTarget: join(stagingDir, \"projects\", safeDirName(dir)),\n\t\t\t});\n\t\t}\n\t}\n\n\t// Claude compat memories\n\tfor (const dir of context.claudeMemoryDirs) {\n\t\tif (existsSync(dir)) {\n\t\t\tallSourceDirs.push({\n\t\t\t\tdir,\n\t\t\t\tstagingTarget: join(stagingDir, \"claude\", safeDirName(dir)),\n\t\t\t});\n\t\t}\n\t}\n\n\t// Count source files before copying\n\tlet sourceFileCount = 0;\n\tlet sourceTotalSize = 0;\n\tfor (const { dir } of allSourceDirs) {\n\t\tconst counts = await countFilesAndSize(dir);\n\t\tsourceFileCount += counts.fileCount;\n\t\tsourceTotalSize += counts.totalSize;\n\t}\n\n\t// Copy source directories into staging area\n\ttry {\n\t\tmkdirSync(stagingDir, { recursive: true });\n\n\t\tfor (const { dir, stagingTarget } of allSourceDirs) {\n\t\t\ttry {\n\t\t\t\tawait cp(dir, stagingTarget, { recursive: true });\n\t\t\t} catch (error) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Failed to copy \"${dir}\" to \"${stagingTarget}\": ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Create .tar.gz archive from staging directory\n\t\tconst execFileAsync = promisify(execFile);\n\t\tawait execFileAsync(\"tar\", [\"czf\", backupPath, \"-C\", dirname(stagingDir), basename(stagingDir)]);\n\t} finally {\n\t\t// Clean up staging directory\n\t\ttry {\n\t\t\trmSync(stagingDir, { recursive: true, force: true });\n\t\t} catch {\n\t\t\t// Best-effort cleanup\n\t\t}\n\t}\n\n\t// Verify: archive exists and has non-zero size\n\tlet verified = false;\n\ttry {\n\t\tconst archiveStat = statSync(backupPath);\n\t\tverified = archiveStat.size > 0;\n\t} catch {\n\t\t// Archive missing or unreadable\n\t}\n\n\treturn {\n\t\tbackupPath,\n\t\ttimestamp,\n\t\tfileCount: sourceFileCount,\n\t\ttotalSize: sourceTotalSize,\n\t\tverified,\n\t};\n}\n\n// =============================================================================\n// Prompt Building\n// =============================================================================\n\nfunction formatBytes(bytes: number): string {\n\tif (bytes < 1024) return `${bytes} bytes`;\n\tif (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n\treturn `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nexport function buildDreamPrompt(context: DreamContext, backupResult: BackupResult): string {\n\tconst verifiedStatus = backupResult.verified ? \"✓ Verified\" : \"⚠ Verification mismatch — check backup integrity\";\n\tconst lastRun = context.lastRunTimestamp ?? \"Never (first run)\";\n\n\t// Build the file listing section\n\tlet fileListing = \"\";\n\tconst allDirs = [\n\t\t{ label: \"Global\", dir: context.globalMemoryDir },\n\t\t...context.projectMemoryDirs.map((d) => ({ label: `Project: ${d}`, dir: d })),\n\t\t...context.claudeMemoryDirs.map((d) => ({ label: `Claude (READ-ONLY): ${d}`, dir: d })),\n\t];\n\n\tfor (const { label, dir } of allDirs) {\n\t\tif (!existsSync(dir)) continue;\n\t\tfileListing += `\\n### ${label}\\n`;\n\t\ttry {\n\t\t\tconst entries = readdirSync(dir);\n\t\t\tfor (const entry of entries) {\n\t\t\t\tif (\n\t\t\t\t\tentry === DREAM_TMP_DIR ||\n\t\t\t\t\tentry === DREAM_LOCK_FILE ||\n\t\t\t\t\tentry === `${DREAM_LOCK_FILE}.lock` ||\n\t\t\t\t\tentry === DREAM_LAST_RUN_FILE\n\t\t\t\t)\n\t\t\t\t\tcontinue;\n\t\t\t\tfileListing += `- ${entry}\\n`;\n\t\t\t}\n\t\t} catch {\n\t\t\tfileListing += `- (unreadable)\\n`;\n\t\t}\n\t}\n\n\t// If file listing is too large, spill to a temp file\n\tlet fileListingSection: string;\n\tif (fileListing.length > LARGE_LISTING_THRESHOLD) {\n\t\tconst tmpDir = join(context.globalMemoryDir, DREAM_TMP_DIR);\n\t\ttry {\n\t\t\tmkdirSync(tmpDir, { recursive: true });\n\t\t} catch {\n\t\t\t// Best-effort\n\t\t}\n\t\tconst tmpPath = join(tmpDir, `dream-listing-${Date.now()}.md`);\n\t\ttry {\n\t\t\twriteFileSync(tmpPath, fileListing, \"utf-8\");\n\t\t\tfileListingSection =\n\t\t\t\t`File listing too large to include inline. Full listing written to: ${tmpPath}\\n` +\n\t\t\t\t\"Read this file for the complete list of memory files.\";\n\t\t} catch {\n\t\t\t// Fallback: include inline anyway\n\t\t\tfileListingSection = fileListing;\n\t\t}\n\t} else {\n\t\tfileListingSection = fileListing;\n\t}\n\n\tconst projectDirsList =\n\t\tcontext.projectMemoryDirs.length > 0\n\t\t\t? context.projectMemoryDirs.map((d) => ` - ${d}`).join(\"\\n\")\n\t\t\t: \" (none discovered)\";\n\n\tconst claudeDirsList =\n\t\tcontext.claudeMemoryDirs.length > 0\n\t\t\t? context.claudeMemoryDirs.map((d) => ` - ${d}`).join(\"\\n\")\n\t\t\t: \" (none discovered)\";\n\n\tconst dreamTmpDirName = DREAM_TMP_DIR;\n\tconst dreamLastRunPath = join(context.globalMemoryDir, DREAM_LAST_RUN_FILE);\n\n\treturn `You are running a memory consolidation (/dream). A backup archive has been created at: ${backupResult.backupPath}\nBackup verification: ${backupResult.fileCount} files, ${formatBytes(backupResult.totalSize)} — ${verifiedStatus}\n\n## Context\n- Global memory: ${context.globalMemoryDir}\n- Project memories:\n${projectDirsList}\n- Claude Code memories (READ-ONLY):\n${claudeDirsList}\n- Last dream run: ${lastRun}\n- Sessions directory: ${context.sessionsDir}\n\n## Memory Files\n${fileListingSection}\n\n## HARD CONSTRAINTS\n- The \\`.claude/\\` memory directories are read-only compatibility imports. Do NOT modify, delete, or rewrite any files under \\`.claude/\\` paths. Only \\`~/.dreb/memory/\\` and \\`<project>/.dreb/memory/\\` are writable.\n- NEVER remove session JSONL data files.\n- NEVER delete or modify \\`.dream.lock\\`, \\`.dream.lock.lock\\`, or \\`.dream-last-run\\` files — these are managed by the dream infrastructure, not by you.\n- Explicitly EXCLUDE \\`subagent-sessions/\\` from scanning scope.\n\n## Pipeline\n\n### Step 0: Verify Backup\nBefore proceeding, verify the backup archive exists and is intact:\n1. Check that the backup file exists at the path shown above\n2. List its contents (e.g., \\`tar tzf <path> | head -20\\`) to confirm memory files are present\n3. If the backup appears incomplete or missing, STOP and inform the user — do not proceed with consolidation\n\n### Step 1: Read All Memories\nRead every MEMORY.md index and every referenced memory file from global, all project scopes, and \\`.claude/\\` read-only paths.\n\n### Step 2: Analyze & Plan\nGroup related entries, identify duplicates, overlapping content, stale references (deleted files, resolved issues). Present the consolidation plan to the user before making any changes.\n\n### Step 3: Consolidate\nMerge related entries, deduplicate, reorganize semantically. Write changes to temp files first (\\`<memory-dir>/${dreamTmpDirName}/\\`), then atomic rename per file. Maintain a rollback manifest listing every original→tmp→final path.\n\n### Step 4: Rewrite Indexes\nProduce new MEMORY.md indexes organized semantically, under 200 lines. Every pointer must reference an existing file. Write to temp first, then rename.\n\n### Step 5: Remove Dead Files\nOnly after indexes are rewritten and validated, delete memory files that have ZERO remaining references in any MEMORY.md index. Never delete a file that is still referenced.\n\n### Step 6: Scan Sessions\nSpawn background Explore subagents to read session JSONL logs from ${context.sessionsDir} (EXCLUDE subagent-sessions/). Only scan sessions since ${lastRun}. First-run cap: 30 days maximum.\n\nSession JSONL format: Each line is a JSON object. Relevant entry types:\n- \\`{\"type\":\"message\",\"message\":{\"role\":\"user\"|\"assistant\",\"content\":...}}\\` — conversation messages\n- \\`{\"type\":\"tool_use\",\"name\":\"...\",\"input\":{...}}\\` — tool calls\n- \\`{\"type\":\"tool_result\",\"content\":...}\\` — tool outputs\n\nEach subagent should return findings in structured format:\n\\`{\"findings\": [{\"type\": \"user-preferences|good-practices|project|navigation\", \"name\": \"...\", \"description\": \"...\", \"content\": \"...\"}]}\\`\n\n### Step 7: STOP AND WAIT\nAfter spawning subagents, stop generating and wait for ALL background-agent-complete messages before proceeding. Do not continue to Step 8 until every subagent has reported back.\n\n### Step 8: Incorporate Findings\nCreate new memory entries from subagent findings, update MEMORY.md indexes.\n\n### Step 9: Report\nStructured summary: X merged, Y pruned, Z added from sessions, W files removed, backup location, any warnings.\n\n### Step 10: Mark Complete\nWrite the current ISO timestamp to ${dreamLastRunPath}\n`;\n}\n\n// =============================================================================\n// Locking\n// =============================================================================\n\nexport async function acquireDreamLock(overrideMemDir?: string): Promise<() => void> {\n\tconst memDir = overrideMemDir ?? join(homedir(), \".dreb\", \"memory\");\n\tconst lockPath = join(memDir, DREAM_LOCK_FILE);\n\n\t// Ensure directory and lock file exist (proper-lockfile requires the file to exist)\n\ttry {\n\t\tmkdirSync(memDir, { recursive: true });\n\t} catch {\n\t\t// Directory already exists\n\t}\n\n\tif (!existsSync(lockPath)) {\n\t\ttry {\n\t\t\twriteFileSync(lockPath, \"\", \"utf-8\");\n\t\t} catch (error) {\n\t\t\tthrow new Error(\n\t\t\t\t`Failed to create dream lock file \"${lockPath}\": ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t);\n\t\t}\n\t}\n\n\tlet release: () => Promise<void>;\n\ttry {\n\t\trelease = await lockfile.lock(lockPath, {\n\t\t\tstale: 60000,\n\t\t\tretries: {\n\t\t\t\tretries: 5,\n\t\t\t\tfactor: 2,\n\t\t\t\tminTimeout: 200,\n\t\t\t\tmaxTimeout: 5000,\n\t\t\t\trandomize: true,\n\t\t\t},\n\t\t});\n\t} catch (error) {\n\t\tconst code =\n\t\t\ttypeof error === \"object\" && error !== null && \"code\" in error\n\t\t\t\t? String((error as { code?: unknown }).code)\n\t\t\t\t: undefined;\n\t\tif (code === \"ELOCKED\") {\n\t\t\tthrow new Error(\n\t\t\t\t\"Another /dream operation is already running. \" +\n\t\t\t\t\t\"If this is stale, wait 60 seconds or manually remove \" +\n\t\t\t\t\t`the lock: ${lockPath}.lock`,\n\t\t\t);\n\t\t}\n\t\tthrow new Error(`Failed to acquire dream lock: ${error instanceof Error ? error.message : String(error)}`);\n\t}\n\n\treturn () => {\n\t\trelease().catch(() => {\n\t\t\t// Best-effort unlock — if it fails the stale timeout will clean up\n\t\t});\n\t};\n}\n\n// =============================================================================\n// Backup Pruning\n// =============================================================================\n\nexport async function pruneOldBackups(archivePath: string, keepCount: number = DEFAULT_KEEP_COUNT): Promise<void> {\n\tif (!existsSync(archivePath)) return;\n\n\tlet dirEntries: string[];\n\ttry {\n\t\tdirEntries = readdirSync(archivePath);\n\t} catch {\n\t\treturn;\n\t}\n\n\tconst backupFiles = dirEntries.filter((name) => name.startsWith(BACKUP_PREFIX) && name.endsWith(\".tar.gz\")).sort();\n\n\tif (backupFiles.length <= keepCount) return;\n\n\tconst toRemove = backupFiles.slice(0, backupFiles.length - keepCount);\n\tfor (const fileName of toRemove) {\n\t\tconst fullPath = join(archivePath, fileName);\n\t\ttry {\n\t\t\tunlinkSync(fullPath);\n\t\t} catch {\n\t\t\t// Best-effort — log would be nice but we don't have a logger here\n\t\t}\n\t}\n}\n\n// =============================================================================\n// Temp Dir Cleanup\n// =============================================================================\n\nexport function cleanupDreamTmpDirs(memoryDirs: string[]): void {\n\tfor (const memDir of memoryDirs) {\n\t\tconst tmpDir = join(memDir, DREAM_TMP_DIR);\n\t\tif (existsSync(tmpDir)) {\n\t\t\ttry {\n\t\t\t\trmSync(tmpDir, { recursive: true, force: true });\n\t\t\t} catch {\n\t\t\t\t// Best-effort cleanup\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
|