feat(app): add Electron desktop UI and evolve memory/retrieval layer
Introduce an Electron app with session browser, memory list, and usage views (vanilla JS + Vue scaffolding). On the data layer: add content_type and is_meta to messages for transcript control-plane filtering, introduce FTS5-backed memory recall with safe tokenization, support memory archival via forget() through the renamed --attune runtime, and expose anchors on memory records.
This commit is contained in:
+737
@@ -0,0 +1,737 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<title>Obelisk — Memory</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Playfair+Display:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #f8f9fb;
|
||||
--surface: #ffffff;
|
||||
--surface-hover: #f3f4f8;
|
||||
--surface-active: #eceef4;
|
||||
--border: #e2e4ea;
|
||||
--border-hover: #c8ccd6;
|
||||
--text: #1e293b;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #6366f1;
|
||||
--accent-soft: #e0e7ff;
|
||||
--accent-glow: rgba(99, 102, 241, 0.08);
|
||||
--purple: #a855f7;
|
||||
--pink: #ec4899;
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fef2f2;
|
||||
--danger-border: #fecaca;
|
||||
--restore: #059669;
|
||||
--restore-soft: #ecfdf5;
|
||||
--restore-border: #a7f3d0;
|
||||
--serif: 'Playfair Display', Charter, 'Iowan Old Style', Georgia, serif;
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
--shadow-sm: 0 1px 2px rgba(30, 41, 59, 0.04);
|
||||
--shadow: 0 2px 8px rgba(30, 41, 59, 0.06);
|
||||
--shadow-lg: 0 8px 24px rgba(30, 41, 59, 0.08);
|
||||
--transition: 160ms ease;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 120px;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center top, rgba(168, 85, 247, 0.35), rgba(99, 102, 241, 0.2) 60%, transparent 80%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.brand-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 40%;
|
||||
width: 10px;
|
||||
height: 18px;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(to right, #475569, #1e293b);
|
||||
clip-path: polygon(30% 0%, 70% 0%, 80% 100%, 20% 100%);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-family: var(--serif);
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
header .subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
font-family: var(--serif);
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
padding: 10px 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: all var(--transition);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-secondary); }
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab .count {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-active);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.project-group {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.project-header:hover .project-name { color: var(--accent); }
|
||||
|
||||
.project-header .chevron {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
transition: transform var(--transition);
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.project-header.collapsed .chevron { transform: rotate(-90deg); }
|
||||
|
||||
.project-header .project-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.project-header .project-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.memory-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.memory-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.memory-item:hover {
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.memory-item.expanded {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow);
|
||||
}
|
||||
|
||||
.memory-item.deleted {
|
||||
opacity: 0.55;
|
||||
background: var(--bg);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.memory-item.deleted:hover { opacity: 0.8; }
|
||||
|
||||
.memory-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memory-path {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memory-summary {
|
||||
font-family: var(--sans);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.memory-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 7px 14px;
|
||||
font-size: 12px;
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
border: 1px solid;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn:hover { transform: translateY(-1px); box-shadow: var(--shadow); }
|
||||
.btn:active { transform: translateY(0); }
|
||||
|
||||
.btn-delete {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.btn-delete:hover {
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.12);
|
||||
}
|
||||
|
||||
.btn-restore {
|
||||
color: var(--restore);
|
||||
border-color: var(--restore-border);
|
||||
background: var(--restore-soft);
|
||||
}
|
||||
.btn-restore:hover {
|
||||
border-color: var(--restore);
|
||||
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.12);
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 12px;
|
||||
padding: 16px 18px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-panel.open { display: block; }
|
||||
|
||||
.detail-panel .file-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-panel .file-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
color: var(--text-secondary);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.detail-panel .file-content h1,
|
||||
.detail-panel .file-content h2,
|
||||
.detail-panel .file-content h3 {
|
||||
font-family: var(--sans);
|
||||
color: var(--text);
|
||||
margin: 1.2em 0 0.4em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.detail-panel .file-content h1 { font-size: 18px; }
|
||||
.detail-panel .file-content h2 { font-size: 16px; }
|
||||
.detail-panel .file-content h3 { font-size: 14px; }
|
||||
.detail-panel .file-content h1:first-child,
|
||||
.detail-panel .file-content h2:first-child { margin-top: 0; }
|
||||
|
||||
.detail-panel .file-content p { margin: 0.6em 0; }
|
||||
|
||||
.detail-panel .file-content ul,
|
||||
.detail-panel .file-content ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.detail-panel .file-content li { margin: 0.25em 0; }
|
||||
|
||||
.detail-panel .file-content code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--surface-active);
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.detail-panel .file-content pre {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
.detail-panel .file-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.detail-panel .provenance {
|
||||
margin-top: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.provenance code {
|
||||
background: var(--surface-active);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(80px);
|
||||
background: var(--text);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 20px;
|
||||
font-size: 12px;
|
||||
color: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
opacity: 0;
|
||||
transition: all 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast .undo-btn {
|
||||
color: var(--accent-soft);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 56px 20px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--serif);
|
||||
font-size: 17px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="brand-icon"></div>
|
||||
<h1>Memory</h1>
|
||||
</div>
|
||||
<div class="subtitle">Let Claude Code search its own memory.</div>
|
||||
<div class="stats-bar">
|
||||
<div class="stat"><span class="stat-value" id="stat-active">0</span><span class="stat-label">active</span></div>
|
||||
<div class="stat"><span class="stat-value" id="stat-deleted">0</span><span class="stat-label">archived</span></div>
|
||||
<div class="stat"><span class="stat-value" id="stat-projects">0</span><span class="stat-label">projects</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="active">Active <span class="count" id="tab-active-count">0</span></div>
|
||||
<div class="tab" data-tab="deleted">Archived <span class="count" id="tab-deleted-count">0</span></div>
|
||||
</div>
|
||||
|
||||
<div id="content"></div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast">
|
||||
<span id="toast-msg"></span>
|
||||
<span class="undo-btn" id="toast-undo">Undo</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const MOCK_MEMORIES = [
|
||||
{
|
||||
id: 'mem-1781021027286-51v0qh',
|
||||
session_id: 'defd4ccd-b2d7-4c07-a32b-0a7b74e8aace',
|
||||
project: '-Users-tomiya-Code-quiet-zero',
|
||||
message_start: 'a1b2c3d4',
|
||||
message_end: 'e5f6g7h8',
|
||||
path: '.obelisk/memories/filter-opts-design.md',
|
||||
summary: 'Unified filter opts design: decided to add project/after/before/limit to all list-returning API functions instead of building a query builder DSL. Agent already knows JS; function composition is the query builder.',
|
||||
created_at: '2026-06-01T16:03:47.286Z',
|
||||
deleted_at: null,
|
||||
deleted_reason: null,
|
||||
_file_content: '# Filter Opts Design Decision\n\nWe chose to add a consistent `opts` object to every list-returning function rather than building a query builder abstraction.\n\n## Reasoning\n\n- The agent already writes JS — function composition IS the query builder\n- A DSL adds a new mental model the agent has to learn\n- `search()` already had the right pattern; we just propagated it\n\n## Alternatives Considered\n\n- Fluent builder (`.filter().select().take()`) — too much implementation, new abstraction\n- Per-function specific filters — inconsistent, hard to compose\n\n## Constraints\n\n- Must be backward compatible (string → sessionId, number → limit)\n- Must push filters to SQL, not pull-and-filter client-side'
|
||||
},
|
||||
{
|
||||
id: 'mem-1781021100000-x9y2z1',
|
||||
session_id: 'defd4ccd-b2d7-4c07-a32b-0a7b74e8aace',
|
||||
project: '-Users-tomiya-Code-quiet-zero',
|
||||
message_start: 'i9j0k1l2',
|
||||
message_end: 'm3n4o5p6',
|
||||
path: '.obelisk/memories/is-error-design.md',
|
||||
summary: 'Use structural is_error field from JSONL instead of text pattern matching for failures(). The old ERROR_PATS approach had ~90% false positive rate. Bash exit code pattern kept as fallback but SQLite LIKE has no character classes.',
|
||||
created_at: '2026-06-02T10:15:00.000Z',
|
||||
deleted_at: null,
|
||||
deleted_reason: null,
|
||||
_file_content: '# is_error Design\n\nReplaced text-based error pattern matching with the structural `is_error` boolean from Claude Code JSONL.\n\n## Problem\n\nThe old `failures()` matched content against patterns like "Error", "failed", "ENOENT". This caught source code containing those words, Agent results discussing errors, etc. ~90% false positive rate.\n\n## Solution\n\nIndex `b.is_error` from tool_result blocks into a new `is_error INTEGER` column on `tool_results`. Query becomes `WHERE is_error = 1`.\n\n## Edge Cases\n\nBash exit code fallback: `content LIKE \'Exit code %\'` for cases where is_error might not be set. Note: SQLite LIKE does not support `[1-9]` character classes.'
|
||||
},
|
||||
{
|
||||
id: 'mem-1781021200000-a3b4c5',
|
||||
session_id: '2831d8a1-df70-4365-a203-59bbe8e354cb',
|
||||
project: '-Users-tomiya-Code-quiet-zero',
|
||||
message_start: 'q7r8s9t0',
|
||||
message_end: 'u1v2w3x4',
|
||||
path: '.obelisk/memories/no-wiki-philosophy.md',
|
||||
summary: 'Core philosophy: raw sessions are already structured data. Do not compile them into wiki pages or markdown summaries as an intermediate entity. Keep the relational structure, let agent query at application layer. Memory layer is selective conclusions with provenance, not comprehensive coverage.',
|
||||
created_at: '2026-05-30T14:20:00.000Z',
|
||||
deleted_at: null,
|
||||
deleted_reason: null,
|
||||
_file_content: '# No Wiki Philosophy\n\n"若无必要,勿增实体"\n\nRaw session data is already structured: messages, tool calls, tool results, files, subagents, workflows, parent chains. Maintaining these relationships in SQLite is already a powerful structure.\n\nCompiling into markdown pages introduces an unnecessary intermediate entity: information gets flattened, causal chains and reference relationships need post-hoc reconstruction.\n\nThe memory layer is NOT a wiki. It is selective, agent-written conclusions with clear provenance. It does not try to comprehensively cover everything.'
|
||||
},
|
||||
{
|
||||
id: 'mem-1781021300000-d6e7f8',
|
||||
session_id: 'ca0b1609-984a-4761-a090-a4fc4f25b8b8',
|
||||
project: '-Users-tomiya-Code-bub',
|
||||
message_start: 'y5z6a7b8',
|
||||
message_end: 'c9d0e1f2',
|
||||
path: '.obelisk/memories/bub-tasktree-decision.md',
|
||||
summary: 'Dynamic TaskTree architecture: chose three-layer design (main Agent → Original Work → Side Quests) over flat task queue. logos_complete semantics split into plan (generate children) and return (bubble up summary).',
|
||||
created_at: '2026-05-29T18:30:00.000Z',
|
||||
deleted_at: null,
|
||||
deleted_reason: null,
|
||||
_file_content: '# Dynamic TaskTree Architecture\n\nChose a three-layer architecture for the dynamic task tree:\n1. Main Agent — orchestrator\n2. Original Work — primary task execution\n3. Side Quests — spawned sub-tasks\n\n## Key Decision\n\nSplit `logos_complete` into two distinct semantics:\n- **plan**: generate child nodes (`plan: [...]`)\n- **return**: bubble up summary to parent (`summary: "..."`)\n\nThe old design conflated these — a node with k subtasks called logos_complete k+1 times.'
|
||||
},
|
||||
{
|
||||
id: 'mem-1781021400000-g3h4i5',
|
||||
session_id: 'defd4ccd-b2d7-4c07-a32b-0a7b74e8aace',
|
||||
project: '-Users-tomiya-Code-quiet-zero',
|
||||
message_start: 'j6k7l8m9',
|
||||
message_end: 'n0o1p2q3',
|
||||
path: '.obelisk/memories/stale-memory-example.md',
|
||||
summary: 'Obsolete: originally planned to add a full query builder with .filter().select() chain syntax. This was rejected in favor of unified filter opts on existing functions.',
|
||||
created_at: '2026-06-01T12:00:00.000Z',
|
||||
deleted_at: '2026-06-03T09:00:00.000Z',
|
||||
deleted_reason: 'Superseded by filter-opts-design memory. The query builder plan was rejected.',
|
||||
_file_content: '# Query Builder Plan (REJECTED)\n\nThis approach was considered and rejected.\n\nWe originally planned a fluent query builder:\n```js\nsummaries.filter({project: like("quiet-zero")}).limit(10)\n```\n\nRejected because:\n- Adds unnecessary abstraction\n- Agent already writes JS\n- Function composition achieves the same thing'
|
||||
}
|
||||
];
|
||||
|
||||
let memories = JSON.parse(JSON.stringify(MOCK_MEMORIES));
|
||||
let currentTab = 'active';
|
||||
let expandedId = null;
|
||||
let collapsedProjects = new Set();
|
||||
let toastTimeout = null;
|
||||
|
||||
function getActive() { return memories.filter(m => !m.deleted_at); }
|
||||
function getDeleted() { return memories.filter(m => m.deleted_at); }
|
||||
function getProjects(list) {
|
||||
const groups = {};
|
||||
for (const m of list) {
|
||||
const p = m.project || '(no project)';
|
||||
if (!groups[p]) groups[p] = [];
|
||||
groups[p].push(m);
|
||||
}
|
||||
return Object.entries(groups).sort((a, b) => {
|
||||
const latestA = Math.max(...a[1].map(m => new Date(m.created_at).getTime()));
|
||||
const latestB = Math.max(...b[1].map(m => new Date(m.created_at).getTime()));
|
||||
return latestB - latestA;
|
||||
});
|
||||
}
|
||||
|
||||
function formatProject(slug) {
|
||||
if (!slug) return '(no project)';
|
||||
const parts = slug.replace(/^-/, '').split('-');
|
||||
return parts.slice(-2).join('/');
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function showToast(msg, undoFn) {
|
||||
const toast = document.getElementById('toast');
|
||||
document.getElementById('toast-msg').textContent = msg;
|
||||
toast.classList.add('show');
|
||||
document.getElementById('toast-undo').onclick = () => { undoFn(); hideToast(); };
|
||||
clearTimeout(toastTimeout);
|
||||
toastTimeout = setTimeout(hideToast, 5000);
|
||||
}
|
||||
|
||||
function hideToast() {
|
||||
document.getElementById('toast').classList.remove('show');
|
||||
clearTimeout(toastTimeout);
|
||||
}
|
||||
|
||||
function softDelete(id) {
|
||||
const mem = memories.find(m => m.id === id);
|
||||
if (!mem) return;
|
||||
mem.deleted_at = new Date().toISOString();
|
||||
mem.deleted_reason = 'Deleted via panel';
|
||||
expandedId = null;
|
||||
render();
|
||||
showToast(`Archived: ${mem.path.split('/').pop()}`, () => restore(id));
|
||||
}
|
||||
|
||||
function restore(id) {
|
||||
const mem = memories.find(m => m.id === id);
|
||||
if (!mem) return;
|
||||
mem.deleted_at = null;
|
||||
mem.deleted_reason = null;
|
||||
render();
|
||||
showToast(`Restored: ${mem.path.split('/').pop()}`, () => softDelete(id));
|
||||
}
|
||||
|
||||
function toggleExpand(id) {
|
||||
expandedId = expandedId === id ? null : id;
|
||||
render();
|
||||
}
|
||||
|
||||
function toggleProject(project) {
|
||||
if (collapsedProjects.has(project)) collapsedProjects.delete(project);
|
||||
else collapsedProjects.add(project);
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const active = getActive();
|
||||
const deleted = getDeleted();
|
||||
const projects = new Set(memories.map(m => m.project).filter(Boolean));
|
||||
|
||||
document.getElementById('stat-active').textContent = active.length;
|
||||
document.getElementById('stat-deleted').textContent = deleted.length;
|
||||
document.getElementById('stat-projects').textContent = projects.size;
|
||||
document.getElementById('tab-active-count').textContent = active.length;
|
||||
document.getElementById('tab-deleted-count').textContent = deleted.length;
|
||||
|
||||
const list = currentTab === 'active' ? active : deleted;
|
||||
const grouped = getProjects(list);
|
||||
const content = document.getElementById('content');
|
||||
|
||||
if (!list.length) {
|
||||
content.innerHTML = `<div class="empty">${currentTab === 'active' ? 'No active memories yet.' : 'No archived memories.'}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = grouped.map(([project, mems]) => {
|
||||
const isCollapsed = collapsedProjects.has(project);
|
||||
return `
|
||||
<div class="project-group">
|
||||
<div class="project-header ${isCollapsed ? 'collapsed' : ''}" onclick="toggleProject('${project}')">
|
||||
<span class="chevron">▾</span>
|
||||
<span class="project-name">${formatProject(project)}</span>
|
||||
<span class="project-count">${mems.length}</span>
|
||||
</div>
|
||||
<div class="memory-list" style="${isCollapsed ? 'display:none' : ''}">
|
||||
${mems.map(m => renderMemory(m)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
}
|
||||
|
||||
function renderMemory(m) {
|
||||
const isExpanded = expandedId === m.id;
|
||||
const isDeleted = !!m.deleted_at;
|
||||
return `
|
||||
<div class="memory-item ${isExpanded ? 'expanded' : ''} ${isDeleted ? 'deleted' : ''}" onclick="toggleExpand('${m.id}')">
|
||||
<div class="memory-content">
|
||||
<div class="memory-path">${m.path}</div>
|
||||
<div class="memory-summary">${m.summary}</div>
|
||||
<div class="memory-meta">
|
||||
<span>${formatDate(m.created_at)}</span>
|
||||
${m.deleted_at ? `<span>archived ${formatDate(m.deleted_at)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="memory-actions" onclick="event.stopPropagation()">
|
||||
${isDeleted
|
||||
? `<button class="btn btn-restore" onclick="restore('${m.id}')">restore</button>`
|
||||
: `<button class="btn btn-delete" onclick="softDelete('${m.id}')">archive</button>`
|
||||
}
|
||||
</div>
|
||||
${isExpanded ? `
|
||||
<div class="detail-panel open" onclick="event.stopPropagation()">
|
||||
<div class="file-label">File Content</div>
|
||||
<div class="file-content">${marked.parse(m._file_content || '*(file not available)*')}</div>
|
||||
<div class="provenance">
|
||||
<span>session <code>${m.session_id?.slice(0, 8) || '—'}</code></span>
|
||||
<span>messages <code>${m.message_start?.slice(0, 8) || '—'}</code> → <code>${m.message_end?.slice(0, 8) || '—'}</code></span>
|
||||
<span>id <code>${m.id}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
currentTab = tab.dataset.tab;
|
||||
expandedId = null;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user