From f212f5a1c42cfa743e1a066e82e96892cf592666 Mon Sep 17 00:00:00 2001
From: m-aebrer
Date: Fri, 7 Aug 2026 11:38:21 -0400
Subject: [PATCH 1/6] chore: open PR for issue 335
From d0701879472c3948cbe100a61147c5ca7644ef6e Mon Sep 17 00:00:00 2001
From: m-aebrer
Date: Fri, 7 Aug 2026 12:56:19 -0400
Subject: [PATCH 2/6] Add dashboard memory management
---
README.md | 6 +-
packages/coding-agent/README.md | 2 +-
packages/coding-agent/docs/dashboard.md | 1 +
packages/coding-agent/src/index.ts | 1 +
packages/dashboard/README.md | 15 +
packages/dashboard/src/client/api.ts | 24 +
packages/dashboard/src/client/app.tsx | 4 +
.../src/client/components/common.tsx | 5 +-
.../dashboard/src/client/screens/memories.tsx | 412 ++++++++++++++++++
packages/dashboard/src/client/state/store.ts | 4 +
packages/dashboard/src/client/styles/app.css | 133 ++++++
packages/dashboard/src/server/memories.ts | 397 +++++++++++++++++
packages/dashboard/src/server/server.ts | 52 +++
packages/dashboard/src/shared/protocol.ts | 48 ++
.../dashboard/test/client/screens.test.tsx | 132 ++++++
packages/dashboard/test/client/store.test.ts | 8 +
packages/dashboard/test/memories.test.ts | 168 +++++++
packages/dashboard/test/server.test.ts | 58 ++-
18 files changed, 1465 insertions(+), 5 deletions(-)
create mode 100644 packages/dashboard/src/client/screens/memories.tsx
create mode 100644 packages/dashboard/src/server/memories.ts
create mode 100644 packages/dashboard/test/memories.test.ts
diff --git a/README.md b/README.md
index b0e29f45..4cbeb75b 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Use dreb if you want a coding agent that can run against direct APIs, coding sub
## Why choose dreb?
-- **Every session, on every device.** The [web dashboard](#web-dashboard) is a first-party browser UI for the same sessions the terminal runs: a fleet overview of all live and past sessions across projects, full chat with steering, live subagent observability, host file access, and settings — one synchronized state on desktop and mobile. Local-only by default; remote access is Tailscale-gated with device pairing.
+- **Every session, on every device.** The [web dashboard](#web-dashboard) is a first-party browser UI for the same sessions the terminal runs: a fleet overview of all live and past sessions across projects, full chat with steering, live subagent observability, host file access, dreb memory management, and settings — one synchronized state on desktop and mobile. Local-only by default; remote access is Tailscale-gated with device pairing.
- **Model and provider freedom.** Authenticate with API keys or `/login` subscriptions, switch models at runtime with `/model`, scope model sets, tune thinking levels, route built-in providers through proxies, use cloud providers such as Bedrock/Vertex/Azure, or add local/proxy/custom models through [Custom Models](packages/coding-agent/docs/models.md) and [Custom Providers](packages/coding-agent/docs/custom-provider.md). See [Providers](packages/coding-agent/docs/providers.md) for the current setup list.
- **A real development workflow.** [mach6](packages/coding-agent/docs/mach6.md) is a built-in issue-to-merge workflow: assess issues, plan work, open draft PRs, implement, push progress, run multi-agent reviews, independently assess findings, fix CI or review items, and publish. Plans, reviews, and progress live on GitHub as shared memory.
- **Composable agent building blocks.** [Skills](packages/coding-agent/docs/skills.md) are markdown workflows loaded on demand; [extensions](packages/coding-agent/docs/extensions.md) are TypeScript modules for custom tools, commands, event hooks, UI components, renderers, keybindings, provider registration, permission gates, and workflow automation; [packages](packages/coding-agent/docs/packages.md) bundle skills, extensions, prompts, and themes for npm, git, or local sharing.
@@ -116,7 +116,7 @@ The same agent runtime powers multiple surfaces:
- **RPC mode** — strict [JSONL stdin/stdout protocol](packages/coding-agent/docs/rpc.md) for non-Node clients and custom UIs.
- **SDK** — import `@dreb/coding-agent` and create agent sessions directly in TypeScript.
- **Telegram** — `@dreb/telegram` runs dreb as a bot with sessions, model controls, file upload/download, live tool status, and visible results for user-facing tools.
-- **Web dashboard** — `dreb dashboard` serves a browser UI (fleet overview of all sessions, full chat with steering, subagent observability, host file browser); local-only by default, remote via Tailscale + rotating pairing code. See [dashboard docs](packages/coding-agent/docs/dashboard.md).
+- **Web dashboard** — `dreb dashboard` serves a browser UI (fleet overview of all sessions, full chat with steering, subagent observability, host file browser, dreb memory editor); local-only by default, remote via Tailscale + rotating pairing code. See [dashboard docs](packages/coding-agent/docs/dashboard.md).
### Web dashboard
@@ -143,6 +143,8 @@ The dashboard is the visual face of dreb: every agent session on the host, live
**Host files, explicitly.** Browse the host filesystem, upload/download, create folders, and start a new session in any directory — every file operation logged server-side.
+**Memories, repairable.** The Memories screen edits dreb memory scopes only: global `~/.dreb/memory` plus `.dreb/memory` for active/disk project roots. It shows the complete `MEMORY.md` index (with a warning when it exceeds the 200-line prompt convention), existing entry metadata or parse errors, sanitized Markdown previews, exact-revision conflict handling that preserves drafts, and entry deletion that synchronously cleans matching index links before unlinking the file. It does not create/rename entries or expose Claude memory paths.
+
**Curated appearance themes.** A theme gallery in settings offers eight dashboard-native themes (entropist.ca, Dim, Solarized, Gruvbox, Caves of Qud, Van Gogh, plus the colorblind-safe Okabe-Ito and Paul Tol palettes), each with its own light and dark palette, plus a system/light/dark mode toggle. Choices are saved per browser and are independent of your TUI theme.
Launch locally:
diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md
index 2e509c4c..35394e41 100644
--- a/packages/coding-agent/README.md
+++ b/packages/coding-agent/README.md
@@ -81,7 +81,7 @@ Or use a custom provider (corporate proxy, Bedrock, etc.) — see [Custom provid
Then just talk to dreb. All 13 standard built-in tools are enabled by default: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, `watch_github_ci`, and `ask_user`. Use `--tools` to restrict to a subset (e.g., `--tools read,grep,find,ls` for read-only). Three additional tools — `search`, `skill`, and `tasks_update` — are always active regardless of `--tools`. `suggest_next` is active by default but excluded when `--tools` is specified. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [packages](#packages).
-**Also available:** [`@dreb/telegram`](https://www.npmjs.com/package/@dreb/telegram) — run dreb as a Telegram bot with live tool status and visible results for user-facing tools (`npm install -g @dreb/telegram`). [`@dreb/dashboard`](https://www.npmjs.com/package/@dreb/dashboard) — run `dreb dashboard` for a browser UI with fleet overview, full chat steering, generic fail-closed built-in slash-command discovery and execution, inline provider/API failures with partial output preserved, sanitized raster tool images plus sent user uploads retained as bounded transcript previews by default, a bounded all-agent subagent panel with drill-in, host file browser, curated appearance themes (per-browser light/dark), and Tailscale/rotating-code pairing (`npm install -g @dreb/dashboard`; see [docs/dashboard.md](docs/dashboard.md)). Tool images cross browser-facing transport as content-addressed references; browser-local Settings offers placeholders, bounded previews, or informed-opt-in originals, with size disclosure and confirmation above 1 MiB. Full-resolution HTML export remains self-contained. Compact SSE snapshots update live fleet cards without repeatedly fetching the cross-project inventory, and session drill-in hydrates state, messages, and background agents through one ordered snapshot request. Terminal provider failures show their reason on fleet cards, while transient failures clear terminal state when automatic retry begins and remain recorded inline on the failed attempt. Its top bar and persistent session header indicators report connecting, connected, retrying, resyncing, disconnected, or auth failed; bounded SSE replay plus an explicit snapshot barrier restores session state, tasks, and image references after a reload, restart, gap, backpressure disconnect, or stalled stream, while authenticated image routes recover bytes separately from authoritative transcripts.
+**Also available:** [`@dreb/telegram`](https://www.npmjs.com/package/@dreb/telegram) — run dreb as a Telegram bot with live tool status and visible results for user-facing tools (`npm install -g @dreb/telegram`). [`@dreb/dashboard`](https://www.npmjs.com/package/@dreb/dashboard) — run `dreb dashboard` for a browser UI with fleet overview, full chat steering, generic fail-closed built-in slash-command discovery and execution, inline provider/API failures with partial output preserved, sanitized raster tool images plus sent user uploads retained as bounded transcript previews by default, a bounded all-agent subagent panel with drill-in, host file browser, dreb memory editor with exact-revision saves and automatic index cleanup on delete, curated appearance themes (per-browser light/dark), and Tailscale/rotating-code pairing (`npm install -g @dreb/dashboard`; see [docs/dashboard.md](docs/dashboard.md)). Tool images cross browser-facing transport as content-addressed references; browser-local Settings offers placeholders, bounded previews, or informed-opt-in originals, with size disclosure and confirmation above 1 MiB. Full-resolution HTML export remains self-contained. The Memories screen is dreb-only (`~/.dreb/memory` and active/on-disk-session project `.dreb/memory`), shows complete indexes with a >200-line warning, surfaces malformed entry frontmatter for repair, preserves drafts on conflicts, and does not create/rename entries or expose Claude paths. Compact SSE snapshots update live fleet cards without repeatedly fetching the cross-project inventory, and session drill-in hydrates state, messages, and background agents through one ordered snapshot request. Terminal provider failures show their reason on fleet cards, while transient failures clear terminal state when automatic retry begins and remain recorded inline on the failed attempt. Its top bar and persistent session header indicators report connecting, connected, retrying, resyncing, disconnected, or auth failed; bounded SSE replay plus an explicit snapshot barrier restores session state, tasks, and image references after a reload, restart, gap, backpressure disconnect, or stalled stream, while authenticated image routes recover bytes separately from authoritative transcripts.
**Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md)
diff --git a/packages/coding-agent/docs/dashboard.md b/packages/coding-agent/docs/dashboard.md
index 5a8118fd..b990cabc 100644
--- a/packages/coding-agent/docs/dashboard.md
+++ b/packages/coding-agent/docs/dashboard.md
@@ -123,6 +123,7 @@ networking window above.
| **Session view** | Full chat drill-in. Markdown streaming transcript (text, thinking blocks with expand preference, inline provider/API failures with partial output preserved, agent-result cards, tool cards with bespoke read/write/edit/bash bodies plus full expandable inputs, markdown-rendered results for markdown-contract tools like subagent/skill/web_fetch/suggest_next, and inline tool-result images, compaction/branch summaries, custom messages), per-message copy, tasks panel, a bounded scrollable subagent panel that lists every retained agent newest-first with full running/done counts, status line with elapsed time plus ■ stop and compaction/retry aborts, a persistent session-header live indicator, and an info bar with cwd, branch, session name, token breakdown, cost/(sub)/daily rollup, ctx%, median tok/s, and a stats popover. Composer supports auto-grow, history, `/` autocomplete from `get_commands`, image attach/paste with sent images retained as user-message previews, queued-message chips with restore-all, steer/follow-up modes, and suggest-next. Registered built-in slash commands are discovered generically, deduplicated ahead of colliding resource commands, and intercepted before prompting: dashboard actions cover settings, model, scoped-models, export/import, name/session stats, fork/tree, new/compact/dream, resume/reload, and quit. `/scoped-models` deep-links to the Settings editor with the session's current cwd as project context; login/logout show an explicit not-yet-implemented notice, while copy/hotkeys/buddy give terminal-only guidance. Future built-ins are intercepted automatically. The RPC prompt boundary rejects any built-in that reaches it during command-loading races or failures, so slash text cannot leak to the model. Attachments are retained and the command is visibly rejected rather than silently discarded. The ⋯ menu covers export HTML, compact, rename, fork-from-message, loaded context, and tool expand/collapse. Session names update live from manual rename or auto-naming. Extension UI requests for select/confirm/input/editor render as modals; a rich `ask`/`ask_user` request renders inline as a single wizard that presents all its questions together — each with Markdown-formatted question text, choices, optional free text — plus an in-card Stop agent action, Escape-to-stop, and the authoritative auto-stop countdown, and is answered as one batch submit. Pending questions set needs-attention state and use the existing hidden-page notification path. Extension notifications render as toasts. |
| **Subagent view** | Read-only transcript of a background agent: live events via the RPC relay, hydrated from the agent's on-disk session log (`/subagents/:agentId/messages`) so the transcript survives browser reloads. Shows the task, streaming output, tool activity, and any safe Dispatch Arbiter changed/unchanged/failure records with the final agent/model/thinking. No raw arbiter output is displayed or transported. No composer — subagents can't be steered yet; the parent session controls them. |
| **Files** | Host-wide browser with places shortcuts (home, /tmp, project roots), breadcrumbs to `/`, new-folder, download, drop-zone/picker upload with explicit collision prompts, and "new session here" on any directory. It also shows the **effective global nested-context trust** for the displayed canonical directory: untrusted, trusted by that root, inherited from a granting root, or global expert trust-all. You can trust the displayed folder and descendants, or untrust the actual granting root; untrusting an inherited folder removes that root's trust for all descendants. |
+| **Memories** | Dreb-only memory management for `~/.dreb/memory` and `.dreb/memory` under active/disk project roots. It lists existing `MEMORY.md` indexes and direct child `.md` entries only (no Claude paths, create, or rename), shows entry frontmatter or metadata errors so malformed files can be repaired, renders sanitized Markdown preview beside a raw editor, and uses exact SHA-256 revisions so stale saves/deletes return conflicts while preserving drafts. The index view is complete, not truncated, and warns when it exceeds the 200-line memory-index convention. Entry deletion requires both entry and index revisions, removes only matching safe Markdown-link index lines (`file.md` / `./file.md`), writes the cleaned index atomically before unlinking the entry, and rolls back loudly if the unlink fails. |
| **Settings** | Persistent defaults (default model, thinking level, steering/follow-up queue modes, auto-compaction, auto-retry) via `get_settings`/`set_settings` — validation errors are shown verbatim. The scoped-models editor controls model cycling for new sessions only: grouped search, model/provider/all toggles, responsive controls, accessible up/down partial-scope ordering, and save/reset. An absent `enabledModels` is future-inclusive all models in registry order and cannot be reordered; a partial scope is a non-empty ordered list of canonical `provider/model` references. Editing legacy glob, fuzzy, or thinking-suffix values saves normalized exact references. The selected context reads effective global + project settings but writes global; a project-level `enabledModels` shadow is warned. The global-only Dispatch Arbiter card exposes enable/disable, exact authenticated model selection, thinking, guide path, and readiness guidance; model-less enablement is blocked and RPC/runtime validation remains fail-closed. Entering Settings flushes pending writes and reloads durable global + project settings, so external edits appear; read, parse, or write failures fail loudly instead of showing stale settings. The global-only nested-context policy lists every explicit trusted root for audit and revoke, offers a simple add-by-path control, and includes a prominently warned expert trust-all toggle; the Files view remains the primary place to grant trust while browsing. Most defaults seed new sessions; context-trust changes are observed by active main/subagent processes for future lazy loads, but cannot remove already injected content. Dashboard-local preferences (always expand thinking, transcript image display mode, needs-attention notification permission) live in the browser, alongside an appearance section: a theme gallery of eight curated themes (entropist.ca, Dim, Solarized, Gruvbox, Caves of Qud, Van Gogh, and the colorblind-safe Okabe-Ito and Paul Tol) with live preview cards and a system/light/dark mode selector, saved per browser. Shows the current rotating pairing code on the host/local dashboard, plus the paired-devices list with unpair. |
| **Pairing** | Remote first-login: identity echo, rotating-code entry, and the security copy explaining what pairing grants. |
diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts
index 0ef160fc..f1b55f48 100644
--- a/packages/coding-agent/src/index.ts
+++ b/packages/coding-agent/src/index.ts
@@ -142,6 +142,7 @@ export {
} from "./core/extensions/index.js";
// Footer data provider (git branch + extension statuses - data not otherwise available to extensions)
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
+export { findGitRoot } from "./core/git-root.js";
export { convertToLlm } from "./core/messages.js";
export { ModelRegistry } from "./core/model-registry.js";
export type {
diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md
index 2546ded4..29523dc3 100644
--- a/packages/dashboard/README.md
+++ b/packages/dashboard/README.md
@@ -59,6 +59,13 @@ Open `http://127.0.0.1:5343`.
effective global nested-context trust for the viewed canonical folder. Trust
the folder and descendants, or untrust the actual granting root (including
its inherited descendants).
+- **Memories** — dreb-only memory management for `~/.dreb/memory` and active
+ project `.dreb/memory` scopes. It edits existing `MEMORY.md` indexes and
+ direct child `.md` entries only (no create/rename and no Claude memory paths),
+ displays valid entry metadata or frontmatter errors, shows sanitized Markdown
+ preview, warns when the complete index is over 200 lines, preserves drafts on
+ exact-revision conflicts, and deletes entries only after synchronously cleaning
+ matching safe index links.
- **Settings** — persistent defaults (provider-grouped model dropdown,
thinking, queue modes, image handling, skill commands, transport,
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
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).
+### Memories
+
+The Memories screen exposes only dreb memory scopes: global `~/.dreb/memory` and project `.dreb/memory` directories derived from currently active sessions plus on-disk session cwd inventory. Missing memory directories are shown as missing and are not created by listing. Documents are existing-only: `MEMORY.md` is the special index, and entries are direct child `.md` files (excluding hidden/internal/path-like names).
+
+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.
+
+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.
+
### Transcript images
Image blocks returned by any tool and images uploaded with a user turn render
diff --git a/packages/dashboard/src/client/api.ts b/packages/dashboard/src/client/api.ts
index 09f4721f..5bc17b07 100644
--- a/packages/dashboard/src/client/api.ts
+++ b/packages/dashboard/src/client/api.ts
@@ -16,6 +16,10 @@ import type {
EventEnvelope,
FleetDto,
ImageAttachmentDto,
+ MemoryDocumentDto,
+ MemoryListingDto,
+ MemoryMutationResultDto,
+ MemoryScopeDto,
ModelInfoDto,
PairedDeviceDto,
PairingCodeDto,
@@ -65,6 +69,10 @@ function json(body: unknown): RequestInit {
return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
}
+function jsonWithMethod(method: "PUT" | "DELETE", body: unknown): RequestInit {
+ return { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
+}
+
function withCwd(path: string, cwd?: string): string {
return cwd ? `${path}?cwd=${encodeURIComponent(cwd)}` : path;
}
@@ -191,6 +199,22 @@ export const api = {
removeTrustedContextFolder: (path: string) =>
request("/api/settings/remove-trusted", json({ path })),
places: () => request<{ places: Array<{ label: string; path: string }> }>("/api/files/places"),
+
+ memoryScopes: () => request<{ scopes: MemoryScopeDto[] }>("/api/memories/scopes"),
+ memoryListing: (scopeId: string) => request(`/api/memories/${encodeURIComponent(scopeId)}`),
+ memoryDocument: (scopeId: string, file: string) =>
+ request(`/api/memories/${encodeURIComponent(scopeId)}/documents/${encodeURIComponent(file)}`),
+ saveMemoryDocument: (scopeId: string, file: string, content: string, revision: string) =>
+ request(
+ `/api/memories/${encodeURIComponent(scopeId)}/documents/${encodeURIComponent(file)}`,
+ jsonWithMethod("PUT", { content, revision }),
+ ),
+ deleteMemoryEntry: (scopeId: string, file: string, revision: string, indexRevision: string | null) =>
+ request(
+ `/api/memories/${encodeURIComponent(scopeId)}/entries/${encodeURIComponent(file)}`,
+ jsonWithMethod("DELETE", { revision, indexRevision }),
+ ),
+
downloadUrl: (path: string) => `/api/files/download?path=${encodeURIComponent(path)}`,
upload: async (dir: string, file: File, overwrite: boolean) => {
const res = await fetch(
diff --git a/packages/dashboard/src/client/app.tsx b/packages/dashboard/src/client/app.tsx
index 3d577508..4422aa76 100644
--- a/packages/dashboard/src/client/app.tsx
+++ b/packages/dashboard/src/client/app.tsx
@@ -7,6 +7,7 @@ import { createEffect, type JSX, Match, onCleanup, onMount, Switch } from "solid
import { ToastRegion } from "./components/common.js";
import { FilesScreen } from "./screens/files.js";
import { FleetScreen } from "./screens/fleet.js";
+import { MemoriesScreen } from "./screens/memories.js";
import { PairingScreen } from "./screens/pairing.js";
import { SessionScreen } from "./screens/session.js";
import { SettingsScreen } from "./screens/settings.js";
@@ -169,6 +170,9 @@ export function App(): JSX.Element {
+
+
+
Object.values(props.store.sessions).filter((s) => s.needsAttention).length +
props.store.fleet().runtimes.filter((r) => r.needsAttention).length;
@@ -51,6 +51,9 @@ export function Topbar(props: { store: AppStore; active: "fleet" | "files" | "se
files
+
+ memories
+
settings
diff --git a/packages/dashboard/src/client/screens/memories.tsx b/packages/dashboard/src/client/screens/memories.tsx
new file mode 100644
index 00000000..3ae5ec8c
--- /dev/null
+++ b/packages/dashboard/src/client/screens/memories.tsx
@@ -0,0 +1,412 @@
+/**
+ * Memories tab — dreb global/project memory browser and editor.
+ * Existing documents only: MEMORY.md index plus direct child .md entries.
+ */
+
+import { createEffect, createResource, createSignal, For, type JSX, Show } from "solid-js";
+import type {
+ MemoryDocumentDto,
+ MemoryEntrySummaryDto,
+ MemoryListingDto,
+ MemoryScopeDto,
+} from "../../shared/protocol.js";
+import { api } from "../api.js";
+import { Modal, Topbar } from "../components/common.js";
+import { MarkdownBody } from "../components/transcript.js";
+import type { AppStore } from "../state/store.js";
+
+const INDEX_FILE = "MEMORY.md";
+
+type SelectedDocument = { scopeId: string; file: string };
+
+function metadataLine(entry: MemoryEntrySummaryDto): string {
+ if (entry.metadata) return `${entry.metadata.name} · ${entry.metadata.type}`;
+ return entry.metadataError ? `metadata error: ${entry.metadataError}` : "metadata unavailable";
+}
+
+function documentTitle(document: MemoryDocumentDto | undefined, file: string | undefined): string {
+ if (!document) return file ?? "memory";
+ if (document.kind === "index") return "MEMORY.md index";
+ return document.metadata?.name ?? document.file;
+}
+
+function selectDefaultFile(listing: MemoryListingDto | undefined): string | undefined {
+ if (!listing) return undefined;
+ if (listing.indexContent !== null) return INDEX_FILE;
+ return listing.entries[0]?.file;
+}
+
+export function MemoriesScreen(props: { store: AppStore }): JSX.Element {
+ const [selectedScopeId, setSelectedScopeId] = createSignal();
+ const [selectedFile, setSelectedFile] = createSignal();
+ const [error, setError] = createSignal();
+ const [status, setStatus] = createSignal();
+ const [draft, setDraft] = createSignal("");
+ const [dirty, setDirty] = createSignal(false);
+ const [saving, setSaving] = createSignal(false);
+ const [deleteTarget, setDeleteTarget] = createSignal();
+
+ const [scopes, { refetch: refetchScopes }] = createResource(async () => {
+ try {
+ const result = await api.memoryScopes();
+ return result.scopes;
+ } catch (failure) {
+ setError(failure instanceof Error ? failure.message : String(failure));
+ return [];
+ }
+ });
+
+ createEffect(() => {
+ const all = scopes();
+ if (!all?.length) return;
+ const current = selectedScopeId();
+ if (!current || !all.some((scope) => scope.id === current)) setSelectedScopeId(all[0].id);
+ });
+
+ const [listing, { mutate: mutateListing, refetch: refetchListing }] = createResource(
+ () => selectedScopeId(),
+ async (scopeId): Promise => {
+ setError(undefined);
+ setStatus(undefined);
+ try {
+ return await api.memoryListing(scopeId);
+ } catch (failure) {
+ setError(failure instanceof Error ? failure.message : String(failure));
+ return undefined;
+ }
+ },
+ );
+
+ createEffect(() => {
+ const current = listing();
+ if (!current) return;
+ const file = selectedFile();
+ const available = new Set([
+ ...(current.indexContent !== null ? [INDEX_FILE] : []),
+ ...current.entries.map((e) => e.file),
+ ]);
+ if (!file || !available.has(file)) setSelectedFile(selectDefaultFile(current));
+ });
+
+ const [document, { mutate: mutateDocument, refetch: refetchDocument }] = createResource(
+ (): SelectedDocument | undefined => {
+ const scopeId = selectedScopeId();
+ const file = selectedFile();
+ return scopeId && file ? { scopeId, file } : undefined;
+ },
+ async (selection): Promise => {
+ setError(undefined);
+ setStatus(undefined);
+ try {
+ return await api.memoryDocument(selection.scopeId, selection.file);
+ } catch (failure) {
+ setError(failure instanceof Error ? failure.message : String(failure));
+ return undefined;
+ }
+ },
+ );
+
+ createEffect(() => {
+ const doc = document();
+ if (!doc || dirty()) return;
+ setDraft(doc.content);
+ });
+
+ function chooseScope(scope: MemoryScopeDto) {
+ if (selectedScopeId() === scope.id) return;
+ setSelectedScopeId(scope.id);
+ setSelectedFile(undefined);
+ mutateDocument(undefined);
+ setDirty(false);
+ setDraft("");
+ }
+
+ function chooseFile(file: string) {
+ if (selectedFile() === file) return;
+ setSelectedFile(file);
+ mutateDocument(undefined);
+ setDirty(false);
+ setDraft("");
+ }
+
+ async function refreshAll() {
+ await refetchScopes();
+ await refetchListing();
+ if (selectedFile()) await refetchDocument();
+ }
+
+ async function save() {
+ const scopeId = selectedScopeId();
+ const doc = document();
+ if (!scopeId || !doc) return;
+ setSaving(true);
+ setError(undefined);
+ setStatus(undefined);
+ try {
+ const result = await api.saveMemoryDocument(scopeId, doc.file, draft(), doc.revision);
+ mutateListing(result.listing);
+ if (result.document) mutateDocument(result.document);
+ setDraft(result.document?.content ?? draft());
+ setDirty(false);
+ setStatus("saved");
+ } catch (err: any) {
+ setError(
+ err?.status === 409
+ ? `${err.message} Your draft is still here.`
+ : err instanceof Error
+ ? err.message
+ : String(err),
+ );
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function confirmDelete() {
+ const scopeId = selectedScopeId();
+ const doc = deleteTarget();
+ const currentListing = listing();
+ if (!scopeId || !doc || doc.kind !== "entry" || !currentListing) return;
+ setSaving(true);
+ setError(undefined);
+ setStatus(undefined);
+ try {
+ const result = await api.deleteMemoryEntry(scopeId, doc.file, doc.revision, currentListing.indexRevision);
+ mutateListing(result.listing);
+ mutateDocument(undefined);
+ setDeleteTarget(undefined);
+ setSelectedFile(selectDefaultFile(result.listing));
+ setDirty(false);
+ setDraft("");
+ setStatus(`deleted ${doc.file}; index links cleaned up`);
+ } catch (err: any) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
Memories
+
+ Edit dreb memory only: global ~/.dreb/memory plus known project .dreb/memory scopes. Claude memory
+ paths are never included.
+
+
+
+
+
+
+
{error()}
+
+
+
{status()}
+
+
+
+
+
+
+
+
+ Complete index warning: MEMORY.md is over 200 lines. The dashboard shows the
+ full file for repair, while the agent prompt may only load the indexed prefix.
+
+ This deletes the entry file and first removes matching […]({target().file}) or{" "}
+ […](./{target().file}) lines from MEMORY.md. If the index changed, deletion will
+ fail with a conflict instead of leaving a dangling link.
+
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/dashboard/src/client/state/store.ts b/packages/dashboard/src/client/state/store.ts
index e92b9f0f..65eac0eb 100644
--- a/packages/dashboard/src/client/state/store.ts
+++ b/packages/dashboard/src/client/state/store.ts
@@ -38,6 +38,7 @@ export type Route =
| { screen: "session"; key: string }
| { screen: "subagent"; key: string; agentId: string }
| { screen: "files"; path?: string }
+ | { screen: "memories" }
| { screen: "settings"; target?: "scoped-models"; cwd?: string }
| { screen: "pairing" };
@@ -50,6 +51,7 @@ function parseHash(): Route {
return { screen: "session", key: rest[0] };
}
if (head === "files") return { screen: "files", path: rest.length ? decodeURIComponent(rest.join("/")) : undefined };
+ if (head === "memories") return { screen: "memories" };
if (head === "settings") {
if (rest[0] === "scoped-models") {
const cwd = new URLSearchParams(query).get("cwd") || undefined;
@@ -71,6 +73,8 @@ export function routeToHash(route: Route): string {
return `#/session/${route.key}/subagent/${route.agentId}`;
case "files":
return route.path ? `#/files/${encodeURIComponent(route.path)}` : "#/files";
+ case "memories":
+ return "#/memories";
case "settings":
return route.target === "scoped-models"
? `#/settings/scoped-models${route.cwd ? `?cwd=${encodeURIComponent(route.cwd)}` : ""}`
diff --git a/packages/dashboard/src/client/styles/app.css b/packages/dashboard/src/client/styles/app.css
index 11f9162a..d40635cb 100644
--- a/packages/dashboard/src/client/styles/app.css
+++ b/packages/dashboard/src/client/styles/app.css
@@ -2978,3 +2978,136 @@ details.thinking .thinking-body {
font-size: 16px;
}
}
+
+/* ------------------------------------------------------------- memories page */
+
+.memories-screen {
+ padding-bottom: var(--space-6);
+}
+
+.memories-head,
+.memory-doc-head,
+.memory-edit-actions {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.memories-head {
+ margin: var(--space-5) 0 var(--space-4);
+}
+
+.memories-layout {
+ display: grid;
+ grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
+ gap: var(--space-4);
+ align-items: start;
+}
+
+.memories-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.memory-panel,
+.memory-editor {
+ border: var(--hairline);
+ border-radius: var(--radius);
+ padding: var(--space-4);
+ min-width: 0;
+}
+
+.memory-panel h2 {
+ font-size: var(--fs-secondary);
+ color: var(--muted);
+ margin-bottom: var(--space-2);
+}
+
+.memory-nav-item {
+ width: 100%;
+ text-align: left;
+ border: var(--hairline);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ color: var(--text);
+ padding: var(--space-2);
+ margin-bottom: var(--space-2);
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.memory-nav-item:hover,
+.memory-nav-item.active {
+ border-color: var(--text);
+}
+
+.memory-nav-item.error {
+ border-color: var(--status-error);
+}
+
+.memory-nav-item small {
+ color: var(--muted);
+ overflow-wrap: anywhere;
+}
+
+.memory-doc-head {
+ margin-bottom: var(--space-3);
+}
+
+.memory-metadata {
+ display: flex;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+ align-items: center;
+ border: var(--hairline);
+ border-radius: var(--radius-sm);
+ padding: var(--space-2);
+ margin-bottom: var(--space-3);
+}
+
+.memory-metadata span {
+ color: var(--muted);
+}
+
+.memory-edit-actions {
+ justify-content: flex-start;
+ align-items: center;
+ margin-bottom: var(--space-3);
+}
+
+.memory-textarea {
+ width: 100%;
+ min-height: 360px;
+ font-family: var(--font-mono);
+ font-size: var(--fs-secondary);
+ background: var(--surface);
+ color: var(--text);
+ border: var(--hairline);
+ border-radius: var(--radius);
+ padding: var(--space-3);
+ resize: vertical;
+}
+
+.memory-preview {
+ margin-top: var(--space-4);
+ border: var(--hairline);
+ border-radius: var(--radius);
+ padding: var(--space-3);
+}
+
+.memory-preview summary {
+ cursor: pointer;
+ color: var(--muted);
+ margin-bottom: var(--space-2);
+}
+
+@media (max-width: 800px) {
+ .memories-layout {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/packages/dashboard/src/server/memories.ts b/packages/dashboard/src/server/memories.ts
new file mode 100644
index 00000000..b74b2801
--- /dev/null
+++ b/packages/dashboard/src/server/memories.ts
@@ -0,0 +1,397 @@
+/**
+ * Dashboard memory API — dreb-only global/project memory editor.
+ *
+ * Scope ids are derived from the server's current cwd inventory. Clients can
+ * select only those ids; absolute target paths never cross the wire as
+ * authority. All path handling fails closed and re-checks symlink containment
+ * immediately before atomic replacement.
+ */
+
+import { createHash, randomBytes } from "node:crypto";
+import { open, readdir, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
+import { basename, join, resolve, sep } from "node:path";
+import { findGitRoot, parseFrontmatter } from "@dreb/coding-agent";
+import type {
+ MemoryDocumentDto,
+ MemoryEntryMetadataDto,
+ MemoryEntrySummaryDto,
+ MemoryEntryTypeDto,
+ MemoryListingDto,
+ MemoryMutationResultDto,
+ MemoryScopeDto,
+} from "../shared/protocol.js";
+import { canonicalizePath } from "./files.js";
+
+export type MemoryOpLogger = (operation: string, scopeId: string, detail?: string) => void;
+
+export const MEMORY_INDEX_FILE = "MEMORY.md";
+export const MAX_MEMORY_CONTENT_BYTES = 1024 * 1024;
+const VALID_ENTRY_TYPES = new Set(["user-preferences", "good-practices", "project", "navigation"]);
+
+function httpError(status: number, message: string, cause?: unknown): Error & { status: number } {
+ return Object.assign(new Error(message), { status, ...(cause === undefined ? {} : { cause }) });
+}
+
+function sha256Hex(content: string): string {
+ return createHash("sha256").update(content, "utf8").digest("hex");
+}
+
+function projectScopeId(canonicalRoot: string): string {
+ return `project-${sha256Hex(canonicalRoot).slice(0, 24)}`;
+}
+
+function isWithinCanonicalRoot(target: string, root: string): boolean {
+ const normalizedRoot = resolve(root);
+ const normalizedTarget = resolve(target);
+ return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${sep}`);
+}
+
+async function pathExists(path: string): Promise {
+ try {
+ await stat(path);
+ return true;
+ } catch (err: any) {
+ if (err?.code === "ENOENT") return false;
+ throw err;
+ }
+}
+
+async function canonicalExistingDirectory(path: string): Promise {
+ try {
+ const canonical = await realpath(path);
+ const info = await stat(canonical);
+ return info.isDirectory() ? canonical : null;
+ } catch {
+ return null;
+ }
+}
+
+function assertContentLimit(content: unknown): asserts content is string {
+ if (typeof content !== "string") throw httpError(400, "content must be a string");
+ if (Buffer.byteLength(content, "utf8") > MAX_MEMORY_CONTENT_BYTES) {
+ throw httpError(413, `Memory document exceeds the ${MAX_MEMORY_CONTENT_BYTES} byte limit`);
+ }
+}
+
+function validateEntryFile(file: string): void {
+ if (typeof file !== "string" || file.length === 0) throw httpError(400, "file is required");
+ if (file.includes("\0") || file.includes("/") || file.includes("\\"))
+ throw httpError(400, `Invalid memory file: ${file}`);
+ if (file === "." || file === ".." || file.startsWith(".") || file.startsWith("_")) {
+ throw httpError(400, `Invalid memory file: ${file}`);
+ }
+ if (file.toLowerCase() === MEMORY_INDEX_FILE.toLowerCase())
+ throw httpError(400, "MEMORY.md is the index, not an entry");
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/.test(file))
+ throw httpError(400, `Memory entries must be .md files: ${file}`);
+}
+
+function validateDocumentFile(file: string): "index" | "entry" {
+ if (file === MEMORY_INDEX_FILE) return "index";
+ validateEntryFile(file);
+ return "entry";
+}
+
+function validateMetadata(frontmatter: Record): MemoryEntryMetadataDto {
+ const { name, description, type } = frontmatter;
+ if (typeof name !== "string" || name.length === 0) throw new Error("frontmatter.name must be a non-empty string");
+ if (typeof description !== "string" || description.length === 0) {
+ throw new Error("frontmatter.description must be a non-empty string");
+ }
+ if (typeof type !== "string" || !VALID_ENTRY_TYPES.has(type as MemoryEntryTypeDto)) {
+ throw new Error("frontmatter.type must be one of user-preferences, good-practices, project, navigation");
+ }
+ return { name, description, type: type as MemoryEntryTypeDto };
+}
+
+function parseEntryMetadata(content: string): { metadata?: MemoryEntryMetadataDto; metadataError?: string } {
+ try {
+ const { frontmatter } = parseFrontmatter>(content);
+ return { metadata: validateMetadata(frontmatter) };
+ } catch (err) {
+ return { metadataError: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+async function readUtf8Limited(path: string): Promise {
+ const info = await stat(path);
+ if (!info.isFile()) throw httpError(400, `Not a file: ${path}`);
+ if (info.size > MAX_MEMORY_CONTENT_BYTES)
+ throw httpError(413, `Memory document exceeds the ${MAX_MEMORY_CONTENT_BYTES} byte limit`);
+ return readFile(path, "utf8");
+}
+
+async function atomicReplace(path: string, content: string): Promise {
+ const dir = resolve(path, "..");
+ const temp = join(dir, `.dreb-memory-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}.tmp`);
+ let handle: Awaited> | undefined;
+ try {
+ handle = await open(temp, "wx");
+ await handle.writeFile(content, "utf8");
+ await handle.close();
+ handle = undefined;
+ await rename(temp, path);
+ } catch (err) {
+ if (handle) await handle.close().catch(() => {});
+ await unlink(temp).catch(() => {});
+ throw err;
+ }
+}
+
+function splitLinesPreserveEndings(content: string): string[] {
+ const matches = content.match(/.*(?:\r\n|\n|\r|$)/g) ?? [];
+ return matches.filter((part, index) => part.length > 0 || index < matches.length - 1);
+}
+
+function localMarkdownTargets(line: string): string[] {
+ const targets: string[] = [];
+ const regex = /\[[^\]]+\]\(([^)]+)\)/g;
+ let match = regex.exec(line);
+ while (match) {
+ targets.push(match[1]);
+ match = regex.exec(line);
+ }
+ return targets;
+}
+
+function targetMatchesFilename(target: string, filename: string): boolean {
+ return target === filename || target === `./${filename}`;
+}
+
+function escapeRegExp(text: string): string {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function removeIndexLinks(indexContent: string, filename: string): { content: string; changed: boolean } {
+ const lines = splitLinesPreserveEndings(indexContent);
+ const escaped = escapeRegExp(filename);
+ const safeLine = new RegExp(
+ `^\\s*[-*+]\\s+\\[[^\\]]+\\]\\((?:\\./)?${escaped}\\)(?:\\s+(?:[-—:]|—)\\s+.*)?\\s*(?:\\r?\\n|\\r)?$`,
+ "u",
+ );
+ let changed = false;
+ const kept: string[] = [];
+ for (const line of lines) {
+ const matches = localMarkdownTargets(line).some((target) => targetMatchesFilename(target, filename));
+ if (!matches) {
+ kept.push(line);
+ continue;
+ }
+ if (!safeLine.test(line)) {
+ throw httpError(409, `Index line for ${filename} is not safe to remove automatically`);
+ }
+ changed = true;
+ }
+ return { content: kept.join(""), changed };
+}
+
+export class MemoryApi {
+ constructor(
+ private readonly homeDir: string,
+ private readonly log: MemoryOpLogger,
+ ) {}
+
+ async scopes(cwdInventory: string[]): Promise {
+ const scopes: MemoryScopeDto[] = [];
+ const globalMemoryDir = resolve(this.homeDir, ".dreb", "memory");
+ scopes.push({
+ id: "global",
+ kind: "global",
+ label: "global",
+ memoryDir: globalMemoryDir,
+ exists: await pathExists(globalMemoryDir),
+ });
+
+ const roots = new Map();
+ for (const cwd of cwdInventory) {
+ if (typeof cwd !== "string" || cwd.length === 0) continue;
+ const existingCwd = await canonicalExistingDirectory(cwd);
+ if (!existingCwd) continue;
+ const root = findGitRoot(existingCwd) ?? existingCwd;
+ const canonicalRoot = await canonicalExistingDirectory(root);
+ if (!canonicalRoot) continue;
+ roots.set(canonicalRoot, canonicalRoot);
+ }
+ for (const root of [...roots.keys()].sort((a, b) => a.localeCompare(b))) {
+ const memoryDir = join(root, ".dreb", "memory");
+ scopes.push({
+ id: projectScopeId(root),
+ kind: "project",
+ label: basename(root) || root,
+ projectRoot: root,
+ memoryDir,
+ exists: await pathExists(memoryDir),
+ });
+ }
+ return scopes;
+ }
+
+ async listing(scopeId: string, cwdInventory: string[]): Promise {
+ const scope = await this.requireScope(scopeId, cwdInventory);
+ const memoryRoot = await this.canonicalMemoryRootIfExists(scope);
+ if (!memoryRoot) {
+ this.log("list", scope.id);
+ return { scope, indexContent: null, indexRevision: null, indexOverLimit: false, entries: [] };
+ }
+
+ let indexContent: string | null = null;
+ let indexRevision: string | null = null;
+ try {
+ const indexPath = await this.resolveExistingTarget(scope, MEMORY_INDEX_FILE, "index");
+ indexContent = await readUtf8Limited(indexPath);
+ indexRevision = sha256Hex(indexContent);
+ } catch (err: any) {
+ if (err?.status !== 404 && err?.code !== "ENOENT") throw err;
+ }
+
+ const dirents = await readdir(memoryRoot, { withFileTypes: true });
+ const entries: MemoryEntrySummaryDto[] = [];
+ for (const dirent of dirents) {
+ if (!dirent.isFile()) continue;
+ if (dirent.name === MEMORY_INDEX_FILE) continue;
+ try {
+ validateEntryFile(dirent.name);
+ } catch {
+ continue;
+ }
+ const path = await this.resolveExistingTarget(scope, dirent.name, "entry");
+ const info = await stat(path);
+ const content = await readUtf8Limited(path);
+ entries.push({
+ file: dirent.name,
+ ...parseEntryMetadata(content),
+ modified: info.mtime.toISOString(),
+ size: info.size,
+ });
+ }
+ entries.sort((a, b) => a.file.localeCompare(b.file));
+ this.log("list", scope.id);
+ return {
+ scope: { ...scope, exists: true, memoryDir: memoryRoot },
+ indexContent,
+ indexRevision,
+ indexOverLimit: (indexContent?.split(/\r\n|\n|\r/).length ?? 0) > 200,
+ entries,
+ };
+ }
+
+ async readDocument(scopeId: string, file: string, cwdInventory: string[]): Promise {
+ const kind = validateDocumentFile(file);
+ const scope = await this.requireScope(scopeId, cwdInventory);
+ const path = await this.resolveExistingTarget(scope, file, kind);
+ const content = await readUtf8Limited(path);
+ this.log("read", scope.id, file);
+ return {
+ kind,
+ file,
+ content,
+ revision: sha256Hex(content),
+ ...(kind === "entry" ? parseEntryMetadata(content) : {}),
+ };
+ }
+
+ async saveDocument(
+ scopeId: string,
+ file: string,
+ body: unknown,
+ cwdInventory: string[],
+ ): Promise {
+ const kind = validateDocumentFile(file);
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw httpError(400, "JSON body is required");
+ const { content, revision } = body as Record;
+ assertContentLimit(content);
+ if (typeof revision !== "string" || revision.length === 0) throw httpError(400, "revision is required");
+ if (kind === "entry") {
+ const parsed = parseEntryMetadata(content);
+ if (parsed.metadataError) throw httpError(400, parsed.metadataError);
+ }
+ const scope = await this.requireScope(scopeId, cwdInventory);
+ const path = await this.resolveExistingTarget(scope, file, kind);
+ const current = await readUtf8Limited(path);
+ if (sha256Hex(current) !== revision) throw httpError(409, "Memory document is stale; refresh before saving");
+ const beforeReplace = await this.resolveExistingTarget(scope, file, kind);
+ await atomicReplace(beforeReplace, content);
+ const document = await this.readDocument(scopeId, file, cwdInventory);
+ const listing = await this.listing(scopeId, cwdInventory);
+ this.log("save", scope.id, file);
+ return { listing, document };
+ }
+
+ async deleteEntry(
+ scopeId: string,
+ file: string,
+ body: unknown,
+ cwdInventory: string[],
+ ): Promise {
+ validateEntryFile(file);
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw httpError(400, "JSON body is required");
+ const { revision, indexRevision } = body as Record;
+ if (typeof revision !== "string" || revision.length === 0) throw httpError(400, "revision is required");
+ if (indexRevision !== null && typeof indexRevision !== "string")
+ throw httpError(400, "indexRevision must be a string or null");
+ const scope = await this.requireScope(scopeId, cwdInventory);
+ const entryPath = await this.resolveExistingTarget(scope, file, "entry");
+ const originalEntry = await readUtf8Limited(entryPath);
+ if (sha256Hex(originalEntry) !== revision) throw httpError(409, "Memory entry is stale; refresh before deleting");
+
+ const memoryRoot = await this.requireCanonicalMemoryRoot(scope);
+ let indexPath = join(memoryRoot, MEMORY_INDEX_FILE);
+ let originalIndex: string | null = null;
+ try {
+ indexPath = await this.resolveExistingTarget(scope, MEMORY_INDEX_FILE, "index");
+ originalIndex = await readUtf8Limited(indexPath);
+ } catch (err: any) {
+ if (err?.code !== "ENOENT" && err?.status !== 404) throw err;
+ }
+ const actualIndexRevision = originalIndex === null ? null : sha256Hex(originalIndex);
+ if (actualIndexRevision !== indexRevision) throw httpError(409, "Memory index is stale; refresh before deleting");
+
+ let updatedIndex: string | null = originalIndex;
+ if (originalIndex !== null) updatedIndex = removeIndexLinks(originalIndex, file).content;
+ if (updatedIndex !== null && updatedIndex !== originalIndex) {
+ indexPath = await this.resolveExistingTarget(scope, MEMORY_INDEX_FILE, "index");
+ await atomicReplace(indexPath, updatedIndex);
+ }
+ try {
+ const beforeUnlink = await this.resolveExistingTarget(scope, file, "entry");
+ await unlink(beforeUnlink);
+ } catch (err) {
+ if (originalIndex !== null && updatedIndex !== originalIndex) await atomicReplace(indexPath, originalIndex);
+ throw err;
+ }
+ const listing = await this.listing(scopeId, cwdInventory);
+ if (
+ listing.indexContent &&
+ localMarkdownTargets(listing.indexContent).some((target) => targetMatchesFilename(target, file))
+ ) {
+ throw httpError(500, `Delete left a dangling index link for ${file}`);
+ }
+ this.log("delete", scope.id, file);
+ return { listing };
+ }
+
+ private async requireScope(scopeId: string, cwdInventory: string[]): Promise {
+ const scope = (await this.scopes(cwdInventory)).find((item) => item.id === scopeId);
+ if (!scope) throw httpError(404, `Unknown memory scope: ${scopeId}`);
+ return scope;
+ }
+
+ private async canonicalMemoryRootIfExists(scope: MemoryScopeDto): Promise {
+ return canonicalExistingDirectory(scope.memoryDir);
+ }
+
+ private async requireCanonicalMemoryRoot(scope: MemoryScopeDto): Promise {
+ const root = await this.canonicalMemoryRootIfExists(scope);
+ if (!root) throw httpError(404, `Memory directory does not exist: ${scope.memoryDir}`);
+ return root;
+ }
+
+ private async resolveExistingTarget(scope: MemoryScopeDto, file: string, kind: "index" | "entry"): Promise {
+ if (kind === "index" && file !== MEMORY_INDEX_FILE) throw httpError(400, "Invalid index file");
+ if (kind === "entry") validateEntryFile(file);
+ const memoryRoot = await this.requireCanonicalMemoryRoot(scope);
+ const target = await canonicalizePath(join(memoryRoot, file), { mustExist: true });
+ if (!isWithinCanonicalRoot(target, memoryRoot)) throw httpError(400, `Memory target escapes scope: ${file}`);
+ return target;
+ }
+}
diff --git a/packages/dashboard/src/server/server.ts b/packages/dashboard/src/server/server.ts
index 17bdbbc2..7f1db78c 100644
--- a/packages/dashboard/src/server/server.ts
+++ b/packages/dashboard/src/server/server.ts
@@ -37,6 +37,7 @@ import {
import { EventHub, formatHeartbeatFrame, type SseWriteMetadata } from "./event-hub.js";
import { defaultPlaces, FileApi } from "./files.js";
import { ImagePreviewWorker } from "./image-preview.js";
+import { MemoryApi } from "./memories.js";
import type { DashboardRuntimeSnapshot, RuntimePool } from "./runtime-pool.js";
import { readSubagentMessages, SubagentSessionLogNotFoundError } from "./subagent-log.js";
@@ -64,6 +65,8 @@ export interface DashboardServerOptions {
imageService?: DashboardImageService;
/** Named heartbeat interval; defaults to 25 seconds. */
heartbeatIntervalMs?: number;
+ /** Test-only override for the global dreb memory home directory. */
+ memoryHomeDir?: string;
}
const DEVICE_COOKIE = "dreb_dashboard_device";
@@ -134,6 +137,9 @@ export function createDashboardServer(options: DashboardServerOptions): Dashboar
const diagnosticConnections = new Map();
const log = options.logger ?? ((line: string) => console.log(`[dashboard] ${line}`));
const files = new FileApi((op, path, detail) => log(`file ${op}: ${path}${detail ? ` (${detail})` : ""}`));
+ const memories = new MemoryApi(options.memoryHomeDir ?? homedir(), (op, scopeId, detail) =>
+ log(`memory ${op}: ${scopeId}${detail ? ` (${detail})` : ""}`),
+ );
const hub = options.eventHub ?? new EventHub();
const images = options.imageService ?? new DashboardImageService(new ImagePreviewWorker());
hub.setEventProjector((key, event) => (key ? images.projectEvent(event, { runtimeKey: key }) : event));
@@ -435,6 +441,52 @@ export function createDashboardServer(options: DashboardServerOptions): Dashboar
const listDiskSessions = async (): Promise =>
((await options.listAllSessions()) as SessionInfoDto[]).filter((session) => existsSync(session.cwd));
+ const currentCwdInventory = async (): Promise => [
+ ...pool.list().map((handle) => handle.cwd),
+ ...(await listDiskSessions()).map((session) => session.cwd),
+ ];
+
+ const handleMemoryError = (res: Response, err: unknown): void => {
+ const status =
+ typeof (err as { status?: unknown })?.status === "number" ? (err as { status: number }).status : 500;
+ res.status(status).json({ error: err instanceof Error ? err.message : String(err) });
+ };
+
+ app.get("/api/memories/scopes", (_req, res) => {
+ currentCwdInventory()
+ .then((inventory) => memories.scopes(inventory))
+ .then((scopes) => res.json({ scopes }))
+ .catch((err) => handleMemoryError(res, err));
+ });
+
+ app.get("/api/memories/:scopeId", (req, res) => {
+ currentCwdInventory()
+ .then((inventory) => memories.listing(req.params.scopeId, inventory))
+ .then((listing) => res.json(listing))
+ .catch((err) => handleMemoryError(res, err));
+ });
+
+ app.get("/api/memories/:scopeId/documents/:file", (req, res) => {
+ currentCwdInventory()
+ .then((inventory) => memories.readDocument(req.params.scopeId, req.params.file, inventory))
+ .then((document) => res.json(document))
+ .catch((err) => handleMemoryError(res, err));
+ });
+
+ app.put("/api/memories/:scopeId/documents/:file", (req, res) => {
+ currentCwdInventory()
+ .then((inventory) => memories.saveDocument(req.params.scopeId, req.params.file, req.body, inventory))
+ .then((result) => res.json(result))
+ .catch((err) => handleMemoryError(res, err));
+ });
+
+ app.delete("/api/memories/:scopeId/entries/:file", (req, res) => {
+ currentCwdInventory()
+ .then((inventory) => memories.deleteEntry(req.params.scopeId, req.params.file, req.body, inventory))
+ .then((result) => res.json(result))
+ .catch((err) => handleMemoryError(res, err));
+ });
+
const getFleet = async (): Promise => {
const runtimes = await Promise.all(pool.list().map((h) => pool.describe(h)));
return { runtimes, diskSessions: await listDiskSessions() };
diff --git a/packages/dashboard/src/shared/protocol.ts b/packages/dashboard/src/shared/protocol.ts
index 841fda82..b1681a8a 100644
--- a/packages/dashboard/src/shared/protocol.ts
+++ b/packages/dashboard/src/shared/protocol.ts
@@ -405,6 +405,54 @@ export interface DirListingDto {
contextTrust: ContextTrustEvaluationDto;
}
+export type MemoryScopeKindDto = "global" | "project";
+export type MemoryEntryTypeDto = "user-preferences" | "good-practices" | "project" | "navigation";
+
+export interface MemoryEntryMetadataDto {
+ name: string;
+ description: string;
+ type: MemoryEntryTypeDto;
+}
+
+export interface MemoryScopeDto {
+ id: string;
+ kind: MemoryScopeKindDto;
+ label: string;
+ projectRoot?: string;
+ memoryDir: string;
+ exists: boolean;
+}
+
+export interface MemoryEntrySummaryDto {
+ file: string;
+ metadata?: MemoryEntryMetadataDto;
+ metadataError?: string;
+ modified: string;
+ size: number;
+}
+
+export interface MemoryListingDto {
+ scope: MemoryScopeDto;
+ indexContent: string | null;
+ indexRevision: string | null;
+ indexOverLimit: boolean;
+ entries: MemoryEntrySummaryDto[];
+}
+
+export interface MemoryDocumentDto {
+ kind: "index" | "entry";
+ file: string;
+ content: string;
+ revision: string;
+ metadata?: MemoryEntryMetadataDto;
+ metadataError?: string;
+}
+
+export interface MemoryMutationResultDto {
+ listing: MemoryListingDto;
+ document?: MemoryDocumentDto;
+}
+
/** Auth mode reported to the client. */
export interface AuthStatusDto {
mode: "local" | "remote";
diff --git a/packages/dashboard/test/client/screens.test.tsx b/packages/dashboard/test/client/screens.test.tsx
index c1ff5336..3e01affd 100644
--- a/packages/dashboard/test/client/screens.test.tsx
+++ b/packages/dashboard/test/client/screens.test.tsx
@@ -114,6 +114,66 @@ vi.mock("../../src/client/api.js", () => ({
lastActivity: new Date().toISOString(),
})),
places: vi.fn(async () => ({ places: [{ label: "home", path: "/home/test" }] })),
+ memoryScopes: vi.fn(async () => ({
+ scopes: [
+ { id: "global", kind: "global", label: "global", memoryDir: "/home/test/.dreb/memory", exists: true },
+ ],
+ })),
+ memoryListing: vi.fn(async () => ({
+ scope: { id: "global", kind: "global", label: "global", memoryDir: "/home/test/.dreb/memory", exists: true },
+ indexContent: "- [Entry](entry.md) — entry\n",
+ indexRevision: "idx1",
+ indexOverLimit: false,
+ entries: [
+ {
+ file: "entry.md",
+ metadata: { name: "Entry", description: "Test entry", type: "project" },
+ modified: new Date().toISOString(),
+ size: 64,
+ },
+ ],
+ })),
+ memoryDocument: vi.fn(async (_scopeId: string, file: string) => ({
+ kind: file === "MEMORY.md" ? "index" : "entry",
+ file,
+ content:
+ file === "MEMORY.md"
+ ? "- [Entry](entry.md) — entry\n"
+ : "---\nname: Entry\ndescription: Test entry\ntype: project\n---\n\nBody\n",
+ revision: file === "MEMORY.md" ? "idx1" : "rev1",
+ ...(file === "MEMORY.md" ? {} : { metadata: { name: "Entry", description: "Test entry", type: "project" } }),
+ })),
+ saveMemoryDocument: vi.fn(async (_scopeId: string, file: string, content: string) => ({
+ listing: {
+ scope: {
+ id: "global",
+ kind: "global",
+ label: "global",
+ memoryDir: "/home/test/.dreb/memory",
+ exists: true,
+ },
+ indexContent: file === "MEMORY.md" ? content : "- [Entry](entry.md) — entry\n",
+ indexRevision: "idx2",
+ indexOverLimit: false,
+ entries: [],
+ },
+ document: { kind: file === "MEMORY.md" ? "index" : "entry", file, content, revision: "rev2" },
+ })),
+ deleteMemoryEntry: vi.fn(async () => ({
+ listing: {
+ scope: {
+ id: "global",
+ kind: "global",
+ label: "global",
+ memoryDir: "/home/test/.dreb/memory",
+ exists: true,
+ },
+ indexContent: "",
+ indexRevision: "idx3",
+ indexOverLimit: false,
+ entries: [],
+ },
+ })),
upload: vi.fn(async (_dir: string, file: File) => ({
path: `/home/test/project/.dreb-dashboard-uploads/${file.name}`,
})),
@@ -198,6 +258,7 @@ import {
} from "../../src/client/components/transcript.js";
import { FilesScreen } from "../../src/client/screens/files.js";
import { FleetScreen, fleetGroupKey } from "../../src/client/screens/fleet.js";
+import { MemoriesScreen } from "../../src/client/screens/memories.js";
import { PairingScreen } from "../../src/client/screens/pairing.js";
import { formatTokens, SessionScreen } from "../../src/client/screens/session.js";
import { SettingsScreen } from "../../src/client/screens/settings.js";
@@ -2461,6 +2522,77 @@ describe("screen smoke tests", () => {
expect(el.textContent).toContain("trust write failed");
});
+ it("memories renders scopes, index warning, editor, conflict, and delete flow", async () => {
+ vi.mocked(api.memoryListing).mockResolvedValueOnce({
+ scope: { id: "global", kind: "global", label: "global", memoryDir: "/home/test/.dreb/memory", exists: true },
+ indexContent: Array.from({ length: 201 }, (_, i) => `line ${i}`).join("\n"),
+ indexRevision: "idx1",
+ indexOverLimit: true,
+ entries: [
+ {
+ file: "entry.md",
+ metadata: { name: "Entry", description: "Test entry", type: "project" },
+ modified: new Date().toISOString(),
+ size: 64,
+ },
+ ],
+ });
+ vi.mocked(api.saveMemoryDocument).mockRejectedValueOnce(
+ Object.assign(new Error("Memory document is stale"), { status: 409 }),
+ );
+ const store = makeStore();
+ const el = mount(() => );
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ expect(el.textContent).toContain("Edit dreb memory only");
+ expect(el.textContent).toContain("Complete index warning");
+ const textarea = el.querySelector("textarea") as HTMLTextAreaElement;
+ textarea.value = `${textarea.value}\nextra`;
+ textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: "extra" }));
+ [...el.querySelectorAll("button")].find((button) => button.textContent === "save")!.click();
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(el.textContent).toContain("Your draft is still here");
+
+ const entryButton = [...el.querySelectorAll("button")].find((button) =>
+ button.textContent?.includes("entry.md"),
+ )!;
+ entryButton.click();
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ entryButton.click();
+ expect([...el.querySelectorAll("button")].some((button) => button.textContent === "delete entry")).toBe(true);
+ [...el.querySelectorAll("button")].find((button) => button.textContent === "delete entry")!.click();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(el.textContent).toContain("Delete entry.md?");
+ [...el.querySelectorAll("button")]
+ .reverse()
+ .find((button) => button.textContent === "delete entry")!
+ .click();
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(api.deleteMemoryEntry).toHaveBeenCalled();
+ });
+
+ it("memories shows missing and malformed empty states", async () => {
+ vi.mocked(api.memoryListing).mockResolvedValueOnce({
+ scope: { id: "global", kind: "global", label: "global", memoryDir: "/home/test/.dreb/memory", exists: false },
+ indexContent: null,
+ indexRevision: null,
+ indexOverLimit: false,
+ entries: [],
+ });
+ const store = makeStore();
+ const el = mount(() => );
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(el.textContent).toContain("Memory directory is missing");
+ });
+
+ it("memories surfaces scope-loading failures", async () => {
+ vi.mocked(api.memoryScopes).mockRejectedValueOnce(new Error("memory inventory failed"));
+ const store = makeStore();
+ const el = mount(() => );
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(el.textContent).toContain("memory inventory failed");
+ });
+
it("settings explains defaults and live-session context trust", async () => {
const store = makeStore();
const el = mount(() => );
diff --git a/packages/dashboard/test/client/store.test.ts b/packages/dashboard/test/client/store.test.ts
index fe069532..1ae79aef 100644
--- a/packages/dashboard/test/client/store.test.ts
+++ b/packages/dashboard/test/client/store.test.ts
@@ -153,6 +153,14 @@ afterEach(() => {
});
describe("settings routes", () => {
+ it("round-trips the memories hash route", () => {
+ expect(routeToHash({ screen: "memories" })).toBe("#/memories");
+ window.location.hash = "#/memories";
+ const store = createAppStore();
+ expect(store.route()).toEqual({ screen: "memories" });
+ store.stop();
+ });
+
it("preserves the legacy settings hash and round-trips scoped-model context", () => {
expect(routeToHash({ screen: "settings" })).toBe("#/settings");
expect(routeToHash({ screen: "settings", target: "scoped-models", cwd: "/tmp/a b" })).toBe(
diff --git a/packages/dashboard/test/memories.test.ts b/packages/dashboard/test/memories.test.ts
new file mode 100644
index 00000000..2ca40960
--- /dev/null
+++ b/packages/dashboard/test/memories.test.ts
@@ -0,0 +1,168 @@
+import { mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { MemoryApi } from "../src/server/memories.js";
+
+const tempDirs: string[] = [];
+
+async function tempDir(): Promise {
+ const dir = await realpath(await mkdtemp(join(tmpdir(), "dreb-dash-memory-")));
+ tempDirs.push(dir);
+ return dir;
+}
+
+afterEach(async () => {
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
+});
+
+async function makeProject(name: string): Promise {
+ const root = join(await tempDir(), name);
+ await mkdir(join(root, ".git"), { recursive: true });
+ await mkdir(join(root, ".dreb", "memory"), { recursive: true });
+ return root;
+}
+
+function entry(name = "alpha", type = "project"): string {
+ return `---\nname: ${name}\ndescription: ${name} desc\ntype: ${type}\n---\n\nBody ${name}\n`;
+}
+
+describe("MemoryApi", () => {
+ it("discovers global and project scopes with dedupe and stable ordering", async () => {
+ const home = await tempDir();
+ await mkdir(join(home, ".dreb", "memory"), { recursive: true });
+ const b = await makeProject("b-project");
+ const a = await makeProject("a-project");
+ await mkdir(join(a, "src"), { recursive: true });
+ const api = new MemoryApi(home, vi.fn());
+
+ const scopes = await api.scopes([join(b, "missing"), b, join(a, "src"), a]);
+
+ expect(scopes.map((scope) => scope.kind)).toEqual(["global", "project", "project"]);
+ expect(scopes.slice(1).map((scope) => scope.projectRoot)).toEqual(
+ [a, b].sort((left, right) => left.localeCompare(right)),
+ );
+ expect(new Set(scopes.map((scope) => scope.id)).size).toBe(scopes.length);
+ });
+
+ it("lists missing directories without creating them and flags long complete indexes", async () => {
+ const home = await tempDir();
+ const api = new MemoryApi(home, vi.fn());
+ const missing = await api.listing("global", []);
+ expect(missing.indexContent).toBeNull();
+ expect(missing.entries).toEqual([]);
+ expect(await readdir(home)).toEqual([]);
+
+ await mkdir(join(home, ".dreb", "memory"), { recursive: true });
+ await writeFile(
+ join(home, ".dreb", "memory", "MEMORY.md"),
+ Array.from({ length: 201 }, (_, i) => `line ${i}`).join("\n"),
+ );
+ const listing = await api.listing("global", []);
+ expect(listing.indexContent?.split("\n")).toHaveLength(201);
+ expect(listing.indexOverLimit).toBe(true);
+ });
+
+ it("surfaces malformed metadata while accepting valid entry summaries", async () => {
+ const home = await tempDir();
+ const memory = join(home, ".dreb", "memory");
+ await mkdir(memory, { recursive: true });
+ await writeFile(join(memory, "good.md"), entry("Good", "navigation"));
+ await writeFile(join(memory, "bad.md"), "---\nname: Bad\ntype: nope\n---\nbody");
+ const api = new MemoryApi(home, vi.fn());
+
+ const listing = await api.listing("global", []);
+ expect(listing.entries.map((item) => item.file)).toEqual(["bad.md", "good.md"]);
+ expect(listing.entries.find((item) => item.file === "good.md")?.metadata).toMatchObject({ type: "navigation" });
+ expect(listing.entries.find((item) => item.file === "bad.md")?.metadataError).toContain(
+ "frontmatter.description",
+ );
+ });
+
+ it("rejects traversal, invalid names, and entry or index symlink escapes", async () => {
+ const home = await tempDir();
+ const memory = join(home, ".dreb", "memory");
+ await mkdir(memory, { recursive: true });
+ await writeFile(join(memory, "good.md"), entry());
+ const outside = join(home, "outside.md");
+ await writeFile(outside, entry("Outside"));
+ await symlink(outside, join(memory, "link.md"));
+ const outsideIndex = join(home, "outside-index.md");
+ await writeFile(outsideIndex, "- [Good](good.md) — outside\n");
+ await symlink(outsideIndex, join(memory, "MEMORY.md"));
+ const api = new MemoryApi(home, vi.fn());
+
+ await expect(api.readDocument("global", "../outside.md", [])).rejects.toMatchObject({ status: 400 });
+ await expect(api.readDocument("global", ".hidden.md", [])).rejects.toMatchObject({ status: 400 });
+ await expect(api.readDocument("global", "link.md", [])).rejects.toMatchObject({ status: 400 });
+ await expect(api.listing("global", [])).rejects.toMatchObject({ status: 400 });
+ const doc = await api.readDocument("global", "good.md", []);
+ await expect(
+ api.deleteEntry("global", "good.md", { revision: doc.revision, indexRevision: null }, []),
+ ).rejects.toMatchObject({ status: 400 });
+ expect(await readFile(outsideIndex, "utf8")).toContain("[Good](good.md)");
+ });
+
+ it("enforces revisions and entry metadata validation on save", async () => {
+ const home = await tempDir();
+ const memory = join(home, ".dreb", "memory");
+ await mkdir(memory, { recursive: true });
+ await writeFile(join(memory, "MEMORY.md"), "# Memory\n");
+ await writeFile(join(memory, "good.md"), entry());
+ const api = new MemoryApi(home, vi.fn());
+ const doc = await api.readDocument("global", "good.md", []);
+
+ await expect(
+ api.saveDocument("global", "good.md", { content: entry("New"), revision: "stale" }, []),
+ ).rejects.toMatchObject({ status: 409 });
+ await expect(
+ api.saveDocument("global", "good.md", { content: "no frontmatter", revision: doc.revision }, []),
+ ).rejects.toMatchObject({ status: 400 });
+ await api.saveDocument("global", "good.md", { content: entry("New"), revision: doc.revision }, []);
+ expect(await readFile(join(memory, "good.md"), "utf8")).toContain("name: New");
+ expect((await readdir(memory)).filter((name) => name.startsWith(".dreb-memory-"))).toEqual([]);
+ });
+
+ it("deletes entries only after synchronized index cleanup and preserves unrelated formatting", async () => {
+ const home = await tempDir();
+ const memory = join(home, ".dreb", "memory");
+ await mkdir(memory, { recursive: true });
+ await writeFile(join(memory, "delete.md"), entry("Delete"));
+ await writeFile(join(memory, "keep.md"), entry("Keep"));
+ const index = "# Memory\r\n\r\n- [Delete](delete.md) — remove me\r\n- [Keep](keep.md) — keep me\r\n";
+ await writeFile(join(memory, "MEMORY.md"), index);
+ const api = new MemoryApi(home, vi.fn());
+ const doc = await api.readDocument("global", "delete.md", []);
+ const listing = await api.listing("global", []);
+
+ await api.deleteEntry(
+ "global",
+ "delete.md",
+ { revision: doc.revision, indexRevision: listing.indexRevision },
+ [],
+ );
+
+ await expect(readFile(join(memory, "delete.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
+ const updated = await readFile(join(memory, "MEMORY.md"), "utf8");
+ expect(updated).toBe("# Memory\r\n\r\n- [Keep](keep.md) — keep me\r\n");
+ });
+
+ it("allows no-index and unindexed deletes, and rejects unsafe index rewrites loudly", async () => {
+ const home = await tempDir();
+ const memory = join(home, ".dreb", "memory");
+ await mkdir(memory, { recursive: true });
+ await writeFile(join(memory, "loose.md"), entry("Loose"));
+ const api = new MemoryApi(home, vi.fn());
+ let doc = await api.readDocument("global", "loose.md", []);
+ await api.deleteEntry("global", "loose.md", { revision: doc.revision, indexRevision: null }, []);
+
+ await writeFile(join(memory, "unsafe.md"), entry("Unsafe"));
+ await writeFile(join(memory, "MEMORY.md"), "prefix [Unsafe](unsafe.md) suffix\n");
+ doc = await api.readDocument("global", "unsafe.md", []);
+ const listing = await api.listing("global", []);
+ await expect(
+ api.deleteEntry("global", "unsafe.md", { revision: doc.revision, indexRevision: listing.indexRevision }, []),
+ ).rejects.toMatchObject({ status: 409 });
+ expect(await readFile(join(memory, "unsafe.md"), "utf8")).toContain("Unsafe");
+ });
+});
diff --git a/packages/dashboard/test/server.test.ts b/packages/dashboard/test/server.test.ts
index 7e74e98c..ee71267b 100644
--- a/packages/dashboard/test/server.test.ts
+++ b/packages/dashboard/test/server.test.ts
@@ -1,4 +1,4 @@
-import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { type IncomingMessage, request, type Server, ServerResponse } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -31,6 +31,7 @@ interface TestServerOptions {
imageService?: DashboardImageService;
heartbeatIntervalMs?: number;
fleetSnapshotDebounceMs?: number;
+ memoryHomeDir?: string;
}
async function createTempProject(): Promise {
@@ -111,6 +112,7 @@ async function startServer(options: TestServerOptions = {}) {
eventHub: options.eventHub,
imageService: options.imageService,
heartbeatIntervalMs: options.heartbeatIntervalMs,
+ memoryHomeDir: options.memoryHomeDir,
});
const server = await new Promise((resolve) => {
const s = app.listen(0, "127.0.0.1", () => resolve(s));
@@ -193,6 +195,60 @@ describe("dashboard server — auth middleware", () => {
});
});
+describe("dashboard server — memories routes", () => {
+ it("lists scopes, reads, saves, conflicts, and deletes through authenticated routes", async () => {
+ const home = await createTempProject();
+ const memory = join(home, ".dreb", "memory");
+ await mkdir(memory, { recursive: true });
+ await writeFile(join(memory, "MEMORY.md"), "- [Entry](entry.md) — entry\n");
+ await writeFile(
+ join(memory, "entry.md"),
+ "---\nname: Entry\ndescription: Test entry\ntype: project\n---\n\nBody\n",
+ );
+ const { base } = await startServer({ memoryHomeDir: home });
+
+ const scopesRes = await fetch(`${base}/api/memories/scopes`);
+ expect(scopesRes.status).toBe(200);
+ const scopes = (await scopesRes.json()) as { scopes: Array<{ id: string; kind: string }> };
+ expect(scopes.scopes).toEqual([expect.objectContaining({ id: "global", kind: "global" })]);
+
+ const listingRes = await fetch(`${base}/api/memories/global`);
+ expect(listingRes.status).toBe(200);
+ const listing = (await listingRes.json()) as { indexRevision: string; entries: Array<{ file: string }> };
+ expect(listing.entries.map((entry) => entry.file)).toEqual(["entry.md"]);
+
+ const docRes = await fetch(`${base}/api/memories/global/documents/entry.md`);
+ expect(docRes.status).toBe(200);
+ const doc = (await docRes.json()) as { revision: string; content: string };
+ const stale = await fetch(`${base}/api/memories/global/documents/entry.md`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ content: doc.content, revision: "stale" }),
+ });
+ expect(stale.status).toBe(409);
+
+ const saved = await fetch(`${base}/api/memories/global/documents/entry.md`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ content: doc.content.replace("Body", "Updated"), revision: doc.revision }),
+ });
+ expect(saved.status).toBe(200);
+ expect(await readFile(join(memory, "entry.md"), "utf8")).toContain("Updated");
+ const savedBody = (await saved.json()) as { document: { revision: string }; listing: { indexRevision: string } };
+
+ const deleted = await fetch(`${base}/api/memories/global/entries/entry.md`, {
+ method: "DELETE",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ revision: savedBody.document.revision,
+ indexRevision: savedBody.listing.indexRevision,
+ }),
+ });
+ expect(deleted.status).toBe(200);
+ expect(await readFile(join(memory, "MEMORY.md"), "utf8")).toBe("");
+ });
+});
+
describe("dashboard server — pairing code", () => {
const alice: TailscaleIdentity = { loginName: "alice@example.com", device: "phone" };
From 02b8c4d39f911f69e778b6ec6720b7e5dd78c1ae Mon Sep 17 00:00:00 2001
From: m-aebrer
Date: Fri, 7 Aug 2026 14:37:13 -0400
Subject: [PATCH 3/6] Fix dashboard memory navigation pressure
---
.../dashboard/src/client/screens/memories.tsx | 16 ++--
packages/dashboard/src/server/memories.ts | 78 +++++++++++++---
.../dashboard/test/client/screens.test.tsx | 90 +++++++++++++++++++
packages/dashboard/test/memories.test.ts | 28 +++++-
4 files changed, 189 insertions(+), 23 deletions(-)
diff --git a/packages/dashboard/src/client/screens/memories.tsx b/packages/dashboard/src/client/screens/memories.tsx
index 3ae5ec8c..a1efcc9b 100644
--- a/packages/dashboard/src/client/screens/memories.tsx
+++ b/packages/dashboard/src/client/screens/memories.tsx
@@ -3,7 +3,7 @@
* Existing documents only: MEMORY.md index plus direct child .md entries.
*/
-import { createEffect, createResource, createSignal, For, type JSX, Show } from "solid-js";
+import { batch, createEffect, createResource, createSignal, For, type JSX, Show } from "solid-js";
import type {
MemoryDocumentDto,
MemoryEntrySummaryDto,
@@ -114,11 +114,13 @@ export function MemoriesScreen(props: { store: AppStore }): JSX.Element {
function chooseScope(scope: MemoryScopeDto) {
if (selectedScopeId() === scope.id) return;
- setSelectedScopeId(scope.id);
- setSelectedFile(undefined);
- mutateDocument(undefined);
- setDirty(false);
- setDraft("");
+ batch(() => {
+ setSelectedFile(undefined);
+ setSelectedScopeId(scope.id);
+ mutateDocument(undefined);
+ setDirty(false);
+ setDraft("");
+ });
}
function chooseFile(file: string) {
@@ -369,7 +371,7 @@ export function MemoriesScreen(props: { store: AppStore }): JSX.Element {
preview
-
+
>
)}
diff --git a/packages/dashboard/src/server/memories.ts b/packages/dashboard/src/server/memories.ts
index b74b2801..4d0fb165 100644
--- a/packages/dashboard/src/server/memories.ts
+++ b/packages/dashboard/src/server/memories.ts
@@ -8,8 +8,10 @@
*/
import { createHash, randomBytes } from "node:crypto";
+import type { Stats } from "node:fs";
import { open, readdir, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
import { basename, join, resolve, sep } from "node:path";
+import { StringDecoder } from "node:string_decoder";
import { findGitRoot, parseFrontmatter } from "@dreb/coding-agent";
import type {
MemoryDocumentDto,
@@ -113,14 +115,49 @@ function parseEntryMetadata(content: string): { metadata?: MemoryEntryMetadataDt
}
}
-async function readUtf8Limited(path: string): Promise {
+async function requireLimitedFile(path: string): Promise {
const info = await stat(path);
if (!info.isFile()) throw httpError(400, `Not a file: ${path}`);
if (info.size > MAX_MEMORY_CONTENT_BYTES)
throw httpError(413, `Memory document exceeds the ${MAX_MEMORY_CONTENT_BYTES} byte limit`);
+ return info;
+}
+
+async function readUtf8Limited(path: string): Promise {
+ await requireLimitedFile(path);
return readFile(path, "utf8");
}
+async function readEntryMetadata(path: string): Promise<{
+ info: Stats;
+ parsed: ReturnType;
+}> {
+ const info = await requireLimitedFile(path);
+ const handle = await open(path, "r");
+ const decoder = new StringDecoder("utf8");
+ const chunk = Buffer.allocUnsafe(16 * 1024);
+ let prefix = "";
+ let position = 0;
+ try {
+ while (position < info.size) {
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, info.size - position), position);
+ if (bytesRead === 0) break;
+ position += bytesRead;
+ prefix += decoder.write(chunk.subarray(0, bytesRead));
+ if (position === bytesRead && !prefix.startsWith("---")) break;
+ const end = prefix.indexOf("\n---", 3);
+ if (end !== -1) {
+ prefix = prefix.slice(0, end + 4);
+ break;
+ }
+ }
+ prefix += decoder.end();
+ } finally {
+ await handle.close();
+ }
+ return { info, parsed: parseEntryMetadata(prefix) };
+}
+
async function atomicReplace(path: string, content: string): Promise {
const dir = resolve(path, "..");
const temp = join(dir, `.dreb-memory-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}.tmp`);
@@ -193,7 +230,8 @@ export class MemoryApi {
async scopes(cwdInventory: string[]): Promise {
const scopes: MemoryScopeDto[] = [];
- const globalMemoryDir = resolve(this.homeDir, ".dreb", "memory");
+ const canonicalHome = (await canonicalExistingDirectory(this.homeDir)) ?? resolve(this.homeDir);
+ const globalMemoryDir = resolve(canonicalHome, ".dreb", "memory");
scopes.push({
id: "global",
kind: "global",
@@ -209,7 +247,7 @@ export class MemoryApi {
if (!existingCwd) continue;
const root = findGitRoot(existingCwd) ?? existingCwd;
const canonicalRoot = await canonicalExistingDirectory(root);
- if (!canonicalRoot) continue;
+ if (!canonicalRoot || canonicalRoot === canonicalHome) continue;
roots.set(canonicalRoot, canonicalRoot);
}
for (const root of [...roots.keys()].sort((a, b) => a.localeCompare(b))) {
@@ -227,7 +265,10 @@ export class MemoryApi {
}
async listing(scopeId: string, cwdInventory: string[]): Promise {
- const scope = await this.requireScope(scopeId, cwdInventory);
+ return this.listingForScope(await this.requireScope(scopeId, cwdInventory));
+ }
+
+ private async listingForScope(scope: MemoryScopeDto): Promise {
const memoryRoot = await this.canonicalMemoryRootIfExists(scope);
if (!memoryRoot) {
this.log("list", scope.id);
@@ -237,7 +278,7 @@ export class MemoryApi {
let indexContent: string | null = null;
let indexRevision: string | null = null;
try {
- const indexPath = await this.resolveExistingTarget(scope, MEMORY_INDEX_FILE, "index");
+ const indexPath = await this.resolveExistingTargetWithinRoot(memoryRoot, MEMORY_INDEX_FILE, "index");
indexContent = await readUtf8Limited(indexPath);
indexRevision = sha256Hex(indexContent);
} catch (err: any) {
@@ -254,12 +295,11 @@ export class MemoryApi {
} catch {
continue;
}
- const path = await this.resolveExistingTarget(scope, dirent.name, "entry");
- const info = await stat(path);
- const content = await readUtf8Limited(path);
+ const path = await this.resolveExistingTargetWithinRoot(memoryRoot, dirent.name, "entry");
+ const { info, parsed } = await readEntryMetadata(path);
entries.push({
file: dirent.name,
- ...parseEntryMetadata(content),
+ ...parsed,
modified: info.mtime.toISOString(),
size: info.size,
});
@@ -276,8 +316,11 @@ export class MemoryApi {
}
async readDocument(scopeId: string, file: string, cwdInventory: string[]): Promise {
+ return this.readDocumentForScope(await this.requireScope(scopeId, cwdInventory), file);
+ }
+
+ private async readDocumentForScope(scope: MemoryScopeDto, file: string): Promise {
const kind = validateDocumentFile(file);
- const scope = await this.requireScope(scopeId, cwdInventory);
const path = await this.resolveExistingTarget(scope, file, kind);
const content = await readUtf8Limited(path);
this.log("read", scope.id, file);
@@ -311,8 +354,8 @@ export class MemoryApi {
if (sha256Hex(current) !== revision) throw httpError(409, "Memory document is stale; refresh before saving");
const beforeReplace = await this.resolveExistingTarget(scope, file, kind);
await atomicReplace(beforeReplace, content);
- const document = await this.readDocument(scopeId, file, cwdInventory);
- const listing = await this.listing(scopeId, cwdInventory);
+ const document = await this.readDocumentForScope(scope, file);
+ const listing = await this.listingForScope(scope);
this.log("save", scope.id, file);
return { listing, document };
}
@@ -359,7 +402,7 @@ export class MemoryApi {
if (originalIndex !== null && updatedIndex !== originalIndex) await atomicReplace(indexPath, originalIndex);
throw err;
}
- const listing = await this.listing(scopeId, cwdInventory);
+ const listing = await this.listingForScope(scope);
if (
listing.indexContent &&
localMarkdownTargets(listing.indexContent).some((target) => targetMatchesFilename(target, file))
@@ -387,9 +430,16 @@ export class MemoryApi {
}
private async resolveExistingTarget(scope: MemoryScopeDto, file: string, kind: "index" | "entry"): Promise {
+ return this.resolveExistingTargetWithinRoot(await this.requireCanonicalMemoryRoot(scope), file, kind);
+ }
+
+ private async resolveExistingTargetWithinRoot(
+ memoryRoot: string,
+ file: string,
+ kind: "index" | "entry",
+ ): Promise {
if (kind === "index" && file !== MEMORY_INDEX_FILE) throw httpError(400, "Invalid index file");
if (kind === "entry") validateEntryFile(file);
- const memoryRoot = await this.requireCanonicalMemoryRoot(scope);
const target = await canonicalizePath(join(memoryRoot, file), { mustExist: true });
if (!isWithinCanonicalRoot(target, memoryRoot)) throw httpError(400, `Memory target escapes scope: ${file}`);
return target;
diff --git a/packages/dashboard/test/client/screens.test.tsx b/packages/dashboard/test/client/screens.test.tsx
index 3e01affd..3ccc4d22 100644
--- a/packages/dashboard/test/client/screens.test.tsx
+++ b/packages/dashboard/test/client/screens.test.tsx
@@ -2571,6 +2571,96 @@ describe("screen smoke tests", () => {
expect(api.deleteMemoryEntry).toHaveBeenCalled();
});
+ it("memories keeps repeated multi-scope navigation bounded and ignores stale loads", async () => {
+ vi.mocked(api.memoryListing).mockClear();
+ vi.mocked(api.memoryDocument).mockClear();
+ type Listing = Awaited>;
+ let resolveGlobal!: (value: Listing) => void;
+ const staleGlobal = new Promise((resolve) => {
+ resolveGlobal = resolve;
+ });
+ const listingFor = (scopeId: string): Listing => ({
+ scope: {
+ id: scopeId,
+ kind: scopeId === "global" ? "global" : "project",
+ label: scopeId,
+ memoryDir: `/memory/${scopeId}`,
+ exists: true,
+ ...(scopeId === "global" ? {} : { projectRoot: `/projects/${scopeId}` }),
+ },
+ indexContent: `# ${scopeId}\n`,
+ indexRevision: `index-${scopeId}`,
+ indexOverLimit: false,
+ entries: [],
+ });
+ vi.mocked(api.memoryScopes).mockResolvedValueOnce({
+ scopes: [
+ { id: "global", kind: "global", label: "global", memoryDir: "/memory/global", exists: true },
+ {
+ id: "project-a",
+ kind: "project",
+ label: "project-a",
+ projectRoot: "/projects/project-a",
+ memoryDir: "/memory/project-a",
+ exists: true,
+ },
+ ],
+ });
+ let listingCalls = 0;
+ vi.mocked(api.memoryListing).mockImplementation((scopeId) => {
+ listingCalls += 1;
+ return scopeId === "global" && listingCalls === 1 ? staleGlobal : Promise.resolve(listingFor(scopeId));
+ });
+ vi.mocked(api.memoryDocument).mockImplementation(async (scopeId, file) => ({
+ kind: "index",
+ file,
+ content: `${scopeId}:${file}:${"x".repeat(256 * 1024)}`,
+ revision: `revision-${scopeId}`,
+ }));
+
+ const el = mount(() => );
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ const project = [...el.querySelectorAll("button")].find((button) => button.textContent?.includes("project-a"))!;
+ project.click();
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ resolveGlobal(listingFor("global"));
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(el.querySelector("textarea")?.value.startsWith("project-a:MEMORY.md:")).toBe(true);
+
+ const global = [...el.querySelectorAll("button")].find((button) =>
+ button.textContent?.includes("/memory/global"),
+ )!;
+ for (let i = 0; i < 6; i++) {
+ (i % 2 === 0 ? global : project).click();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ expect(api.memoryListing).toHaveBeenCalledTimes(8);
+ expect(api.memoryDocument).toHaveBeenCalledTimes(7);
+ expect(el.querySelectorAll(".memory-editor textarea")).toHaveLength(1);
+ expect(el.querySelectorAll(".memory-preview .markdown-body")).toHaveLength(1);
+ expect(el.querySelector("textarea")?.value.startsWith("project-a:MEMORY.md:")).toBe(true);
+ });
+
+ it("throttles live memory preview rendering while typing", async () => {
+ vi.useFakeTimers();
+ const parse = vi.spyOn(marked, "parse");
+ const el = mount(() => );
+ await vi.advanceTimersByTimeAsync(200);
+ const textarea = el.querySelector("textarea") as HTMLTextAreaElement;
+ const baseline = parse.mock.calls.length;
+ for (let i = 0; i < 20; i++) {
+ textarea.value = `draft ${i}`;
+ textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: String(i) }));
+ }
+ expect(parse.mock.calls.length).toBe(baseline);
+ await vi.advanceTimersByTimeAsync(149);
+ expect(parse.mock.calls.length).toBe(baseline);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(parse.mock.calls.length).toBe(baseline + 1);
+ expect(el.querySelector(".memory-preview")?.textContent).toContain("draft 19");
+ parse.mockRestore();
+ });
+
it("memories shows missing and malformed empty states", async () => {
vi.mocked(api.memoryListing).mockResolvedValueOnce({
scope: { id: "global", kind: "global", label: "global", memoryDir: "/home/test/.dreb/memory", exists: false },
diff --git a/packages/dashboard/test/memories.test.ts b/packages/dashboard/test/memories.test.ts
index 2ca40960..7aa39ba1 100644
--- a/packages/dashboard/test/memories.test.ts
+++ b/packages/dashboard/test/memories.test.ts
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } f
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { MemoryApi } from "../src/server/memories.js";
+import { MAX_MEMORY_CONTENT_BYTES, MemoryApi } from "../src/server/memories.js";
const tempDirs: string[] = [];
@@ -31,18 +31,22 @@ describe("MemoryApi", () => {
it("discovers global and project scopes with dedupe and stable ordering", async () => {
const home = await tempDir();
await mkdir(join(home, ".dreb", "memory"), { recursive: true });
+ await mkdir(join(home, ".git"), { recursive: true });
const b = await makeProject("b-project");
const a = await makeProject("a-project");
await mkdir(join(a, "src"), { recursive: true });
+ const aAlias = join(await tempDir(), "a-alias");
+ await symlink(a, aAlias);
const api = new MemoryApi(home, vi.fn());
- const scopes = await api.scopes([join(b, "missing"), b, join(a, "src"), a]);
+ const scopes = await api.scopes([home, join(b, "missing"), b, join(a, "src"), a, aAlias]);
expect(scopes.map((scope) => scope.kind)).toEqual(["global", "project", "project"]);
expect(scopes.slice(1).map((scope) => scope.projectRoot)).toEqual(
[a, b].sort((left, right) => left.localeCompare(right)),
);
expect(new Set(scopes.map((scope) => scope.id)).size).toBe(scopes.length);
+ expect(new Set(scopes.map((scope) => scope.memoryDir)).size).toBe(scopes.length);
});
it("lists missing directories without creating them and flags long complete indexes", async () => {
@@ -63,6 +67,26 @@ describe("MemoryApi", () => {
expect(listing.indexOverLimit).toBe(true);
});
+ it("enforces the content limit at the exact byte boundary for reads and saves", async () => {
+ const home = await tempDir();
+ const memory = join(home, ".dreb", "memory");
+ await mkdir(memory, { recursive: true });
+ const base = entry("Boundary");
+ const atLimit = `${base}${"x".repeat(MAX_MEMORY_CONTENT_BYTES - Buffer.byteLength(base))}`;
+ await writeFile(join(memory, "boundary.md"), atLimit);
+ const api = new MemoryApi(home, vi.fn());
+
+ const document = await api.readDocument("global", "boundary.md", []);
+ expect(Buffer.byteLength(document.content)).toBe(MAX_MEMORY_CONTENT_BYTES);
+ await api.saveDocument("global", "boundary.md", { content: atLimit, revision: document.revision }, []);
+ await expect(
+ api.saveDocument("global", "boundary.md", { content: `${atLimit}x`, revision: document.revision }, []),
+ ).rejects.toMatchObject({ status: 413 });
+
+ await writeFile(join(memory, "oversized.md"), `${atLimit}x`);
+ await expect(api.readDocument("global", "oversized.md", [])).rejects.toMatchObject({ status: 413 });
+ });
+
it("surfaces malformed metadata while accepting valid entry summaries", async () => {
const home = await tempDir();
const memory = join(home, ".dreb", "memory");
From 703f87b83d4b50b5402dfc28f506b34f400bc861 Mon Sep 17 00:00:00 2001
From: m-aebrer
Date: Fri, 7 Aug 2026 15:17:47 -0400
Subject: [PATCH 4/6] Fix Memories QA issues
---
README.md | 2 +-
packages/coding-agent/README.md | 2 +-
packages/coding-agent/docs/dashboard.md | 4 +
packages/dashboard/README.md | 2 +-
.../dashboard/src/client/screens/memories.tsx | 187 +++++++++++-------
packages/dashboard/src/server/memories.ts | 18 +-
.../dashboard/test/client/screens.test.tsx | 47 +++++
packages/dashboard/test/memories.test.ts | 17 ++
8 files changed, 200 insertions(+), 79 deletions(-)
diff --git a/README.md b/README.md
index 4cbeb75b..cb6dbb23 100644
--- a/README.md
+++ b/README.md
@@ -143,7 +143,7 @@ The dashboard is the visual face of dreb: every agent session on the host, live
**Host files, explicitly.** Browse the host filesystem, upload/download, create folders, and start a new session in any directory — every file operation logged server-side.
-**Memories, repairable.** The Memories screen edits dreb memory scopes only: global `~/.dreb/memory` plus `.dreb/memory` for active/disk project roots. It shows the complete `MEMORY.md` index (with a warning when it exceeds the 200-line prompt convention), existing entry metadata or parse errors, sanitized Markdown previews, exact-revision conflict handling that preserves drafts, and entry deletion that synchronously cleans matching index links before unlinking the file. It does not create/rename entries or expose Claude memory paths.
+**Memories, repairable.** The Memories screen edits dreb memory scopes only: global `~/.dreb/memory` plus populated `.dreb/memory` directories for active/disk project roots (empty projects are omitted because entry creation is outside this screen). It shows the complete `MEMORY.md` index (with a warning when it exceeds the 200-line prompt convention), opens local index links in the current scope, provides visible loading feedback, displays existing entry metadata or parse errors and sanitized Markdown previews, preserves drafts on exact-revision conflicts, and synchronously cleans matching index links before deleting an entry. It does not create/rename entries or expose Claude memory paths.
**Curated appearance themes.** A theme gallery in settings offers eight dashboard-native themes (entropist.ca, Dim, Solarized, Gruvbox, Caves of Qud, Van Gogh, plus the colorblind-safe Okabe-Ito and Paul Tol palettes), each with its own light and dark palette, plus a system/light/dark mode toggle. Choices are saved per browser and are independent of your TUI theme.
diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md
index 35394e41..3e25cb76 100644
--- a/packages/coding-agent/README.md
+++ b/packages/coding-agent/README.md
@@ -81,7 +81,7 @@ Or use a custom provider (corporate proxy, Bedrock, etc.) — see [Custom provid
Then just talk to dreb. All 13 standard built-in tools are enabled by default: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, `watch_github_ci`, and `ask_user`. Use `--tools` to restrict to a subset (e.g., `--tools read,grep,find,ls` for read-only). Three additional tools — `search`, `skill`, and `tasks_update` — are always active regardless of `--tools`. `suggest_next` is active by default but excluded when `--tools` is specified. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [packages](#packages).
-**Also available:** [`@dreb/telegram`](https://www.npmjs.com/package/@dreb/telegram) — run dreb as a Telegram bot with live tool status and visible results for user-facing tools (`npm install -g @dreb/telegram`). [`@dreb/dashboard`](https://www.npmjs.com/package/@dreb/dashboard) — run `dreb dashboard` for a browser UI with fleet overview, full chat steering, generic fail-closed built-in slash-command discovery and execution, inline provider/API failures with partial output preserved, sanitized raster tool images plus sent user uploads retained as bounded transcript previews by default, a bounded all-agent subagent panel with drill-in, host file browser, dreb memory editor with exact-revision saves and automatic index cleanup on delete, curated appearance themes (per-browser light/dark), and Tailscale/rotating-code pairing (`npm install -g @dreb/dashboard`; see [docs/dashboard.md](docs/dashboard.md)). Tool images cross browser-facing transport as content-addressed references; browser-local Settings offers placeholders, bounded previews, or informed-opt-in originals, with size disclosure and confirmation above 1 MiB. Full-resolution HTML export remains self-contained. The Memories screen is dreb-only (`~/.dreb/memory` and active/on-disk-session project `.dreb/memory`), shows complete indexes with a >200-line warning, surfaces malformed entry frontmatter for repair, preserves drafts on conflicts, and does not create/rename entries or expose Claude paths. Compact SSE snapshots update live fleet cards without repeatedly fetching the cross-project inventory, and session drill-in hydrates state, messages, and background agents through one ordered snapshot request. Terminal provider failures show their reason on fleet cards, while transient failures clear terminal state when automatic retry begins and remain recorded inline on the failed attempt. Its top bar and persistent session header indicators report connecting, connected, retrying, resyncing, disconnected, or auth failed; bounded SSE replay plus an explicit snapshot barrier restores session state, tasks, and image references after a reload, restart, gap, backpressure disconnect, or stalled stream, while authenticated image routes recover bytes separately from authoritative transcripts.
+**Also available:** [`@dreb/telegram`](https://www.npmjs.com/package/@dreb/telegram) — run dreb as a Telegram bot with live tool status and visible results for user-facing tools (`npm install -g @dreb/telegram`). [`@dreb/dashboard`](https://www.npmjs.com/package/@dreb/dashboard) — run `dreb dashboard` for a browser UI with fleet overview, full chat steering, generic fail-closed built-in slash-command discovery and execution, inline provider/API failures with partial output preserved, sanitized raster tool images plus sent user uploads retained as bounded transcript previews by default, a bounded all-agent subagent panel with drill-in, host file browser, dreb memory editor with exact-revision saves and automatic index cleanup on delete, curated appearance themes (per-browser light/dark), and Tailscale/rotating-code pairing (`npm install -g @dreb/dashboard`; see [docs/dashboard.md](docs/dashboard.md)). Tool images cross browser-facing transport as content-addressed references; browser-local Settings offers placeholders, bounded previews, or informed-opt-in originals, with size disclosure and confirmation above 1 MiB. Full-resolution HTML export remains self-contained. The Memories screen is dreb-only (`~/.dreb/memory` and populated active/on-disk-session project `.dreb/memory`; empty projects are omitted), shows complete indexes with a >200-line warning, opens local index links within the selected scope, replaces stale editor content with visible loading feedback, surfaces malformed entry frontmatter for repair, preserves drafts on conflicts, and does not create/rename entries or expose Claude paths. Compact SSE snapshots update live fleet cards without repeatedly fetching the cross-project inventory, and session drill-in hydrates state, messages, and background agents through one ordered snapshot request. Terminal provider failures show their reason on fleet cards, while transient failures clear terminal state when automatic retry begins and remain recorded inline on the failed attempt. Its top bar and persistent session header indicators report connecting, connected, retrying, resyncing, disconnected, or auth failed; bounded SSE replay plus an explicit snapshot barrier restores session state, tasks, and image references after a reload, restart, gap, backpressure disconnect, or stalled stream, while authenticated image routes recover bytes separately from authoritative transcripts.
**Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md)
diff --git a/packages/coding-agent/docs/dashboard.md b/packages/coding-agent/docs/dashboard.md
index b990cabc..a5c4afae 100644
--- a/packages/coding-agent/docs/dashboard.md
+++ b/packages/coding-agent/docs/dashboard.md
@@ -10,6 +10,10 @@ over [RPC mode](rpc.md): the server maintains a pool of `dreb --mode rpc`
child processes, one per live session. The server uses dreb's public session
APIs for on-disk inventory/delete and serves its own host file API.
+## Memories
+
+The Memories tab keeps the global dreb scope visible and lists only populated project `.dreb/memory` directories discovered from active and on-disk sessions. Empty or missing project scopes are omitted because the dashboard edits and deletes existing documents but does not create entries. The complete `MEMORY.md` index and direct-child entries are editable with revision conflicts, sanitized previews, and synchronized index cleanup on delete. Local direct-child links in the rendered index open the entry within the current scope; external links retain normal safe link behavior. Scope and document changes immediately hide stale editor content and show loading feedback while fresh data is read.
+
## Launching
```bash
diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md
index 29523dc3..ad3e3ba5 100644
--- a/packages/dashboard/README.md
+++ b/packages/dashboard/README.md
@@ -92,7 +92,7 @@ The selected project context reads effective global + project settings, but save
### Memories
-The Memories screen exposes only dreb memory scopes: global `~/.dreb/memory` and project `.dreb/memory` directories derived from currently active sessions plus on-disk session cwd inventory. Missing memory directories are shown as missing and are not created by listing. Documents are existing-only: `MEMORY.md` is the special index, and entries are direct child `.md` files (excluding hidden/internal/path-like names).
+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.
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.
diff --git a/packages/dashboard/src/client/screens/memories.tsx b/packages/dashboard/src/client/screens/memories.tsx
index a1efcc9b..32149697 100644
--- a/packages/dashboard/src/client/screens/memories.tsx
+++ b/packages/dashboard/src/client/screens/memories.tsx
@@ -36,6 +36,20 @@ function selectDefaultFile(listing: MemoryListingDto | undefined): string | unde
return listing.entries[0]?.file;
}
+function localMemoryFile(href: string | null, listing: MemoryListingDto | undefined): string | undefined {
+ if (!href || !listing) return undefined;
+ let target: string;
+ try {
+ target = decodeURIComponent(href);
+ } catch {
+ return undefined;
+ }
+ if (target.startsWith("./")) target = target.slice(2);
+ if (!target || target.includes("/") || target.includes("\\") || target.includes("?") || target.includes("#"))
+ return undefined;
+ return listing.entries.some((entry) => entry.file === target) ? target : undefined;
+}
+
export function MemoriesScreen(props: { store: AppStore }): JSX.Element {
const [selectedScopeId, setSelectedScopeId] = createSignal();
const [selectedFile, setSelectedFile] = createSignal();
@@ -131,6 +145,15 @@ export function MemoriesScreen(props: { store: AppStore }): JSX.Element {
setDraft("");
}
+ function followMemoryLink(event: Event) {
+ const anchor = (event.target as Element | null)?.closest("a");
+ if (!(anchor instanceof HTMLAnchorElement)) return;
+ const file = localMemoryFile(anchor.getAttribute("href"), listing());
+ if (!file) return;
+ event.preventDefault();
+ chooseFile(file);
+ }
+
async function refreshAll() {
await refetchScopes();
await refetchListing();
@@ -290,91 +313,107 @@ export function MemoriesScreen(props: { store: AppStore }): JSX.Element {
-
-
-
- Complete index warning: MEMORY.md is over 200 lines. The dashboard shows the
- full file for repair, while the agent prompt may only load the indexed prefix.
-
+ Complete index warning: MEMORY.md is over 200 lines. The dashboard shows the
+ full file for repair, while the agent prompt may only load the indexed prefix.
+