diff --git a/src/claude_memory/api/app.py b/src/claude_memory/api/app.py
index cdebb1f..954c3d3 100644
--- a/src/claude_memory/api/app.py
+++ b/src/claude_memory/api/app.py
@@ -2,12 +2,14 @@
import json
import logging
+import pathlib
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any, AsyncGenerator, Optional
from fastapi import Depends, FastAPI, HTTPException, Request
-from fastapi.responses import Response
+from fastapi.responses import FileResponse, Response
+from fastapi.staticfiles import StaticFiles
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from starlette.middleware.base import BaseHTTPMiddleware
@@ -39,6 +41,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI(title="Claude Memory API", lifespan=lifespan)
+UI_DIR = pathlib.Path(__file__).parent.parent / "ui" / "static"
+
+
+@app.get("/")
+async def ui_root():
+ """Serve the UI single-page app."""
+ return FileResponse(UI_DIR / "index.html")
+
def _detect_sensitive(content: str) -> bool:
"""Check if content contains credentials using the credential detector."""
@@ -315,26 +325,32 @@ async def recall_memories(body: MemoryRecall, user: AuthUser = Depends(get_curre
async def list_memories(
category: Optional[str] = None,
limit: int = 50,
+ offset: int = 0,
user: AuthUser = Depends(get_current_user),
) -> dict[str, Any]:
pool = await get_pool()
if category:
+ count_query = "SELECT COUNT(*) FROM memories WHERE user_id = $1 AND deleted_at IS NULL AND category = $2"
+ count_params: list[Any] = [user.user_id, category]
query = """
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
FROM memories WHERE user_id = $1 AND deleted_at IS NULL AND category = $2
- ORDER BY importance DESC LIMIT $3
+ ORDER BY importance DESC LIMIT $3 OFFSET $4
"""
- params: list[Any] = [user.user_id, category, limit]
+ params: list[Any] = [user.user_id, category, limit, offset]
else:
+ count_query = "SELECT COUNT(*) FROM memories WHERE user_id = $1 AND deleted_at IS NULL"
+ count_params = [user.user_id]
query = """
SELECT id, content, category, tags, importance, is_sensitive, created_at, updated_at
FROM memories WHERE user_id = $1 AND deleted_at IS NULL
- ORDER BY importance DESC LIMIT $2
+ ORDER BY importance DESC LIMIT $2 OFFSET $3
"""
- params = [user.user_id, limit]
+ params = [user.user_id, limit, offset]
async with pool.acquire() as conn:
+ total = await conn.fetchval(count_query, *count_params)
rows = await conn.fetch(query, *params)
results = []
@@ -355,7 +371,89 @@ async def list_memories(
}
)
- return {"memories": results}
+ return {"memories": results, "total": total}
+
+
+@app.get("/api/categories")
+async def list_categories(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
+ """Return distinct category values for the current user."""
+ pool = await get_pool()
+ async with pool.acquire() as conn:
+ rows = await conn.fetch(
+ "SELECT DISTINCT category FROM memories WHERE user_id = $1 AND deleted_at IS NULL ORDER BY category",
+ user.user_id,
+ )
+ return {"categories": [r["category"] for r in rows]}
+
+
+@app.get("/api/stats")
+async def get_stats(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
+ """Aggregated stats for the dashboard."""
+ pool = await get_pool()
+ async with pool.acquire() as conn:
+ total = await conn.fetchval(
+ "SELECT COUNT(*) FROM memories WHERE user_id = $1 AND deleted_at IS NULL",
+ user.user_id,
+ )
+
+ cat_rows = await conn.fetch(
+ "SELECT category, COUNT(*) AS cnt FROM memories WHERE user_id = $1 AND deleted_at IS NULL GROUP BY category ORDER BY cnt DESC",
+ user.user_id,
+ )
+ by_category = {r["category"]: r["cnt"] for r in cat_rows}
+
+ imp_rows = await conn.fetch(
+ """
+ SELECT
+ CASE
+ WHEN importance < 0.2 THEN '0.0-0.2'
+ WHEN importance < 0.4 THEN '0.2-0.4'
+ WHEN importance < 0.6 THEN '0.4-0.6'
+ WHEN importance < 0.8 THEN '0.6-0.8'
+ ELSE '0.8-1.0'
+ END AS bucket,
+ COUNT(*) AS cnt
+ FROM memories WHERE user_id = $1 AND deleted_at IS NULL
+ GROUP BY bucket ORDER BY bucket
+ """,
+ user.user_id,
+ )
+ by_importance = {r["bucket"]: r["cnt"] for r in imp_rows}
+
+ activity_rows = await conn.fetch(
+ """
+ SELECT d::date AS date,
+ COUNT(*) FILTER (WHERE created_at::date = d::date) AS created,
+ COUNT(*) FILTER (WHERE updated_at::date = d::date AND updated_at > created_at + interval '1 second') AS updated
+ FROM memories,
+ generate_series(CURRENT_DATE - interval '29 days', CURRENT_DATE, '1 day') AS d
+ WHERE user_id = $1 AND deleted_at IS NULL
+ AND (created_at::date = d::date OR (updated_at::date = d::date AND updated_at > created_at + interval '1 second'))
+ GROUP BY d::date ORDER BY d::date
+ """,
+ user.user_id,
+ )
+ recent_activity = [
+ {"date": r["date"].isoformat(), "created": r["created"], "updated": r["updated"]}
+ for r in activity_rows
+ ]
+
+ shared_by_me = await conn.fetchval(
+ "SELECT COUNT(*) FROM memory_shares WHERE owner_id = $1",
+ user.user_id,
+ )
+ shared_with_me = await conn.fetchval(
+ "SELECT COUNT(*) FROM memory_shares WHERE shared_with = $1",
+ user.user_id,
+ )
+
+ return {
+ "total_memories": total,
+ "by_category": by_category,
+ "by_importance": by_importance,
+ "recent_activity": recent_activity,
+ "sharing_stats": {"shared_by_me": shared_by_me, "shared_with_me": shared_with_me},
+ }
@app.delete("/api/memories/{memory_id}")
@@ -1033,6 +1131,9 @@ class HandleSSE:
)
+# Static files for UI (before MCP mount)
+app.mount("/static", StaticFiles(directory=UI_DIR), name="static")
+
# Client connects to /mcp/sse, posts to /mcp/messages/
app.router.routes.insert(0, Mount("/mcp", routes=[
Route("/sse", endpoint=HandleSSE()),
diff --git a/src/claude_memory/ui/static/css/app.css b/src/claude_memory/ui/static/css/app.css
new file mode 100644
index 0000000..538ed93
--- /dev/null
+++ b/src/claude_memory/ui/static/css/app.css
@@ -0,0 +1,302 @@
+/* Dark theme base */
+:root {
+ --bg-primary: #0f172a;
+ --bg-secondary: #1e293b;
+ --bg-tertiary: #334155;
+ --text-primary: #f1f5f9;
+ --text-secondary: #94a3b8;
+ --accent: #6366f1;
+ --accent-hover: #818cf8;
+ --success: #22c55e;
+ --warning: #f59e0b;
+ --danger: #ef4444;
+ --border: #334155;
+}
+
+body {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar { width: 6px; }
+::-webkit-scrollbar-track { background: var(--bg-primary); }
+::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 3px; }
+
+/* Login modal */
+.login-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 50;
+}
+
+.login-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 2rem;
+ width: 100%;
+ max-width: 400px;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+}
+
+/* Tab navigation */
+.tab-btn {
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all 0.15s;
+ cursor: pointer;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+}
+
+.tab-btn:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.tab-btn.active {
+ background: var(--accent);
+ color: white;
+}
+
+/* Cards */
+.memory-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1rem;
+ transition: border-color 0.15s;
+ cursor: pointer;
+}
+
+.memory-card:hover {
+ border-color: var(--accent);
+}
+
+/* Category badge */
+.badge {
+ display: inline-block;
+ padding: 0.125rem 0.5rem;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.badge-category {
+ background: rgba(99, 102, 241, 0.2);
+ color: #a5b4fc;
+}
+
+.badge-sensitive {
+ background: rgba(239, 68, 68, 0.2);
+ color: #fca5a5;
+}
+
+.badge-read {
+ background: rgba(148, 163, 184, 0.2);
+ color: #94a3b8;
+}
+
+.badge-write {
+ background: rgba(34, 197, 94, 0.2);
+ color: #86efac;
+}
+
+/* Tag pills */
+.tag-pill {
+ display: inline-block;
+ padding: 0.125rem 0.375rem;
+ border-radius: 4px;
+ font-size: 0.7rem;
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ margin-right: 0.25rem;
+ margin-bottom: 0.25rem;
+}
+
+/* Importance bar */
+.importance-bar {
+ height: 4px;
+ border-radius: 2px;
+ background: var(--bg-tertiary);
+ overflow: hidden;
+}
+
+.importance-fill {
+ height: 100%;
+ border-radius: 2px;
+ background: var(--accent);
+ transition: width 0.3s;
+}
+
+/* Form inputs */
+.input-field {
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.5rem 0.75rem;
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ width: 100%;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.input-field:focus {
+ border-color: var(--accent);
+}
+
+textarea.input-field {
+ resize: vertical;
+ min-height: 80px;
+}
+
+/* Buttons */
+.btn {
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ border: none;
+ transition: all 0.15s;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+}
+
+.btn-danger:hover {
+ background: #dc2626;
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+ border: 1px solid var(--border);
+}
+
+.btn-ghost:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+/* Stats card */
+.stat-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1.25rem;
+}
+
+.stat-card .stat-value {
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: var(--accent);
+}
+
+.stat-card .stat-label {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ margin-top: 0.25rem;
+}
+
+/* Chart container */
+.chart-container {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1rem;
+ position: relative;
+}
+
+/* Graph */
+.graph-container {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ min-height: 500px;
+}
+
+.graph-container svg {
+ display: block;
+}
+
+/* Node detail panel */
+.node-detail {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1rem;
+}
+
+/* Shared memory indicator */
+.shared-indicator {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+/* Loading spinner */
+.spinner {
+ border: 2px solid var(--bg-tertiary);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ width: 20px;
+ height: 20px;
+ animation: spin 0.6s linear infinite;
+ display: inline-block;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Range slider */
+input[type="range"] {
+ -webkit-appearance: none;
+ background: var(--bg-tertiary);
+ height: 4px;
+ border-radius: 2px;
+ outline: none;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--accent);
+ cursor: pointer;
+}
+
+/* Section header */
+.section-title {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-secondary);
+ margin-bottom: 0.75rem;
+}
diff --git a/src/claude_memory/ui/static/index.html b/src/claude_memory/ui/static/index.html
new file mode 100644
index 0000000..4aac0b4
--- /dev/null
+++ b/src/claude_memory/ui/static/index.html
@@ -0,0 +1,424 @@
+
+
+
+
+
+ Claude Memory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Claude Memory
+
Enter your API key to continue
+
+
+
+
+
+
+
+
+
+
+
+
+
Claude Memory
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ / memories
+
+
+
+
+
+
My Memories
+
+
+
+
+
+
+
+ #
+
+
+ SENSITIVE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Created:
+ · Updated:
+ · Importance:
+
+
+
+
+
+
+
+
+ Delete this memory?
+
+
+
+
+
+
+ Not shared with anyone
+
+
+
+ Shared with
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Shared With Me
+
+
+
+
+
+
+
+
+
+
+
+
+ Created:
+ · Updated:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
#
+
+
+ SENSITIVE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Created:
+ · Updated:
+
+ · Permission:
+
+
+
+
+
+
+
+
No results found
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+ SENSITIVE
+
+
+
+
+
+
+
+
+
+
Importance:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Importance Distribution
+
+
+
+
+
+
+
+
Activity (Last 30 Days)
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/claude_memory/ui/static/js/api.js b/src/claude_memory/ui/static/js/api.js
new file mode 100644
index 0000000..5b8b4ab
--- /dev/null
+++ b/src/claude_memory/ui/static/js/api.js
@@ -0,0 +1,84 @@
+// API client with Bearer auth
+const api = {
+ getToken() {
+ return sessionStorage.getItem('api_key');
+ },
+
+ getUserId() {
+ return sessionStorage.getItem('user_id');
+ },
+
+ setAuth(key, userId) {
+ sessionStorage.setItem('api_key', key);
+ sessionStorage.setItem('user_id', userId);
+ },
+
+ clearAuth() {
+ sessionStorage.removeItem('api_key');
+ sessionStorage.removeItem('user_id');
+ },
+
+ isAuthenticated() {
+ return !!this.getToken();
+ },
+
+ async fetch(url, options = {}) {
+ const token = this.getToken();
+ if (!token) {
+ window.dispatchEvent(new CustomEvent('auth:required'));
+ throw new Error('Not authenticated');
+ }
+
+ const headers = {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ };
+
+ const res = await fetch(url, { ...options, headers });
+
+ if (res.status === 401) {
+ this.clearAuth();
+ window.dispatchEvent(new CustomEvent('auth:required'));
+ throw new Error('Unauthorized');
+ }
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`API error ${res.status}: ${body}`);
+ }
+
+ return res.json();
+ },
+
+ get(url) {
+ return this.fetch(url);
+ },
+
+ post(url, data) {
+ return this.fetch(url, { method: 'POST', body: JSON.stringify(data) });
+ },
+
+ put(url, data) {
+ return this.fetch(url, { method: 'PUT', body: JSON.stringify(data) });
+ },
+
+ del(url) {
+ return this.fetch(url, { method: 'DELETE' });
+ },
+
+ async login(key) {
+ const res = await fetch('/api/auth-check', {
+ headers: { 'Authorization': `Bearer ${key}` },
+ });
+ if (!res.ok) throw new Error('Invalid API key');
+ const data = await res.json();
+ this.setAuth(key, data.user_id);
+ return data;
+ },
+
+ logout() {
+ this.clearAuth();
+ window.dispatchEvent(new CustomEvent('auth:required'));
+ },
+};
diff --git a/src/claude_memory/ui/static/js/app.js b/src/claude_memory/ui/static/js/app.js
new file mode 100644
index 0000000..194ea66
--- /dev/null
+++ b/src/claude_memory/ui/static/js/app.js
@@ -0,0 +1,41 @@
+// Alpine.js global store and tab navigation
+document.addEventListener('alpine:init', () => {
+ Alpine.store('app', {
+ activeTab: 'memories',
+ userId: api.getUserId() || '',
+ authenticated: api.isAuthenticated(),
+ loginKey: '',
+ loginError: '',
+ loginLoading: false,
+
+ async login() {
+ this.loginError = '';
+ this.loginLoading = true;
+ try {
+ const data = await api.login(this.loginKey);
+ this.userId = data.user_id;
+ this.authenticated = true;
+ this.loginKey = '';
+ } catch (e) {
+ this.loginError = e.message;
+ } finally {
+ this.loginLoading = false;
+ }
+ },
+
+ logout() {
+ api.logout();
+ this.authenticated = false;
+ this.userId = '';
+ },
+
+ switchTab(tab) {
+ this.activeTab = tab;
+ },
+ });
+
+ window.addEventListener('auth:required', () => {
+ Alpine.store('app').authenticated = false;
+ Alpine.store('app').userId = '';
+ });
+});
diff --git a/src/claude_memory/ui/static/js/dashboard.js b/src/claude_memory/ui/static/js/dashboard.js
new file mode 100644
index 0000000..7e15a0d
--- /dev/null
+++ b/src/claude_memory/ui/static/js/dashboard.js
@@ -0,0 +1,137 @@
+// Dashboard stats component
+function dashboardComponent() {
+ return {
+ stats: null,
+ loading: false,
+ charts: {},
+
+ async init() {
+ this.loading = true;
+ try {
+ this.stats = await api.get('/api/stats');
+ this.$nextTick(() => this.renderCharts());
+ } catch (e) {
+ console.error('Failed to load stats:', e);
+ }
+ this.loading = false;
+ },
+
+ renderCharts() {
+ if (!this.stats) return;
+ this.renderCategoryChart();
+ this.renderImportanceChart();
+ this.renderActivityChart();
+ },
+
+ renderCategoryChart() {
+ const ctx = this.$refs.categoryChart;
+ if (!ctx) return;
+ if (this.charts.category) this.charts.category.destroy();
+
+ const labels = Object.keys(this.stats.by_category);
+ const data = Object.values(this.stats.by_category);
+ const colors = generateColors(labels.length);
+
+ this.charts.category = new Chart(ctx, {
+ type: 'doughnut',
+ data: {
+ labels,
+ datasets: [{ data, backgroundColor: colors, borderColor: '#1e293b', borderWidth: 2 }],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { position: 'bottom', labels: { color: '#94a3b8', padding: 12 } },
+ },
+ },
+ });
+ },
+
+ renderImportanceChart() {
+ const ctx = this.$refs.importanceChart;
+ if (!ctx) return;
+ if (this.charts.importance) this.charts.importance.destroy();
+
+ const labels = ['0.0-0.2', '0.2-0.4', '0.4-0.6', '0.6-0.8', '0.8-1.0'];
+ const data = labels.map(l => this.stats.by_importance[l] || 0);
+
+ this.charts.importance = new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels,
+ datasets: [{
+ label: 'Memories',
+ data,
+ backgroundColor: '#6366f1',
+ borderRadius: 4,
+ }],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ x: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' } },
+ y: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' }, beginAtZero: true },
+ },
+ plugins: { legend: { display: false } },
+ },
+ });
+ },
+
+ renderActivityChart() {
+ const ctx = this.$refs.activityChart;
+ if (!ctx) return;
+ if (this.charts.activity) this.charts.activity.destroy();
+
+ const activity = this.stats.recent_activity || [];
+ const labels = activity.map(a => a.date);
+ const created = activity.map(a => a.created);
+ const updated = activity.map(a => a.updated);
+
+ this.charts.activity = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels,
+ datasets: [
+ {
+ label: 'Created',
+ data: created,
+ borderColor: '#22c55e',
+ backgroundColor: 'rgba(34,197,94,0.1)',
+ fill: true,
+ tension: 0.3,
+ },
+ {
+ label: 'Updated',
+ data: updated,
+ borderColor: '#f59e0b',
+ backgroundColor: 'rgba(245,158,11,0.1)',
+ fill: true,
+ tension: 0.3,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ x: { ticks: { color: '#94a3b8', maxTicksLimit: 10 }, grid: { color: '#334155' } },
+ y: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' }, beginAtZero: true },
+ },
+ plugins: { legend: { labels: { color: '#94a3b8' } } },
+ },
+ });
+ },
+ };
+}
+
+function generateColors(count) {
+ const palette = [
+ '#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4',
+ '#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b',
+ ];
+ const result = [];
+ for (let i = 0; i < count; i++) result.push(palette[i % palette.length]);
+ return result;
+}
diff --git a/src/claude_memory/ui/static/js/graph.js b/src/claude_memory/ui/static/js/graph.js
new file mode 100644
index 0000000..97fa2c6
--- /dev/null
+++ b/src/claude_memory/ui/static/js/graph.js
@@ -0,0 +1,161 @@
+// D3.js force-directed graph visualization
+function graphComponent() {
+ return {
+ memories: [],
+ categories: [],
+ selectedCategories: {},
+ loading: false,
+ selectedNode: null,
+ simulation: null,
+
+ async init() {
+ this.loading = true;
+ try {
+ const [memData, catData] = await Promise.all([
+ api.get('/api/memories?limit=200'),
+ api.get('/api/categories'),
+ ]);
+ this.memories = memData.memories;
+ this.categories = catData.categories;
+ this.categories.forEach(c => this.selectedCategories[c] = true);
+ this.$nextTick(() => this.render());
+ } catch (e) {
+ console.error('Failed to load graph data:', e);
+ }
+ this.loading = false;
+ },
+
+ toggleCategory(cat) {
+ this.selectedCategories[cat] = !this.selectedCategories[cat];
+ this.render();
+ },
+
+ getFilteredMemories() {
+ return this.memories.filter(m => this.selectedCategories[m.category]);
+ },
+
+ render() {
+ const container = this.$refs.graphContainer;
+ if (!container) return;
+
+ // Clear previous
+ d3.select(container).selectAll('*').remove();
+ if (this.simulation) this.simulation.stop();
+
+ const memories = this.getFilteredMemories().slice(0, 300);
+ if (memories.length === 0) return;
+
+ const width = container.clientWidth;
+ const height = container.clientHeight || 500;
+
+ // Build nodes and tag-based edges
+ const nodes = memories.map(m => ({
+ id: m.id,
+ content: m.content,
+ category: m.category,
+ tags: m.tags,
+ importance: m.importance,
+ is_sensitive: m.is_sensitive,
+ }));
+
+ const tagMap = {};
+ nodes.forEach(n => {
+ if (!n.tags) return;
+ n.tags.split(',').map(t => t.trim()).filter(Boolean).forEach(tag => {
+ if (!tagMap[tag]) tagMap[tag] = [];
+ tagMap[tag].push(n.id);
+ });
+ });
+
+ const links = [];
+ const linkSet = new Set();
+ Object.values(tagMap).forEach(ids => {
+ for (let i = 0; i < ids.length && i < 5; i++) {
+ for (let j = i + 1; j < ids.length && j < 5; j++) {
+ const key = `${ids[i]}-${ids[j]}`;
+ if (!linkSet.has(key)) {
+ linkSet.add(key);
+ links.push({ source: ids[i], target: ids[j] });
+ }
+ }
+ }
+ });
+
+ const categoryColors = {};
+ const palette = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4', '#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b'];
+ this.categories.forEach((c, i) => categoryColors[c] = palette[i % palette.length]);
+
+ const svg = d3.select(container)
+ .append('svg')
+ .attr('width', width)
+ .attr('height', height);
+
+ const g = svg.append('g');
+
+ // Zoom
+ const zoom = d3.zoom()
+ .scaleExtent([0.2, 5])
+ .on('zoom', (event) => g.attr('transform', event.transform));
+ svg.call(zoom);
+
+ const simulation = d3.forceSimulation(nodes)
+ .force('link', d3.forceLink(links).id(d => d.id).distance(80))
+ .force('charge', d3.forceManyBody().strength(-100))
+ .force('center', d3.forceCenter(width / 2, height / 2))
+ .force('collide', d3.forceCollide().radius(d => 8 + d.importance * 15));
+
+ this.simulation = simulation;
+
+ const link = g.append('g')
+ .selectAll('line')
+ .data(links)
+ .join('line')
+ .attr('stroke', '#334155')
+ .attr('stroke-opacity', 0.4)
+ .attr('stroke-width', 1);
+
+ const node = g.append('g')
+ .selectAll('circle')
+ .data(nodes)
+ .join('circle')
+ .attr('r', d => 5 + d.importance * 15)
+ .attr('fill', d => categoryColors[d.category] || '#64748b')
+ .attr('stroke', '#1e293b')
+ .attr('stroke-width', 1.5)
+ .attr('cursor', 'pointer')
+ .on('click', (event, d) => {
+ this.selectedNode = d;
+ })
+ .call(d3.drag()
+ .on('start', (event, d) => {
+ if (!event.active) simulation.alphaTarget(0.3).restart();
+ d.fx = d.x; d.fy = d.y;
+ })
+ .on('drag', (event, d) => {
+ d.fx = event.x; d.fy = event.y;
+ })
+ .on('end', (event, d) => {
+ if (!event.active) simulation.alphaTarget(0);
+ d.fx = null; d.fy = null;
+ }));
+
+ node.append('title').text(d => d.content ? d.content.substring(0, 60) : '');
+
+ simulation.on('tick', () => {
+ link
+ .attr('x1', d => d.source.x)
+ .attr('y1', d => d.source.y)
+ .attr('x2', d => d.target.x)
+ .attr('y2', d => d.target.y);
+ node
+ .attr('cx', d => d.x)
+ .attr('cy', d => d.y);
+ });
+ },
+
+ preview(content, len = 200) {
+ if (!content) return '';
+ return content.length > len ? content.substring(0, len) + '...' : content;
+ },
+ };
+}
diff --git a/src/claude_memory/ui/static/js/memories.js b/src/claude_memory/ui/static/js/memories.js
new file mode 100644
index 0000000..5c37495
--- /dev/null
+++ b/src/claude_memory/ui/static/js/memories.js
@@ -0,0 +1,145 @@
+// Memory browser/editor component
+function memoriesBrowser() {
+ return {
+ memories: [],
+ sharedMemories: [],
+ categories: [],
+ selectedCategory: '',
+ total: 0,
+ offset: 0,
+ limit: 50,
+ loading: false,
+ expandedId: null,
+ editingId: null,
+ editForm: { content: '', tags: '', importance: 0.5 },
+ sharesExpanded: null,
+ sharesData: [],
+ deleteConfirm: null,
+
+ async init() {
+ await Promise.all([this.loadMemories(), this.loadShared(), this.loadCategories()]);
+ },
+
+ async loadCategories() {
+ try {
+ const data = await api.get('/api/categories');
+ this.categories = data.categories;
+ } catch (e) { console.error('Failed to load categories:', e); }
+ },
+
+ async loadMemories() {
+ this.loading = true;
+ try {
+ let url = `/api/memories?limit=${this.limit}&offset=${this.offset}`;
+ if (this.selectedCategory) url += `&category=${encodeURIComponent(this.selectedCategory)}`;
+ const data = await api.get(url);
+ if (this.offset === 0) {
+ this.memories = data.memories;
+ } else {
+ this.memories = [...this.memories, ...data.memories];
+ }
+ this.total = data.total;
+ } catch (e) { console.error('Failed to load memories:', e); }
+ this.loading = false;
+ },
+
+ async loadShared() {
+ try {
+ const data = await api.get('/api/memories/shared-with-me');
+ this.sharedMemories = data.memories;
+ } catch (e) { console.error('Failed to load shared:', e); }
+ },
+
+ async filterByCategory() {
+ this.offset = 0;
+ await this.loadMemories();
+ },
+
+ async loadMore() {
+ this.offset += this.limit;
+ await this.loadMemories();
+ },
+
+ get hasMore() {
+ return this.memories.length < this.total;
+ },
+
+ toggle(id) {
+ this.expandedId = this.expandedId === id ? null : id;
+ this.editingId = null;
+ this.sharesExpanded = null;
+ },
+
+ startEdit(mem) {
+ this.editingId = mem.id;
+ this.editForm = {
+ content: mem.content,
+ tags: mem.tags || '',
+ importance: mem.importance,
+ };
+ },
+
+ cancelEdit() {
+ this.editingId = null;
+ },
+
+ async saveEdit(id) {
+ try {
+ await api.put(`/api/memories/${id}`, {
+ content: this.editForm.content,
+ tags: this.editForm.tags,
+ importance: parseFloat(this.editForm.importance),
+ });
+ this.editingId = null;
+ this.offset = 0;
+ await this.loadMemories();
+ } catch (e) {
+ alert('Save failed: ' + e.message);
+ }
+ },
+
+ async confirmDelete(id) {
+ this.deleteConfirm = id;
+ },
+
+ async doDelete(id) {
+ try {
+ await api.del(`/api/memories/${id}`);
+ this.deleteConfirm = null;
+ this.offset = 0;
+ await this.loadMemories();
+ } catch (e) {
+ alert('Delete failed: ' + e.message);
+ }
+ },
+
+ async toggleShares(memId) {
+ if (this.sharesExpanded === memId) {
+ this.sharesExpanded = null;
+ return;
+ }
+ try {
+ const data = await api.get('/api/memories/my-shares');
+ this.sharesData = data.memory_shares.filter(s => s.memory_id === memId);
+ this.sharesExpanded = memId;
+ } catch (e) { console.error('Failed to load shares:', e); }
+ },
+
+ preview(content, len = 100) {
+ if (!content) return '';
+ return content.length > len ? content.substring(0, len) + '...' : content;
+ },
+
+ relativeTime(iso) {
+ const diff = Date.now() - new Date(iso).getTime();
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return 'just now';
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h ago`;
+ const days = Math.floor(hrs / 24);
+ if (days < 30) return `${days}d ago`;
+ return new Date(iso).toLocaleDateString();
+ },
+ };
+}
diff --git a/src/claude_memory/ui/static/js/search.js b/src/claude_memory/ui/static/js/search.js
new file mode 100644
index 0000000..8d57362
--- /dev/null
+++ b/src/claude_memory/ui/static/js/search.js
@@ -0,0 +1,67 @@
+// Search interface component
+function searchComponent() {
+ return {
+ query: '',
+ category: '',
+ sortBy: 'importance',
+ results: [],
+ categories: [],
+ loading: false,
+ expandedId: null,
+ debounceTimer: null,
+
+ async init() {
+ try {
+ const data = await api.get('/api/categories');
+ this.categories = data.categories;
+ } catch (e) { console.error(e); }
+ },
+
+ onInput() {
+ clearTimeout(this.debounceTimer);
+ this.debounceTimer = setTimeout(() => this.doSearch(), 300);
+ },
+
+ async doSearch() {
+ if (!this.query.trim()) {
+ this.results = [];
+ return;
+ }
+ this.loading = true;
+ try {
+ const body = {
+ context: this.query,
+ sort_by: this.sortBy,
+ limit: 20,
+ };
+ if (this.category) body.category = this.category;
+ const data = await api.post('/api/memories/recall', body);
+ this.results = data.memories;
+ } catch (e) {
+ console.error('Search failed:', e);
+ }
+ this.loading = false;
+ },
+
+ toggle(id) {
+ this.expandedId = this.expandedId === id ? null : id;
+ },
+
+ preview(content, len = 120) {
+ if (!content) return '';
+ return content.length > len ? content.substring(0, len) + '...' : content;
+ },
+
+ relativeTime(iso) {
+ const diff = Date.now() - new Date(iso).getTime();
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return 'just now';
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h ago`;
+ const days = Math.floor(hrs / 24);
+ if (days < 30) return `${days}d ago`;
+ return new Date(iso).toLocaleDateString();
+ },
+ };
+}