@dreb/dashboard 2.54.0 → 2.55.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/README.md CHANGED
@@ -59,6 +59,13 @@ Open `http://127.0.0.1:5343`.
59
59
  effective global nested-context trust for the viewed canonical folder. Trust
60
60
  the folder and descendants, or untrust the actual granting root (including
61
61
  its inherited descendants).
62
+ - **Memories** — dreb-only memory management for `~/.dreb/memory` and active
63
+ project `.dreb/memory` scopes. It edits existing `MEMORY.md` indexes and
64
+ direct child `.md` entries only (no create/rename and no Claude memory paths),
65
+ displays valid entry metadata or frontmatter errors, shows sanitized Markdown
66
+ preview, warns when the complete index is over 200 lines, preserves drafts on
67
+ exact-revision conflicts, and deletes entries only after synchronously cleaning
68
+ matching safe index links.
62
69
  - **Settings** — persistent defaults (provider-grouped model dropdown,
63
70
  thinking, queue modes, image handling, skill commands, transport,
64
71
  hide-thinking, compaction/retry), a scoped-models editor, per-agent model
@@ -83,6 +90,14 @@ The Settings scoped-models editor manages the persistent model-cycling scope. Se
83
90
 
84
91
  The selected project context reads effective global + project settings, but saves always write the global setting and warn if `.dreb/settings.json` shadows it. Changes seed new sessions only and never modify a running session. Running `/scoped-models` in a dashboard session opens this editor with that session's cwd selected. For persisted-setting and RPC details, see [Model Cycling](../coding-agent/docs/settings.md#model-cycling) and [`get_settings` / `set_settings`](../coding-agent/docs/rpc.md#settings).
85
92
 
93
+ ### Memories
94
+
95
+ The Memories screen exposes only dreb memory scopes: global `~/.dreb/memory` and populated project `.dreb/memory` directories derived from currently active sessions plus on-disk session cwd inventory. Empty or missing project memory directories are omitted because this screen cannot create entries; the global scope remains visible. Documents are existing-only: `MEMORY.md` is the special index, and entries are direct child `.md` files (excluding hidden/internal/path-like names). Local direct-child links in the rendered index open that entry in the current scope, while external links keep their normal safe behavior. Scope and document changes replace stale editor content with visible loading feedback.
96
+
97
+ Saves require the exact opaque SHA-256 revision of the UTF-8 content that was loaded. A stale revision returns a conflict and leaves the browser draft intact. Entry saves validate `name`, `description`, and `type` frontmatter (`user-preferences`, `good-practices`, `project`, or `navigation`); listing/reading malformed entries surfaces a metadata error instead of hiding them so they can be repaired. The index accepts Markdown, is shown complete, and warns when it exceeds the 200-line memory-index convention.
98
+
99
+ Deleting an entry requires both the entry revision and the current index revision (or `null` when no index exists). The server removes only index lines containing a Markdown link whose local target is exactly the entry filename or `./filename`; unsafe mixed-content lines fail loudly instead of being rewritten broadly. The cleaned index is written atomically before the entry is unlinked, and the original index is restored if unlinking fails, so a successful delete never leaves a matching dangling index link.
100
+
86
101
  ### Transcript images
87
102
 
