-
MARM: Local-First Persistent Multi-Agent Memory Layer for MCP Clients v2.22.1
+
MARM: Local-First Persistent Multi-Agent Memory Layer for MCP Clients v2.23.0
[](https://github.com/Lyellr88/marm-memory/blob/MARM-main/LICENSE)
[](https://www.python.org/)
@@ -26,9 +28,10 @@
## Important Messages
-- marm-console now ships as the local web app successor to marm-dashboard, with live Memory, Knowledge, and Projects workspaces plus MCP-backed Memory mutation actions. Packaging and one-command startup polish are still in progress.
+- marm-console now ships as the local web app for memory, knowledge, projects, and MCP-backed memory mutation actions. Packaging and one-command startup polish are still in progress.
- I am waiting to get access back to my PYPI account, till restored pip will be behind a few versions. I will update the README when it is back up to date.
+
## Table of Contents
- [Why MARM Memory](#why-marm-memory)
@@ -38,7 +41,6 @@
- [Using MARM: Talk, Don't Call Tools](#using-marm-talk-dont-call-tools)
- [Understanding MARM Memory](#understanding-marm-memory)
- [Knowledge Graphs: Code & Concepts](#knowledge-graphs-code--concepts)
-- [MARM Dashboard](#marm-dashboard)
- [Architecture & Internals](#architecture--internals)
- [Troubleshooting](#troubleshooting)
- [Contributing](#contributing)
@@ -76,7 +78,7 @@ See [Performance & Scaling Benchmarks](#performance--scaling-benchmarks) for ret
**Recommended: guided setup with `marm-init`**
-The easiest way to install MARM is to let your agent do the setup with you. `marm-init` turns the usual MCP setup mess into one guided conversation: Python or Docker, HTTP or STDIO, local or remote server, API keys, config paths, dashboard startup, and multi-agent linking for Claude, Codex, Gemini, Qwen, Cursor, VS Code, and other MCP clients. No hunting through install docs, no guessing which config file your client uses, and no rewriting the same connection by hand for every agent.
+The easiest way to install MARM is to let your agent do the setup with you. `marm-init` turns the usual MCP setup mess into one guided conversation: Python or Docker, HTTP or STDIO, local or remote server, API keys, config paths, server startup, and multi-agent linking for Claude, Codex, Gemini, Qwen, Cursor, VS Code, and other MCP clients. No hunting through install docs, no guessing which config file your client uses, and no rewriting the same connection by hand for every agent.
```bash
npx degit Lyellr88/marm-memory/skills
@@ -520,7 +522,7 @@ Full platform walkthroughs, key setup, and OS-specific notes: [Windows](docs/INS
**Verify installation**
-Use the MARM Dashboard status panel for the easiest live check. It polls the MCP server health endpoint and shows reachability, version, status, latency, and last checked time. For terminal validation:
+Use the MCP server health endpoint for the fastest live check:
```bash
curl http://localhost:8001/health
@@ -758,37 +760,6 @@ How to use it:
This fills the cross-session structure gap that flat memory search leaves open: sessions organize memories, but the concept graph *connects* them, so "what depends on the write queue?" is answerable even when the answer spans five sessions from three different agents.
-## MARM Dashboard
-
-A local web UI for browsing and managing your MARM memory. It is bundled with `marm-mcp-server` and mounts at `/dashboard` when the HTTP server starts.
-
-| What it gives you | How it works |
-|-------------------|-------------|
-| Browse/search/edit all memories | Direct SQLite access to the same `~/.marm/marm_memory.db` |
-| Manage sessions and protocol logs | Open `http://localhost:8001/dashboard` beside the MCP endpoint on `:8001` |
-| Notebook CRUD with inline editor | Same `MARM_API_KEY` auth model as the MCP server |
-| Delete-all with count confirmation | Included in the unified pip package and Docker image |
-| View the write queue in real time | Pulls live data from the write queue |
-| Live server health panel | Polls the health endpoint: reachability, version, latency, last checked |
-
-Start MARM HTTP, then open the dashboard:
-
-```bash
-python -m marm_mcp_server
-# browser: http://localhost:8001/dashboard
-```
-
-Docker uses the same unified image and key:
-
-```bash
-docker run -d --name marm-mcp-server \
- -p 127.0.0.1:8001:8001 \
- -e MARM_API_KEY=your-key \
- -v ~/.marm:/home/marm/.marm \
- lyellr88/marm-mcp-server:latest
-# browser: http://localhost:8001/dashboard
-```
-
## Architecture & Internals
Everything above runs on a small number of deliberate mechanisms. This section is the full map, so you (or your agent) never have to guess what the server is doing.
@@ -904,14 +875,14 @@ Packaged docs are indexed into the `marm_system` memory namespace on startup and
**AI client can't connect to MARM**
-- Verify server is running in the dashboard health panel, or with `curl http://localhost:8001/health`
+- Verify the server is running with `curl http://localhost:8001/health`
- Check firewall isn't blocking port 8001
- For STDIO: use `marm-mcp-stdio` (console script) or `python -m marm_mcp_server.server_stdio`
- Restart both server and AI client
**Tools not appearing in AI client**
-- Verify HTTP mode in the dashboard health panel, or with `curl http://localhost:8001/health`
+- Verify HTTP mode with `curl http://localhost:8001/health`
- Check server logs for initialization errors
- Disconnect and reconnect AI client to refresh tool list
- Both HTTP and STDIO expose 14 tools: 7 core memory/logging/notebook/compaction tools, 5 bundled code-graph tools, and 2 concept-graph tools
@@ -938,7 +909,7 @@ Packaged docs are indexed into the `marm_system` memory namespace on startup and
- Verify `~/.marm/` directory exists and has write permissions
- Check available disk space
- Test with simple memory: ask AI to save a single line and check with `marm_log_show`
-- For HTTP mode, verify server health in the dashboard health panel, or with `curl http://localhost:8001/health`
+- For HTTP mode, verify server health with `curl http://localhost:8001/health`
**Search returns no results**
@@ -1066,7 +1037,3 @@ Copyright © 2026 Ryan A. Lyell. MARM is released under the [Apache 2.0 License]
- **[ACKNOWLEDGMENTS.md](https://github.com/Lyellr88/marm-memory/blob/MARM-main/docs/ACKNOWLEDGMENTS.md)** - Contributors and acknowledgments
- **[ROADMAP.md](https://github.com/Lyellr88/marm-memory/blob/MARM-main/docs/ROADMAP.md)** - Planned features and development roadmap
- **[LICENSE](https://github.com/Lyellr88/marm-memory/blob/MARM-main/LICENSE)** - Apache 2.0 license terms
-
----
-
-mcp-name: io.github.Lyellr88/marm-mcp-server
diff --git a/marm-mcp-server/docker-compose.yml b/marm-mcp-server/docker-compose.yml
index 83322b4d..fe6738a1 100644
--- a/marm-mcp-server/docker-compose.yml
+++ b/marm-mcp-server/docker-compose.yml
@@ -3,7 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile
- # :latest is now the all-in-one image (memory + graph + dashboard, one port).
+ # :latest is the all-in-one MCP image (memory + graph, one port).
# Pin :memory-only instead if you need the pre-unification image shape.
image: lyellr88/marm-mcp-server:latest
container_name: marm-mcp-server
@@ -18,7 +18,7 @@ services:
environment:
- SERVER_HOST=0.0.0.0
- SERVER_PORT=8001
- - SERVER_VERSION=2.22.1
+ - SERVER_VERSION=2.23.0
- SEMANTIC_MODEL=all-MiniLM-L6-v2
diff --git a/marm-mcp-server/marm-docs/FAQ.md b/marm-mcp-server/marm-docs/FAQ.md
index ede4ff83..6822b723 100644
--- a/marm-mcp-server/marm-docs/FAQ.md
+++ b/marm-mcp-server/marm-docs/FAQ.md
@@ -14,7 +14,7 @@ marm-memory is a persistent memory layer for AI agents. The MCP server gives Cla
|-----------|-------------|----------|
| **MARM MCP Server** | Persistent memory server with 14 MCP tools (HTTP + STDIO): 7 core memory tools, 5 bundled code-graph tools, and 2 concept-graph tools | AI agents, IDEs, local workflows, shared team memory |
| **MARM Protocol** | Runtime guidance delivered automatically by the MCP server | Keeping agents aligned on what to store, recall, and trust |
-| **MARM Dashboard** | Local browser UI for viewing memory and server health | Inspection, cleanup, and quick status checks |
+| **MARM Console** | Local browser UI for viewing memory, knowledge, projects, and server health | Inspection, cleanup, and quick status checks |
### Q: How is MARM different from built-in AI memory?
@@ -71,7 +71,7 @@ Docker HTTP mode should use `MARM_API_KEY` because the server is listening throu
#### Q: How do I know if MARM is working correctly?
-For HTTP mode, use the MARM Dashboard status panel or run `curl http://localhost:8001/health`. For STDIO mode, confirm your MCP client lists the MARM tools and can call a simple recall or log command.
+For HTTP mode, run `curl http://localhost:8001/health` or use MARM Console when it is running locally. For STDIO mode, confirm your MCP client lists the MARM tools and can call a simple recall or log command.
---
diff --git a/marm-mcp-server/marm-docs/README.md b/marm-mcp-server/marm-docs/README.md
index d4edc69b..a1c9e9eb 100644
--- a/marm-mcp-server/marm-docs/README.md
+++ b/marm-mcp-server/marm-docs/README.md
@@ -1,4 +1,4 @@
-# MARM: Local-First Persistent Multi-Agent Memory Layer for MCP Clients v2.22.1
+# MARM: Local-First Persistent Multi-Agent Memory Layer for MCP Clients v2.23.0
## Table of Contents
@@ -9,7 +9,6 @@
- [Using MARM: Talk, Don't Call Tools](#using-marm-talk-dont-call-tools)
- [Understanding MARM Memory](#understanding-marm-memory)
- [Knowledge Graphs: Code & Concepts](#knowledge-graphs-code--concepts)
-- [MARM Dashboard](#marm-dashboard)
- [Architecture & Internals](#architecture--internals)
- [Troubleshooting](#troubleshooting)
@@ -45,7 +44,7 @@ See [Performance & Scaling Benchmarks](#performance--scaling-benchmarks) for ret
**Recommended: guided setup with `marm-init`**
-The easiest way to install MARM is to let your agent do the setup with you. `marm-init` turns the usual MCP setup mess into one guided conversation: Python or Docker, HTTP or STDIO, local or remote server, API keys, config paths, dashboard startup, and multi-agent linking for Claude, Codex, Gemini, Qwen, Cursor, VS Code, and other MCP clients. No hunting through install docs, no guessing which config file your client uses, and no rewriting the same connection by hand for every agent.
+The easiest way to install MARM is to let your agent do the setup with you. `marm-init` turns the usual MCP setup mess into one guided conversation: Python or Docker, HTTP or STDIO, local or remote server, API keys, config paths, server startup, and multi-agent linking for Claude, Codex, Gemini, Qwen, Cursor, VS Code, and other MCP clients. No hunting through install docs, no guessing which config file your client uses, and no rewriting the same connection by hand for every agent.
```bash
npx degit Lyellr88/marm-memory/skills
@@ -489,7 +488,7 @@ Full platform walkthroughs, key setup, and OS-specific notes: [Windows](docs/INS
**Verify installation**
-Use the MARM Dashboard status panel for the easiest live check. It polls the MCP server health endpoint and shows reachability, version, status, latency, and last checked time. For terminal validation:
+Use the MCP server health endpoint for the fastest live check:
```bash
curl http://localhost:8001/health
@@ -721,37 +720,6 @@ How to use it:
This fills the cross-session structure gap that flat memory search leaves open: sessions organize memories, but the concept graph *connects* them, so "what depends on the write queue?" is answerable even when the answer spans five sessions from three different agents.
-## MARM Dashboard
-
-A local web UI for browsing and managing your MARM memory. It is bundled with `marm-mcp-server` and mounts at `/dashboard` when the HTTP server starts.
-
-| What it gives you | How it works |
-|-------------------|-------------|
-| Browse/search/edit all memories | Direct SQLite access to the same `~/.marm/marm_memory.db` |
-| Manage sessions and protocol logs | Open `http://localhost:8001/dashboard` beside the MCP endpoint on `:8001` |
-| Notebook CRUD with inline editor | Same `MARM_API_KEY` auth model as the MCP server |
-| Delete-all with count confirmation | Included in the unified pip package and Docker image |
-| View the write queue in real time | Pulls live data from the write queue |
-| Live server health panel | Polls the health endpoint: reachability, version, latency, last checked |
-
-Start MARM HTTP, then open the dashboard:
-
-```bash
-python -m marm_mcp_server
-# browser: http://localhost:8001/dashboard
-```
-
-Docker uses the same unified image and key:
-
-```bash
-docker run -d --name marm-mcp-server \
- -p 127.0.0.1:8001:8001 \
- -e MARM_API_KEY=your-key \
- -v ~/.marm:/home/marm/.marm \
- lyellr88/marm-mcp-server:latest
-# browser: http://localhost:8001/dashboard
-```
-
## Architecture & Internals
Everything above runs on a small number of deliberate mechanisms. This section is the full map, so you (or your agent) never have to guess what the server is doing.
@@ -867,14 +835,14 @@ Packaged docs are indexed into the `marm_system` memory namespace on startup and
**AI client can't connect to MARM**
-- Verify server is running in the dashboard health panel, or with `curl http://localhost:8001/health`
+- Verify the server is running with `curl http://localhost:8001/health`
- Check firewall isn't blocking port 8001
- For STDIO: use `marm-mcp-stdio` (console script) or `python -m marm_mcp_server.server_stdio`
- Restart both server and AI client
**Tools not appearing in AI client**
-- Verify HTTP mode in the dashboard health panel, or with `curl http://localhost:8001/health`
+- Verify HTTP mode with `curl http://localhost:8001/health`
- Check server logs for initialization errors
- Disconnect and reconnect AI client to refresh tool list
- Both HTTP and STDIO expose 14 tools: 7 core memory/logging/notebook/compaction tools, 5 bundled code-graph tools, and 2 concept-graph tools
@@ -901,7 +869,7 @@ Packaged docs are indexed into the `marm_system` memory namespace on startup and
- Verify `~/.marm/` directory exists and has write permissions
- Check available disk space
- Test with simple memory: ask AI to save a single line and check with `marm_log_show`
-- For HTTP mode, verify server health in the dashboard health panel, or with `curl http://localhost:8001/health`
+- For HTTP mode, verify server health with `curl http://localhost:8001/health`
**Search returns no results**
diff --git a/marm-mcp-server/marm-docs/ROADMAP.md b/marm-mcp-server/marm-docs/ROADMAP.md
index eba9d2e4..a94a48b7 100644
--- a/marm-mcp-server/marm-docs/ROADMAP.md
+++ b/marm-mcp-server/marm-docs/ROADMAP.md
@@ -9,9 +9,9 @@ MARM is focused on one clear goal: make AI memory practical across real tools, r
The active product direction has two tracks:
- **MARM MCP Server**: the agent-facing memory layer used by Claude, Codex, Gemini, Qwen, VS Code, Cursor, and other MCP clients.
-- **MARM Dashboard**: the human-facing local admin UI for inspecting, editing, exporting, and maintaining the same memory database.
+- **MARM Console**: the human-facing local admin UI for inspecting, editing, exporting, and maintaining the same memory database.
-MARM is not currently being built as a paid upgrade product. The near-term focus is a strong open base: reliable local memory, clean transports, useful dashboard workflows, and future extension points for plugins, SDKs, research, and team workflows.
+MARM is not currently being built as a paid upgrade product. The near-term focus is a strong open base: reliable local memory, clean transports, useful console workflows, and future extension points for plugins, SDKs, research, and team workflows.
---
@@ -25,7 +25,7 @@ MARM now has the core pieces needed for a serious local memory system:
- **Docker support** with HTTP and STDIO modes from one image
- **API-key auth** for Docker, exposed, or shared HTTP deployments
- **VS Code and Cursor support** through native MCP config files
-- **MARM Dashboard** as an optional local SQLite admin UI for human memory management
+- **MARM Console** as an optional local admin UI for human memory management
- **Fresh test suite** covering HTTP tools, auth, rate limits, response limits, database behavior, STDIO, and Docker smoke paths
- **Automation scripts** for version sync, test runs, stale-doc scans, Docker smoke, and release preflight
@@ -57,7 +57,7 @@ Planned direction:
- Track lightweight usage signals such as recalled, edited, deleted, reused, or ignored memories
- Improve ranking over time using recency, frequency, session/project relevance, and user cleanup behavior
- Identify related memories across sessions so solutions, decisions, and patterns are easier to rediscover
-- Add stale-memory indicators so users can clean old or low-value entries from the dashboard
+- Add stale-memory indicators so users can clean old or low-value entries from marm-console
Why it matters:
@@ -114,11 +114,11 @@ Fewer, clearer tools improve client discovery, reduce token overhead, and make a
---
-## Dashboard Roadmap
+## Console Roadmap
### 1. Export and Reporting
-The dashboard is the natural place for human-friendly memory export.
+marm-console is the natural place for human-friendly memory export.
Planned direction:
@@ -134,7 +134,7 @@ Memory should be portable. Users need backups, reports, and ways to move MARM kn
### 2. Safer Admin Workflows
-The dashboard already asks before destructive actions. The next step is making high-impact edits even safer and easier to inspect.
+marm-console already asks before destructive actions. The next step is making high-impact edits even safer and easier to inspect.
Planned direction:
@@ -147,22 +147,22 @@ Planned direction:
Why it matters:
-The dashboard can edit real memory. It should feel efficient, but not casual about destructive changes.
+marm-console can edit real memory. It should feel efficient, but not casual about destructive changes.
-### 3. Dashboard and MCP Schema Alignment
+### 3. Console and MCP Schema Alignment
-The dashboard writes directly to SQLite, so it must stay aligned with MCP schema changes.
+marm-console uses MCP-backed mutation paths and local read APIs, so it must stay aligned with MCP schema changes.
Planned direction:
-- Add tests for dashboard compatibility with current MCP tables
-- Keep dashboard CRUD behavior aligned with MCP sanitization and metadata conventions
+- Add tests for console compatibility with current MCP tables
+- Keep console CRUD behavior aligned with MCP sanitization and metadata conventions
- Surface MCP server reachability and database state clearly
-- Avoid adding dashboard-only fields unless the MCP server also understands them
+- Avoid adding console-only fields unless the MCP server also understands them
Why it matters:
-The dashboard is useful because it manages the same data. Schema drift would make it dangerous.
+marm-console is useful because it manages the same data. Schema drift would make it dangerous.
---
@@ -181,7 +181,7 @@ These are not product features, but they keep both tracks trustworthy.
## Long-Term Possibilities
-These are conditional directions after the MCP server and dashboard are stable:
+These are conditional directions after the MCP server and marm-console are stable:
- **Plugin integrations** for editors, local tools, and MCP client ecosystems
- **SDKs** for developers who want MARM-backed memory in their own apps
diff --git a/marm-mcp-server/marm_dashboard/__init__.py b/marm-mcp-server/marm_dashboard/__init__.py
deleted file mode 100644
index 64b67d22..00000000
--- a/marm-mcp-server/marm_dashboard/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""MARM Dashboard — local UI for MARM memory SQLite."""
-
-__version__ = "1.2.0"
diff --git a/marm-mcp-server/marm_dashboard/__main__.py b/marm-mcp-server/marm_dashboard/__main__.py
deleted file mode 100644
index 049455b3..00000000
--- a/marm-mcp-server/marm_dashboard/__main__.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""Run: python -m marm_dashboard"""
-
-import argparse
-import threading
-import webbrowser
-
-import uvicorn
-
-from .config import DASHBOARD_HOST, DASHBOARD_PORT, MARM_API_KEY, get_db_path
-
-
-def main() -> None:
- parser = argparse.ArgumentParser(
- description="MARM Dashboard — local memory browser"
- )
- parser.add_argument(
- "--host", default=DASHBOARD_HOST, help="Bind host (default 127.0.0.1)"
- )
- parser.add_argument(
- "--port", type=int, default=DASHBOARD_PORT, help="Bind port (default 8002)"
- )
- parser.add_argument(
- "--open",
- action="store_true",
- help="Open the dashboard in your default browser after start",
- )
- args = parser.parse_args()
-
- url = f"http://{args.host}:{args.port}/"
- print("MARM Dashboard")
- print(f" Database: {get_db_path()}")
- print(f" URL: {url}")
- print(" (MCP server can stay on :8001; this plugin reads the same SQLite file.)")
- if MARM_API_KEY:
- print(
- " Auth: MARM_API_KEY set — unlock in browser with the same Bearer key as MCP."
- )
- else:
- print(" Auth: loopback only (set MARM_API_KEY to match Docker/MCP).")
-
- if args.open:
-
- def _open_browser() -> None:
- webbrowser.open(url)
-
- threading.Timer(1.2, _open_browser).start()
-
- uvicorn.run(
- "marm_dashboard.server:app",
- host=args.host,
- port=args.port,
- log_level="info",
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/marm-mcp-server/marm_dashboard/auth.py b/marm-mcp-server/marm_dashboard/auth.py
deleted file mode 100644
index f360eb97..00000000
--- a/marm-mcp-server/marm_dashboard/auth.py
+++ /dev/null
@@ -1,130 +0,0 @@
-"""Auth middleware — mirrors marm-mcp-server/middleware/auth.py."""
-
-import secrets
-
-from fastapi import Request
-from fastapi.responses import JSONResponse
-
-from .config import MARM_API_KEY
-
-PUBLIC_PATHS = {"/health", "/api/auth/unlock"}
-PUBLIC_PREFIXES = ("/assets/",)
-
-SECURITY_HEADERS = {
- "Cache-Control": "no-store",
- "X-Content-Type-Options": "nosniff",
- "Referrer-Policy": "no-referrer",
- "X-Frame-Options": "DENY",
- "Content-Security-Policy": (
- "default-src 'self'; "
- "script-src 'self'; "
- "style-src 'self' 'unsafe-inline'; "
- "img-src 'self' data:; "
- "connect-src 'self' http://127.0.0.1:8001 http://localhost:8001; "
- "object-src 'none'; "
- "base-uri 'none'; "
- "frame-ancestors 'none'"
- ),
-}
-
-
-_TRUSTED_PROXY_IPS = {"127.0.0.1", "::1"}
-
-
-def _client_ip(request: Request) -> str:
- """Extract client IP from request, handling proxies.
-
- X-Forwarded-For/X-Real-IP are only trusted when the direct TCP connection
- comes from a known local proxy -- mirrors marm-mcp-server's own
- middleware/rate_limiting.py get_client_ip(), so a remote caller can't spoof
- 127.0.0.1 to bypass the loopback-only auth mode.
- """
- direct_ip = request.client.host if request.client else ""
-
- if direct_ip in _TRUSTED_PROXY_IPS:
- forwarded = request.headers.get("X-Forwarded-For")
- if forwarded:
- return forwarded.split(",")[0].strip()
- real = request.headers.get("X-Real-IP")
- if real:
- return real.strip()
-
- return direct_ip
-
-
-def _bearer_token(request: Request) -> str:
- auth = request.headers.get("Authorization", "")
- if auth.startswith("Bearer "):
- return auth[7:]
- return ""
-
-
-def is_valid_key(candidate: str) -> bool:
- if not MARM_API_KEY or not candidate:
- return False
- return secrets.compare_digest(candidate, MARM_API_KEY)
-
-
-def _mount_relative_path(request: Request) -> str:
- """request.url.path relative to this app's own mount point.
-
- When this app runs standalone, root_path is "" and this is a no-op. When
- mounted as a sub-app (e.g. at /dashboard inside marm-mcp-server), the ASGI
- scope's root_path carries the mount prefix but path stays the full
- original path -- so comparing raw request.url.path against PUBLIC_PATHS/
- PUBLIC_PREFIXES never matches once mounted, 401-ing every request
- including static assets and the unlock endpoint itself.
- """
- path = request.url.path
- root_path = request.scope.get("root_path", "")
- if root_path and path.startswith(root_path):
- path = path[len(root_path) :] or "/"
- return path
-
-
-async def auth_middleware(request: Request, call_next):
- path = _mount_relative_path(request)
- if path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES):
- response = await call_next(request)
- response.headers.update(SECURITY_HEADERS)
- return response
-
- if path == "/" and request.method == "GET":
- response = await call_next(request)
- response.headers.update(SECURITY_HEADERS)
- return response
-
- if not MARM_API_KEY:
- if _client_ip(request) not in ("127.0.0.1", "::1", "localhost"):
- response = JSONResponse(
- status_code=401,
- content={
- "error": "Unauthorized",
- "message": (
- "Dashboard is reachable on the network but MARM_API_KEY is not set. "
- "Set the same key as marm-mcp-server, or bind to 127.0.0.1."
- ),
- },
- )
- response.headers.update(SECURITY_HEADERS)
- return response
- response = await call_next(request)
- response.headers.update(SECURITY_HEADERS)
- return response
-
- if not is_valid_key(_bearer_token(request)):
- response = JSONResponse(
- status_code=401,
- content={
- "error": "Unauthorized",
- "message": "Authorization: Bearer
required (same key as MCP).",
- "auth_required": True,
- },
- headers={"WWW-Authenticate": "Bearer"},
- )
- response.headers.update(SECURITY_HEADERS)
- return response
-
- response = await call_next(request)
- response.headers.update(SECURITY_HEADERS)
- return response
diff --git a/marm-mcp-server/marm_dashboard/config.py b/marm-mcp-server/marm_dashboard/config.py
deleted file mode 100644
index 8ffcccd6..00000000
--- a/marm-mcp-server/marm_dashboard/config.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""Dashboard configuration (aligned with marm-mcp-server auth rules)."""
-
-import os
-from pathlib import Path
-
-_MARM_ENV_PATH = Path.home() / ".marm" / ".env"
-
-
-def get_db_path() -> str:
- env_path = os.environ.get("MARM_DB_PATH")
- if env_path:
- Path(env_path).parent.mkdir(parents=True, exist_ok=True)
- return env_path
-
- marm_dir = Path.home() / ".marm"
- marm_dir.mkdir(exist_ok=True)
- return str(marm_dir / "marm_memory.db")
-
-
-def _load_key_from_file() -> str:
- """Read MARM_API_KEY from ~/.marm/.env (same file MCP uses in Docker mode)."""
- try:
- for line in _MARM_ENV_PATH.read_text().splitlines():
- line = line.strip()
- if line.startswith("MARM_API_KEY=") and not line.startswith("#"):
- return line.split("=", 1)[1].strip()
- except OSError:
- pass
- return ""
-
-
-MARM_API_KEY = os.environ.get("MARM_API_KEY", "").strip()
-if not MARM_API_KEY:
- MARM_API_KEY = _load_key_from_file()
-
-DASHBOARD_HOST = os.environ.get("MARM_DASHBOARD_HOST", "127.0.0.1")
-DASHBOARD_PORT = int(os.environ.get("MARM_DASHBOARD_PORT", "8002"))
diff --git a/marm-mcp-server/marm_dashboard/db.py b/marm-mcp-server/marm_dashboard/db.py
deleted file mode 100644
index 105168ab..00000000
--- a/marm-mcp-server/marm_dashboard/db.py
+++ /dev/null
@@ -1,949 +0,0 @@
-"""Direct SQLite access to the MARM memory database."""
-
-from __future__ import annotations
-
-import html
-import json
-import re
-import sqlite3
-import threading
-import uuid
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any, Dict, List, Optional
-
-from .config import get_db_path
-
-_ENCODER = None
-_ENCODER_FAILED = False
-_ENCODER_LOCK = threading.Lock()
-_SEMANTIC_MODEL = "all-MiniLM-L6-v2"
-_CONTEXT_TYPES = frozenset({"general", "code", "project", "book"})
-
-
-def _strip_script_tags(text: str) -> str:
- lower = text.lower()
- result = []
- i = 0
- while i < len(text):
- start = lower.find("", cs)
- if close_end != -1:
- i = close_end + 1
- break
- j = cs + 8
- if close_end == -1:
- result.append(text[open_end + 1 :])
- break
- return "".join(result)
-
-
-def _parse_metadata(raw: Optional[str]) -> Dict[str, Any]:
- if not raw:
- return {}
- try:
- return json.loads(raw)
- except json.JSONDecodeError:
- return {}
-
-
-def _for_display(text: str) -> str:
- """Undo stored HTML entities for human-readable UI (MCP stores escaped content)."""
- if not text:
- return text
- return html.unescape(text)
-
-
-def _like_pattern(query: str) -> str:
- escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
- return f"%{escaped}%"
-
-
-def _strip_scripts(content: str) -> str:
- """Notebook parity with MCP: store raw text, only strip obvious script blocks."""
- if not content:
- return content
- if len(content) > 500_000:
- content = content[:500_000]
- return _strip_script_tags(content)
-
-
-def _sanitize_memory(content: str) -> str:
- """Match marm-mcp-server memory storage (escaped for XSS)."""
- if not content:
- return content
- if len(content) > 10_000:
- content = content[:10_000]
- sanitized = _strip_scripts(content)
- sanitized = re.sub(
- r"javascript:", "blocked-protocol:", sanitized, flags=re.IGNORECASE
- )
- return html.escape(sanitized)
-
-
-def _connect() -> sqlite3.Connection:
- path = get_db_path()
- if not Path(path).exists():
- raise FileNotFoundError(
- f"MARM database not found at {path}. Start marm-mcp-server once or set MARM_DB_PATH."
- )
- conn = sqlite3.connect(path, check_same_thread=False, timeout=20.0)
- conn.row_factory = sqlite3.Row
- conn.execute("PRAGMA journal_mode=WAL")
- conn.execute("PRAGMA busy_timeout=20000")
- return conn
-
-
-def _touch_session(conn: sqlite3.Connection, session_name: str, timestamp: str) -> None:
- cur = conn.execute(
- "UPDATE sessions SET last_accessed = ? WHERE session_name = ?",
- (timestamp, session_name),
- )
- if cur.rowcount == 0:
- conn.execute(
- "INSERT INTO sessions (session_name, last_accessed) VALUES (?, ?)",
- (session_name, timestamp),
- )
-
-
-def embeddings_package_available() -> bool:
- try:
- import fastembed # noqa: F401
-
- return True
- except ImportError:
- return False
-
-
-def _maybe_embedding(text: str) -> Optional[bytes]:
- """Serialized like core/memory.py's _encode_sync: dashboard routes are sync
- def handlers, which FastAPI runs on its thread pool, so concurrent requests
- can genuinely race on lazy TextEmbedding init and .embed() without a lock."""
- global _ENCODER, _ENCODER_FAILED
- if _ENCODER_FAILED or not text.strip():
- return None
- try:
- with _ENCODER_LOCK:
- if _ENCODER is None:
- from fastembed import TextEmbedding
-
- _ENCODER = TextEmbedding(
- model_name=f"sentence-transformers/{_SEMANTIC_MODEL}"
- )
- import numpy as np
-
- return next(iter(_ENCODER.embed([text]))).astype(np.float32).tobytes()
- except Exception:
- _ENCODER_FAILED = True
- return None
-
-
-def get_summary() -> Dict[str, Any]:
- with _connect() as conn:
- memories = conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
- sessions = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
- logs = conn.execute("SELECT COUNT(*) FROM log_entries").fetchone()[0]
- notebook = conn.execute("SELECT COUNT(*) FROM notebook_entries").fetchone()[0]
- active = conn.execute(
- "SELECT session_name FROM sessions WHERE marm_active = 1 LIMIT 1"
- ).fetchone()
- return {
- "db_path": get_db_path(),
- "counts": {
- "memories": memories,
- "sessions": sessions,
- "log_entries": logs,
- "notebook_entries": notebook,
- },
- "active_session": active[0] if active else None,
- "embeddings_package_available": embeddings_package_available(),
- "semantic_model_loaded": _ENCODER is not None and not _ENCODER_FAILED,
- "timestamp": datetime.now(timezone.utc).isoformat(),
- }
-
-
-def list_session_names() -> List[str]:
- with _connect() as conn:
- rows = conn.execute(
- """
- SELECT session_name FROM sessions
- UNION
- SELECT DISTINCT session_name FROM memories
- UNION
- SELECT DISTINCT session_name FROM log_entries
- ORDER BY session_name COLLATE NOCASE
- """
- ).fetchall()
- return [r[0] for r in rows if r[0]]
-
-
-def list_sessions(q: Optional[str] = None) -> List[Dict[str, Any]]:
- params: List[Any] = []
- where = ""
- if q:
- where = "WHERE s.session_name LIKE ? ESCAPE '\\'"
- params.append(_like_pattern(q))
- with _connect() as conn:
- rows = conn.execute(
- f"""
- SELECT s.session_name, s.marm_active, s.last_accessed,
- (SELECT COUNT(*) FROM memories m WHERE m.session_name = s.session_name) AS memory_count,
- (SELECT COUNT(*) FROM log_entries l WHERE l.session_name = s.session_name) AS log_count
- FROM sessions s
- {where}
- ORDER BY s.last_accessed DESC
- """,
- params,
- ).fetchall()
- return [dict(r) for r in rows]
-
-
-def list_memories(
- session: Optional[str] = None,
- q: Optional[str] = None,
- limit: int = 50,
- offset: int = 0,
-) -> Dict[str, Any]:
- limit = min(max(limit, 1), 200)
- offset = max(offset, 0)
- clauses: List[str] = []
- params: List[Any] = []
-
- # Exclude compacted source memories
- clauses.append("(compaction_role IS NULL OR compaction_role != 'source')")
-
- if session:
- clauses.append("session_name = ?")
- params.append(session)
- if q:
- clauses.append("content LIKE ? ESCAPE '\\'")
- params.append(_like_pattern(q))
-
- where = f"WHERE {' AND '.join(clauses)}"
-
- with _connect() as conn:
- total = conn.execute(
- f"SELECT COUNT(*) FROM memories {where}", params
- ).fetchone()[0]
- rows = conn.execute(
- f"""
- SELECT id, session_name, content, timestamp, context_type, metadata, created_at
- FROM memories
- {where}
- ORDER BY timestamp DESC
- LIMIT ? OFFSET ?
- """,
- [*params, limit, offset],
- ).fetchall()
-
- items = []
- for row in rows:
- raw = row["content"] or ""
- preview = (raw[:240] + "…") if len(raw) > 240 else raw
- items.append(
- {
- "id": row["id"],
- "session_name": row["session_name"],
- "content": raw,
- "display_content": _for_display(raw),
- "timestamp": row["timestamp"],
- "context_type": row["context_type"],
- "metadata": _parse_metadata(row["metadata"]),
- "created_at": row["created_at"],
- "preview": preview,
- "display_preview": _for_display(preview),
- }
- )
- return {"total": total, "limit": limit, "offset": offset, "items": items}
-
-
-def add_memory(content: str, session_name: str, context_type: str = "general") -> str:
- if context_type not in _CONTEXT_TYPES:
- raise ValueError(
- f"context_type must be one of: {', '.join(sorted(_CONTEXT_TYPES))}"
- )
-
- sanitized = _sanitize_memory(content.strip())
- if not sanitized:
- raise ValueError("Content cannot be empty")
- if not session_name.strip():
- raise ValueError("Session name is required")
-
- memory_id = str(uuid.uuid4())
- timestamp = datetime.now(timezone.utc).isoformat()
- embedding = _maybe_embedding(sanitized)
-
- with _connect() as conn:
- conn.execute(
- """
- INSERT INTO memories (id, session_name, content, embedding, timestamp, context_type, metadata)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- """,
- (
- memory_id,
- session_name.strip(),
- sanitized,
- embedding,
- timestamp,
- context_type,
- "{}",
- ),
- )
- _touch_session(conn, session_name.strip(), timestamp)
- conn.commit()
- return memory_id
-
-
-def update_memory(memory_id: str, content: str, context_type: str) -> bool:
- if context_type not in _CONTEXT_TYPES:
- raise ValueError(
- f"context_type must be one of: {', '.join(sorted(_CONTEXT_TYPES))}"
- )
- sanitized = _sanitize_memory(content.strip())
- if not sanitized:
- raise ValueError("Content cannot be empty")
- embedding = _maybe_embedding(sanitized)
- with _connect() as conn:
- cur = conn.execute(
- "UPDATE memories SET content = ?, context_type = ?, embedding = ? WHERE id = ?",
- (sanitized, context_type, embedding, memory_id),
- )
- conn.commit()
- return cur.rowcount > 0
-
-
-def delete_memory(memory_id: str) -> bool:
- with _connect() as conn:
- cur = conn.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
- conn.commit()
- return cur.rowcount > 0
-
-
-def delete_all_memories(session: Optional[str] = None) -> int:
- with _connect() as conn:
- if session:
- cur = conn.execute(
- "DELETE FROM memories WHERE session_name = ?", (session,)
- )
- else:
- cur = conn.execute("DELETE FROM memories")
- conn.commit()
- return cur.rowcount
-
-
-def list_logs(
- session: Optional[str] = None,
- q: Optional[str] = None,
- limit: int = 50,
- offset: int = 0,
-) -> Dict[str, Any]:
- limit = min(max(limit, 1), 200)
- offset = max(offset, 0)
- clauses: List[str] = []
- params: List[Any] = []
- if session:
- clauses.append("session_name = ?")
- params.append(session)
- if q:
- pattern = _like_pattern(q)
- clauses.append("(topic LIKE ? ESCAPE '\\' OR summary LIKE ? ESCAPE '\\')")
- params.extend([pattern, pattern])
- where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
-
- with _connect() as conn:
- total = conn.execute(
- f"SELECT COUNT(*) FROM log_entries {where}", params
- ).fetchone()[0]
- rows = conn.execute(
- f"""
- SELECT id, session_name, entry_date, topic, summary, full_entry, created_at
- FROM log_entries
- {where}
- ORDER BY created_at DESC
- LIMIT ? OFFSET ?
- """,
- [*params, limit, offset],
- ).fetchall()
-
- items = []
- for row in rows:
- item = dict(row)
- item["display_summary"] = _for_display(item.get("summary") or "")
- item["display_full_entry"] = _for_display(item.get("full_entry") or "")
- items.append(item)
-
- return {
- "total": total,
- "limit": limit,
- "offset": offset,
- "items": items,
- }
-
-
-def delete_all_logs() -> int:
- with _connect() as conn:
- cur = conn.execute("DELETE FROM log_entries")
- conn.commit()
- return cur.rowcount
-
-
-def delete_log(log_id: str) -> bool:
- with _connect() as conn:
- cur = conn.execute("DELETE FROM log_entries WHERE id = ?", (log_id,))
- conn.commit()
- return cur.rowcount > 0
-
-
-def add_session(session_name: str) -> None:
- name = session_name.strip()
- if not name:
- raise ValueError("Session name is required")
- timestamp = datetime.now(timezone.utc).isoformat()
- with _connect() as conn:
- existing = conn.execute(
- "SELECT 1 FROM sessions WHERE session_name = ?", (name,)
- ).fetchone()
- if existing:
- raise ValueError(f"Session '{name}' already exists")
- conn.execute(
- "INSERT INTO sessions (session_name, last_accessed) VALUES (?, ?)",
- (name, timestamp),
- )
- conn.commit()
-
-
-def delete_session(session_name: str) -> bool:
- with _connect() as conn:
- cur = conn.execute(
- "DELETE FROM sessions WHERE session_name = ?", (session_name,)
- )
- conn.commit()
- return cur.rowcount > 0
-
-
-def delete_all_sessions() -> int:
- with _connect() as conn:
- cur = conn.execute("DELETE FROM sessions")
- conn.commit()
- return cur.rowcount
-
-
-def list_notebook(q: Optional[str] = None) -> List[Dict[str, Any]]:
- params: List[Any] = []
- where = ""
- if q:
- pattern = _like_pattern(q)
- where = "WHERE (name LIKE ? ESCAPE '\\' OR data LIKE ? ESCAPE '\\')"
- params = [pattern, pattern]
- with _connect() as conn:
- rows = conn.execute(
- f"""
- SELECT name, data, created_at, updated_at
- FROM notebook_entries
- {where}
- ORDER BY updated_at DESC
- """,
- params,
- ).fetchall()
- out = []
- for row in rows:
- data = row["data"] or ""
- preview = (data[:200] + "…") if len(data) > 200 else data
- out.append(
- {
- "name": row["name"],
- "data": data,
- "created_at": row["created_at"],
- "updated_at": row["updated_at"],
- "preview": preview,
- "display_preview": _for_display(preview),
- "size_chars": len(data),
- }
- )
- return out
-
-
-def upsert_notebook(name: str, data: str) -> None:
- if not name.strip():
- raise ValueError("Name is required")
- clean_name = name.strip()
- clean_data = _strip_scripts(data)
- now = datetime.now(timezone.utc).isoformat()
- embedding = _maybe_embedding(clean_data)
-
- with _connect() as conn:
- exists = conn.execute(
- "SELECT 1 FROM notebook_entries WHERE name = ?", (clean_name,)
- ).fetchone()
- if exists:
- conn.execute(
- """
- UPDATE notebook_entries
- SET data = ?, embedding = ?, updated_at = ?
- WHERE name = ?
- """,
- (clean_data, embedding, now, clean_name),
- )
- else:
- conn.execute(
- """
- INSERT INTO notebook_entries (name, data, embedding, updated_at)
- VALUES (?, ?, ?, ?)
- """,
- (clean_name, clean_data, embedding, now),
- )
- conn.commit()
-
-
-def delete_notebook(name: str) -> bool:
- with _connect() as conn:
- cur = conn.execute("DELETE FROM notebook_entries WHERE name = ?", (name,))
- conn.commit()
- return cur.rowcount > 0
-
-
-def delete_all_notebook() -> int:
- with _connect() as conn:
- cur = conn.execute("DELETE FROM notebook_entries")
- conn.commit()
- return cur.rowcount
-
-
-# ==================== Compaction (Phase 1: Manual) ====================
-
-# UUID validation pattern
-_UUID_PATTERN = re.compile(
- r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
-)
-
-
-def _validate_memory_ids(memory_ids: List[str]) -> None:
- """Validate that all provided IDs are valid UUIDs to prevent SQL injection."""
- for mem_id in memory_ids:
- if not _UUID_PATTERN.match(mem_id):
- raise ValueError(f"Invalid memory ID format: {mem_id}")
-
-
-def list_memories_for_compaction(
- session: Optional[str] = None,
- limit: int = 50,
- offset: int = 0,
-) -> Dict[str, Any]:
- """List memories eligible for compaction (excludes compacted sources and summaries)."""
- limit = min(max(limit, 1), 200)
- offset = max(offset, 0)
- clauses: List[str] = ["compaction_role IS NULL"]
- params: List[Any] = []
-
- if session:
- clauses.append("session_name = ?")
- params.append(session)
-
- where = f"WHERE {' AND '.join(clauses)}"
-
- with _connect() as conn:
- total = conn.execute(
- f"SELECT COUNT(*) FROM memories {where}", params
- ).fetchone()[0]
- rows = conn.execute(
- f"""
- SELECT id, session_name, content, timestamp, context_type, created_at
- FROM memories
- {where}
- ORDER BY timestamp DESC
- LIMIT ? OFFSET ?
- """,
- [*params, limit, offset],
- ).fetchall()
-
- items = []
- for row in rows:
- raw = row["content"] or ""
- preview = (raw[:150] + "…") if len(raw) > 150 else raw
- items.append(
- {
- "id": row["id"],
- "session_name": row["session_name"],
- "content": raw,
- "display_content": _for_display(raw),
- "timestamp": row["timestamp"],
- "context_type": row["context_type"],
- "created_at": row["created_at"],
- "preview": preview,
- "display_preview": _for_display(preview),
- }
- )
- return {"total": total, "limit": limit, "offset": offset, "items": items}
-
-
-def generate_compaction_preview(memory_ids: List[str]) -> Dict[str, Any]:
- """Generate a preview of compacting selected memories."""
- if not memory_ids:
- raise ValueError("At least one memory ID required")
- if len(memory_ids) > 50:
- raise ValueError("Cannot compact more than 50 memories at once")
-
- # Validate UUIDs to prevent SQL injection
- _validate_memory_ids(memory_ids)
-
- with _connect() as conn:
- placeholders = ",".join("?" * len(memory_ids))
- rows = conn.execute(
- f"""
- SELECT id, session_name, content, timestamp
- FROM memories
- WHERE id IN ({placeholders})
- AND (compaction_role IS NULL OR compaction_role != 'source')
- ORDER BY timestamp ASC
- """,
- memory_ids,
- ).fetchall()
-
- if not rows:
- raise ValueError("No valid memories found for compaction")
-
- # Validate all from same session
- sessions = set(r["session_name"] for r in rows)
- if len(sessions) > 1:
- raise ValueError("All memories must be from the same session")
-
- # Build preview text
- source_texts = [r["content"] for r in rows if r["content"]]
- combined_text = "\n\n".join(source_texts)
-
- # Simple summary generation (token counting estimate)
- total_chars = len(combined_text)
- estimated_tokens_before = total_chars // 4 # rough estimate
-
- # Generate summary using LLM (if available) or fallback
- try:
- summary = _generate_summary_llm(source_texts)
- estimated_tokens_after = len(summary) // 4
- except Exception:
- # Fallback: extract first sentence from each memory
- summary = _generate_summary_fallback(source_texts)
- estimated_tokens_after = len(summary) // 4
-
- savings_pct = round(
- (1 - estimated_tokens_after / estimated_tokens_before) * 100
- if estimated_tokens_before > 0
- else 0
- )
-
- return {
- "summary": summary,
- "source_count": len(rows),
- "source_memory_ids": [r["id"] for r in rows],
- "session_name": rows[0]["session_name"],
- "token_savings_estimate": f"~{savings_pct}%",
- "original_char_count": total_chars,
- "summary_char_count": len(summary),
- "sources_preview": [
- {
- "id": r["id"],
- "content": r["content"][:200]
- + ("…" if len(r["content"]) > 200 else ""),
- "timestamp": r["timestamp"],
- }
- for r in rows
- ],
- }
-
-
-def _generate_summary_llm(texts: List[str]) -> str:
- """Generate summary using sentence-transformers semantic model (if available)."""
- # For now, use a simple extractive summary
- # TODO: Add LLM call for better summaries in Phase 1.1
- return _generate_summary_fallback(texts)
-
-
-def _generate_summary_fallback(texts: List[str]) -> str:
- """Fallback summary: concatenate first 2 sentences from each memory.
-
- Note: Uses naive sentence splitting which may not work well for code blocks,
- URLs, or technical content with abbreviations. Consider this a basic fallback
- when LLM summarization is unavailable.
- """
- summary_parts = []
- for text in texts[:10]: # Limit to first 10 for brevity
- # Extract first 2 sentences (naive split on ., !, ?)
- # This may break on code, URLs, abbreviations - document known limitation
- sentences = re.split(r"[.!?]+", text.strip())
- first_two = ". ".join(s.strip() for s in sentences[:2] if s.strip())
- if first_two:
- summary_parts.append(first_two)
-
- summary = " | ".join(summary_parts)
-
- # Cap at 1000 chars
- if len(summary) > 1000:
- summary = summary[:1000] + "…"
-
- return summary or "Summary of selected memories."
-
-
-def apply_manual_compaction(
- memory_ids: List[str],
- summary_content: str,
- session_name: str,
-) -> str:
- """Apply manual compaction: create summary memory, mark sources as compacted."""
- if not memory_ids:
- raise ValueError("At least one memory ID required")
- if not summary_content.strip():
- raise ValueError("Summary content cannot be empty")
- if not session_name.strip():
- raise ValueError("Session name required")
-
- # Validate UUIDs to prevent SQL injection
- _validate_memory_ids(memory_ids)
-
- sanitized_summary = _sanitize_memory(summary_content.strip())
- summary_id = str(uuid.uuid4())
- now = datetime.now(timezone.utc).isoformat()
- embedding = _maybe_embedding(sanitized_summary)
-
- # Compute candidate hash for staging record
- import hashlib
-
- sorted_ids = sorted(memory_ids)
- candidate_hash = hashlib.sha256("|".join(sorted_ids).encode("utf-8")).hexdigest()[
- :16
- ]
-
- with _connect() as conn:
- # Verify all sources exist and are not already compacted
- placeholders = ",".join("?" * len(memory_ids))
- sources = conn.execute(
- f"""
- SELECT id, session_name, content, timestamp
- FROM memories
- WHERE id IN ({placeholders})
- AND (compaction_role IS NULL OR compaction_role != 'source')
- """,
- memory_ids,
- ).fetchall()
-
- if len(sources) != len(memory_ids):
- raise ValueError("Some memories not found or already compacted")
-
- # Validate all from same session
- sessions = set(r["session_name"] for r in sources)
- if len(sessions) > 1:
- raise ValueError("All memories must be from the same session")
-
- # Insert summary memory
- conn.execute(
- """
- INSERT INTO memories
- (id, session_name, content, embedding, timestamp,
- context_type, metadata, compaction_role, created_at)
- VALUES (?, ?, ?, ?, ?, 'general', '{}', 'summary', ?)
- """,
- (summary_id, session_name.strip(), sanitized_summary, embedding, now, now),
- )
-
- # Mark sources as compacted
- for mem_id in memory_ids:
- conn.execute(
- """
- UPDATE memories
- SET compaction_role = 'source', compacted_into = ?
- WHERE id = ?
- """,
- (summary_id, mem_id),
- )
-
- # Create staging record (status='applied')
- source_snapshot = json.dumps(
- {
- row["id"]: {
- "content": row["content"][:100],
- "timestamp": row["timestamp"],
- }
- for row in sources
- }
- )
-
- preview_data = json.dumps(
- [{"id": row["id"], "snippet": row["content"][:100]} for row in sources]
- )
-
- conn.execute(
- """
- INSERT INTO compaction_staging
- (id, session_name, source_memory_ids, preview, suggested_summary,
- status, candidate_hash, source_updated_at_snapshot,
- expires_at, created_at, updated_at, reviewed_at)
- VALUES (?, ?, ?, ?, ?, 'applied', ?, ?, ?, ?, ?, ?)
- """,
- (
- str(uuid.uuid4()),
- session_name.strip(),
- json.dumps(sorted_ids),
- preview_data,
- sanitized_summary,
- candidate_hash,
- source_snapshot,
- now, # expires_at (not relevant for applied)
- now, # created_at
- now, # updated_at
- now, # reviewed_at
- ),
- )
-
- conn.commit()
-
- return summary_id
-
-
-# ==================== Maintenance (Phase 2) ====================
-
-
-def get_compaction_summary() -> Dict[str, int]:
- """Get counts by compaction status for dashboard overview."""
- conn = _connect()
-
- counts = {
- "pending_summary": 0,
- "summary_staged": 0,
- "applied": 0,
- "discarded": 0,
- "stale": 0,
- "nudge_exhausted": 0,
- }
-
- rows = conn.execute("""
- SELECT status, COUNT(*) as count
- FROM compaction_staging
- GROUP BY status
- """).fetchall()
-
- for row in rows:
- status = row["status"]
- count = row["count"]
- if status in counts:
- counts[status] = count
-
- return counts
-
-
-def list_compaction_candidates(
- session: Optional[str] = None,
- status: Optional[str] = None,
- limit: int = 20,
- offset: int = 0,
-) -> Dict[str, Any]:
- """List compaction candidates with optional filters."""
- conn = _connect()
-
- where_clauses = []
- params: List[Any] = []
-
- if session:
- where_clauses.append("session_name = ?")
- params.append(session)
-
- if status:
- where_clauses.append("status = ?")
- params.append(status)
-
- where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
-
- # Get total count
- count_query = f"SELECT COUNT(*) as total FROM compaction_staging{where_sql}"
- total = conn.execute(count_query, params).fetchone()["total"]
-
- # Get paginated items
- query = f"""
- SELECT
- id,
- session_name,
- source_memory_ids,
- preview,
- suggested_summary,
- status,
- created_at,
- updated_at,
- expires_at,
- nudge_count,
- last_nudged_at
- FROM compaction_staging
- {where_sql}
- ORDER BY created_at DESC
- LIMIT ? OFFSET ?
- """
- params.extend([limit, offset])
-
- rows = conn.execute(query, params).fetchall()
-
- items = []
- for row in rows:
- # Parse source_memory_ids JSON safely
- try:
- source_ids = json.loads(row["source_memory_ids"])
- except (json.JSONDecodeError, TypeError):
- source_ids = []
-
- items.append(
- {
- "id": row["id"],
- "session_name": row["session_name"],
- "source_memory_ids": source_ids,
- "source_count": len(source_ids),
- "preview": row["preview"][:200] if row["preview"] else "",
- "suggested_summary": row["suggested_summary"][:200]
- if row["suggested_summary"]
- else None,
- "status": row["status"],
- "created_at": row["created_at"],
- "updated_at": row["updated_at"],
- "expires_at": row["expires_at"],
- "nudge_count": row["nudge_count"],
- "last_nudged_at": row["last_nudged_at"],
- }
- )
-
- return {"total": total, "items": items}
-
-
-def discard_compaction_candidate(candidate_id: str) -> bool:
- """Mark a compaction candidate as discarded."""
- # Validate UUID format
- try:
- uuid.UUID(candidate_id)
- except ValueError:
- return False
-
- conn = _connect()
- now = datetime.now(timezone.utc).isoformat()
-
- cursor = conn.execute(
- """
- UPDATE compaction_staging
- SET status = 'discarded', reviewed_at = ?, updated_at = ?
- WHERE id = ?
- """,
- (now, now, candidate_id),
- )
-
- conn.commit()
- return cursor.rowcount > 0
diff --git a/marm-mcp-server/marm_dashboard/server.py b/marm-mcp-server/marm_dashboard/server.py
deleted file mode 100644
index 4f5b4520..00000000
--- a/marm-mcp-server/marm_dashboard/server.py
+++ /dev/null
@@ -1,386 +0,0 @@
-"""FastAPI app for the MARM Dashboard plugin."""
-
-from pathlib import Path
-from time import perf_counter
-from typing import Literal, Optional
-
-from fastapi import FastAPI, HTTPException, Query
-from fastapi.responses import FileResponse
-from fastapi.staticfiles import StaticFiles
-from pydantic import BaseModel, Field
-
-from . import __version__
-from . import db
-from .auth import auth_middleware, is_valid_key
-from .config import MARM_API_KEY
-
-STATIC_DIR = Path(__file__).parent / "static"
-
-app = FastAPI(
- title="MARM Dashboard",
- description="Local UI for MARM memory (reads ~/.marm/marm_memory.db)",
- version=__version__,
-)
-
-app.middleware("http")(auth_middleware)
-
-
-class MemoryCreate(BaseModel):
- content: str = Field(..., min_length=1)
- session_name: str = Field(..., min_length=1)
- context_type: Literal["general", "code", "project", "book"] = "general"
-
-
-class MemoryUpdate(BaseModel):
- content: str = Field(..., min_length=1)
- context_type: Literal["general", "code", "project", "book"] = "general"
-
-
-class NotebookUpsert(BaseModel):
- name: str = Field(..., min_length=1)
- data: str = ""
-
-
-class SessionCreate(BaseModel):
- session_name: str = Field(..., min_length=1)
-
-
-class UnlockRequest(BaseModel):
- api_key: str = Field(..., min_length=1)
-
-
-class CompactionPreviewRequest(BaseModel):
- memory_ids: list[str] = Field(..., min_length=1)
-
-
-class CompactionApplyRequest(BaseModel):
- memory_ids: list[str] = Field(..., min_length=1)
- summary_content: str = Field(..., min_length=1)
- session_name: str = Field(..., min_length=1)
-
-
-@app.get("/health")
-def health():
- return {
- "status": "ok",
- "auth_required": bool(MARM_API_KEY),
- }
-
-
-@app.get("/api/mcp-status")
-def api_mcp_status():
- import json as _json
- import urllib.error
- import urllib.request
-
- started = perf_counter()
- try:
- with urllib.request.urlopen(
- "http://127.0.0.1:8001/health", timeout=2.5
- ) as resp:
- body = _json.loads(resp.read())
- return {
- "reachable": True,
- "status_code": getattr(resp, "status", 200),
- "latency_ms": round((perf_counter() - started) * 1000, 1),
- "body": body,
- }
- except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
- return {
- "reachable": False,
- "latency_ms": round((perf_counter() - started) * 1000, 1),
- "error": e.__class__.__name__,
- }
-
-
-@app.post("/api/auth/unlock")
-def api_unlock(body: UnlockRequest):
- """Verify key for browser UI; client keeps it in memory for API calls."""
- if not MARM_API_KEY:
- return {"ok": True, "auth_required": False}
- if not is_valid_key(body.api_key.strip()):
- raise HTTPException(status_code=401, detail="Invalid API key")
- return {"ok": True, "auth_required": True}
-
-
-@app.get("/api/summary")
-def api_summary():
- try:
- return db.get_summary()
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.post("/api/sessions", status_code=201)
-def api_create_session(body: SessionCreate):
- try:
- db.add_session(body.session_name)
- return {"status": "created", "session_name": body.session_name.strip()}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
-
-
-@app.delete("/api/sessions")
-def api_delete_all_sessions():
- try:
- count = db.delete_all_sessions()
- return {"status": "deleted", "count": count}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.delete("/api/sessions/{session_name}")
-def api_delete_session(session_name: str):
- try:
- if not db.delete_session(session_name):
- raise HTTPException(status_code=404, detail="Session not found")
- return {"status": "deleted", "session_name": session_name}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.get("/api/sessions")
-def api_sessions(q: Optional[str] = None):
- try:
- return {"items": db.list_sessions(q=q)}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.get("/api/session-names")
-def api_session_names():
- try:
- return {"items": db.list_session_names()}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.get("/api/memories")
-def api_memories(
- session: Optional[str] = None,
- q: Optional[str] = None,
- limit: int = Query(50, ge=1, le=200),
- offset: int = Query(0, ge=0),
-):
- try:
- return db.list_memories(session=session, q=q, limit=limit, offset=offset)
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.post("/api/memories", status_code=201)
-def api_create_memory(body: MemoryCreate):
- try:
- memory_id = db.add_memory(body.content, body.session_name, body.context_type)
- return {"id": memory_id, "status": "created"}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
-
-
-@app.put("/api/memories/{memory_id}")
-def api_update_memory(memory_id: str, body: MemoryUpdate):
- try:
- if not db.update_memory(memory_id, body.content, body.context_type):
- raise HTTPException(status_code=404, detail="Memory not found")
- return {"status": "updated", "id": memory_id}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
-
-
-@app.delete("/api/memories")
-def api_delete_all_memories(session: Optional[str] = Query(None)):
- try:
- count = db.delete_all_memories(session=session)
- return {"status": "deleted", "count": count}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.delete("/api/memories/{memory_id}")
-def api_delete_memory(memory_id: str):
- try:
- if not db.delete_memory(memory_id):
- raise HTTPException(status_code=404, detail="Memory not found")
- return {"status": "deleted", "id": memory_id}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.delete("/api/logs")
-def api_delete_all_logs():
- try:
- count = db.delete_all_logs()
- return {"status": "deleted", "count": count}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.get("/api/logs")
-def api_logs(
- session: Optional[str] = None,
- q: Optional[str] = None,
- limit: int = Query(50, ge=1, le=200),
- offset: int = Query(0, ge=0),
-):
- try:
- return db.list_logs(session=session, q=q, limit=limit, offset=offset)
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.delete("/api/logs/{log_id}")
-def api_delete_log(log_id: str):
- try:
- if not db.delete_log(log_id):
- raise HTTPException(status_code=404, detail="Log entry not found")
- return {"status": "deleted", "id": log_id}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.get("/api/notebook")
-def api_notebook(q: Optional[str] = None):
- try:
- return {"items": db.list_notebook(q=q)}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.post("/api/notebook", status_code=201)
-def api_upsert_notebook(body: NotebookUpsert):
- try:
- db.upsert_notebook(body.name, body.data)
- return {"status": "saved", "name": body.name.strip()}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
-
-
-@app.delete("/api/notebook")
-def api_delete_all_notebook():
- try:
- count = db.delete_all_notebook()
- return {"status": "deleted", "count": count}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.delete("/api/notebook/{name}")
-def api_delete_notebook(name: str):
- try:
- if not db.delete_notebook(name):
- raise HTTPException(status_code=404, detail="Notebook entry not found")
- return {"status": "deleted", "name": name}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-# ==================== Compaction (Phase 1: Manual) ====================
-
-
-@app.get("/api/compaction/memories")
-def api_compaction_memories(
- session: Optional[str] = None,
- limit: int = Query(50, ge=1, le=200),
- offset: int = Query(0, ge=0),
-):
- """List memories eligible for compaction."""
- try:
- return db.list_memories_for_compaction(
- session=session, limit=limit, offset=offset
- )
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.post("/api/compaction/preview")
-def api_compaction_preview(body: CompactionPreviewRequest):
- """Generate preview of compacting selected memories."""
- try:
- preview = db.generate_compaction_preview(body.memory_ids)
- return preview
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
-
-
-@app.post("/api/compaction/apply", status_code=201)
-def api_compaction_apply(body: CompactionApplyRequest):
- """Apply manual compaction: create summary + mark sources."""
- try:
- summary_id = db.apply_manual_compaction(
- body.memory_ids,
- body.summary_content,
- body.session_name,
- )
- return {
- "status": "applied",
- "summary_id": summary_id,
- "source_count": len(body.memory_ids),
- }
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
-
-
-# ==================== Maintenance (Phase 2) ====================
-
-
-@app.get("/api/maintenance/compaction-summary")
-def api_maintenance_compaction_summary():
- """Get compaction pipeline status counts."""
- try:
- return db.get_compaction_summary()
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.get("/api/maintenance/candidates")
-def api_maintenance_candidates(
- session: Optional[str] = None,
- status: Optional[str] = None,
- limit: int = Query(20, ge=1, le=100),
- offset: int = Query(0, ge=0),
-):
- """List compaction candidates with filters."""
- try:
- return db.list_compaction_candidates(
- session=session, status=status, limit=limit, offset=offset
- )
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
-
-
-@app.post("/api/maintenance/candidates/{candidate_id}/discard")
-def api_discard_candidate(candidate_id: str):
- """Mark a candidate as discarded."""
- try:
- success = db.discard_compaction_candidate(candidate_id)
- if not success:
- raise HTTPException(status_code=404, detail="Candidate not found")
- return {"status": "discarded", "id": candidate_id}
- except FileNotFoundError as e:
- raise HTTPException(status_code=503, detail=str(e)) from e
- except HTTPException:
- raise
-
-
-@app.get("/")
-async def root():
- index = STATIC_DIR / "index.html"
- if index.exists():
- return FileResponse(index)
- raise HTTPException(status_code=404, detail="Dashboard UI not found")
-
-
-if STATIC_DIR.exists():
- app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
diff --git a/marm-mcp-server/marm_dashboard/static/assets/app.css b/marm-mcp-server/marm_dashboard/static/assets/app.css
deleted file mode 100644
index a5f62c19..00000000
--- a/marm-mcp-server/marm_dashboard/static/assets/app.css
+++ /dev/null
@@ -1,944 +0,0 @@
-:root {
- --bg: #0c0f12;
- --surface: #141a21;
- --surface-2: #1b232c;
- --border: #2a3542;
- --text: #e8edf2;
- --muted: #8b9aab;
- --accent: #e8a838;
- --accent-dim: #b8842a;
- --danger: #e85d5d;
- --ok: #5dce8e;
- --mono: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
- --sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- --radius: 10px;
- --shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
-}
-
-*,
-*::before,
-*::after {
- box-sizing: border-box;
-}
-
-html,
-body {
- margin: 0;
- min-height: 100%;
- font-family: var(--sans);
- background: var(--bg);
- color: var(--text);
-}
-
-.backdrop {
- position: fixed;
- inset: 0;
- pointer-events: none;
- background:
- radial-gradient(ellipse 80% 50% at 10% -10%, rgba(232, 168, 56, 0.12), transparent 55%),
- radial-gradient(ellipse 60% 40% at 90% 0%, rgba(93, 206, 142, 0.06), transparent 50%);
- z-index: 0;
-}
-
-.topbar,
-.tabs,
-.main {
- position: relative;
- z-index: 1;
-}
-
-.topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1rem;
- padding: 1.25rem 1.5rem 0.5rem;
- max-width: 1100px;
- margin: 0 auto;
-}
-
-.brand {
- display: flex;
- align-items: center;
- gap: 0.85rem;
-}
-
-.brand-mark {
- width: 42px;
- height: 42px;
- display: grid;
- place-items: center;
- font-weight: 700;
- font-size: 1.1rem;
- background: linear-gradient(145deg, var(--accent), var(--accent-dim));
- color: #1a1408;
- border-radius: 12px;
- box-shadow: var(--shadow);
-}
-
-h1 {
- margin: 0;
- font-size: 1.35rem;
- font-weight: 600;
- letter-spacing: -0.02em;
-}
-
-.subtitle {
- margin: 0.15rem 0 0;
- font-size: 0.85rem;
- color: var(--muted);
-}
-
-.topbar-meta {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- justify-content: flex-end;
-}
-
-.pill {
- font-size: 0.75rem;
- padding: 0.35rem 0.65rem;
- border-radius: 999px;
- border: 1px solid var(--border);
- background: var(--surface);
-}
-
-.pill.ok {
- border-color: rgba(93, 206, 142, 0.35);
- color: var(--ok);
-}
-
-.pill.muted {
- color: var(--muted);
-}
-
-.pill.error {
- border-color: rgba(232, 93, 93, 0.45);
- color: var(--danger);
-}
-
-.tabs {
- display: flex;
- gap: 0.35rem;
- padding: 0 1.5rem 1rem;
- max-width: 1100px;
- margin: 0 auto;
- overflow-x: auto;
-}
-
-.tab {
- font: inherit;
- font-size: 0.88rem;
- padding: 0.5rem 0.9rem;
- border: 1px solid transparent;
- border-radius: var(--radius);
- background: transparent;
- color: var(--muted);
- cursor: pointer;
- white-space: nowrap;
- transition: color 0.15s, background 0.15s, border-color 0.15s;
-}
-
-.tab:hover {
- color: var(--text);
- background: var(--surface);
-}
-
-.tab.active {
- color: var(--text);
- background: var(--surface-2);
- border-color: var(--border);
-}
-
-.main {
- max-width: 1100px;
- margin: 0 auto;
- padding: 0 1.5rem 3rem;
-}
-
-.panel[hidden] {
- display: none !important;
-}
-
-.stats-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
- gap: 0.75rem;
- margin-bottom: 1rem;
-}
-
-.stat {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 1rem;
- animation: fadeUp 0.4s ease both;
-}
-
-.stat strong {
- display: block;
- font-size: 1.75rem;
- font-weight: 600;
- color: var(--accent);
- line-height: 1.1;
-}
-
-.stat span {
- font-size: 0.8rem;
- color: var(--muted);
-}
-
-.card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 1.25rem;
- margin-bottom: 1rem;
-}
-
-.card h2,
-.card h3 {
- margin: 0 0 0.75rem;
- font-size: 1rem;
-}
-
-.hint {
- font-size: 0.85rem;
- color: var(--muted);
- line-height: 1.5;
-}
-
-.mono,
-code {
- font-family: var(--mono);
- font-size: 0.82rem;
-}
-
-.path {
- word-break: break-all;
- color: var(--accent);
-}
-
-.status-dl {
- margin: 0;
- display: grid;
- gap: 0.65rem;
-}
-
-.status-dl div {
- display: grid;
- grid-template-columns: 9rem 1fr;
- gap: 0.75rem;
- align-items: baseline;
-}
-
-.status-dl dt {
- margin: 0;
- font-size: 0.8rem;
- color: var(--muted);
- font-weight: 500;
-}
-
-.status-dl dd {
- margin: 0;
- font-size: 0.88rem;
-}
-
-.status-dl dd.mono {
- font-family: var(--mono);
- font-size: 0.78rem;
- word-break: break-all;
-}
-
-.hint-inline {
- font-size: 0.78rem;
- color: var(--muted);
-}
-
-.toolbar {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- margin-bottom: 1rem;
- align-items: center;
-}
-
-.toolbar input[type="search"],
-.toolbar select,
-.form-card input,
-.form-card textarea,
-.form-card select {
- font: inherit;
- background: var(--surface-2);
- border: 1px solid var(--border);
- color: var(--text);
- border-radius: 8px;
- padding: 0.5rem 0.75rem;
-}
-
-.toolbar input[type="search"] {
- flex: 1;
- min-width: 180px;
-}
-
-.btn {
- font: inherit;
- font-size: 0.85rem;
- padding: 0.5rem 0.9rem;
- border-radius: 8px;
- border: 1px solid var(--border);
- background: var(--surface-2);
- color: var(--text);
- cursor: pointer;
- transition: background 0.15s, border-color 0.15s;
-}
-
-.btn:hover {
- border-color: var(--muted);
-}
-
-.btn.primary {
- background: var(--accent);
- border-color: var(--accent-dim);
- color: #1a1408;
- font-weight: 600;
-}
-
-.btn.primary:hover {
- filter: brightness(1.05);
-}
-
-.btn.danger {
- border-color: rgba(232, 93, 93, 0.4);
- color: var(--danger);
-}
-
-.btn.ghost {
- background: transparent;
-}
-
-.btn.sm {
- padding: 0.35rem 0.6rem;
- font-size: 0.78rem;
-}
-
-.form-card label {
- display: block;
- margin-bottom: 0.75rem;
- font-size: 0.85rem;
- color: var(--muted);
-}
-
-.form-card input,
-.form-card textarea,
-.form-card select {
- display: block;
- width: 100%;
- margin-top: 0.35rem;
-}
-
-.form-actions {
- display: flex;
- gap: 0.5rem;
- margin-top: 0.5rem;
-}
-
-.list {
- display: flex;
- flex-direction: column;
- gap: 0.65rem;
-}
-
-.item {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 1rem 1.1rem;
- animation: fadeUp 0.35s ease both;
-}
-
-.item-head {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem;
- margin-bottom: 0.5rem;
-}
-
-.tag {
- font-size: 0.72rem;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- padding: 0.2rem 0.45rem;
- border-radius: 6px;
- background: var(--surface-2);
- color: var(--muted);
- border: 1px solid var(--border);
-}
-
-.tag.session {
- color: var(--accent);
- border-color: rgba(232, 168, 56, 0.35);
-}
-
-.item p {
- margin: 0;
- font-size: 0.9rem;
- line-height: 1.55;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.item-meta {
- font-size: 0.75rem;
- color: var(--muted);
- margin-top: 0.5rem;
-}
-
-.item-actions {
- display: flex;
- gap: 0.4rem;
- margin-top: 0.65rem;
-}
-
-.empty {
- text-align: center;
- padding: 2.5rem 1rem;
- color: var(--muted);
- border: 1px dashed var(--border);
- border-radius: var(--radius);
-}
-
-.pager {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 0.75rem;
- margin-top: 1rem;
- font-size: 0.85rem;
- color: var(--muted);
-}
-
-#detail-dialog,
-#confirm-dialog {
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--surface);
- color: var(--text);
- padding: 0;
- box-shadow: var(--shadow);
-}
-
-#detail-dialog {
- max-width: min(720px, 92vw);
-}
-
-#confirm-dialog {
- max-width: min(440px, 92vw);
-}
-
-#detail-dialog::backdrop,
-#confirm-dialog::backdrop {
- background: rgba(0, 0, 0, 0.65);
-}
-
-.confirm-body {
- padding: 1rem 1.25rem 1.25rem;
-}
-
-.session-chip {
- display: inline-flex;
- align-items: center;
- gap: 0.3rem;
- padding: 0.3rem 0.5rem 0.3rem 0.65rem;
- border-radius: 999px;
- border: 1px solid rgba(232, 168, 56, 0.45);
- background: rgba(232, 168, 56, 0.08);
- color: var(--accent);
- font-size: 0.78rem;
- white-space: nowrap;
-}
-
-.session-chip-clear {
- font: inherit;
- font-size: 0.85rem;
- line-height: 1;
- padding: 0 0.2rem;
- background: none;
- border: none;
- color: var(--accent);
- cursor: pointer;
- opacity: 0.7;
-}
-
-.session-chip-clear:hover {
- opacity: 1;
-}
-
-.dialog-head {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 1rem 1.25rem;
- border-bottom: 1px solid var(--border);
-}
-
-.dialog-head h3 {
- margin: 0;
- font-size: 1rem;
-}
-
-.dialog-body {
- margin: 0;
- padding: 1.25rem;
- max-height: 60vh;
- overflow: auto;
- font-family: var(--mono);
- font-size: 0.8rem;
- line-height: 1.5;
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.toast {
- position: fixed;
- bottom: 1.25rem;
- right: 1.25rem;
- padding: 0.75rem 1rem;
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- font-size: 0.88rem;
- box-shadow: var(--shadow);
- z-index: 100;
- animation: fadeUp 0.25s ease;
-}
-
-.toast.error {
- border-color: rgba(232, 93, 93, 0.5);
- color: var(--danger);
-}
-
-.loading-screen {
- position: fixed;
- inset: 0;
- z-index: 300;
- display: grid;
- place-items: center;
- background: var(--bg);
-}
-
-.loading-screen[hidden] {
- display: none !important;
-}
-
-.loading-card {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 1.25rem;
-}
-
-.loading-mark {
- width: 56px;
- height: 56px;
- font-size: 1.4rem;
- animation: loadingPulse 1.6s ease-in-out infinite;
-}
-
-.loading-text {
- margin: 0;
- color: var(--muted);
- font-size: 0.9rem;
- letter-spacing: 0.04em;
-}
-
-@keyframes loadingPulse {
- 0%, 100% { opacity: 1; transform: scale(1); }
- 50% { opacity: 0.5; transform: scale(0.93); }
-}
-
-.unlock-screen {
- position: fixed;
- inset: 0;
- z-index: 200;
- display: grid;
- place-items: center;
- background: rgba(12, 15, 18, 0.92);
- backdrop-filter: blur(6px);
- padding: 1.5rem;
-}
-
-.unlock-screen[hidden] {
- display: none !important;
-}
-
-.unlock-card {
- width: min(420px, 100%);
- margin: 0;
-}
-
-.unlock-error {
- margin: 0.75rem 0 0;
- font-size: 0.85rem;
- color: var(--danger);
-}
-
-body.locked .topbar,
-body.locked .tabs,
-body.locked .main {
- filter: blur(4px);
- pointer-events: none;
- user-select: none;
-}
-
-@keyframes fadeUp {
- from {
- opacity: 0;
- transform: translateY(8px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-@media (max-width: 600px) {
- .topbar {
- flex-direction: column;
- align-items: flex-start;
- }
-}
-
-/* Compaction UI Styles */
-
-.compaction-item {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 1rem 1.1rem;
- animation: fadeUp 0.35s ease both;
- transition: border-color 0.15s, background 0.15s;
-}
-
-.compaction-item:hover {
- border-color: var(--muted);
-}
-
-.compaction-checkbox {
- display: flex;
- gap: 0.85rem;
- cursor: pointer;
- align-items: flex-start;
-}
-
-.compaction-checkbox input[type="checkbox"] {
- margin-top: 0.25rem;
- width: 18px;
- height: 18px;
- cursor: pointer;
- accent-color: var(--accent);
-}
-
-.compaction-content {
- flex: 1;
- min-width: 0;
-}
-
-.compaction-meta {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem;
- margin-bottom: 0.5rem;
-}
-
-.compaction-preview {
- margin: 0.5rem 0 0;
- font-size: 0.9rem;
- line-height: 1.55;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
- max-height: 4.5em;
- overflow: hidden;
- text-overflow: ellipsis;
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
-}
-
-.btn.accent {
- background: var(--accent);
- border-color: var(--accent-dim);
- color: #1a1408;
- font-weight: 600;
-}
-
-.btn.accent:hover {
- filter: brightness(1.05);
-}
-
-.btn.accent:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- filter: none;
-}
-
-#compaction-preview-dialog {
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--surface);
- color: var(--text);
- padding: 0;
- box-shadow: var(--shadow);
- max-width: min(800px, 92vw);
- max-height: 85vh;
-}
-
-#compaction-preview-dialog::backdrop {
- background: rgba(0, 0, 0, 0.65);
-}
-
-.compaction-preview-body {
- padding: 1.25rem;
- max-height: calc(85vh - 120px);
- overflow-y: auto;
-}
-
-.preview-section {
- margin-bottom: 1.5rem;
-}
-
-.preview-section:last-child {
- margin-bottom: 0;
-}
-
-.preview-section h4 {
- margin: 0 0 0.75rem;
- font-size: 0.9rem;
- color: var(--muted);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.preview-sources {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-.preview-source-item {
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 0.75rem;
- font-size: 0.85rem;
- line-height: 1.5;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
- max-height: 8em;
- overflow: hidden;
- text-overflow: ellipsis;
- display: -webkit-box;
- -webkit-line-clamp: 5;
- -webkit-box-orient: vertical;
-}
-
-.preview-summary {
- background: var(--surface-2);
- border: 1px solid rgba(232, 168, 56, 0.35);
- border-radius: 8px;
- padding: 1rem;
-}
-
-.preview-summary-text {
- margin: 0;
- font-size: 0.9rem;
- line-height: 1.6;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.preview-stats {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- margin-top: 0.75rem;
-}
-
-.stat-pill {
- display: inline-flex;
- align-items: center;
- font-size: 0.75rem;
- padding: 0.35rem 0.65rem;
- border-radius: 999px;
- border: 1px solid var(--border);
- background: var(--surface);
- color: var(--muted);
-}
-
-.stat-pill strong {
- color: var(--accent);
- font-weight: 600;
- margin-right: 0.25rem;
-}
-
-.stat-pill.savings {
- border-color: rgba(93, 206, 142, 0.35);
-}
-
-.stat-pill.savings strong {
- color: var(--ok);
-}
-
-
-/* Maintenance UI Styles (Phase 2) */
-
-.section-title {
- margin: 0 0 1rem;
- font-size: 1.1rem;
- font-weight: 600;
-}
-
-.section-subtitle {
- margin: 1.5rem 0 1rem;
- font-size: 0.95rem;
- font-weight: 600;
- color: var(--muted);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.maintenance-stats {
- margin-bottom: 1.5rem;
-}
-
-.maintenance-tabs {
- display: flex;
- gap: 0.5rem;
- margin-bottom: 1.5rem;
- border-bottom: 2px solid var(--border);
- padding-bottom: 0;
-}
-
-.maintenance-tab {
- font: inherit;
- font-size: 0.88rem;
- padding: 0.65rem 1rem;
- border: none;
- border-bottom: 2px solid transparent;
- background: transparent;
- color: var(--muted);
- cursor: pointer;
- transition: color 0.15s, border-color 0.15s;
- margin-bottom: -2px;
-}
-
-.maintenance-tab:hover {
- color: var(--text);
-}
-
-.maintenance-tab.active {
- color: var(--accent);
- border-bottom-color: var(--accent);
-}
-
-.maintenance-section {
- animation: fadeUp 0.3s ease;
-}
-
-.maintenance-section[hidden] {
- display: none;
-}
-
-.candidate-item {
- position: relative;
-}
-
-.candidate-preview {
- margin: 0.5rem 0 0;
- font-size: 0.9rem;
- line-height: 1.5;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
- max-height: 4.5em;
- overflow: hidden;
- text-overflow: ellipsis;
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
-}
-
-.tag.ok {
- border-color: rgba(93, 206, 142, 0.35);
- color: var(--ok);
-}
-
-.tag.error {
- border-color: rgba(232, 93, 93, 0.45);
- color: var(--danger);
-}
-
-#maintenance-detail-dialog {
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--surface);
- color: var(--text);
- padding: 0;
- box-shadow: var(--shadow);
- max-width: min(700px, 92vw);
- max-height: 85vh;
-}
-
-#maintenance-detail-dialog::backdrop {
- background: rgba(0, 0, 0, 0.65);
-}
-
-.maintenance-detail-body {
- padding: 1.25rem;
- max-height: calc(85vh - 80px);
- overflow-y: auto;
-}
-
-.detail-section {
- margin-bottom: 1.25rem;
-}
-
-.detail-section:last-child {
- margin-bottom: 0;
-}
-
-.detail-section h4 {
- margin: 0 0 0.5rem;
- font-size: 0.85rem;
- color: var(--muted);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.detail-section p {
- margin: 0.25rem 0;
- font-size: 0.9rem;
- line-height: 1.5;
-}
-
-.detail-preview {
- margin: 0.5rem 0 0;
- padding: 0.75rem;
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: 8px;
- font-family: var(--mono);
- font-size: 0.82rem;
- line-height: 1.5;
- white-space: pre-wrap;
- word-break: break-word;
- max-height: 12em;
- overflow-y: auto;
-}
diff --git a/marm-mcp-server/marm_dashboard/static/assets/app.js b/marm-mcp-server/marm_dashboard/static/assets/app.js
deleted file mode 100644
index 3e34dc54..00000000
--- a/marm-mcp-server/marm_dashboard/static/assets/app.js
+++ /dev/null
@@ -1,1277 +0,0 @@
-const MCP_STATUS_POLL_MS = 15_000;
-let unlockedApiKey = "";
-
-const state = {
- memoryOffset: 0,
- memoryLimit: 30,
- memoryTotal: 0,
- memorySession: "",
- logOffset: 0,
- logLimit: 30,
- logTotal: 0,
- notebookTotal: 0,
- sessions: [],
- mcpPollId: null,
- compactionOffset: 0,
- compactionLimit: 30,
- compactionTotal: 0,
- compactionSession: "",
- compactionSelected: new Set(),
- compactionPreviewData: null,
- maintenanceOffset: 0,
- maintenanceLimit: 20,
- maintenanceTotal: 0,
- maintenanceSession: "",
- maintenanceStatus: "",
-};
-
-function getStoredKey() {
- return unlockedApiKey;
-}
-
-function setStoredKey(key) {
- unlockedApiKey = key || "";
-}
-
-function authHeaders() {
- const key = getStoredKey();
- if (!key) return {};
- return { Authorization: `Bearer ${key}` };
-}
-
-function $(sel) {
- return document.querySelector(sel);
-}
-
-function escapeHtml(str) {
- const d = document.createElement("div");
- d.textContent = str ?? "";
- return d.innerHTML;
-}
-
-function formatDate(iso) {
- if (!iso) return "—";
- const d = new Date(iso);
- if (isNaN(d)) return iso;
- const diff = Date.now() - d.getTime();
- if (diff < 60_000) return "just now";
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
- return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
-}
-
-async function api(path, options = {}) {
- const res = await fetch(path, {
- headers: {
- "Content-Type": "application/json",
- ...authHeaders(),
- ...(options.headers || {}),
- },
- ...options,
- });
- let body = null;
- try {
- body = await res.json();
- } catch {
- body = null;
- }
- if (res.status === 401 && body?.auth_required) {
- setStoredKey("");
- showUnlock("Session expired or key required.");
- throw new Error("Authentication required");
- }
- if (!res.ok) {
- const msg = body?.detail || res.statusText || "Request failed";
- throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
- }
- return body;
-}
-
-function showUnlock(message = "") {
- document.body.classList.add("locked");
- const screen = $("#unlock-screen");
- screen.hidden = false;
- const err = $("#unlock-error");
- if (message) {
- err.textContent = message;
- err.hidden = false;
- } else {
- err.hidden = true;
- }
-}
-
-function hideUnlock() {
- document.body.classList.remove("locked");
- $("#unlock-screen").hidden = true;
- $("#unlock-error").hidden = true;
-}
-
-function setupUnlock() {
- $("#unlock-form").addEventListener("submit", async (e) => {
- e.preventDefault();
- const fd = new FormData(e.target);
- const apiKey = String(fd.get("api_key") || "").trim();
- try {
- const res = await fetch(`api/auth/unlock`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ api_key: apiKey }),
- });
- const body = await res.json().catch(() => ({}));
- if (!res.ok) {
- $("#unlock-error").textContent =
- typeof body.detail === "string" ? body.detail : "Invalid API key";
- $("#unlock-error").hidden = false;
- return;
- }
- if (body.auth_required !== false) setStoredKey(apiKey);
- hideUnlock();
- await initDashboard();
- } catch (err) {
- $("#unlock-error").textContent = err.message;
- $("#unlock-error").hidden = false;
- }
- });
-}
-
-function toast(msg, isError = false) {
- const el = $("#toast");
- el.textContent = msg;
- el.classList.toggle("error", isError);
- el.hidden = false;
- clearTimeout(toast._t);
- toast._t = setTimeout(() => {
- el.hidden = true;
- }, 3200);
-}
-
-function showDetail(title, text) {
- $("#dialog-title").textContent = title;
- $("#dialog-body").textContent = text;
- $("#detail-dialog").showModal();
-}
-
-function showConfirm(title, message, confirmLabel = "Delete all") {
- return new Promise((resolve) => {
- const dialog = $("#confirm-dialog");
- $("#confirm-title").textContent = title;
- $("#confirm-message").textContent = message;
- $("#confirm-ok").textContent = confirmLabel;
- dialog.returnValue = "";
- dialog.showModal();
- const handler = () => {
- dialog.removeEventListener("close", handler);
- resolve(dialog.returnValue === "confirm");
- };
- dialog.addEventListener("close", handler);
- });
-}
-
-function setupTabs() {
- document.querySelectorAll(".tab").forEach((btn) => {
- btn.addEventListener("click", () => {
- const panel = btn.dataset.panel;
- document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === btn));
- document.querySelectorAll(".panel").forEach((p) => {
- const on = p.id === `panel-${panel}`;
- p.hidden = !on;
- p.classList.toggle("active", on);
- });
- if (panel === "memories") loadMemories();
- if (panel === "sessions") loadSessions();
- if (panel === "compaction") loadCompaction();
- if (panel === "maintenance") loadMaintenance();
- if (panel === "logs") loadLogs();
- if (panel === "notebook") loadNotebook();
- });
- });
-}
-
-
-function setHeaderStatus(mode, extraPills = "") {
- const meta = $("#header-meta");
- if (mode === "live") {
- meta.innerHTML = `Dashboard live ${extraPills}`;
- } else if (mode === "offline") {
- meta.innerHTML = `Dashboard offline `;
- } else if (mode === "locked") {
- meta.innerHTML = `Unlock required `;
- } else {
- meta.innerHTML = `Connecting… `;
- }
-}
-
-function embeddingPill(data) {
- if (data.semantic_model_loaded) {
- return `Embeddings loaded `;
- }
- if (data.embeddings_package_available) {
- return `Embeddings available `;
- }
- return `Text search only `;
-}
-
-async function probeMcpHealth() {
- const el = $("#mcp-status-dd");
- if (!el) return;
- try {
- const res = await fetch(`api/mcp-status`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json();
- if (data.reachable) {
- const ver = data.body?.version ? ` v${escapeHtml(data.body.version)}` : "";
- const status = data.body?.status ? escapeHtml(data.body.status) : "ok";
- const latency = Number.isFinite(data.latency_ms) ? `${data.latency_ms}ms` : "—";
- el.innerHTML = `
- Reachable${ver}
- status: ${status} · ${latency} · ${new Date().toLocaleTimeString()}
- `;
- } else {
- throw new Error("not reachable");
- }
- } catch {
- el.innerHTML =
- `Not on :8001 STDIO or stopped is OK · ${new Date().toLocaleTimeString()} `;
- }
-}
-
-function startMcpHealthPolling() {
- if (state.mcpPollId) clearInterval(state.mcpPollId);
- state.mcpPollId = setInterval(probeMcpHealth, MCP_STATUS_POLL_MS);
-}
-
-async function loadSummary() {
- const [data, health] = await Promise.all([
- api("api/summary"),
- fetch(`health`).then((r) => r.json()).catch(() => ({})),
- ]);
- const c = data.counts;
- $("#stats-grid").innerHTML = [
- ["Memories", c.memories],
- ["Sessions", c.sessions],
- ["Log entries", c.log_entries],
- ["Notebook", c.notebook_entries],
- ]
- .map(
- ([label, n], i) => `
-
- ${n}
- ${escapeHtml(label)}
-
`
- )
- .join("");
-
- const authLabel = health.auth_required
- ? "API key required"
- : "Localhost only (no key)";
-
- $("#status-dl").innerHTML = `
-
Dashboard Live
-
MCP server Checking…
-
Database ${escapeHtml(data.db_path)}
-
Active session ${escapeHtml(data.active_session || "—")}
-
Auth ${escapeHtml(authLabel)}
-
Search ${
- data.semantic_model_loaded
- ? "Semantic model loaded"
- : data.embeddings_package_available
- ? "Install [embeddings] for semantic search"
- : "Text search only"
- }
-
Refreshed ${escapeHtml(new Date(data.timestamp).toLocaleString())}
- `;
-
- setHeaderStatus(
- "live",
- `${data.active_session ? `Active: ${escapeHtml(data.active_session)} ` : ""}${embeddingPill(data)}`
- );
-
- probeMcpHealth();
- startMcpHealthPolling();
-}
-
-async function loadSessions() {
- const q = $("#sessions-search")?.value.trim();
- const params = q ? `?q=${encodeURIComponent(q)}` : "";
- const { items } = await api(`api/sessions${params}`);
- state.sessions = items;
-
- const el = $("#sessions-list");
- if (!items.length) {
- el.innerHTML = 'No sessions yet. Memories and MCP tools create them automatically.
';
- return;
- }
-
- el.innerHTML = items
- .map(
- (s, i) => `
-
-
- ${escapeHtml(s.session_name)}
- ${s.marm_active ? 'MARM active ' : ""}
-
-
- ${s.memory_count} memories · ${s.log_count} logs · last ${escapeHtml(formatDate(s.last_accessed))}
-
-
- View memories
- Delete session
-
- `
- )
- .join("");
-
- el.querySelectorAll("[data-view-memories]").forEach((btn) => {
- btn.addEventListener("click", () => {
- const card = btn.closest(".item");
- const sessionName = card.querySelector(".tag.session")?.textContent || "";
- state.memorySession = sessionName;
- document.querySelector('.tab[data-panel="memories"]').click();
- });
- });
-
- el.querySelectorAll("[data-del-session]").forEach((btn) => {
- btn.addEventListener("click", async () => {
- const name = btn.dataset.delSession;
- const ok = await showConfirm(
- `Delete session "${name}"?`,
- "This removes the session record. Memories and logs for this session are not deleted.",
- "Delete session"
- );
- if (!ok) return;
- try {
- await api(`api/sessions/${encodeURIComponent(name)}`, { method: "DELETE" });
- toast(`Session "${name}" deleted`);
- loadSessions();
- loadSummary();
- } catch (e) {
- toast(e.message, true);
- }
- });
- });
-}
-
-function updateSessionChip() {
- const chip = $("#memory-session-chip");
- const label = $("#memory-session-chip-label");
- if (state.memorySession) {
- label.textContent = state.memorySession;
- chip.hidden = false;
- } else {
- chip.hidden = true;
- }
-}
-
-async function loadMemories(reset = true) {
- if (reset) state.memoryOffset = 0;
- const q = $("#memory-search").value.trim();
- const params = new URLSearchParams({
- limit: state.memoryLimit,
- offset: state.memoryOffset,
- });
- if (state.memorySession) params.set("session", state.memorySession);
- if (q) params.set("q", q);
- updateSessionChip();
-
- const data = await api(`api/memories?${params}`);
- state.memoryTotal = data.total;
-
- const el = $("#memories-list");
- if (!data.items.length) {
- el.innerHTML = 'No memories match. Add one or clear filters.
';
- } else {
- el.innerHTML = data.items
- .map(
- (m, i) => `
-
-
- ${escapeHtml(m.session_name)}
- ${escapeHtml(m.context_type)}
-
- ${escapeHtml(m.display_preview ?? m.preview)}
- ${escapeHtml(formatDate(m.timestamp))} · id ${escapeHtml(m.id.slice(0, 8))}…
-
- View
- Edit
- Delete
-
- `
- )
- .join("");
-
- const byId = Object.fromEntries(data.items.map((m) => [m.id, m]));
- el.querySelectorAll("[data-view-memory]").forEach((btn) => {
- btn.addEventListener("click", () => {
- const m = byId[btn.dataset.viewMemory];
- showDetail(
- `${m.session_name} · ${m.context_type}`,
- m.display_content ?? m.content
- );
- });
- });
- el.querySelectorAll("[data-edit-memory]").forEach((btn) => {
- btn.addEventListener("click", () => {
- const m = byId[btn.dataset.editMemory];
- const form = $("#memory-add-form");
- form.dataset.editingId = m.id;
- form.querySelector("h3").textContent = "Edit memory";
- form.elements.namedItem("session_name").value = m.session_name;
- form.elements.namedItem("session_name").readOnly = true;
- form.elements.namedItem("context_type").value = m.context_type;
- form.elements.namedItem("content").value = m.display_content ?? m.content;
- form.hidden = false;
- form.elements.namedItem("content").focus();
- });
- });
-
- el.querySelectorAll("[data-del-memory]").forEach((btn) => {
- btn.addEventListener("click", async () => {
- if (!confirm("Delete this memory permanently?")) return;
- try {
- await api(`api/memories/${btn.dataset.delMemory}`, { method: "DELETE" });
- toast("Memory deleted");
- loadMemories(true);
- loadSummary();
- } catch (e) {
- toast(e.message, true);
- }
- });
- });
- }
-
- const pager = $("#memories-pager");
- const end = Math.min(state.memoryOffset + data.items.length, data.total);
- const start = data.total === 0 ? 0 : state.memoryOffset + 1;
- pager.innerHTML = `
- Prev
- ${start}–${end} of ${data.total}
- = data.total ? "disabled" : ""}>Next
- `;
- $("#mem-prev")?.addEventListener("click", () => {
- state.memoryOffset = Math.max(0, state.memoryOffset - state.memoryLimit);
- loadMemories(false);
- });
- $("#mem-next")?.addEventListener("click", () => {
- state.memoryOffset += state.memoryLimit;
- loadMemories(false);
- });
-}
-
-async function loadLogs(reset = true) {
- if (reset) state.logOffset = 0;
- const q = $("#log-search")?.value.trim();
- const params = new URLSearchParams({
- limit: state.logLimit,
- offset: state.logOffset,
- });
- if (q) params.set("q", q);
- const data = await api(`api/logs?${params}`);
- state.logTotal = data.total;
- const el = $("#logs-list");
-
- if (!data.items.length) {
- el.innerHTML = 'No protocol log entries.
';
- } else {
- el.innerHTML = data.items
- .map(
- (l, i) => `
-
-
- ${escapeHtml(l.session_name)}
- ${escapeHtml(l.entry_date ? new Date(l.entry_date).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : "—")}
- ${escapeHtml(l.topic)}
-
- ${escapeHtml(l.display_summary ?? l.summary)}
-
- Full entry
- Delete
-
- `
- )
- .join("");
-
- const byId = Object.fromEntries(data.items.map((l) => [l.id, l]));
- el.querySelectorAll("[data-view-log]").forEach((btn) => {
- btn.addEventListener("click", () => {
- const l = byId[btn.dataset.viewLog];
- showDetail(
- `${l.session_name} · ${l.topic}`,
- l.display_full_entry ?? l.full_entry
- );
- });
- });
- el.querySelectorAll("[data-del-log]").forEach((btn) => {
- btn.addEventListener("click", async () => {
- if (!confirm("Delete this log entry?")) return;
- try {
- await api(`api/logs/${btn.dataset.delLog}`, { method: "DELETE" });
- toast("Log deleted");
- loadLogs(true);
- loadSummary();
- } catch (e) {
- toast(e.message, true);
- }
- });
- });
- }
-
- const pager = $("#logs-pager");
- const end = Math.min(state.logOffset + data.items.length, data.total);
- const start = data.total === 0 ? 0 : state.logOffset + 1;
- pager.innerHTML = `
- Prev
- ${start}–${end} of ${data.total}
- = data.total ? "disabled" : ""}>Next
- `;
- $("#log-prev")?.addEventListener("click", () => {
- state.logOffset = Math.max(0, state.logOffset - state.logLimit);
- loadLogs(false);
- });
- $("#log-next")?.addEventListener("click", () => {
- state.logOffset += state.logLimit;
- loadLogs(false);
- });
-}
-
-async function loadNotebook() {
- const q = $("#notebook-search")?.value.trim();
- const params = q ? `?q=${encodeURIComponent(q)}` : "";
- const { items } = await api(`api/notebook${params}`);
- state.notebookTotal = items.length;
- const el = $("#notebook-list");
- if (!items.length) {
- el.innerHTML = 'Notebook is empty.
';
- return;
- }
-
- el.innerHTML = items
- .map(
- (n, i) => `
-
- ${escapeHtml(n.name)}
- ${escapeHtml(n.display_preview ?? n.preview)}
- ${n.size_chars} chars · updated ${escapeHtml(formatDate(n.updated_at))}
-
- View
- Edit
- Delete
-
- `
- )
- .join("");
-
- const byName = Object.fromEntries(items.map((n) => [n.name, n]));
- el.querySelectorAll("[data-view-nb]").forEach((btn) => {
- btn.addEventListener("click", () => {
- const n = byName[btn.dataset.viewNb];
- showDetail(n.name, n.data);
- });
- });
- el.querySelectorAll("[data-edit-nb]").forEach((btn) => {
- btn.addEventListener("click", () => {
- const n = byName[btn.dataset.editNb];
- const form = $("#notebook-form");
- form.hidden = false;
- form.elements.namedItem("name").value = n.name;
- form.elements.namedItem("name").readOnly = true;
- form.elements.namedItem("data").value = n.data;
- });
- });
- el.querySelectorAll("[data-del-nb]").forEach((btn) => {
- btn.addEventListener("click", async () => {
- if (!confirm(`Delete notebook entry "${btn.dataset.delNb}"?`)) return;
- try {
- await api(`api/notebook/${encodeURIComponent(btn.dataset.delNb)}`, {
- method: "DELETE",
- });
- toast("Notebook entry deleted");
- loadNotebook();
- loadSummary();
- } catch (e) {
- toast(e.message, true);
- }
- });
- });
-}
-
-// ==================== Compaction (Phase 1: Manual) ====================
-
-async function loadCompaction(reset = true) {
- if (reset) state.compactionOffset = 0;
- const session = state.compactionSession || "";
- const params = new URLSearchParams({
- limit: state.compactionLimit,
- offset: state.compactionOffset,
- });
- if (session) params.set("session", session);
-
- try {
- const data = await api(`api/compaction/memories?${params}`);
- state.compactionTotal = data.total;
- renderCompactionList(data.items);
- renderCompactionPager();
- } catch (e) {
- toast(e.message, true);
- }
-}
-
-function renderCompactionList(items) {
- const list = $("#compaction-list");
- if (!items.length) {
- list.innerHTML = 'No memories available for compaction.
';
- return;
- }
- list.innerHTML = items.map((m) => `
-
-
-
-
-
- ${escapeHtml(m.session_name)}
- ${formatDate(m.timestamp)}
-
-
${escapeHtml(m.display_preview)}
-
-
-
- `).join('');
-
- // Wire up checkbox handlers
- list.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
- cb.addEventListener('change', (e) => {
- const id = e.target.dataset.memoryId;
- if (e.target.checked) {
- state.compactionSelected.add(id);
- } else {
- state.compactionSelected.delete(id);
- }
- updateCompactionButton();
- });
- });
-}
-
-function updateCompactionButton() {
- const btn = $("#compaction-compact-btn");
- const count = $("#compaction-count");
- const size = state.compactionSelected.size;
- count.textContent = size;
- btn.disabled = size === 0;
-}
-
-function renderCompactionPager() {
- const pager = $("#compaction-pager");
- const hasPrev = state.compactionOffset > 0;
- const hasNext = state.compactionOffset + state.compactionLimit < state.compactionTotal;
- const start = state.compactionTotal ? state.compactionOffset + 1 : 0;
- const end = Math.min(state.compactionOffset + state.compactionLimit, state.compactionTotal);
-
- pager.innerHTML = `
- Prev
- ${start}–${end} of ${state.compactionTotal}
- Next
- `;
-
- $("#compact-prev")?.addEventListener("click", () => {
- state.compactionOffset = Math.max(0, state.compactionOffset - state.compactionLimit);
- loadCompaction(false);
- });
- $("#compact-next")?.addEventListener("click", () => {
- state.compactionOffset += state.compactionLimit;
- loadCompaction(false);
- });
-}
-
-async function showCompactionPreview() {
- if (state.compactionSelected.size === 0) return;
-
- try {
- const memoryIds = Array.from(state.compactionSelected);
- const preview = await api("api/compaction/preview", {
- method: "POST",
- body: JSON.stringify({ memory_ids: memoryIds }),
- });
-
- state.compactionPreviewData = preview;
- renderCompactionPreview(preview);
- $("#compaction-preview-dialog").showModal();
- } catch (e) {
- toast(e.message, true);
- }
-}
-
-function renderCompactionPreview(preview) {
- // Defensive check for malformed response
- if (!preview?.sources_preview) {
- toast("Invalid preview data received", true);
- $("#compaction-preview-dialog").close();
- return;
- }
-
- $("#preview-sources").innerHTML = preview.sources_preview.map((s) => `
-
-
${formatDate(s.timestamp)}
-
${escapeHtml(s.content)}
-
- `).join('');
-
- $("#preview-summary").innerHTML = `${escapeHtml(preview.summary)}
`;
- $("#preview-source-count").textContent = preview.source_count;
- $("#preview-savings").textContent = preview.token_savings_estimate;
-}
-
-async function applyCompaction() {
- const preview = state.compactionPreviewData;
- if (!preview) return;
-
- try {
- const result = await api("api/compaction/apply", {
- method: "POST",
- body: JSON.stringify({
- memory_ids: preview.source_memory_ids,
- summary_content: preview.summary,
- session_name: preview.session_name,
- }),
- });
-
- toast(`✓ Compacted ${result.source_count} memories into 1 summary`);
- $("#compaction-preview-dialog").close();
-
- // Reset selection and reload
- state.compactionSelected.clear();
- state.compactionPreviewData = null;
- loadCompaction(true);
- loadSummary();
- } catch (e) {
- toast(e.message, true);
- }
-}
-
-function populateCompactionSessionFilter() {
- const filter = $("#compaction-session-filter");
- if (!filter) return;
-
- const options = state.sessions.map(s =>
- `${escapeHtml(s.session_name)} `
- ).join('');
-
- filter.innerHTML = 'All sessions ' + options;
-}
-
-// ==================== Maintenance (Phase 2) ====================
-
-async function loadMaintenance() {
- await loadMaintenanceSummary();
- await loadMaintenanceCandidates(true);
- setupMaintenanceSubtabs();
-}
-
-async function loadMaintenanceSummary() {
- try {
- const data = await api("api/maintenance/compaction-summary");
- renderMaintenanceStats(data);
- } catch (e) {
- toast(e.message, true);
- }
-}
-
-function renderMaintenanceStats(counts) {
- const stats = $("#maintenance-stats");
- stats.innerHTML = `
-
- ${counts.pending_summary || 0}
- Pending
-
-
- ${counts.summary_staged || 0}
- Staged
-
-
- ${counts.applied || 0}
- Applied
-
-
- ${counts.stale || 0}
- Stale
-
- `;
-}
-
-async function loadMaintenanceCandidates(reset = true) {
- if (reset) state.maintenanceOffset = 0;
- const params = new URLSearchParams({
- limit: state.maintenanceLimit,
- offset: state.maintenanceOffset,
- });
- if (state.maintenanceSession) params.set("session", state.maintenanceSession);
- if (state.maintenanceStatus) params.set("status", state.maintenanceStatus);
-
- try {
- const data = await api(`api/maintenance/candidates?${params}`);
- state.maintenanceTotal = data.total;
- renderMaintenanceCandidates(data.items);
- renderMaintenancePager();
- } catch (e) {
- toast(e.message, true);
- }
-}
-
-function renderMaintenanceCandidates(items) {
- const list = $("#maintenance-candidates-list");
- if (!items.length) {
- list.innerHTML = 'No compaction candidates.
';
- return;
- }
-
- list.innerHTML = items.map((c, i) => {
- const statusLabel = getStatusLabel(c.status);
- const statusClass = getStatusClass(c.status);
- return `
-
-
- ${statusLabel}
- ${escapeHtml(c.session_name)}
- ${c.source_count} memories
-
- ${escapeHtml(c.preview)}
-
- Created ${formatDate(c.created_at)} • Updated ${formatDate(c.updated_at)}
- ${c.nudge_count > 0 ? ` • ${c.nudge_count} nudges` : ''}
-
-
- Details
- ${c.status !== 'discarded' && c.status !== 'applied' ?
- `Discard ` : ''}
-
-
- `;
- }).join('');
-
- list.querySelectorAll('[data-view-candidate]').forEach(btn => {
- btn.addEventListener('click', () => {
- const id = btn.dataset.viewCandidate;
- const candidate = items.find(c => c.id === id);
- if (candidate) showCandidateDetail(candidate);
- });
- });
-
- list.querySelectorAll('[data-discard-candidate]').forEach(btn => {
- btn.addEventListener('click', async () => {
- const id = btn.dataset.discardCandidate;
- if (confirm('Discard this compaction candidate?')) {
- try {
- await api(`api/maintenance/candidates/${id}/discard`, { method: 'POST' });
- toast('✓ Candidate discarded');
- loadMaintenanceSummary();
- loadMaintenanceCandidates(false);
- } catch (e) {
- toast(e.message, true);
- }
- }
- });
- });
-}
-
-function getStatusLabel(status) {
- const labels = {
- 'pending_summary': 'Pending',
- 'summary_staged': 'Staged',
- 'applied': 'Applied',
- 'discarded': 'Discarded',
- 'stale': 'Stale',
- 'nudge_exhausted': 'Exhausted',
- };
- return labels[status] || status;
-}
-
-function getStatusClass(status) {
- if (status === 'applied') return 'ok';
- if (status === 'stale' || status === 'nudge_exhausted') return 'error';
- if (status === 'summary_staged') return 'session';
- return '';
-}
-
-function renderMaintenancePager() {
- const pager = $("#maintenance-pager");
- const hasPrev = state.maintenanceOffset > 0;
- const hasNext = state.maintenanceOffset + state.maintenanceLimit < state.maintenanceTotal;
- const start = state.maintenanceTotal ? state.maintenanceOffset + 1 : 0;
- const end = Math.min(state.maintenanceOffset + state.maintenanceLimit, state.maintenanceTotal);
-
- pager.innerHTML = `
- Prev
- ${start}–${end} of ${state.maintenanceTotal}
- Next
- `;
-
- $("#maintenance-prev")?.addEventListener("click", () => {
- state.maintenanceOffset = Math.max(0, state.maintenanceOffset - state.maintenanceLimit);
- loadMaintenanceCandidates(false);
- });
- $("#maintenance-next")?.addEventListener("click", () => {
- state.maintenanceOffset += state.maintenanceLimit;
- loadMaintenanceCandidates(false);
- });
-}
-
-function showCandidateDetail(candidate) {
- const body = $("#maintenance-detail-body");
- body.innerHTML = `
-
-
Status
-
${getStatusLabel(candidate.status)}
-
-
-
Session
-
${escapeHtml(candidate.session_name)}
-
-
-
Source Memories
-
${candidate.source_count} memories selected for compaction
-
${escapeHtml(candidate.preview)}
-
- ${candidate.suggested_summary ? `
-
-
Suggested Summary
-
${escapeHtml(candidate.suggested_summary)}
-
- ` : ''}
-
-
Timeline
-
Created: ${formatDate(candidate.created_at)}
-
Updated: ${formatDate(candidate.updated_at)}
- ${candidate.last_nudged_at ? `
Last Nudged: ${formatDate(candidate.last_nudged_at)}
` : ''}
-
Nudge Count: ${candidate.nudge_count}
-
- `;
- $("#maintenance-detail-dialog").showModal();
-}
-
-function populateMaintenanceSessionFilter() {
- const filter = $("#maintenance-session-filter");
- if (!filter) return;
-
- const options = state.sessions.map(s =>
- `${escapeHtml(s.session_name)} `
- ).join('');
-
- filter.innerHTML = 'All sessions ' + options;
-}
-
-function setupMaintenanceSubtabs() {
- document.querySelectorAll(".maintenance-tab").forEach((btn) => {
- btn.addEventListener("click", () => {
- const section = btn.dataset.section;
-
- // Update tab active state
- document.querySelectorAll(".maintenance-tab").forEach((t) =>
- t.classList.toggle("active", t === btn)
- );
-
- // Show/hide sections
- document.querySelectorAll(".maintenance-section").forEach((s) => {
- s.hidden = !s.id.includes(section);
- });
-
- // Load data for the selected section
- if (section === "candidates") {
- loadMaintenanceCandidates(true);
- } else if (section === "logs") {
- loadLogs(true);
- }
- });
- });
-}
-
-function setupForms() {
- function resetMemoryForm() {
- const form = $("#memory-add-form");
- delete form.dataset.editingId;
- form.querySelector("h3").textContent = "New memory";
- form.elements.namedItem("session_name").readOnly = false;
- form.reset();
- form.hidden = true;
- }
-
- $("#memory-add-toggle").addEventListener("click", () => {
- const form = $("#memory-add-form");
- if (!form.hidden && !form.dataset.editingId) { resetMemoryForm(); return; }
- resetMemoryForm();
- form.hidden = false;
- form.elements.namedItem("session_name").focus();
- });
- $("#memory-add-cancel").addEventListener("click", resetMemoryForm);
-
- $("#memory-add-form").addEventListener("submit", async (e) => {
- e.preventDefault();
- const fd = new FormData(e.target);
- const editingId = e.target.dataset.editingId;
- try {
- if (editingId) {
- await api(`api/memories/${editingId}`, {
- method: "PUT",
- body: JSON.stringify({
- content: fd.get("content"),
- context_type: fd.get("context_type"),
- }),
- });
- toast("Memory updated");
- } else {
- await api("api/memories", {
- method: "POST",
- body: JSON.stringify({
- content: fd.get("content"),
- session_name: fd.get("session_name"),
- context_type: fd.get("context_type"),
- }),
- });
- toast("Memory saved");
- loadSessions();
- }
- resetMemoryForm();
- loadMemories(true);
- loadSummary();
- } catch (err) {
- toast(err.message, true);
- }
- });
-
- $("#memory-session-chip-clear").addEventListener("click", () => {
- state.memorySession = "";
- loadMemories(true);
- });
-
- $("#memory-delete-all").addEventListener("click", async () => {
- const count = state.memoryTotal;
- const ok = await showConfirm(
- `Delete ${count} ${count === 1 ? "memory" : "memories"}?`,
- "This will permanently delete all memories across all sessions. This cannot be undone."
- );
- if (!ok) return;
- try {
- const res = await api("api/memories", { method: "DELETE" });
- toast(`Deleted ${res.count} memories`);
- loadMemories(true);
- loadSummary();
- loadSessions();
- } catch (e) {
- toast(e.message, true);
- }
- });
-
- $("#sessions-delete-all").addEventListener("click", async () => {
- const count = state.sessions.length;
- const ok = await showConfirm(
- `Delete ${count} ${count === 1 ? "session" : "sessions"}?`,
- "This removes all session records. Memories and logs are not deleted."
- );
- if (!ok) return;
- try {
- const res = await api("api/sessions", { method: "DELETE" });
- toast(`Deleted ${res.count} sessions`);
- loadSessions();
- loadSummary();
- } catch (e) {
- toast(e.message, true);
- }
- });
-
- $("#notebook-delete-all").addEventListener("click", async () => {
- const count = state.notebookTotal;
- const ok = await showConfirm(
- `Delete ${count} notebook ${count === 1 ? "entry" : "entries"}?`,
- "This will permanently delete all notebook entries. This cannot be undone."
- );
- if (!ok) return;
- try {
- const res = await api("api/notebook", { method: "DELETE" });
- toast(`Deleted ${res.count} notebook entries`);
- loadNotebook();
- loadSummary();
- } catch (e) {
- toast(e.message, true);
- }
- });
-
- let searchTimer;
- $("#memory-search").addEventListener("input", () => {
- clearTimeout(searchTimer);
- searchTimer = setTimeout(() => loadMemories(true), 300);
- });
- $("#memory-refresh").addEventListener("click", () => loadMemories(true));
-
- let sessionsSearchTimer;
- $("#sessions-search").addEventListener("input", () => {
- clearTimeout(sessionsSearchTimer);
- sessionsSearchTimer = setTimeout(() => loadSessions(), 300);
- });
- $("#sessions-refresh").addEventListener("click", () => loadSessions());
-
- $("#sessions-add-toggle").addEventListener("click", () => {
- const form = $("#session-add-form");
- form.hidden = false;
- form.elements.namedItem("session_name").focus();
- });
- $("#sessions-add-cancel").addEventListener("click", () => {
- $("#session-add-form").hidden = true;
- });
- $("#session-add-form").addEventListener("submit", async (e) => {
- e.preventDefault();
- const fd = new FormData(e.target);
- try {
- await api("api/sessions", {
- method: "POST",
- body: JSON.stringify({ session_name: fd.get("session_name") }),
- });
- toast("Session created");
- e.target.reset();
- e.target.hidden = true;
- loadSessions();
- loadSummary();
- } catch (err) {
- toast(err.message, true);
- }
- });
-
- $("#logs-refresh").addEventListener("click", () => loadLogs(true));
-
- let logSearchTimer;
- $("#log-search").addEventListener("input", () => {
- clearTimeout(logSearchTimer);
- logSearchTimer = setTimeout(() => loadLogs(true), 300);
- });
-
- $("#logs-delete-all").addEventListener("click", async () => {
- const count = state.logTotal;
- const ok = await showConfirm(
- `Delete ${count} log ${count === 1 ? "entry" : "entries"}?`,
- "This will permanently delete all protocol log entries. This cannot be undone."
- );
- if (!ok) return;
- try {
- const res = await api("api/logs", { method: "DELETE" });
- toast(`Deleted ${res.count} log entries`);
- loadLogs(true);
- loadSummary();
- } catch (e) {
- toast(e.message, true);
- }
- });
-
- let notebookSearchTimer;
- $("#notebook-search").addEventListener("input", () => {
- clearTimeout(notebookSearchTimer);
- notebookSearchTimer = setTimeout(() => loadNotebook(), 300);
- });
-
- $("#notebook-add-toggle").addEventListener("click", () => {
- const form = $("#notebook-form");
- form.hidden = false;
- form.reset();
- form.elements.namedItem("name").readOnly = false;
- form.elements.namedItem("name").focus();
- });
- $("#notebook-cancel").addEventListener("click", () => {
- $("#notebook-form").hidden = true;
- });
- $("#notebook-refresh").addEventListener("click", loadNotebook);
-
- // Compaction handlers
- $("#compaction-refresh").addEventListener("click", () => loadCompaction(true));
-
- $("#compaction-session-filter").addEventListener("change", (e) => {
- state.compactionSession = e.target.value;
- loadCompaction(true);
- });
-
- $("#compaction-compact-btn").addEventListener("click", showCompactionPreview);
-
- $("#compaction-preview-dialog").addEventListener("close", (e) => {
- const returnValue = e.target.returnValue;
- if (returnValue === "proceed") {
- applyCompaction();
- }
- });
-
- // Maintenance handlers
- $("#maintenance-refresh").addEventListener("click", () => {
- loadMaintenanceSummary();
- loadMaintenanceCandidates(true);
- });
-
- $("#maintenance-session-filter").addEventListener("change", (e) => {
- state.maintenanceSession = e.target.value;
- loadMaintenanceCandidates(true);
- });
-
- $("#maintenance-status-filter").addEventListener("change", (e) => {
- state.maintenanceStatus = e.target.value;
- loadMaintenanceCandidates(true);
- });
-
- $("#notebook-form").addEventListener("submit", async (e) => {
- e.preventDefault();
- const fd = new FormData(e.target);
- try {
- await api("api/notebook", {
- method: "POST",
- body: JSON.stringify({ name: fd.get("name"), data: fd.get("data") }),
- });
- toast("Notebook saved");
- e.target.hidden = true;
- loadNotebook();
- loadSummary();
- } catch (err) {
- toast(err.message, true);
- }
- });
-}
-
-async function initDashboard() {
- try {
- await loadSummary();
- } catch (e) {
- if (e.message === "Authentication required") return;
- setHeaderStatus("offline");
- $("#stats-grid").innerHTML = "";
- $("#status-dl").innerHTML = `${escapeHtml(e.message)}
`;
- toast(e.message, true);
- return;
- }
- try {
- await loadSessions();
- // Populate compaction session filter
- populateCompactionSessionFilter();
- populateMaintenanceSessionFilter();
- } catch (e) {
- toast(`Could not load sessions: ${e.message}`, true);
- }
-}
-
-async function init() {
- setupTabs();
- setupForms();
- setupUnlock();
-
- const health = await fetch(`health`).then((r) => r.json()).catch(() => ({}));
- $("#loading-screen").hidden = true;
-
- if (health.auth_required && !getStoredKey()) {
- setHeaderStatus("locked");
- showUnlock();
- return;
- }
-
- if (health.auth_required && getStoredKey()) {
- const check = await fetch(`api/summary`, { headers: authHeaders() });
- if (check.status === 401) {
- setHeaderStatus("locked");
- showUnlock("Invalid or expired key.");
- return;
- }
- }
-
- hideUnlock();
- await initDashboard();
-}
-
-init();
diff --git a/marm-mcp-server/marm_dashboard/static/index.html b/marm-mcp-server/marm_dashboard/static/index.html
deleted file mode 100644
index 89b89bb1..00000000
--- a/marm-mcp-server/marm_dashboard/static/index.html
+++ /dev/null
@@ -1,251 +0,0 @@
-
-
-
-
-
- MARM Dashboard
-
-
-
-
-
-
-
-
M
-
-
MARM Dashboard
-
Local memory browser
-
-
-
-
-
-
- Overview
- Memories
- Sessions
- Compaction
- Notebook
- Maintenance
-
-
-
-
-
-
- Status
-
- Dashboard (:8002) is a direct SQLite admin UI for the same database used by MCP (:8001). MCP can be off while this page stays live.
-
-
-
-
-
-
-
-
-
-
- All sessions
-
- Refresh
- Compact Selected (0 )
-
-
-
-
-
-
-
-
- Compaction Pipeline
-
-
-
- Candidates
- Protocol Logs
-
-
-
-
-
- All sessions
-
-
- All statuses
- Pending
- Staged
- Applied
- Discarded
- Stale
- Nudge Exhausted
-
- Refresh
-
-
-
-
-
-
-
-
- Refresh
- Delete all
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Candidate Details
- Close
-
-
-
-
-
-
-
-
-
-
-
-
-
- Cancel
- Delete all
-
-
-
-
-
-
-
-
- Compaction Preview
- Cancel
-
-
-
-
Before (Source Memories)
-
-
-
-
- 📝 memories
- 💾 savings
-
-
- Cancel
- Proceed
-
-
-
-
-
-
-
-
-
- Unlock dashboard
- Use the same MARM_API_KEY as your MCP server (Docker / ~/.marm/.env).
-
- API key
- Unlock
-
-
-
-
-
-
-
-
diff --git a/marm-mcp-server/marm_mcp_server/__init__.py b/marm-mcp-server/marm_mcp_server/__init__.py
index c0d0ddca..f951a892 100644
--- a/marm-mcp-server/marm_mcp_server/__init__.py
+++ b/marm-mcp-server/marm_mcp_server/__init__.py
@@ -14,10 +14,10 @@
- Production-grade performance
Author: Ryan Lyell - marm-memory
-Version: 2.22.1
+Version: 2.23.0
"""
-__version__ = "2.22.1"
+__version__ = "2.23.0"
__author__ = "Ryan Lyell"
__email__ = "lyell@marmsystems.com"
diff --git a/marm-mcp-server/marm_mcp_server/config/settings.py b/marm-mcp-server/marm_mcp_server/config/settings.py
index 2de5d251..bfeab692 100644
--- a/marm-mcp-server/marm_mcp_server/config/settings.py
+++ b/marm-mcp-server/marm_mcp_server/config/settings.py
@@ -121,7 +121,7 @@ def get_analytics_db_path():
f"WARNING: SERVER_PORT={_raw_port} out of [1, 65535], clamped to {SERVER_PORT}",
file=sys.stderr,
)
-SERVER_VERSION = "2.22.1"
+SERVER_VERSION = "2.23.0"
GRAPH_ENABLED = os.environ.get("GRAPH_ENABLED", "true").lower() != "false"
diff --git a/marm-mcp-server/marm_mcp_server/core/dashboard_mount.py b/marm-mcp-server/marm_mcp_server/core/dashboard_mount.py
deleted file mode 100644
index a4bc020e..00000000
--- a/marm-mcp-server/marm_mcp_server/core/dashboard_mount.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""Optional marm-dashboard sub-app mount for the unified server.
-
-The dashboard package is bundled with marm-mcp-server. `get_dashboard_app()`
-still degrades to None on import failure so a dashboard issue cannot take down
-the memory/graph MCP server.
-"""
-
-from typing import Optional
-
-import structlog
-from fastapi import FastAPI
-
-logger = structlog.get_logger()
-
-
-def get_dashboard_app() -> Optional[FastAPI]:
- try:
- from marm_dashboard.server import app as dashboard_app
-
- return dashboard_app
- except ImportError:
- return None # not installed in this build variant
- except Exception as e:
- # Any other import-time failure (bad config, unwritable DB path, etc.)
- # must not take memory/graph down with it -- same degrade-not-crash
- # posture as graph_supervisor's own failure handling.
- logger.warning("dashboard.mount_failed", error=str(e))
- return None
diff --git a/marm-mcp-server/marm_mcp_server/core/memory_ops.py b/marm-mcp-server/marm_mcp_server/core/memory_ops.py
index b2cb8328..a54505fa 100644
--- a/marm-mcp-server/marm_mcp_server/core/memory_ops.py
+++ b/marm-mcp-server/marm_mcp_server/core/memory_ops.py
@@ -50,10 +50,6 @@ async def _update_memory(mem, memory_id: str, new_content: str) -> bool:
write lock -- callers must not assume the merge landed just because this
returned without raising.
"""
- # Unlocked pre-read -- matches _store_memory's duplicate pre-check
- # convention. This is a read-then-write, but the write lock is only
- # acquired later, right before the actual UPDATE, so this read must
- # be re-verified under the lock before it's trusted (see below).
with mem.get_connection() as conn:
row = conn.execute(
"SELECT content, metadata FROM memories WHERE id = ?", (memory_id,)
@@ -83,12 +79,6 @@ async def _update_memory(mem, memory_id: str, new_content: str) -> bool:
merged_hash = compute_content_hash(merged_content)
- # Compute the embedding before acquiring the write lock, same as
- # _store_memory/_replace_memory -- embedding work can be slow, and
- # holding BEGIN IMMEDIATE's write lock across an await would block
- # every other writer (sqlite3 calls are synchronous, so a concurrent
- # BEGIN IMMEDIATE on another connection can stall the whole event
- # loop) for the duration.
merged_embedding_bytes = None
encoder_ok = merged_content.strip() and mem._load_encoder_lazily()
if encoder_ok:
@@ -101,14 +91,6 @@ async def _update_memory(mem, memory_id: str, new_content: str) -> bool:
with mem.get_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
try:
- # Re-check under the lock: the row may have been deleted, or
- # its content OR metadata changed by a concurrent writer,
- # since the unlocked read above -- metadata-only writers
- # exist in this file too (_restore_sources_from_deleted_summary,
- # _update_summary_metadata_after_delete), so content alone
- # isn't enough to catch every concurrent write. Bail rather
- # than risk clobbering a concurrent update with a merge based
- # on stale data.
current = conn.execute(
"SELECT content, metadata FROM memories WHERE id = ?", (memory_id,)
).fetchone()
diff --git a/marm-mcp-server/marm_mcp_server/middleware/auth.py b/marm-mcp-server/marm_mcp_server/middleware/auth.py
index 1b4f3f7b..66accd72 100644
--- a/marm-mcp-server/marm_mcp_server/middleware/auth.py
+++ b/marm-mcp-server/marm_mcp_server/middleware/auth.py
@@ -8,16 +8,6 @@
PUBLIC_PREFIXES = ("/openapi",)
-def _is_dashboard_path(path: str) -> bool:
- """/dashboard and /dashboard/* only -- not /dashboardevil or similar.
-
- Exempt from this gate because the mounted marm-dashboard sub-app runs its
- own independent MARM_API_KEY check (marm_dashboard/auth.py); this only
- avoids double-gating it, not skipping auth entirely.
- """
- return path == "/dashboard" or path.startswith("/dashboard/")
-
-
async def auth_middleware(request: Request, call_next):
"""
Two-mode auth gate:
@@ -25,11 +15,7 @@ async def auth_middleware(request: Request, call_next):
- MARM_API_KEY set: require Authorization: Bearer on all non-public routes.
"""
path = request.url.path
- if (
- path in PUBLIC_PATHS
- or path.startswith(PUBLIC_PREFIXES)
- or _is_dashboard_path(path)
- ):
+ if path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES):
return await call_next(request)
if not MARM_API_KEY:
diff --git a/marm-mcp-server/marm_mcp_server/server.py b/marm-mcp-server/marm_mcp_server/server.py
index 3167eb2d..004daa58 100644
--- a/marm-mcp-server/marm_mcp_server/server.py
+++ b/marm-mcp-server/marm_mcp_server/server.py
@@ -5,7 +5,7 @@
FastAPI application, compliant with the MCP protocol via FastApiMCP.
Author: Lyell - marm-memory
-Version: 2.22.1
+Version: 2.23.0
"""
import asyncio
@@ -23,7 +23,6 @@
SERVER_VERSION,
)
from .core.compaction_scheduler import _maybe_start_compaction_scheduler
-from .core.dashboard_mount import get_dashboard_app
from .core.graph_supervisor import graph_supervisor
from .core.memory import memory
from .endpoints.compaction import router as compaction_router
@@ -115,15 +114,9 @@ def get_memory_usage():
app.include_router(graph_router)
app.include_router(concepts_router)
-_dashboard_app = get_dashboard_app()
-if _dashboard_app is not None:
- app.mount("/dashboard", _dashboard_app)
-
-# Explicit whitelist as defense-in-depth: mounted sub-app routes (dashboard)
-# already never appear in FastApiMCP's OpenAPI-derived tool list, but this
-# matches marm-graph's own stricter whitelist pattern instead of relying on
-# that alone.
+# Explicit whitelist as defense-in-depth: marm-graph/internal routes must not
+# appear in FastApiMCP's OpenAPI-derived tool list unless intentionally exposed.
MCP_TOOL_OPERATIONS = [
"marm_smart_recall",
"marm_log_entry",
diff --git a/marm-mcp-server/pyproject.toml b/marm-mcp-server/pyproject.toml
index 77a033d3..d6ae9e10 100644
--- a/marm-mcp-server/pyproject.toml
+++ b/marm-mcp-server/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "marm-mcp-server"
-version = "2.22.1"
+version = "2.23.0"
description = "marm-memory is a local-first MCP memory layer with hybrid recall, agent coordination, code intelligence, and persistent project context."
readme = "README.md"
license = "Apache-2.0"
@@ -79,12 +79,11 @@ dev = [
[tool.setuptools.packages.find]
where = ["."]
-include = ["marm_mcp_server*", "marm_graph*", "marm_dashboard*"]
+include = ["marm_mcp_server*", "marm_graph*"]
exclude = ["tests*", "*.tests*", "docs*"]
[tool.setuptools.package-data]
"*" = ["*.json", "*.yaml", "*.yml", "config/*", "templates/*"]
-"marm_dashboard" = ["static/**/*"]
[tool.black]
line-length = 88
diff --git a/marm-mcp-server/server.json b/marm-mcp-server/server.json
index aa588cb9..cb745396 100644
--- a/marm-mcp-server/server.json
+++ b/marm-mcp-server/server.json
@@ -3,7 +3,7 @@
"_schema_date": "2025-12-11",
"name": "io.github.Lyellr88/marm-mcp-server",
"description": "Universal MCP Server with advanced AI memory capabilities and semantic search.",
- "version": "2.22.1",
+ "version": "2.23.0",
"author": "Ryan Lyell - marm-memory",
"license": "Apache-2.0",
"homepage": "https://marmsystems.com",
@@ -17,12 +17,12 @@
{
"registryType": "pypi",
"identifier": "marm-mcp-server",
- "version": "2.22.1",
+ "version": "2.23.0",
"transport": { "type": "stdio" }
},
{
"registryType": "oci",
- "identifier": "lyellr88/marm-mcp-server:2.22.1",
+ "identifier": "lyellr88/marm-mcp-server:2.23.0",
"transport": { "type": "stdio" }
}
],
diff --git a/marm-mcp-server/tests/conftest.py b/marm-mcp-server/tests/conftest.py
index 9c1e3c7a..8b5838ba 100644
--- a/marm-mcp-server/tests/conftest.py
+++ b/marm-mcp-server/tests/conftest.py
@@ -2,7 +2,6 @@
import importlib
import os
-import sqlite3
import sys
from pathlib import Path
@@ -38,12 +37,7 @@ def _resolve_cbm_binary() -> str | None:
def load_isolated_server(monkeypatch, tmp_path, api_key="", write_queue_enabled=False):
"""Import the server after pointing global state at a temporary database."""
for name in list(sys.modules):
- if (
- name == "marm_mcp_server"
- or name.startswith("marm_mcp_server.")
- or name == "marm_dashboard"
- or name.startswith("marm_dashboard.")
- ):
+ if name == "marm_mcp_server" or name.startswith("marm_mcp_server."):
del sys.modules[name]
monkeypatch.setenv("MARM_DB_PATH", str(tmp_path / "marm_memory.db"))
@@ -69,90 +63,6 @@ def load_isolated_server(monkeypatch, tmp_path, api_key="", write_queue_enabled=
return server
-def init_dashboard_db(path):
- with sqlite3.connect(path) as conn:
- conn.executescript(
- """
- CREATE TABLE sessions (
- session_name TEXT PRIMARY KEY,
- marm_active BOOLEAN DEFAULT FALSE,
- last_accessed TEXT,
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE memories (
- id TEXT PRIMARY KEY,
- session_name TEXT NOT NULL,
- content TEXT NOT NULL,
- embedding BLOB,
- timestamp TEXT NOT NULL,
- context_type TEXT DEFAULT 'general',
- metadata TEXT,
- compaction_role TEXT DEFAULT NULL,
- compacted_into TEXT DEFAULT NULL,
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE log_entries (
- id TEXT PRIMARY KEY,
- session_name TEXT NOT NULL,
- entry_date TEXT NOT NULL,
- topic TEXT NOT NULL,
- summary TEXT NOT NULL,
- full_entry TEXT NOT NULL,
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE notebook_entries (
- name TEXT PRIMARY KEY,
- data TEXT NOT NULL,
- embedding BLOB,
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
- updated_at TEXT DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE compaction_staging (
- id TEXT PRIMARY KEY,
- session_name TEXT NOT NULL,
- source_memory_ids TEXT NOT NULL,
- preview TEXT NOT NULL,
- suggested_summary TEXT,
- status TEXT NOT NULL DEFAULT 'pending_summary',
- candidate_hash TEXT NOT NULL,
- source_updated_at_snapshot TEXT NOT NULL,
- expires_at TEXT NOT NULL,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- reviewed_at TEXT,
- nudge_count INTEGER NOT NULL DEFAULT 0,
- last_nudged_at TEXT
- );
- """
- )
-
-
-def load_dashboard(monkeypatch, tmp_path, api_key=""):
- for name in list(sys.modules):
- if name == "marm_dashboard" or name.startswith("marm_dashboard."):
- del sys.modules[name]
-
- db_path = tmp_path / "marm_memory.db"
- init_dashboard_db(db_path)
- monkeypatch.setenv("MARM_DB_PATH", str(db_path))
- monkeypatch.setenv("MARM_DASHBOARD_HOST", "127.0.0.1")
- monkeypatch.setenv("HOME", str(tmp_path))
- monkeypatch.setenv("USERPROFILE", str(tmp_path))
- if api_key:
- monkeypatch.setenv("MARM_API_KEY", api_key)
- else:
- monkeypatch.delenv("MARM_API_KEY", raising=False)
-
- server = importlib.import_module("marm_dashboard.server")
- db_module = importlib.import_module("marm_dashboard.db")
- db_module._ENCODER_FAILED = True
- return server
-
-
def local_client(app):
return TestClient(app, client=("127.0.0.1", 50000))
diff --git a/marm-mcp-server/tests/test_concept_endpoints.py b/marm-mcp-server/tests/test_concept_endpoints.py
index 8374c070..e39133de 100644
--- a/marm-mcp-server/tests/test_concept_endpoints.py
+++ b/marm-mcp-server/tests/test_concept_endpoints.py
@@ -834,7 +834,7 @@ def test_get_concept_db_lock_serializes_concurrent_first_calls(
"""Both tools dispatch via asyncio.to_thread, so first-use is genuinely
concurrent-capable. Before the lock, two concurrent first calls could
each construct (and one leak) a ConceptDB/connection pool -- mirrors
- test_dashboard_encoder_lock.py's proven mutation-test pattern."""
+ the existing encoder-lock mutation-test pattern."""
import threading
_server, concepts, _memory_module = concepts_env
diff --git a/marm-mcp-server/tests/test_dashboard_auth.py b/marm-mcp-server/tests/test_dashboard_auth.py
deleted file mode 100644
index e775b63c..00000000
--- a/marm-mcp-server/tests/test_dashboard_auth.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from conftest import load_dashboard, local_client, remote_client
-
-
-def test_loopback_without_key_can_use_api_but_remote_is_blocked(monkeypatch, tmp_path):
- server = load_dashboard(monkeypatch, tmp_path)
-
- local = local_client(server.app)
- remote = remote_client(server.app)
-
- assert local.get("/api/summary").status_code == 200
- blocked = remote.get("/api/summary")
- assert blocked.status_code == 401
- assert "MARM_API_KEY" in blocked.json()["message"]
-
-
-def test_remote_client_cannot_spoof_loopback_via_forwarded_headers(
- monkeypatch, tmp_path
-):
- """A remote direct TCP peer must not bypass the loopback-only gate just by
- sending X-Forwarded-For: 127.0.0.1 -- those headers are only trusted when
- the direct connection itself is already a known local proxy.
- """
- server = load_dashboard(monkeypatch, tmp_path)
- remote = remote_client(server.app)
-
- spoofed = remote.get("/api/summary", headers={"X-Forwarded-For": "127.0.0.1"})
- assert spoofed.status_code == 401
-
- spoofed_real_ip = remote.get("/api/summary", headers={"X-Real-IP": "127.0.0.1"})
- assert spoofed_real_ip.status_code == 401
-
-
-def test_key_mode_requires_bearer_token_and_unlock_validates_key(monkeypatch, tmp_path):
- server = load_dashboard(monkeypatch, tmp_path, api_key="dash-key-123")
- client = local_client(server.app)
-
- assert client.get("/").status_code == 200
- assert client.get("/health").json()["auth_required"] is True
- assert client.post("/api/auth/unlock", json={"api_key": "wrong"}).status_code == 401
- assert client.post("/api/auth/unlock", json={"api_key": "dash-key-123"}).json() == {
- "ok": True,
- "auth_required": True,
- }
-
- missing = client.get("/api/summary")
- wrong = client.get("/api/summary", headers={"Authorization": "Bearer wrong"})
- correct = client.get(
- "/api/summary", headers={"Authorization": "Bearer dash-key-123"}
- )
-
- assert missing.status_code == 401
- assert missing.headers["www-authenticate"] == "Bearer"
- assert wrong.status_code == 401
- assert correct.status_code == 200
-
-
-def test_security_headers_are_added_to_html_api_and_errors(monkeypatch, tmp_path):
- server = load_dashboard(monkeypatch, tmp_path, api_key="dash-key-123")
- client = local_client(server.app)
-
- responses = [
- client.get("/"),
- client.get("/health"),
- client.get("/api/summary"),
- ]
-
- for response in responses:
- assert response.headers["cache-control"] == "no-store"
- assert response.headers["x-content-type-options"] == "nosniff"
- assert response.headers["x-frame-options"] == "DENY"
- assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
diff --git a/marm-mcp-server/tests/test_dashboard_compaction.py b/marm-mcp-server/tests/test_dashboard_compaction.py
deleted file mode 100644
index b24081a8..00000000
--- a/marm-mcp-server/tests/test_dashboard_compaction.py
+++ /dev/null
@@ -1,583 +0,0 @@
-import sqlite3
-from datetime import datetime, timedelta, timezone
-
-from conftest import load_dashboard, local_client
-
-
-def _future_iso(days=30):
- return (
- (datetime.now(timezone.utc) + timedelta(days=days))
- .isoformat()
- .replace("+00:00", "Z")
- )
-
-
-def test_compaction_memories_list_excludes_compacted_sources(monkeypatch, tmp_path):
- """Eligible memories exclude those already marked as compacted sources."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
-
- with sqlite3.connect(db_path) as conn:
- conn.executemany(
- "INSERT INTO memories (id, session_name, content, timestamp, context_type, compaction_role, compacted_into) VALUES (?, ?, ?, ?, ?, ?, ?)",
- [
- (
- "m1",
- "main",
- "eligible memory 1",
- "2026-01-01T10:00:00Z",
- "general",
- None,
- None,
- ),
- (
- "m2",
- "main",
- "eligible memory 2",
- "2026-01-01T11:00:00Z",
- "general",
- None,
- None,
- ),
- (
- "m3",
- "main",
- "compacted source",
- "2026-01-01T09:00:00Z",
- "general",
- "source",
- "summary-1",
- ),
- (
- "summary-1",
- "main",
- "this is summary",
- "2026-01-01T12:00:00Z",
- "general",
- "summary",
- None,
- ),
- ],
- )
- conn.commit()
-
- result = client.get("/api/compaction/memories", params={"session": "main"}).json()
-
- assert result["total"] == 2
- ids = {item["id"] for item in result["items"]}
- assert ids == {"m1", "m2"}
- assert "m3" not in ids
- assert "summary-1" not in ids
-
-
-def test_compaction_preview_generates_summary_with_savings(monkeypatch, tmp_path):
- """Preview endpoint returns summary, source count, and token savings estimate."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
-
- with sqlite3.connect(db_path) as conn:
- conn.executemany(
- "INSERT INTO memories (id, session_name, content, timestamp, context_type) VALUES (?, ?, ?, ?, ?)",
- [
- (
- "00000000-0000-4000-8000-000000000001",
- "preview-test",
- "Memory about Docker deployment process with detailed steps",
- "2026-01-01T10:00:00Z",
- "general",
- ),
- (
- "00000000-0000-4000-8000-000000000002",
- "preview-test",
- "Another memory discussing Docker configuration settings",
- "2026-01-01T11:00:00Z",
- "general",
- ),
- (
- "00000000-0000-4000-8000-000000000003",
- "preview-test",
- "Final memory covering Docker troubleshooting techniques",
- "2026-01-01T12:00:00Z",
- "general",
- ),
- ],
- )
- conn.commit()
-
- preview = client.post(
- "/api/compaction/preview",
- json={
- "memory_ids": [
- "00000000-0000-4000-8000-000000000001",
- "00000000-0000-4000-8000-000000000002",
- "00000000-0000-4000-8000-000000000003",
- ]
- },
- ).json()
-
- assert preview["source_count"] == 3
- assert "summary" in preview
- assert len(preview["summary"]) > 0
- assert "token_savings_estimate" in preview
- assert "%" in preview["token_savings_estimate"]
- assert "sources_preview" in preview
- assert len(preview["sources_preview"]) == 3
-
-
-def test_compaction_preview_rejects_invalid_uuids(monkeypatch, tmp_path):
- """Preview endpoint validates memory IDs to prevent SQL injection."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
-
- # Invalid UUID formats should return 400
- response = client.post(
- "/api/compaction/preview",
- json={
- "memory_ids": [
- "123e4567-e89b-12d3-a456-426614174000",
- "'; DROP TABLE memories; --",
- ]
- },
- )
-
- assert response.status_code == 400
- assert "invalid" in response.json()["detail"].lower()
-
-
-def test_compaction_apply_creates_summary_and_marks_sources(monkeypatch, tmp_path):
- """Apply endpoint creates summary memory and marks sources as compacted."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
-
- with sqlite3.connect(db_path) as conn:
- conn.executemany(
- "INSERT INTO memories (id, session_name, content, timestamp, context_type) VALUES (?, ?, ?, ?, ?)",
- [
- (
- "00000000-0000-4000-8000-000000000011",
- "apply-test",
- "source memory 1",
- "2026-01-01T10:00:00Z",
- "general",
- ),
- (
- "00000000-0000-4000-8000-000000000012",
- "apply-test",
- "source memory 2",
- "2026-01-01T11:00:00Z",
- "general",
- ),
- ],
- )
- conn.commit()
-
- result = client.post(
- "/api/compaction/apply",
- json={
- "memory_ids": [
- "00000000-0000-4000-8000-000000000011",
- "00000000-0000-4000-8000-000000000012",
- ],
- "summary_content": "Combined summary of both memories",
- "session_name": "apply-test",
- },
- ).json()
-
- assert result["status"] == "applied"
- assert result["source_count"] == 2
- summary_id = result["summary_id"]
-
- with sqlite3.connect(db_path) as conn:
- # Verify summary was created
- summary = conn.execute(
- "SELECT id, session_name, content, compaction_role FROM memories WHERE id = ?",
- (summary_id,),
- ).fetchone()
- assert summary is not None
- assert summary[2] == "Combined summary of both memories" # content
- assert summary[3] == "summary" # compaction_role
-
- # Verify sources were marked
- sources = conn.execute(
- "SELECT id, compaction_role, compacted_into FROM memories WHERE id IN ('00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000012')"
- ).fetchall()
- assert len(sources) == 2
- for row in sources:
- assert row[1] == "source"
- assert row[2] == summary_id
-
- # Verify staging record was created with actual source memory IDs
- staging_row = conn.execute(
- "SELECT status, source_memory_ids FROM compaction_staging WHERE session_name = ?",
- ("apply-test",),
- ).fetchone()
- assert staging_row is not None
- assert staging_row[0] == "applied"
-
- # Verify source_memory_ids contains the actual source IDs, not summary_id
- import json as test_json
-
- source_ids = test_json.loads(staging_row[1])
- assert set(source_ids) == {
- "00000000-0000-4000-8000-000000000011",
- "00000000-0000-4000-8000-000000000012",
- }
-
-
-def test_compaction_apply_rejects_cross_session_memories(monkeypatch, tmp_path):
- """Apply endpoint rejects memories from different sessions."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
-
- with sqlite3.connect(db_path) as conn:
- conn.executemany(
- "INSERT INTO memories (id, session_name, content, timestamp, context_type) VALUES (?, ?, ?, ?, ?)",
- [
- (
- "00000000-0000-4000-8000-000000000021",
- "session-a",
- "memory in session A",
- "2026-01-01T10:00:00Z",
- "general",
- ),
- (
- "00000000-0000-4000-8000-000000000022",
- "session-b",
- "memory in session B",
- "2026-01-01T11:00:00Z",
- "general",
- ),
- ],
- )
- conn.commit()
-
- response = client.post(
- "/api/compaction/apply",
- json={
- "memory_ids": [
- "00000000-0000-4000-8000-000000000021",
- "00000000-0000-4000-8000-000000000022",
- ],
- "summary_content": "invalid cross-session summary",
- "session_name": "session-a",
- },
- )
-
- assert response.status_code == 400
- assert "session" in response.json()["detail"].lower()
-
-
-def test_compaction_memories_pagination_works(monkeypatch, tmp_path):
- """Compaction memories list supports limit and offset pagination."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
-
- with sqlite3.connect(db_path) as conn:
- for i in range(5):
- conn.execute(
- "INSERT INTO memories (id, session_name, content, timestamp, context_type) VALUES (?, ?, ?, ?, ?)",
- (
- f"page-{i}",
- "pager",
- f"memory {i}",
- "2026-01-01T10:00:00Z",
- "general",
- ),
- )
- conn.commit()
-
- page1 = client.get(
- "/api/compaction/memories", params={"limit": 2, "offset": 0}
- ).json()
- page2 = client.get(
- "/api/compaction/memories", params={"limit": 2, "offset": 2}
- ).json()
-
- assert page1["total"] == 5
- assert len(page1["items"]) == 2
- assert page2["total"] == 5
- assert len(page2["items"]) == 2
- assert {i["id"] for i in page1["items"]}.isdisjoint(
- {i["id"] for i in page2["items"]}
- )
-
-
-def test_maintenance_compaction_summary_counts_by_status(monkeypatch, tmp_path):
- """Maintenance summary endpoint returns counts grouped by status."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
- future_expires_at = _future_iso()
-
- with sqlite3.connect(db_path) as conn:
- conn.executemany(
- "INSERT INTO compaction_staging (id, session_name, source_memory_ids, preview, status, candidate_hash, source_updated_at_snapshot, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- [
- (
- "c1",
- "main",
- '["m1","m2"]',
- "preview",
- "pending_summary",
- "hash1",
- "{}",
- future_expires_at,
- "2026-01-01T10:00:00Z",
- "2026-01-01T10:00:00Z",
- ),
- (
- "c2",
- "main",
- '["m3","m4"]',
- "preview",
- "summary_staged",
- "hash2",
- "{}",
- future_expires_at,
- "2026-01-01T11:00:00Z",
- "2026-01-01T11:00:00Z",
- ),
- (
- "c3",
- "main",
- '["m5","m6"]',
- "preview",
- "applied",
- "hash3",
- "{}",
- future_expires_at,
- "2026-01-01T12:00:00Z",
- "2026-01-01T12:00:00Z",
- ),
- (
- "c4",
- "main",
- '["m7"]',
- "preview",
- "stale",
- "hash4",
- "{}",
- "2026-01-02T00:00:00Z",
- "2026-01-01T13:00:00Z",
- "2026-01-01T13:00:00Z",
- ),
- ],
- )
- conn.commit()
-
- summary = client.get("/api/maintenance/compaction-summary").json()
-
- assert summary["pending_summary"] == 1
- assert summary["summary_staged"] == 1
- assert summary["applied"] == 1
- assert summary["stale"] == 1
- assert summary["discarded"] == 0
-
-
-def test_maintenance_candidates_filters_by_session(monkeypatch, tmp_path):
- """Maintenance candidates list filters by session name."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
- future_expires_at = _future_iso()
-
- with sqlite3.connect(db_path) as conn:
- conn.executemany(
- "INSERT INTO compaction_staging (id, session_name, source_memory_ids, preview, status, candidate_hash, source_updated_at_snapshot, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- [
- (
- "cf1",
- "alpha",
- '["m1"]',
- "alpha preview",
- "pending_summary",
- "h1",
- "{}",
- future_expires_at,
- "2026-01-01T10:00:00Z",
- "2026-01-01T10:00:00Z",
- ),
- (
- "cf2",
- "beta",
- '["m2"]',
- "beta preview",
- "pending_summary",
- "h2",
- "{}",
- future_expires_at,
- "2026-01-01T11:00:00Z",
- "2026-01-01T11:00:00Z",
- ),
- ],
- )
- conn.commit()
-
- all_candidates = client.get("/api/maintenance/candidates").json()
- assert all_candidates["total"] == 2
-
- alpha_only = client.get(
- "/api/maintenance/candidates", params={"session": "alpha"}
- ).json()
- assert alpha_only["total"] == 1
- assert alpha_only["items"][0]["session_name"] == "alpha"
-
-
-def test_maintenance_candidates_filters_by_status(monkeypatch, tmp_path):
- """Maintenance candidates list filters by status."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
- future_expires_at = _future_iso()
-
- with sqlite3.connect(db_path) as conn:
- conn.executemany(
- "INSERT INTO compaction_staging (id, session_name, source_memory_ids, preview, status, candidate_hash, source_updated_at_snapshot, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- [
- (
- "st1",
- "main",
- '["m1"]',
- "preview",
- "pending_summary",
- "h1",
- "{}",
- future_expires_at,
- "2026-01-01T10:00:00Z",
- "2026-01-01T10:00:00Z",
- ),
- (
- "st2",
- "main",
- '["m2"]',
- "preview",
- "applied",
- "h2",
- "{}",
- future_expires_at,
- "2026-01-01T11:00:00Z",
- "2026-01-01T11:00:00Z",
- ),
- ],
- )
- conn.commit()
-
- pending_only = client.get(
- "/api/maintenance/candidates", params={"status": "pending_summary"}
- ).json()
- assert pending_only["total"] == 1
- assert pending_only["items"][0]["status"] == "pending_summary"
-
- applied_only = client.get(
- "/api/maintenance/candidates", params={"status": "applied"}
- ).json()
- assert applied_only["total"] == 1
- assert applied_only["items"][0]["status"] == "applied"
-
-
-def test_maintenance_discard_candidate_marks_as_discarded(monkeypatch, tmp_path):
- """Discard endpoint updates candidate status to discarded."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
- future_expires_at = _future_iso()
-
- with sqlite3.connect(db_path) as conn:
- conn.execute(
- "INSERT INTO compaction_staging (id, session_name, source_memory_ids, preview, status, candidate_hash, source_updated_at_snapshot, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- (
- "00000000-0000-4000-8000-000000000031",
- "main",
- '["m1"]',
- "preview",
- "pending_summary",
- "h1",
- "{}",
- future_expires_at,
- "2026-01-01T10:00:00Z",
- "2026-01-01T10:00:00Z",
- ),
- )
- conn.commit()
-
- result = client.post(
- "/api/maintenance/candidates/00000000-0000-4000-8000-000000000031/discard"
- ).json()
- assert result["status"] == "discarded"
- assert result["id"] == "00000000-0000-4000-8000-000000000031"
-
- with sqlite3.connect(db_path) as conn:
- row = conn.execute(
- "SELECT status, reviewed_at FROM compaction_staging WHERE id = ?",
- ("00000000-0000-4000-8000-000000000031",),
- ).fetchone()
- assert row[0] == "discarded"
- assert row[1] is not None
-
-
-def test_maintenance_discard_nonexistent_candidate_returns_404(monkeypatch, tmp_path):
- """Discard endpoint returns 404 for nonexistent candidate."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
-
- with sqlite3.connect(db_path) as conn:
- conn.commit()
-
- response = client.post("/api/maintenance/candidates/nonexistent-id/discard")
- assert response.status_code == 404
-
-
-def test_maintenance_candidates_parses_source_memory_ids_json(monkeypatch, tmp_path):
- """Candidates endpoint safely parses source_memory_ids JSON array."""
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
- db_path = tmp_path / "marm_memory.db"
- future_expires_at = _future_iso()
-
- with sqlite3.connect(db_path) as conn:
- conn.executemany(
- "INSERT INTO compaction_staging (id, session_name, source_memory_ids, preview, status, candidate_hash, source_updated_at_snapshot, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- [
- (
- "json1",
- "main",
- '["id1","id2","id3"]',
- "preview",
- "pending_summary",
- "h1",
- "{}",
- future_expires_at,
- "2026-01-01T10:00:00Z",
- "2026-01-01T10:00:00Z",
- ),
- (
- "json2",
- "main",
- "invalid json",
- "preview",
- "pending_summary",
- "h2",
- "{}",
- future_expires_at,
- "2026-01-01T11:00:00Z",
- "2026-01-01T11:00:00Z",
- ),
- ],
- )
- conn.commit()
-
- result = client.get("/api/maintenance/candidates").json()
-
- valid = next(item for item in result["items"] if item["id"] == "json1")
- assert valid["source_memory_ids"] == ["id1", "id2", "id3"]
- assert valid["source_count"] == 3
-
- invalid = next(item for item in result["items"] if item["id"] == "json2")
- assert invalid["source_memory_ids"] == []
- assert invalid["source_count"] == 0
diff --git a/marm-mcp-server/tests/test_dashboard_db.py b/marm-mcp-server/tests/test_dashboard_db.py
deleted file mode 100644
index 6f52770d..00000000
--- a/marm-mcp-server/tests/test_dashboard_db.py
+++ /dev/null
@@ -1,484 +0,0 @@
-import sqlite3
-
-from conftest import load_dashboard, local_client
-
-
-def test_memory_create_sanitizes_content_updates_session_and_can_delete(
- monkeypatch, tmp_path
-):
- server = load_dashboard(monkeypatch, tmp_path)
- client = local_client(server.app)
-
- created = client.post(
- "/api/memories",
- json={
- "session_name": "dashboard-session",
- "context_type": "project",
- "content": "javascript:alert(2) project note",
- },
- )
-
- assert created.status_code == 201
- memory_id = created.json()["id"]
-
- listed = client.get("/api/memories", params={"session": "dashboard-session"})
- item = listed.json()["items"][0]
- assert item["id"] == memory_id
- assert item["context_type"] == "project"
- assert "Use Docker STDIO"},
- )
- assert saved.status_code == 201
-
- item = client.get("/api/notebook").json()["items"][0]
- assert item["name"] == "deploy-note"
- assert "keep this", "keep this", "alert(1)"),
- (
- "close-with-attrs",
- "keep this",
- "keep this",
- "alert(1)",
- ),
- (
- "close-with-space",
- "keep this",
- "keep this",
- "alert(1)",
- ),
- ("broken-close", "