Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>agentmemory viewer</title> | |
| <link rel="icon" type="image/svg+xml" href="/favicon.svg"> | |
| <!-- Removed Google Fonts <link> in #323: the viewer CSP is strict | |
| (default-src 'none', style-src 'unsafe-inline', font-src 'self') | |
| and external stylesheets from fonts.googleapis.com were blocked, | |
| producing console CSP violations that some browsers (Windows | |
| Edge in particular) escalate into broader script-execution | |
| issues. System-font fallbacks are already declared on every | |
| --font-* CSS variable below, so the viewer renders fine with | |
| Georgia / system serif / system sans / system mono. --> | |
| <style> | |
| :root { | |
| --bg: #F9F9F7; | |
| --bg-alt: #F0F0EC; | |
| --bg-subtle: #F4F4F0; | |
| --bg-inset: #E8E8E3; | |
| --border: #111111; | |
| --border-light: #D4D4CF; | |
| --border-heavy: #111111; | |
| --ink: #111111; | |
| --ink-secondary: #333333; | |
| --ink-muted: #666666; | |
| --ink-faint: #999999; | |
| --accent: #CC0000; | |
| --accent-light: #FF1A1A; | |
| --cream: #F5F0E8; | |
| --node-file: #2D6A4F; | |
| --node-function: #1D4E89; | |
| --node-concept: #B8860B; | |
| --node-error: #CC0000; | |
| --node-decision: #6B3FA0; | |
| --node-pattern: #2563EB; | |
| --node-library: #C2410C; | |
| --node-person: #111111; | |
| --green: #2D6A4F; | |
| --blue: #1D4E89; | |
| --yellow: #B8860B; | |
| --red: #CC0000; | |
| --purple: #6B3FA0; | |
| --orange: #C2410C; | |
| --cyan: #0E7490; | |
| --font-display: 'Playfair Display', Georgia, 'Times New Roman', serif; | |
| --font-body: 'Lora', Georgia, serif; | |
| --font-ui: 'Inter', -apple-system, sans-serif; | |
| --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; | |
| } | |
| html[data-theme="dark"] { | |
| --bg: #1a1a1e; | |
| --bg-alt: #232328; | |
| --bg-subtle: #1f1f24; | |
| --bg-inset: #2a2a30; | |
| --border: #444; | |
| --border-light: #3a3a42; | |
| --border-heavy: #ccc; | |
| --ink: #eee; | |
| --ink-secondary: #ccc; | |
| --ink-muted: #999; | |
| --ink-faint: #777; | |
| --cream: #2a2520; | |
| } | |
| html[data-theme="dark"] body { | |
| background-image: radial-gradient(circle, #3a3a42 0.5px, transparent 0.5px); | |
| } | |
| html[data-theme="dark"] .graph-tooltip { | |
| background: rgba(30,30,35,0.92); | |
| border-color: rgba(255,255,255,0.1); | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.4); | |
| } | |
| html[data-theme="dark"] .graph-controls button { | |
| background: rgba(30,30,35,0.92); | |
| border-color: rgba(255,255,255,0.1); | |
| } | |
| html[data-theme="dark"] .graph-controls button:hover { | |
| background: var(--ink); | |
| color: var(--bg); | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: var(--font-body); | |
| background: var(--bg); | |
| color: var(--ink-secondary); | |
| line-height: 1.6; | |
| overflow: hidden; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| background-image: radial-gradient(circle, #D4D4CF 0.5px, transparent 0.5px); | |
| background-size: 16px 16px; | |
| } | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: var(--bg); } | |
| ::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 0; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); } | |
| .app-header { | |
| padding: 10px 24px; | |
| border-bottom: 4px solid var(--border-heavy); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| background: var(--bg); | |
| flex: 0 0 auto; | |
| position: relative; | |
| z-index: 3; | |
| } | |
| .app-header .brand { | |
| display: flex; | |
| align-items: baseline; | |
| gap: 10px; | |
| color: inherit; | |
| text-decoration: none; | |
| cursor: pointer; | |
| } | |
| .app-header .brand:hover h1, | |
| .app-header .brand:focus-visible h1 { color: var(--accent); } | |
| .app-header .brand:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; } | |
| .app-header .brand h1 { | |
| font-size: 22px; | |
| color: var(--ink); | |
| font-weight: 900; | |
| font-family: var(--font-display); | |
| letter-spacing: -0.02em; | |
| text-transform: lowercase; | |
| } | |
| .app-header .brand .version { | |
| font-size: 10px; | |
| color: var(--ink-faint); | |
| font-family: var(--font-mono); | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| } | |
| .header-right { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .ws-status { | |
| font-size: 10px; | |
| padding: 3px 10px; | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| font-family: var(--font-ui); | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| font-weight: 600; | |
| border: 1px solid var(--border-light); | |
| } | |
| .ws-status::before { | |
| content: ''; | |
| width: 6px; | |
| height: 6px; | |
| display: inline-block; | |
| } | |
| .ws-status.connected { border-color: var(--green); color: var(--green); } | |
| .ws-status.connected::before { background: var(--green); } | |
| .ws-status.disconnected { border-color: var(--ink-faint); color: var(--ink-faint); } | |
| .ws-status.disconnected::before { background: var(--ink-faint); } | |
| .tab-bar { | |
| display: flex; | |
| height: 48px; | |
| flex-shrink: 0; | |
| border-bottom: 1px solid var(--border-light); | |
| background: var(--bg); | |
| overflow-x: auto; | |
| flex: 0 0 auto; | |
| position: relative; | |
| z-index: 2; | |
| } | |
| .tab-bar button { | |
| background: none; | |
| border: none; | |
| color: var(--ink-muted); | |
| padding: 10px 20px; | |
| font-size: 11px; | |
| cursor: pointer; | |
| border-bottom: 2px solid transparent; | |
| white-space: nowrap; | |
| font-family: var(--font-ui); | |
| text-transform: uppercase; | |
| letter-spacing: 0.12em; | |
| font-weight: 600; | |
| transition: color 0.15s, border-color 0.15s; | |
| } | |
| .tab-bar button:hover { color: var(--ink); } | |
| .tab-bar button.active { | |
| color: var(--ink); | |
| border-bottom-color: var(--accent); | |
| } | |
| .view { display: none; flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 24px; } | |
| .view.active { display: block; } | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| gap: 0; | |
| margin-bottom: 24px; | |
| border: 1px solid var(--border); | |
| } | |
| .stat-card { | |
| background: var(--bg); | |
| padding: 16px 20px; | |
| border-right: 1px solid var(--border-light); | |
| border-bottom: 1px solid var(--border-light); | |
| } | |
| .stat-card:last-child { border-right: none; } | |
| .stat-card .label { | |
| font-size: 9px; | |
| color: var(--ink-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.15em; | |
| margin-bottom: 4px; | |
| font-family: var(--font-ui); | |
| font-weight: 600; | |
| } | |
| .stat-card .value { | |
| font-size: 32px; | |
| font-weight: 900; | |
| color: var(--ink); | |
| font-family: var(--font-display); | |
| line-height: 1.1; | |
| } | |
| .stat-card .sub { | |
| font-size: 11px; | |
| color: var(--ink-faint); | |
| margin-top: 2px; | |
| font-family: var(--font-ui); | |
| } | |
| .card { | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| padding: 20px; | |
| margin-bottom: 16px; | |
| transition: box-shadow 0.15s; | |
| } | |
| .card:hover { | |
| box-shadow: 4px 4px 0px 0px var(--border); | |
| } | |
| .card-title { | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: var(--ink); | |
| margin-bottom: 12px; | |
| font-family: var(--font-display); | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| padding-bottom: 8px; | |
| border-bottom: 1px solid var(--border-light); | |
| } | |
| .health-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 8px; | |
| } | |
| .health-dot { | |
| width: 10px; | |
| height: 10px; | |
| } | |
| .health-dot.healthy { background: var(--green); } | |
| .health-dot.degraded { background: var(--yellow); } | |
| .health-dot.critical { background: var(--accent); } | |
| .badge { | |
| display: inline-block; | |
| font-size: 9px; | |
| padding: 2px 8px; | |
| font-weight: 600; | |
| font-family: var(--font-ui); | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| border: 1px solid; | |
| } | |
| .badge-blue { border-color: var(--blue); color: var(--blue); background: transparent; } | |
| .badge-green { border-color: var(--green); color: var(--green); background: transparent; } | |
| .badge-yellow { border-color: var(--yellow); color: var(--yellow); background: transparent; } | |
| .badge-red { border-color: var(--accent); color: var(--accent); background: transparent; } | |
| .badge-purple { border-color: var(--purple); color: var(--purple); background: transparent; } | |
| .badge-orange { border-color: var(--orange); color: var(--orange); background: transparent; } | |
| .badge-cyan { border-color: var(--cyan); color: var(--cyan); background: transparent; } | |
| .badge-muted { border-color: var(--border-light); color: var(--ink-muted); background: transparent; } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 13px; | |
| font-family: var(--font-body); | |
| } | |
| th { | |
| text-align: left; | |
| padding: 8px 12px; | |
| border-bottom: 2px solid var(--border); | |
| color: var(--ink); | |
| font-size: 9px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.12em; | |
| font-weight: 600; | |
| font-family: var(--font-ui); | |
| } | |
| td { | |
| padding: 8px 12px; | |
| border-bottom: 1px solid var(--border-light); | |
| vertical-align: top; | |
| } | |
| tr:hover td { background: var(--bg-alt); } | |
| .strength-bar { | |
| width: 60px; | |
| height: 4px; | |
| background: var(--bg-inset); | |
| overflow: hidden; | |
| display: inline-block; | |
| vertical-align: middle; | |
| } | |
| .strength-bar .fill { | |
| height: 100%; | |
| transition: width 0.3s; | |
| } | |
| .toolbar { | |
| display: flex; | |
| gap: 10px; | |
| margin-bottom: 20px; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| .toolbar input, .toolbar select { | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| color: var(--ink); | |
| padding: 7px 12px; | |
| font-size: 13px; | |
| outline: none; | |
| font-family: var(--font-ui); | |
| } | |
| .toolbar input:focus, .toolbar select:focus { | |
| border-color: var(--ink); | |
| box-shadow: 2px 2px 0px 0px var(--border); | |
| } | |
| .toolbar input { flex: 1; min-width: 200px; } | |
| .btn { | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| color: var(--ink); | |
| padding: 7px 16px; | |
| font-size: 11px; | |
| cursor: pointer; | |
| transition: box-shadow 0.1s, transform 0.1s; | |
| font-family: var(--font-ui); | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| } | |
| .btn:hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); } | |
| .btn:active { box-shadow: none; transform: translate(0, 0); } | |
| .btn-danger { border-color: var(--accent); color: var(--accent); } | |
| .btn-danger:hover { background: var(--accent); color: white; box-shadow: 3px 3px 0px 0px var(--border); } | |
| .btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); } | |
| .btn-primary:hover { background: var(--ink-secondary); box-shadow: 3px 3px 0px 0px var(--ink-muted); } | |
| .graph-container { | |
| display: flex; | |
| height: calc(100vh - 130px); | |
| margin: -24px; | |
| border-top: 1px solid var(--border-light); | |
| } | |
| .graph-canvas-wrap { | |
| flex: 1; | |
| position: relative; | |
| overflow: hidden; | |
| background: var(--bg); | |
| } | |
| .graph-canvas-wrap canvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .graph-sidebar { | |
| width: 260px; | |
| border-left: 2px solid var(--border); | |
| padding: 20px; | |
| overflow-y: auto; | |
| background: var(--bg); | |
| } | |
| .graph-sidebar h3 { | |
| font-size: 9px; | |
| color: var(--ink); | |
| text-transform: uppercase; | |
| letter-spacing: 0.15em; | |
| margin-bottom: 12px; | |
| font-family: var(--font-ui); | |
| font-weight: 600; | |
| padding-bottom: 6px; | |
| border-bottom: 1px solid var(--border-light); | |
| } | |
| .filter-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 4px 0; | |
| font-size: 12px; | |
| cursor: pointer; | |
| font-family: var(--font-ui); | |
| } | |
| .filter-item input[type="checkbox"] { | |
| accent-color: var(--ink); | |
| } | |
| .filter-dot { | |
| width: 8px; | |
| height: 8px; | |
| display: inline-block; | |
| } | |
| .graph-info { | |
| margin-top: 16px; | |
| padding-top: 16px; | |
| border-top: 1px solid var(--border-light); | |
| } | |
| .graph-info .info-row { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 12px; | |
| padding: 3px 0; | |
| font-family: var(--font-ui); | |
| } | |
| .graph-info .info-row .info-label { color: var(--ink-muted); } | |
| .graph-info .info-row .info-value { color: var(--ink); font-weight: 600; font-family: var(--font-mono); } | |
| .obs-card { | |
| background: var(--bg); | |
| border: 1px solid var(--border-light); | |
| padding: 16px 20px; | |
| margin-bottom: 12px; | |
| border-left: 3px solid var(--border-light); | |
| transition: box-shadow 0.15s; | |
| min-width: 0; | |
| max-width: 100%; | |
| overflow: hidden; | |
| } | |
| .obs-card:hover { box-shadow: 3px 3px 0px 0px var(--border-light); } | |
| .obs-card.imp-high { border-left-color: var(--accent); } | |
| .obs-card.imp-med { border-left-color: var(--yellow); } | |
| .obs-card.imp-low { border-left-color: var(--green); } | |
| .obs-card .obs-head { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) auto; | |
| align-items: start; | |
| gap: 12px; | |
| margin-bottom: 6px; | |
| } | |
| .obs-card .obs-title-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| min-width: 0; | |
| } | |
| .obs-card .obs-meta { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| flex: 0 0 auto; | |
| white-space: nowrap; | |
| } | |
| .obs-card .obs-type-icon { | |
| flex: 0 0 auto; | |
| } | |
| .obs-card .obs-title { | |
| font-size: 14px; | |
| font-weight: 700; | |
| color: var(--ink); | |
| font-family: var(--font-display); | |
| min-width: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .obs-card .obs-time { | |
| font-size: 10px; | |
| color: var(--ink-faint); | |
| font-family: var(--font-mono); | |
| letter-spacing: 0.04em; | |
| } | |
| .obs-card .obs-narrative { | |
| font-size: 13px; | |
| color: var(--ink-muted); | |
| margin-bottom: 6px; | |
| overflow-wrap: anywhere; | |
| word-break: break-word; | |
| } | |
| .obs-card pre { | |
| max-width: 100%; | |
| white-space: pre-wrap; | |
| overflow-wrap: anywhere; | |
| word-break: break-word; | |
| } | |
| .obs-card .obs-facts { | |
| margin: 6px 0 6px 16px; | |
| font-size: 12px; | |
| color: var(--ink-muted); | |
| } | |
| .obs-card .obs-facts li { margin-bottom: 2px; } | |
| .tag-list { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; } | |
| mark { background: rgba(204, 0, 0, 0.12); color: var(--ink); padding: 0 2px; border-radius: 2px; } | |
| .tag { | |
| font-size: 10px; | |
| padding: 1px 6px; | |
| border: 1px solid var(--blue); | |
| color: var(--blue); | |
| font-family: var(--font-mono); | |
| font-weight: 500; | |
| max-width: 100%; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .tag.file-tag { border-color: var(--green); color: var(--green); } | |
| .session-list { display: flex; flex-direction: column; gap: 0; } | |
| .session-item { | |
| background: var(--bg); | |
| border: 1px solid var(--border-light); | |
| border-bottom: none; | |
| padding: 14px 20px; | |
| cursor: pointer; | |
| transition: background 0.1s; | |
| } | |
| .session-item:last-child { border-bottom: 1px solid var(--border-light); } | |
| .session-item:hover { background: var(--bg-alt); } | |
| .session-item.selected { background: var(--bg-alt); border-left: 3px solid var(--accent); } | |
| .session-item .session-top { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 4px; | |
| } | |
| .session-item .session-project { | |
| font-weight: 700; | |
| color: var(--ink); | |
| font-size: 14px; | |
| font-family: var(--font-display); | |
| } | |
| .session-item .session-meta { | |
| font-size: 11px; | |
| color: var(--ink-muted); | |
| font-family: var(--font-mono); | |
| } | |
| .detail-panel { | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| padding: 24px; | |
| margin-top: 20px; | |
| } | |
| .detail-panel h3 { | |
| font-size: 15px; | |
| font-weight: 700; | |
| color: var(--ink); | |
| margin-bottom: 16px; | |
| font-family: var(--font-display); | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| padding-bottom: 8px; | |
| border-bottom: 2px solid var(--border); | |
| } | |
| .detail-row { | |
| display: flex; | |
| padding: 6px 0; | |
| font-size: 13px; | |
| border-bottom: 1px solid var(--bg-inset); | |
| } | |
| .detail-row .dl { color: var(--ink-muted); width: 140px; flex-shrink: 0; font-family: var(--font-ui); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; padding-top: 2px; } | |
| .detail-row .dv { color: var(--ink); font-family: var(--font-body); } | |
| .audit-entry { | |
| padding: 12px 0; | |
| border-bottom: 1px solid var(--border-light); | |
| font-size: 13px; | |
| } | |
| .audit-entry:last-child { border-bottom: none; } | |
| .audit-head { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 4px; | |
| } | |
| .audit-detail { | |
| font-size: 12px; | |
| color: var(--ink-faint); | |
| margin-top: 4px; | |
| max-height: 0; | |
| overflow: hidden; | |
| transition: max-height 0.2s; | |
| } | |
| .audit-detail.open { max-height: 200px; } | |
| .audit-detail pre { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| background: var(--bg-alt); | |
| padding: 10px; | |
| border: 1px solid var(--border-light); | |
| overflow-x: auto; | |
| } | |
| .bar-chart { margin-top: 8px; } | |
| .bar-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 6px; | |
| font-size: 12px; | |
| } | |
| .bar-label { width: 120px; color: var(--ink-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); font-size: 11px; } | |
| .bar-track { | |
| flex: 1; | |
| height: 6px; | |
| background: var(--bg-inset); | |
| overflow: hidden; | |
| } | |
| .bar-fill { | |
| height: 100%; | |
| transition: width 0.3s; | |
| } | |
| .bar-value { width: 30px; text-align: right; color: var(--ink-muted); font-size: 11px; font-family: var(--font-mono); font-weight: 500; } | |
| .empty-state { | |
| text-align: center; | |
| padding: 60px 20px; | |
| color: var(--ink-faint); | |
| } | |
| .empty-state .empty-icon { font-size: 36px; margin-bottom: 10px; opacity: 0.4; } | |
| .empty-state p { font-size: 14px; font-family: var(--font-body); font-style: italic; } | |
| .empty-state .empty-title { font-size: 16px; font-weight: 600; font-style: normal; color: var(--ink-muted); margin-bottom: 8px; } | |
| .empty-state .empty-lead { font-style: normal; font-size: 14px; color: var(--ink-muted); max-width: 520px; margin: 0 auto 14px; line-height: 1.5; } | |
| .empty-state pre.empty-cmd { | |
| display: inline-block; margin: 10px auto 12px; padding: 10px 14px; | |
| background: var(--bg-alt); border: 1px solid var(--border); | |
| border-radius: 4px; font-family: var(--font-mono); font-size: 12px; | |
| color: var(--ink); text-align: left; font-style: normal; white-space: pre; | |
| } | |
| .empty-state .empty-link { color: var(--accent); text-decoration: underline; font-size: 13px; font-style: normal; } | |
| /* Feature flag banner system — compact collapsed by default */ | |
| .flag-banners { | |
| padding: 0 12px 10px 12px; | |
| background: var(--bg); | |
| flex: 0 0 auto; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .flag-banners:empty { display: none; } | |
| button.flag-summary { | |
| display: flex; align-items: center; gap: 12px; | |
| padding: 8px 14px; border-radius: 4px; | |
| border: 1px solid var(--border); | |
| background: var(--bg-subtle); | |
| font-family: var(--font-ui); font-size: 12px; | |
| color: var(--ink-muted); | |
| cursor: pointer; user-select: none; | |
| width: 100%; text-align: left; | |
| appearance: none; | |
| flex: 1 1 auto; | |
| } | |
| button.flag-summary:hover, | |
| button.flag-summary:focus-visible { background: var(--bg-alt); outline: 2px solid var(--border); outline-offset: 1px; } | |
| .flag-summary .flag-count { color: var(--ink); font-weight: 600; } | |
| .flag-summary .flag-pill { | |
| display: inline-block; padding: 1px 8px; border-radius: 10px; | |
| background: #f59e0b20; color: #d97706; font-size: 11px; font-weight: 600; | |
| margin-right: 6px; | |
| } | |
| .flag-summary .flag-pill.info { background: var(--border-light); color: var(--ink-muted); } | |
| .flag-summary .flag-toggle { margin-left: auto; font-size: 11px; opacity: 0.7; } | |
| .flag-list { | |
| display: none; flex-direction: column; gap: 6px; | |
| margin-top: 6px; | |
| max-height: min(30vh, 260px); | |
| overflow-y: auto; | |
| } | |
| .flag-list.open { display: flex; } | |
| .flag-banner { | |
| display: flex; align-items: flex-start; gap: 10px; | |
| padding: 10px 14px; border-radius: 3px; | |
| border: 1px solid var(--border); | |
| background: var(--bg-subtle); | |
| font-family: var(--font-ui); font-size: 12px; | |
| } | |
| .flag-banner.warn { border-left: 3px solid #f59e0b; } | |
| .flag-banner.info { border-left: 3px solid var(--ink-muted); } | |
| .flag-banner .flag-icon { flex-shrink: 0; font-size: 14px; line-height: 1.3; } | |
| .flag-banner .flag-body { flex: 1; min-width: 0; } | |
| .flag-banner .flag-title { font-weight: 600; color: var(--ink); margin-bottom: 2px; font-size: 12px; } | |
| .flag-banner .flag-title code { font-family: var(--font-mono); font-size: 10px; color: var(--ink-muted); font-weight: 400; margin-left: 4px; } | |
| .flag-banner .flag-desc { color: var(--ink-muted); margin-bottom: 4px; line-height: 1.4; font-size: 12px; } | |
| .flag-banner .flag-enable { | |
| display: block; margin-top: 2px; padding: 5px 8px; | |
| background: var(--bg); border: 1px solid var(--border); border-radius: 3px; | |
| font-family: var(--font-mono); font-size: 10px; color: var(--ink); | |
| white-space: pre-wrap; word-break: break-all; | |
| } | |
| .flag-close { | |
| background: none; border: none; color: var(--ink-faint); cursor: pointer; | |
| font-size: 16px; line-height: 1; padding: 0 4px; font-family: inherit; | |
| flex: 0 0 auto; | |
| } | |
| .flag-close:hover, | |
| .flag-close:focus-visible { color: var(--ink); outline: 2px solid var(--border); outline-offset: 1px; } | |
| .viewer-auth { | |
| display: none; | |
| padding: 0 24px 10px 24px; | |
| background: var(--bg); | |
| flex: 0 0 auto; | |
| position: relative; | |
| z-index: 1; | |
| } | |
| .viewer-auth.open { display: block; } | |
| .viewer-auth-panel { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) minmax(180px, 320px) auto; | |
| gap: 10px; | |
| align-items: center; | |
| width: min(960px, 100%); | |
| max-width: 960px; | |
| padding: 10px 14px; | |
| border: 1px solid var(--border); | |
| border-left: 3px solid var(--accent); | |
| background: var(--bg-subtle); | |
| font-family: var(--font-ui); | |
| font-size: 12px; | |
| } | |
| .viewer-auth-title { font-weight: 600; color: var(--ink); margin-bottom: 2px; } | |
| .viewer-auth-desc { color: var(--ink-muted); line-height: 1.4; } | |
| .viewer-auth-desc code { font-family: var(--font-mono); font-size: 10px; color: var(--ink); } | |
| .viewer-auth input { | |
| width: 100%; | |
| min-width: 0; | |
| padding: 7px 9px; | |
| border: 1px solid var(--border); | |
| background: var(--bg); | |
| color: var(--ink); | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| } | |
| @media (max-width: 900px) { | |
| .viewer-auth-panel { grid-template-columns: 1fr; } | |
| } | |
| /* Viewer footer */ | |
| .viewer-footer { | |
| margin-top: 48px; padding: 16px 0 24px; | |
| border-top: 1px solid var(--border-light); | |
| display: flex; align-items: center; gap: 10px; | |
| font-family: var(--font-ui); font-size: 11px; | |
| color: var(--ink-faint); letter-spacing: 0.05em; | |
| } | |
| .viewer-footer a { color: var(--ink-muted); text-decoration: none; } | |
| .viewer-footer a:hover { color: var(--ink); text-decoration: underline; } | |
| .viewer-footer .footer-sep { color: var(--ink-faint); opacity: 0.5; } | |
| .loading { color: var(--ink-faint); padding: 20px; text-align: center; font-style: italic; font-family: var(--font-body); } | |
| .empty { color: var(--ink-muted); padding: 24px; text-align: center; font-family: var(--font-body); font-style: italic; border: 1px dashed var(--border); } | |
| .replay-controls { display: flex; align-items: center; gap: 6px; padding: 10px 0; flex-wrap: wrap; font-family: var(--font-ui); font-size: 12px; } | |
| .replay-controls button { padding: 4px 10px; border: 1px solid var(--border); background: var(--bg); color: var(--ink); cursor: pointer; font-family: var(--font-ui); font-size: 12px; } | |
| .replay-controls button:hover { background: var(--bg-alt); } | |
| .replay-controls button.active { background: var(--ink); color: var(--bg); } | |
| .replay-controls .sep { width: 12px; } | |
| .replay-progress { height: 3px; background: var(--border-light); margin: 4px 0 12px 0; } | |
| .replay-progress-bar { height: 100%; background: var(--ink); transition: width 100ms linear; } | |
| .replay-grid { display: grid; grid-template-columns: 340px 1fr; gap: 16px; align-items: start; } | |
| .replay-list { max-height: 60vh; overflow-y: auto; border: 1px solid var(--border); } | |
| .replay-event { display: grid; grid-template-columns: 90px 1fr 60px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-light); font-family: var(--font-ui); font-size: 11px; cursor: default; } | |
| .replay-event:hover { background: var(--bg-alt); } | |
| .replay-event-active { background: var(--bg-alt); border-left: 2px solid var(--ink); } | |
| .replay-event-kind { font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-muted); align-self: center; } | |
| .replay-event-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .replay-event-time { text-align: right; font-family: var(--font-mono); color: var(--ink-muted); } | |
| .replay-event-prompt .replay-event-kind { color: var(--blue, #0366d6); } | |
| .replay-event-response .replay-event-kind { color: var(--green, #2ea043); } | |
| .replay-event-tool_call .replay-event-kind { color: var(--orange, #bf8700); } | |
| .replay-event-tool_result .replay-event-kind { color: var(--ink-muted); } | |
| .replay-event-tool_error .replay-event-kind { color: var(--red, #cf222e); } | |
| .replay-detail { border: 1px solid var(--border); padding: 14px; max-height: 60vh; overflow-y: auto; font-family: var(--font-body); font-size: 13px; } | |
| .replay-detail-header { margin-bottom: 6px; } | |
| .replay-body { background: var(--bg-alt); padding: 10px; white-space: pre-wrap; word-break: break-word; font-family: var(--font-mono); font-size: 12px; } | |
| .replay-tool { margin-top: 10px; font-family: var(--font-ui); font-size: 12px; } | |
| .replay-tool-block { margin-top: 8px; } | |
| .replay-tool-block pre { background: var(--bg-alt); padding: 10px; max-height: 240px; overflow: auto; font-family: var(--font-mono); font-size: 11px; white-space: pre-wrap; word-break: break-word; } | |
| .muted { color: var(--ink-muted); font-size: 11px; } | |
| .metric-table { width: 100%; border-collapse: collapse; font-size: 12px; } | |
| .metric-table th { padding: 6px 8px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-muted); border-bottom: 2px solid var(--border); text-align: left; font-family: var(--font-ui); font-weight: 600; } | |
| .metric-table td { padding: 5px 8px; border-bottom: 1px solid var(--border-light); } | |
| .metric-table tr:hover td { background: var(--bg-alt); } | |
| .metric-fn { font-family: var(--font-mono); font-size: 11px; color: var(--blue); } | |
| .metric-num { font-family: var(--font-mono); color: var(--ink); text-align: right; } | |
| .cb-indicator { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; font-size: 10px; font-weight: 600; font-family: var(--font-ui); text-transform: uppercase; letter-spacing: 0.08em; border: 1px solid; } | |
| .cb-closed { border-color: var(--green); color: var(--green); } | |
| .cb-open { border-color: var(--accent); color: var(--accent); } | |
| .cb-half-open { border-color: var(--yellow); color: var(--yellow); } | |
| .worker-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; font-family: var(--font-ui); } | |
| .worker-dot { width: 8px; height: 8px; } | |
| .worker-dot.running { background: var(--green); } | |
| .worker-dot.stopped { background: var(--accent); } | |
| .worker-dot.starting { background: var(--yellow); } | |
| .gauge { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } | |
| .gauge-bar { flex: 1; height: 6px; background: var(--bg-inset); overflow: hidden; } | |
| .gauge-fill { height: 100%; transition: width 0.5s; } | |
| .gauge-label { width: 90px; font-size: 10px; color: var(--ink-muted); font-family: var(--font-ui); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; } | |
| .gauge-value { width: 70px; font-size: 11px; color: var(--ink); text-align: right; font-family: var(--font-mono); } | |
| .obs-type-icon { font-size: 16px; margin-right: 4px; } | |
| .obs-subtitle { font-size: 12px; color: var(--ink-faint); margin-top: 2px; font-style: italic; } | |
| .obs-importance { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; font-size: 11px; font-weight: 700; font-family: var(--font-mono); border: 1px solid; } | |
| .imp-1, .imp-2, .imp-3 { border-color: var(--green); color: var(--green); } | |
| .imp-4, .imp-5, .imp-6 { border-color: var(--yellow); color: var(--yellow); } | |
| .imp-7, .imp-8, .imp-9, .imp-10 { border-color: var(--accent); color: var(--accent); } | |
| .three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; } | |
| @media (max-width: 1100px) { .three-col { grid-template-columns: 1fr 1fr; } } | |
| @media (max-width: 768px) { .three-col { grid-template-columns: 1fr; } } | |
| .pagination { | |
| display: flex; | |
| justify-content: center; | |
| gap: 8px; | |
| margin-top: 20px; | |
| } | |
| .modal-overlay { | |
| display: none; | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(0,0,0,0.3); | |
| z-index: 100; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modal-overlay.open { display: flex; } | |
| .modal { | |
| background: var(--bg); | |
| border: 2px solid var(--border); | |
| padding: 28px; | |
| max-width: 460px; | |
| width: 90%; | |
| box-shadow: 6px 6px 0px 0px var(--border); | |
| } | |
| .modal h3 { | |
| font-size: 18px; | |
| font-weight: 700; | |
| color: var(--ink); | |
| margin-bottom: 12px; | |
| font-family: var(--font-display); | |
| } | |
| .modal p { font-size: 13px; color: var(--ink-muted); margin-bottom: 16px; } | |
| .modal-actions { | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 8px; | |
| } | |
| .selected-node-info { | |
| margin-top: 16px; | |
| padding-top: 16px; | |
| border-top: 1px solid var(--border-light); | |
| } | |
| .selected-node-info h4 { | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: var(--ink); | |
| margin-bottom: 6px; | |
| font-family: var(--font-display); | |
| } | |
| .selected-node-info .prop { | |
| font-size: 12px; | |
| color: var(--ink-muted); | |
| padding: 2px 0; | |
| font-family: var(--font-ui); | |
| } | |
| .two-col { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 16px; | |
| } | |
| @media (max-width: 768px) { | |
| .two-col { grid-template-columns: 1fr; } | |
| .graph-sidebar { width: 200px; } | |
| .stats-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } | |
| } | |
| .section-rule { | |
| border: none; | |
| border-top: 1px solid var(--border-light); | |
| margin: 20px 0; | |
| } | |
| .dateline { | |
| font-family: var(--font-mono); | |
| font-size: 10px; | |
| color: var(--ink-faint); | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| } | |
| .timeline-container { position: relative; padding: 20px 0; } | |
| .timeline-container::before { content: ''; position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; background: var(--border-light); transform: translateX(-50%); } | |
| .timeline-item { position: relative; width: 45%; margin-bottom: 20px; min-width: 0; } | |
| .timeline-item.tl-left { margin-left: 0; margin-right: auto; text-align: right; padding-right: 30px; } | |
| .timeline-item.tl-right { margin-left: auto; margin-right: 0; padding-left: 30px; } | |
| .timeline-dot { position: absolute; width: 12px; height: 12px; border-radius: 50%; top: 16px; z-index: 1; border: 2px solid var(--bg); } | |
| .timeline-item.tl-left .timeline-dot { right: -6px; transform: translateX(50%); } | |
| .timeline-item.tl-right .timeline-dot { left: -6px; transform: translateX(-50%); } | |
| .timeline-connector { position: absolute; top: 21px; height: 1px; background: var(--border-light); width: 24px; } | |
| .timeline-item.tl-left .timeline-connector { right: 0; } | |
| .timeline-item.tl-right .timeline-connector { left: 0; } | |
| .timeline-date-marker { text-align: center; position: relative; margin: 24px 0 16px; z-index: 2; } | |
| .timeline-date-marker span { background: var(--bg); padding: 4px 16px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-muted); border: 1px solid var(--border-light); } | |
| .heatmap-wrap { overflow-x: auto; padding: 8px 0; } | |
| .heatmap-grid { display: grid; grid-template-rows: repeat(7, 1fr); grid-auto-flow: column; grid-auto-columns: 12px; gap: 2px; } | |
| .heatmap-cell { width: 10px; height: 10px; background: var(--bg-inset); cursor: default; } | |
| .heatmap-cell[title] { cursor: pointer; } | |
| .heatmap-cell.level-1 { background: rgba(45,106,79,0.2); } | |
| .heatmap-cell.level-2 { background: rgba(45,106,79,0.4); } | |
| .heatmap-cell.level-3 { background: rgba(45,106,79,0.65); } | |
| .heatmap-cell.level-4 { background: var(--green); } | |
| .heatmap-labels { display: flex; gap: 2px; font-size: 9px; color: var(--ink-faint); font-family: var(--font-mono); margin-bottom: 4px; } | |
| .graph-search { width: 100%; background: var(--bg); border: 1px solid var(--border); padding: 7px 12px; font-size: 12px; font-family: var(--font-ui); margin-bottom: 12px; outline: none; } | |
| .graph-search:focus { border-color: var(--ink); box-shadow: 2px 2px 0px 0px var(--border); } | |
| .graph-legend { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-light); } | |
| .graph-legend-item { display: flex; align-items: center; gap: 6px; padding: 3px 0; font-size: 11px; font-family: var(--font-ui); color: var(--ink-muted); } | |
| .graph-legend-shape { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; } | |
| .graph-tooltip { position: absolute; background: rgba(255,255,255,0.88); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(17,17,17,0.08); padding: 12px 16px; font-size: 11px; font-family: var(--font-ui); pointer-events: none; z-index: 10; box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06); max-width: 260px; display: none; border-radius: 8px; transition: opacity 0.15s ease; } | |
| .graph-tooltip.visible { display: block; opacity: 1; } | |
| .graph-tooltip .tt-name { font-weight: 700; color: var(--ink); margin-bottom: 4px; font-family: var(--font-display); font-size: 13px; } | |
| .graph-tooltip .tt-type { font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 6px; font-weight: 600; padding: 2px 6px; border-radius: 3px; display: inline-block; } | |
| .graph-tooltip .tt-prop { font-size: 10px; color: var(--ink-muted); padding: 1px 0; } | |
| .graph-tooltip .tt-conns { font-size: 10px; color: var(--ink-faint); margin-top: 6px; border-top: 1px solid rgba(17,17,17,0.08); padding-top: 6px; font-family: var(--font-mono); } | |
| .graph-controls { position: absolute; bottom: 16px; right: 16px; display: flex; flex-direction: column; gap: 2px; z-index: 5; } | |
| .graph-controls button { width: 36px; height: 36px; font-size: 18px; cursor: pointer; background: rgba(255,255,255,0.92); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid rgba(17,17,17,0.1); color: var(--ink); display: flex; align-items: center; justify-content: center; font-weight: 500; font-family: var(--font-ui); border-radius: 6px; transition: all 0.15s ease; box-shadow: 0 2px 8px rgba(0,0,0,0.06); } | |
| .graph-controls button:hover { background: var(--ink); color: var(--bg); transform: scale(1.05); box-shadow: 0 4px 12px rgba(0,0,0,0.15); } | |
| .graph-controls .ctrl-divider { height: 1px; background: var(--border-light); margin: 2px 4px; } | |
| .type-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; } | |
| .type-chip { font-size: 10px; padding: 3px 10px; border: 1px solid var(--border-light); cursor: pointer; font-family: var(--font-ui); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; transition: all 0.15s; background: var(--bg); } | |
| .type-chip:hover { border-color: var(--ink); } | |
| .type-chip.active { background: var(--ink); color: var(--bg); border-color: var(--ink); } | |
| .memory-fact { padding: 8px 0; border-bottom: 1px solid var(--border-light); font-size: 13px; display: flex; justify-content: space-between; align-items: center; gap: 8px; } | |
| .memory-fact:last-child { border-bottom: none; } | |
| .procedure-item { padding: 10px 0; border-bottom: 1px solid var(--border-light); } | |
| .procedure-item:last-child { border-bottom: none; } | |
| .procedure-steps { margin: 6px 0 0 16px; font-size: 12px; color: var(--ink-muted); } | |
| .procedure-steps li { margin-bottom: 2px; } | |
| .consolidation-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; font-family: var(--font-ui); } | |
| .consolidation-row .cl { color: var(--ink-muted); } | |
| .consolidation-row .cv { color: var(--ink); font-weight: 600; font-family: var(--font-mono); } | |
| .activity-feed-item { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border-light); font-size: 13px; } | |
| .activity-feed-item:last-child { border-bottom: none; } | |
| .activity-feed-icon { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; border: 1px solid var(--border-light); } | |
| .activity-feed-body { flex: 1; min-width: 0; } | |
| .activity-feed-title { font-weight: 600; color: var(--ink); font-family: var(--font-display); font-size: 13px; } | |
| .activity-feed-meta { font-size: 10px; color: var(--ink-faint); font-family: var(--font-mono); margin-top: 2px; } | |
| /* Personal Second Brain styles */ | |
| .personal-grid { | |
| display: grid; | |
| grid-template-columns: 280px 1fr; | |
| gap: 20px; | |
| align-items: start; | |
| } | |
| @media (max-width: 768px) { | |
| .personal-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .personal-sidebar { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .personal-search-wrapper { | |
| position: relative; | |
| } | |
| .personal-search-input { | |
| width: 100%; | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| padding: 10px 14px; | |
| font-size: 13px; | |
| font-family: var(--font-ui); | |
| outline: none; | |
| transition: box-shadow 0.15s; | |
| } | |
| .personal-search-input:focus { | |
| box-shadow: 2px 2px 0px 0px var(--border); | |
| } | |
| .personal-file-list { | |
| display: flex; | |
| flex-direction: column; | |
| border: 1px solid var(--border-light); | |
| background: var(--bg); | |
| max-height: 500px; | |
| overflow-y: auto; | |
| } | |
| .personal-file-item { | |
| padding: 12px 16px; | |
| cursor: pointer; | |
| border-bottom: 1px solid var(--border-light); | |
| transition: background 0.1s, border-left 0.1s; | |
| font-family: var(--font-ui); | |
| font-size: 13px; | |
| color: var(--ink); | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .personal-file-item:last-child { | |
| border-bottom: none; | |
| } | |
| .personal-file-item:hover { | |
| background: var(--bg-alt); | |
| } | |
| .personal-file-item.selected { | |
| background: var(--bg-alt); | |
| border-left: 3px solid var(--accent); | |
| font-weight: 600; | |
| } | |
| .personal-file-name { | |
| flex: 1; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .personal-file-size { | |
| font-size: 10px; | |
| color: var(--ink-faint); | |
| font-family: var(--font-mono); | |
| } | |
| .personal-editor-card { | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| padding: 24px; | |
| min-height: 450px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| } | |
| .personal-editor-header { | |
| border-bottom: 1px solid var(--border-light); | |
| padding-bottom: 12px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| } | |
| .personal-editor-title { | |
| font-size: 16px; | |
| font-weight: 700; | |
| color: var(--ink); | |
| font-family: var(--font-display); | |
| } | |
| .personal-editor-meta { | |
| font-size: 11px; | |
| color: var(--ink-muted); | |
| font-family: var(--font-mono); | |
| } | |
| .personal-editor-textarea { | |
| width: 100%; | |
| height: 380px; | |
| background: var(--bg); | |
| color: var(--ink); | |
| border: 1px solid var(--border-light); | |
| padding: 14px; | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| line-height: 1.5; | |
| resize: vertical; | |
| outline: none; | |
| transition: border-color 0.15s, box-shadow 0.15s; | |
| } | |
| .personal-editor-textarea:focus { | |
| border-color: var(--border); | |
| box-shadow: 2px 2px 0px 0px var(--border); | |
| } | |
| .personal-actions { | |
| display: flex; | |
| justify-content: flex-end; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .personal-status-indicator { | |
| font-size: 12px; | |
| color: var(--green); | |
| font-weight: 500; | |
| font-family: var(--font-ui); | |
| opacity: 0; | |
| transition: opacity 0.35s ease; | |
| } | |
| .personal-status-indicator.visible { | |
| opacity: 1; | |
| } | |
| .personal-status-indicator.error { | |
| color: var(--accent); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app-header"> | |
| <a class="brand" href="#dashboard" data-tab-link="dashboard" aria-label="Open dashboard"> | |
| <h1>agentmemory</h1> | |
| <span class="version">v__AGENTMEMORY_VERSION__</span> | |
| </a> | |
| <div class="header-right"> | |
| <span class="dateline" id="dateline"></span> | |
| <button id="theme-toggle" class="btn" style="font-size:9px;padding:3px 10px;letter-spacing:0.1em;margin-right:8px;" data-action="toggle-theme">DARK</button> | |
| <span id="ws-status" class="ws-status disconnected">live updates off</span> | |
| </div> | |
| </div> | |
| <div class="tab-bar" id="tab-bar"> | |
| <button class="active" data-tab="dashboard" aria-current="page">Dashboard</button> | |
| <button data-tab="graph">Graph</button> | |
| <button data-tab="memories">Memories</button> | |
| <button data-tab="timeline">Timeline</button> | |
| <button data-tab="sessions">Sessions</button> | |
| <button data-tab="lessons">Lessons</button> | |
| <button data-tab="actions">Actions</button> | |
| <button data-tab="crystals">Crystals</button> | |
| <button data-tab="audit">Audit</button> | |
| <button data-tab="activity">Activity</button> | |
| <button data-tab="profile">Profile</button> | |
| <button data-tab="replay">Replay</button> | |
| <button data-tab="personal">Personal</button> | |
| </div> | |
| <div id="flag-banners" class="flag-banners"></div> | |
| <div id="viewer-auth" class="viewer-auth"></div> | |
| <div id="view-dashboard" class="view active"></div> | |
| <div id="view-graph" class="view"></div> | |
| <div id="view-memories" class="view"></div> | |
| <div id="view-lessons" class="view"></div> | |
| <div id="view-actions" class="view"></div> | |
| <div id="view-crystals" class="view"></div> | |
| <div id="view-timeline" class="view"></div> | |
| <div id="view-sessions" class="view"></div> | |
| <div id="view-audit" class="view"></div> | |
| <div id="view-activity" class="view"></div> | |
| <div id="view-profile" class="view"></div> | |
| <div id="view-replay" class="view"></div> | |
| <div id="view-personal" class="view"></div> | |
| <div id="modal-overlay" class="modal-overlay"> | |
| <div class="modal" id="modal"></div> | |
| </div> | |
| <footer id="viewer-footer" class="viewer-footer"> | |
| <span>agentmemory viewer · <span id="footer-version">loading...</span></span> | |
| <span class="footer-sep">·</span> | |
| <a href="https://github.com/rohitg00/agentmemory" target="_blank" rel="noopener">github</a> | |
| <span class="footer-sep">·</span> | |
| <a href="https://github.com/rohitg00/agentmemory#readme" target="_blank" rel="noopener">docs</a> | |
| <span class="footer-sep">·</span> | |
| <a id="footer-feedback" href="#" target="_blank" rel="noopener">report issue →</a> | |
| </footer> | |
| <script nonce="__AGENTMEMORY_VIEWER_NONCE__"> | |
| var params = new URLSearchParams(window.location.search); | |
| var paramPort = params.get('port'); | |
| var locPort = window.location.port; | |
| var wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| var REST, WS_URL, WS_DIRECT_URL, wsPort; | |
| if (paramPort) { | |
| var resolvedPort = paramPort; | |
| REST = window.location.protocol + '//' + window.location.hostname + ':' + resolvedPort; | |
| wsPort = params.get('wsPort') || resolvedPort; | |
| WS_URL = wsProto + '//' + window.location.hostname + ':' + wsPort; | |
| WS_DIRECT_URL = WS_URL + '/stream/mem-live/viewer'; | |
| } else if (locPort) { | |
| var resolvedPort = locPort; | |
| REST = window.location.protocol + '//' + window.location.hostname + ':' + resolvedPort; | |
| wsPort = params.get('wsPort') || resolvedPort; | |
| WS_URL = wsProto + '//' + window.location.hostname + ':' + wsPort; | |
| WS_DIRECT_URL = WS_URL + '/stream/mem-live/viewer'; | |
| } else { | |
| REST = window.location.origin; | |
| wsPort = params.get('wsPort'); | |
| WS_URL = wsPort | |
| ? wsProto + '//' + window.location.hostname + ':' + wsPort | |
| : wsProto + '//' + window.location.host; | |
| WS_DIRECT_URL = WS_URL + '/stream/mem-live/viewer'; | |
| } | |
| var dateEl = document.getElementById('dateline'); | |
| if (dateEl) dateEl.textContent = new Date().toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }); | |
| function isDarkMode() { return document.documentElement.dataset.theme === 'dark'; } | |
| function applyTheme(dark, persist) { | |
| document.documentElement.dataset.theme = dark ? 'dark' : 'light'; | |
| var btn = document.getElementById('theme-toggle'); | |
| if (btn) btn.textContent = dark ? 'LIGHT' : 'DARK'; | |
| if (persist) localStorage.setItem('agentmemory-theme', dark ? 'dark' : 'light'); | |
| } | |
| window.toggleTheme = function() { applyTheme(!isDarkMode(), true); }; | |
| var savedTheme = localStorage.getItem('agentmemory-theme'); | |
| if (savedTheme) { | |
| applyTheme(savedTheme === 'dark', false); | |
| } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { | |
| applyTheme(true, false); | |
| } | |
| var NODE_COLORS = { | |
| file: '#2D6A4F', function: '#1D4E89', concept: '#B8860B', error: '#CC0000', | |
| decision: '#6B3FA0', pattern: '#2563EB', library: '#C2410C', person: '#111111', | |
| folder: '#4F46E5' | |
| }; | |
| function folderColor(id) { | |
| var h = 0; | |
| for (var i = 0; i < id.length; i++) { h = (h * 31 + id.charCodeAt(i)) & 0xfffffff; } | |
| var hue = (h % 360 + 360) % 360; | |
| var sat = 55 + (h % 25); | |
| var lig = isDarkMode() ? 52 + (h % 16) : 38 + (h % 14); | |
| // Return hex so parseInt(color.slice(1,3),16) works in gradient code | |
| var s = sat / 100, l = lig / 100; | |
| var a = s * Math.min(l, 1 - l); | |
| function f(n) { | |
| var k = (n + hue / 30) % 12; | |
| var c = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); | |
| return Math.round(255 * c).toString(16).padStart(2, '0'); | |
| } | |
| return '#' + f(0) + f(8) + f(4); | |
| } | |
| var OP_BADGES = { | |
| observe: 'badge-blue', compress: 'badge-cyan', remember: 'badge-green', | |
| forget: 'badge-red', evolve: 'badge-purple', consolidate: 'badge-yellow', | |
| share: 'badge-orange', delete: 'badge-red', import: 'badge-blue', export: 'badge-blue' | |
| }; | |
| var TYPE_BADGES = { | |
| pattern: 'badge-purple', preference: 'badge-blue', architecture: 'badge-cyan', | |
| bug: 'badge-red', workflow: 'badge-green', fact: 'badge-yellow' | |
| }; | |
| var OBS_TYPE_COLORS = { | |
| file_read: '#1D4E89', file_write: '#2D6A4F', file_edit: '#B8860B', | |
| command_run: '#C2410C', search: '#2563EB', web_fetch: '#6B3FA0', | |
| conversation: '#111111', error: '#CC0000', decision: '#B8860B', | |
| discovery: '#2D6A4F', subagent: '#6B3FA0', notification: '#0E7490', | |
| task: '#1D4E89', other: '#666666' | |
| }; | |
| var OBS_TYPE_ICONS = { | |
| file_read: '📄', file_write: '✏', file_edit: '📝', | |
| command_run: '⚡', search: '🔎', web_fetch: '🌐', | |
| conversation: '💬', error: '⚠', decision: '🤔', | |
| discovery: '💡', subagent: '🤖', notification: '🔔', | |
| task: '☑', other: '📄' | |
| }; | |
| var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' }; | |
| var TAB_IDS = ['dashboard', 'graph', 'memories', 'timeline', 'sessions', 'lessons', 'actions', 'crystals', 'audit', 'activity', 'profile', 'replay', 'personal']; | |
| var VIEWER_TOKEN_STORAGE_KEY = 'agentmemory-viewer-token'; | |
| var state = { | |
| activeTab: 'dashboard', | |
| dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [], semantic: [], procedural: [] }, | |
| graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null, queryError: null, truncated: false, totalNodes: 0, totalEdges: 0 }, | |
| memories: { loaded: false, items: [], search: '', typeFilter: '', folderFilter: '' }, | |
| timeline: { loaded: false, observations: [], sessionId: '', minImportance: 0, page: 0, pageSize: 50 }, | |
| sessions: { loaded: false, items: [], selectedId: null, folderFilter: '' }, | |
| audit: { loaded: false, entries: [], opFilter: '' }, | |
| activity: { loaded: false, observations: [], sessions: [], typeFilter: '' }, | |
| lessons: { loaded: false, items: [], search: '' }, | |
| actions: { loaded: false, items: [], frontier: [], statusFilter: '', search: '' }, | |
| crystals: { loaded: false, items: [], search: '', lessonMap: {} }, | |
| profile: { loaded: false, projects: [], selectedProject: '', data: null }, | |
| replay: { loaded: false, sessions: [], selectedId: '', timeline: null, cursor: 0, playing: false, speed: 1, timer: null, startAt: 0, offsetAt: 0 }, | |
| personal: { loaded: false, files: [], selectedFile: '', search: '' }, | |
| flagsConfig: null, | |
| flagsDismissed: {}, | |
| ws: null | |
| }; | |
| function esc(s) { | |
| if (!s) return ''; | |
| var d = document.createElement('div'); | |
| d.textContent = String(s); | |
| return d.innerHTML; | |
| } | |
| function formatTime(ts) { | |
| if (!ts) return ''; | |
| try { return new Date(ts).toLocaleString(); } catch { return ts; } | |
| } | |
| function shortTime(ts) { | |
| if (!ts) return ''; | |
| try { return new Date(ts).toLocaleTimeString(); } catch { return ts; } | |
| } | |
| function truncate(s, n) { | |
| if (!s) return ''; | |
| return s.length > n ? s.slice(0, n) + '...' : s; | |
| } | |
| function sessionId(s) { | |
| return s && s.id !== undefined && s.id !== null ? String(s.id) : ''; | |
| } | |
| function shortSessionId(s, n) { | |
| var id = sessionId(s); | |
| return id ? id.slice(0, n || 8) : ''; | |
| } | |
| function sessionDisplayName(s) { | |
| var folder = s && s.project ? String(s.project).split(/[\\/]/).pop() : ''; | |
| var title = s && s.title ? String(s.title).trim() : ''; | |
| if (title && folder) { | |
| return title + ' (' + folder + ')'; | |
| } else if (title) { | |
| return title; | |
| } else if (folder) { | |
| return folder; | |
| } | |
| return shortSessionId(s, 8) || 'Unknown session'; | |
| } | |
| function sessionLabel(s) { | |
| var id = shortSessionId(s, 8); | |
| var name = sessionDisplayName(s); | |
| return id ? name + ' (' + id + ')' : name + ' (missing id)'; | |
| } | |
| function debounce(fn, ms) { | |
| var t; | |
| return function() { | |
| var args = arguments, ctx = this; | |
| clearTimeout(t); | |
| t = setTimeout(function() { fn.apply(ctx, args); }, ms); | |
| }; | |
| } | |
| // IME_SAFE_SEARCH_V2 | |
| function bindImeSafeSearch(input, ms, onSearch) { | |
| var composing = false; | |
| var justCommitted = false; | |
| var run = debounce(function(value) { onSearch(value); }, ms); | |
| input.addEventListener('compositionstart', function() { composing = true; }); | |
| input.addEventListener('compositionend', function() { | |
| composing = false; | |
| justCommitted = true; | |
| onSearch(input.value); | |
| setTimeout(function() { justCommitted = false; }, 0); | |
| }); | |
| input.addEventListener('input', function(e) { | |
| if (composing || e.isComposing) return; | |
| if (justCommitted) return; | |
| run(input.value); | |
| }); | |
| } | |
| function captureSearchFocus(ids) { | |
| var a = document.activeElement; | |
| if (!a || ids.indexOf(a.id) < 0) return null; | |
| return { id: a.id, start: a.selectionStart, end: a.selectionEnd }; | |
| } | |
| function restoreSearchFocus(focus) { | |
| if (!focus) return; | |
| var el = document.getElementById(focus.id); | |
| if (!el) return; | |
| el.focus(); | |
| if (typeof el.setSelectionRange === 'function') { | |
| try { el.setSelectionRange(focus.start, focus.end); } catch (e) {} | |
| } | |
| } | |
| (function() { | |
| var autoToken = '__AGENTMEMORY_AUTO_TOKEN__'; | |
| if (autoToken && autoToken.length > 0 && autoToken !== '__AGENTMEMORY_AUTO_TOKEN__') { | |
| try { sessionStorage.setItem(VIEWER_TOKEN_STORAGE_KEY, autoToken); } catch (_) {} | |
| } | |
| })(); | |
| function getViewerToken() { | |
| try { return sessionStorage.getItem(VIEWER_TOKEN_STORAGE_KEY) || ''; } catch (_) { return ''; } | |
| } | |
| function setViewerToken(token) { | |
| try { | |
| if (token) sessionStorage.setItem(VIEWER_TOKEN_STORAGE_KEY, token); | |
| else sessionStorage.removeItem(VIEWER_TOKEN_STORAGE_KEY); | |
| } catch (_) {} | |
| } | |
| function showViewerAuthPrompt() { | |
| var host = document.getElementById('viewer-auth'); | |
| if (!host) return; | |
| host.classList.add('open'); | |
| host.innerHTML = | |
| '<div class="viewer-auth-panel">' + | |
| '<div>' + | |
| '<div class="viewer-auth-title">Viewer authorization required</div>' + | |
| '<div id="viewer-auth-desc" class="viewer-auth-desc">Enter <code>AGENTMEMORY_SECRET</code> to unlock viewer API access.</div>' + | |
| '</div>' + | |
| '<input id="viewer-auth-token" type="password" autocomplete="off" spellcheck="false" aria-label="AGENTMEMORY_SECRET" aria-describedby="viewer-auth-desc" placeholder="AGENTMEMORY_SECRET" />' + | |
| '<button class="btn" data-action="save-viewer-token">Unlock</button>' + | |
| '</div>'; | |
| var input = document.getElementById('viewer-auth-token'); | |
| if (input && typeof input.focus === 'function') input.focus(); | |
| } | |
| function hideViewerAuthPrompt() { | |
| var host = document.getElementById('viewer-auth'); | |
| if (!host) return; | |
| host.classList.remove('open'); | |
| host.innerHTML = ''; | |
| } | |
| async function api(path, opts) { | |
| try { | |
| var url = REST + '/agentmemory/' + path; | |
| var headers = Object.assign({ 'Cache-Control': 'no-cache' }, (opts && opts.headers) || {}); | |
| var viewerToken = getViewerToken(); | |
| if (viewerToken && !headers.Authorization && !headers.authorization) { | |
| headers.Authorization = 'Bearer ' + viewerToken; | |
| } | |
| var fetchOpts = Object.assign({}, opts || {}, { headers: headers }); | |
| var res = await fetch(url, fetchOpts); | |
| if (!res.ok) { | |
| if (res.status === 401) showViewerAuthPrompt(); | |
| console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' returned ' + res.status); | |
| return null; | |
| } | |
| hideViewerAuthPrompt(); | |
| return await res.json(); | |
| } catch (err) { | |
| console.warn('[viewer] API error on ' + path + ':', err); | |
| return null; | |
| } | |
| } | |
| async function apiGet(path) { return api(path); } | |
| async function apiPost(path, body) { | |
| return api(path, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body || {}) | |
| }); | |
| } | |
| async function apiDelete(path, body) { | |
| return api(path, { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body || {}) | |
| }); | |
| } | |
| function normalizeTab(tab) { | |
| var normalized = String(tab || '').replace(/^#/, '').toLowerCase(); | |
| return TAB_IDS.indexOf(normalized) >= 0 ? normalized : 'dashboard'; | |
| } | |
| function tabFromRoute() { | |
| try { | |
| return normalizeTab(decodeURIComponent(window.location.hash.slice(1))); | |
| } catch (_) { | |
| return 'dashboard'; | |
| } | |
| } | |
| function updateTabRoute(tab, replace) { | |
| var target = '#' + tab; | |
| if (window.location.hash === target) return; | |
| if (replace) { | |
| history.replaceState(null, '', target); | |
| } else { | |
| history.pushState(null, '', target); | |
| } | |
| } | |
| function switchTab(tab, opts) { | |
| opts = opts || {}; | |
| tab = normalizeTab(tab); | |
| if (state.activeTab === 'replay' && tab !== 'replay' && typeof stopReplayTimer === 'function') { | |
| stopReplayTimer(); | |
| } | |
| if (!opts.skipRoute) { | |
| updateTabRoute(tab, !!opts.replaceRoute); | |
| } | |
| state.activeTab = tab; | |
| document.querySelectorAll('.tab-bar button').forEach(function(b) { | |
| var isActive = b.dataset.tab === tab; | |
| b.classList.toggle('active', isActive); | |
| if (isActive) { | |
| b.setAttribute('aria-current', 'page'); | |
| } else { | |
| b.removeAttribute('aria-current'); | |
| } | |
| }); | |
| document.querySelectorAll('.view').forEach(function(v) { | |
| v.classList.toggle('active', v.id === 'view-' + tab); | |
| }); | |
| if (state.flagsConfig) renderFlagBanners(state.flagsConfig); | |
| loadTab(tab); | |
| } | |
| async function loadTab(tab) { | |
| switch(tab) { | |
| case 'dashboard': if (!state.dashboard.loaded) await loadDashboard(); break; | |
| case 'graph': if (!state.graph.loaded) await loadGraph(); break; | |
| case 'memories': if (!state.memories.loaded) await loadMemories(); break; | |
| case 'timeline': if (!state.timeline.loaded) await loadTimeline(); break; | |
| case 'sessions': if (!state.sessions.loaded) await loadSessions(); break; | |
| case 'lessons': if (!state.lessons.loaded) await loadLessons(); break; | |
| case 'actions': if (!state.actions.loaded) await loadActions(); break; | |
| case 'crystals': if (!state.crystals.loaded) await loadCrystals(); break; | |
| case 'audit': if (!state.audit.loaded) await loadAudit(); break; | |
| case 'activity': if (!state.activity.loaded) await loadActivity(); break; | |
| case 'profile': if (!state.profile.loaded) await loadProfile(); break; | |
| case 'replay': if (!state.replay.loaded) await loadReplay(); break; | |
| case 'personal': if (!state.personal.loaded) await loadPersonal(); break; | |
| } | |
| } | |
| async function loadDashboard() { | |
| var el = document.getElementById('view-dashboard'); | |
| el.innerHTML = '<div class="loading">Loading dashboard...</div>'; | |
| try { | |
| var results = await Promise.all([ | |
| apiGet('health'), | |
| apiGet('sessions'), | |
| apiGet('memories?latest=true&limit=500'), | |
| apiGet('graph/stats'), | |
| apiGet('audit?limit=5'), | |
| apiGet('semantic'), | |
| apiGet('procedural'), | |
| apiGet('relations'), | |
| apiGet('lessons'), | |
| apiGet('crystals') | |
| ]); | |
| state.dashboard.health = results[0]; | |
| state.dashboard.sessions = (results[1] && results[1].sessions) || []; | |
| state.dashboard.memories = (results[2] && results[2].memories) || []; | |
| state.dashboard.graphStats = results[3]; | |
| state.dashboard.recentAudit = (results[4] && results[4].entries) || []; | |
| state.dashboard.semantic = (results[5] && results[5].items) || (results[5] && results[5].facts) || (results[5] && results[5].semantic) || []; | |
| state.dashboard.procedural = (results[6] && results[6].items) || (results[6] && results[6].procedures) || (results[6] && results[6].procedural) || []; | |
| state.dashboard.lessons = (results[8] && results[8].lessons) || []; | |
| state.dashboard.crystals = (results[9] && results[9].crystals) || []; | |
| state.dashboard.relations = (results[7] && results[7].relations) || []; | |
| state.dashboard.loaded = true; | |
| renderDashboard(); | |
| } catch (err) { | |
| var msg = (err && err.message) ? err.message : String(err); | |
| console.error('[viewer] loadDashboard failed:', err); | |
| el.innerHTML = | |
| '<div class="loading" style="color:var(--accent);">' + | |
| 'Dashboard failed to load: ' + msg + | |
| '<br><br><span style="font-size:12px;color:var(--ink-muted);">' + | |
| 'Check the browser console for the full error. If you see CSP ' + | |
| 'violations, please open an issue with the agentmemory version ' + | |
| '(top-right of the viewer) and the violation text.' + | |
| '</span></div>'; | |
| } | |
| } | |
| function renderDashboard() { | |
| var el = document.getElementById('view-dashboard'); | |
| var d = state.dashboard; | |
| var h = d.health || {}; | |
| var snap = h.health || {}; | |
| var healthStatus = h.status || 'unknown'; | |
| var dotClass = healthStatus === 'healthy' ? 'healthy' : healthStatus === 'degraded' ? 'degraded' : healthStatus === 'critical' ? 'critical' : ''; | |
| var activeSessions = d.sessions.filter(function(s) { return s.status === 'active'; }).length; | |
| var gs = d.graphStats || {}; | |
| var nodeCount = gs.totalNodes !== undefined ? gs.totalNodes : (gs.nodes !== undefined ? gs.nodes : (gs.nodeCount || 0)); | |
| var edgeCount = gs.totalEdges !== undefined ? gs.totalEdges : (gs.edges !== undefined ? gs.edges : (gs.edgeCount || 0)); | |
| var fMetrics = h.functionMetrics || []; | |
| var cb = h.circuitBreaker || null; | |
| var workers = snap.workers || []; | |
| var html = ''; | |
| if (d.sessions.length === 0) { | |
| html += '<div class="card" style="margin-bottom:14px;padding:24px 28px;background:var(--bg-subtle);border-left:3px solid var(--accent);">' + | |
| '<div style="font-family:var(--font-ui);font-size:11px;letter-spacing:0.15em;text-transform:uppercase;color:var(--accent);font-weight:700;margin-bottom:8px;">First run → magical moment in 10 seconds</div>' + | |
| '<div style="font-family:var(--font-display,Lora,Georgia,serif);font-size:22px;font-weight:700;color:var(--ink);margin-bottom:8px;">Seed sample data + prove semantic recall works</div>' + | |
| '<div style="font-size:13px;color:var(--ink-muted);margin-bottom:12px;line-height:1.5;max-width:640px;">agentmemory is running but hasn’t seen any sessions yet. Run the demo command in a second terminal: it seeds 3 realistic coding sessions and proves the hybrid search finds semantically-related memories that keyword search would miss.</div>' + | |
| '<pre style="display:inline-block;margin:0;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-family:var(--font-mono);font-size:12px;color:var(--ink);">npx @agentmemory/agentmemory demo</pre>' + | |
| '<div style="margin-top:10px;"><a class="empty-link" href="https://github.com/rohitg00/agentmemory#quick-start" target="_blank" rel="noopener" style="font-size:12px;">Or: wire up your real agent →</a></div>' + | |
| '</div>'; | |
| } | |
| html += '<div class="stats-grid">'; | |
| html += '<div class="stat-card"><div class="label">Sessions</div><div class="value">' + d.sessions.length + '</div><div class="sub">' + activeSessions + ' active</div></div>'; | |
| html += '<div class="stat-card"><div class="label">Memories</div><div class="value">' + d.memories.length + '</div><div class="sub">latest versions</div></div>'; | |
| var lessonCount = (d.lessons || []).length; | |
| var crystalCount = (d.crystals || []).length; | |
| html += '<div class="stat-card"><div class="label">Lessons</div><div class="value">' + lessonCount + '</div><div class="sub">confidence-scored</div></div>'; | |
| html += '<div class="stat-card"><div class="label">Crystals</div><div class="value">' + crystalCount + '</div><div class="sub">action digests</div></div>'; | |
| html += '<div class="stat-card"><div class="label">Graph Nodes</div><div class="value">' + nodeCount + '</div><div class="sub">' + edgeCount + ' edges</div></div>'; | |
| html += '<div class="stat-card"><div class="label">Health</div><div class="value"><div class="health-bar"><span class="health-dot ' + dotClass + '"></span> ' + esc(healthStatus) + '</div></div>'; | |
| html += '<div class="sub">' + esc(snap.connectionState || 'unknown') + '</div></div>'; | |
| var totalCalls = fMetrics.reduce(function(a, m) { return a + (m.totalCalls || 0); }, 0); | |
| html += '<div class="stat-card"><div class="label">Function Calls</div><div class="value">' + totalCalls + '</div><div class="sub">' + fMetrics.length + ' functions tracked</div></div>'; | |
| if (cb) { | |
| var cbClass = cb.state === 'closed' ? 'cb-closed' : cb.state === 'open' ? 'cb-open' : 'cb-half-open'; | |
| html += '<div class="stat-card"><div class="label">Circuit Breaker</div><div class="value"><span class="cb-indicator ' + cbClass + '">' + esc(cb.state) + '</span></div>'; | |
| html += '<div class="sub">' + (cb.failures || 0) + ' failures</div></div>'; | |
| } | |
| var totalObs = d.sessions.reduce(function(a, s) { return a + (s.observationCount || 0); }, 0); | |
| var tokenBudget = parseInt(new URLSearchParams(window.location.search).get('tokenBudget') || '2000', 10) || 2000; | |
| var estFull = totalObs * 80; | |
| var estInjected = d.sessions.length * tokenBudget; | |
| var savings = estFull > 0 ? Math.round((1 - estInjected / Math.max(estFull, 1)) * 100) : 0; | |
| if (savings < 0) savings = 0; | |
| var tokensSaved = Math.max(0, estFull - estInjected); | |
| var costDollars = tokensSaved / 1000 * 0.3; | |
| var costCents = Math.round(costDollars * 100); | |
| var costStr = costCents >= 100 ? '$' + (costCents / 100).toFixed(2) : costCents + 'ct'; | |
| html += '<div class="stat-card"><div class="label">Token Savings</div><div class="value">' + savings + '%</div><div class="sub">~' + tokensSaved.toLocaleString() + ' tokens · ' + costStr + ' saved</div></div>'; | |
| html += '</div>'; | |
| if (snap.memory || snap.cpu) { | |
| html += '<div class="card" style="margin-bottom:16px"><div class="card-title">System Resources</div>'; | |
| if (snap.memory) { | |
| var heapUsed = Math.round((snap.memory.heapUsed || 0) / 1024 / 1024); | |
| var heapTotal = Math.round((snap.memory.heapTotal || 0) / 1024 / 1024); | |
| var rss = Math.round((snap.memory.rss || 0) / 1024 / 1024); | |
| var heapPct = heapTotal > 0 ? Math.round((heapUsed / heapTotal) * 100) : 0; | |
| var rssAboveFloor = rss >= 512; | |
| var heapColor = (heapPct > 80 && rssAboveFloor) ? 'var(--red)' : (heapPct > 60 && rssAboveFloor) ? 'var(--yellow)' : 'var(--green)'; | |
| html += '<div class="gauge"><span class="gauge-label">Heap</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + heapPct + '%;background:' + heapColor + '"></div></div><span class="gauge-value">' + heapUsed + ' / ' + heapTotal + ' MB</span></div>'; | |
| html += '<div class="gauge"><span class="gauge-label">RSS</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, Math.round(rss / 512 * 100)) + '%;background:var(--blue)"></div></div><span class="gauge-value">' + rss + ' MB</span></div>'; | |
| if (snap.memory.external) { | |
| var ext = Math.round(snap.memory.external / 1024 / 1024); | |
| html += '<div class="gauge"><span class="gauge-label">External</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, Math.round(ext / 128 * 100)) + '%;background:var(--purple)"></div></div><span class="gauge-value">' + ext + ' MB</span></div>'; | |
| } | |
| } | |
| if (snap.cpu) { | |
| var cpuPct = snap.cpu.percent || 0; | |
| var cpuColor = cpuPct > 80 ? 'var(--red)' : cpuPct > 50 ? 'var(--yellow)' : 'var(--green)'; | |
| html += '<div class="gauge"><span class="gauge-label">CPU</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, cpuPct) + '%;background:' + cpuColor + '"></div></div><span class="gauge-value">' + cpuPct.toFixed(1) + '%</span></div>'; | |
| } | |
| if (snap.eventLoopLagMs !== undefined) { | |
| var lag = snap.eventLoopLagMs; | |
| var lagColor = lag > 100 ? 'var(--red)' : lag > 20 ? 'var(--yellow)' : 'var(--green)'; | |
| html += '<div class="gauge"><span class="gauge-label">Event Loop</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, lag) + '%;background:' + lagColor + '"></div></div><span class="gauge-value">' + lag.toFixed(1) + ' ms</span></div>'; | |
| } | |
| if (snap.uptimeSeconds) { | |
| var mins = Math.floor(snap.uptimeSeconds / 60); | |
| var hrs = Math.floor(mins / 60); | |
| var upStr = hrs > 0 ? hrs + 'h ' + (mins % 60) + 'm' : mins + 'm'; | |
| html += '<div style="font-size:10px;color:var(--ink-faint);margin-top:6px;font-family:var(--font-mono);letter-spacing:0.04em;">UPTIME: ' + upStr + '</div>'; | |
| } | |
| html += '</div>'; | |
| } | |
| if (snap.alerts && snap.alerts.length > 0) { | |
| html += '<div class="card" style="margin-bottom:16px;border-color:var(--accent);border-width:2px;"><div class="card-title" style="color:var(--accent);border-bottom-color:var(--accent);">Alerts (' + snap.alerts.length + ')</div>'; | |
| snap.alerts.forEach(function(al) { | |
| html += '<div style="font-size:12px;color:var(--accent);padding:4px 0;border-bottom:1px solid var(--border-light);font-family:var(--font-ui);">' + esc(al) + '</div>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| if (snap.notes && snap.notes.length > 0) { | |
| html += '<div class="card" style="margin-bottom:16px;"><div class="card-title" style="color:var(--ink-muted);">Notes (' + snap.notes.length + ')</div>'; | |
| snap.notes.forEach(function(n) { | |
| html += '<div style="font-size:12px;color:var(--ink-muted);padding:4px 0;border-bottom:1px solid var(--border-light);font-family:var(--font-ui);">' + esc(n) + '</div>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| html += '<div class="two-col">'; | |
| html += '<div class="card"><div class="card-title">Recent Sessions</div>'; | |
| if (d.sessions.length === 0) { | |
| html += '<div class="empty-state"><p>No sessions yet. Start a coding session with agentmemory hooks enabled.</p></div>'; | |
| } else { | |
| var recent = d.sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).slice(0, 5); | |
| html += '<table><tr><th>Project</th><th>Status</th><th>Obs</th><th>Started</th></tr>'; | |
| recent.forEach(function(s) { | |
| var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted'; | |
| html += '<tr><td style="color:var(--ink);font-weight:500;">' + esc(sessionDisplayName(s)) + '</td>'; | |
| html += '<td><span class="badge ' + statusBadge + '">' + esc(s.status) + '</span></td>'; | |
| html += '<td style="color:var(--ink-muted);font-family:var(--font-mono);font-size:12px;">' + (s.observationCount || 0) + '</td>'; | |
| html += '<td style="font-family:var(--font-mono);font-size:11px;color:var(--ink-faint);">' + esc(shortTime(s.startedAt)) + '</td></tr>'; | |
| }); | |
| html += '</table>'; | |
| } | |
| html += '</div>'; | |
| html += '<div class="card"><div class="card-title">Recent Activity</div>'; | |
| if (d.recentAudit.length === 0) { | |
| html += '<div class="empty-state"><p>No activity recorded yet</p></div>'; | |
| } else { | |
| d.recentAudit.forEach(function(a) { | |
| var badgeClass = OP_BADGES[a.operation] || 'badge-muted'; | |
| html += '<div style="padding:6px 0;border-bottom:1px solid var(--border-light);font-size:13px;">'; | |
| html += '<span class="badge ' + badgeClass + '">' + esc(a.operation) + '</span> '; | |
| if (a.functionId) html += '<span style="font-size:11px;color:var(--ink-muted);font-family:var(--font-mono);">' + esc(a.functionId) + '</span> '; | |
| html += '<span style="color:var(--ink-faint);font-size:10px;font-family:var(--font-mono);">' + esc(shortTime(a.timestamp)) + '</span>'; | |
| if (a.targetIds && a.targetIds.length) html += '<span style="font-size:10px;color:var(--ink-faint);margin-left:4px;">(' + a.targetIds.length + ' targets)</span>'; | |
| html += '</div>'; | |
| }); | |
| } | |
| html += '</div>'; | |
| html += '</div>'; | |
| html += '<div class="two-col" style="margin-top:16px;">'; | |
| html += '<div class="card"><div class="card-title">Semantic Memory (Consolidated Facts)</div>'; | |
| var semanticList = d.semantic || []; | |
| if (semanticList.length === 0) { | |
| html += '<div class="empty-state"><p>No semantic memories consolidated yet. Run consolidation to synthesize episodic facts.</p></div>'; | |
| } else { | |
| html += '<div style="display:flex; flex-direction:column; gap:8px; max-height: 300px; overflow-y: auto; padding-right: 4px;">'; | |
| semanticList.forEach(function(item) { | |
| var confPct = Math.round((item.confidence || 0) * 100); | |
| var confClass = confPct > 80 ? 'badge-green' : confPct > 50 ? 'badge-blue' : 'badge-muted'; | |
| html += '<div style="padding:8px 10px; border:1px solid var(--border-light); border-radius:4px; font-size:13px; background:var(--bg);">'; | |
| html += '<div style="color:var(--ink); font-weight:500; line-height:1.4;">' + esc(item.fact) + '</div>'; | |
| html += '<div style="margin-top:6px; display:flex; gap:8px; align-items:center; font-size:11px; color:var(--ink-muted); font-family:var(--font-mono);">'; | |
| html += '<span class="badge ' + confClass + '">' + confPct + '% confidence</span>'; | |
| html += '<span>accessed: ' + (item.accessCount || 1) + 'x</span>'; | |
| html += '</div></div>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| html += '</div>'; | |
| html += '<div class="card"><div class="card-title">Procedural Memory (Action Workflows)</div>'; | |
| var proceduralList = d.procedural || []; | |
| if (proceduralList.length === 0) { | |
| html += '<div class="empty-state"><p>No procedural memories extracted yet. Consolidated sessions with recurring patterns will generate reusable workflows.</p></div>'; | |
| } else { | |
| html += '<div style="display:flex; flex-direction:column; gap:10px; max-height: 300px; overflow-y: auto; padding-right: 4px;">'; | |
| proceduralList.forEach(function(item) { | |
| html += '<div style="padding:10px; border:1px solid var(--border-light); border-radius:4px; background:var(--bg);">'; | |
| html += '<div style="font-weight:700; color:var(--ink); font-size:14px; font-family:var(--font-display);">' + esc(item.name) + '</div>'; | |
| if (item.triggerCondition) { | |
| html += '<div style="font-size:11px; color:var(--ink-muted); font-style:italic; margin:4px 0;">Trigger: ' + esc(item.triggerCondition) + '</div>'; | |
| } | |
| if (item.steps && item.steps.length > 0) { | |
| html += '<ol style="margin-left:16px; margin-top:6px; font-size:12px; line-height:1.5; color:var(--ink-secondary);">'; | |
| item.steps.forEach(function(step) { | |
| html += '<li>' + esc(step) + '</li>'; | |
| }); | |
| html += '</ol>'; | |
| } | |
| html += '<div style="margin-top:6px; font-size:10px; color:var(--ink-faint); font-family:var(--font-mono);">'; | |
| html += 'Frequency: ' + (item.frequency || 1) + 'x'; | |
| html += '</div></div>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| html += '</div>'; | |
| html += '</div>'; | |
| if (fMetrics.length > 0) { | |
| var sorted = fMetrics.slice().sort(function(a, b) { return (b.totalCalls || 0) - (a.totalCalls || 0); }); | |
| html += '<div class="card" style="margin-top:16px"><div class="card-title">Function Metrics (OTel)</div>'; | |
| html += '<table class="metric-table"><tr><th>Function</th><th style="text-align:right">Calls</th><th style="text-align:right">Success</th><th style="text-align:right">Fail</th><th style="text-align:right">Avg Latency</th><th style="text-align:right">Quality</th></tr>'; | |
| sorted.forEach(function(m) { | |
| var successRate = m.totalCalls > 0 ? Math.round((m.successCount / m.totalCalls) * 100) : 0; | |
| var rateColor = successRate >= 95 ? 'var(--green)' : successRate >= 80 ? 'var(--yellow)' : 'var(--red)'; | |
| var latencyColor = m.avgLatencyMs > 1000 ? 'var(--red)' : m.avgLatencyMs > 200 ? 'var(--yellow)' : 'var(--green)'; | |
| html += '<tr>'; | |
| html += '<td class="metric-fn">' + esc(m.functionId) + '</td>'; | |
| html += '<td class="metric-num">' + m.totalCalls + '</td>'; | |
| html += '<td class="metric-num" style="color:' + rateColor + '">' + m.successCount + ' (' + successRate + '%)</td>'; | |
| html += '<td class="metric-num" style="color:' + (m.failureCount > 0 ? 'var(--red)' : 'var(--ink-faint)') + '">' + m.failureCount + '</td>'; | |
| html += '<td class="metric-num" style="color:' + latencyColor + '">' + Math.round(m.avgLatencyMs) + ' ms</td>'; | |
| html += '<td class="metric-num">' + (m.avgQualityScore > 0 ? m.avgQualityScore.toFixed(2) : '-') + '</td>'; | |
| html += '</tr>'; | |
| }); | |
| html += '</table></div>'; | |
| } | |
| if (workers.length > 0) { | |
| html += '<div class="card" style="margin-top:16px"><div class="card-title">Workers</div>'; | |
| workers.forEach(function(w) { | |
| var statusClass = w.status === 'running' ? 'running' : w.status === 'starting' ? 'starting' : 'stopped'; | |
| html += '<div class="worker-row"><span class="worker-dot ' + statusClass + '"></span>'; | |
| html += '<span style="color:var(--ink);font-weight:600;font-family:var(--font-ui);font-size:12px;">' + esc(w.name) + '</span>'; | |
| html += '<span class="badge ' + (w.status === 'running' ? 'badge-green' : 'badge-muted') + '">' + esc(w.status) + '</span>'; | |
| html += '<span style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);">' + esc(w.id) + '</span></div>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| if (cb && cb.state !== 'closed') { | |
| html += '<div class="card" style="margin-top:16px;border-color:var(--accent);border-width:2px;"><div class="card-title" style="color:var(--accent);">Circuit Breaker Details</div>'; | |
| html += '<div class="detail-row"><div class="dl">State</div><div class="dv"><span class="cb-indicator ' + (cb.state === 'open' ? 'cb-open' : 'cb-half-open') + '">' + esc(cb.state) + '</span></div></div>'; | |
| html += '<div class="detail-row"><div class="dl">Failures</div><div class="dv" style="color:var(--accent);font-family:var(--font-mono);">' + (cb.failures || 0) + '</div></div>'; | |
| if (cb.lastFailureAt) html += '<div class="detail-row"><div class="dl">Last Failure</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(cb.lastFailureAt)) + '</div></div>'; | |
| if (cb.openedAt) html += '<div class="detail-row"><div class="dl">Opened At</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(cb.openedAt)) + '</div></div>'; | |
| html += '</div>'; | |
| } | |
| var semFacts = d.semantic || []; | |
| var procItems = d.procedural || []; | |
| var relItems = d.relations || []; | |
| html += '<div class="card" style="margin-top:16px;"><div class="card-title">Consolidation Status</div>'; | |
| html += '<div class="consolidation-row"><span class="cl">Semantic facts</span><span class="cv">' + semFacts.length + '</span></div>'; | |
| html += '<div class="consolidation-row"><span class="cl">Procedures</span><span class="cv">' + procItems.length + '</span></div>'; | |
| html += '<div class="consolidation-row"><span class="cl">Relations</span><span class="cv">' + relItems.length + '</span></div>'; | |
| html += '</div>'; | |
| if (relItems.length > 0) { | |
| html += '<div class="card" style="margin-top:16px;"><div class="card-title">Memory Relations</div>'; | |
| relItems.slice(0, 8).forEach(function(r) { | |
| var relType = r.type || r.relationType || 'related'; | |
| var badgeClass = relType === 'supersedes' ? 'badge-red' : relType === 'extends' ? 'badge-green' : relType === 'contradicts' ? 'badge-yellow' : 'badge-muted'; | |
| html += '<div style="padding:4px 0;border-bottom:1px solid var(--border-light);font-size:12px;display:flex;align-items:center;gap:6px;">'; | |
| html += '<span style="font-family:var(--font-mono);color:var(--blue);font-size:11px;">' + esc(truncate(r.sourceId || r.fromId || '', 8)) + '</span>'; | |
| html += '<span class="badge ' + badgeClass + '">' + esc(relType) + '</span>'; | |
| html += '<span style="font-family:var(--font-mono);color:var(--blue);font-size:11px;">' + esc(truncate(r.targetId || r.toId || '', 8)) + '</span>'; | |
| html += '</div>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| html += '<div style="text-align:center;margin-top:20px;"><button class="btn btn-primary" data-action="refresh-dashboard">Refresh</button>'; | |
| html += '<span style="font-size:10px;color:var(--ink-faint);margin-left:10px;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:0.08em;">Auto-refresh 30s</span></div>'; | |
| el.innerHTML = html; | |
| } | |
| var dashboardTimer = null; | |
| function refreshDashboard() { | |
| state.dashboard.loaded = false; | |
| loadDashboard(); | |
| } | |
| function startDashboardAutoRefresh() { | |
| if (dashboardTimer) clearInterval(dashboardTimer); | |
| dashboardTimer = setInterval(function() { | |
| if (pollTimer) return; | |
| if (state.activeTab === 'dashboard') refreshDashboard(); | |
| }, 30000); | |
| } | |
| var graphSim = { nodes: [], edges: [], running: false, canvas: null, ctx: null, raf: null, panX: 0, panY: 0, zoom: 1, dragNode: null, mouseX: 0, mouseY: 0, tickCount: 0, quietTicks: 0 }; | |
| function wakeGraphSim() { | |
| graphSim.quietTicks = 0; | |
| if (graphSim.running && !graphSim.raf) { | |
| graphSim.raf = requestAnimationFrame(runSimulation); | |
| } | |
| } | |
| var GRAPH_INITIAL_LIMIT = 500; | |
| async function loadGraph() { | |
| var el = document.getElementById('view-graph'); | |
| el.innerHTML = '<div class="graph-container"><div class="graph-canvas-wrap"><canvas id="graph-canvas"></canvas><div class="graph-controls"><button title="Zoom In" data-action="zoom-graph" data-dir="1">+</button><button title="Zoom Out" data-action="zoom-graph" data-dir="-1">−</button><div class="ctrl-divider"></div><button title="Recenter" data-action="recenter-graph">⌖</button></div><div class="graph-tooltip" id="graph-tooltip"></div></div><div class="graph-sidebar" id="graph-sidebar"></div></div>'; | |
| var results = await Promise.all([ | |
| apiGet('sessions'), | |
| apiGet('memories?latest=true&limit=2000'), | |
| apiGet('graph/stats') | |
| ]); | |
| var sessions = (results[0] && results[0].sessions) || []; | |
| var memories = (results[1] && results[1].memories) || []; | |
| var stats = results[2] || {}; | |
| state.graph.queryError = null; | |
| // Extract unique folders (projects) | |
| var folderMap = {}; | |
| sessions.forEach(function(s) { | |
| var path = s.project ? String(s.project).trim() : ''; | |
| if (!path) return; | |
| if (!path.includes('/') && !path.includes('\\')) return; | |
| if (!folderMap[path]) { | |
| folderMap[path] = { | |
| id: path, | |
| name: path.split(/[\\/]/).pop() || path, | |
| fullPath: path, | |
| type: 'folder', | |
| sessions: [], | |
| memories: [], | |
| concepts: new Set(), | |
| firstPrompts: [], | |
| summaries: [] | |
| }; | |
| } | |
| folderMap[path].sessions.push(s); | |
| if (s.firstPrompt) folderMap[path].firstPrompts.push(s.firstPrompt); | |
| if (s.summary) folderMap[path].summaries.push(s.summary); | |
| }); | |
| memories.forEach(function(m) { | |
| var path = m.project ? String(m.project).trim() : ''; | |
| if (!path) return; | |
| if (!path.includes('/') && !path.includes('\\')) return; | |
| if (!folderMap[path]) { | |
| // If the folder is not in sessions, create it | |
| folderMap[path] = { | |
| id: path, | |
| name: path.split(/[\\/]/).pop() || path, | |
| fullPath: path, | |
| type: 'folder', | |
| sessions: [], | |
| memories: [], | |
| concepts: new Set(), | |
| firstPrompts: [], | |
| summaries: [] | |
| }; | |
| } | |
| folderMap[path].memories.push(m); | |
| if (m.concepts && Array.isArray(m.concepts)) { | |
| m.concepts.forEach(function(c) { | |
| folderMap[path].concepts.add(c.toLowerCase()); | |
| }); | |
| } | |
| }); | |
| var folders = Object.values(folderMap); | |
| // Compute detailed properties/descriptions | |
| folders.forEach(function(f) { | |
| var desc = ''; | |
| if (f.summaries.length > 0) { | |
| desc += '<strong>Summaries:</strong><br>' + f.summaries.slice(0, 3).map(function(s) { return '• ' + esc(s); }).join('<br>') + '<br><br>'; | |
| } else if (f.firstPrompts.length > 0) { | |
| desc += '<strong>Prompts:</strong><br>' + f.firstPrompts.slice(0, 3).map(function(p) { return '• ' + esc(p); }).join('<br>') + '<br><br>'; | |
| } | |
| var conceptsArr = Array.from(f.concepts); | |
| if (conceptsArr.length > 0) { | |
| desc += '<strong>Concepts:</strong><br>' + conceptsArr.slice(0, 10).map(function(c) { return '<span class="tag">' + esc(c) + '</span>'; }).join(' ') + '<br><br>'; | |
| } | |
| if (f.memories.length > 0) { | |
| desc += '<strong>Memories:</strong><br>' + f.memories.slice(0, 5).map(function(m) { return '• ' + esc(m.title); }).join('<br>'); | |
| } | |
| f.properties = { | |
| description: desc || 'No description available for this folder.', | |
| fullPath: f.fullPath, | |
| sessionCount: f.sessions.length, | |
| memoryCount: f.memories.length | |
| }; | |
| }); | |
| // Compute edges (connections between folders) | |
| var edges = []; | |
| for (var i = 0; i < folders.length; i++) { | |
| for (var j = i + 1; j < folders.length; j++) { | |
| var f1 = folders[i]; | |
| var f2 = folders[j]; | |
| var sharedConcepts = []; | |
| f1.concepts.forEach(function(c) { | |
| if (f2.concepts.has(c)) { | |
| sharedConcepts.push(c); | |
| } | |
| }); | |
| var path1 = f1.fullPath.split(/[\\/]/).filter(Boolean); | |
| var path2 = f2.fullPath.split(/[\\/]/).filter(Boolean); | |
| var commonSubdirs = 0; | |
| for (var k = 0; k < Math.min(path1.length, path2.length); k++) { | |
| if (path1[k].toLowerCase() === path2[k].toLowerCase()) { | |
| var p = path1[k].toLowerCase(); | |
| if (p !== 'c:' && p !== 'd:' && p !== 'downloads' && p !== 'projects' && p !== 'other projects') { | |
| commonSubdirs++; | |
| } | |
| } else { | |
| break; | |
| } | |
| } | |
| var connectionType = []; | |
| var weight = 0; | |
| if (sharedConcepts.length > 0) { | |
| connectionType.push('Shared concepts: ' + sharedConcepts.slice(0, 2).join(', ')); | |
| weight += sharedConcepts.length * 0.8; | |
| } | |
| if (commonSubdirs > 0) { | |
| connectionType.push('Common parent path'); | |
| weight += commonSubdirs * 1.5; | |
| } | |
| if (weight > 0) { | |
| edges.push({ | |
| id: f1.fullPath + '->' + f2.fullPath, | |
| sourceNodeId: f1.fullPath, | |
| targetNodeId: f2.fullPath, | |
| type: connectionType.join(' & '), | |
| weight: Math.min(5, weight) | |
| }); | |
| } | |
| } | |
| } | |
| state.graph.nodes = folders; | |
| state.graph.edges = edges; | |
| state.graph.truncated = false; | |
| state.graph.totalNodes = folders.length; | |
| state.graph.totalEdges = edges.length; | |
| state.graph.stats = { totalNodes: folders.length, totalEdges: edges.length }; | |
| state.graph.loaded = true; | |
| var types = {}; | |
| state.graph.nodes.forEach(function(n) { types[n.type] = true; }); | |
| state.graph.filters = types; | |
| renderGraphSidebar(); | |
| initGraph(); | |
| } | |
| var NODE_SHAPES = { | |
| file: 'rect', function: 'circle', concept: 'circle', error: 'diamond', | |
| decision: 'diamond', pattern: 'circle', library: 'hexagon', person: 'circle', | |
| folder: 'rect' | |
| }; | |
| var graphSearchTerm = ''; | |
| function renderGraphSidebar() { | |
| var sb = document.getElementById('graph-sidebar'); | |
| if (!sb) return; | |
| var gs = state.graph.stats || {}; | |
| var nodeCount = gs.totalNodes !== undefined ? gs.totalNodes : (gs.nodes !== undefined ? gs.nodes : (gs.nodeCount || state.graph.nodes.length)); | |
| var edgeCount = gs.totalEdges !== undefined ? gs.totalEdges : (gs.edges !== undefined ? gs.edges : (gs.edgeCount || state.graph.edges.length)); | |
| var html = ''; | |
| if (state.graph.queryError) { | |
| html += '<div style="margin:8px 0;padding:10px 12px;border:1px solid var(--accent);background:var(--bg-alt);font-size:12px;color:var(--ink);line-height:1.4;">'; | |
| html += '<div style="font-weight:600;margin-bottom:4px;">Graph query failed</div>'; | |
| html += '<div style="font-size:11px;color:var(--ink-muted);">' + esc(state.graph.queryError) + '</div>'; | |
| html += '<button class="btn" data-action="rebuild-graph" style="margin-top:8px;font-size:11px;">Retry</button>'; | |
| html += '</div>'; | |
| } else if (state.graph.truncated) { | |
| html += '<div style="margin:8px 0;padding:10px 12px;border:1px solid var(--border);background:var(--bg-alt);font-size:11px;color:var(--ink-muted);line-height:1.4;">'; | |
| html += 'Showing ' + state.graph.nodes.length + ' of ' + state.graph.totalNodes + ' nodes (most-connected first). The full graph is too large to render at once.'; | |
| html += '</div>'; | |
| } | |
| html += '<input type="text" class="graph-search" id="graph-search" placeholder="Search nodes...">'; | |
| html += '<h3 style="margin-top:16px;font-size:10px;text-transform:uppercase;letter-spacing:0.12em;color:var(--ink-muted);font-family:var(--font-ui);font-weight:700;">Graph Stats</h3>'; | |
| html += '<div style="display:flex;gap:20px;margin:10px 0 16px;padding:12px;background:var(--bg-alt);border:1px solid var(--border-light);border-radius:4px;">'; | |
| html += '<div style="text-align:center;flex:1;"><span style="font-size:28px;font-weight:900;font-family:var(--font-display);color:var(--ink);line-height:1;">' + nodeCount + '</span><div style="font-size:8px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:0.12em;font-family:var(--font-ui);font-weight:600;margin-top:4px;">Nodes</div></div>'; | |
| html += '<div style="width:1px;background:var(--border-light);"></div>'; | |
| html += '<div style="text-align:center;flex:1;"><span style="font-size:28px;font-weight:900;font-family:var(--font-display);color:var(--ink);line-height:1;">' + edgeCount + '</span><div style="font-size:8px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:0.12em;font-family:var(--font-ui);font-weight:600;margin-top:4px;">Edges</div></div>'; | |
| html += '</div>'; | |
| html += '<h3 style="margin-top:12px;font-size:10px;text-transform:uppercase;letter-spacing:0.12em;color:var(--ink-muted);font-family:var(--font-ui);font-weight:700;">Filter by Type</h3>'; | |
| Object.keys(state.graph.filters).forEach(function(type) { | |
| var color = NODE_COLORS[type] || '#666666'; | |
| html += '<label class="filter-item"><input type="checkbox" checked data-type="' + esc(type) + '"><span class="filter-dot" style="background:' + color + '"></span>' + esc(type) + '</label>'; | |
| }); | |
| html += '<div class="graph-legend"><h3>Legend</h3>'; | |
| var shapeLabels = { rect: '▭', circle: '●', diamond: '◆', hexagon: '⬢' }; | |
| var shownShapes = {}; | |
| Object.keys(NODE_COLORS).forEach(function(type) { | |
| var shape = NODE_SHAPES[type] || 'circle'; | |
| var color = NODE_COLORS[type]; | |
| var key = type; | |
| if (shownShapes[key]) return; | |
| shownShapes[key] = true; | |
| html += '<div class="graph-legend-item"><span class="graph-legend-shape" style="color:' + color + ';font-size:14px;">' + (shapeLabels[shape] || '●') + '</span><span>' + esc(type) + '</span></div>'; | |
| }); | |
| html += '</div>'; | |
| html += '<button class="btn" style="margin-top:14px;width:100%;font-size:11px;padding:8px;letter-spacing:0.06em;transition:all 0.15s ease;" data-action="rebuild-graph">↻ Rebuild Graph</button>'; | |
| html += '<div id="selected-node-panel"></div>'; | |
| var __focus = captureSearchFocus(['graph-search']); | |
| sb.innerHTML = html; | |
| sb.querySelectorAll('input[type="checkbox"]').forEach(function(cb) { | |
| cb.addEventListener('change', function() { | |
| state.graph.filters[this.dataset.type] = this.checked; | |
| renderGraph(); | |
| }); | |
| }); | |
| var searchInput = document.getElementById('graph-search'); | |
| if (searchInput) { | |
| bindImeSafeSearch(searchInput, 200, function(v){ graphSearchTerm = v.toLowerCase(); renderGraph(); }); | |
| } | |
| restoreSearchFocus(__focus); | |
| } | |
| function initGraph() { | |
| var canvas = document.getElementById('graph-canvas'); | |
| if (!canvas) return; | |
| graphSim.canvas = canvas; | |
| graphSim.ctx = canvas.getContext('2d'); | |
| function resize() { | |
| var r = canvas.parentElement.getBoundingClientRect(); | |
| canvas.width = r.width * window.devicePixelRatio; | |
| canvas.height = r.height * window.devicePixelRatio; | |
| canvas.style.width = r.width + 'px'; | |
| canvas.style.height = r.height + 'px'; | |
| graphSim.ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0); | |
| } | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| var cw = canvas.width / window.devicePixelRatio; | |
| var ch = canvas.height / window.devicePixelRatio; | |
| graphSim.panX = cw / 2; | |
| graphSim.panY = ch / 2; | |
| var edgeMap = {}; | |
| state.graph.edges.forEach(function(e) { | |
| edgeMap[e.sourceNodeId] = (edgeMap[e.sourceNodeId] || 0) + 1; | |
| edgeMap[e.targetNodeId] = (edgeMap[e.targetNodeId] || 0) + 1; | |
| }); | |
| graphSim.nodes = state.graph.nodes.map(function(n, i) { | |
| var angle = (2 * Math.PI * i) / Math.max(state.graph.nodes.length, 1); | |
| var radius = Math.min(cw, ch) * 0.38; | |
| var deg = edgeMap[n.id] || 0; | |
| var activity = ((n.sessions && n.sessions.length) || 0) + ((n.memories && n.memories.length) || 0); | |
| var nodeR = n.type === 'folder' | |
| ? Math.max(24, Math.min(52, 24 + Math.sqrt(activity + 1) * 3.5)) | |
| : Math.max(10, Math.min(26, 10 + deg * 2.5)); | |
| return { | |
| id: n.id, type: n.type, name: n.name, properties: n.properties, | |
| sessions: n.sessions, memories: n.memories, | |
| x: Math.cos(angle) * radius, | |
| y: Math.sin(angle) * radius, | |
| vx: 0, vy: 0, | |
| r: nodeR | |
| }; | |
| }); | |
| graphSim.edges = state.graph.edges.slice(); | |
| graphSim.running = true; | |
| graphSim.dragNode = null; | |
| setupGraphInteraction(canvas); | |
| runSimulation(); | |
| } | |
| function setupGraphInteraction(canvas) { | |
| var isPanning = false; | |
| var lastMX = 0, lastMY = 0; | |
| function canvasCoords(e) { | |
| var rect = canvas.getBoundingClientRect(); | |
| return { | |
| x: (e.clientX - rect.left - graphSim.panX) / graphSim.zoom, | |
| y: (e.clientY - rect.top - graphSim.panY) / graphSim.zoom | |
| }; | |
| } | |
| function findNode(cx, cy) { | |
| for (var i = graphSim.nodes.length - 1; i >= 0; i--) { | |
| var n = graphSim.nodes[i]; | |
| if (!state.graph.filters[n.type]) continue; | |
| var dx = n.x - cx, dy = n.y - cy; | |
| if (dx * dx + dy * dy < n.r * n.r + 25) return n; | |
| } | |
| return null; | |
| } | |
| canvas.addEventListener('mousedown', function(e) { | |
| var c = canvasCoords(e); | |
| var node = findNode(c.x, c.y); | |
| if (node) { | |
| graphSim.dragNode = node; | |
| } else { | |
| isPanning = true; | |
| } | |
| lastMX = e.clientX; | |
| lastMY = e.clientY; | |
| wakeGraphSim(); | |
| }); | |
| canvas.addEventListener('mousemove', function(e) { | |
| var dx = e.clientX - lastMX; | |
| var dy = e.clientY - lastMY; | |
| if (graphSim.dragNode) { | |
| graphSim.dragNode.x += dx / graphSim.zoom; | |
| graphSim.dragNode.y += dy / graphSim.zoom; | |
| graphSim.dragNode.vx = 0; | |
| graphSim.dragNode.vy = 0; | |
| } else if (isPanning) { | |
| graphSim.panX += dx; | |
| graphSim.panY += dy; | |
| } | |
| lastMX = e.clientX; | |
| lastMY = e.clientY; | |
| graphSim.mouseX = e.clientX; | |
| graphSim.mouseY = e.clientY; | |
| var c = canvasCoords(e); | |
| var hoverNode = findNode(c.x, c.y); | |
| var tooltip = document.getElementById('graph-tooltip'); | |
| if (tooltip) { | |
| if (hoverNode && !graphSim.dragNode && !isPanning) { | |
| var conns = graphSim.edges.filter(function(ed) { return ed.sourceNodeId === hoverNode.id || ed.targetNodeId === hoverNode.id; }).length; | |
| var ttHtml = '<div class="tt-name">' + esc(hoverNode.name) + '</div>'; | |
| ttHtml += '<div class="tt-type" style="color:' + (NODE_COLORS[hoverNode.type] || '#666') + '">' + esc(hoverNode.type) + '</div>'; | |
| if (hoverNode.type === 'folder' && hoverNode.properties) { | |
| if (hoverNode.properties.fullPath) { | |
| ttHtml += '<div class="tt-prop">Path: ' + esc(truncate(hoverNode.properties.fullPath, 45)) + '</div>'; | |
| } | |
| if (typeof hoverNode.properties.sessionCount === 'number') { | |
| ttHtml += '<div class="tt-prop">Sessions: ' + hoverNode.properties.sessionCount + '</div>'; | |
| } | |
| if (typeof hoverNode.properties.memoryCount === 'number') { | |
| ttHtml += '<div class="tt-prop">Memories: ' + hoverNode.properties.memoryCount + '</div>'; | |
| } | |
| } else if (hoverNode.properties) { | |
| var propKeys = Object.keys(hoverNode.properties).slice(0, 3); | |
| propKeys.forEach(function(k) { | |
| ttHtml += '<div class="tt-prop">' + esc(k) + ': ' + esc(truncate(String(hoverNode.properties[k]), 30)) + '</div>'; | |
| }); | |
| } | |
| ttHtml += '<div class="tt-conns">' + conns + ' connection' + (conns !== 1 ? 's' : '') + '</div>'; | |
| tooltip.innerHTML = ttHtml; | |
| var rect = canvas.getBoundingClientRect(); | |
| tooltip.style.left = (e.clientX - rect.left + 12) + 'px'; | |
| tooltip.style.top = (e.clientY - rect.top + 12) + 'px'; | |
| tooltip.classList.add('visible'); | |
| canvas.style.cursor = 'pointer'; | |
| } else { | |
| tooltip.classList.remove('visible'); | |
| canvas.style.cursor = graphSim.dragNode || isPanning ? 'grabbing' : 'grab'; | |
| } | |
| } | |
| }); | |
| canvas.addEventListener('mouseup', function(e) { | |
| if (graphSim.dragNode && !isPanning) { | |
| selectGraphNode(graphSim.dragNode); | |
| } | |
| graphSim.dragNode = null; | |
| isPanning = false; | |
| }); | |
| canvas.addEventListener('wheel', function(e) { | |
| e.preventDefault(); | |
| var factor = e.deltaY > 0 ? 0.9 : 1.1; | |
| graphSim.zoom = Math.max(0.1, Math.min(5, graphSim.zoom * factor)); | |
| wakeGraphSim(); | |
| }, { passive: false }); | |
| canvas.addEventListener('dblclick', function(e) { | |
| var c = canvasCoords(e); | |
| var node = findNode(c.x, c.y); | |
| if (node) { | |
| selectGraphNode(node); | |
| expandNode(node.id); | |
| } | |
| }); | |
| } | |
| window.zoomGraph = function(dir) { | |
| var factor = dir > 0 ? 1.25 : 0.8; | |
| graphSim.zoom = Math.max(0.1, Math.min(5, graphSim.zoom * factor)); | |
| wakeGraphSim(); | |
| }; | |
| window.recenterGraph = function() { | |
| graphSim.zoom = 1; | |
| if (graphSim.canvas) { | |
| var cw = graphSim.canvas.width / window.devicePixelRatio; | |
| var ch = graphSim.canvas.height / window.devicePixelRatio; | |
| graphSim.panX = cw / 2; | |
| graphSim.panY = ch / 2; | |
| } | |
| wakeGraphSim(); | |
| }; | |
| function selectGraphNode(simNode) { | |
| state.graph.selectedNode = simNode; | |
| var panel = document.getElementById('selected-node-panel'); | |
| if (!panel) return; | |
| var color = NODE_COLORS[simNode.type] || '#666666'; | |
| var html = '<div class="selected-node-info">'; | |
| html += '<h4 style="color:' + color + '">' + esc(simNode.name) + '</h4>'; | |
| html += '<div class="prop">Type: ' + esc(simNode.type) + '</div>'; | |
| if (simNode.type === 'folder' && simNode.properties && simNode.properties.description) { | |
| html += '<div class="prop-desc" style="margin-top:10px;font-size:12px;color:var(--ink);line-height:1.5;max-height:300px;overflow-y:auto;border-top:1px solid var(--border);padding-top:10px;">' + simNode.properties.description + '</div>'; | |
| } else if (simNode.properties) { | |
| Object.keys(simNode.properties).forEach(function(k) { | |
| html += '<div class="prop">' + esc(k) + ': ' + esc(truncate(simNode.properties[k], 50)) + '</div>'; | |
| }); | |
| } | |
| var conns = graphSim.edges.filter(function(e) { return e.sourceNodeId === simNode.id || e.targetNodeId === simNode.id; }).length; | |
| html += '<div class="prop" style="margin-top:10px;">Connections: ' + conns + '</div>'; | |
| if (simNode.type !== 'folder') { | |
| html += '<button class="btn btn-primary" style="margin-top:8px;width:100%;" data-action="expand-node" data-node-id="' + esc(simNode.id) + '">Expand neighbors</button>'; | |
| } | |
| html += '</div>'; | |
| panel.innerHTML = html; | |
| } | |
| async function expandNode(nodeId) { | |
| var result = await apiPost('graph/query', { startNodeId: nodeId, maxDepth: 1 }); | |
| if (!result) return; | |
| var existingIds = {}; | |
| graphSim.nodes.forEach(function(n) { existingIds[n.id] = true; }); | |
| var parentNode = graphSim.nodes.find(function(n) { return n.id === nodeId; }); | |
| var px = parentNode ? parentNode.x : 0; | |
| var py = parentNode ? parentNode.y : 0; | |
| (result.nodes || []).forEach(function(n) { | |
| if (!existingIds[n.id]) { | |
| state.graph.nodes.push(n); | |
| if (!state.graph.filters.hasOwnProperty(n.type)) state.graph.filters[n.type] = true; | |
| var angle = Math.random() * Math.PI * 2; | |
| graphSim.nodes.push({ | |
| id: n.id, type: n.type, name: n.name, properties: n.properties, | |
| x: px + Math.cos(angle) * 80, | |
| y: py + Math.sin(angle) * 80, | |
| vx: 0, vy: 0, r: 8 | |
| }); | |
| } | |
| }); | |
| var existingEdges = {}; | |
| graphSim.edges.forEach(function(e) { existingEdges[e.id] = true; }); | |
| (result.edges || []).forEach(function(e) { | |
| if (!existingEdges[e.id]) { | |
| state.graph.edges.push(e); | |
| graphSim.edges.push(e); | |
| } | |
| }); | |
| renderGraphSidebar(); | |
| } | |
| function runSimulation() { | |
| if (!graphSim.running) return; | |
| var nodes = graphSim.nodes; | |
| var edges = graphSim.edges; | |
| var nodeCount = nodes.length; | |
| graphSim.tickCount = (graphSim.tickCount || 0) + 1; | |
| var coolBoost = Math.min(0.4, graphSim.tickCount / 1500); | |
| var damping = 0.9 - coolBoost; | |
| var repulsion = nodeCount > 1000 ? 3000 : nodeCount > 100 ? 2000 : nodeCount > 50 ? 1800 : nodeCount > 10 ? 8000 : 18000; | |
| var attraction = nodeCount > 100 ? 0.002 : 0.005; | |
| var centerGravity = nodeCount > 1000 ? 0.012 : nodeCount > 100 ? 0.005 : 0.01; | |
| var velocityCap = nodeCount > 1000 ? 6 : nodeCount > 200 ? 12 : 24; | |
| var nodeMap = {}; | |
| nodes.forEach(function(n) { nodeMap[n.id] = n; }); | |
| for (var i = 0; i < nodes.length; i++) { | |
| if (graphSim.dragNode === nodes[i]) continue; | |
| var n = nodes[i]; | |
| var fx = 0, fy = 0; | |
| for (var j = 0; j < nodes.length; j++) { | |
| if (i === j) continue; | |
| var dx = n.x - nodes[j].x; | |
| var dy = n.y - nodes[j].y; | |
| var dist = Math.sqrt(dx * dx + dy * dy) || 1; | |
| var force = repulsion / (dist * dist); | |
| fx += (dx / dist) * force; | |
| fy += (dy / dist) * force; | |
| } | |
| fx -= n.x * centerGravity; | |
| fy -= n.y * centerGravity; | |
| var nvx = (n.vx + fx) * damping; | |
| var nvy = (n.vy + fy) * damping; | |
| if (nvx > velocityCap) nvx = velocityCap; else if (nvx < -velocityCap) nvx = -velocityCap; | |
| if (nvy > velocityCap) nvy = velocityCap; else if (nvy < -velocityCap) nvy = -velocityCap; | |
| n.vx = nvx; | |
| n.vy = nvy; | |
| } | |
| edges.forEach(function(e) { | |
| var s = nodeMap[e.sourceNodeId]; | |
| var t = nodeMap[e.targetNodeId]; | |
| if (!s || !t) return; | |
| var dx = t.x - s.x; | |
| var dy = t.y - s.y; | |
| var dist = Math.sqrt(dx * dx + dy * dy) || 1; | |
| var f = (dist - (s.type === 'folder' || t.type === 'folder' ? 220 : 100)) * attraction; | |
| var fx = (dx / dist) * f; | |
| var fy = (dy / dist) * f; | |
| if (graphSim.dragNode !== s) { s.vx += fx; s.vy += fy; } | |
| if (graphSim.dragNode !== t) { t.vx -= fx; t.vy -= fy; } | |
| }); | |
| var totalKineticEnergy = 0; | |
| nodes.forEach(function(n) { | |
| if (graphSim.dragNode === n) return; | |
| n.x += n.vx; | |
| n.y += n.vy; | |
| totalKineticEnergy += n.vx * n.vx + n.vy * n.vy; | |
| }); | |
| var rmsVelocity = nodes.length > 0 ? Math.sqrt(totalKineticEnergy / nodes.length) : 0; | |
| if (rmsVelocity < 0.05 && graphSim.tickCount > 60 && !graphSim.dragNode) { | |
| graphSim.quietTicks = (graphSim.quietTicks || 0) + 1; | |
| } else { | |
| graphSim.quietTicks = 0; | |
| } | |
| renderGraph(); | |
| if (graphSim.quietTicks > 30) { | |
| graphSim.raf = null; | |
| return; | |
| } | |
| graphSim.raf = requestAnimationFrame(runSimulation); | |
| } | |
| async function rebuildGraph() { | |
| var sb = document.getElementById('graph-sidebar'); | |
| if (sb) sb.innerHTML = '<h3>Graph</h3><p style="font-size:12px;color:var(--ink-faint);font-style:italic;">Rebuilding graph from observations...</p>'; | |
| await apiPost('graph/build', {}); | |
| state.graph.loaded = false; | |
| loadGraph(); | |
| } | |
| function drawNodeShape(ctx, x, y, r, type) { | |
| var shape = NODE_SHAPES[type] || 'circle'; | |
| switch(shape) { | |
| case 'rect': | |
| ctx.beginPath(); | |
| ctx.rect(x - r, y - r * 0.75, r * 2, r * 1.5); | |
| break; | |
| case 'diamond': | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y - r); | |
| ctx.lineTo(x + r, y); | |
| ctx.lineTo(x, y + r); | |
| ctx.lineTo(x - r, y); | |
| ctx.closePath(); | |
| break; | |
| case 'hexagon': | |
| ctx.beginPath(); | |
| for (var i = 0; i < 6; i++) { | |
| var angle = (Math.PI / 3) * i - Math.PI / 2; | |
| var hx = x + r * Math.cos(angle); | |
| var hy = y + r * Math.sin(angle); | |
| if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy); | |
| } | |
| ctx.closePath(); | |
| break; | |
| default: | |
| ctx.beginPath(); | |
| ctx.arc(x, y, r, 0, Math.PI * 2); | |
| break; | |
| } | |
| } | |
| function renderGraph() { | |
| var ctx = graphSim.ctx; | |
| var canvas = graphSim.canvas; | |
| if (!ctx || !canvas) return; | |
| var w = canvas.width / window.devicePixelRatio; | |
| var h = canvas.height / window.devicePixelRatio; | |
| ctx.clearRect(0, 0, w, h); | |
| var gridSize = 24; | |
| ctx.save(); | |
| ctx.strokeStyle = isDarkMode() ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'; | |
| ctx.lineWidth = 0.5; | |
| for (var gx = 0; gx < w; gx += gridSize) { | |
| ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, h); ctx.stroke(); | |
| } | |
| for (var gy = 0; gy < h; gy += gridSize) { | |
| ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| ctx.save(); | |
| ctx.translate(graphSim.panX, graphSim.panY); | |
| ctx.scale(graphSim.zoom, graphSim.zoom); | |
| var nodeMap = {}; | |
| graphSim.nodes.forEach(function(n) { nodeMap[n.id] = n; }); | |
| var searchActive = graphSearchTerm.length > 0; | |
| var totalVisible = graphSim.nodes.filter(function(n) { return state.graph.filters[n.type]; }).length; | |
| var isDense = totalVisible > 40; | |
| var labelZoomThreshold = isDense ? 1.5 : 0.5; | |
| var edgeLabelZoomThreshold = isDense ? 2.5 : 1.2; | |
| var selectedId = state.graph.selectedNode ? state.graph.selectedNode.id : null; | |
| var hoverNodeId = null; | |
| if (!graphSim.dragNode && graphSim.canvas) { | |
| var rect = graphSim.canvas.getBoundingClientRect(); | |
| var hx = (graphSim.mouseX - rect.left - graphSim.panX) / graphSim.zoom; | |
| var hy = (graphSim.mouseY - rect.top - graphSim.panY) / graphSim.zoom; | |
| for (var hi = graphSim.nodes.length - 1; hi >= 0; hi--) { | |
| var hn = graphSim.nodes[hi]; | |
| if (!state.graph.filters[hn.type]) continue; | |
| var hdx = hn.x - hx, hdy = hn.y - hy; | |
| if (hdx * hdx + hdy * hdy < hn.r * hn.r + 25) { hoverNodeId = hn.id; break; } | |
| } | |
| } | |
| var focusNodeId = selectedId || hoverNodeId; | |
| graphSim.edges.forEach(function(e) { | |
| var s = nodeMap[e.sourceNodeId]; | |
| var t = nodeMap[e.targetNodeId]; | |
| if (!s || !t) return; | |
| if (!state.graph.filters[s.type] || !state.graph.filters[t.type]) return; | |
| var edgeDimmed = searchActive && !(s.name.toLowerCase().includes(graphSearchTerm) || t.name.toLowerCase().includes(graphSearchTerm)); | |
| var isConnectedToFocus = focusNodeId && (e.sourceNodeId === focusNodeId || e.targetNodeId === focusNodeId); | |
| var isFocusActive = focusNodeId !== null; | |
| var weight = typeof e.weight === 'number' ? e.weight : 0.5; | |
| var lineWidth = isConnectedToFocus ? 2 + weight * 2 : 1 + weight * 1.5; | |
| var dx = t.x - s.x; | |
| var dy = t.y - s.y; | |
| var len = Math.sqrt(dx * dx + dy * dy) || 1; | |
| var curveOffset = isDense ? 12 : 18; | |
| var offsetX = -dy / len * curveOffset; | |
| var offsetY = dx / len * curveOffset; | |
| var cpx = (s.x + t.x) / 2 + offsetX; | |
| var cpy = (s.y + t.y) / 2 + offsetY; | |
| var edgeColor = s.type === 'folder' ? folderColor(s.id) : (NODE_COLORS[s.type] || '#666666'); | |
| var edgeAlpha; | |
| if (edgeDimmed) { | |
| edgeAlpha = 0.06; | |
| } else if (isFocusActive && isConnectedToFocus) { | |
| edgeAlpha = 0.65; | |
| } else if (isFocusActive && !isConnectedToFocus) { | |
| edgeAlpha = 0.06; | |
| } else { | |
| edgeAlpha = isDense ? 0.15 : 0.25; | |
| } | |
| ctx.beginPath(); | |
| ctx.moveTo(s.x, s.y); | |
| ctx.quadraticCurveTo(cpx, cpy, t.x, t.y); | |
| var r = parseInt(edgeColor.slice(1,3), 16); | |
| var g = parseInt(edgeColor.slice(3,5), 16); | |
| var b = parseInt(edgeColor.slice(5,7), 16); | |
| ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + edgeAlpha + ')'; | |
| ctx.lineWidth = lineWidth; | |
| ctx.stroke(); | |
| if (!isDense || isConnectedToFocus) { | |
| var arrowAngle = Math.atan2(t.y - cpy, t.x - cpx); | |
| var arrowLen = 5 + lineWidth; | |
| ctx.beginPath(); | |
| ctx.moveTo(t.x - t.r * Math.cos(arrowAngle), t.y - t.r * Math.sin(arrowAngle)); | |
| ctx.lineTo(t.x - (t.r + arrowLen) * Math.cos(arrowAngle - 0.3), t.y - (t.r + arrowLen) * Math.sin(arrowAngle - 0.3)); | |
| ctx.lineTo(t.x - (t.r + arrowLen) * Math.cos(arrowAngle + 0.3), t.y - (t.r + arrowLen) * Math.sin(arrowAngle + 0.3)); | |
| ctx.closePath(); | |
| ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + (edgeDimmed ? 0.06 : isConnectedToFocus ? 0.6 : 0.2) + ')'; | |
| ctx.fill(); | |
| } | |
| var showEdgeLabel = e.type && !edgeDimmed && isConnectedToFocus && graphSim.zoom > 0.4; | |
| if (showEdgeLabel) { | |
| var zoomInv = 1 / graphSim.zoom; | |
| ctx.save(); | |
| ctx.fillStyle = isDarkMode() ? (isConnectedToFocus ? 'rgba(238,238,238,0.9)' : 'rgba(180,180,180,0.7)') : (isConnectedToFocus ? 'rgba(17,17,17,0.85)' : 'rgba(80,80,80,0.7)'); | |
| ctx.font = (isConnectedToFocus ? '600 ' : '500 ') + (11 * zoomInv).toFixed(1) + 'px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(e.type, cpx, cpy - (4 * zoomInv)); | |
| ctx.restore(); | |
| } | |
| }); | |
| graphSim.nodes.forEach(function(n) { | |
| if (!state.graph.filters[n.type]) return; | |
| var color = n.type === 'folder' ? folderColor(n.id) : (NODE_COLORS[n.type] || '#666666'); | |
| var isSelected = selectedId === n.id; | |
| var isHovered = hoverNodeId === n.id; | |
| var matchesSearch = !searchActive || n.name.toLowerCase().includes(graphSearchTerm); | |
| var isFocusFaded = focusNodeId && n.id !== focusNodeId && !graphSim.edges.some(function(ed) { | |
| return (ed.sourceNodeId === focusNodeId && ed.targetNodeId === n.id) || | |
| (ed.targetNodeId === focusNodeId && ed.sourceNodeId === n.id); | |
| }); | |
| var nodeAlpha = !matchesSearch ? 0.12 : (isFocusFaded ? 0.2 : 1); | |
| ctx.save(); | |
| ctx.globalAlpha = nodeAlpha; | |
| if (matchesSearch && !isFocusFaded && (isSelected || isHovered || !searchActive)) { | |
| ctx.shadowColor = color; | |
| ctx.shadowBlur = isSelected ? 20 : isHovered ? 16 : (isDense ? 4 : 8); | |
| } | |
| drawNodeShape(ctx, n.x, n.y, n.r, n.type); | |
| var grad = ctx.createRadialGradient(n.x - n.r * 0.3, n.y - n.r * 0.3, 0, n.x, n.y, n.r * 1.2); | |
| var cr = parseInt(color.slice(1,3), 16); | |
| var cg = parseInt(color.slice(3,5), 16); | |
| var cb = parseInt(color.slice(5,7), 16); | |
| grad.addColorStop(0, 'rgba(' + Math.min(255, cr + 60) + ',' + Math.min(255, cg + 60) + ',' + Math.min(255, cb + 60) + ',0.95)'); | |
| grad.addColorStop(1, color); | |
| ctx.fillStyle = grad; | |
| ctx.fill(); | |
| ctx.restore(); | |
| if (isSelected) { | |
| ctx.save(); | |
| drawNodeShape(ctx, n.x, n.y, n.r + 3, n.type); | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 3; | |
| ctx.shadowColor = color; | |
| ctx.shadowBlur = 12; | |
| ctx.stroke(); | |
| ctx.restore(); | |
| } else if (isHovered) { | |
| drawNodeShape(ctx, n.x, n.y, n.r + 2, n.type); | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| } else if (searchActive && matchesSearch) { | |
| drawNodeShape(ctx, n.x, n.y, n.r, n.type); | |
| ctx.strokeStyle = '#CC0000'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| } | |
| var showLabel = !isFocusFaded && ( | |
| n.type === 'folder' || | |
| isSelected || isHovered || | |
| (searchActive && matchesSearch) || | |
| (!isDense && graphSim.zoom > labelZoomThreshold) || | |
| (isDense && graphSim.zoom > labelZoomThreshold && n.r > 10) | |
| ); | |
| if (showLabel) { | |
| var zoomInv = 1 / graphSim.zoom; | |
| ctx.save(); | |
| ctx.font = (isSelected || isHovered ? '600 ' : '500 ') + (13 * zoomInv).toFixed(1) + 'px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| var label = truncate(n.name, 18); | |
| var textW = ctx.measureText(label).width; | |
| var labelW = textW + (16 * zoomInv); | |
| var labelH = 20 * zoomInv; | |
| var labelY = n.y + n.r + (8 * zoomInv); | |
| ctx.fillStyle = isDarkMode() ? 'rgba(30,30,35,0.92)' : 'rgba(255,255,255,0.92)'; | |
| ctx.beginPath(); | |
| ctx.roundRect ? ctx.roundRect(n.x - labelW / 2, labelY, labelW, labelH, 4 * zoomInv) : ctx.rect(n.x - labelW / 2, labelY, labelW, labelH); | |
| ctx.fill(); | |
| ctx.strokeStyle = isDarkMode() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; | |
| ctx.lineWidth = 1 * zoomInv; | |
| ctx.stroke(); | |
| ctx.fillStyle = isDarkMode() ? (isSelected || isHovered ? '#eeeeee' : '#bbbbbb') : (isSelected || isHovered ? '#111111' : '#444444'); | |
| ctx.fillText(label, n.x, labelY + (14 * zoomInv)); | |
| ctx.restore(); | |
| } | |
| }); | |
| ctx.restore(); | |
| if (graphSim.nodes.length === 0) { | |
| ctx.fillStyle = '#999999'; | |
| ctx.font = '14px Lora, Georgia, serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('No graph data yet.', w / 2, h / 2 - 16); | |
| ctx.font = '12px Inter, sans-serif'; | |
| ctx.fillText('Set GRAPH_EXTRACTION_ENABLED=true to enable knowledge graph extraction.', w / 2, h / 2 + 8); | |
| } | |
| } | |
| async function loadMemories() { | |
| var el = document.getElementById('view-memories'); | |
| el.innerHTML = '<div class="loading">Loading memories...</div>'; | |
| var result = await apiGet('memories?latest=true&limit=2000'); | |
| var items = (result && result.memories) || []; | |
| items.sort(function(a, b) { | |
| var ac = (a && a.createdAt) || (a && a.updatedAt) || ''; | |
| var bc = (b && b.createdAt) || (b && b.updatedAt) || ''; | |
| return bc.localeCompare(ac); | |
| }); | |
| state.memories.items = items; | |
| state.memories.total = (result && typeof result.total === 'number') ? result.total : items.length; | |
| state.memories.loaded = true; | |
| renderMemories(); | |
| } | |
| function renderMemories() { | |
| var el = document.getElementById('view-memories'); | |
| var items = state.memories.items; | |
| var search = state.memories.search.toLowerCase(); | |
| var typeFilter = state.memories.typeFilter; | |
| var folderFilter = state.memories.folderFilter || ''; | |
| var projects = []; | |
| var hasNoFolder = false; | |
| items.forEach(function(m) { | |
| var pPath = m.project ? String(m.project).trim() : ''; | |
| if (pPath) { | |
| if (!projects.includes(pPath)) { | |
| projects.push(pPath); | |
| } | |
| } else { | |
| hasNoFolder = true; | |
| } | |
| }); | |
| projects.sort(); | |
| var filtered = items.filter(function(m) { | |
| if (typeFilter && m.type !== typeFilter) return false; | |
| var pPath = m.project ? String(m.project).trim() : ''; | |
| if (folderFilter) { | |
| if (folderFilter === '_none_') { | |
| if (pPath) return false; | |
| } else if (pPath !== folderFilter) { | |
| return false; | |
| } | |
| } | |
| const normalizedSearch = (search || '').normalize("NFKC").toLowerCase(); | |
| const normalizedTitle = (m.title || '').normalize("NFKC").toLowerCase(); | |
| const normalizedContent = (m.content || '').normalize("NFKC").toLowerCase(); | |
| if (search && !normalizedTitle.includes(normalizedSearch) && !normalizedContent.includes(normalizedSearch)) { | |
| return false; | |
| } | |
| return true; | |
| }); | |
| var types = {}; | |
| items.forEach(function(m) { types[m.type] = true; }); | |
| var typeOptions = Object.keys(types).sort(); | |
| var html = '<div class="card" style="margin-bottom:12px;padding:12px;background:var(--bg-subtle);">'; | |
| html += '<div style="font-size:13px;color:var(--ink-muted);line-height:1.5;">'; | |
| html += '<strong>Memories</strong> are durable facts, architecture notes, conventions, and lessons saved via <code>memory_remember</code> MCP tool or the <code>/agentmemory/remember</code> endpoint. They survive across sessions and supersede each other as v1, v2, etc. '; | |
| html += '<span style="color:var(--ink-faint);">Shown: ' + items.length + ' total.</span>'; | |
| html += '</div></div>'; | |
| html += '<div class="toolbar">'; | |
| html += '<input type="text" id="mem-search" placeholder="Search memories..." value="' + esc(state.memories.search) + '">'; | |
| html += '<select id="mem-type-filter"><option value="">All types</option>'; | |
| typeOptions.forEach(function(t) { | |
| html += '<option value="' + esc(t) + '"' + (typeFilter === t ? ' selected' : '') + '>' + esc(t) + '</option>'; | |
| }); | |
| html += '</select>'; | |
| html += '<select id="mem-folder-filter"><option value="">All folders</option>'; | |
| if (hasNoFolder) { | |
| html += '<option value="_none_"' + (folderFilter === '_none_' ? ' selected' : '') + '>Uncategorized (No Folder)</option>'; | |
| } | |
| projects.forEach(function(p) { | |
| html += '<option value="' + esc(p) + '"' + (folderFilter === p ? ' selected' : '') + '>' + esc(p) + '</option>'; | |
| }); | |
| html += '</select>'; | |
| html += '</div>'; | |
| if (filtered.length === 0) { | |
| html += '<div class="empty-state">' + | |
| '<div class="empty-icon">📚</div>' + | |
| '<div class="empty-title">No memories yet</div>' + | |
| '<div class="empty-lead">Memories are the distilled facts agentmemory keeps across sessions — things like file paths, architectural decisions, and user preferences. Hooks capture them automatically during coding sessions; you can also save one directly.</div>' + | |
| '<pre class="empty-cmd">memory_remember {\n title: "auth uses jose middleware",\n content: "src/middleware/auth.ts handles JWT validation",\n type: "architecture"\n}</pre>' + | |
| '<div><a class="empty-link" href="https://github.com/rohitg00/agentmemory#memories" target="_blank" rel="noopener">Memory types →</a></div>' + | |
| '</div>'; | |
| } else { | |
| html += '<table><tr><th>Title</th><th>Type</th><th>Strength</th><th>Version</th><th>Updated</th><th>Actions</th></tr>'; | |
| filtered.forEach(function(m) { | |
| var badgeClass = TYPE_BADGES[m.type] || 'badge-muted'; | |
| var rawStrength = m.strength || 0; | |
| var strength = Math.round(rawStrength <= 1 ? rawStrength * 100 : rawStrength * 10); | |
| if (strength > 100) strength = 100; | |
| var barColor = strength > 70 ? 'var(--green)' : strength > 40 ? 'var(--yellow)' : 'var(--red)'; | |
| html += '<tr>'; | |
| var preview = (m.content || '').split('\n').slice(0, 2).join(' ').trim(); | |
| var previewHtml = esc(truncate(preview, 150)); | |
| if (search && search.length > 2) { | |
| var re = new RegExp('(' + search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'); | |
| previewHtml = previewHtml.replace(re, '<mark>$1</mark>'); | |
| } | |
| html += '<td><span style="color:var(--ink);font-weight:600;">' + esc(truncate(m.title, 50)) + '</span>'; | |
| html += '<div style="font-size:12px;color:var(--ink-muted);margin-top:3px;line-height:1.4;max-height:34px;overflow:hidden;">' + previewHtml + '</div>'; | |
| if (m.concepts && m.concepts.length > 0) { | |
| html += '<div style="margin-top:3px;display:flex;gap:4px;flex-wrap:wrap;">'; | |
| m.concepts.slice(0, 4).forEach(function(c) { html += '<span class="tag">' + esc(c) + '</span>'; }); | |
| html += '</div>'; | |
| } | |
| html += '</td>'; | |
| html += '<td><span class="badge ' + badgeClass + '">' + esc(m.type) + '</span></td>'; | |
| html += '<td><div class="strength-bar"><div class="fill" style="width:' + strength + '%;background:' + barColor + '"></div></div> <span style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);">' + strength + '%</span></td>'; | |
| html += '<td style="color:var(--ink-muted);font-family:var(--font-mono);font-size:12px;">v' + (m.version || 1) + '</td>'; | |
| html += '<td style="font-size:11px;color:var(--ink-faint);font-family:var(--font-mono);">' + esc(formatTime(m.updatedAt)) + '</td>'; | |
| html += '<td><button class="btn btn-danger" style="font-size:9px;padding:2px 8px;" data-action="delete-memory" data-memory-id="' + esc(m.id) + '" data-memory-title="' + esc(m.title || '') + '">Delete</button></td>'; | |
| html += '</tr>'; | |
| }); | |
| html += '</table>'; | |
| } | |
| var __focus = captureSearchFocus(['mem-search']); | |
| el.innerHTML = html; | |
| var searchInput = document.getElementById('mem-search'); | |
| if (searchInput) { | |
| bindImeSafeSearch(searchInput, 200, function(v){ state.memories.search = v; renderMemories(); }); | |
| } | |
| var typeSelect = document.getElementById('mem-type-filter'); | |
| if (typeSelect) { | |
| typeSelect.addEventListener('change', function() { | |
| state.memories.typeFilter = this.value; | |
| renderMemories(); | |
| }); | |
| } | |
| var folderSelect = document.getElementById('mem-folder-filter'); | |
| if (folderSelect) { | |
| folderSelect.addEventListener('change', function() { | |
| state.memories.folderFilter = this.value; | |
| renderMemories(); | |
| }); | |
| } | |
| restoreSearchFocus(__focus); | |
| } | |
| function deleteMemory(id, title) { | |
| var modal = document.getElementById('modal'); | |
| var overlay = document.getElementById('modal-overlay'); | |
| modal.innerHTML = '<h3>Delete Memory</h3><p>Are you sure you want to delete "' + esc(title) + '"? This action cannot be undone.</p><div class="modal-actions"><button class="btn" data-action="close-modal">Cancel</button><button class="btn btn-danger" data-action="confirm-delete-memory" data-memory-id="' + esc(id) + '">Delete</button></div>'; | |
| overlay.classList.add('open'); | |
| } | |
| async function confirmDeleteMemory(id) { | |
| closeModal(); | |
| await apiDelete('governance/memories', { memoryIds: [id], reason: 'Deleted via viewer' }); | |
| state.memories.loaded = false; | |
| loadMemories(); | |
| } | |
| function closeModal() { | |
| document.getElementById('modal-overlay').classList.remove('open'); | |
| } | |
| async function loadTimeline() { | |
| var el = document.getElementById('view-timeline'); | |
| el.innerHTML = '<div class="loading">Loading timeline...</div>'; | |
| var sessResult = await apiGet('sessions'); | |
| var sessions = (sessResult && sessResult.sessions) || []; | |
| state.timeline.loaded = true; | |
| if (sessions.length > 0 && !state.timeline.sessionId) { | |
| var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }); | |
| var firstSelectable = sorted.find(function(s) { return sessionId(s); }); | |
| state.timeline.sessionId = firstSelectable ? sessionId(firstSelectable) : ''; | |
| } | |
| renderTimelineToolbar(sessions); | |
| if (state.timeline.sessionId) await loadObservations(); | |
| } | |
| function renderTimelineToolbar(sessions) { | |
| var el = document.getElementById('view-timeline'); | |
| var html = '<div class="toolbar">'; | |
| html += '<select id="tl-session"><option value="">Select session</option>'; | |
| sessions.sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).forEach(function(s) { | |
| var id = sessionId(s); | |
| var disabled = id ? '' : ' disabled'; | |
| html += '<option value="' + esc(id) + '"' + (id && state.timeline.sessionId === id ? ' selected' : '') + disabled + '>' + esc(sessionLabel(s)) + '</option>'; | |
| }); | |
| html += '</select>'; | |
| html += '<select id="tl-importance"><option value="0">All importance</option>'; | |
| for (var i = 1; i <= 9; i++) { | |
| html += '<option value="' + i + '"' + (state.timeline.minImportance === i ? ' selected' : '') + '>≥ ' + i + '</option>'; | |
| } | |
| html += '</select></div>'; | |
| html += '<div id="tl-content"></div>'; | |
| el.innerHTML = html; | |
| document.getElementById('tl-session').addEventListener('change', function() { | |
| state.timeline.sessionId = this.value; | |
| state.timeline.page = 0; | |
| loadObservations(); | |
| }); | |
| document.getElementById('tl-importance').addEventListener('change', function() { | |
| state.timeline.minImportance = parseInt(this.value); | |
| renderObservations(); | |
| }); | |
| } | |
| async function loadObservations() { | |
| var content = document.getElementById('tl-content'); | |
| if (!content) return; | |
| if (!state.timeline.sessionId) { | |
| content.innerHTML = '<div class="empty-state"><div class="empty-icon">🕑</div><p>Select a session to view observations</p></div>'; | |
| return; | |
| } | |
| content.innerHTML = '<div class="loading">Loading observations...</div>'; | |
| var result = await apiGet('observations?sessionId=' + encodeURIComponent(state.timeline.sessionId)); | |
| state.timeline.observations = (result && result.observations) || []; | |
| renderObservations(); | |
| } | |
| var tlTypeFilter = ''; | |
| function renderObservations() { | |
| var content = document.getElementById('tl-content'); | |
| if (!content) return; | |
| var obs = state.timeline.observations; | |
| var minImp = state.timeline.minImportance; | |
| var filtered = minImp > 0 ? obs.filter(function(o) { return (o.importance || 0) >= minImp; }) : obs; | |
| var TOOL_TYPE_MAP = { Read: 'file_read', Write: 'file_write', Edit: 'file_edit', Bash: 'command_run', Grep: 'search', Glob: 'search', WebFetch: 'web_fetch', WebSearch: 'web_fetch', AskUserQuestion: 'conversation', Task: 'subagent' }; | |
| var typeCounts = {}; | |
| filtered.forEach(function(o) { | |
| var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other'); | |
| typeCounts[t] = (typeCounts[t] || 0) + 1; | |
| }); | |
| var typeList = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; }); | |
| if (tlTypeFilter) { | |
| filtered = filtered.filter(function(o) { | |
| var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other'); | |
| return t === tlTypeFilter; | |
| }); | |
| } | |
| var pageSize = state.timeline.pageSize; | |
| var page = state.timeline.page; | |
| var start = page * pageSize; | |
| var paged = filtered.slice(start, start + pageSize); | |
| var totalPages = Math.ceil(filtered.length / pageSize); | |
| var html = '<div class="type-chips">'; | |
| html += '<span class="type-chip' + (!tlTypeFilter ? ' active' : '') + '" data-action="timeline-filter" data-type-filter="">All (' + obs.length + ')</span>'; | |
| typeList.forEach(function(t) { | |
| var color = OBS_TYPE_COLORS[t] || '#666666'; | |
| html += '<span class="type-chip' + (tlTypeFilter === t ? ' active' : '') + '" data-action="timeline-filter" data-type-filter="' + esc(t) + '" style="' + (tlTypeFilter === t ? 'background:' + color + ';border-color:' + color + ';' : 'border-color:' + color + ';color:' + color + ';') + '">' + esc(t.replace(/_/g, ' ')) + ' (' + typeCounts[t] + ')</span>'; | |
| }); | |
| html += '</div>'; | |
| if (paged.length === 0) { | |
| html += '<div class="empty-state"><div class="empty-icon">🕑</div><p>No observations' + (obs.length > 0 ? ' match the filter (' + obs.length + ' total)' : ' for this session') + '</p></div>'; | |
| content.innerHTML = html; | |
| return; | |
| } | |
| html += '<div style="font-size:11px;color:var(--ink-faint);margin-bottom:16px;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:0.06em;">' + filtered.length + ' observations shown</div>'; | |
| html += '<div class="timeline-container">'; | |
| var lastDateGroup = ''; | |
| paged.forEach(function(o, idx) { | |
| var isCompressed = !!o.narrative || !!o.type; | |
| var isRaw = !isCompressed; | |
| var type = o.type || TOOL_TYPE_MAP[o.toolName] || 'other'; | |
| var impVal = typeof o.importance === 'number' ? o.importance : 5; | |
| var impClass = impVal >= 7 ? 'high' : impVal >= 4 ? 'med' : 'low'; | |
| var title = o.title || o.toolName || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'Observation'); | |
| var typeColor = OBS_TYPE_COLORS[type] || '#666666'; | |
| var icon = OBS_TYPE_ICONS[type] || '📄'; | |
| var dateGroup = ''; | |
| try { | |
| var d = new Date(o.timestamp); | |
| dateGroup = d.toLocaleDateString() + ' ' + d.getHours() + ':00'; | |
| } catch(e) { dateGroup = ''; } | |
| if (dateGroup && dateGroup !== lastDateGroup) { | |
| html += '<div class="timeline-date-marker"><span>' + esc(dateGroup) + '</span></div>'; | |
| lastDateGroup = dateGroup; | |
| } | |
| var side = idx % 2 === 0 ? 'tl-left' : 'tl-right'; | |
| html += '<div class="timeline-item ' + side + '">'; | |
| html += '<div class="timeline-dot" style="background:' + typeColor + ';"></div>'; | |
| html += '<div class="timeline-connector"></div>'; | |
| html += '<div class="obs-card imp-' + impClass + '" style="border-left-color:' + typeColor + ';text-align:left;">'; | |
| html += '<div class="obs-head">'; | |
| html += '<div class="obs-title-row">'; | |
| html += '<span class="obs-type-icon">' + icon + '</span>'; | |
| html += '<span class="obs-title" title="' + esc(title) + '">' + esc(title) + '</span>'; | |
| if (isRaw) html += '<span class="badge badge-muted" style="font-size:8px;margin-left:4px;">raw</span>'; | |
| html += '</div>'; | |
| html += '<div class="obs-meta">'; | |
| if (isCompressed) html += '<span class="obs-importance imp-' + impVal + '" title="Importance: ' + impVal + '/10">' + impVal + '</span>'; | |
| html += '<span class="obs-time">' + esc(shortTime(o.timestamp)) + '</span>'; | |
| html += '</div></div>'; | |
| if (o.subtitle) html += '<div class="obs-subtitle">' + esc(o.subtitle) + '</div>'; | |
| html += '<div style="margin-top:4px;">'; | |
| html += '<span class="badge" style="border-color:' + typeColor + ';color:' + typeColor + ';margin-right:4px;">' + esc(type.replace(/_/g, ' ')) + '</span>'; | |
| if (o.hookType) html += '<span class="badge badge-muted" style="margin-right:4px;">' + esc(o.hookType) + '</span>'; | |
| html += '</div>'; | |
| if (isRaw && o.toolInput) { | |
| var inputStr = typeof o.toolInput === 'string' ? o.toolInput : JSON.stringify(o.toolInput); | |
| html += '<div style="margin-top:6px;"><span style="font-size:10px;color:var(--ink-muted);font-weight:600;font-family:var(--font-ui);text-transform:uppercase;letter-spacing:0.08em;">Input:</span>'; | |
| html += '<pre style="font-size:11px;color:var(--ink-muted);background:var(--bg-alt);padding:8px 10px;border:1px solid var(--border-light);margin-top:3px;overflow-x:auto;max-height:80px;font-family:var(--font-mono);">' + esc(truncate(inputStr, 300)) + '</pre></div>'; | |
| } | |
| if (isRaw && o.toolOutput) { | |
| var outputStr = typeof o.toolOutput === 'string' ? o.toolOutput : JSON.stringify(o.toolOutput); | |
| html += '<div style="margin-top:4px;"><span style="font-size:10px;color:var(--ink-muted);font-weight:600;font-family:var(--font-ui);text-transform:uppercase;letter-spacing:0.08em;">Output:</span>'; | |
| html += '<div class="obs-narrative" style="margin-top:3px;">' + esc(truncate(outputStr, 300)) + '</div></div>'; | |
| } | |
| if (o.narrative) html += '<div class="obs-narrative" style="margin-top:8px;">' + esc(o.narrative) + '</div>'; | |
| if (o.facts && o.facts.length > 0) { | |
| html += '<ul class="obs-facts">'; | |
| o.facts.forEach(function(f) { html += '<li>' + esc(f) + '</li>'; }); | |
| html += '</ul>'; | |
| } | |
| var hasTags = (o.concepts && o.concepts.length) || (o.files && o.files.length); | |
| if (hasTags) { | |
| html += '<div class="tag-list">'; | |
| (o.concepts || []).forEach(function(c) { html += '<span class="tag">' + esc(c) + '</span>'; }); | |
| (o.files || []).forEach(function(f) { | |
| var short = f.split('/').pop(); | |
| html += '<span class="tag file-tag" title="' + esc(f) + '">' + esc(short) + '</span>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| if (isRaw && o.toolInput) { | |
| var files = []; | |
| var ti = o.toolInput; | |
| if (typeof ti === 'object' && ti !== null) { | |
| if (ti.file_path) files.push(ti.file_path); | |
| if (ti.path) files.push(ti.path); | |
| } | |
| if (files.length > 0) { | |
| html += '<div class="tag-list">'; | |
| files.forEach(function(f) { | |
| var short = String(f).split('/').pop(); | |
| html += '<span class="tag file-tag" title="' + esc(f) + '">' + esc(short) + '</span>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| } | |
| html += '</div>'; | |
| html += '</div>'; | |
| }); | |
| html += '</div>'; | |
| if (totalPages > 1) { | |
| html += '<div class="pagination">'; | |
| if (page > 0) html += '<button class="btn" data-action="timeline-page" data-page="' + (page - 1) + '">Prev</button>'; | |
| html += '<span style="color:var(--ink-faint);font-size:12px;padding:6px;font-family:var(--font-mono);">Page ' + (page + 1) + ' of ' + totalPages + ' (' + filtered.length + ' total)</span>'; | |
| if (page < totalPages - 1) html += '<button class="btn" data-action="timeline-page" data-page="' + (page + 1) + '">Next</button>'; | |
| html += '</div>'; | |
| } | |
| content.innerHTML = html; | |
| } | |
| function setTlTypeFilter(type) { | |
| tlTypeFilter = type; | |
| state.timeline.page = 0; | |
| renderObservations(); | |
| } | |
| function tlPage(p) { | |
| state.timeline.page = p; | |
| renderObservations(); | |
| } | |
| async function loadActivity() { | |
| var el = document.getElementById('view-activity'); | |
| el.innerHTML = '<div class="loading">Loading activity...</div>'; | |
| var results = await Promise.all([ | |
| apiGet('sessions'), | |
| apiGet('audit?limit=200') | |
| ]); | |
| var sessions = (results[0] && results[0].sessions) || []; | |
| var auditEntries = (results[1] && results[1].entries) || []; | |
| var allObs = []; | |
| var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }); | |
| var recentSessions = sorted.slice(0, 5); | |
| var obsResults = await Promise.all(recentSessions.filter(function(s) { return sessionId(s); }).map(function(s) { | |
| return apiGet('observations?sessionId=' + encodeURIComponent(sessionId(s))); | |
| })); | |
| obsResults.forEach(function(r) { | |
| if (r && r.observations) allObs = allObs.concat(r.observations); | |
| }); | |
| state.activity.sessions = sessions; | |
| state.activity.observations = allObs; | |
| state.activity.audit = auditEntries; | |
| state.activity.loaded = true; | |
| renderActivity(); | |
| } | |
| function renderActivity() { | |
| var el = document.getElementById('view-activity'); | |
| var obs = state.activity.observations; | |
| var sessions = state.activity.sessions; | |
| var TOOL_TYPE_MAP = { Read: 'file_read', Write: 'file_write', Edit: 'file_edit', Bash: 'command_run', Grep: 'search', Glob: 'search', WebFetch: 'web_fetch', WebSearch: 'web_fetch', AskUserQuestion: 'conversation', Task: 'subagent' }; | |
| var html = ''; | |
| html += '<div class="card"><div class="card-title">Activity Heatmap (Past Year)</div>'; | |
| var dayCounts = {}; | |
| obs.forEach(function(o) { | |
| try { | |
| var d = new Date(o.timestamp); | |
| var key = d.toISOString().slice(0, 10); | |
| dayCounts[key] = (dayCounts[key] || 0) + 1; | |
| } catch(e) {} | |
| }); | |
| sessions.forEach(function(s) { | |
| try { | |
| var d = new Date(s.startedAt); | |
| var key = d.toISOString().slice(0, 10); | |
| dayCounts[key] = (dayCounts[key] || 0) + 1; | |
| } catch(e) {} | |
| }); | |
| var maxCount = 0; | |
| Object.keys(dayCounts).forEach(function(k) { if (dayCounts[k] > maxCount) maxCount = dayCounts[k]; }); | |
| var today = new Date(); | |
| var dayLabels = ['Mon', '', 'Wed', '', 'Fri', '', '']; | |
| html += '<div class="heatmap-labels">'; | |
| dayLabels.forEach(function(l) { html += '<span style="width:10px;text-align:center;">' + l + '</span>'; }); | |
| html += '</div>'; | |
| html += '<div class="heatmap-wrap"><div class="heatmap-grid">'; | |
| for (var w = 51; w >= 0; w--) { | |
| for (var d = 0; d < 7; d++) { | |
| var cellDate = new Date(today); | |
| cellDate.setDate(cellDate.getDate() - (w * 7 + (6 - d))); | |
| var key = cellDate.toISOString().slice(0, 10); | |
| var count = dayCounts[key] || 0; | |
| var level = count === 0 ? '' : count <= (maxCount * 0.25) ? 'level-1' : count <= (maxCount * 0.5) ? 'level-2' : count <= (maxCount * 0.75) ? 'level-3' : 'level-4'; | |
| var title = key + ': ' + count + ' event' + (count !== 1 ? 's' : ''); | |
| html += '<div class="heatmap-cell ' + level + '" title="' + esc(title) + '"></div>'; | |
| } | |
| } | |
| html += '</div></div>'; | |
| html += '<div style="display:flex;align-items:center;gap:4px;margin-top:8px;font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);justify-content:flex-end;">Less '; | |
| html += '<div class="heatmap-cell" style="display:inline-block;"></div>'; | |
| html += '<div class="heatmap-cell level-1" style="display:inline-block;"></div>'; | |
| html += '<div class="heatmap-cell level-2" style="display:inline-block;"></div>'; | |
| html += '<div class="heatmap-cell level-3" style="display:inline-block;"></div>'; | |
| html += '<div class="heatmap-cell level-4" style="display:inline-block;"></div>'; | |
| html += ' More</div>'; | |
| html += '</div>'; | |
| var typeCounts = {}; | |
| obs.forEach(function(o) { | |
| var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other'); | |
| typeCounts[t] = (typeCounts[t] || 0) + 1; | |
| }); | |
| var typeList = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; }); | |
| var totalObs = obs.length || 1; | |
| html += '<div class="two-col" style="margin-top:16px;">'; | |
| html += '<div class="card"><div class="card-title">Type Breakdown</div>'; | |
| if (typeList.length === 0) { | |
| html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No observations yet</div>'; | |
| } else { | |
| html += '<div class="bar-chart">'; | |
| typeList.slice(0, 12).forEach(function(t) { | |
| var pct = Math.round((typeCounts[t] / totalObs) * 100); | |
| var color = OBS_TYPE_COLORS[t] || '#666666'; | |
| html += '<div class="bar-row"><span class="bar-label">' + esc(t.replace(/_/g, ' ')) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:' + color + ';"></div></div><span class="bar-value">' + typeCounts[t] + '</span></div>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| html += '</div>'; | |
| html += '<div class="card"><div class="card-title">Activity Feed</div>'; | |
| var sortedObs = obs.slice().sort(function(a, b) { return (b.timestamp || '').localeCompare(a.timestamp || ''); }); | |
| if (sortedObs.length === 0) { | |
| html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No recent activity</div>'; | |
| } else { | |
| sortedObs.slice(0, 20).forEach(function(o) { | |
| var type = o.type || TOOL_TYPE_MAP[o.toolName] || 'other'; | |
| var typeColor = OBS_TYPE_COLORS[type] || '#666666'; | |
| var icon = OBS_TYPE_ICONS[type] || '📄'; | |
| var title = o.title || o.toolName || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'Observation'); | |
| html += '<div class="activity-feed-item">'; | |
| html += '<div class="activity-feed-icon" style="color:' + typeColor + ';border-color:' + typeColor + ';">' + icon + '</div>'; | |
| html += '<div class="activity-feed-body">'; | |
| html += '<div class="activity-feed-title">' + esc(truncate(title, 60)) + '</div>'; | |
| if (o.narrative) html += '<div style="font-size:12px;color:var(--ink-muted);margin-top:2px;">' + esc(truncate(o.narrative, 100)) + '</div>'; | |
| html += '<div class="activity-feed-meta">' + esc(type.replace(/_/g, ' ')); | |
| if (o.files && o.files.length) html += ' · <span class="tag file-tag" style="font-size:9px;padding:0 4px;">' + esc(o.files[0].split('/').pop()) + '</span>'; | |
| html += ' · ' + esc(shortTime(o.timestamp)) + '</div>'; | |
| html += '</div></div>'; | |
| }); | |
| } | |
| html += '</div>'; | |
| html += '</div>'; | |
| el.innerHTML = html; | |
| } | |
| async function loadSessions() { | |
| var el = document.getElementById('view-sessions'); | |
| el.innerHTML = '<div class="loading">Loading sessions...</div>'; | |
| var result = await apiGet('sessions'); | |
| state.sessions.items = (result && result.sessions) || []; | |
| state.sessions.loaded = true; | |
| renderSessions(); | |
| } | |
| function renderSessions() { | |
| var el = document.getElementById('view-sessions'); | |
| var items = state.sessions.items.slice().sort(function(a, b) { | |
| return (b.startedAt || '').localeCompare(a.startedAt || ''); | |
| }); | |
| var folderFilter = state.sessions.folderFilter || ''; | |
| var projects = []; | |
| var hasNoFolder = false; | |
| state.sessions.items.forEach(function(s) { | |
| var pPath = s.project ? String(s.project).trim() : ''; | |
| if (pPath) { | |
| if (!projects.includes(pPath)) { | |
| projects.push(pPath); | |
| } | |
| } else { | |
| hasNoFolder = true; | |
| } | |
| }); | |
| projects.sort(); | |
| var toolbarHtml = '<div class="toolbar" style="margin-bottom: 12px; display: flex; gap: 10px; align-items: center;">'; | |
| toolbarHtml += '<span style="font-size: 12px; font-weight: 600; color: var(--ink-muted);">FILTER BY FOLDER PATH:</span>'; | |
| toolbarHtml += '<select id="sessions-folder-filter" style="width: 280px; padding: 6px 10px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--ink);">'; | |
| toolbarHtml += '<option value="">All Folders</option>'; | |
| if (hasNoFolder) { | |
| var selected = folderFilter === '_none_' ? ' selected' : ''; | |
| toolbarHtml += '<option value="_none_"' + selected + '>Uncategorized (No Folder)</option>'; | |
| } | |
| projects.forEach(function(p) { | |
| var selected = p === folderFilter ? ' selected' : ''; | |
| toolbarHtml += '<option value="' + esc(p) + '"' + selected + '>' + esc(p) + '</option>'; | |
| }); | |
| toolbarHtml += '</select>'; | |
| toolbarHtml += '</div>'; | |
| var filteredItems = items.filter(function(s) { | |
| if (!folderFilter) return true; | |
| var pPath = s.project ? String(s.project).trim() : ''; | |
| if (folderFilter === '_none_') { | |
| return !pPath; | |
| } | |
| return pPath === folderFilter; | |
| }); | |
| var html = toolbarHtml + '<div class="session-list">'; | |
| if (filteredItems.length === 0) { | |
| html += '<div class="empty-state"><div class="empty-icon">🗒</div><p>No sessions</p></div>'; | |
| } else { | |
| filteredItems.forEach(function(s) { | |
| var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted'; | |
| var id = sessionId(s); | |
| var selected = id && state.sessions.selectedId === id; | |
| html += '<div class="session-item' + (selected ? ' selected' : '') + '"' + (id ? ' data-action="select-session" data-session-id="' + esc(id) + '"' : '') + '>'; | |
| html += '<div class="session-top"><span class="session-project">' + esc(sessionDisplayName(s)) + '</span>'; | |
| html += '<span class="badge ' + statusBadge + '">' + esc(s.status) + '</span></div>'; | |
| var preview = s.summary || s.firstPrompt || ''; | |
| if (preview) { | |
| html += '<div class="session-preview" style="font-size:13px;color:var(--ink);margin:4px 0;line-height:1.4;">' + esc(truncate(preview, 140)) + '</div>'; | |
| } | |
| html += '<div class="session-meta">' + esc(shortSessionId(s, 12) || 'missing id') + ' · ' + esc(formatTime(s.startedAt)); | |
| html += ' · ' + (s.observationCount || 0) + ' obs'; | |
| if (s.model) html += ' · ' + esc(s.model); | |
| html += '</div></div>'; | |
| }); | |
| } | |
| html += '</div>'; | |
| html += '<div id="session-detail"></div>'; | |
| el.innerHTML = html; | |
| var selectFilter = document.getElementById('sessions-folder-filter'); | |
| if (selectFilter) { | |
| selectFilter.addEventListener('change', function() { | |
| state.sessions.folderFilter = this.value; | |
| renderSessions(); | |
| }); | |
| } | |
| if (state.sessions.selectedId) renderSessionDetail(); | |
| } | |
| function selectSession(id) { | |
| state.sessions.selectedId = state.sessions.selectedId === id ? null : id; | |
| renderSessions(); | |
| } | |
| async function renderSessionDetail() { | |
| var panel = document.getElementById('session-detail'); | |
| if (!panel) return; | |
| var s = state.sessions.items.find(function(x) { return sessionId(x) === state.sessions.selectedId; }); | |
| var id = sessionId(s); | |
| if (!s || !id) { panel.innerHTML = ''; return; } | |
| panel.innerHTML = '<div class="detail-panel"><h3>Loading session details…</h3></div>'; | |
| var obsRes = await apiGet('observations?sessionId=' + encodeURIComponent(id)); | |
| var obs = (obsRes && obsRes.observations) || []; | |
| var typeCounts = {}; | |
| var toolCounts = {}; | |
| var fileSet = new Set(); | |
| var firstPromptFromObs = ''; | |
| obs.forEach(function(o) { | |
| var t = o.type || o.hookType || 'other'; | |
| typeCounts[t] = (typeCounts[t] || 0) + 1; | |
| var tool = o.title || o.toolName; | |
| if (tool && t !== 'conversation') toolCounts[tool] = (toolCounts[tool] || 0) + 1; | |
| (o.files || []).forEach(function(f) { fileSet.add(f); }); | |
| if (!firstPromptFromObs && (o.userPrompt || (o.type === 'conversation' && o.narrative))) { | |
| firstPromptFromObs = o.userPrompt || o.narrative || ''; | |
| } | |
| }); | |
| var durationMs = s.endedAt ? new Date(s.endedAt).getTime() - new Date(s.startedAt).getTime() : 0; | |
| var durationLabel = durationMs > 0 ? (durationMs < 60000 ? (durationMs / 1000).toFixed(1) + 's' : (durationMs / 60000).toFixed(1) + 'm') : '-'; | |
| var preview = s.summary || s.firstPrompt || firstPromptFromObs || ''; | |
| var html = '<div class="detail-panel">'; | |
| html += '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px;">'; | |
| html += '<h3 style="margin:0;">Session · ' + esc(s.project || 'Unknown') + '</h3>'; | |
| html += '<span class="badge ' + (s.status === 'active' ? 'badge-green' : 'badge-blue') + '">' + esc(s.status) + '</span>'; | |
| html += '</div>'; | |
| if (preview) { | |
| html += '<div style="padding:10px 12px;margin-bottom:12px;background:var(--bg-alt);border-left:3px solid var(--accent);font-size:13px;line-height:1.5;color:var(--ink);">' + esc(truncate(preview, 600)) + '</div>'; | |
| } | |
| html += '<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:10px;margin-bottom:14px;">'; | |
| html += '<div class="card" style="padding:10px;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);">OBSERVATIONS</div><div style="font-size:20px;font-weight:600;">' + obs.length + '</div></div>'; | |
| html += '<div class="card" style="padding:10px;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);">TOOLS USED</div><div style="font-size:20px;font-weight:600;">' + Object.keys(toolCounts).length + '</div></div>'; | |
| html += '<div class="card" style="padding:10px;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);">FILES TOUCHED</div><div style="font-size:20px;font-weight:600;">' + fileSet.size + '</div></div>'; | |
| html += '<div class="card" style="padding:10px;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);">DURATION</div><div style="font-size:20px;font-weight:600;">' + esc(durationLabel) + '</div></div>'; | |
| html += '</div>'; | |
| var topTools = Object.keys(toolCounts).sort(function(a, b) { return toolCounts[b] - toolCounts[a]; }).slice(0, 10); | |
| if (topTools.length > 0) { | |
| var maxC = toolCounts[topTools[0]] || 1; | |
| html += '<div class="card" style="margin-bottom:12px;"><div class="card-title">Tool Invocations</div>'; | |
| html += '<div class="bar-chart" style="margin-top:8px;">'; | |
| topTools.forEach(function(t) { | |
| var pct = Math.round((toolCounts[t] / maxC) * 100); | |
| html += '<div class="bar-row"><span class="bar-label" style="font-family:var(--font-mono);">' + esc(t) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--accent);"></div></div><span class="bar-value">' + toolCounts[t] + '</span></div>'; | |
| }); | |
| html += '</div></div>'; | |
| } | |
| var typeKeys = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; }); | |
| if (typeKeys.length > 0) { | |
| html += '<div class="card" style="margin-bottom:12px;"><div class="card-title">Activity Breakdown</div>'; | |
| html += '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px;">'; | |
| typeKeys.forEach(function(t) { | |
| html += '<span class="badge badge-muted" style="font-family:var(--font-mono);">' + esc(t.replace(/_/g, ' ')) + ' · ' + typeCounts[t] + '</span>'; | |
| }); | |
| html += '</div></div>'; | |
| } | |
| if (fileSet.size > 0) { | |
| var filesArr = Array.from(fileSet).slice(0, 30); | |
| html += '<div class="card" style="margin-bottom:12px;"><div class="card-title">Files</div>'; | |
| html += '<div style="font-size:12px;font-family:var(--font-mono);line-height:1.6;margin-top:8px;">'; | |
| filesArr.forEach(function(f) { html += '<div>• ' + esc(f) + '</div>'; }); | |
| if (fileSet.size > 30) html += '<div style="color:var(--ink-faint);">+' + (fileSet.size - 30) + ' more</div>'; | |
| html += '</div></div>'; | |
| } | |
| html += '<div class="card" style="margin-bottom:12px;"><div class="card-title">Metadata</div>'; | |
| html += '<div style="font-size:12px;font-family:var(--font-mono);margin-top:8px;line-height:1.7;">'; | |
| var detailId = sessionId(s); | |
| html += '<div><span style="color:var(--ink-muted);">id:</span> ' + esc(detailId || 'missing id') + '</div>'; | |
| html += '<div><span style="color:var(--ink-muted);">cwd:</span> ' + esc(s.cwd || '-') + '</div>'; | |
| html += '<div><span style="color:var(--ink-muted);">started:</span> ' + esc(formatTime(s.startedAt)) + '</div>'; | |
| if (s.endedAt) html += '<div><span style="color:var(--ink-muted);">ended:</span> ' + esc(formatTime(s.endedAt)) + '</div>'; | |
| if (s.model) html += '<div><span style="color:var(--ink-muted);">model:</span> ' + esc(s.model) + '</div>'; | |
| if (s.tags && s.tags.length) html += '<div><span style="color:var(--ink-muted);">tags:</span> ' + s.tags.map(esc).join(', ') + '</div>'; | |
| html += '</div></div>'; | |
| html += '<div style="display:flex;gap:8px;">'; | |
| if (detailId && s.status === 'active') { | |
| html += '<button class="btn btn-danger" data-action="end-session" data-session-id="' + esc(detailId) + '">End Session</button>'; | |
| } | |
| if (detailId) { | |
| html += '<button class="btn btn-primary" data-action="summarize-session" data-session-id="' + esc(detailId) + '">Summarize</button>'; | |
| } else { | |
| html += '<button class="btn btn-primary" disabled>Summarize unavailable</button>'; | |
| } | |
| html += '</div></div>'; | |
| panel.innerHTML = html; | |
| } | |
| async function endSession(id) { | |
| await apiPost('session/end', { sessionId: id }); | |
| state.sessions.loaded = false; | |
| loadSessions(); | |
| } | |
| async function summarizeSession(id, btn) { | |
| if (!btn) return; | |
| btn.textContent = 'Summarizing...'; | |
| btn.disabled = true; | |
| await apiPost('summarize', { sessionId: id }); | |
| btn.textContent = 'Done'; | |
| setTimeout(function() { btn.textContent = 'Summarize'; btn.disabled = false; }, 2000); | |
| } | |
| async function loadLessons() { | |
| var el = document.getElementById('view-lessons'); | |
| el.innerHTML = '<div class="loading">Loading lessons...</div>'; | |
| var result = await apiGet('lessons'); | |
| state.lessons.items = (result && result.lessons) || []; | |
| state.lessons.loaded = true; | |
| renderLessons(); | |
| } | |
| function renderLessons() { | |
| var el = document.getElementById('view-lessons'); | |
| var items = state.lessons.items; | |
| var search = state.lessons.search.toLowerCase(); | |
| if (search) { | |
| items = items.filter(function(l) { | |
| return (l.content + ' ' + l.context + ' ' + (l.tags || []).join(' ')).toLowerCase().indexOf(search) >= 0; | |
| }); | |
| } | |
| var html = '<div class="card" style="margin-bottom:12px;padding:12px;background:var(--bg-subtle);">'; | |
| html += '<div style="font-size:13px;color:var(--ink-muted);line-height:1.5;">'; | |
| html += '<strong>Lessons</strong> are portable heuristics — short imperative rules (always/never/prefer/avoid) extracted from past work. Auto-surface from JSONL imports (low confidence, tag <code>auto-import</code>), get reinforced when the agent applies them, and decay if unused. Higher confidence = more battle-tested.'; | |
| html += '</div></div>'; | |
| html += '<div style="display:flex;gap:8px;margin-bottom:12px;">'; | |
| html += '<input id="lessons-search" class="search-input" type="text" placeholder="Search lessons..." value="' + esc(state.lessons.search) + '" style="flex:1" />'; | |
| html += '<span style="font-size:12px;color:var(--ink-faint);align-self:center;">' + items.length + ' lessons</span>'; | |
| html += '</div>'; | |
| if (items.length === 0) { | |
| html += '<div class="empty-state">' + | |
| '<div class="empty-icon">💡</div>' + | |
| '<div class="empty-title">No lessons yet</div>' + | |
| '<div class="empty-lead">Lessons are confidence-scored pattern observations — things you corrected once that the agent should never do again. They persist across projects.</div>' + | |
| '<pre class="empty-cmd"># Save a lesson explicitly\nmemory_lesson_save { rule, reason, confidence }\n\n# Or: Replay tab → Import JSONL auto-extracts lessons\n# from your past Claude Code sessions</pre>' + | |
| '<div><a class="empty-link" href="https://github.com/rohitg00/agentmemory#lessons" target="_blank" rel="noopener">Lesson decay & scoring →</a></div>' + | |
| '</div>'; | |
| } else { | |
| html += '<table><thead><tr><th>Lesson</th><th>Confidence</th><th>Reinforcements</th><th>Source</th><th>Project</th><th>Updated</th></tr></thead><tbody>'; | |
| items.forEach(function(l) { | |
| var confPct = Math.round(l.confidence * 100); | |
| var confColor = confPct >= 70 ? 'var(--green)' : confPct >= 40 ? 'var(--yellow)' : 'var(--red)'; | |
| html += '<tr>'; | |
| html += '<td style="max-width:400px;">' + esc(truncate(l.content, 120)) + (l.context ? '<div style="font-size:11px;color:var(--ink-faint);margin-top:2px;">' + esc(truncate(l.context, 80)) + '</div>' : '') + '</td>'; | |
| html += '<td><div class="gauge" style="min-width:80px;"><div class="gauge-bar"><div class="gauge-fill" style="width:' + confPct + '%;background:' + confColor + '"></div></div><span class="gauge-value" style="font-size:11px;">' + confPct + '%</span></div></td>'; | |
| html += '<td style="text-align:center;">' + (l.reinforcements || 0) + '</td>'; | |
| html += '<td><span class="badge badge-' + (l.source === 'crystal' ? 'purple' : l.source === 'consolidation' ? 'yellow' : 'blue') + '">' + esc(l.source) + '</span></td>'; | |
| html += '<td style="font-size:12px;color:var(--ink-muted);">' + esc(l.project || '-') + '</td>'; | |
| html += '<td style="font-size:12px;color:var(--ink-muted);">' + shortTime(l.updatedAt) + '</td>'; | |
| html += '</tr>'; | |
| }); | |
| html += '</tbody></table>'; | |
| } | |
| var __focus = captureSearchFocus(['lessons-search']); | |
| el.innerHTML = html; | |
| var __ls = document.getElementById('lessons-search'); | |
| if (__ls) bindImeSafeSearch(__ls, 200, function(v){ state.lessons.search = v; renderLessons(); }); | |
| restoreSearchFocus(__focus); | |
| } | |
| async function loadActions() { | |
| var el = document.getElementById('view-actions'); | |
| el.innerHTML = '<div class="loading">Loading actions...</div>'; | |
| var results = await Promise.all([apiGet('actions'), apiGet('frontier')]); | |
| state.actions.items = (results[0] && results[0].actions) || []; | |
| state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || []; | |
| state.actions.loaded = true; | |
| renderActions(); | |
| } | |
| function renderActions() { | |
| var el = document.getElementById('view-actions'); | |
| var items = state.actions.items; | |
| var search = state.actions.search.toLowerCase(); | |
| var statusFilter = state.actions.statusFilter; | |
| var frontierIds = new Set((state.actions.frontier || []).map(function(a) { return a.id; })); | |
| if (search) { | |
| items = items.filter(function(a) { | |
| return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ')).toLowerCase().indexOf(search) >= 0; | |
| }); | |
| } | |
| if (statusFilter) { | |
| items = items.filter(function(a) { return a.status === statusFilter; }); | |
| } | |
| var html = '<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">'; | |
| html += '<input id="actions-search" class="search-input" type="text" placeholder="Search actions..." value="' + esc(state.actions.search) + '" style="flex:1;min-width:200px" />'; | |
| html += '<select id="actions-status-filter" style="padding:4px 8px;font-size:12px;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--ink);">'; | |
| html += '<option value="">All statuses</option>'; | |
| ['pending','active','done','blocked','cancelled'].forEach(function(s) { | |
| html += '<option value="' + s + '"' + (statusFilter === s ? ' selected' : '') + '>' + s + '</option>'; | |
| }); | |
| html += '</select>'; | |
| html += '<span style="font-size:12px;color:var(--ink-faint);align-self:center;">' + items.length + ' actions</span>'; | |
| html += '</div>'; | |
| if (items.length === 0) { | |
| html += '<div class="empty-state">' + | |
| '<div class="empty-icon">☑</div>' + | |
| '<div class="empty-title">No actions tracked yet</div>' + | |
| '<div class="empty-lead">Actions are follow-ups the agent surfaced during a session: <em>decisions to revisit</em>, <em>files to inspect</em>, <em>tasks blocked on input</em>. They show up here with status pending → active → done/blocked so nothing slips through between sessions.</div>' + | |
| '<div class="empty-lead" style="margin-top:0;">Three ways to create them:</div>' + | |
| '<pre class="empty-cmd"># 1. MCP tool (from any agent)\nmemory_action_create { title, description, priority }\n\n# 2. Curl\ncurl -X POST http://localhost:3111/agentmemory/actions \\\n -H \'Content-Type: application/json\' \\\n -d \'{"title":"ship v1","priority":"high"}\'\n\n# 3. Hooks auto-extract from long session bodies</pre>' + | |
| '<div><a class="empty-link" href="https://github.com/rohitg00/agentmemory#actions" target="_blank" rel="noopener">Action lifecycle docs →</a></div>' + | |
| '</div>'; | |
| } else { | |
| html += '<table><thead><tr><th>Title</th><th>Status</th><th>Priority</th><th>Tags</th><th>Frontier</th><th>Updated</th></tr></thead><tbody>'; | |
| items = items.slice().sort(function(a, b) { return (b.priority || 0) - (a.priority || 0); }); | |
| items.forEach(function(a) { | |
| var statusClass = a.status === 'done' ? 'badge-green' : a.status === 'active' ? 'badge-blue' : a.status === 'blocked' ? 'badge-red' : a.status === 'cancelled' ? 'badge-red' : 'badge-yellow'; | |
| var isFrontier = frontierIds.has(a.id); | |
| html += '<tr' + (isFrontier ? ' style="background:rgba(45,106,79,0.08);"' : '') + '>'; | |
| html += '<td style="max-width:350px;"><strong>' + esc(a.title) + '</strong>'; | |
| if (a.description) html += '<div style="font-size:11px;color:var(--ink-faint);margin-top:2px;">' + esc(truncate(a.description, 80)) + '</div>'; | |
| html += '</td>'; | |
| html += '<td><span class="badge ' + statusClass + '">' + esc(a.status) + '</span></td>'; | |
| html += '<td style="text-align:center;font-weight:600;">' + (a.priority || '-') + '</td>'; | |
| html += '<td style="font-size:11px;color:var(--ink-muted);">' + (a.tags || []).map(esc).join(', ') + '</td>'; | |
| html += '<td style="text-align:center;">' + (isFrontier ? '⚡' : '') + '</td>'; | |
| html += '<td style="font-size:12px;color:var(--ink-muted);">' + shortTime(a.updatedAt) + '</td>'; | |
| html += '</tr>'; | |
| }); | |
| html += '</tbody></table>'; | |
| } | |
| var __focus = captureSearchFocus(['actions-search']); | |
| el.innerHTML = html; | |
| var __as = document.getElementById('actions-search'); | |
| if (__as) bindImeSafeSearch(__as, 200, function(v){ state.actions.search = v; renderActions(); }); | |
| var __af = document.getElementById('actions-status-filter'); | |
| if (__af) __af.addEventListener('change', function(){ state.actions.statusFilter = this.value; renderActions(); }); | |
| restoreSearchFocus(__focus); | |
| } | |
| async function loadCrystals() { | |
| var el = document.getElementById('view-crystals'); | |
| el.innerHTML = '<div class="loading">Loading crystals...</div>'; | |
| var results = await Promise.all([apiGet('crystals'), apiGet('lessons')]); | |
| state.crystals.items = (results[0] && results[0].crystals) || []; | |
| var lessonMap = {}; | |
| var lessons = (results[1] && results[1].lessons) || []; | |
| lessons.forEach(function(l) { if (l && l.id) lessonMap[l.id] = l; }); | |
| state.crystals.lessonMap = lessonMap; | |
| state.crystals.loaded = true; | |
| renderCrystals(); | |
| } | |
| function renderCrystals() { | |
| var el = document.getElementById('view-crystals'); | |
| var items = state.crystals.items; | |
| var search = state.crystals.search.toLowerCase(); | |
| var lessonMap = state.crystals.lessonMap || {}; | |
| if (search) { | |
| items = items.filter(function(c) { | |
| var lessonText = (c.lessons || []) | |
| .map(function(lid) { | |
| var l = lessonMap[lid]; | |
| return l && typeof l.content === 'string' ? l.content : lid; | |
| }) | |
| .join(' '); | |
| var filesText = (c.filesAffected || []).join(' '); | |
| var haystack = [ | |
| c.narrative || '', | |
| (c.keyOutcomes || []).join(' '), | |
| lessonText, | |
| filesText, | |
| c.project || '', | |
| ].join(' ').toLowerCase(); | |
| return haystack.indexOf(search) >= 0; | |
| }); | |
| } | |
| var html = '<div class="card" style="margin-bottom:12px;padding:12px;background:var(--bg-subtle);">'; | |
| html += '<div style="font-size:13px;color:var(--ink-muted);line-height:1.5;">'; | |
| html += '<strong>Crystals</strong> are frozen snapshots of completed work. Each crystal captures one session\'s narrative, the tools invoked (key outcomes), files touched, and lessons surfaced — a replayable summary you keep after raw observations are pruned. Auto-created on JSONL import or via <code>memory_crystallize</code>.'; | |
| html += '</div></div>'; | |
| html += '<div style="display:flex;gap:8px;margin-bottom:12px;">'; | |
| html += '<input id="crystals-search" class="search-input" type="text" placeholder="Search crystals..." value="' + esc(state.crystals.search) + '" style="flex:1" />'; | |
| html += '<span style="font-size:12px;color:var(--ink-faint);align-self:center;">' + items.length + ' crystals</span>'; | |
| html += '</div>'; | |
| if (items.length === 0) { | |
| html += '<div class="empty-state">' + | |
| '<div class="empty-icon">💎</div>' + | |
| '<div class="empty-title">No crystals yet</div>' + | |
| '<div class="empty-lead">Crystals are compressed action digests — the 3-line summary of what happened in a session. Generated from long conversations to give the next session fast context without re-reading everything.</div>' + | |
| '<pre class="empty-cmd"># Auto: import a JSONL transcript\n# Replay tab → Import JSONL\n\n# Manual: crystallize a specific session\nmemory_crystallize { sessionId }</pre>' + | |
| '<div><a class="empty-link" href="https://github.com/rohitg00/agentmemory#crystals" target="_blank" rel="noopener">Crystal pipeline →</a></div>' + | |
| '</div>'; | |
| } else { | |
| items.forEach(function(c) { | |
| html += '<div class="card" style="margin-bottom:12px;border-left:3px solid var(--accent);">'; | |
| html += '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:8px;">'; | |
| html += '<div style="flex:1;font-size:14px;font-weight:600;color:var(--ink);line-height:1.4;">' + esc(truncate(c.narrative || 'Untitled crystal', 300)) + '</div>'; | |
| html += '<div style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);white-space:nowrap;">' + esc(formatTime(c.createdAt)) + '</div>'; | |
| html += '</div>'; | |
| var pillRow = []; | |
| if (c.project) pillRow.push('<span class="badge badge-muted">' + esc(c.project) + '</span>'); | |
| if (c.sessionId) pillRow.push('<span class="badge badge-blue" style="font-family:var(--font-mono);">' + esc(c.sessionId.slice(0, 14)) + '</span>'); | |
| if (c.keyOutcomes && c.keyOutcomes.length) pillRow.push('<span style="font-size:11px;color:var(--ink-muted);">' + c.keyOutcomes.length + ' tools</span>'); | |
| if (c.filesAffected && c.filesAffected.length) pillRow.push('<span style="font-size:11px;color:var(--ink-muted);">' + c.filesAffected.length + ' files</span>'); | |
| if (c.lessons && c.lessons.length) pillRow.push('<span style="font-size:11px;color:var(--ink-muted);">' + c.lessons.length + ' lessons</span>'); | |
| if (pillRow.length) html += '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;">' + pillRow.join('') + '</div>'; | |
| if (c.keyOutcomes && c.keyOutcomes.length > 0) { | |
| html += '<div style="margin:10px 0;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);margin-bottom:4px;">TOOLS USED</div>'; | |
| html += '<div style="display:flex;gap:4px;flex-wrap:wrap;">'; | |
| c.keyOutcomes.forEach(function(o) { | |
| html += '<span class="badge" style="background:var(--bg-alt);color:var(--ink);font-family:var(--font-mono);">' + esc(o) + '</span>'; | |
| }); | |
| html += '</div></div>'; | |
| } | |
| if (c.filesAffected && c.filesAffected.length > 0) { | |
| html += '<div style="margin:10px 0;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);margin-bottom:4px;">FILES TOUCHED</div>'; | |
| html += '<div style="font-size:12px;font-family:var(--font-mono);color:var(--ink);line-height:1.6;">'; | |
| c.filesAffected.slice(0, 10).forEach(function(f) { | |
| html += '<div>• ' + esc(f) + '</div>'; | |
| }); | |
| if (c.filesAffected.length > 10) html += '<div style="color:var(--ink-faint);">+' + (c.filesAffected.length - 10) + ' more</div>'; | |
| html += '</div></div>'; | |
| } | |
| if (c.lessons && c.lessons.length > 0) { | |
| html += '<div style="margin:10px 0;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);margin-bottom:4px;">LESSONS SURFACED</div>'; | |
| c.lessons.slice(0, 8).forEach(function(lid) { | |
| var content = lessonMap[lid] ? lessonMap[lid].content : lid; | |
| html += '<div style="font-size:12px;padding:4px 8px;margin:2px 0;background:var(--bg-alt);border-radius:3px;color:var(--ink);line-height:1.4;">💡 ' + esc(content) + '</div>'; | |
| }); | |
| if (c.lessons.length > 8) html += '<div style="font-size:11px;color:var(--ink-faint);margin-top:4px;">+' + (c.lessons.length - 8) + ' more lessons</div>'; | |
| html += '</div>'; | |
| } | |
| html += '</div>'; | |
| }); | |
| } | |
| var __focus = captureSearchFocus(['crystals-search']); | |
| el.innerHTML = html; | |
| var __cs = document.getElementById('crystals-search'); | |
| if (__cs) bindImeSafeSearch(__cs, 200, function(v){ state.crystals.search = v; renderCrystals(); }); | |
| restoreSearchFocus(__focus); | |
| } | |
| async function loadAudit() { | |
| var el = document.getElementById('view-audit'); | |
| el.innerHTML = '<div class="loading">Loading audit log...</div>'; | |
| var result = await apiGet('audit?limit=100'); | |
| state.audit.entries = (result && result.entries) || []; | |
| state.audit.loaded = true; | |
| renderAudit(); | |
| } | |
| function renderAudit() { | |
| var el = document.getElementById('view-audit'); | |
| var entries = state.audit.entries; | |
| var opFilter = state.audit.opFilter; | |
| var ops = {}; | |
| entries.forEach(function(e) { ops[e.operation] = true; }); | |
| var opList = Object.keys(ops).sort(); | |
| var filtered = opFilter ? entries.filter(function(e) { return e.operation === opFilter; }) : entries; | |
| var html = '<div class="toolbar">'; | |
| html += '<select id="audit-op-filter"><option value="">All operations</option>'; | |
| opList.forEach(function(op) { | |
| html += '<option value="' + esc(op) + '"' + (opFilter === op ? ' selected' : '') + '>' + esc(op) + '</option>'; | |
| }); | |
| html += '</select></div>'; | |
| html += '<div class="card">'; | |
| if (filtered.length === 0) { | |
| html += '<div class="empty-state"><div class="empty-icon">📜</div><p>No audit entries yet</p><p style="font-size:12px;color:var(--ink-faint);font-style:italic;">Audit entries are created by governance operations (delete, evolve, consolidate).</p></div>'; | |
| } else { | |
| filtered.forEach(function(a, idx) { | |
| var badgeClass = OP_BADGES[a.operation] || 'badge-muted'; | |
| html += '<div class="audit-entry">'; | |
| html += '<div class="audit-head">'; | |
| html += '<span class="badge ' + badgeClass + '">' + esc(a.operation) + '</span>'; | |
| html += '<span style="font-size:12px;color:var(--ink-muted);font-family:var(--font-mono);">' + esc(a.functionId || '') + '</span>'; | |
| html += '<span style="font-size:10px;color:var(--ink-faint);margin-left:auto;font-family:var(--font-mono);">' + esc(formatTime(a.timestamp)) + '</span>'; | |
| html += '<button class="btn" style="font-size:9px;padding:1px 6px;margin-left:8px;" data-action="toggle-audit" data-audit-index="' + idx + '">▼</button>'; | |
| html += '</div>'; | |
| if (a.targetIds && a.targetIds.length) { | |
| html += '<div style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);">' + a.targetIds.length + ' target(s): ' + esc(a.targetIds.slice(0, 3).join(', ')) + (a.targetIds.length > 3 ? '...' : '') + '</div>'; | |
| } | |
| html += '<div class="audit-detail" id="audit-detail-' + idx + '"><pre>' + esc(JSON.stringify(a.details || {}, null, 2)) + '</pre></div>'; | |
| html += '</div>'; | |
| }); | |
| } | |
| html += '</div>'; | |
| el.innerHTML = html; | |
| document.getElementById('audit-op-filter').addEventListener('change', function() { | |
| state.audit.opFilter = this.value; | |
| renderAudit(); | |
| }); | |
| } | |
| function toggleAuditDetail(idx) { | |
| var el = document.getElementById('audit-detail-' + idx); | |
| if (el) el.classList.toggle('open'); | |
| } | |
| async function loadProfile() { | |
| var el = document.getElementById('view-profile'); | |
| el.innerHTML = '<div class="loading">Loading profile...</div>'; | |
| var sessResult = await apiGet('sessions'); | |
| var sessions = (sessResult && sessResult.sessions) || []; | |
| var projects = {}; | |
| sessions.forEach(function(s) { if (s.project) projects[s.project] = true; }); | |
| state.profile.projects = Object.keys(projects).sort(); | |
| state.profile.loaded = true; | |
| if (state.profile.projects.length > 0 && !state.profile.selectedProject) { | |
| state.profile.selectedProject = state.profile.projects[0]; | |
| } | |
| renderProfileToolbar(); | |
| if (state.profile.selectedProject) await loadProfileData(); | |
| } | |
| function renderProfileToolbar() { | |
| var el = document.getElementById('view-profile'); | |
| var html = '<div class="toolbar">'; | |
| html += '<select id="profile-project">'; | |
| if (state.profile.projects.length === 0) { | |
| html += '<option value="">No projects</option>'; | |
| } else { | |
| state.profile.projects.forEach(function(p) { | |
| html += '<option value="' + esc(p) + '"' + (state.profile.selectedProject === p ? ' selected' : '') + '>' + esc(p) + '</option>'; | |
| }); | |
| } | |
| html += '</select></div>'; | |
| html += '<div id="profile-content"></div>'; | |
| el.innerHTML = html; | |
| document.getElementById('profile-project').addEventListener('change', function() { | |
| state.profile.selectedProject = this.value; | |
| loadProfileData(); | |
| }); | |
| } | |
| async function loadProfileData() { | |
| var content = document.getElementById('profile-content'); | |
| if (!content || !state.profile.selectedProject) return; | |
| content.innerHTML = '<div class="loading">Loading profile data...</div>'; | |
| var result = await apiGet('profile?project=' + encodeURIComponent(state.profile.selectedProject)); | |
| state.profile.data = (result && result.profile) ? result.profile : result; | |
| renderProfile(); | |
| } | |
| function renderProfile() { | |
| var content = document.getElementById('profile-content'); | |
| if (!content) return; | |
| var p = state.profile.data; | |
| if (!p) { | |
| content.innerHTML = '<div class="empty-state"><div class="empty-icon">📋</div><p>No profile data for this project</p></div>'; | |
| return; | |
| } | |
| var html = '<div class="two-col">'; | |
| html += '<div class="card"><div class="card-title">Top Concepts</div>'; | |
| var concepts = p.topConcepts || []; | |
| if (concepts.length === 0) { | |
| html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No concepts yet</div>'; | |
| } else { | |
| var maxC = Math.max.apply(null, concepts.map(function(c) { return c.frequency; })) || 1; | |
| html += '<div class="bar-chart">'; | |
| concepts.slice(0, 10).forEach(function(c) { | |
| var pct = Math.round((c.frequency / maxC) * 100); | |
| html += '<div class="bar-row"><span class="bar-label">' + esc(c.concept) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--yellow);"></div></div><span class="bar-value">' + c.frequency + '</span></div>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| html += '</div>'; | |
| html += '<div class="card"><div class="card-title">Top Files</div>'; | |
| var files = p.topFiles || []; | |
| if (files.length === 0) { | |
| html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No files yet</div>'; | |
| } else { | |
| var maxF = Math.max.apply(null, files.map(function(f) { return f.frequency; })) || 1; | |
| html += '<div class="bar-chart">'; | |
| files.slice(0, 10).forEach(function(f) { | |
| var pct = Math.round((f.frequency / maxF) * 100); | |
| html += '<div class="bar-row"><span class="bar-label">' + esc(f.file.split('/').pop()) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--green);"></div></div><span class="bar-value">' + f.frequency + '</span></div>'; | |
| }); | |
| html += '</div>'; | |
| } | |
| html += '</div>'; | |
| html += '</div>'; | |
| html += '<div class="card" style="margin-top:16px;"><div class="card-title">Conventions</div>'; | |
| var conventions = p.conventions || []; | |
| if (conventions.length === 0) { | |
| html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No conventions detected yet</div>'; | |
| } else { | |
| html += '<ul style="padding-left:16px;">'; | |
| conventions.forEach(function(c) { html += '<li style="font-size:13px;color:var(--ink-muted);margin-bottom:4px;">' + esc(c) + '</li>'; }); | |
| html += '</ul>'; | |
| } | |
| html += '</div>'; | |
| if (p.summary) { | |
| html += '<div class="card" style="margin-top:16px;"><div class="card-title">Project Summary</div>'; | |
| html += '<p style="font-size:13px;color:var(--ink-muted);line-height:1.7;">' + esc(p.summary) + '</p></div>'; | |
| } | |
| var stats = '<div class="card" style="margin-top:16px;"><div class="card-title">Project Stats</div>'; | |
| stats += '<div class="detail-row"><div class="dl">Sessions</div><div class="dv" style="font-family:var(--font-mono);">' + (p.sessionCount || 0) + '</div></div>'; | |
| stats += '<div class="detail-row"><div class="dl">Total Obs</div><div class="dv" style="font-family:var(--font-mono);">' + (p.totalObservations || 0) + '</div></div>'; | |
| stats += '<div class="detail-row"><div class="dl">Updated</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(p.updatedAt)) + '</div></div>'; | |
| stats += '</div>'; | |
| content.innerHTML = html + stats; | |
| } | |
| async function loadPersonal() { | |
| var el = document.getElementById('view-personal'); | |
| el.innerHTML = '<div class="loading">Loading personal details...</div>'; | |
| try { | |
| var res = await apiGet('second-brain'); | |
| if (res && res.success) { | |
| state.personal.files = res.files || []; | |
| state.personal.loaded = true; | |
| if (state.personal.files.length > 0 && !state.personal.selectedFile) { | |
| state.personal.selectedFile = state.personal.files[0].name; | |
| } | |
| renderPersonal(); | |
| } else { | |
| var errorMsg = (res && res.error) ? res.error : 'Unknown error'; | |
| el.innerHTML = '<div class="loading" style="color:var(--accent);">Failed to load personal files: ' + esc(errorMsg) + '</div>'; | |
| } | |
| } catch (err) { | |
| var msg = (err && err.message) ? err.message : String(err); | |
| console.error('[viewer] loadPersonal failed:', err); | |
| el.innerHTML = '<div class="loading" style="color:var(--accent);">Failed to load personal files: ' + esc(msg) + '</div>'; | |
| } | |
| } | |
| function renderPersonal() { | |
| var el = document.getElementById('view-personal'); | |
| if (!el) return; | |
| var searchVal = (state.personal.search || '').toLowerCase(); | |
| var filtered = state.personal.files.filter(function(f) { | |
| return f.name.toLowerCase().indexOf(searchVal) >= 0; | |
| }); | |
| var fileListHtml = ''; | |
| if (filtered.length === 0) { | |
| fileListHtml = '<div class="empty-state" style="padding: 20px;"><p style="font-size:12px;">No matching files</p></div>'; | |
| } else { | |
| filtered.forEach(function(f) { | |
| var selected = f.name === state.personal.selectedFile; | |
| var sizeStr = f.size >= 1024 ? (f.size / 1024).toFixed(1) + ' KB' : f.size + ' B'; | |
| fileListHtml += '<div class="personal-file-item' + (selected ? ' selected' : '') + '" data-file="' + esc(f.name) + '">' + | |
| '<span style="font-size:14px;">📄</span>' + | |
| '<div class="personal-file-name">' + esc(f.name) + '</div>' + | |
| '<div class="personal-file-size">' + esc(sizeStr) + '</div>' + | |
| '</div>'; | |
| }); | |
| } | |
| var selectedFileData = state.personal.files.find(function(f) { | |
| return f.name === state.personal.selectedFile; | |
| }); | |
| var editorHtml = ''; | |
| if (!selectedFileData) { | |
| editorHtml = '<div class="empty-state"><div class="empty-icon">🗒</div><p>No file selected</p></div>'; | |
| } else { | |
| var sizeStr = selectedFileData.size >= 1024 ? (selectedFileData.size / 1024).toFixed(1) + ' KB' : selectedFileData.size + ' B'; | |
| editorHtml = '<div class="personal-editor-card">' + | |
| '<div class="personal-editor-header">' + | |
| '<div>' + | |
| '<div class="personal-editor-title">' + esc(selectedFileData.name) + '</div>' + | |
| '<div class="personal-editor-meta">' + esc(sizeStr) + ' · Markdown Document</div>' + | |
| '</div>' + | |
| '</div>' + | |
| '<textarea class="personal-editor-textarea" id="personal-textarea">' + esc(selectedFileData.content) + '</textarea>' + | |
| '<div class="personal-actions">' + | |
| '<span class="personal-status-indicator" id="personal-status">Saved successfully!</span>' + | |
| '<button class="btn btn-primary" id="personal-save-btn">Save Changes</button>' + | |
| '</div>' + | |
| '</div>'; | |
| } | |
| el.innerHTML = | |
| '<div class="personal-grid">' + | |
| '<div class="personal-sidebar">' + | |
| '<div class="personal-search-wrapper">' + | |
| '<input type="text" class="personal-search-input" id="personal-search" placeholder="Search files..." value="' + esc(state.personal.search || '') + '">' + | |
| '</div>' + | |
| '<div class="personal-file-list">' + | |
| fileListHtml + | |
| '</div>' + | |
| '</div>' + | |
| '<div class="personal-editor-pane">' + | |
| editorHtml + | |
| '</div>' + | |
| '</div>'; | |
| var searchInput = document.getElementById('personal-search'); | |
| if (searchInput) { | |
| searchInput.addEventListener('input', function() { | |
| state.personal.search = this.value; | |
| renderPersonalFileListOnly(); | |
| }); | |
| } | |
| el.querySelectorAll('.personal-file-item').forEach(function(item) { | |
| item.addEventListener('click', function() { | |
| var fileName = this.getAttribute('data-file'); | |
| if (fileName) { | |
| state.personal.selectedFile = fileName; | |
| renderPersonal(); | |
| } | |
| }); | |
| }); | |
| var saveBtn = document.getElementById('personal-save-btn'); | |
| if (saveBtn) { | |
| saveBtn.addEventListener('click', async function() { | |
| var textarea = document.getElementById('personal-textarea'); | |
| if (!textarea || !state.personal.selectedFile) return; | |
| var content = textarea.value; | |
| saveBtn.disabled = true; | |
| var statusEl = document.getElementById('personal-status'); | |
| if (statusEl) { | |
| statusEl.textContent = 'Saving...'; | |
| statusEl.className = 'personal-status-indicator visible'; | |
| } | |
| try { | |
| var res = await apiPost('second-brain', { | |
| file: state.personal.selectedFile, | |
| content: content | |
| }); | |
| if (res && res.success) { | |
| var fileObj = state.personal.files.find(function(f) { | |
| return f.name === state.personal.selectedFile; | |
| }); | |
| if (fileObj) { | |
| fileObj.content = content; | |
| fileObj.size = res.size || content.length; | |
| } | |
| if (statusEl) { | |
| statusEl.textContent = 'Saved successfully!'; | |
| statusEl.className = 'personal-status-indicator visible'; | |
| } | |
| renderPersonalFileListOnly(); | |
| } else { | |
| var errorMsg = (res && res.error) ? res.error : 'Unknown error'; | |
| if (statusEl) { | |
| statusEl.textContent = 'Error: ' + errorMsg; | |
| statusEl.className = 'personal-status-indicator visible error'; | |
| } | |
| } | |
| } catch (err) { | |
| if (statusEl) { | |
| statusEl.textContent = 'Error saving file'; | |
| statusEl.className = 'personal-status-indicator visible error'; | |
| } | |
| } finally { | |
| saveBtn.disabled = false; | |
| setTimeout(function() { | |
| if (statusEl && statusEl.textContent !== 'Saving...') { | |
| statusEl.className = 'personal-status-indicator'; | |
| } | |
| }, 3000); | |
| } | |
| }); | |
| } | |
| } | |
| function renderPersonalFileListOnly() { | |
| var searchVal = (state.personal.search || '').toLowerCase(); | |
| var filtered = state.personal.files.filter(function(f) { | |
| return f.name.toLowerCase().indexOf(searchVal) >= 0; | |
| }); | |
| var fileListHtml = ''; | |
| if (filtered.length === 0) { | |
| fileListHtml = '<div class="empty-state" style="padding: 20px;"><p style="font-size:12px;">No matching files</p></div>'; | |
| } else { | |
| filtered.forEach(function(f) { | |
| var selected = f.name === state.personal.selectedFile; | |
| var sizeStr = f.size >= 1024 ? (f.size / 1024).toFixed(1) + ' KB' : f.size + ' B'; | |
| fileListHtml += '<div class="personal-file-item' + (selected ? ' selected' : '') + '" data-file="' + esc(f.name) + '">' + | |
| '<span style="font-size:14px;">📄</span>' + | |
| '<div class="personal-file-name">' + esc(f.name) + '</div>' + | |
| '<div class="personal-file-size">' + esc(sizeStr) + '</div>' + | |
| '</div>'; | |
| }); | |
| } | |
| var listContainer = document.querySelector('.personal-file-list'); | |
| if (listContainer) { | |
| listContainer.innerHTML = fileListHtml; | |
| listContainer.querySelectorAll('.personal-file-item').forEach(function(item) { | |
| item.addEventListener('click', function() { | |
| var fileName = this.getAttribute('data-file'); | |
| if (fileName) { | |
| state.personal.selectedFile = fileName; | |
| renderPersonal(); | |
| } | |
| }); | |
| }); | |
| } | |
| } | |
| var wsReconnectTimer = null; | |
| var wsRetries = 0; | |
| var WS_MAX_RETRIES = 4; | |
| var directFailed = false; | |
| var directFailures = 0; | |
| var DIRECT_FAILURE_THRESHOLD = 2; | |
| var pollTimer = null; | |
| var POLL_INTERVAL_MS = 10000; | |
| function setWsStatus(text, cls) { | |
| var el = document.getElementById('ws-status'); | |
| if (!el) return; | |
| el.textContent = text; | |
| el.className = 'ws-status ' + cls; | |
| } | |
| var WS_REPROBE_EVERY_TICKS = 6; | |
| function startPolling() { | |
| if (pollTimer) return; | |
| setWsStatus('polling · ' + (POLL_INTERVAL_MS / 1000) + 's', 'disconnected'); | |
| var tick = 0; | |
| pollTimer = setInterval(function() { | |
| tick++; | |
| if (state.activeTab === 'dashboard') { | |
| state.dashboard.loaded = false; | |
| loadDashboard(); | |
| } else if (state.activeTab === 'memories') { | |
| state.memories.loaded = false; | |
| loadMemories(); | |
| } else if (state.activeTab === 'sessions') { | |
| state.sessions.loaded = false; | |
| loadSessions(); | |
| } else if (state.activeTab === 'activity') { | |
| state.activity.loaded = false; | |
| loadActivity(); | |
| } | |
| if (tick % WS_REPROBE_EVERY_TICKS === 0) { | |
| var ws = state.ws; | |
| if (!ws || ws.readyState !== WebSocket.OPEN) { | |
| wsRetries = 0; | |
| directFailures = 0; | |
| directFailed = false; | |
| connectWs(); | |
| } | |
| } | |
| }, POLL_INTERVAL_MS); | |
| } | |
| function stopPolling() { | |
| if (!pollTimer) return; | |
| clearInterval(pollTimer); | |
| pollTimer = null; | |
| } | |
| var WS_CONNECT_TIMEOUT_MS = 5000; | |
| function connectWs() { | |
| if (wsRetries >= WS_MAX_RETRIES) { | |
| startPolling(); | |
| return; | |
| } | |
| var useDirect = !directFailed; | |
| var token = getViewerToken(); | |
| var suffix = token ? '?token=' + encodeURIComponent(token) : ''; | |
| var ws; | |
| try { | |
| ws = new WebSocket((useDirect ? WS_DIRECT_URL : WS_URL) + suffix); | |
| ws.__direct = useDirect; | |
| } catch (_) { | |
| ws = new WebSocket(WS_URL + suffix); | |
| ws.__direct = false; | |
| } | |
| var connectTimer = setTimeout(function() { | |
| if (ws.readyState === WebSocket.CONNECTING) { | |
| try { ws.close(); } catch {} | |
| } | |
| }, WS_CONNECT_TIMEOUT_MS); | |
| try { | |
| ws.onopen = function() { | |
| clearTimeout(connectTimer); | |
| if (state.ws !== ws) return; | |
| wsRetries = 0; | |
| stopPolling(); | |
| if (ws.__direct) { | |
| directFailures = 0; | |
| directFailed = false; | |
| } | |
| if (!ws.__direct) { | |
| ws.send(JSON.stringify({ | |
| type: 'join', | |
| data: { | |
| subscriptionId: 'viewer-' + Date.now(), | |
| streamName: 'mem-live', | |
| groupId: 'viewer' | |
| } | |
| })); | |
| } | |
| setWsStatus('live', 'connected'); | |
| }; | |
| ws.onmessage = function(e) { | |
| if (state.ws !== ws) return; | |
| try { | |
| var msg = JSON.parse(e.data); | |
| if (msg.type === 'stream' && msg.event) { | |
| handleStreamEvent(msg); | |
| } else if (msg.event_type && msg.data) { | |
| handleStreamEvent({ event: { type: 'create', data: msg.data, event_type: msg.event_type } }); | |
| } | |
| } catch {} | |
| }; | |
| ws.onclose = function(e) { | |
| clearTimeout(connectTimer); | |
| if (state.ws !== ws) return; | |
| if (e && e.code === 1008) { | |
| showViewerAuthPrompt(); | |
| return; | |
| } | |
| if (ws.__direct) { | |
| directFailures += 1; | |
| if (directFailures >= DIRECT_FAILURE_THRESHOLD) { | |
| directFailed = true; | |
| } | |
| } | |
| wsRetries++; | |
| if (wsRetries < WS_MAX_RETRIES) { | |
| setWsStatus('connecting...', 'disconnected'); | |
| wsReconnectTimer = setTimeout(connectWs, 2000 + Math.min(wsRetries * 1000, 8000)); | |
| } else { | |
| startPolling(); | |
| } | |
| }; | |
| ws.onerror = function() { | |
| if (state.ws !== ws) return; | |
| try { ws.close(); } catch {} | |
| }; | |
| state.ws = ws; | |
| } catch { | |
| wsRetries++; | |
| if (wsRetries < WS_MAX_RETRIES) { | |
| wsReconnectTimer = setTimeout(connectWs, 2000 + Math.min(wsRetries * 1000, 8000)); | |
| } else { | |
| startPolling(); | |
| } | |
| } | |
| } | |
| function looksLikeObservation(obj) { | |
| return !!(obj && typeof obj === 'object' && obj.id && obj.timestamp); | |
| } | |
| function handleStreamEvent(msg) { | |
| var evt = msg.event; | |
| var observation; | |
| if (!evt) return; | |
| if (evt.event_type && evt.event_type !== 'observation' && evt.event_type !== 'create' && evt.event_type !== 'update') { | |
| return; | |
| } | |
| if (evt.type === 'event' && evt.data) { | |
| observation = evt.data.observation || evt.data; | |
| if (looksLikeObservation(observation)) { | |
| routeWsMessage({ observation: observation }); | |
| } | |
| return; | |
| } | |
| if ((evt.type === 'create' || evt.type === 'update') && evt.data) { | |
| var payload = evt.data; | |
| observation = payload.observation || payload; | |
| if (looksLikeObservation(observation)) { | |
| routeWsMessage({ observation: observation }); | |
| } | |
| } else if (evt.type === 'sync') { | |
| var items = Array.isArray(evt.data) ? evt.data : []; | |
| items.forEach(function(item) { | |
| var payload = item.data || item; | |
| observation = payload.observation || payload; | |
| if (looksLikeObservation(observation)) { | |
| routeWsMessage({ observation: observation }); | |
| } | |
| }); | |
| } | |
| } | |
| function routeWsMessage(msg) { | |
| if (state.activeTab === 'timeline' && msg.observation) { | |
| if (!state.timeline.sessionId || msg.observation.sessionId === state.timeline.sessionId) { | |
| var existing = state.timeline.observations.findIndex(function(o) { return o.id === msg.observation.id; }); | |
| if (existing >= 0) { | |
| state.timeline.observations[existing] = msg.observation; | |
| } else { | |
| state.timeline.observations.unshift(msg.observation); | |
| } | |
| renderObservations(); | |
| } | |
| } | |
| if (state.activeTab === 'dashboard') { | |
| state.dashboard.loaded = false; | |
| loadDashboard(); | |
| } | |
| if (state.activeTab === 'activity' && msg.observation) { | |
| state.activity.observations.unshift(msg.observation); | |
| renderActivity(); | |
| } | |
| } | |
| document.getElementById('tab-bar').addEventListener('click', function(e) { | |
| var btn = e.target instanceof Element ? e.target.closest('button[data-tab]') : null; | |
| if (btn) switchTab(btn.dataset.tab); | |
| }); | |
| document.querySelectorAll('[data-tab-link]').forEach(function(link) { | |
| link.addEventListener('click', function(e) { | |
| if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; | |
| e.preventDefault(); | |
| switchTab(link.getAttribute('data-tab-link')); | |
| }); | |
| }); | |
| function syncTabFromRoute() { | |
| switchTab(tabFromRoute(), { replaceRoute: true }); | |
| } | |
| window.addEventListener('hashchange', syncTabFromRoute); | |
| window.addEventListener('popstate', syncTabFromRoute); | |
| function getDismissedFlags() { | |
| if (!state.flagsDismissed) state.flagsDismissed = {}; | |
| return state.flagsDismissed; | |
| } | |
| function dismissFlags(keys) { | |
| var dismissed = getDismissedFlags(); | |
| keys.forEach(function(key) { | |
| if (key) dismissed[key] = true; | |
| }); | |
| } | |
| function renderFlagBanners(cfg) { | |
| var host = document.getElementById('flag-banners'); | |
| if (!host) return; | |
| var dismissed = getDismissedFlags(); | |
| var banners = []; | |
| (cfg.flags || []).forEach(function(f) { | |
| if (f.enabled) return; | |
| if (dismissed[f.key]) return; | |
| var tabsAffected = (f.affects || []).map(function(t) { return t.toLowerCase(); }); | |
| if (tabsAffected.length && tabsAffected.indexOf(state.activeTab) === -1 && state.activeTab !== 'dashboard') return; | |
| banners.push({ | |
| kind: 'warn', | |
| icon: '⚠', | |
| title: f.label, | |
| keyLabel: f.key, | |
| desc: f.description + (f.needsLlm ? ' Requires an LLM provider key (ANTHROPIC_API_KEY, GEMINI_API_KEY, etc.).' : ''), | |
| enable: f.enableHow, | |
| docs: f.docsHref, | |
| dismissKey: f.key, | |
| }); | |
| }); | |
| if (cfg.provider === 'noop' && !dismissed['__provider_noop']) { | |
| banners.unshift({ | |
| kind: 'warn', | |
| icon: '🔒', | |
| title: 'No LLM provider key set', | |
| keyLabel: 'ANTHROPIC_API_KEY', | |
| desc: 'Compression, summarization, and graph extraction stay disabled until a key is provided.', | |
| enable: 'export ANTHROPIC_API_KEY=sk-ant-...\n# then restart: npx @agentmemory/agentmemory', | |
| docs: 'https://github.com/rohitg00/agentmemory#quick-start', | |
| dismissKey: '__provider_noop', | |
| }); | |
| } | |
| if (cfg.embeddingProvider === 'none' && !dismissed['__embedding_none']) { | |
| banners.push({ | |
| kind: 'info', | |
| icon: '⚙', | |
| title: 'Running in BM25-only mode', | |
| keyLabel: 'OPENAI_API_KEY', | |
| desc: 'Semantic vector search is off. BM25 keyword search is active and good for exact matches.', | |
| enable: 'export OPENAI_API_KEY=sk-...\n# or VOYAGE_API_KEY, COHERE_API_KEY, OLLAMA_HOST', | |
| docs: 'https://github.com/rohitg00/agentmemory#embedding-providers', | |
| dismissKey: '__embedding_none', | |
| }); | |
| } | |
| if (banners.length === 0) { host.innerHTML = ''; return; } | |
| var warnCount = banners.filter(function(b) { return b.kind === 'warn'; }).length; | |
| var infoCount = banners.filter(function(b) { return b.kind === 'info'; }).length; | |
| var expanded = host.getAttribute('data-expanded') === '1'; | |
| var pills = ''; | |
| if (warnCount) pills += '<span class="flag-pill">' + warnCount + ' off</span>'; | |
| if (infoCount) pills += '<span class="flag-pill info">' + infoCount + ' note</span>'; | |
| var escHtml = function(s) { | |
| return String(s).replace(/[<>&"]/g, function(c) { | |
| return { '<': '<', '>': '>', '&': '&', '"': '"' }[c]; | |
| }); | |
| }; | |
| var listHtml = banners.map(function(b) { | |
| return '<div class="flag-banner ' + b.kind + '" data-flag="' + escHtml(b.dismissKey) + '">' + | |
| '<span class="flag-icon">' + b.icon + '</span>' + | |
| '<div class="flag-body">' + | |
| '<div class="flag-title">' + escHtml(b.title) + ' <code>' + escHtml(b.keyLabel) + '</code></div>' + | |
| '<div class="flag-desc">' + escHtml(b.desc) + '</div>' + | |
| '<code class="flag-enable">' + escHtml(b.enable) + '</code>' + | |
| (b.docs ? ' <a class="empty-link" href="' + escHtml(b.docs) + '" target="_blank" rel="noopener">Learn more →</a>' : '') + | |
| '</div>' + | |
| '<button type="button" class="flag-close" data-dismiss-flag="' + escHtml(b.dismissKey) + '" aria-label="Dismiss">×</button>' + | |
| '</div>'; | |
| }).join(''); | |
| host.innerHTML = '<button type="button" class="flag-summary" data-action="toggle-flags" aria-expanded="' + (expanded ? 'true' : 'false') + '" aria-controls="flag-list">' + | |
| pills + | |
| '<span class="flag-count">Feature flags</span>' + | |
| '<span style="color:var(--ink-faint);">— click to ' + (expanded ? 'collapse' : 'expand') + '</span>' + | |
| '<span class="flag-toggle" aria-hidden="true">' + (expanded ? '▲' : '▼') + '</span>' + | |
| '</button>' + | |
| '<div class="flag-list' + (expanded ? ' open' : '') + '" id="flag-list">' + listHtml + '</div>'; | |
| } | |
| async function fetchFlags() { | |
| var res = await apiGet('config/flags'); | |
| if (!res) return; | |
| state.flagsConfig = res; | |
| renderFlagBanners(res); | |
| updateFooter(res); | |
| } | |
| function updateFooter(cfg) { | |
| var vEl = document.getElementById('footer-version'); | |
| if (vEl) vEl.textContent = 'v' + (cfg.version || '?'); | |
| var fbEl = document.getElementById('footer-feedback'); | |
| if (fbEl) { | |
| var flagSummary = (cfg.flags || []).map(function(f) { return f.key + '=' + (f.enabled ? 'on' : 'off'); }).join(', '); | |
| var body = encodeURIComponent( | |
| '**Version:** ' + (cfg.version || '?') + '\n' + | |
| '**Provider:** ' + (cfg.provider || '?') + '\n' + | |
| '**Embedding:** ' + (cfg.embeddingProvider || '?') + '\n' + | |
| '**Flags:** ' + flagSummary + '\n' + | |
| '**User agent:** ' + navigator.userAgent + '\n\n' + | |
| '### What went wrong\n\n' + | |
| '(describe the issue)\n\n' + | |
| '### Steps to reproduce\n\n' + | |
| '1. \n2. \n3. \n' | |
| ); | |
| fbEl.href = 'https://github.com/rohitg00/agentmemory/issues/new?title=' + | |
| encodeURIComponent('[viewer] ') + '&body=' + body; | |
| } | |
| } | |
| document.addEventListener('click', function(e) { | |
| if (!(e.target instanceof Element)) return; | |
| var btn = e.target.closest('[data-dismiss-flag]'); | |
| if (btn) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| var key = btn.getAttribute('data-dismiss-flag'); | |
| dismissFlags([key]); | |
| if (state.flagsConfig) renderFlagBanners(state.flagsConfig); | |
| return; | |
| } | |
| var toggle = e.target.closest('[data-action="toggle-flags"]'); | |
| if (toggle) { | |
| var host = document.getElementById('flag-banners'); | |
| var cur = host.getAttribute('data-expanded') === '1'; | |
| host.setAttribute('data-expanded', cur ? '0' : '1'); | |
| if (state.flagsConfig) renderFlagBanners(state.flagsConfig); | |
| } | |
| }); | |
| fetchFlags(); | |
| document.addEventListener('click', function(e) { | |
| if (!(e.target instanceof Element)) return; | |
| var target = e.target.closest('[data-action]'); | |
| if (!target) return; | |
| var action = target.getAttribute('data-action'); | |
| if (!action) return; | |
| if (action === 'toggle-theme') { | |
| toggleTheme(); | |
| return; | |
| } | |
| if (action === 'refresh-dashboard') { | |
| refreshDashboard(); | |
| return; | |
| } | |
| if (action === 'zoom-graph') { | |
| zoomGraph(parseInt(target.getAttribute('data-dir') || '0', 10)); | |
| return; | |
| } | |
| if (action === 'recenter-graph') { | |
| recenterGraph(); | |
| return; | |
| } | |
| if (action === 'rebuild-graph') { | |
| rebuildGraph(); | |
| return; | |
| } | |
| if (action === 'expand-node') { | |
| var nodeId = target.getAttribute('data-node-id'); | |
| if (nodeId) expandNode(nodeId); | |
| return; | |
| } | |
| if (action === 'delete-memory') { | |
| deleteMemory( | |
| target.getAttribute('data-memory-id') || '', | |
| target.getAttribute('data-memory-title') || '', | |
| ); | |
| return; | |
| } | |
| if (action === 'close-modal') { | |
| closeModal(); | |
| return; | |
| } | |
| if (action === 'confirm-delete-memory') { | |
| var memoryId = target.getAttribute('data-memory-id'); | |
| if (memoryId) confirmDeleteMemory(memoryId); | |
| return; | |
| } | |
| if (action === 'save-viewer-token') { | |
| var tokenInput = document.getElementById('viewer-auth-token'); | |
| var token = tokenInput ? tokenInput.value.trim() : ''; | |
| if (token) { | |
| setViewerToken(token); | |
| hideViewerAuthPrompt(); | |
| fetchFlags(); | |
| if (state[state.activeTab] && typeof state[state.activeTab] === 'object') { | |
| state[state.activeTab].loaded = false; | |
| } | |
| loadTab(state.activeTab); | |
| } | |
| return; | |
| } | |
| if (action === 'timeline-filter') { | |
| setTlTypeFilter(target.getAttribute('data-type-filter') || ''); | |
| return; | |
| } | |
| if (action === 'timeline-page') { | |
| var page = parseInt(target.getAttribute('data-page') || '', 10); | |
| if (!Number.isNaN(page)) tlPage(page); | |
| return; | |
| } | |
| if (action === 'select-session') { | |
| var sessionId = target.getAttribute('data-session-id'); | |
| if (sessionId) selectSession(sessionId); | |
| return; | |
| } | |
| if (action === 'end-session') { | |
| var endSessionId = target.getAttribute('data-session-id'); | |
| if (endSessionId) endSession(endSessionId); | |
| return; | |
| } | |
| if (action === 'summarize-session') { | |
| var summarizeSessionId = target.getAttribute('data-session-id'); | |
| if (summarizeSessionId) summarizeSession(summarizeSessionId, target); | |
| return; | |
| } | |
| if (action === 'toggle-audit') { | |
| var auditIndex = parseInt(target.getAttribute('data-audit-index') || '', 10); | |
| if (!Number.isNaN(auditIndex)) toggleAuditDetail(auditIndex); | |
| } | |
| if (action === 'replay-select') { | |
| var rSid = target.getAttribute('data-session-id'); | |
| if (rSid) selectReplaySession(rSid); | |
| return; | |
| } | |
| if (action === 'replay-toggle-play') { toggleReplayPlay(); return; } | |
| if (action === 'replay-step') { | |
| var d = parseInt(target.getAttribute('data-dir') || '1', 10); | |
| stepReplay(d); | |
| return; | |
| } | |
| if (action === 'replay-speed') { | |
| var sp = parseFloat(target.getAttribute('data-speed') || '1'); | |
| setReplaySpeed(sp); | |
| return; | |
| } | |
| if (action === 'replay-reset') { resetReplay(); return; } | |
| if (action === 'replay-import') { runReplayImport(); return; } | |
| if (action === 'replay-refresh') { refreshReplaySessions(); return; } | |
| }); | |
| document.getElementById('modal-overlay').addEventListener('click', function(e) { | |
| if (e.target === this) closeModal(); | |
| }); | |
| async function loadReplay() { | |
| var el = document.getElementById('view-replay'); | |
| el.innerHTML = '<div class="loading">Loading sessions…</div>'; | |
| var res = await apiGet('replay/sessions'); | |
| state.replay.sessions = (res && res.sessions) || []; | |
| state.replay.loaded = true; | |
| renderReplay(); | |
| } | |
| async function refreshReplaySessions() { | |
| state.replay.loaded = false; | |
| await loadReplay(); | |
| } | |
| function renderReplay() { | |
| var el = document.getElementById('view-replay'); | |
| var sessions = state.replay.sessions || []; | |
| var options = '<option value="">— pick a session —</option>' + sessions.map(function(s) { | |
| var id = sessionId(s); | |
| var label = sessionDisplayName(s) + ' · ' + (shortSessionId(s, 8) || 'missing id') + ' · ' + (s.observationCount || 0) + ' obs'; | |
| return '<option value="' + esc(id) + '"' + (id && id === state.replay.selectedId ? ' selected' : '') + (id ? '' : ' disabled') + '>' + esc(label) + '</option>'; | |
| }).join(''); | |
| var tl = state.replay.timeline; | |
| var hasTl = tl && tl.events && tl.events.length > 0; | |
| var cursorEvent = hasTl ? tl.events[Math.min(state.replay.cursor, tl.events.length - 1)] : null; | |
| var progress = hasTl && tl.totalDurationMs > 0 ? Math.min(100, (state.replay.offsetAt / tl.totalDurationMs) * 100) : 0; | |
| el.innerHTML = | |
| '<div class="toolbar">' + | |
| '<select id="replay-session-select">' + options + '</select>' + | |
| '<button data-action="replay-refresh">Refresh</button>' + | |
| '<span class="sep"></span>' + | |
| '<input type="text" id="replay-import-path" placeholder="~/.claude/projects or file.jsonl" style="width:280px">' + | |
| '<button data-action="replay-import">Import JSONL</button>' + | |
| '</div>' + | |
| (hasTl | |
| ? '<div class="replay-controls">' + | |
| '<button data-action="replay-step" data-dir="-1" title="Previous (←)">◀</button>' + | |
| '<button data-action="replay-toggle-play" title="Play/Pause (Space)">' + (state.replay.playing ? '❚❚ Pause' : '▶ Play') + '</button>' + | |
| '<button data-action="replay-step" data-dir="1" title="Next (→)">▶</button>' + | |
| '<button data-action="replay-reset" title="Reset">⟲</button>' + | |
| '<span class="sep"></span>' + | |
| '<span>Speed</span>' + | |
| ['0.5', '1', '2', '4'].map(function(sp) { | |
| var active = Math.abs(state.replay.speed - parseFloat(sp)) < 0.01; | |
| return '<button data-action="replay-speed" data-speed="' + sp + '"' + (active ? ' class="active"' : '') + '>' + sp + '×</button>'; | |
| }).join('') + | |
| '<span class="sep"></span>' + | |
| '<span>' + (state.replay.cursor + 1) + ' / ' + tl.eventCount + '</span>' + | |
| '</div>' + | |
| '<div class="replay-progress"><div class="replay-progress-bar" style="width:' + progress.toFixed(1) + '%"></div></div>' + | |
| '<div class="replay-grid">' + | |
| '<div class="replay-list" id="replay-list">' + | |
| tl.events.map(function(ev, i) { | |
| var active = i === state.replay.cursor ? ' replay-event-active' : ''; | |
| return '<div class="replay-event replay-event-' + esc(ev.kind) + active + '" data-replay-idx="' + i + '">' + | |
| '<span class="replay-event-kind">' + esc(ev.kind) + '</span>' + | |
| '<span class="replay-event-label">' + esc(ev.label) + '</span>' + | |
| '<span class="replay-event-time">' + (ev.offsetMs / 1000).toFixed(1) + 's</span>' + | |
| '</div>'; | |
| }).join('') + | |
| '</div>' + | |
| '<div class="replay-detail">' + renderReplayDetail(cursorEvent) + '</div>' + | |
| '</div>' | |
| : '<div class="empty">Pick a session to replay, or import Claude Code JSONL transcripts from ~/.claude/projects.</div>'); | |
| var sel = document.getElementById('replay-session-select'); | |
| if (sel) sel.addEventListener('change', function() { selectReplaySession(sel.value); }); | |
| } | |
| function renderReplayDetail(ev) { | |
| if (!ev) return '<div class="empty">No event selected.</div>'; | |
| var blocks = []; | |
| blocks.push('<div class="replay-detail-header"><b>' + esc(ev.label) + '</b> <span class="muted">' + esc(ev.kind) + '</span></div>'); | |
| if (ev.ts) blocks.push('<div class="muted">' + esc(formatTime(ev.ts)) + '</div>'); | |
| if (ev.body) { | |
| blocks.push('<pre class="replay-body">' + esc(ev.body) + '</pre>'); | |
| } | |
| if (ev.toolName) { | |
| blocks.push('<div class="replay-tool"><b>Tool:</b> ' + esc(ev.toolName) + '</div>'); | |
| } | |
| if (ev.toolInput !== undefined && ev.toolInput !== null) { | |
| var inp = typeof ev.toolInput === 'string' ? ev.toolInput : JSON.stringify(ev.toolInput, null, 2); | |
| blocks.push('<div class="replay-tool-block"><b>Input</b><pre>' + esc(truncate(inp, 4000)) + '</pre></div>'); | |
| } | |
| if (ev.toolOutput !== undefined && ev.toolOutput !== null) { | |
| var out = typeof ev.toolOutput === 'string' ? ev.toolOutput : JSON.stringify(ev.toolOutput, null, 2); | |
| blocks.push('<div class="replay-tool-block"><b>Output</b><pre>' + esc(truncate(out, 4000)) + '</pre></div>'); | |
| } | |
| return blocks.join(''); | |
| } | |
| async function selectReplaySession(sessionId) { | |
| stopReplayTimer(); | |
| state.replay.selectedId = sessionId; | |
| state.replay.timeline = null; | |
| state.replay.cursor = 0; | |
| state.replay.offsetAt = 0; | |
| state.replay.playing = false; | |
| if (!sessionId) { renderReplay(); return; } | |
| var el = document.getElementById('view-replay'); | |
| el.innerHTML = '<div class="loading">Loading replay…</div>'; | |
| var res = await apiGet('replay/load?sessionId=' + encodeURIComponent(sessionId)); | |
| if (res && res.success && res.timeline) { | |
| state.replay.timeline = res.timeline; | |
| } else { | |
| state.replay.timeline = { events: [], eventCount: 0, totalDurationMs: 0 }; | |
| } | |
| renderReplay(); | |
| } | |
| function toggleReplayPlay() { | |
| if (!state.replay.timeline || state.replay.timeline.eventCount === 0) return; | |
| if (state.replay.playing) { | |
| stopReplayTimer(); | |
| } else { | |
| startReplayTimer(); | |
| } | |
| renderReplay(); | |
| } | |
| function startReplayTimer() { | |
| state.replay.playing = true; | |
| state.replay.startAt = Date.now(); | |
| var baseOffset = state.replay.offsetAt; | |
| if (state.replay.timer) clearInterval(state.replay.timer); | |
| state.replay.timer = setInterval(function() { | |
| if (!state.replay.timeline) return; | |
| var elapsed = (Date.now() - state.replay.startAt) * state.replay.speed; | |
| state.replay.offsetAt = baseOffset + elapsed; | |
| var events = state.replay.timeline.events; | |
| var newCursor = state.replay.cursor; | |
| for (var i = newCursor; i < events.length; i++) { | |
| if (events[i].offsetMs <= state.replay.offsetAt) newCursor = i; | |
| else break; | |
| } | |
| var changed = newCursor !== state.replay.cursor; | |
| state.replay.cursor = newCursor; | |
| if (state.replay.offsetAt >= state.replay.timeline.totalDurationMs) { | |
| state.replay.offsetAt = state.replay.timeline.totalDurationMs; | |
| stopReplayTimer(); | |
| renderReplay(); | |
| return; | |
| } | |
| if (changed) renderReplay(); | |
| }, 100); | |
| } | |
| function stopReplayTimer() { | |
| state.replay.playing = false; | |
| if (state.replay.timer) { | |
| clearInterval(state.replay.timer); | |
| state.replay.timer = null; | |
| } | |
| } | |
| function stepReplay(dir) { | |
| if (!state.replay.timeline) return; | |
| stopReplayTimer(); | |
| var next = state.replay.cursor + dir; | |
| if (next < 0) next = 0; | |
| if (next >= state.replay.timeline.eventCount) next = state.replay.timeline.eventCount - 1; | |
| state.replay.cursor = next; | |
| state.replay.offsetAt = state.replay.timeline.events[next].offsetMs; | |
| renderReplay(); | |
| } | |
| function setReplaySpeed(sp) { | |
| if (!sp || sp <= 0) return; | |
| var wasPlaying = state.replay.playing; | |
| stopReplayTimer(); | |
| state.replay.speed = sp; | |
| if (wasPlaying) startReplayTimer(); | |
| renderReplay(); | |
| } | |
| function resetReplay() { | |
| stopReplayTimer(); | |
| state.replay.cursor = 0; | |
| state.replay.offsetAt = 0; | |
| renderReplay(); | |
| } | |
| async function runReplayImport() { | |
| var input = document.getElementById('replay-import-path'); | |
| var pathVal = input ? input.value.trim() : ''; | |
| var body = {}; | |
| if (pathVal) body.path = pathVal; | |
| var el = document.getElementById('view-replay'); | |
| var prior = el.innerHTML; | |
| el.innerHTML = '<div class="loading">Importing JSONL…</div>'; | |
| var res = await apiPost('replay/import-jsonl', body); | |
| if (!res || res.success === false) { | |
| el.innerHTML = prior; | |
| alert((res && res.error) || 'Import failed'); | |
| return; | |
| } | |
| alert('Imported ' + (res.imported || 0) + ' file(s), ' + (res.observations || 0) + ' observation(s)'); | |
| await refreshReplaySessions(); | |
| } | |
| document.addEventListener('keydown', function(e) { | |
| if (state.activeTab !== 'replay') return; | |
| if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA')) return; | |
| if (e.key === ' ') { e.preventDefault(); toggleReplayPlay(); } | |
| else if (e.key === 'ArrowLeft') { e.preventDefault(); stepReplay(-1); } | |
| else if (e.key === 'ArrowRight') { e.preventDefault(); stepReplay(1); } | |
| }); | |
| switchTab(tabFromRoute(), { replaceRoute: true }); | |
| connectWs(); | |
| startDashboardAutoRefresh(); | |
| </script> | |
| </body> | |
| </html> | |