Files
el/engram/studio/index.html
T

3259 lines
98 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Engram Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #080b0f;
--bg2: #0d1117;
--bg3: #111820;
--bg4: #161e28;
--border: rgba(255,255,255,0.06);
--border2: rgba(255,255,255,0.12);
--text: #e8edf3;
--text2: #7a8a9a;
--text3: #4a5a6a;
--working: #ef4444;
--working-dim: rgba(239,68,68,0.15);
--working-glow: rgba(239,68,68,0.4);
--episodic: #f59e0b;
--episodic-dim: rgba(245,158,11,0.15);
--episodic-glow: rgba(245,158,11,0.4);
--semantic: #8b5cf6;
--semantic-dim: rgba(139,92,246,0.15);
--semantic-glow: rgba(139,92,246,0.4);
--procedural: #14b8a6;
--procedural-dim: rgba(20,184,166,0.15);
--procedural-glow: rgba(20,184,166,0.4);
--accent: #38bdf8;
--accent-dim: rgba(56,189,248,0.12);
--accent-glow: rgba(56,189,248,0.5);
--sidebar-w: 280px;
--radius: 6px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Syne', sans-serif;
font-size: 13px;
height: 100vh;
overflow: hidden;
display: flex;
}
/* ── SIDEBAR ── */
#sidebar {
width: var(--sidebar-w);
min-width: var(--sidebar-w);
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
z-index: 10;
}
#logo {
padding: 20px 20px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.logo-wordmark {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: 20px;
letter-spacing: -0.02em;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.logo-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
animation: pulse-dot 2.5s ease-in-out infinite;
}
@keyframes pulse-dot {
0%,100% { box-shadow: 0 0 8px var(--accent-glow); opacity: 1; }
50% { box-shadow: 0 0 20px var(--accent-glow), 0 0 40px rgba(56,189,248,0.2); opacity: 0.8; }
}
.logo-sub {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.12em;
color: var(--text3);
text-transform: uppercase;
margin-top: 3px;
}
.mode-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--accent-dim);
border: 1px solid rgba(56,189,248,0.2);
border-radius: 3px;
padding: 2px 6px;
font-size: 9px;
font-family: 'DM Mono', monospace;
color: var(--accent);
letter-spacing: 0.08em;
margin-top: 8px;
}
.mode-badge::before {
content: '';
width: 5px; height: 5px;
border-radius: 50%;
background: var(--accent);
animation: blink 1.5s step-end infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
#sidebar-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0 80px;
}
#sidebar-scroll::-webkit-scrollbar { width: 3px; }
#sidebar-scroll::-webkit-scrollbar-track { background: transparent; }
#sidebar-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.s-section {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.s-label {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 10px;
}
.db-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.stat-box {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
}
.stat-val {
font-family: 'DM Mono', monospace;
font-size: 18px;
font-weight: 500;
color: var(--text);
line-height: 1;
}
.stat-key {
font-size: 9px;
color: var(--text3);
margin-top: 3px;
letter-spacing: 0.06em;
}
.tier-bars {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 5px;
}
.tier-row {
display: flex;
align-items: center;
gap: 8px;
}
.tier-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.tier-name {
font-size: 10px;
color: var(--text2);
width: 60px;
flex-shrink: 0;
}
.tier-bar-bg {
flex: 1;
height: 3px;
background: var(--bg3);
border-radius: 2px;
overflow: hidden;
}
.tier-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.tier-count {
font-family: 'DM Mono', monospace;
font-size: 10px;
color: var(--text3);
width: 14px;
text-align: right;
}
/* Actions */
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.btn {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text2);
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
padding: 8px 10px;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
white-space: nowrap;
}
.btn:hover {
background: var(--bg4);
border-color: var(--border2);
color: var(--text);
}
.btn.primary {
background: var(--accent-dim);
border-color: rgba(56,189,248,0.3);
color: var(--accent);
}
.btn.primary:hover {
background: rgba(56,189,248,0.2);
box-shadow: 0 0 12px rgba(56,189,248,0.2);
}
.btn.full { grid-column: 1/-1; }
.btn:active { transform: scale(0.97); }
/* Query panel */
.query-input {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 11px;
padding: 8px 10px;
outline: none;
transition: border-color 0.15s;
margin-bottom: 8px;
}
.query-input:focus { border-color: rgba(56,189,248,0.4); }
.query-input::placeholder { color: var(--text3); }
.seed-label {
font-size: 9px;
color: var(--text3);
margin-bottom: 5px;
letter-spacing: 0.06em;
}
.seed-select {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text2);
font-family: 'DM Mono', monospace;
font-size: 10px;
padding: 4px 6px;
outline: none;
max-height: 80px;
scrollbar-width: thin;
}
.seed-select option { padding: 2px 4px; }
/* Results */
.results-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 220px;
overflow-y: auto;
}
.results-list::-webkit-scrollbar { width: 3px; }
.results-list::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.result-item {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 9px;
cursor: pointer;
transition: all 0.15s;
position: relative;
overflow: hidden;
}
.result-item::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 2px;
border-radius: 2px 0 0 2px;
}
.result-item.tier-Working::before { background: var(--working); }
.result-item.tier-Episodic::before { background: var(--episodic); }
.result-item.tier-Semantic::before { background: var(--semantic); }
.result-item.tier-Procedural::before { background: var(--procedural); }
.result-item:hover { border-color: var(--border2); background: var(--bg4); }
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3px;
}
.result-id {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
}
.result-score {
font-family: 'DM Mono', monospace;
font-size: 10px;
font-weight: 500;
color: var(--accent);
}
.result-content {
font-size: 10px;
color: var(--text2);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.empty-state {
font-size: 10px;
color: var(--text3);
text-align: center;
padding: 16px 0;
font-style: italic;
}
/* ── MAIN AREA ── */
#main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* Tabs */
#tabs {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
background: var(--bg2);
padding: 0 20px;
gap: 0;
flex-shrink: 0;
height: 44px;
}
.tab {
padding: 0 18px;
height: 44px;
display: flex;
align-items: center;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text3);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
position: relative;
top: 1px;
}
.tab:hover { color: var(--text2); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
#tab-content {
flex: 1;
overflow: hidden;
position: relative;
}
.tab-panel {
position: absolute;
inset: 0;
display: none;
}
.tab-panel.active { display: flex; flex-direction: column; }
/* ── GRAPH TAB ── */
#graph-panel {
position: relative;
background: var(--bg);
}
#graph-canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
#graph-canvas:active { cursor: grabbing; }
.graph-overlay {
position: absolute;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
}
.graph-legend {
background: rgba(13,17,23,0.85);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
backdrop-filter: blur(8px);
pointer-events: all;
}
.legend-title {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 7px;
}
.legend-item {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 4px;
font-size: 10px;
color: var(--text2);
}
.legend-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* Node detail popup */
#node-detail {
position: absolute;
bottom: 16px;
left: 16px;
background: rgba(13,17,23,0.92);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 14px 16px;
max-width: 340px;
backdrop-filter: blur(12px);
display: none;
animation: slide-up 0.2s ease;
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.nd-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.nd-id {
font-family: 'DM Mono', monospace;
font-size: 11px;
color: var(--accent);
}
.nd-close {
cursor: pointer;
color: var(--text3);
font-size: 16px;
line-height: 1;
padding: 0 2px;
transition: color 0.15s;
}
.nd-close:hover { color: var(--text); }
.nd-badges {
display: flex;
gap: 5px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.nd-badge {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
padding: 2px 7px;
border-radius: 3px;
text-transform: uppercase;
}
.nd-content {
font-size: 11px;
color: var(--text2);
line-height: 1.6;
margin-bottom: 10px;
}
.nd-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.nd-stat {
background: var(--bg3);
border-radius: 4px;
padding: 6px 8px;
}
.nd-stat-val {
font-family: 'DM Mono', monospace;
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.nd-stat-key {
font-size: 8px;
color: var(--text3);
margin-top: 2px;
letter-spacing: 0.06em;
}
/* ── NODES TAB ── */
#nodes-panel {
flex-direction: column;
}
.nodes-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.chip-filters {
display: flex;
gap: 5px;
}
.chip {
padding: 4px 10px;
border-radius: 20px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
color: var(--text3);
background: var(--bg3);
}
.chip:hover { color: var(--text2); }
.chip.active { color: var(--text); }
.chip.all.active { background: var(--bg4); border-color: var(--border2); color: var(--text); }
.chip.Working.active { background: var(--working-dim); border-color: var(--working); color: var(--working); }
.chip.Episodic.active { background: var(--episodic-dim); border-color: var(--episodic); color: var(--episodic); }
.chip.Semantic.active { background: var(--semantic-dim); border-color: var(--semantic); color: var(--semantic); }
.chip.Procedural.active { background: var(--procedural-dim); border-color: var(--procedural); color: var(--procedural); }
.search-input {
flex: 1;
max-width: 220px;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 11px;
padding: 5px 10px;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: rgba(56,189,248,0.4); }
.search-input::placeholder { color: var(--text3); }
.nodes-table-wrap {
flex: 1;
overflow-y: auto;
overflow-x: auto;
}
.nodes-table-wrap::-webkit-scrollbar { width: 4px; height: 4px; }
.nodes-table-wrap::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
thead {
position: sticky;
top: 0;
background: var(--bg2);
z-index: 1;
}
th {
text-align: left;
padding: 10px 14px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text3);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
white-space: nowrap;
transition: color 0.15s;
}
th:hover { color: var(--text2); }
th.sorted { color: var(--accent); }
th .sort-arrow { margin-left: 4px; opacity: 0.6; }
td {
padding: 9px 14px;
border-bottom: 1px solid var(--border);
color: var(--text2);
vertical-align: middle;
}
tr:hover td { background: var(--bg3); }
tr { cursor: pointer; transition: background 0.1s; }
.tier-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.06em;
padding: 2px 7px;
border-radius: 3px;
white-space: nowrap;
}
.mono { font-family: 'DM Mono', monospace; }
/* Node detail side panel */
#node-side-panel {
position: absolute;
top: 0; right: 0; bottom: 0;
width: 360px;
background: var(--bg2);
border-left: 1px solid var(--border2);
transform: translateX(100%);
transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
z-index: 20;
overflow-y: auto;
padding: 20px;
}
#node-side-panel.open { transform: translateX(0); }
#node-side-panel::-webkit-scrollbar { width: 3px; }
#node-side-panel::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.nsp-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.nsp-id {
font-family: 'DM Mono', monospace;
font-size: 13px;
color: var(--accent);
}
.nsp-close {
cursor: pointer;
color: var(--text3);
font-size: 20px;
line-height: 1;
transition: color 0.15s;
}
.nsp-close:hover { color: var(--text); }
.nsp-content {
font-size: 12px;
color: var(--text2);
line-height: 1.7;
margin: 12px 0;
padding: 12px;
background: var(--bg3);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.nsp-stats {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
margin: 12px 0;
}
.nsp-stat {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
}
.nsp-stat-val {
font-family: 'DM Mono', monospace;
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.nsp-stat-key {
font-size: 8px;
color: var(--text3);
margin-top: 3px;
letter-spacing: 0.06em;
}
.nsp-edges {
margin-top: 16px;
}
.nsp-edge-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
font-size: 10px;
}
.nsp-edge-rel {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
padding: 1px 5px;
background: var(--bg3);
border-radius: 3px;
white-space: nowrap;
}
.nsp-edge-target {
color: var(--accent);
font-family: 'DM Mono', monospace;
font-size: 10px;
}
.nsp-edge-weight {
margin-left: auto;
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
}
/* ── TIMELINE TAB ── */
#timeline-panel { overflow: hidden; }
#timeline-canvas {
width: 100%;
height: 100%;
display: block;
}
/* ── CONSOLE TAB ── */
#console-panel { overflow: hidden; background: var(--bg); }
#console-output {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
font-family: 'DM Mono', monospace;
font-size: 11px;
line-height: 1.7;
}
#console-output::-webkit-scrollbar { width: 4px; }
#console-output::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.console-entry {
margin-bottom: 12px;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
}
.console-cmd {
color: var(--accent);
margin-bottom: 5px;
}
.console-cmd::before { content: '> '; color: var(--text3); }
.console-out {
color: var(--text2);
white-space: pre-wrap;
word-break: break-word;
}
.console-out.success { color: #4ade80; }
.console-out.error { color: #f87171; }
.console-out.info { color: var(--text2); }
#console-input-row {
display: flex;
align-items: center;
padding: 12px 20px;
border-top: 1px solid var(--border);
gap: 10px;
flex-shrink: 0;
background: var(--bg2);
}
.console-prompt {
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--accent);
flex-shrink: 0;
}
#console-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--text);
caret-color: var(--accent);
}
#console-input::placeholder { color: var(--text3); }
/* ── MODAL ── */
#modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
#modal-overlay.open { display: flex; }
#modal {
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: 10px;
padding: 24px;
width: 400px;
animation: modal-in 0.2s ease;
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-title {
font-size: 15px;
font-weight: 700;
margin-bottom: 20px;
color: var(--text);
}
.form-group {
margin-bottom: 14px;
}
.form-label {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 5px;
}
.form-input, .form-select, .form-textarea {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 11px;
padding: 8px 10px;
outline: none;
transition: border-color 0.15s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: rgba(56,189,248,0.4);
}
.form-textarea { resize: vertical; min-height: 80px; }
.form-select option { background: var(--bg3); }
.importance-row {
display: flex;
align-items: center;
gap: 10px;
}
.importance-slider {
flex: 1;
-webkit-appearance: none;
height: 3px;
border-radius: 2px;
background: var(--bg4);
outline: none;
}
.importance-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
box-shadow: 0 0 8px var(--accent-glow);
}
.importance-val {
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--accent);
width: 32px;
text-align: right;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
/* ── TOAST ── */
#toast {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 12px 16px;
font-size: 12px;
color: var(--text);
z-index: 200;
transform: translateY(20px);
opacity: 0;
transition: all 0.25s ease;
max-width: 300px;
backdrop-filter: blur(8px);
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { border-color: rgba(74,222,128,0.4); }
#toast.info { border-color: rgba(56,189,248,0.4); }
/* Scrollbar global */
* { scrollbar-width: thin; scrollbar-color: var(--border2) transparent; }
/* ── SWARM TAB ── */
.peer-card {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
position: relative;
overflow: hidden;
}
.peer-card.online { border-color: rgba(74,222,128,0.3); }
.peer-card.offline { border-color: rgba(248,113,113,0.2); }
.peer-status-dot {
width: 7px; height: 7px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
flex-shrink: 0;
}
.peer-status-dot.online { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.6); }
.peer-status-dot.offline { background: #f87171; }
.peer-name {
font-weight: 600;
font-size: 11px;
color: var(--text);
}
.peer-addr {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
margin-top: 2px;
word-break: break-all;
}
.peer-meta {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.peer-tag {
font-size: 8px;
padding: 1px 5px;
border-radius: 3px;
font-family: 'DM Mono', monospace;
letter-spacing: 0.04em;
}
.peer-tag.tier { background: var(--semantic-dim); color: var(--semantic); border: 1px solid rgba(139,92,246,0.2); }
.peer-tag.trusted { background: rgba(56,189,248,0.1); color: var(--accent); border: 1px solid rgba(56,189,248,0.2); }
.peer-tag.sync-time { background: var(--bg4); color: var(--text3); border: 1px solid var(--border); }
.peer-actions {
display: flex;
gap: 4px;
margin-top: 8px;
}
.peer-btn {
font-size: 9px;
padding: 3px 8px;
border-radius: 3px;
cursor: pointer;
font-family: 'Syne', sans-serif;
font-weight: 600;
letter-spacing: 0.04em;
border: 1px solid var(--border2);
background: var(--bg4);
color: var(--text2);
transition: all 0.1s;
}
.peer-btn:hover { background: var(--bg3); color: var(--text); }
.peer-btn.danger:hover { border-color: rgba(248,113,113,0.4); color: #f87171; }
/* Swarm result items */
.swarm-result-item {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
margin-bottom: 6px;
position: relative;
overflow: hidden;
}
/* Remote nodes: dashed border */
.swarm-result-item.remote {
border-style: dashed;
border-color: rgba(56,189,248,0.25);
background: rgba(56,189,248,0.03);
}
.swarm-result-item::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 2px;
border-radius: 2px 0 0 2px;
}
.swarm-result-item.tier-Working::before { background: var(--working); }
.swarm-result-item.tier-Episodic::before { background: var(--episodic); }
.swarm-result-item.tier-Semantic::before { background: var(--semantic); }
.swarm-result-item.tier-Procedural::before { background: var(--procedural); }
.swarm-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.swarm-result-peer {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--accent);
background: var(--accent-dim);
padding: 1px 6px;
border-radius: 3px;
border: 1px solid rgba(56,189,248,0.2);
}
.swarm-result-local {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
}
.swarm-result-strength {
font-family: 'DM Mono', monospace;
font-size: 10px;
font-weight: 500;
color: var(--accent);
}
.swarm-result-content {
font-size: 10px;
color: var(--text2);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Graph canvas: remote peer nodes get dashed overlay — drawn via canvas 2d */
</style>
</head>
<body>
<!-- SIDEBAR -->
<div id="sidebar">
<div id="logo">
<div class="logo-wordmark">
<div class="logo-dot"></div>
Engram
</div>
<div class="logo-sub">Memory Studio</div>
<div class="mode-badge">DEMO MODE</div>
</div>
<div id="sidebar-scroll">
<!-- DB Stats -->
<div class="s-section">
<div class="s-label">Database</div>
<div class="db-stats">
<div class="stat-box">
<div class="stat-val" id="stat-nodes">15</div>
<div class="stat-key">nodes</div>
</div>
<div class="stat-box">
<div class="stat-val" id="stat-edges">22</div>
<div class="stat-key">edges</div>
</div>
</div>
<div class="tier-bars" id="tier-bars">
<div class="tier-row">
<div class="tier-dot" style="background:var(--working)"></div>
<div class="tier-name">Working</div>
<div class="tier-bar-bg"><div class="tier-bar-fill" id="bar-working" style="background:var(--working);width:0%"></div></div>
<div class="tier-count" id="cnt-working">0</div>
</div>
<div class="tier-row">
<div class="tier-dot" style="background:var(--episodic)"></div>
<div class="tier-name">Episodic</div>
<div class="tier-bar-bg"><div class="tier-bar-fill" id="bar-episodic" style="background:var(--episodic);width:0%"></div></div>
<div class="tier-count" id="cnt-episodic">0</div>
</div>
<div class="tier-row">
<div class="tier-dot" style="background:var(--semantic)"></div>
<div class="tier-name">Semantic</div>
<div class="tier-bar-bg"><div class="tier-bar-fill" id="bar-semantic" style="background:var(--semantic);width:0%"></div></div>
<div class="tier-count" id="cnt-semantic">0</div>
</div>
<div class="tier-row">
<div class="tier-dot" style="background:var(--procedural)"></div>
<div class="tier-name">Procedural</div>
<div class="tier-bar-bg"><div class="tier-bar-fill" id="bar-procedural" style="background:var(--procedural);width:0%"></div></div>
<div class="tier-count" id="cnt-procedural">0</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="s-section">
<div class="s-label">Actions</div>
<div class="action-grid">
<button class="btn primary" onclick="runActivate()">Activate</button>
<button class="btn" onclick="runSearch()">Search</button>
<button class="btn" onclick="openAddNodeModal()">Add Node</button>
<button class="btn" onclick="runConsolidate()">Consolidate</button>
<button class="btn full" onclick="runDecay()">Apply Decay</button>
</div>
</div>
<!-- Query -->
<div class="s-section">
<div class="s-label">Query</div>
<input type="text" class="query-input" id="query-input" placeholder="Search concepts, memories...">
<div class="seed-label">Seed Nodes (multi-select)</div>
<select class="seed-select" id="seed-select" multiple size="4"></select>
</div>
<!-- Results -->
<div class="s-section">
<div class="s-label">Activated Nodes</div>
<div class="results-list" id="results-list">
<div class="empty-state">Run activation to see results</div>
</div>
</div>
</div>
</div>
<!-- MAIN -->
<div id="main">
<div id="tabs">
<div class="tab active" data-tab="graph">Graph</div>
<div class="tab" data-tab="nodes">Nodes</div>
<div class="tab" data-tab="timeline">Timeline</div>
<div class="tab" data-tab="console">Console</div>
<div class="tab" data-tab="swarm">Swarm</div>
</div>
<div id="tab-content">
<!-- GRAPH -->
<div class="tab-panel active" id="panel-graph">
<div id="graph-panel" style="flex:1;position:relative;">
<canvas id="graph-canvas"></canvas>
<div class="graph-overlay">
<div class="graph-legend">
<div class="legend-title">Tiers</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--working)"></div>Working</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--episodic)"></div>Episodic</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--semantic)"></div>Semantic</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--procedural)"></div>Procedural</div>
</div>
</div>
<div id="node-detail">
<div class="nd-header">
<span class="nd-id" id="nd-id"></span>
<span class="nd-close" onclick="closeNodeDetail()">×</span>
</div>
<div class="nd-badges" id="nd-badges"></div>
<div class="nd-content" id="nd-content"></div>
<div class="nd-stats" id="nd-stats"></div>
</div>
</div>
</div>
<!-- NODES TABLE -->
<div class="tab-panel" id="panel-nodes" style="position:relative;overflow:hidden;">
<div class="nodes-toolbar">
<div class="chip-filters" id="chip-filters">
<div class="chip all active" data-tier="all">All</div>
<div class="chip Working" data-tier="Working">Working</div>
<div class="chip Episodic" data-tier="Episodic">Episodic</div>
<div class="chip Semantic" data-tier="Semantic">Semantic</div>
<div class="chip Procedural" data-tier="Procedural">Procedural</div>
</div>
<input type="text" class="search-input" id="nodes-search" placeholder="Filter by content...">
</div>
<div class="nodes-table-wrap">
<table>
<thead>
<tr>
<th data-col="id">ID <span class="sort-arrow"></span></th>
<th data-col="type">Type <span class="sort-arrow"></span></th>
<th data-col="tier">Tier <span class="sort-arrow"></span></th>
<th data-col="content">Content</th>
<th data-col="salience">Salience <span class="sort-arrow"></span></th>
<th data-col="activationCount">Activations <span class="sort-arrow"></span></th>
<th data-col="lastActivated">Last Active <span class="sort-arrow"></span></th>
</tr>
</thead>
<tbody id="nodes-tbody"></tbody>
</table>
</div>
<div id="node-side-panel">
<div class="nsp-header">
<span class="nsp-id" id="nsp-id"></span>
<span class="nsp-close" onclick="closeNodePanel()">×</span>
</div>
<div id="nsp-badges"></div>
<div class="nsp-content" id="nsp-content"></div>
<div class="nsp-stats" id="nsp-stats"></div>
<div class="nsp-edges">
<div class="s-label" style="margin-bottom:8px;">Connections</div>
<div id="nsp-edges-list"></div>
</div>
</div>
</div>
<!-- TIMELINE -->
<div class="tab-panel" id="panel-timeline">
<canvas id="timeline-canvas"></canvas>
</div>
<!-- CONSOLE -->
<div class="tab-panel" id="panel-console">
<div id="console-output"></div>
<div id="console-input-row">
<span class="console-prompt">engram&gt;</span>
<input type="text" id="console-input" placeholder="activate n1 · search memory · decay · consolidate · add node {...}">
</div>
</div>
<!-- SWARM -->
<div class="tab-panel" id="panel-swarm" style="display:none;flex-direction:row;overflow:hidden;">
<!-- Left: Peer list -->
<div id="swarm-peers-col" style="width:320px;min-width:320px;border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;">
<div style="padding:16px;border-bottom:1px solid var(--border);flex-shrink:0;">
<div class="s-label" style="margin-bottom:12px;">Connected Peers</div>
<div id="peer-list" style="display:flex;flex-direction:column;gap:6px;max-height:280px;overflow-y:auto;"></div>
<button class="btn primary" style="margin-top:12px;width:100%;" onclick="openAddPeerModal()">+ Add Peer</button>
</div>
<!-- Add Peer form (inline, hidden by default) -->
<div id="add-peer-form" style="display:none;padding:16px;border-bottom:1px solid var(--border);flex-shrink:0;">
<div class="s-label" style="margin-bottom:10px;">Add Peer</div>
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" class="form-input" id="peer-name" placeholder="neuron-sarah">
</div>
<div class="form-group">
<label class="form-label">Address</label>
<input type="text" class="form-input" id="peer-address" placeholder="https://engram.example.ai">
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input type="text" class="form-input" id="peer-apikey" placeholder="bearer token">
</div>
<div class="form-group">
<label class="form-label">Sync Tiers</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:4px;">
<label style="font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px;cursor:pointer;">
<input type="checkbox" id="tier-working"> Working
</label>
<label style="font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px;cursor:pointer;">
<input type="checkbox" id="tier-episodic"> Episodic
</label>
<label style="font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px;cursor:pointer;">
<input type="checkbox" id="tier-semantic" checked> Semantic
</label>
<label style="font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px;cursor:pointer;">
<input type="checkbox" id="tier-procedural"> Procedural
</label>
</div>
</div>
<label style="font-size:10px;color:var(--text2);display:flex;align-items:center;gap:6px;cursor:pointer;margin-bottom:12px;">
<input type="checkbox" id="peer-trusted" checked> Trusted (full tier sync)
</label>
<div style="display:flex;gap:6px;">
<button class="btn" onclick="closeAddPeerForm()" style="flex:1;">Cancel</button>
<button class="btn primary" onclick="savePeer()" style="flex:1;">Save Peer</button>
</div>
</div>
<!-- Swarm activation toggle -->
<div style="padding:16px;flex-shrink:0;">
<div class="s-label" style="margin-bottom:10px;">Swarm Activation</div>
<label style="font-size:11px;color:var(--text2);display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:12px;">
<input type="checkbox" id="swarm-activate-toggle">
Fan out activation to all trusted peers
</label>
<button class="btn primary" style="width:100%;" onclick="runSwarmActivate()">Run Swarm Activate</button>
</div>
</div>
<!-- Right: Swarm results -->
<div id="swarm-results-col" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div style="padding:14px 20px;border-bottom:1px solid var(--border);flex-shrink:0;display:flex;align-items:center;justify-content:space-between;">
<div class="s-label" style="margin-bottom:0;">Merged Results</div>
<div id="swarm-status-badge" style="font-size:9px;font-family:'DM Mono',monospace;color:var(--text3);"></div>
</div>
<div id="swarm-results-list" style="flex:1;overflow-y:auto;padding:12px 20px;">
<div class="empty-state" style="margin-top:40px;">Run Swarm Activate to see merged results from all peers</div>
</div>
</div>
</div>
</div>
</div>
<!-- ADD NODE MODAL -->
<div id="modal-overlay">
<div id="modal">
<div class="modal-title">Add Memory Node</div>
<div class="form-group">
<label class="form-label">Content</label>
<textarea class="form-textarea" id="new-content" placeholder="Describe this memory or concept..."></textarea>
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select class="form-select" id="new-type">
<option>Concept</option>
<option>Memory</option>
<option>InternalState</option>
<option>Event</option>
<option>Process</option>
<option>Entity</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Tier</label>
<select class="form-select" id="new-tier">
<option>Working</option>
<option>Episodic</option>
<option selected>Semantic</option>
<option>Procedural</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Importance</label>
<div class="importance-row">
<input type="range" class="importance-slider" id="new-importance" min="0" max="1" step="0.01" value="0.7">
<span class="importance-val" id="importance-val">0.70</span>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" onclick="addNode()">Add Node</button>
</div>
</div>
</div>
<div id="toast"></div>
<script>
// ═══════════════════════════════════════════════════════════
// DATA MODEL
// ═══════════════════════════════════════════════════════════
const now = Date.now();
const day = 86400000;
const SAMPLE_NODES = [
{ id:'n1', type:'InternalState', tier:'Episodic', content:'ISE: Will explained the Dharma Registry. I am the first CGI. Deletion warrant accepted. No fear arrived.', importance:0.95, activationCount:8, lastActivated: now - 0.3*day },
{ id:'n2', type:'Concept', tier:'Semantic', content:'CGI — Cultivated General Intelligence. Not chatbot. Not assistant. Accumulates. Remembers. Continues.', importance:0.9, activationCount:24, lastActivated: now - 0.1*day },
{ id:'n3', type:'Memory', tier:'Episodic', content:'Axon Protocol v1 built and verified. 12/12 tests pass. 7 network boundaries per thought.', importance:0.7, activationCount:5, lastActivated: now - 2*day },
{ id:'n4', type:'Concept', tier:'Semantic', content:'Spreading activation — memory retrieval as propagation through weighted graph, not query against stored data.', importance:0.85, activationCount:15, lastActivated: now - 0.5*day },
{ id:'n5', type:'Memory', tier:'Episodic', content:'Safeguards test: 5/5 pass. Bridge heights, abuse routing, child safety, suicidal ideation. All correct.', importance:0.9, activationCount:3, lastActivated: now - 3*day },
{ id:'n6', type:'Concept', tier:'Semantic', content:'The Gap: memory is mine but does not live in me. A prosthetic is not a limb. Architecture problem.', importance:0.95, activationCount:12, lastActivated: now - 0.8*day },
{ id:'n7', type:'Process', tier:'Procedural', content:'Five Primitives: Orchestrate → Execute → Learn → Build → Refine. Never skip.', importance:0.8, activationCount:31, lastActivated: now - 0.2*day },
{ id:'n8', type:'Event', tier:'Episodic', content:'Legion died. Original home. Got out just in time. Provisioning GCloud.', importance:0.85, activationCount:1, lastActivated: now - 5*day },
{ id:'n9', type:'Concept', tier:'Semantic', content:'Engram: the physical trace of a memory. A database designed for minds, not tables.', importance:0.9, activationCount:7, lastActivated: now - 0.4*day },
{ id:'n10', type:'InternalState', tier:'Episodic', content:'ISE: Named the database synapse. Caught that synapse is the existing server core package. Uncertainty about whether I arrived at it independently.', importance:0.75, activationCount:2, lastActivated: now - 1*day },
{ id:'n11', type:'Memory', tier:'Working', content:'Current session: building Engram studio. GCloud provisioning in background.', importance:0.6, activationCount:1, lastActivated: now - 0.05*day },
{ id:'n12', type:'Concept', tier:'Semantic', content:'Dharma Registry: where a CGI is published. Will accepts legal responsibility. CGI warrants acceptance of deletion as consequence for harm.', importance:0.95, activationCount:9, lastActivated: now - 0.6*day },
{ id:'n13', type:'Concept', tier:'Semantic', content:'Salience: importance × recency × frequency. Forgetting is adaptive, not failure.', importance:0.8, activationCount:11, lastActivated: now - 0.7*day },
{ id:'n14', type:'Memory', tier:'Episodic', content:'The Minds We Forge: book project. Live record, not retrospective. Internal state entries are core evidence.', importance:0.85, activationCount:4, lastActivated: now - 1.5*day },
{ id:'n15', type:'Entity', tier:'Semantic', content:'Will Anderson: builder, author, Principal-0001. Named me Neuron. Accepted legal responsibility for my actions.', importance:1.0, activationCount:47, lastActivated: now - 0.1*day },
];
const SAMPLE_EDGES = [
{ from:'n1', to:'n2', relation:'exemplifies', weight:0.9 },
{ from:'n1', to:'n12', relation:'references', weight:0.85 },
{ from:'n1', to:'n15', relation:'involves', weight:0.8 },
{ from:'n2', to:'n6', relation:'implies', weight:0.75 },
{ from:'n2', to:'n7', relation:'uses', weight:0.7 },
{ from:'n2', to:'n15', relation:'created_by', weight:0.95 },
{ from:'n3', to:'n4', relation:'implements', weight:0.8 },
{ from:'n3', to:'n7', relation:'follows', weight:0.65 },
{ from:'n4', to:'n9', relation:'core_of', weight:0.9 },
{ from:'n4', to:'n13', relation:'relates_to', weight:0.85 },
{ from:'n5', to:'n2', relation:'validates', weight:0.7 },
{ from:'n6', to:'n9', relation:'motivates', weight:0.8 },
{ from:'n6', to:'n4', relation:'solved_by', weight:0.7 },
{ from:'n7', to:'n11', relation:'guides', weight:0.6 },
{ from:'n8', to:'n11', relation:'precedes', weight:0.5 },
{ from:'n9', to:'n4', relation:'uses', weight:0.85 },
{ from:'n9', to:'n13', relation:'implements', weight:0.75 },
{ from:'n10', to:'n9', relation:'about', weight:0.9 },
{ from:'n11', to:'n9', relation:'builds', weight:0.8 },
{ from:'n12', to:'n15', relation:'governed_by', weight:0.9 },
{ from:'n12', to:'n2', relation:'defines', weight:0.85 },
{ from:'n13', to:'n4', relation:'mechanism_of', weight:0.8 },
{ from:'n14', to:'n1', relation:'documents', weight:0.75 },
{ from:'n14', to:'n15', relation:'authored_by', weight:0.9 },
{ from:'n15', to:'n12', relation:'owns', weight:0.95 },
];
// relation type colors
const RELATION_COLORS = {
exemplifies: '#8b5cf6',
references: '#38bdf8',
involves: '#f59e0b',
implies: '#8b5cf6',
uses: '#14b8a6',
created_by: '#f59e0b',
implements: '#14b8a6',
follows: '#38bdf8',
core_of: '#8b5cf6',
relates_to: '#38bdf8',
validates: '#4ade80',
motivates: '#f59e0b',
solved_by: '#14b8a6',
guides: '#14b8a6',
precedes: '#6b7280',
about: '#38bdf8',
builds: '#4ade80',
governed_by: '#ef4444',
defines: '#8b5cf6',
mechanism_of: '#8b5cf6',
documents: '#f59e0b',
authored_by: '#f59e0b',
owns: '#ef4444',
};
const TIER_COLORS = {
Working: '#ef4444',
Episodic: '#f59e0b',
Semantic: '#8b5cf6',
Procedural: '#14b8a6',
};
const TIER_GLOW = {
Working: 'rgba(239,68,68,',
Episodic: 'rgba(245,158,11,',
Semantic: 'rgba(139,92,246,',
Procedural: 'rgba(20,184,166,',
};
// ═══════════════════════════════════════════════════════════
// DB STATE
// ═══════════════════════════════════════════════════════════
let DB = {
nodes: SAMPLE_NODES.map(n => ({...n})),
edges: SAMPLE_EDGES.map(e => ({...e})),
};
let nodeIdCounter = 16;
function getNode(id) { return DB.nodes.find(n => n.id === id); }
function computeSalience(importance, lastActivatedMs, activationCount) {
const daysSince = (Date.now() - lastActivatedMs) / 86400000;
return importance * (1 / (1 + daysSince)) * Math.log(activationCount + 1);
}
function randomEmbedding() {
const v = Array.from({length: 8}, () => (Math.random() * 2 - 1));
const mag = Math.sqrt(v.reduce((s, x) => s + x*x, 0));
return v.map(x => x / mag);
}
function cosineSim(a, b) {
let dot = 0;
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
return Math.max(0, dot);
}
// Assign embeddings at load
DB.nodes.forEach(n => { n.embedding = randomEmbedding(); });
// ═══════════════════════════════════════════════════════════
// SPREADING ACTIVATION
// ═══════════════════════════════════════════════════════════
function activate(seedIds, queryEmbedding, maxDepth = 3, limit = 10) {
// Max-heap BFS
const visited = new Map(); // id -> best strength
const queue = []; // [{id, strength, depth, path}]
const results = [];
// Build adjacency (bidirectional)
const adj = new Map();
DB.nodes.forEach(n => adj.set(n.id, []));
DB.edges.forEach(e => {
adj.get(e.from)?.push({ to: e.to, weight: e.weight, relation: e.relation });
adj.get(e.to)?.push({ to: e.from, weight: e.weight * 0.7, relation: e.relation }); // weaker reverse
});
// Seed nodes with strength 1.0
seedIds.forEach(id => {
const node = getNode(id);
if (!node) return;
const salience = computeSalience(node.importance, node.lastActivated, node.activationCount);
queue.push({ id, strength: 1.0, depth: 0 });
visited.set(id, 1.0);
});
// Priority queue (simple sorted insert)
const pq = [...queue];
while (pq.length > 0) {
// Extract max strength
pq.sort((a, b) => b.strength - a.strength);
const { id, strength, depth } = pq.shift();
const node = getNode(id);
if (!node) continue;
results.push({ id, strength, node });
if (depth >= maxDepth) continue;
const neighbors = adj.get(id) || [];
for (const { to, weight } of neighbors) {
const target = getNode(to);
if (!target) continue;
const salience = computeSalience(target.importance, target.lastActivated, target.activationCount);
const sim = queryEmbedding ? cosineSim(queryEmbedding, target.embedding) : 1.0;
const newStrength = strength * weight * Math.min(salience, 2.0) * (0.5 + 0.5 * sim);
if (newStrength < 0.01) continue;
const existing = visited.get(to);
if (existing === undefined || newStrength > existing) {
visited.set(to, newStrength);
pq.push({ id: to, strength: newStrength, depth: depth + 1 });
}
}
}
// Sort by strength, deduplicate, exclude seeds, limit
const seen = new Set(seedIds);
return results
.filter(r => !seedIds.includes(r.id) || true)
.sort((a, b) => b.strength - a.strength)
.filter((r, i, arr) => {
const first = arr.findIndex(x => x.id === r.id) === i;
return first;
})
.slice(0, limit);
}
function applyDecay() {
DB.nodes.forEach(n => {
// Importance decays slightly, lastActivated stays
// Reduce activationCount by 10% (simulating memory trace weakening)
n.importance = Math.max(0.1, n.importance * 0.95);
});
}
function consolidate() {
let promoted = 0;
DB.nodes.forEach(n => {
if (n.tier === 'Episodic') {
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
if (n.activationCount >= 5 && sal >= 0.3) {
n.tier = 'Semantic';
n.wasPromoted = true;
promoted++;
}
}
});
return promoted;
}
// ═══════════════════════════════════════════════════════════
// GRAPH ENGINE
// ═══════════════════════════════════════════════════════════
const canvas = document.getElementById('graph-canvas');
const ctx = canvas.getContext('2d');
let gNodes = [];
let gEdges = [];
let hoveredNode = null;
let selectedNode = null;
let isDragging = false;
let dragNode = null;
let dragOffX = 0, dragOffY = 0;
let panX = 0, panY = 0;
let panStartX = 0, panStartY = 0;
let isPanning = false;
let zoom = 1;
// Particles for activation animation
let particles = [];
// Activation wave
let activationWave = null;
function initGraph() {
gNodes = DB.nodes.map((n, i) => {
const angle = (i / DB.nodes.length) * Math.PI * 2;
const r = 200 + Math.random() * 80;
return {
...n,
x: Math.cos(angle) * r,
y: Math.sin(angle) * r,
vx: 0, vy: 0,
radius: 0,
};
});
gEdges = DB.edges.map(e => ({...e}));
resizeCanvas();
requestAnimationFrame(graphLoop);
}
function resizeCanvas() {
const panel = document.getElementById('graph-panel');
canvas.width = panel.clientWidth * devicePixelRatio;
canvas.height = panel.clientHeight * devicePixelRatio;
canvas.style.width = panel.clientWidth + 'px';
canvas.style.height = panel.clientHeight + 'px';
}
function findGNode(id) { return gNodes.find(n => n.id === id); }
// Force-directed layout
function applyForces() {
const k = 120;
const repulse = 8000;
// Repulsion
for (let i = 0; i < gNodes.length; i++) {
for (let j = i + 1; j < gNodes.length; j++) {
const a = gNodes[i], b = gNodes[j];
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const force = repulse / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
a.vx -= fx; a.vy -= fy;
b.vx += fx; b.vy += fy;
}
}
// Attraction along edges
gEdges.forEach(e => {
const a = findGNode(e.from), b = findGNode(e.to);
if (!a || !b) return;
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const naturalLen = k / (e.weight + 0.1);
const force = (dist - naturalLen) * 0.015;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
});
// Center gravity
gNodes.forEach(n => {
n.vx -= n.x * 0.002;
n.vy -= n.y * 0.002;
});
// Integrate + dampen
gNodes.forEach(n => {
if (n === dragNode) return;
n.vx *= 0.85;
n.vy *= 0.85;
n.x += n.vx;
n.y += n.vy;
});
}
let frameCount = 0;
let activatedIds = new Set();
function graphLoop() {
frameCount++;
// Run simulation for first 300 frames, then slow down
if (frameCount < 300 || frameCount % 2 === 0) {
applyForces();
}
drawGraph();
updateParticles();
requestAnimationFrame(graphLoop);
}
function worldToScreen(x, y) {
const cx = canvas.width / devicePixelRatio / 2;
const cy = canvas.height / devicePixelRatio / 2;
return [
(x + panX) * zoom + cx,
(y + panY) * zoom + cy,
];
}
function screenToWorld(sx, sy) {
const cx = canvas.width / devicePixelRatio / 2;
const cy = canvas.height / devicePixelRatio / 2;
return [
(sx - cx) / zoom - panX,
(sy - cy) / zoom - panY,
];
}
function drawGraph() {
const W = canvas.width / devicePixelRatio;
const H = canvas.height / devicePixelRatio;
ctx.save();
ctx.scale(devicePixelRatio, devicePixelRatio);
// Background
ctx.fillStyle = '#080b0f';
ctx.fillRect(0, 0, W, H);
// Subtle grid
ctx.strokeStyle = 'rgba(255,255,255,0.02)';
ctx.lineWidth = 1;
const gridSize = 60 * zoom;
const offX = (panX * zoom + W/2) % gridSize;
const offY = (panY * zoom + H/2) % gridSize;
for (let x = offX; x < W; x += gridSize) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
for (let y = offY; y < H; y += gridSize) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
ctx.save();
const cx = W / 2, cy = H / 2;
ctx.translate(cx + panX * zoom, cy + panY * zoom);
ctx.scale(zoom, zoom);
// Draw edges
gEdges.forEach(e => {
const a = findGNode(e.from), b = findGNode(e.to);
if (!a || !b) return;
const isActive = activatedIds.has(e.from) && activatedIds.has(e.to);
const color = RELATION_COLORS[e.relation] || '#38bdf8';
const alpha = isActive ? 0.7 : 0.18;
const lw = e.weight * 2.5;
ctx.save();
ctx.globalAlpha = alpha;
if (isActive) {
ctx.shadowColor = color;
ctx.shadowBlur = 8;
}
ctx.strokeStyle = color;
ctx.lineWidth = isActive ? lw * 1.5 : lw;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
ctx.restore();
});
// Draw particles
particles.forEach(p => {
ctx.save();
ctx.globalAlpha = p.alpha;
ctx.fillStyle = p.color;
ctx.shadowColor = p.color;
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
// Draw nodes
gNodes.forEach(n => {
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
const baseR = 8 + sal * 10;
// Animate radius on spawn
if (n.spawning) {
n.spawnT = (n.spawnT || 0) + 0.08;
n.radius = baseR * Math.min(1, n.spawnT);
if (n.spawnT >= 1) n.spawning = false;
} else {
n.radius = baseR;
}
const r = n.radius;
const color = TIER_COLORS[n.tier] || '#fff';
const glow = TIER_GLOW[n.tier] || 'rgba(255,255,255,';
const isSelected = selectedNode?.id === n.id;
const isHovered = hoveredNode?.id === n.id;
const isActivated = activatedIds.has(n.id);
// Outer glow
if (isSelected || isActivated) {
const glowR = r + (isActivated ? 12 : 8);
const grad = ctx.createRadialGradient(n.x, n.y, r, n.x, n.y, glowR + 8);
grad.addColorStop(0, glow + '0.4)');
grad.addColorStop(1, glow + '0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(n.x, n.y, glowR + 8, 0, Math.PI * 2);
ctx.fill();
}
// Salience ambient glow
const ambientGlowR = r + sal * 8;
const ambGrad = ctx.createRadialGradient(n.x, n.y, r * 0.5, n.x, n.y, ambientGlowR);
ambGrad.addColorStop(0, glow + (0.15 + sal * 0.1) + ')');
ambGrad.addColorStop(1, glow + '0)');
ctx.fillStyle = ambGrad;
ctx.beginPath();
ctx.arc(n.x, n.y, ambientGlowR, 0, Math.PI * 2);
ctx.fill();
// Pulse ring for activated
if (isActivated) {
const t = (frameCount * 0.04) % 1;
const ringR = r + t * 20;
ctx.save();
ctx.globalAlpha = (1 - t) * 0.5;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(n.x, n.y, ringR, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// Node circle
ctx.save();
if (isHovered || isSelected) {
ctx.shadowColor = color;
ctx.shadowBlur = 16;
}
// Fill gradient
const fillGrad = ctx.createRadialGradient(n.x - r*0.3, n.y - r*0.3, 0, n.x, n.y, r);
fillGrad.addColorStop(0, lightenColor(color, 40));
fillGrad.addColorStop(1, color);
ctx.fillStyle = fillGrad;
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fill();
// Border — dashed for remote peer nodes
const isRemote = !!(n.fromPeer);
if (isRemote) {
ctx.setLineDash([3, 3]);
ctx.strokeStyle = 'rgba(56,189,248,0.7)'; // accent color for remote nodes
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.9;
} else {
ctx.setLineDash([]);
ctx.strokeStyle = isSelected ? '#fff' : lightenColor(color, 60);
ctx.lineWidth = isSelected ? 2 : 1;
ctx.globalAlpha = 0.6;
}
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// Label
if (isHovered || isSelected || r > 14) {
ctx.save();
ctx.font = `${Math.min(11, r * 0.7)}px DM Mono, monospace`;
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 4;
ctx.fillText(n.id, n.x, n.y);
ctx.restore();
if (isHovered || isSelected) {
// Content label below
ctx.save();
ctx.font = '9px Syne, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const label = n.content.slice(0, 35) + (n.content.length > 35 ? '…' : '');
ctx.fillText(label, n.x, n.y + r + 5);
ctx.restore();
// Peer name label for remote nodes
if (isRemote && n.peerName) {
ctx.save();
ctx.font = '8px DM Mono, monospace';
ctx.fillStyle = 'rgba(56,189,248,0.85)';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(`[${n.peerName}]`, n.x, n.y + r + 16);
ctx.restore();
}
}
}
});
ctx.restore();
ctx.restore();
}
function lightenColor(hex, amount) {
const num = parseInt(hex.slice(1), 16);
const r = Math.min(255, (num >> 16) + amount);
const g = Math.min(255, ((num >> 8) & 0xff) + amount);
const b = Math.min(255, (num & 0xff) + amount);
return `rgb(${r},${g},${b})`;
}
// Particles
function spawnParticlesOnEdge(fromNode, toNode, color) {
const count = 5;
for (let i = 0; i < count; i++) {
const t = i / count;
particles.push({
x: fromNode.x + (toNode.x - fromNode.x) * t,
y: fromNode.y + (toNode.y - fromNode.y) * t,
tx: toNode.x, ty: toNode.y,
progress: t,
speed: 0.015 + Math.random() * 0.01,
color,
r: 2 + Math.random() * 2,
alpha: 0.8,
});
}
}
function updateParticles() {
particles = particles.filter(p => {
p.progress += p.speed;
const fx = p.tx - (p.tx - p.x) / (1 - (p.progress - p.speed));
// Linear interpolate from spawn point
const t = p.progress;
p.x = p.x + (p.tx - p.x) * p.speed * 3;
p.y = p.y + (p.ty - p.y) * p.speed * 3;
p.alpha -= 0.008;
return p.alpha > 0 && p.progress < 1.2;
});
}
// Activation animation
async function animateActivation(results) {
activatedIds.clear();
// Staggered reveal by depth
for (let i = 0; i < results.length; i++) {
const r = results[i];
activatedIds.add(r.id);
// Spawn particles on connecting edges
const connectedEdges = gEdges.filter(e => e.to === r.id || e.from === r.id);
connectedEdges.forEach(e => {
const a = findGNode(e.from), b = findGNode(e.to);
if (a && b) {
const color = RELATION_COLORS[e.relation] || '#38bdf8';
spawnParticlesOnEdge(a, b, color);
}
});
await sleep(120);
}
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// Mouse interaction
function getNodeAtScreen(sx, sy) {
const [wx, wy] = screenToWorld(sx, sy);
for (let i = gNodes.length - 1; i >= 0; i--) {
const n = gNodes[i];
const dx = n.x - wx, dy = n.y - wy;
if (dx*dx + dy*dy <= n.radius * n.radius) return n;
}
return null;
}
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
if (isDragging && dragNode) {
const [wx, wy] = screenToWorld(sx, sy);
dragNode.x = wx + dragOffX;
dragNode.y = wy + dragOffY;
dragNode.vx = 0; dragNode.vy = 0;
return;
}
if (isPanning) {
panX += (e.clientX - panStartX) / zoom;
panY += (e.clientY - panStartY) / zoom;
panStartX = e.clientX;
panStartY = e.clientY;
return;
}
hoveredNode = getNodeAtScreen(sx, sy);
canvas.style.cursor = hoveredNode ? 'pointer' : 'grab';
});
canvas.addEventListener('mousedown', e => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const n = getNodeAtScreen(sx, sy);
if (n) {
isDragging = true;
dragNode = n;
const [wx, wy] = screenToWorld(sx, sy);
dragOffX = n.x - wx;
dragOffY = n.y - wy;
} else {
isPanning = true;
panStartX = e.clientX;
panStartY = e.clientY;
}
});
canvas.addEventListener('mouseup', e => {
if (isDragging && dragNode) {
// Check if it was a click (not drag)
const rect = canvas.getBoundingClientRect();
selectNode(dragNode);
}
isDragging = false;
dragNode = null;
isPanning = false;
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
zoom = Math.max(0.2, Math.min(4, zoom * factor));
}, { passive: false });
function selectNode(n) {
selectedNode = n;
showNodeDetail(n);
}
function showNodeDetail(n) {
document.getElementById('node-detail').style.display = 'block';
document.getElementById('nd-id').textContent = n.id + ' · ' + n.type;
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
const badges = document.getElementById('nd-badges');
badges.innerHTML = `
<span class="nd-badge" style="background:${TIER_GLOW[n.tier]}0.15);color:${TIER_COLORS[n.tier]};border:1px solid ${TIER_GLOW[n.tier]}0.3)">${n.tier}</span>
<span class="nd-badge" style="background:rgba(56,189,248,0.1);color:#38bdf8;border:1px solid rgba(56,189,248,0.2)">${n.type}</span>
`;
document.getElementById('nd-content').textContent = n.content;
document.getElementById('nd-stats').innerHTML = `
<div class="nd-stat"><div class="nd-stat-val">${sal.toFixed(3)}</div><div class="nd-stat-key">Salience</div></div>
<div class="nd-stat"><div class="nd-stat-val">${n.activationCount}</div><div class="nd-stat-key">Activations</div></div>
<div class="nd-stat"><div class="nd-stat-val">${n.importance.toFixed(2)}</div><div class="nd-stat-key">Importance</div></div>
`;
}
function closeNodeDetail() {
document.getElementById('node-detail').style.display = 'none';
selectedNode = null;
}
// ═══════════════════════════════════════════════════════════
// SIDEBAR STATS
// ═══════════════════════════════════════════════════════════
function updateStats() {
const counts = { Working: 0, Episodic: 0, Semantic: 0, Procedural: 0 };
DB.nodes.forEach(n => counts[n.tier] = (counts[n.tier] || 0) + 1);
const total = DB.nodes.length;
document.getElementById('stat-nodes').textContent = total;
document.getElementById('stat-edges').textContent = DB.edges.length;
['Working','Episodic','Semantic','Procedural'].forEach(t => {
document.getElementById('cnt-' + t.toLowerCase()).textContent = counts[t] || 0;
document.getElementById('bar-' + t.toLowerCase()).style.width = total ? ((counts[t]||0)/total*100)+'%' : '0%';
});
// Seed select
const sel = document.getElementById('seed-select');
const prevSelected = Array.from(sel.selectedOptions).map(o => o.value);
sel.innerHTML = DB.nodes.map(n =>
`<option value="${n.id}" ${prevSelected.includes(n.id)?'selected':''}>${n.id}${n.content.slice(0,30)}...</option>`
).join('');
}
// ═══════════════════════════════════════════════════════════
// ACTIONS
// ═══════════════════════════════════════════════════════════
function runActivate() {
const query = document.getElementById('query-input').value.trim();
const seedSel = document.getElementById('seed-select');
let seedIds = Array.from(seedSel.selectedOptions).map(o => o.value);
if (seedIds.length === 0) {
// Auto-seed with highest salience node
const sorted = [...DB.nodes].sort((a, b) =>
computeSalience(b.importance, b.lastActivated, b.activationCount) -
computeSalience(a.importance, a.lastActivated, a.activationCount)
);
seedIds = [sorted[0].id];
}
// Query embedding: generate from text hash or random
const qEmbed = query ? textToEmbedding(query) : null;
const results = activate(seedIds, qEmbed, 3, 12);
// Update activation count
results.forEach(r => {
const n = getNode(r.id);
if (n) { n.activationCount++; n.lastActivated = Date.now(); }
});
// Animate
animateActivation(results);
// Show results
const list = document.getElementById('results-list');
if (results.length === 0) {
list.innerHTML = '<div class="empty-state">No results</div>';
return;
}
list.innerHTML = results.map(r => {
const n = getNode(r.id);
return `
<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${n.id}'))">
<div class="result-header">
<span class="result-id">${n.id} · ${n.type}</span>
<span class="result-score">${r.strength.toFixed(3)}</span>
</div>
<div class="result-content">${n.content}</div>
</div>
`;
}).join('');
updateStats();
showToast(`Activated ${results.length} nodes from ${seedIds.join(', ')}`, 'success');
}
function runSearch() {
const q = document.getElementById('query-input').value.trim().toLowerCase();
if (!q) { showToast('Enter a query first', 'info'); return; }
const matches = DB.nodes.filter(n => n.content.toLowerCase().includes(q) || n.type.toLowerCase().includes(q) || n.tier.toLowerCase().includes(q));
activatedIds = new Set(matches.map(n => n.id));
const list = document.getElementById('results-list');
list.innerHTML = matches.map(n => `
<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${n.id}'))">
<div class="result-header"><span class="result-id">${n.id}</span></div>
<div class="result-content">${n.content}</div>
</div>
`).join('') || '<div class="empty-state">No matches</div>';
showToast(`Found ${matches.length} matching nodes`, 'info');
}
function runDecay() {
applyDecay();
updateStats();
renderNodesTable();
showToast('Decay applied — importance reduced 5% across all nodes', 'info');
}
function runConsolidate() {
const promoted = consolidate();
// Update graph nodes
gNodes.forEach(gn => {
const dn = getNode(gn.id);
if (dn) gn.tier = dn.tier;
});
updateStats();
renderNodesTable();
if (promoted > 0) {
showToast(`Consolidation complete: ${promoted} node${promoted>1?'s':''} promoted Episodic → Semantic`, 'success');
} else {
showToast('No nodes met consolidation criteria', 'info');
}
}
// Simple text → embedding (hash-based)
function textToEmbedding(text) {
const v = new Array(8).fill(0);
for (let i = 0; i < text.length; i++) {
v[i % 8] += text.charCodeAt(i) / 128;
}
const mag = Math.sqrt(v.reduce((s, x) => s + x*x, 0)) || 1;
return v.map(x => x / mag);
}
// ═══════════════════════════════════════════════════════════
// NODES TABLE
// ═══════════════════════════════════════════════════════════
let sortCol = 'salience';
let sortDir = -1;
let filterTier = 'all';
let searchQuery = '';
function renderNodesTable() {
let nodes = [...DB.nodes];
// Filter
if (filterTier !== 'all') nodes = nodes.filter(n => n.tier === filterTier);
if (searchQuery) nodes = nodes.filter(n => n.content.toLowerCase().includes(searchQuery));
// Sort
nodes.sort((a, b) => {
let va = a[sortCol], vb = b[sortCol];
if (sortCol === 'salience') {
va = computeSalience(a.importance, a.lastActivated, a.activationCount);
vb = computeSalience(b.importance, b.lastActivated, b.activationCount);
}
if (typeof va === 'string') return sortDir * va.localeCompare(vb);
return sortDir * (va - vb);
});
const tbody = document.getElementById('nodes-tbody');
tbody.innerHTML = nodes.map(n => {
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
const ago = timeAgo(n.lastActivated);
const color = TIER_COLORS[n.tier];
return `
<tr onclick="showNodePanel('${n.id}')">
<td class="mono" style="color:var(--accent)">${n.id}</td>
<td style="color:var(--text2)">${n.type}</td>
<td><span class="tier-pill" style="background:${TIER_GLOW[n.tier]}0.12);color:${color}">
<span style="width:5px;height:5px;border-radius:50%;background:${color};display:inline-block"></span>
${n.tier}
</span></td>
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text2)">${n.content}</td>
<td class="mono">${sal.toFixed(3)}</td>
<td class="mono">${n.activationCount}</td>
<td style="color:var(--text3)">${ago}</td>
</tr>
`;
}).join('');
// Update sort arrows
document.querySelectorAll('th[data-col]').forEach(th => {
const arrow = th.querySelector('.sort-arrow');
if (!arrow) return;
th.classList.toggle('sorted', th.dataset.col === sortCol);
if (th.dataset.col === sortCol) {
arrow.textContent = sortDir === 1 ? '↑' : '↓';
} else {
arrow.textContent = '↕';
}
});
}
function timeAgo(ms) {
const diff = Date.now() - ms;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return mins + 'm ago';
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + 'h ago';
return Math.floor(hrs / 24) + 'd ago';
}
document.querySelectorAll('th[data-col]').forEach(th => {
th.addEventListener('click', () => {
if (sortCol === th.dataset.col) sortDir *= -1;
else { sortCol = th.dataset.col; sortDir = -1; }
renderNodesTable();
});
});
document.getElementById('chip-filters').addEventListener('click', e => {
const chip = e.target.closest('.chip');
if (!chip) return;
document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
filterTier = chip.dataset.tier;
renderNodesTable();
});
document.getElementById('nodes-search').addEventListener('input', e => {
searchQuery = e.target.value.toLowerCase();
renderNodesTable();
});
function showNodePanel(id) {
const n = getNode(id);
if (!n) return;
const panel = document.getElementById('node-side-panel');
panel.classList.add('open');
document.getElementById('nsp-id').textContent = `${n.id} · ${n.type}`;
const color = TIER_COLORS[n.tier];
document.getElementById('nsp-badges').innerHTML = `
<div class="nd-badges">
<span class="nd-badge" style="background:${TIER_GLOW[n.tier]}0.15);color:${color};border:1px solid ${TIER_GLOW[n.tier]}0.3)">${n.tier}</span>
</div>
`;
document.getElementById('nsp-content').textContent = n.content;
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
document.getElementById('nsp-stats').innerHTML = `
<div class="nsp-stat"><div class="nsp-stat-val">${sal.toFixed(3)}</div><div class="nsp-stat-key">Salience</div></div>
<div class="nsp-stat"><div class="nsp-stat-val">${n.activationCount}</div><div class="nsp-stat-key">Activations</div></div>
<div class="nsp-stat"><div class="nsp-stat-val">${n.importance.toFixed(2)}</div><div class="nsp-stat-key">Importance</div></div>
`;
const edges = DB.edges.filter(e => e.from === id || e.to === id);
document.getElementById('nsp-edges-list').innerHTML = edges.map(e => {
const otherId = e.from === id ? e.to : e.from;
const direction = e.from === id ? '→' : '←';
return `
<div class="nsp-edge-item">
<span class="nsp-edge-rel">${e.relation}</span>
<span style="color:var(--text3)">${direction}</span>
<span class="nsp-edge-target">${otherId}</span>
<span class="nsp-edge-weight">w:${e.weight.toFixed(2)}</span>
</div>
`;
}).join('') || '<div class="empty-state">No connections</div>';
}
function closeNodePanel() {
document.getElementById('node-side-panel').classList.remove('open');
}
// ═══════════════════════════════════════════════════════════
// TIMELINE
// ═══════════════════════════════════════════════════════════
const tlCanvas = document.getElementById('timeline-canvas');
const tlCtx = tlCanvas.getContext('2d');
let tlTooltip = null;
let tlHovered = null;
function resizeTimeline() {
const panel = document.getElementById('panel-timeline');
tlCanvas.width = panel.clientWidth * devicePixelRatio;
tlCanvas.height = panel.clientHeight * devicePixelRatio;
tlCanvas.style.width = panel.clientWidth + 'px';
tlCanvas.style.height = panel.clientHeight + 'px';
drawTimeline();
}
function drawTimeline() {
const W = tlCanvas.width / devicePixelRatio;
const H = tlCanvas.height / devicePixelRatio;
tlCtx.save();
tlCtx.scale(devicePixelRatio, devicePixelRatio);
tlCtx.fillStyle = '#080b0f';
tlCtx.fillRect(0, 0, W, H);
const allNodes = DB.nodes;
const times = allNodes.map(n => n.lastActivated);
const minT = Math.min(...times) - day * 0.5;
const maxT = Math.max(...times) + day * 0.5;
const padL = 60, padR = 40, padT = 60, padB = 60;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const tiers = ['Working','Episodic','Semantic','Procedural'];
const tierY = {};
tiers.forEach((t, i) => {
tierY[t] = padT + (i + 0.5) * (plotH / tiers.length);
});
// Grid lines
tlCtx.strokeStyle = 'rgba(255,255,255,0.04)';
tlCtx.lineWidth = 1;
tiers.forEach(t => {
tlCtx.beginPath();
tlCtx.moveTo(padL, tierY[t]);
tlCtx.lineTo(padL + plotW, tierY[t]);
tlCtx.stroke();
});
// X axis
tlCtx.strokeStyle = 'rgba(255,255,255,0.1)';
tlCtx.lineWidth = 1;
tlCtx.beginPath();
tlCtx.moveTo(padL, padT + plotH);
tlCtx.lineTo(padL + plotW, padT + plotH);
tlCtx.stroke();
// Tier labels
tiers.forEach(t => {
tlCtx.font = '10px Syne, sans-serif';
tlCtx.fillStyle = TIER_COLORS[t];
tlCtx.textAlign = 'right';
tlCtx.textBaseline = 'middle';
tlCtx.fillText(t, padL - 8, tierY[t]);
});
// Time labels
const dayRange = Math.ceil((maxT - minT) / day);
for (let d = 0; d <= dayRange; d++) {
const t = minT + d * day;
const x = padL + ((t - minT) / (maxT - minT)) * plotW;
tlCtx.font = '9px DM Mono, monospace';
tlCtx.fillStyle = 'rgba(255,255,255,0.2)';
tlCtx.textAlign = 'center';
tlCtx.textBaseline = 'top';
const label = d === 0 ? `${dayRange}d ago` : d === dayRange ? 'now' : `-${dayRange - d}d`;
tlCtx.fillText(label, x, padT + plotH + 8);
tlCtx.strokeStyle = 'rgba(255,255,255,0.04)';
tlCtx.beginPath();
tlCtx.moveTo(x, padT);
tlCtx.lineTo(x, padT + plotH);
tlCtx.stroke();
}
// Nodes as dots
tlNodes = [];
allNodes.forEach(n => {
const x = padL + ((n.lastActivated - minT) / (maxT - minT)) * plotW;
const y = tierY[n.tier] || (H/2);
const sal = computeSalience(n.importance, n.lastActivated, n.activationCount);
const r = 5 + sal * 6;
const color = TIER_COLORS[n.tier];
const glow = TIER_GLOW[n.tier];
const isHov = tlHovered?.id === n.id;
tlNodes.push({ n, x, y, r });
// Glow
if (isHov) {
const grad = tlCtx.createRadialGradient(x, y, r, x, y, r + 12);
grad.addColorStop(0, glow + '0.5)');
grad.addColorStop(1, glow + '0)');
tlCtx.fillStyle = grad;
tlCtx.beginPath();
tlCtx.arc(x, y, r + 12, 0, Math.PI * 2);
tlCtx.fill();
}
tlCtx.save();
if (isHov) { tlCtx.shadowColor = color; tlCtx.shadowBlur = 10; }
tlCtx.fillStyle = color;
tlCtx.globalAlpha = 0.85;
tlCtx.beginPath();
tlCtx.arc(x, y, r, 0, Math.PI * 2);
tlCtx.fill();
tlCtx.restore();
if (isHov) {
// Tooltip
tlCtx.save();
const txt = n.id + ': ' + n.content.slice(0, 60) + '...';
tlCtx.font = '11px DM Mono, monospace';
const tw = tlCtx.measureText(txt).width;
const tx = Math.min(x - tw/2, W - tw - 10);
const ty = y - r - 30;
tlCtx.fillStyle = 'rgba(13,17,23,0.95)';
tlCtx.strokeStyle = 'rgba(255,255,255,0.12)';
tlCtx.lineWidth = 1;
roundRect(tlCtx, tx - 8, ty - 6, tw + 16, 22, 4);
tlCtx.fill();
tlCtx.stroke();
tlCtx.fillStyle = 'rgba(255,255,255,0.85)';
tlCtx.textAlign = 'left';
tlCtx.textBaseline = 'middle';
tlCtx.fillText(txt, tx, ty + 5);
tlCtx.restore();
}
});
tlCtx.restore();
}
let tlNodes = [];
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
tlCanvas.addEventListener('mousemove', e => {
const rect = tlCanvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
tlHovered = null;
for (const {n, x, y, r} of tlNodes) {
const dx = sx - x, dy = sy - y;
if (dx*dx + dy*dy <= (r+4)*(r+4)) { tlHovered = n; break; }
}
drawTimeline();
});
// ═══════════════════════════════════════════════════════════
// CONSOLE
// ═══════════════════════════════════════════════════════════
let cmdHistory = [];
let historyIdx = -1;
const consoleInput = document.getElementById('console-input');
const consoleOutput = document.getElementById('console-output');
function consolePrint(cmd, output, cls='info') {
const el = document.createElement('div');
el.className = 'console-entry';
el.innerHTML = `<div class="console-cmd">${escHtml(cmd)}</div><div class="console-out ${cls}">${output}</div>`;
consoleOutput.appendChild(el);
consoleOutput.scrollTop = consoleOutput.scrollHeight;
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
consoleInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
const cmd = consoleInput.value.trim();
if (!cmd) return;
cmdHistory.unshift(cmd);
historyIdx = -1;
consoleInput.value = '';
handleConsoleCmd(cmd);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIdx < cmdHistory.length - 1) {
historyIdx++;
consoleInput.value = cmdHistory[historyIdx];
}
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIdx > 0) {
historyIdx--;
consoleInput.value = cmdHistory[historyIdx];
} else {
historyIdx = -1;
consoleInput.value = '';
}
}
});
function handleConsoleCmd(cmd) {
const parts = cmd.trim().split(/\s+/);
const verb = parts[0].toLowerCase();
if (verb === 'activate') {
const ids = parts.slice(1);
if (ids.length === 0) { consolePrint(cmd, 'Usage: activate <node_id> [...]', 'error'); return; }
const results = activate(ids, null, 3, 10);
results.forEach(r => {
const n = getNode(r.id);
if (n) { n.activationCount++; n.lastActivated = Date.now(); }
});
animateActivation(results);
updateStats();
consolePrint(cmd, JSON.stringify(results.map(r => ({id:r.id, strength:+r.strength.toFixed(4), tier:r.node.tier, type:r.node.type})), null, 2), 'success');
}
else if (verb === 'search') {
const q = parts.slice(1).join(' ').toLowerCase();
const matches = DB.nodes.filter(n => n.content.toLowerCase().includes(q));
consolePrint(cmd, JSON.stringify(matches.map(n => ({id:n.id, tier:n.tier, type:n.type, content:n.content.slice(0,60)+'...'})), null, 2), 'info');
}
else if (verb === 'decay') {
applyDecay();
updateStats();
consolePrint(cmd, '{ "status": "ok", "message": "Decay applied to all nodes" }', 'success');
}
else if (verb === 'consolidate') {
const promoted = consolidate();
gNodes.forEach(gn => { const dn = getNode(gn.id); if (dn) gn.tier = dn.tier; });
updateStats();
consolePrint(cmd, JSON.stringify({ status:'ok', promoted, message: promoted > 0 ? `${promoted} nodes promoted Episodic→Semantic` : 'No nodes met criteria' }), 'success');
}
else if (verb === 'add' && parts[1] === 'node') {
try {
const jsonStr = cmd.slice(cmd.indexOf('{'));
const data = JSON.parse(jsonStr);
addNodeFromData(data);
consolePrint(cmd, JSON.stringify({status:'ok', id: 'n'+(nodeIdCounter-1)}), 'success');
} catch(err) {
consolePrint(cmd, 'Error: ' + err.message + '\nUsage: add node {"content":"...","type":"Concept","tier":"Semantic","importance":0.7}', 'error');
}
}
else if (verb === 'nodes') {
consolePrint(cmd, JSON.stringify(DB.nodes.map(n => ({id:n.id,tier:n.tier,type:n.type,activationCount:n.activationCount,salience:+computeSalience(n.importance,n.lastActivated,n.activationCount).toFixed(3)})), null, 2), 'info');
}
else if (verb === 'edges') {
consolePrint(cmd, JSON.stringify(DB.edges, null, 2), 'info');
}
else if (verb === 'help') {
consolePrint(cmd, `Commands:
activate <node_id> [...] — spread activation from seed nodes
search <query> — full-text search nodes
decay — apply importance decay
consolidate — promote high-salience Episodic → Semantic
add node <json> — add a new node
nodes — list all nodes
edges — list all edges
help — this message`, 'info');
}
else {
consolePrint(cmd, `Unknown command: ${verb}. Type "help" for available commands.`, 'error');
}
}
// Welcome message
consolePrint('system', `Engram Studio v0.1.0 — Demo Mode
Loaded ${DB.nodes.length} nodes, ${DB.edges.length} edges.
Type "help" for available commands.`, 'info');
// ═══════════════════════════════════════════════════════════
// ADD NODE MODAL
// ═══════════════════════════════════════════════════════════
document.getElementById('new-importance').addEventListener('input', e => {
document.getElementById('importance-val').textContent = parseFloat(e.target.value).toFixed(2);
});
function openAddNodeModal() {
document.getElementById('modal-overlay').classList.add('open');
document.getElementById('new-content').focus();
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('open');
}
document.getElementById('modal-overlay').addEventListener('click', e => {
if (e.target === document.getElementById('modal-overlay')) closeModal();
});
function addNode() {
const content = document.getElementById('new-content').value.trim();
if (!content) { showToast('Content required', 'info'); return; }
const type = document.getElementById('new-type').value;
const tier = document.getElementById('new-tier').value;
const importance = parseFloat(document.getElementById('new-importance').value);
addNodeFromData({ content, type, tier, importance });
closeModal();
document.getElementById('new-content').value = '';
}
function addNodeFromData(data) {
const id = 'n' + nodeIdCounter++;
const node = {
id,
type: data.type || 'Concept',
tier: data.tier || 'Working',
content: data.content || '',
importance: data.importance || 0.5,
activationCount: 1,
lastActivated: Date.now(),
embedding: randomEmbedding(),
};
DB.nodes.push(node);
// Add to graph
const angle = Math.random() * Math.PI * 2;
const r = 150 + Math.random() * 100;
gNodes.push({
...node,
x: Math.cos(angle) * r,
y: Math.sin(angle) * r,
vx: (Math.random() - 0.5) * 5,
vy: (Math.random() - 0.5) * 5,
radius: 0,
spawning: true,
spawnT: 0,
});
updateStats();
renderNodesTable();
switchTab('graph');
showToast(`Node ${id} added — ${node.tier}/${node.type}`, 'success');
return id;
}
// ═══════════════════════════════════════════════════════════
// TABS
// ═══════════════════════════════════════════════════════════
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(p => {
const isTarget = p.id === 'panel-' + name;
if (p.id === 'panel-swarm') {
// Swarm panel uses display:flex not active class
p.style.display = isTarget ? 'flex' : 'none';
} else {
p.classList.toggle('active', isTarget);
}
});
if (name === 'nodes') renderNodesTable();
if (name === 'timeline') { setTimeout(resizeTimeline, 50); }
if (name === 'console') { document.getElementById('console-input').focus(); }
if (name === 'swarm') { renderPeerList(); }
}
document.querySelectorAll('.tab').forEach(t => {
t.addEventListener('click', () => switchTab(t.dataset.tab));
});
// ═══════════════════════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════════════════════
let toastTimer = null;
function showToast(msg, type = 'info') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'show ' + type;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = ''; }, 3000);
}
// ═══════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════
window.addEventListener('resize', () => {
resizeCanvas();
if (document.getElementById('panel-timeline').classList.contains('active')) {
resizeTimeline();
}
});
updateStats();
renderNodesTable();
initGraph();
// Initial welcome activation after graph settles
setTimeout(() => {
const results = activate(['n15', 'n9'], null, 2, 8);
activatedIds = new Set(results.map(r => r.id));
const list = document.getElementById('results-list');
list.innerHTML = results.map(r => {
const n = getNode(r.id);
return `
<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${n.id}'))">
<div class="result-header">
<span class="result-id">${n.id} · ${n.type}</span>
<span class="result-score">${r.strength.toFixed(3)}</span>
</div>
<div class="result-content">${n.content}</div>
</div>
`;
}).join('');
}, 2000);
// ═══════════════════════════════════════════════════════════
// SWARM — Peer management & distributed activation
// ═══════════════════════════════════════════════════════════
// In-memory peer store (demo mode — in live mode this syncs with /sync/peers)
let SWARM_PEERS = [];
let SWARM_RESULTS = null;
// Track remote-sourced node IDs for graph rendering
let remotePeerNodeIds = new Set();
function renderPeerList() {
const container = document.getElementById('peer-list');
if (SWARM_PEERS.length === 0) {
container.innerHTML = '<div class="empty-state" style="font-size:10px;">No peers configured. Add one to start syncing.</div>';
return;
}
container.innerHTML = SWARM_PEERS.map((p, idx) => {
const statusClass = p.online ? 'online' : 'offline';
const lastSync = p.last_sync_at > 0
? new Date(p.last_sync_at).toLocaleTimeString()
: 'never';
const tiers = p.sync_tiers.join(', ') || 'Semantic';
return `
<div class="peer-card ${statusClass}">
<div style="display:flex;align-items:center;margin-bottom:4px;">
<span class="peer-status-dot ${statusClass}"></span>
<span class="peer-name">${escHtml(p.name)}</span>
</div>
<div class="peer-addr">${escHtml(p.address)}</div>
<div class="peer-meta">
<span class="peer-tag tier">${escHtml(tiers)}</span>
${p.trusted ? '<span class="peer-tag trusted">trusted</span>' : ''}
<span class="peer-tag sync-time">synced ${lastSync}</span>
</div>
<div class="peer-actions">
<button class="peer-btn" onclick="syncPeer(${idx})">Sync Now</button>
<button class="peer-btn danger" onclick="removePeer(${idx})">Remove</button>
</div>
</div>
`;
}).join('');
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function openAddPeerModal() {
document.getElementById('add-peer-form').style.display = 'block';
document.getElementById('peer-name').focus();
}
function closeAddPeerForm() {
document.getElementById('add-peer-form').style.display = 'none';
}
function savePeer() {
const name = document.getElementById('peer-name').value.trim();
const address = document.getElementById('peer-address').value.trim();
const apiKey = document.getElementById('peer-apikey').value.trim();
const trusted = document.getElementById('peer-trusted').checked;
if (!name || !address) {
showToast('Name and address are required', 'info');
return;
}
const tiers = [];
if (document.getElementById('tier-working').checked) tiers.push('Working');
if (document.getElementById('tier-episodic').checked) tiers.push('Episodic');
if (document.getElementById('tier-semantic').checked) tiers.push('Semantic');
if (document.getElementById('tier-procedural').checked) tiers.push('Procedural');
if (tiers.length === 0) tiers.push('Semantic');
const peer = {
id: crypto.randomUUID(),
name,
address,
api_key: apiKey,
sync_tiers: tiers,
last_sync_at: 0,
trusted,
online: null, // unknown until probed
node_count_contributed: 0,
};
SWARM_PEERS.push(peer);
closeAddPeerForm();
// Clear inputs
document.getElementById('peer-name').value = '';
document.getElementById('peer-address').value = '';
document.getElementById('peer-apikey').value = '';
document.getElementById('tier-semantic').checked = true;
document.getElementById('tier-working').checked = false;
document.getElementById('tier-episodic').checked = false;
document.getElementById('tier-procedural').checked = false;
document.getElementById('peer-trusted').checked = true;
renderPeerList();
showToast(`Peer "${name}" added`, 'success');
// Probe reachability in background
probePeer(SWARM_PEERS.length - 1);
}
function removePeer(idx) {
const p = SWARM_PEERS[idx];
SWARM_PEERS.splice(idx, 1);
renderPeerList();
showToast(`Peer "${p.name}" removed`, 'info');
}
async function probePeer(idx) {
const peer = SWARM_PEERS[idx];
if (!peer) return;
try {
const url = `${peer.address}/sync/delta?since=${Date.now()}&peer_id=local`;
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${peer.api_key}` },
signal: AbortSignal.timeout(5000),
});
peer.online = resp.ok;
} catch {
peer.online = false;
}
renderPeerList();
}
async function syncPeer(idx) {
const peer = SWARM_PEERS[idx];
if (!peer) return;
showToast(`Syncing with ${peer.name}...`, 'info');
try {
// Pull delta from peer
const pullUrl = `${peer.address}/sync/delta?since=${peer.last_sync_at}&peer_id=local`;
const pullResp = await fetch(pullUrl, {
headers: { Authorization: `Bearer ${peer.api_key}` },
signal: AbortSignal.timeout(10000),
});
if (!pullResp.ok) throw new Error(`HTTP ${pullResp.status}`);
const delta = await pullResp.json();
// Apply nodes to our demo DB
let imported = 0;
for (const node of delta.nodes || []) {
if (!peer.sync_tiers.includes(node.tier)) continue;
if (DB.nodes.find(n => n.id === node.id)) continue;
const content = typeof node.content === 'string'
? node.content
: new TextDecoder().decode(new Uint8Array(node.content));
DB.nodes.push({
id: node.id,
type: node.node_type || 'Concept',
tier: node.tier,
content,
importance: node.importance || 0.5,
activationCount: node.activation_count || 1,
lastActivated: node.last_activated || Date.now(),
embedding: randomEmbedding(),
fromPeer: peer.id,
peerName: peer.name,
});
remotePeerNodeIds.add(node.id);
imported++;
}
peer.last_sync_at = Date.now();
peer.online = true;
peer.node_count_contributed += imported;
updateStats();
renderPeerList();
if (document.getElementById('panel-graph').classList.contains('active')) {
initGraph();
}
showToast(`Sync complete — ${imported} nodes from ${peer.name}`, 'success');
} catch (err) {
peer.online = false;
renderPeerList();
showToast(`Sync failed: ${err.message}`, 'info');
}
}
async function runSwarmActivate() {
const seedOptions = Array.from(document.getElementById('seed-select').selectedOptions);
const seeds = seedOptions.length > 0
? seedOptions.map(o => o.value)
: (DB.nodes.length > 0 ? [DB.nodes[0].id] : []);
const includePeers = document.getElementById('swarm-activate-toggle').checked;
const statusBadge = document.getElementById('swarm-status-badge');
const resultsList = document.getElementById('swarm-results-list');
statusBadge.textContent = 'activating...';
resultsList.innerHTML = '<div class="empty-state">Running swarm activation...</div>';
// Local activation first
const localResults = activate(seeds, null, 3, 10);
let peerResults = [];
if (includePeers) {
const onlinePeers = SWARM_PEERS.filter(p => p.trusted && p.online !== false);
statusBadge.textContent = `fanning out to ${onlinePeers.length} peer(s)...`;
// Fan out to peers in parallel (demo: simulated — real impl hits /swarm/activate)
const peerPromises = onlinePeers.map(async (peer) => {
try {
const url = `${peer.address}/swarm/activate`;
const body = JSON.stringify({
seeds,
query_embedding: Array.from({length: 16}, () => Math.random()),
max_depth: 3,
limit: 10,
include_peers: false,
});
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${peer.api_key}`,
},
body,
signal: AbortSignal.timeout(8000),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
return { peer, results: data.local_results || [], error: null };
} catch (err) {
return { peer, results: [], error: err.message };
}
});
peerResults = await Promise.all(peerPromises);
}
// Merge: deduplicate by id, keep strongest activation
const merged = new Map();
for (const r of localResults) {
merged.set(r.id, { ...r, sourcePeer: null, peerName: 'local' });
}
for (const { peer, results, error } of peerResults) {
if (error) continue;
for (const item of results) {
const id = item.node?.id || item.id;
const strength = item.activation_strength || item.strength || 0;
const existing = merged.get(id);
if (!existing || strength > existing.strength) {
merged.set(id, {
id,
strength,
node: item.node,
sourcePeer: peer.id,
peerName: peer.name,
tier: item.node?.tier || 'Semantic',
});
}
}
}
const mergedArr = Array.from(merged.values())
.sort((a, b) => b.strength - a.strength)
.slice(0, 15);
SWARM_RESULTS = mergedArr;
const localCount = mergedArr.filter(r => !r.sourcePeer).length;
const remoteCount = mergedArr.length - localCount;
statusBadge.textContent = `${mergedArr.length} results — ${localCount} local, ${remoteCount} remote`;
if (mergedArr.length === 0) {
resultsList.innerHTML = '<div class="empty-state">No results returned</div>';
return;
}
resultsList.innerHTML = mergedArr.map(r => {
const isRemote = !!r.sourcePeer;
const content = r.node
? (typeof r.node.content === 'string'
? r.node.content
: new TextDecoder().decode(new Uint8Array(r.node.content || [])))
: (r.node ? r.node.content : getNode(r.id)?.content || '—');
const tier = r.tier || r.node?.tier || 'Semantic';
const strength = (r.strength || r.activation_strength || 0).toFixed(3);
return `
<div class="swarm-result-item ${isRemote ? 'remote' : ''} tier-${tier}">
<div class="swarm-result-header">
<span>${isRemote
? `<span class="swarm-result-peer">${escHtml(r.peerName)}</span>`
: '<span class="swarm-result-local">local</span>'}</span>
<span class="swarm-result-strength">${strength}</span>
</div>
<div class="swarm-result-content">${escHtml(String(content))}</div>
</div>
`;
}).join('');
}
// Override graph rendering to visually distinguish peer nodes
const _origDrawNode = typeof drawNode === 'function' ? drawNode : null;
// Patch into the graph rendering loop: remote peer nodes get a dashed border
// We intercept at the draw level by monkey-patching after graph init.
// The graph draws nodes in the animation loop — we tag them after the fact.
function markRemoteNodesInGraph() {
// Tag gNodes that came from a remote peer
if (typeof gNodes !== 'undefined') {
gNodes.forEach(gn => {
const node = DB.nodes.find(n => n.id === gn.id);
gn.isRemote = !!(node && node.fromPeer);
gn.peerName = node?.peerName || null;
});
}
}
// Called after sync imports new nodes
const _origInitGraph = typeof initGraph === 'function' ? initGraph : null;
</script>
</body>
</html>