3259 lines
98 KiB
HTML
3259 lines
98 KiB
HTML
<!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></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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
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>
|