88
103
  Image blocks returned by any tool and images uploaded with a user turn render
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Dashboard memory API — dreb-only global/project memory editor.
3
+ *
4
+ * Scope ids are derived from the server's current cwd inventory. Clients can
5
+ * select only those ids; absolute target paths never cross the wire as
6
+ * authority. All path handling fails closed and re-checks symlink containment
7
+ * immediately before atomic replacement.
8
+ */
9
+ import type { MemoryDocumentDto, MemoryListingDto, MemoryMutationResultDto, MemoryScopeDto } from "../shared/protocol.js";
10
+ export type MemoryOpLogger = (operation: string, scopeId: string, detail?: string) => void;
11
+ export declare const MEMORY_INDEX_FILE = "MEMORY.md";
12
+ export declare const MAX_MEMORY_CONTENT_BYTES: number;
13
+ export declare class MemoryApi {
14
+ private readonly homeDir;
15
+ private readonly log;
16
+ constructor(homeDir: string, log: MemoryOpLogger);
17
+ scopes(cwdInventory: string[]): Promise<MemoryScopeDto[]>;
18
+ listing(scopeId: string, cwdInventory: string[]): Promise<MemoryListingDto>;
19
+ private listingForScope;
20
+ readDocument(scopeId: string, file: string, cwdInventory: string[]): Promise<MemoryDocumentDto>;
21
+ private readDocumentForScope;
22
+ saveDocument(scopeId: string, file: string, body: unknown, cwdInventory: string[]): Promise<MemoryMutationResultDto>;
23
+ deleteEntry(scopeId: string, file: string, body: unknown, cwdInventory: string[]): Promise<MemoryMutationResultDto>;
24
+ private requireScope;
25
+ private canonicalMemoryRootIfExists;
26
+ private requireCanonicalMemoryRoot;
27
+ private resolveExistingTarget;
28
+ private resolveExistingTargetWithinRoot;
29
+ }
30
+ //# sourceMappingURL=memories.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memories.d.ts","sourceRoot":"","sources":["../../src/server/memories.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,OAAO,KAAK,EACX,iBAAiB,EAIjB,gBAAgB,EAChB,uBAAuB,EACvB,cAAc,EACd,MAAM,uBAAuB,CAAC;AAG/B,MAAM,MAAM,cAAc,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;AAE3F,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAC7C,eAAO,MAAM,wBAAwB,QAAc,CAAC;AAmMpD,qBAAa,SAAS;IAEpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAFrB,YACkB,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,cAAc,EACjC;IAEE,MAAM,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAgD9D;IAEK,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAEhF;YAEa,eAAe;IA+CvB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAEpG;YAEa,oBAAoB;IAc5B,YAAY,CACjB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,OAAO,EACb,YAAY,EAAE,MAAM,EAAE,GACpB,OAAO,CAAC,uBAAuB,CAAC,CAoBlC;IAEK,WAAW,CAChB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,OAAO,EACb,YAAY,EAAE,MAAM,EAAE,GACpB,OAAO,CAAC,uBAAuB,CAAC,CA8ClC;YAEa,YAAY;YAMZ,2BAA2B;YAI3B,0BAA0B;YAM1B,qBAAqB;YAIrB,+BAA+B;CAW7C","sourcesContent":["/**\n * Dashboard memory API — dreb-only global/project memory editor.\n *\n * Scope ids are derived from the server's current cwd inventory. Clients can\n * select only those ids; absolute target paths never cross the wire as\n * authority. All path handling fails closed and re-checks symlink containment\n * immediately before atomic replacement.\n */\n\nimport { createHash, randomBytes } from \"node:crypto\";\nimport type { Stats } from \"node:fs\";\nimport { open, readdir, readFile, realpath, rename, stat, unlink } from \"node:fs/promises\";\nimport { basename, join, resolve, sep } from \"node:path\";\nimport { StringDecoder } from \"node:string_decoder\";\nimport { findGitRoot, parseFrontmatter } from \"@dreb/coding-agent\";\nimport type {\n\tMemoryDocumentDto,\n\tMemoryEntryMetadataDto,\n\tMemoryEntrySummaryDto,\n\tMemoryEntryTypeDto,\n\tMemoryListingDto,\n\tMemoryMutationResultDto,\n\tMemoryScopeDto,\n} from \"../shared/protocol.js\";\nimport { canonicalizePath } from \"./files.js\";\n\nexport type MemoryOpLogger = (operation: string, scopeId: string, detail?: string) => void;\n\nexport const MEMORY_INDEX_FILE = \"MEMORY.md\";\nexport const MAX_MEMORY_CONTENT_BYTES = 1024 * 1024;\nconst VALID_ENTRY_TYPES = new Set<MemoryEntryTypeDto>([\"user-preferences\", \"good-practices\", \"project\", \"navigation\"]);\n\nfunction httpError(status: number, message: string, cause?: unknown): Error & { status: number } {\n\treturn Object.assign(new Error(message), { status, ...(cause === undefined ? {} : { cause }) });\n}\n\nfunction sha256Hex(content: string): string {\n\treturn createHash(\"sha256\").update(content, \"utf8\").digest(\"hex\");\n}\n\nfunction projectScopeId(canonicalRoot: string): string {\n\treturn `project-${sha256Hex(canonicalRoot).slice(0, 24)}`;\n}\n\nfunction isWithinCanonicalRoot(target: string, root: string): boolean {\n\tconst normalizedRoot = resolve(root);\n\tconst normalizedTarget = resolve(target);\n\treturn normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${sep}`);\n}\n\nasync function pathExists(path: string): Promise<boolean> {\n\ttry {\n\t\tawait stat(path);\n\t\treturn true;\n\t} catch (err: any) {\n\t\tif (err?.code === \"ENOENT\") return false;\n\t\tthrow err;\n\t}\n}\n\nasync function canonicalExistingDirectory(path: string): Promise<string | null> {\n\ttry {\n\t\tconst canonical = await realpath(path);\n\t\tconst info = await stat(canonical);\n\t\treturn info.isDirectory() ? canonical : null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction assertContentLimit(content: unknown): asserts content is string {\n\tif (typeof content !== \"string\") throw httpError(400, \"content must be a string\");\n\tif (Buffer.byteLength(content, \"utf8\") > MAX_MEMORY_CONTENT_BYTES) {\n\t\tthrow httpError(413, `Memory document exceeds the ${MAX_MEMORY_CONTENT_BYTES} byte limit`);\n\t}\n}\n\nfunction validateEntryFile(file: string): void {\n\tif (typeof file !== \"string\" || file.length === 0) throw httpError(400, \"file is required\");\n\tif (file.includes(\"\\0\") || file.includes(\"/\") || file.includes(\"\\\\\"))\n\t\tthrow httpError(400, `Invalid memory file: ${file}`);\n\tif (file === \".\" || file === \"..\" || file.startsWith(\".\") || file.startsWith(\"_\")) {\n\t\tthrow httpError(400, `Invalid memory file: ${file}`);\n\t}\n\tif (file.toLowerCase() === MEMORY_INDEX_FILE.toLowerCase())\n\t\tthrow httpError(400, \"MEMORY.md is the index, not an entry\");\n\tif (!/^[A-Za-z0-9][A-Za-z0-9._-]*\\.md$/.test(file))\n\t\tthrow httpError(400, `Memory entries must be .md files: ${file}`);\n}\n\nfunction validateDocumentFile(file: string): \"index\" | \"entry\" {\n\tif (file === MEMORY_INDEX_FILE) return \"index\";\n\tvalidateEntryFile(file);\n\treturn \"entry\";\n}\n\nfunction validateMetadata(frontmatter: Record<string, unknown>): MemoryEntryMetadataDto {\n\tconst { name, description, type } = frontmatter;\n\tif (typeof name !== \"string\" || name.length === 0) throw new Error(\"frontmatter.name must be a non-empty string\");\n\tif (typeof description !== \"string\" || description.length === 0) {\n\t\tthrow new Error(\"frontmatter.description must be a non-empty string\");\n\t}\n\tif (typeof type !== \"string\" || !VALID_ENTRY_TYPES.has(type as MemoryEntryTypeDto)) {\n\t\tthrow new Error(\"frontmatter.type must be one of user-preferences, good-practices, project, navigation\");\n\t}\n\treturn { name, description, type: type as MemoryEntryTypeDto };\n}\n\nfunction parseEntryMetadata(content: string): { metadata?: MemoryEntryMetadataDto; metadataError?: string } {\n\ttry {\n\t\tconst { frontmatter } = parseFrontmatter<Record<string, unknown>>(content);\n\t\treturn { metadata: validateMetadata(frontmatter) };\n\t} catch (err) {\n\t\treturn { metadataError: err instanceof Error ? err.message : String(err) };\n\t}\n}\n\nasync function requireLimitedFile(path: string): Promise<Stats> {\n\tconst info = await stat(path);\n\tif (!info.isFile()) throw httpError(400, `Not a file: ${path}`);\n\tif (info.size > MAX_MEMORY_CONTENT_BYTES)\n\t\tthrow httpError(413, `Memory document exceeds the ${MAX_MEMORY_CONTENT_BYTES} byte limit`);\n\treturn info;\n}\n\nasync function readUtf8Limited(path: string): Promise<string> {\n\tawait requireLimitedFile(path);\n\treturn readFile(path, \"utf8\");\n}\n\nasync function readEntryMetadata(path: string): Promise<{\n\tinfo: Stats;\n\tparsed: ReturnType<typeof parseEntryMetadata>;\n}> {\n\tconst info = await requireLimitedFile(path);\n\tconst handle = await open(path, \"r\");\n\tconst decoder = new StringDecoder(\"utf8\");\n\tconst chunk = Buffer.allocUnsafe(16 * 1024);\n\tlet prefix = \"\";\n\tlet position = 0;\n\ttry {\n\t\twhile (position < info.size) {\n\t\t\tconst { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, info.size - position), position);\n\t\t\tif (bytesRead === 0) break;\n\t\t\tposition += bytesRead;\n\t\t\tprefix += decoder.write(chunk.subarray(0, bytesRead));\n\t\t\tif (position === bytesRead && !prefix.startsWith(\"---\")) break;\n\t\t\tconst end = prefix.indexOf(\"\\n---\", 3);\n\t\t\tif (end !== -1) {\n\t\t\t\tprefix = prefix.slice(0, end + 4);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tprefix += decoder.end();\n\t} finally {\n\t\tawait handle.close();\n\t}\n\treturn { info, parsed: parseEntryMetadata(prefix) };\n}\n\nasync function atomicReplace(path: string, content: string): Promise<void> {\n\tconst dir = resolve(path, \"..\");\n\tconst temp = join(dir, `.dreb-memory-${process.pid}-${Date.now()}-${randomBytes(6).toString(\"hex\")}.tmp`);\n\tlet handle: Awaited<ReturnType<typeof open>> | undefined;\n\ttry {\n\t\thandle = await open(temp, \"wx\");\n\t\tawait handle.writeFile(content, \"utf8\");\n\t\tawait handle.close();\n\t\thandle = undefined;\n\t\tawait rename(temp, path);\n\t} catch (err) {\n\t\tif (handle) await handle.close().catch(() => {});\n\t\tawait unlink(temp).catch(() => {});\n\t\tthrow err;\n\t}\n}\n\nfunction splitLinesPreserveEndings(content: string): string[] {\n\tconst matches = content.match(/.*(?:\\r\\n|\\n|\\r|$)/g) ?? [];\n\treturn matches.filter((part, index) => part.length > 0 || index < matches.length - 1);\n}\n\nfunction localMarkdownTargets(line: string): string[] {\n\tconst targets: string[] = [];\n\tconst regex = /\\[[^\\]]+\\]\\(([^)]+)\\)/g;\n\tlet match = regex.exec(line);\n\twhile (match) {\n\t\ttargets.push(match[1]);\n\t\tmatch = regex.exec(line);\n\t}\n\treturn targets;\n}\n\nfunction targetMatchesFilename(target: string, filename: string): boolean {\n\treturn target === filename || target === `./${filename}`;\n}\n\nfunction escapeRegExp(text: string): string {\n\treturn text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nfunction removeIndexLinks(indexContent: string, filename: string): { content: string; changed: boolean } {\n\tconst lines = splitLinesPreserveEndings(indexContent);\n\tconst escaped = escapeRegExp(filename);\n\tconst safeLine = new RegExp(\n\t\t`^\\\\s*[-*+]\\\\s+\\\\[[^\\\\]]+\\\\]\\\\((?:\\\\./)?${escaped}\\\\)(?:\\\\s+(?:[-—:]|—)\\\\s+.*)?\\\\s*(?:\\\\r?\\\\n|\\\\r)?$`,\n\t\t\"u\",\n\t);\n\tlet changed = false;\n\tconst kept: string[] = [];\n\tfor (const line of lines) {\n\t\tconst matches = localMarkdownTargets(line).some((target) => targetMatchesFilename(target, filename));\n\t\tif (!matches) {\n\t\t\tkept.push(line);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!safeLine.test(line)) {\n\t\t\tthrow httpError(409, `Index line for ${filename} is not safe to remove automatically`);\n\t\t}\n\t\tchanged = true;\n\t}\n\treturn { content: kept.join(\"\"), changed };\n}\n\nexport class MemoryApi {\n\tconstructor(\n\t\tprivate readonly homeDir: string,\n\t\tprivate readonly log: MemoryOpLogger,\n\t) {}\n\n\tasync scopes(cwdInventory: string[]): Promise<MemoryScopeDto[]> {\n\t\tconst scopes: MemoryScopeDto[] = [];\n\t\tconst canonicalHome = (await canonicalExistingDirectory(this.homeDir)) ?? resolve(this.homeDir);\n\t\tconst globalMemoryDir = resolve(canonicalHome, \".dreb\", \"memory\");\n\t\tscopes.push({\n\t\t\tid: \"global\",\n\t\t\tkind: \"global\",\n\t\t\tlabel: \"global\",\n\t\t\tmemoryDir: globalMemoryDir,\n\t\t\texists: await pathExists(globalMemoryDir),\n\t\t});\n\n\t\tconst roots = new Map<string, string>();\n\t\tfor (const cwd of cwdInventory) {\n\t\t\tif (typeof cwd !== \"string\" || cwd.length === 0) continue;\n\t\t\tconst existingCwd = await canonicalExistingDirectory(cwd);\n\t\t\tif (!existingCwd) continue;\n\t\t\tconst root = findGitRoot(existingCwd) ?? existingCwd;\n\t\t\tconst canonicalRoot = await canonicalExistingDirectory(root);\n\t\t\tif (!canonicalRoot || canonicalRoot === canonicalHome) continue;\n\t\t\troots.set(canonicalRoot, canonicalRoot);\n\t\t}\n\t\tfor (const root of [...roots.keys()].sort((a, b) => a.localeCompare(b))) {\n\t\t\tconst memoryDir = join(root, \".dreb\", \"memory\");\n\t\t\tconst canonicalMemoryDir = await canonicalExistingDirectory(memoryDir);\n\t\t\tif (!canonicalMemoryDir) continue;\n\t\t\tconst dirents = await readdir(canonicalMemoryDir, { withFileTypes: true });\n\t\t\tconst browseable = dirents.some((dirent) => {\n\t\t\t\tif (!dirent.isFile()) return false;\n\t\t\t\tif (dirent.name === MEMORY_INDEX_FILE) return true;\n\t\t\t\ttry {\n\t\t\t\t\tvalidateEntryFile(dirent.name);\n\t\t\t\t\treturn true;\n\t\t\t\t} catch {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t});\n\t\t\tif (!browseable) continue;\n\t\t\tscopes.push({\n\t\t\t\tid: projectScopeId(root),\n\t\t\t\tkind: \"project\",\n\t\t\t\tlabel: basename(root) || root,\n\t\t\t\tprojectRoot: root,\n\t\t\t\tmemoryDir: canonicalMemoryDir,\n\t\t\t\texists: true,\n\t\t\t});\n\t\t}\n\t\treturn scopes;\n\t}\n\n\tasync listing(scopeId: string, cwdInventory: string[]): Promise<MemoryListingDto> {\n\t\treturn this.listingForScope(await this.requireScope(scopeId, cwdInventory));\n\t}\n\n\tprivate async listingForScope(scope: MemoryScopeDto): Promise<MemoryListingDto> {\n\t\tconst memoryRoot = await this.canonicalMemoryRootIfExists(scope);\n\t\tif (!memoryRoot) {\n\t\t\tthis.log(\"list\", scope.id);\n\t\t\treturn { scope, indexContent: null, indexRevision: null, indexOverLimit: false, entries: [] };\n\t\t}\n\n\t\tlet indexContent: string | null = null;\n\t\tlet indexRevision: string | null = null;\n\t\ttry {\n\t\t\tconst indexPath = await this.resolveExistingTargetWithinRoot(memoryRoot, MEMORY_INDEX_FILE, \"index\");\n\t\t\tindexContent = await readUtf8Limited(indexPath);\n\t\t\tindexRevision = sha256Hex(indexContent);\n\t\t} catch (err: any) {\n\t\t\tif (err?.status !== 404 && err?.code !== \"ENOENT\") throw err;\n\t\t}\n\n\t\tconst dirents = await readdir(memoryRoot, { withFileTypes: true });\n\t\tconst entries: MemoryEntrySummaryDto[] = [];\n\t\tfor (const dirent of dirents) {\n\t\t\tif (!dirent.isFile()) continue;\n\t\t\tif (dirent.name === MEMORY_INDEX_FILE) continue;\n\t\t\ttry {\n\t\t\t\tvalidateEntryFile(dirent.name);\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst path = await this.resolveExistingTargetWithinRoot(memoryRoot, dirent.name, \"entry\");\n\t\t\tconst { info, parsed } = await readEntryMetadata(path);\n\t\t\tentries.push({\n\t\t\t\tfile: dirent.name,\n\t\t\t\t...parsed,\n\t\t\t\tmodified: info.mtime.toISOString(),\n\t\t\t\tsize: info.size,\n\t\t\t});\n\t\t}\n\t\tentries.sort((a, b) => a.file.localeCompare(b.file));\n\t\tthis.log(\"list\", scope.id);\n\t\treturn {\n\t\t\tscope: { ...scope, exists: true, memoryDir: memoryRoot },\n\t\t\tindexContent,\n\t\t\tindexRevision,\n\t\t\tindexOverLimit: (indexContent?.split(/\\r\\n|\\n|\\r/).length ?? 0) > 200,\n\t\t\tentries,\n\t\t};\n\t}\n\n\tasync readDocument(scopeId: string, file: string, cwdInventory: string[]): Promise<MemoryDocumentDto> {\n\t\treturn this.readDocumentForScope(await this.requireScope(scopeId, cwdInventory), file);\n\t}\n\n\tprivate async readDocumentForScope(scope: MemoryScopeDto, file: string): Promise<MemoryDocumentDto> {\n\t\tconst kind = validateDocumentFile(file);\n\t\tconst path = await this.resolveExistingTarget(scope, file, kind);\n\t\tconst content = await readUtf8Limited(path);\n\t\tthis.log(\"read\", scope.id, file);\n\t\treturn {\n\t\t\tkind,\n\t\t\tfile,\n\t\t\tcontent,\n\t\t\trevision: sha256Hex(content),\n\t\t\t...(kind === \"entry\" ? parseEntryMetadata(content) : {}),\n\t\t};\n\t}\n\n\tasync saveDocument(\n\t\tscopeId: string,\n\t\tfile: string,\n\t\tbody: unknown,\n\t\tcwdInventory: string[],\n\t): Promise<MemoryMutationResultDto> {\n\t\tconst kind = validateDocumentFile(file);\n\t\tif (!body || typeof body !== \"object\" || Array.isArray(body)) throw httpError(400, \"JSON body is required\");\n\t\tconst { content, revision } = body as Record<string, unknown>;\n\t\tassertContentLimit(content);\n\t\tif (typeof revision !== \"string\" || revision.length === 0) throw httpError(400, \"revision is required\");\n\t\tif (kind === \"entry\") {\n\t\t\tconst parsed = parseEntryMetadata(content);\n\t\t\tif (parsed.metadataError) throw httpError(400, parsed.metadataError);\n\t\t}\n\t\tconst scope = await this.requireScope(scopeId, cwdInventory);\n\t\tconst path = await this.resolveExistingTarget(scope, file, kind);\n\t\tconst current = await readUtf8Limited(path);\n\t\tif (sha256Hex(current) !== revision) throw httpError(409, \"Memory document is stale; refresh before saving\");\n\t\tconst beforeReplace = await this.resolveExistingTarget(scope, file, kind);\n\t\tawait atomicReplace(beforeReplace, content);\n\t\tconst document = await this.readDocumentForScope(scope, file);\n\t\tconst listing = await this.listingForScope(scope);\n\t\tthis.log(\"save\", scope.id, file);\n\t\treturn { listing, document };\n\t}\n\n\tasync deleteEntry(\n\t\tscopeId: string,\n\t\tfile: string,\n\t\tbody: unknown,\n\t\tcwdInventory: string[],\n\t): Promise<MemoryMutationResultDto> {\n\t\tvalidateEntryFile(file);\n\t\tif (!body || typeof body !== \"object\" || Array.isArray(body)) throw httpError(400, \"JSON body is required\");\n\t\tconst { revision, indexRevision } = body as Record<string, unknown>;\n\t\tif (typeof revision !== \"string\" || revision.length === 0) throw httpError(400, \"revision is required\");\n\t\tif (indexRevision !== null && typeof indexRevision !== \"string\")\n\t\t\tthrow httpError(400, \"indexRevision must be a string or null\");\n\t\tconst scope = await this.requireScope(scopeId, cwdInventory);\n\t\tconst entryPath = await this.resolveExistingTarget(scope, file, \"entry\");\n\t\tconst originalEntry = await readUtf8Limited(entryPath);\n\t\tif (sha256Hex(originalEntry) !== revision) throw httpError(409, \"Memory entry is stale; refresh before deleting\");\n\n\t\tconst memoryRoot = await this.requireCanonicalMemoryRoot(scope);\n\t\tlet indexPath = join(memoryRoot, MEMORY_INDEX_FILE);\n\t\tlet originalIndex: string | null = null;\n\t\ttry {\n\t\t\tindexPath = await this.resolveExistingTarget(scope, MEMORY_INDEX_FILE, \"index\");\n\t\t\toriginalIndex = await readUtf8Limited(indexPath);\n\t\t} catch (err: any) {\n\t\t\tif (err?.code !== \"ENOENT\" && err?.status !== 404) throw err;\n\t\t}\n\t\tconst actualIndexRevision = originalIndex === null ? null : sha256Hex(originalIndex);\n\t\tif (actualIndexRevision !== indexRevision) throw httpError(409, \"Memory index is stale; refresh before deleting\");\n\n\t\tlet updatedIndex: string | null = originalIndex;\n\t\tif (originalIndex !== null) updatedIndex = removeIndexLinks(originalIndex, file).content;\n\t\tif (updatedIndex !== null && updatedIndex !== originalIndex) {\n\t\t\tindexPath = await this.resolveExistingTarget(scope, MEMORY_INDEX_FILE, \"index\");\n\t\t\tawait atomicReplace(indexPath, updatedIndex);\n\t\t}\n\t\ttry {\n\t\t\tconst beforeUnlink = await this.resolveExistingTarget(scope, file, \"entry\");\n\t\t\tawait unlink(beforeUnlink);\n\t\t} catch (err) {\n\t\t\tif (originalIndex !== null && updatedIndex !== originalIndex) await atomicReplace(indexPath, originalIndex);\n\t\t\tthrow err;\n\t\t}\n\t\tconst listing = await this.listingForScope(scope);\n\t\tif (\n\t\t\tlisting.indexContent &&\n\t\t\tlocalMarkdownTargets(listing.indexContent).some((target) => targetMatchesFilename(target, file))\n\t\t) {\n\t\t\tthrow httpError(500, `Delete left a dangling index link for ${file}`);\n\t\t}\n\t\tthis.log(\"delete\", scope.id, file);\n\t\treturn { listing };\n\t}\n\n\tprivate async requireScope(scopeId: string, cwdInventory: string[]): Promise<MemoryScopeDto> {\n\t\tconst scope = (await this.scopes(cwdInventory)).find((item) => item.id === scopeId);\n\t\tif (!scope) throw httpError(404, `Unknown memory scope: ${scopeId}`);\n\t\treturn scope;\n\t}\n\n\tprivate async canonicalMemoryRootIfExists(scope: MemoryScopeDto): Promise<string | null> {\n\t\treturn canonicalExistingDirectory(scope.memoryDir);\n\t}\n\n\tprivate async requireCanonicalMemoryRoot(scope: MemoryScopeDto): Promise<string> {\n\t\tconst root = await this.canonicalMemoryRootIfExists(scope);\n\t\tif (!root) throw httpError(404, `Memory directory does not exist: ${scope.memoryDir}`);\n\t\treturn root;\n\t}\n\n\tprivate async resolveExistingTarget(scope: MemoryScopeDto, file: string, kind: \"index\" | \"entry\"): Promise<string> {\n\t\treturn this.resolveExistingTargetWithinRoot(await this.requireCanonicalMemoryRoot(scope), file, kind);\n\t}\n\n\tprivate async resolveExistingTargetWithinRoot(\n\t\tmemoryRoot: string,\n\t\tfile: string,\n\t\tkind: \"index\" | \"entry\",\n\t): Promise<string> {\n\t\tif (kind === \"index\" && file !== MEMORY_INDEX_FILE) throw httpError(400, \"Invalid index file\");\n\t\tif (kind === \"entry\") validateEntryFile(file);\n\t\tconst target = await canonicalizePath(join(memoryRoot, file), { mustExist: true });\n\t\tif (!isWithinCanonicalRoot(target, memoryRoot)) throw httpError(400, `Memory target escapes scope: ${file}`);\n\t\treturn target;\n\t}\n}\n"]}
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Dashboard memory API — dreb-only global/project memory editor.
3
+ *
4
+ * Scope ids are derived from the server's current cwd inventory. Clients can
5
+ * select only those ids; absolute target paths never cross the wire as
6
+ * authority. All path handling fails closed and re-checks symlink containment
7
+ * immediately before atomic replacement.
8
+ */
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import { open, readdir, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
11
+ import { basename, join, resolve, sep } from "node:path";
12
+ import { StringDecoder } from "node:string_decoder";
13
+ import { findGitRoot, parseFrontmatter } from "@dreb/coding-agent";
14
+ import { canonicalizePath } from "./files.js";
15
+ export const MEMORY_INDEX_FILE = "MEMORY.md";
16
+ export const MAX_MEMORY_CONTENT_BYTES = 1024 * 1024;
17
+ const VALID_ENTRY_TYPES = new Set(["user-preferences", "good-practices", "project", "navigation"]);
18
+ function httpError(status, message, cause) {
19
+ return Object.assign(new Error(message), { status, ...(cause === undefined ? {} : { cause }) });
20
+ }
21
+ function sha256Hex(content) {
22
+ return createHash("sha256").update(content, "utf8").digest("hex");
23
+ }
24
+ function projectScopeId(canonicalRoot) {
25
+ return `project-${sha256Hex(canonicalRoot).slice(0, 24)}`;
26
+ }
27
+ function isWithinCanonicalRoot(target, root) {
28
+ const normalizedRoot = resolve(root);
29
+ const normalizedTarget = resolve(target);
30
+ return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${sep}`);
31
+ }
32
+ async function pathExists(path) {
33
+ try {
34
+ await stat(path);
35
+ return true;
36
+ }
37
+ catch (err) {
38
+ if (err?.code === "ENOENT")
39
+ return false;
40
+ throw err;
41
+ }
42
+ }
43
+ async function canonicalExistingDirectory(path) {
44
+ try {
45
+ const canonical = await realpath(path);
46
+ const info = await stat(canonical);
47
+ return info.isDirectory() ? canonical : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ function assertContentLimit(content) {
54
+ if (typeof content !== "string")
55
+ throw httpError(400, "content must be a string");
56
+ if (Buffer.byteLength(content, "utf8") > MAX_MEMORY_CONTENT_BYTES) {
57
+ throw httpError(413, `Memory document exceeds the ${MAX_MEMORY_CONTENT_BYTES} byte limit`);
58
+ }
59
+ }
60
+ function validateEntryFile(file) {
61
+ if (typeof file !== "string" || file.length === 0)
62
+ throw httpError(400, "file is required");
63
+ if (file.includes("\0") || file.includes("/") || file.includes("\\"))
64
+ throw httpError(400, `Invalid memory file: ${file}`);
65
+ if (file === "." || file === ".." || file.startsWith(".") || file.startsWith("_")) {
66
+ throw httpError(400, `Invalid memory file: ${file}`);
67
+ }
68
+ if (file.toLowerCase() === MEMORY_INDEX_FILE.toLowerCase())
69
+ throw httpError(400, "MEMORY.md is the index, not an entry");
70
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/.test(file))
71
+ throw httpError(400, `Memory entries must be .md files: ${file}`);
72
+ }
73
+ function validateDocumentFile(file) {
74
+ if (file === MEMORY_INDEX_FILE)
75
+ return "index";
76
+ validateEntryFile(file);
77
+ return "entry";
78
+ }
79
+ function validateMetadata(frontmatter) {
80
+ const { name, description, type } = frontmatter;
81
+ if (typeof name !== "string" || name.length === 0)
82
+ throw new Error("frontmatter.name must be a non-empty string");
83
+ if (typeof description !== "string" || description.length === 0) {
84
+ throw new Error("frontmatter.description must be a non-empty string");
85
+ }
86
+ if (typeof type !== "string" || !VALID_ENTRY_TYPES.has(type)) {
87
+ throw new Error("frontmatter.type must be one of user-preferences, good-practices, project, navigation");
88
+ }
89
+ return { name, description, type: type };
90
+ }
91
+ function parseEntryMetadata(content) {
92
+ try {
93
+ const { frontmatter } = parseFrontmatter(content);
94
+ return { metadata: validateMetadata(frontmatter) };
95
+ }
96
+ catch (err) {
97
+ return { metadataError: err instanceof Error ? err.message : String(err) };
98
+ }
99
+ }
100
+ async function requireLimitedFile(path) {
101
+ const info = await stat(path);
102
+ if (!info.isFile())
103
+ throw httpError(400, `Not a file: ${path}`);
104
+ if (info.size > MAX_MEMORY_CONTENT_BYTES)
105
+ throw httpError(413, `Memory document exceeds the ${MAX_MEMORY_CONTENT_BYTES} byte limit`);
106
+ return info;
107
+ }
108
+ async function readUtf8Limited(path) {
109
+ await requireLimitedFile(path);
110
+ return readFile(path, "utf8");
111
+ }
112
+ async function readEntryMetadata(path) {
113
+ const info = await requireLimitedFile(path);
114
+ const handle = await open(path, "r");
115
+ const decoder = new StringDecoder("utf8");
116
+ const chunk = Buffer.allocUnsafe(16 * 1024);
117
+ let prefix = "";
118
+ let position = 0;
119
+ try {
120
+ while (position < info.size) {
121
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, info.size - position), position);
122
+ if (bytesRead === 0)
123
+ break;
124
+ position += bytesRead;
125
+ prefix += decoder.write(chunk.subarray(0, bytesRead));
126
+ if (position === bytesRead && !prefix.startsWith("---"))
127
+ break;
128
+ const end = prefix.indexOf("\n---", 3);
129
+ if (end !== -1) {
130
+ prefix = prefix.slice(0, end + 4);
131
+ break;
132
+ }
133
+ }
134
+ prefix += decoder.end();
135
+ }
136
+ finally {
137
+ await handle.close();
138
+ }
139
+ return { info, parsed: parseEntryMetadata(prefix) };
140
+ }
141
+ async function atomicReplace(path, content) {
142
+ const dir = resolve(path, "..");
143
+ const temp = join(dir, `.dreb-memory-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}.tmp`);
144
+ let handle;
145
+ try {
146
+ handle = await open(temp, "wx");
147
+ await handle.writeFile(content, "utf8");
148
+ await handle.close();
149
+ handle = undefined;
150
+ await rename(temp, path);
151
+ }
152
+ catch (err) {
153
+ if (handle)
154
+ await handle.close().catch(() => { });
155
+ await unlink(temp).catch(() => { });
156
+ throw err;
157
+ }
158
+ }
159
+ function splitLinesPreserveEndings(content) {
160
+ const matches = content.match(/.*(?:\r\n|\n|\r|$)/g) ?? [];
161
+ return matches.filter((part, index) => part.length > 0 || index < matches.length - 1);
162
+ }
163
+ function localMarkdownTargets(line) {
164
+ const targets = [];
165
+ const regex = /\[[^\]]+\]\(([^)]+)\)/g;
166
+ let match = regex.exec(line);
167
+ while (match) {
168
+ targets.push(match[1]);
169
+ match = regex.exec(line);
170
+ }
171
+ return targets;
172
+ }
173
+ function targetMatchesFilename(target, filename) {
174
+ return target === filename || target === `./${filename}`;
175
+ }
176
+ function escapeRegExp(text) {
177
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
178
+ }
179
+ function removeIndexLinks(indexContent, filename) {
180
+ const lines = splitLinesPreserveEndings(indexContent);
181
+ const escaped = escapeRegExp(filename);
182
+ const safeLine = new RegExp(`^\\s*[-*+]\\s+\\[[^\\]]+\\]\\((?:\\./)?${escaped}\\)(?:\\s+(?:[-—:]|—)\\s+.*)?\\s*(?:\\r?\\n|\\r)?$`, "u");
183
+ let changed = false;
184
+ const kept = [];
185
+ for (const line of lines) {
186
+ const matches = localMarkdownTargets(line).some((target) => targetMatchesFilename(target, filename));
187
+ if (!matches) {
188
+ kept.push(line);
189
+ continue;
190
+ }
191
+ if (!safeLine.test(line)) {
192
+ throw httpError(409, `Index line for ${filename} is not safe to remove automatically`);
193
+ }
194
+ changed = true;
195
+ }
196
+ return { content: kept.join(""), changed };
197
+ }
198
+ export class MemoryApi {
199
+ homeDir;
200
+ log;
201
+ constructor(homeDir, log) {
202
+ this.homeDir = homeDir;
203
+ this.log = log;
204
+ }
205
+ async scopes(cwdInventory) {
206
+ const scopes = [];
207
+ const canonicalHome = (await canonicalExistingDirectory(this.homeDir)) ?? resolve(this.homeDir);
208
+ const globalMemoryDir = resolve(canonicalHome, ".dreb", "memory");
209
+ scopes.push({
210
+ id: "global",
211
+ kind: "global",
212
+ label: "global",
213
+ memoryDir: globalMemoryDir,
214
+ exists: await pathExists(globalMemoryDir),
215
+ });
216
+ const roots = new Map();
217
+ for (const cwd of cwdInventory) {
218
+ if (typeof cwd !== "string" || cwd.length === 0)
219
+ continue;
220
+ const existingCwd = await canonicalExistingDirectory(cwd);
221
+ if (!existingCwd)
222
+ continue;
223
+ const root = findGitRoot(existingCwd) ?? existingCwd;
224
+ const canonicalRoot = await canonicalExistingDirectory(root);
225
+ if (!canonicalRoot || canonicalRoot === canonicalHome)
226
+ continue;
227
+ roots.set(canonicalRoot, canonicalRoot);
228
+ }
229
+ for (const root of [...roots.keys()].sort((a, b) => a.localeCompare(b))) {
230
+ const memoryDir = join(root, ".dreb", "memory");
231
+ const canonicalMemoryDir = await canonicalExistingDirectory(memoryDir);
232
+ if (!canonicalMemoryDir)
233
+ continue;
234
+ const dirents = await readdir(canonicalMemoryDir, { withFileTypes: true });
235
+ const browseable = dirents.some((dirent) => {
236
+ if (!dirent.isFile())
237
+ return false;
238
+ if (dirent.name === MEMORY_INDEX_FILE)
239
+ return true;
240
+ try {
241
+ validateEntryFile(dirent.name);
242
+ return true;
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ });
248
+ if (!browseable)
249
+ continue;
250
+ scopes.push({
251
+ id: projectScopeId(root),
252
+ kind: "project",
253
+ label: basename(root) || root,
254
+ projectRoot: root,
255
+ memoryDir: canonicalMemoryDir,
256
+ exists: true,
257
+ });
258
+ }
259
+ return scopes;
260
+ }
261
+ async listing(scopeId, cwdInventory) {
262
+ return this.listingForScope(await this.requireScope(scopeId, cwdInventory));
263
+ }
264
+ async listingForScope(scope) {
265
+ const memoryRoot = await this.canonicalMemoryRootIfExists(scope);
266
+ if (!memoryRoot) {
267
+ this.log("list", scope.id);
268
+ return { scope, indexContent: null, indexRevision: null, indexOverLimit: false, entries: [] };
269
+ }
270
+ let indexContent = null;
271
+ let indexRevision = null;
272
+ try {
273
+ const indexPath = await this.resolveExistingTargetWithinRoot(memoryRoot, MEMORY_INDEX_FILE, "index");
274
+ indexContent = await readUtf8Limited(indexPath);
275
+ indexRevision = sha256Hex(indexContent);
276
+ }
277
+ catch (err) {
278
+ if (err?.status !== 404 && err?.code !== "ENOENT")
279
+ throw err;
280
+ }
281
+ const dirents = await readdir(memoryRoot, { withFileTypes: true });
282
+ const entries = [];
283
+ for (const dirent of dirents) {
284
+ if (!dirent.isFile())
285
+ continue;
286
+ if (dirent.name === MEMORY_INDEX_FILE)
287
+ continue;
288
+ try {
289
+ validateEntryFile(dirent.name);
290
+ }
291
+ catch {
292
+ continue;
293
+ }
294
+ const path = await this.resolveExistingTargetWithinRoot(memoryRoot, dirent.name, "entry");
295
+ const { info, parsed } = await readEntryMetadata(path);
296
+ entries.push({
297
+ file: dirent.name,
298
+ ...parsed,
299
+ modified: info.mtime.toISOString(),
300
+ size: info.size,
301
+ });
302
+ }
303
+ entries.sort((a, b) => a.file.localeCompare(b.file));
304
+ this.log("list", scope.id);
305
+ return {
306
+ scope: { ...scope, exists: true, memoryDir: memoryRoot },
307
+ indexContent,
308
+ indexRevision,
309
+ indexOverLimit: (indexContent?.split(/\r\n|\n|\r/).length ?? 0) > 200,
310
+ entries,
311
+ };
312
+ }
313
+ async readDocument(scopeId, file, cwdInventory) {
314
+ return this.readDocumentForScope(await this.requireScope(scopeId, cwdInventory), file);
315
+ }
316
+ async readDocumentForScope(scope, file) {
317
+ const kind = validateDocumentFile(file);
318
+ const path = await this.resolveExistingTarget(scope, file, kind);
319
+ const content = await readUtf8Limited(path);
320
+ this.log("read", scope.id, file);
321
+ return {
322
+ kind,
323
+ file,
324
+ content,
325
+ revision: sha256Hex(content),
326
+ ...(kind === "entry" ? parseEntryMetadata(content) : {}),
327
+ };
328
+ }
329
+ async saveDocument(scopeId, file, body, cwdInventory) {
330
+ const kind = validateDocumentFile(file);
331
+ if (!body || typeof body !== "object" || Array.isArray(body))
332
+ throw httpError(400, "JSON body is required");
333
+ const { content, revision } = body;
334
+ assertContentLimit(content);
335
+ if (typeof revision !== "string" || revision.length === 0)
336
+ throw httpError(400, "revision is required");
337
+ if (kind === "entry") {
338
+ const parsed = parseEntryMetadata(content);
339
+ if (parsed.metadataError)
340
+ throw httpError(400, parsed.metadataError);
341
+ }
342
+ const scope = await this.requireScope(scopeId, cwdInventory);
343
+ const path = await this.resolveExistingTarget(scope, file, kind);
344
+ const current = await readUtf8Limited(path);
345
+ if (sha256Hex(current) !== revision)
346
+ throw httpError(409, "Memory document is stale; refresh before saving");
347
+ const beforeReplace = await this.resolveExistingTarget(scope, file, kind);
348
+ await atomicReplace(beforeReplace, content);
349
+ const document = await this.readDocumentForScope(scope, file);
350
+ const listing = await this.listingForScope(scope);
351
+ this.log("save", scope.id, file);
352
+ return { listing, document };
353
+ }
354
+ async deleteEntry(scopeId, file, body, cwdInventory) {
355
+ validateEntryFile(file);
356
+ if (!body || typeof body !== "object" || Array.isArray(body))
357
+ throw httpError(400, "JSON body is required");
358
+ const { revision, indexRevision } = body;
359
+ if (typeof revision !== "string" || revision.length === 0)
360
+ throw httpError(400, "revision is required");
361
+ if (indexRevision !== null && typeof indexRevision !== "string")
362
+ throw httpError(400, "indexRevision must be a string or null");
363
+ const scope = await this.requireScope(scopeId, cwdInventory);
364
+ const entryPath = await this.resolveExistingTarget(scope, file, "entry");
365
+ const originalEntry = await readUtf8Limited(entryPath);
366
+ if (sha256Hex(originalEntry) !== revision)
367
+ throw httpError(409, "Memory entry is stale; refresh before deleting");
368
+ const memoryRoot = await this.requireCanonicalMemoryRoot(scope);
369
+ let indexPath = join(memoryRoot, MEMORY_INDEX_FILE);
370
+ let originalIndex = null;
371
+ try {
372
+ indexPath = await this.resolveExistingTarget(scope, MEMORY_INDEX_FILE, "index");
373
+ originalIndex = await readUtf8Limited(indexPath);
374
+ }
375
+ catch (err) {
376
+ if (err?.code !== "ENOENT" && err?.status !== 404)
377
+ throw err;
378
+ }
379
+ const actualIndexRevision = originalIndex === null ? null : sha256Hex(originalIndex);
380
+ if (actualIndexRevision !== indexRevision)
381
+ throw httpError(409, "Memory index is stale; refresh before deleting");
382
+ let updatedIndex = originalIndex;
383
+ if (originalIndex !== null)
384
+ updatedIndex = removeIndexLinks(originalIndex, file).content;
385
+ if (updatedIndex !== null && updatedIndex !== originalIndex) {
386
+ indexPath = await this.resolveExistingTarget(scope, MEMORY_INDEX_FILE, "index");
387
+ await atomicReplace(indexPath, updatedIndex);
388
+ }
389
+ try {
390
+ const beforeUnlink = await this.resolveExistingTarget(scope, file, "entry");
391
+ await unlink(beforeUnlink);
392
+ }
393
+ catch (err) {
394
+ if (originalIndex !== null && updatedIndex !== originalIndex)
395
+ await atomicReplace(indexPath, originalIndex);
396
+ throw err;
397
+ }
398
+ const listing = await this.listingForScope(scope);
399
+ if (listing.indexContent &&
400
+ localMarkdownTargets(listing.indexContent).some((target) => targetMatchesFilename(target, file))) {
401
+ throw httpError(500, `Delete left a dangling index link for ${file}`);
402
+ }
403
+ this.log("delete", scope.id, file);
404
+ return { listing };
405
+ }
406
+ async requireScope(scopeId, cwdInventory) {
407
+ const scope = (await this.scopes(cwdInventory)).find((item) => item.id === scopeId);
408
+ if (!scope)
409
+ throw httpError(404, `Unknown memory scope: ${scopeId}`);
410
+ return scope;
411
+ }
412
+ async canonicalMemoryRootIfExists(scope) {
413
+ return canonicalExistingDirectory(scope.memoryDir);
414
+ }
415
+ async requireCanonicalMemoryRoot(scope) {
416
+ const root = await this.canonicalMemoryRootIfExists(scope);
417
+ if (!root)
418
+ throw httpError(404, `Memory directory does not exist: ${scope.memoryDir}`);
419
+ return root;
420
+ }
421
+ async resolveExistingTarget(scope, file, kind) {
422
+ return this.resolveExistingTargetWithinRoot(await this.requireCanonicalMemoryRoot(scope), file, kind);
423
+ }
424
+ async resolveExistingTargetWithinRoot(memoryRoot, file, kind) {
425
+ if (kind === "index" && file !== MEMORY_INDEX_FILE)
426
+ throw httpError(400, "Invalid index file");
427
+ if (kind === "entry")
428
+ validateEntryFile(file);
429
+ const target = await canonicalizePath(join(memoryRoot, file), { mustExist: true });
430
+ if (!isWithinCanonicalRoot(target, memoryRoot))
431
+ throw httpError(400, `Memory target escapes scope: ${file}`);
432
+ return target;
433
+ }
434
+ }
435
+ //# sourceMappingURL=memories.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memories.js","sourceRoot":"","sources":["../../src/server/memories.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Dashboard memory API — dreb-only global/project memory editor.\n *\n * Scope ids are derived from the server's current cwd inventory. Clients can\n * select only those ids; absolute target paths never cross the wire as\n * authority. All path handling fails closed and re-checks symlink containment\n * immediately before atomic replacement.\n */\n\nimport { createHash, randomBytes } from \"node:crypto\";\nimport type { Stats } from \"node:fs\";\nimport { open, readdir, readFile, realpath, rename, stat, unlink } from \"node:fs/promises\";\nimport { basename, join, resolve, sep } from \"node:path\";\nimport { StringDecoder } from \"node:string_decoder\";\nimport { findGitRoot, parseFrontmatter } from \"@dreb/coding-agent\";\nimport type {\n\tMemoryDocumentDto,\n\tMemoryEntryMetadataDto,\n\tMemoryEntrySummaryDto,\n\tMemoryEntryTypeDto,\n\tMemoryListingDto,\n\tMemoryMutationResultDto,\n\tMemoryScopeDto,\n} from \"../shared/protocol.js\";\nimport { canonicalizePath } from \"./files.js\";\n\nexport type MemoryOpLogger = (operation: string, scopeId: string, detail?: string) => void;\n\nexport const MEMORY_INDEX_FILE = \"MEMORY.md\";\nexport const MAX_MEMORY_CONTENT_BYTES = 1024 * 1024;\nconst VALID_ENTRY_TYPES = new Set<MemoryEntryTypeDto>([\"user-preferences\", \"good-practices\", \"project\", \"navigation\"]);\n\nfunction httpError(status: number, message: string, cause?: unknown): Error & { status: number } {\n\treturn Object.assign(new Error(message), { status, ...(cause === undefined ? {} : { cause }) });\n}\n\nfunction sha256Hex(content: string): string {\n\treturn createHash(\"sha256\").update(content, \"utf8\").digest(\"hex\");\n}\n\nfunction projectScopeId(canonicalRoot: string): string {\n\treturn `project-${sha256Hex(canonicalRoot).slice(0, 24)}`;\n}\n\nfunction isWithinCanonicalRoot(target: string, root: string): boolean {\n\tconst normalizedRoot = resolve(root);\n\tconst normalizedTarget = resolve(target);\n\treturn normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${sep}`);\n}\n\nasync function pathExists(path: string): Promise<boolean> {\n\ttry {\n\t\tawait stat(path);\n\t\treturn true;\n\t} catch (err: any) {\n\t\tif (err?.code === \"ENOENT\") return false;\n\t\tthrow err;\n\t}\n}\n\nasync function canonicalExistingDirectory(path: string): Promise<string | null> {\n\ttry {\n\t\tconst canonical = await realpath(path);\n\t\tconst info = await stat(canonical);\n\t\treturn info.isDirectory() ? canonical : null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction assertContentLimit(content: unknown): asserts content is string {\n\tif (typeof content !== \"string\") throw httpError(400, \"content must be a string\");\n\tif (Buffer.byteLength(content, \"utf8\") > MAX_MEMORY_CONTENT_BYTES) {\n\t\tthrow httpError(413, `Memory document exceeds the ${MAX_MEMORY_CONTENT_BYTES} byte limit`);\n\t}\n}\n\nfunction validateEntryFile(file: string): void {\n\tif (typeof file !== \"string\" || file.length === 0) throw httpError(400, \"file is required\");\n\tif (file.includes(\"\\0\") || file.includes(\"/\") || file.includes(\"\\\\\"))\n\t\tthrow httpError(400, `Invalid memory file: ${file}`);\n\tif (file === \".\" || file === \"..\" || file.startsWith(\".\") || file.startsWith(\"_\")) {\n\t\tthrow httpError(400, `Invalid memory file: ${file}`);\n\t}\n\tif (file.toLowerCase() === MEMORY_INDEX_FILE.toLowerCase())\n\t\tthrow httpError(400, \"MEMORY.md is the index, not an entry\");\n\tif (!/^[A-Za-z0-9][A-Za-z0-9._-]*\\.md$/.test(file))\n\t\tthrow httpError(400, `Memory entries must be .md files: ${file}`);\n}\n\nfunction validateDocumentFile(file: string): \"index\" | \"entry\" {\n\tif (file === MEMORY_INDEX_FILE) return \"index\";\n\tvalidateEntryFile(file);\n\treturn \"entry\";\n}\n\nfunction validateMetadata(frontmatter: Record<string, unknown>): MemoryEntryMetadataDto {\n\tconst { name, description, type } = frontmatter;\n\tif (typeof name !== \"string\" || name.length === 0) throw new Error(\"frontmatter.name must be a non-empty string\");\n\tif (typeof description !== \"string\" || description.length === 0) {\n\t\tthrow new Error(\"frontmatter.description must be a non-empty string\");\n\t}\n\tif (typeof type !== \"string\" || !VALID_ENTRY_TYPES.has(type as MemoryEntryTypeDto)) {\n\t\tthrow new Error(\"frontmatter.type must be one of user-preferences, good-practices, project, navigation\");\n\t}\n\treturn { name, description, type: type as MemoryEntryTypeDto };\n}\n\nfunction parseEntryMetadata(content: string): { metadata?: MemoryEntryMetadataDto; metadataError?: string } {\n\ttry {\n\t\tconst { frontmatter } = parseFrontmatter<Record<string, unknown>>(content);\n\t\treturn { metadata: validateMetadata(frontmatter) };\n\t} catch (err) {\n\t\treturn { metadataError: err instanceof Error ? err.message : String(err) };\n\t}\n}\n\nasync function requireLimitedFile(path: string): Promise<Stats> {\n\tconst info = await stat(path);\n\tif (!info.isFile()) throw httpError(400, `Not a file: ${path}`);\n\tif (info.size > MAX_MEMORY_CONTENT_BYTES)\n\t\tthrow httpError(413, `Memory document exceeds the ${MAX_MEMORY_CONTENT_BYTES} byte limit`);\n\treturn info;\n}\n\nasync function readUtf8Limited(path: string): Promise<string> {\n\tawait requireLimitedFile(path);\n\treturn readFile(path, \"utf8\");\n}\n\nasync function readEntryMetadata(path: string): Promise<{\n\tinfo: Stats;\n\tparsed: ReturnType<typeof parseEntryMetadata>;\n}> {\n\tconst info = await requireLimitedFile(path);\n\tconst handle = await open(path, \"r\");\n\tconst decoder = new StringDecoder(\"utf8\");\n\tconst chunk = Buffer.allocUnsafe(16 * 1024);\n\tlet prefix = \"\";\n\tlet position = 0;\n\ttry {\n\t\twhile (position < info.size) {\n\t\t\tconst { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, info.size - position), position);\n\t\t\tif (bytesRead === 0) break;\n\t\t\tposition += bytesRead;\n\t\t\tprefix += decoder.write(chunk.subarray(0, bytesRead));\n\t\t\tif (position === bytesRead && !prefix.startsWith(\"---\")) break;\n\t\t\tconst end = prefix.indexOf(\"\\n---\", 3);\n\t\t\tif (end !== -1) {\n\t\t\t\tprefix = prefix.slice(0, end + 4);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tprefix += decoder.end();\n\t} finally {\n\t\tawait handle.close();\n\t}\n\treturn { info, parsed: parseEntryMetadata(prefix) };\n}\n\nasync function atomicReplace(path: string, content: string): Promise<void> {\n\tconst dir = resolve(path, \"..\");\n\tconst temp = join(dir, `.dreb-memory-${process.pid}-${Date.now()}-${randomBytes(6).toString(\"hex\")}.tmp`);\n\tlet handle: Awaited<ReturnType<typeof open>> | undefined;\n\ttry {\n\t\thandle = await open(temp, \"wx\");\n\t\tawait handle.writeFile(content, \"utf8\");\n\t\tawait handle.close();\n\t\thandle = undefined;\n\t\tawait rename(temp, path);\n\t} catch (err) {\n\t\tif (handle) await handle.close().catch(() => {});\n\t\tawait unlink(temp).catch(() => {});\n\t\tthrow err;\n\t}\n}\n\nfunction splitLinesPreserveEndings(content: string): string[] {\n\tconst matches = content.match(/.*(?:\\r\\n|\\n|\\r|$)/g) ?? [];\n\treturn matches.filter((part, index) => part.length > 0 || index < matches.length - 1);\n}\n\nfunction localMarkdownTargets(line: string): string[] {\n\tconst targets: string[] = [];\n\tconst regex = /\\[[^\\]]+\\]\\(([^)]+)\\)/g;\n\tlet match = regex.exec(line);\n\twhile (match) {\n\t\ttargets.push(match[1]);\n\t\tmatch = regex.exec(line);\n\t}\n\treturn targets;\n}\n\nfunction targetMatchesFilename(target: string, filename: string): boolean {\n\treturn target === filename || target === `./${filename}`;\n}\n\nfunction escapeRegExp(text: string): string {\n\treturn text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nfunction removeIndexLinks(indexContent: string, filename: string): { content: string; changed: boolean } {\n\tconst lines = splitLinesPreserveEndings(indexContent);\n\tconst escaped = escapeRegExp(filename);\n\tconst safeLine = new RegExp(\n\t\t`^\\\\s*[-*+]\\\\s+\\\\[[^\\\\]]+\\\\]\\\\((?:\\\\./)?${escaped}\\\\)(?:\\\\s+(?:[-—:]|—)\\\\s+.*)?\\\\s*(?:\\\\r?\\\\n|\\\\r)?$`,\n\t\t\"u\",\n\t);\n\tlet changed = false;\n\tconst kept: string[] = [];\n\tfor (const line of lines) {\n\t\tconst matches = localMarkdownTargets(line).some((target) => targetMatchesFilename(target, filename));\n\t\tif (!matches) {\n\t\t\tkept.push(line);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!safeLine.test(line)) {\n\t\t\tthrow httpError(409, `Index line for ${filename} is not safe to remove automatically`);\n\t\t}\n\t\tchanged = true;\n\t}\n\treturn { content: kept.join(\"\"), changed };\n}\n\nexport class MemoryApi {\n\tconstructor(\n\t\tprivate readonly homeDir: string,\n\t\tprivate readonly log: MemoryOpLogger,\n\t) {}\n\n\tasync scopes(cwdInventory: string[]): Promise<MemoryScopeDto[]> {\n\t\tconst scopes: MemoryScopeDto[] = [];\n\t\tconst canonicalHome = (await canonicalExistingDirectory(this.homeDir)) ?? resolve(this.homeDir);\n\t\tconst globalMemoryDir = resolve(canonicalHome, \".dreb\", \"memory\");\n\t\tscopes.push({\n\t\t\tid: \"global\",\n\t\t\tkind: \"global\",\n\t\t\tlabel: \"global\",\n\t\t\tmemoryDir: globalMemoryDir,\n\t\t\texists: await pathExists(globalMemoryDir),\n\t\t});\n\n\t\tconst roots = new Map<string, string>();\n\t\tfor (const cwd of cwdInventory) {\n\t\t\tif (typeof cwd !== \"string\" || cwd.length === 0) continue;\n\t\t\tconst existingCwd = await canonicalExistingDirectory(cwd);\n\t\t\tif (!existingCwd) continue;\n\t\t\tconst root = findGitRoot(existingCwd) ?? existingCwd;\n\t\t\tconst canonicalRoot = await canonicalExistingDirectory(root);\n\t\t\tif (!canonicalRoot || canonicalRoot === canonicalHome) continue;\n\t\t\troots.set(canonicalRoot, canonicalRoot);\n\t\t}\n\t\tfor (const root of [...roots.keys()].sort((a, b) => a.localeCompare(b))) {\n\t\t\tconst memoryDir = join(root, \".dreb\", \"memory\");\n\t\t\tconst canonicalMemoryDir = await canonicalExistingDirectory(memoryDir);\n\t\t\tif (!canonicalMemoryDir) continue;\n\t\t\tconst dirents = await readdir(canonicalMemoryDir, { withFileTypes: true });\n\t\t\tconst browseable = dirents.some((dirent) => {\n\t\t\t\tif (!dirent.isFile()) return false;\n\t\t\t\tif (dirent.name === MEMORY_INDEX_FILE) return true;\n\t\t\t\ttry {\n\t\t\t\t\tvalidateEntryFile(dirent.name);\n\t\t\t\t\treturn true;\n\t\t\t\t} catch {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t});\n\t\t\tif (!browseable) continue;\n\t\t\tscopes.push({\n\t\t\t\tid: projectScopeId(root),\n\t\t\t\tkind: \"project\",\n\t\t\t\tlabel: basename(root) || root,\n\t\t\t\tprojectRoot: root,\n\t\t\t\tmemoryDir: canonicalMemoryDir,\n\t\t\t\texists: true,\n\t\t\t});\n\t\t}\n\t\treturn scopes;\n\t}\n\n\tasync listing(scopeId: string, cwdInventory: string[]): Promise<MemoryListingDto> {\n\t\treturn this.listingForScope(await this.requireScope(scopeId, cwdInventory));\n\t}\n\n\tprivate async listingForScope(scope: MemoryScopeDto): Promise<MemoryListingDto> {\n\t\tconst memoryRoot = await this.canonicalMemoryRootIfExists(scope);\n\t\tif (!memoryRoot) {\n\t\t\tthis.log(\"list\", scope.id);\n\t\t\treturn { scope, indexContent: null, indexRevision: null, indexOverLimit: false, entries: [] };\n\t\t}\n\n\t\tlet indexContent: string | null = null;\n\t\tlet indexRevision: string | null = null;\n\t\ttry {\n\t\t\tconst indexPath = await this.resolveExistingTargetWithinRoot(memoryRoot, MEMORY_INDEX_FILE, \"index\");\n\t\t\tindexContent = await readUtf8Limited(indexPath);\n\t\t\tindexRevision = sha256Hex(indexContent);\n\t\t} catch (err: any) {\n\t\t\tif (err?.status !== 404 && err?.code !== \"ENOENT\") throw err;\n\t\t}\n\n\t\tconst dirents = await readdir(memoryRoot, { withFileTypes: true });\n\t\tconst entries: MemoryEntrySummaryDto[] = [];\n\t\tfor (const dirent of dirents) {\n\t\t\tif (!dirent.isFile()) continue;\n\t\t\tif (dirent.name === MEMORY_INDEX_FILE) continue;\n\t\t\ttry {\n\t\t\t\tvalidateEntryFile(dirent.name);\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst path = await this.resolveExistingTargetWithinRoot(memoryRoot, dirent.name, \"entry\");\n\t\t\tconst { info, parsed } = await readEntryMetadata(path);\n\t\t\tentries.push({\n\t\t\t\tfile: dirent.name,\n\t\t\t\t...parsed,\n\t\t\t\tmodified: info.mtime.toISOString(),\n\t\t\t\tsize: info.size,\n\t\t\t});\n\t\t}\n\t\tentries.sort((a, b) => a.file.localeCompare(b.file));\n\t\tthis.log(\"list\", scope.id);\n\t\treturn {\n\t\t\tscope: { ...scope, exists: true, memoryDir: memoryRoot },\n\t\t\tindexContent,\n\t\t\tindexRevision,\n\t\t\tindexOverLimit: (indexContent?.split(/\\r\\n|\\n|\\r/).length ?? 0) > 200,\n\t\t\tentries,\n\t\t};\n\t}\n\n\tasync readDocument(scopeId: string, file: string, cwdInventory: string[]): Promise<MemoryDocumentDto> {\n\t\treturn this.readDocumentForScope(await this.requireScope(scopeId, cwdInventory), file);\n\t}\n\n\tprivate async readDocumentForScope(scope: MemoryScopeDto, file: string): Promise<MemoryDocumentDto> {\n\t\tconst kind = validateDocumentFile(file);\n\t\tconst path = await this.resolveExistingTarget(scope, file, kind);\n\t\tconst content = await readUtf8Limited(path);\n\t\tthis.log(\"read\", scope.id, file);\n\t\treturn {\n\t\t\tkind,\n\t\t\tfile,\n\t\t\tcontent,\n\t\t\trevision: sha256Hex(content),\n\t\t\t...(kind === \"entry\" ? parseEntryMetadata(content) : {}),\n\t\t};\n\t}\n\n\tasync saveDocument(\n\t\tscopeId: string,\n\t\tfile: string,\n\t\tbody: unknown,\n\t\tcwdInventory: string[],\n\t): Promise<MemoryMutationResultDto> {\n\t\tconst kind = validateDocumentFile(file);\n\t\tif (!body || typeof body !== \"object\" || Array.isArray(body)) throw httpError(400, \"JSON body is required\");\n\t\tconst { content, revision } = body as Record<string, unknown>;\n\t\tassertContentLimit(content);\n\t\tif (typeof revision !== \"string\" || revision.length === 0) throw httpError(400, \"revision is required\");\n\t\tif (kind === \"entry\") {\n\t\t\tconst parsed = parseEntryMetadata(content);\n\t\t\tif (parsed.metadataError) throw httpError(400, parsed.metadataError);\n\t\t}\n\t\tconst scope = await this.requireScope(scopeId, cwdInventory);\n\t\tconst path = await this.resolveExistingTarget(scope, file, kind);\n\t\tconst current = await readUtf8Limited(path);\n\t\tif (sha256Hex(current) !== revision) throw httpError(409, \"Memory document is stale; refresh before saving\");\n\t\tconst beforeReplace = await this.resolveExistingTarget(scope, file, kind);\n\t\tawait atomicReplace(beforeReplace, content);\n\t\tconst document = await this.readDocumentForScope(scope, file);\n\t\tconst listing = await this.listingForScope(scope);\n\t\tthis.log(\"save\", scope.id, file);\n\t\treturn { listing, document };\n\t}\n\n\tasync deleteEntry(\n\t\tscopeId: string,\n\t\tfile: string,\n\t\tbody: unknown,\n\t\tcwdInventory: string[],\n\t): Promise<MemoryMutationResultDto> {\n\t\tvalidateEntryFile(file);\n\t\tif (!body || typeof body !== \"object\" || Array.isArray(body)) throw httpError(400, \"JSON body is required\");\n\t\tconst { revision, indexRevision } = body as Record<string, unknown>;\n\t\tif (typeof revision !== \"string\" || revision.length === 0) throw httpError(400, \"revision is required\");\n\t\tif (indexRevision !== null && typeof indexRevision !== \"string\")\n\t\t\tthrow httpError(400, \"indexRevision must be a string or null\");\n\t\tconst scope = await this.requireScope(scopeId, cwdInventory);\n\t\tconst entryPath = await this.resolveExistingTarget(scope, file, \"entry\");\n\t\tconst originalEntry = await readUtf8Limited(entryPath);\n\t\tif (sha256Hex(originalEntry) !== revision) throw httpError(409, \"Memory entry is stale; refresh before deleting\");\n\n\t\tconst memoryRoot = await this.requireCanonicalMemoryRoot(scope);\n\t\tlet indexPath = join(memoryRoot, MEMORY_INDEX_FILE);\n\t\tlet originalIndex: string | null = null;\n\t\ttry {\n\t\t\tindexPath = await this.resolveExistingTarget(scope, MEMORY_INDEX_FILE, \"index\");\n\t\t\toriginalIndex = await readUtf8Limited(indexPath);\n\t\t} catch (err: any) {\n\t\t\tif (err?.code !== \"ENOENT\" && err?.status !== 404) throw err;\n\t\t}\n\t\tconst actualIndexRevision = originalIndex === null ? null : sha256Hex(originalIndex);\n\t\tif (actualIndexRevision !== indexRevision) throw httpError(409, \"Memory index is stale; refresh before deleting\");\n\n\t\tlet updatedIndex: string | null = originalIndex;\n\t\tif (originalIndex !== null) updatedIndex = removeIndexLinks(originalIndex, file).content;\n\t\tif (updatedIndex !== null && updatedIndex !== originalIndex) {\n\t\t\tindexPath = await this.resolveExistingTarget(scope, MEMORY_INDEX_FILE, \"index\");\n\t\t\tawait atomicReplace(indexPath, updatedIndex);\n\t\t}\n\t\ttry {\n\t\t\tconst beforeUnlink = await this.resolveExistingTarget(scope, file, \"entry\");\n\t\t\tawait unlink(beforeUnlink);\n\t\t} catch (err) {\n\t\t\tif (originalIndex !== null && updatedIndex !== originalIndex) await atomicReplace(indexPath, originalIndex);\n\t\t\tthrow err;\n\t\t}\n\t\tconst listing = await this.listingForScope(scope);\n\t\tif (\n\t\t\tlisting.indexContent &&\n\t\t\tlocalMarkdownTargets(listing.indexContent).some((target) => targetMatchesFilename(target, file))\n\t\t) {\n\t\t\tthrow httpError(500, `Delete left a dangling index link for ${file}`);\n\t\t}\n\t\tthis.log(\"delete\", scope.id, file);\n\t\treturn { listing };\n\t}\n\n\tprivate async requireScope(scopeId: string, cwdInventory: string[]): Promise<MemoryScopeDto> {\n\t\tconst scope = (await this.scopes(cwdInventory)).find((item) => item.id === scopeId);\n\t\tif (!scope) throw httpError(404, `Unknown memory scope: ${scopeId}`);\n\t\treturn scope;\n\t}\n\n\tprivate async canonicalMemoryRootIfExists(scope: MemoryScopeDto): Promise<string | null> {\n\t\treturn canonicalExistingDirectory(scope.memoryDir);\n\t}\n\n\tprivate async requireCanonicalMemoryRoot(scope: MemoryScopeDto): Promise<string> {\n\t\tconst root = await this.canonicalMemoryRootIfExists(scope);\n\t\tif (!root) throw httpError(404, `Memory directory does not exist: ${scope.memoryDir}`);\n\t\treturn root;\n\t}\n\n\tprivate async resolveExistingTarget(scope: MemoryScopeDto, file: string, kind: \"index\" | \"entry\"): Promise<string> {\n\t\treturn this.resolveExistingTargetWithinRoot(await this.requireCanonicalMemoryRoot(scope), file, kind);\n\t}\n\n\tprivate async resolveExistingTargetWithinRoot(\n\t\tmemoryRoot: string,\n\t\tfile: string,\n\t\tkind: \"index\" | \"entry\",\n\t): Promise<string> {\n\t\tif (kind === \"index\" && file !== MEMORY_INDEX_FILE) throw httpError(400, \"Invalid index file\");\n\t\tif (kind === \"entry\") validateEntryFile(file);\n\t\tconst target = await canonicalizePath(join(memoryRoot, file), { mustExist: true });\n\t\tif (!isWithinCanonicalRoot(target, memoryRoot)) throw httpError(400, `Memory target escapes scope: ${file}`);\n\t\treturn target;\n\t}\n}\n"]}
@@ -34,6 +34,8 @@ export interface DashboardServerOptions {
34
34
  imageService?: DashboardImageService;
35
35
  /** Named heartbeat interval; defaults to 25 seconds. */
36
36
  heartbeatIntervalMs?: number;
37
+ /** Test-only override for the global dreb memory home directory. */
38
+ memoryHomeDir?: string;
37
39
  }
38
40
  export declare const MAX_SSE_BUFFERED_BYTES: number;
39
41
  export declare const CLIENT_DIAGNOSTIC_RATE_LIMIT_MS = 30000;