Files
el/engram/studio/index.html
T
Will Anderson 909c1577f1 rename crates/ to engrams/, bindings/ to receptors/
- crates/ → engrams/ (Rust engrams live here)
- bindings/ → receptors/ (cross-language access points into the graph)
- Cargo.toml workspace paths updated
2026-04-29 03:27:33 -05:00

4262 lines
125 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Engram Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #080b0f;
--bg2: #0d1117;
--bg3: #111820;
--bg4: #161e28;
--border: rgba(255,255,255,0.06);
--border2: rgba(255,255,255,0.12);
--text: #e8edf3;
--text2: #7a8a9a;
--text3: #4a5a6a;
--working: #ef4444;
--working-dim: rgba(239,68,68,0.15);
--working-glow: rgba(239,68,68,0.4);
--episodic: #f59e0b;
--episodic-dim: rgba(245,158,11,0.15);
--episodic-glow: rgba(245,158,11,0.4);
--semantic: #8b5cf6;
--semantic-dim: rgba(139,92,246,0.15);
--semantic-glow: rgba(139,92,246,0.4);
--procedural: #14b8a6;
--procedural-dim: rgba(20,184,166,0.15);
--procedural-glow: rgba(20,184,166,0.4);
--accent: #38bdf8;
--accent-dim: rgba(56,189,248,0.12);
--accent-glow: rgba(56,189,248,0.5);
--sidebar-w: 280px;
--radius: 6px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Syne', sans-serif;
font-size: 13px;
height: 100vh;
overflow: hidden;
display: flex;
}
/* ── SIDEBAR ── */
#sidebar {
width: var(--sidebar-w);
min-width: var(--sidebar-w);
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
z-index: 10;
}
#logo {
padding: 20px 20px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.logo-wordmark {
font-family: 'Syne', sans-serif;
font-weight: 800;
font-size: 20px;
letter-spacing: -0.02em;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.logo-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
animation: pulse-dot 2.5s ease-in-out infinite;
}
@keyframes pulse-dot {
0%,100% { box-shadow: 0 0 8px var(--accent-glow); opacity: 1; }
50% { box-shadow: 0 0 20px var(--accent-glow), 0 0 40px rgba(56,189,248,0.2); opacity: 0.8; }
}
.logo-sub {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.12em;
color: var(--text3);
text-transform: uppercase;
margin-top: 3px;
}
.mode-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--accent-dim);
border: 1px solid rgba(56,189,248,0.2);
border-radius: 3px;
padding: 2px 6px;
font-size: 9px;
font-family: 'DM Mono', monospace;
color: var(--accent);
letter-spacing: 0.08em;
margin-top: 8px;
}
.mode-badge::before {
content: '';
width: 5px; height: 5px;
border-radius: 50%;
background: var(--accent);
animation: blink 1.5s step-end infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
#sidebar-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0 80px;
}
#sidebar-scroll::-webkit-scrollbar { width: 3px; }
#sidebar-scroll::-webkit-scrollbar-track { background: transparent; }
#sidebar-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.s-section {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.s-label {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 10px;
}
.db-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.stat-box {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
}
.stat-val {
font-family: 'DM Mono', monospace;
font-size: 18px;
font-weight: 500;
color: var(--text);
line-height: 1;
}
.stat-key {
font-size: 9px;
color: var(--text3);
margin-top: 3px;
letter-spacing: 0.06em;
}
.tier-bars {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 5px;
}
.tier-row {
display: flex;
align-items: center;
gap: 8px;
}
.tier-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.tier-name {
font-size: 10px;
color: var(--text2);
width: 60px;
flex-shrink: 0;
}
.tier-bar-bg {
flex: 1;
height: 3px;
background: var(--bg3);
border-radius: 2px;
overflow: hidden;
}
.tier-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.tier-count {
font-family: 'DM Mono', monospace;
font-size: 10px;
color: var(--text3);
width: 14px;
text-align: right;
}
/* Actions */
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.btn {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text2);
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
padding: 8px 10px;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
white-space: nowrap;
}
.btn:hover {
background: var(--bg4);
border-color: var(--border2);
color: var(--text);
}
.btn.primary {
background: var(--accent-dim);
border-color: rgba(56,189,248,0.3);
color: var(--accent);
}
.btn.primary:hover {
background: rgba(56,189,248,0.2);
box-shadow: 0 0 12px rgba(56,189,248,0.2);
}
.btn.full { grid-column: 1/-1; }
.btn:active { transform: scale(0.97); }
/* Query panel */
.query-input {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 11px;
padding: 8px 10px;
outline: none;
transition: border-color 0.15s;
margin-bottom: 8px;
}
.query-input:focus { border-color: rgba(56,189,248,0.4); }
.query-input::placeholder { color: var(--text3); }
.seed-label {
font-size: 9px;
color: var(--text3);
margin-bottom: 5px;
letter-spacing: 0.06em;
}
.seed-select {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text2);
font-family: 'DM Mono', monospace;
font-size: 10px;
padding: 4px 6px;
outline: none;
max-height: 80px;
scrollbar-width: thin;
}
.seed-select option { padding: 2px 4px; }
/* Results */
.results-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 220px;
overflow-y: auto;
}
.results-list::-webkit-scrollbar { width: 3px; }
.results-list::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.result-item {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 9px;
cursor: pointer;
transition: all 0.15s;
position: relative;
overflow: hidden;
}
.result-item::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 2px;
border-radius: 2px 0 0 2px;
}
.result-item.tier-Working::before { background: var(--working); }
.result-item.tier-Episodic::before { background: var(--episodic); }
.result-item.tier-Semantic::before { background: var(--semantic); }
.result-item.tier-Procedural::before { background: var(--procedural); }
.result-item:hover { border-color: var(--border2); background: var(--bg4); }
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3px;
}
.result-id {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
}
.result-score {
font-family: 'DM Mono', monospace;
font-size: 10px;
font-weight: 500;
color: var(--accent);
}
.result-content {
font-size: 10px;
color: var(--text2);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.empty-state {
font-size: 10px;
color: var(--text3);
text-align: center;
padding: 16px 0;
font-style: italic;
}
/* ── MAIN AREA ── */
#main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* Tabs */
#tabs {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
background: var(--bg2);
padding: 0 20px;
gap: 0;
flex-shrink: 0;
height: 44px;
}
.tab {
padding: 0 18px;
height: 44px;
display: flex;
align-items: center;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text3);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
position: relative;
top: 1px;
}
.tab:hover { color: var(--text2); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
#tab-content {
flex: 1;
overflow: hidden;
position: relative;
}
.tab-panel {
position: absolute;
inset: 0;
display: none;
}
.tab-panel.active { display: flex; flex-direction: column; }
/* ── GRAPH TAB ── */
#graph-panel {
position: relative;
background: var(--bg);
}
#graph-canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
#graph-canvas:active { cursor: grabbing; }
.graph-overlay {
position: absolute;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
}
.graph-legend {
background: rgba(13,17,23,0.85);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
backdrop-filter: blur(8px);
pointer-events: all;
}
.legend-title {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 7px;
}
.legend-item {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 4px;
font-size: 10px;
color: var(--text2);
}
.legend-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* Node detail popup */
#node-detail {
position: absolute;
bottom: 16px;
left: 16px;
background: rgba(13,17,23,0.92);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 14px 16px;
max-width: 340px;
backdrop-filter: blur(12px);
display: none;
animation: slide-up 0.2s ease;
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.nd-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.nd-id {
font-family: 'DM Mono', monospace;
font-size: 11px;
color: var(--accent);
}
.nd-close {
cursor: pointer;
color: var(--text3);
font-size: 16px;
line-height: 1;
padding: 0 2px;
transition: color 0.15s;
}
.nd-close:hover { color: var(--text); }
.nd-badges {
display: flex;
gap: 5px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.nd-badge {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
padding: 2px 7px;
border-radius: 3px;
text-transform: uppercase;
}
.nd-content {
font-size: 11px;
color: var(--text2);
line-height: 1.6;
margin-bottom: 10px;
}
.nd-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.nd-stat {
background: var(--bg3);
border-radius: 4px;
padding: 6px 8px;
}
.nd-stat-val {
font-family: 'DM Mono', monospace;
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.nd-stat-key {
font-size: 8px;
color: var(--text3);
margin-top: 2px;
letter-spacing: 0.06em;
}
/* ── NODES TAB ── */
#nodes-panel {
flex-direction: column;
}
.nodes-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.chip-filters {
display: flex;
gap: 5px;
}
.chip {
padding: 4px 10px;
border-radius: 20px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
color: var(--text3);
background: var(--bg3);
}
.chip:hover { color: var(--text2); }
.chip.active { color: var(--text); }
.chip.all.active { background: var(--bg4); border-color: var(--border2); color: var(--text); }
.chip.Working.active { background: var(--working-dim); border-color: var(--working); color: var(--working); }
.chip.Episodic.active { background: var(--episodic-dim); border-color: var(--episodic); color: var(--episodic); }
.chip.Semantic.active { background: var(--semantic-dim); border-color: var(--semantic); color: var(--semantic); }
.chip.Procedural.active { background: var(--procedural-dim); border-color: var(--procedural); color: var(--procedural); }
.search-input {
flex: 1;
max-width: 220px;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 11px;
padding: 5px 10px;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: rgba(56,189,248,0.4); }
.search-input::placeholder { color: var(--text3); }
.nodes-table-wrap {
flex: 1;
overflow-y: auto;
overflow-x: auto;
}
.nodes-table-wrap::-webkit-scrollbar { width: 4px; height: 4px; }
.nodes-table-wrap::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
thead {
position: sticky;
top: 0;
background: var(--bg2);
z-index: 1;
}
th {
text-align: left;
padding: 10px 14px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text3);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
white-space: nowrap;
transition: color 0.15s;
}
th:hover { color: var(--text2); }
th.sorted { color: var(--accent); }
th .sort-arrow { margin-left: 4px; opacity: 0.6; }
td {
padding: 9px 14px;
border-bottom: 1px solid var(--border);
color: var(--text2);
vertical-align: middle;
}
tr:hover td { background: var(--bg3); }
tr { cursor: pointer; transition: background 0.1s; }
.tier-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.06em;
padding: 2px 7px;
border-radius: 3px;
white-space: nowrap;
}
.mono { font-family: 'DM Mono', monospace; }
/* Node detail side panel */
#node-side-panel {
position: absolute;
top: 0; right: 0; bottom: 0;
width: 360px;
background: var(--bg2);
border-left: 1px solid var(--border2);
transform: translateX(100%);
transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
z-index: 20;
overflow-y: auto;
padding: 20px;
}
#node-side-panel.open { transform: translateX(0); }
#node-side-panel::-webkit-scrollbar { width: 3px; }
#node-side-panel::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.nsp-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.nsp-id {
font-family: 'DM Mono', monospace;
font-size: 13px;
color: var(--accent);
}
.nsp-close {
cursor: pointer;
color: var(--text3);
font-size: 20px;
line-height: 1;
transition: color 0.15s;
}
.nsp-close:hover { color: var(--text); }
.nsp-content {
font-size: 12px;
color: var(--text2);
line-height: 1.7;
margin: 12px 0;
padding: 12px;
background: var(--bg3);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.nsp-stats {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
margin: 12px 0;
}
.nsp-stat {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
}
.nsp-stat-val {
font-family: 'DM Mono', monospace;
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.nsp-stat-key {
font-size: 8px;
color: var(--text3);
margin-top: 3px;
letter-spacing: 0.06em;
}
.nsp-edges {
margin-top: 16px;
}
.nsp-edge-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
font-size: 10px;
}
.nsp-edge-rel {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
padding: 1px 5px;
background: var(--bg3);
border-radius: 3px;
white-space: nowrap;
}
.nsp-edge-target {
color: var(--accent);
font-family: 'DM Mono', monospace;
font-size: 10px;
}
.nsp-edge-weight {
margin-left: auto;
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
}
/* ── TIMELINE TAB ── */
#timeline-panel { overflow: hidden; }
#timeline-canvas {
width: 100%;
height: 100%;
display: block;
}
/* ── CONSOLE TAB ── */
#console-panel { overflow: hidden; background: var(--bg); }
#console-output {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
font-family: 'DM Mono', monospace;
font-size: 11px;
line-height: 1.7;
}
#console-output::-webkit-scrollbar { width: 4px; }
#console-output::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.console-entry {
margin-bottom: 12px;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
}
.console-cmd {
color: var(--accent);
margin-bottom: 5px;
}
.console-cmd::before { content: '> '; color: var(--text3); }
.console-out {
color: var(--text2);
white-space: pre-wrap;
word-break: break-word;
}
.console-out.success { color: #4ade80; }
.console-out.error { color: #f87171; }
.console-out.info { color: var(--text2); }
#console-input-row {
display: flex;
align-items: center;
padding: 12px 20px;
border-top: 1px solid var(--border);
gap: 10px;
flex-shrink: 0;
background: var(--bg2);
}
.console-prompt {
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--accent);
flex-shrink: 0;
}
#console-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--text);
caret-color: var(--accent);
}
#console-input::placeholder { color: var(--text3); }
/* ── MODAL ── */
#modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
#modal-overlay.open { display: flex; }
#modal {
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: 10px;
padding: 24px;
width: 400px;
animation: modal-in 0.2s ease;
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-title {
font-size: 15px;
font-weight: 700;
margin-bottom: 20px;
color: var(--text);
}
.form-group {
margin-bottom: 14px;
}
.form-label {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 5px;
}
.form-input, .form-select, .form-textarea {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 11px;
padding: 8px 10px;
outline: none;
transition: border-color 0.15s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
border-color: rgba(56,189,248,0.4);
}
.form-textarea { resize: vertical; min-height: 80px; }
.form-select option { background: var(--bg3); }
.importance-row {
display: flex;
align-items: center;
gap: 10px;
}
.importance-slider {
flex: 1;
-webkit-appearance: none;
height: 3px;
border-radius: 2px;
background: var(--bg4);
outline: none;
}
.importance-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
box-shadow: 0 0 8px var(--accent-glow);
}
.importance-val {
font-family: 'DM Mono', monospace;
font-size: 12px;
color: var(--accent);
width: 32px;
text-align: right;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
/* ── TOAST ── */
#toast {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 12px 16px;
font-size: 12px;
color: var(--text);
z-index: 200;
transform: translateY(20px);
opacity: 0;
transition: all 0.25s ease;
max-width: 300px;
backdrop-filter: blur(8px);
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { border-color: rgba(74,222,128,0.4); }
#toast.info { border-color: rgba(56,189,248,0.4); }
/* Scrollbar global */
* { scrollbar-width: thin; scrollbar-color: var(--border2) transparent; }
/* ── SWARM TAB ── */
.peer-card {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
position: relative;
overflow: hidden;
}
.peer-card.online { border-color: rgba(74,222,128,0.3); }
.peer-card.offline { border-color: rgba(248,113,113,0.2); }
.peer-status-dot {
width: 7px; height: 7px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
flex-shrink: 0;
}
.peer-status-dot.online { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.6); }
.peer-status-dot.offline { background: #f87171; }
.peer-name {
font-weight: 600;
font-size: 11px;
color: var(--text);
}
.peer-addr {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
margin-top: 2px;
word-break: break-all;
}
.peer-meta {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.peer-tag {
font-size: 8px;
padding: 1px 5px;
border-radius: 3px;
font-family: 'DM Mono', monospace;
letter-spacing: 0.04em;
}
.peer-tag.tier { background: var(--semantic-dim); color: var(--semantic); border: 1px solid rgba(139,92,246,0.2); }
.peer-tag.trusted { background: rgba(56,189,248,0.1); color: var(--accent); border: 1px solid rgba(56,189,248,0.2); }
.peer-tag.sync-time { background: var(--bg4); color: var(--text3); border: 1px solid var(--border); }
.peer-actions {
display: flex;
gap: 4px;
margin-top: 8px;
}
.peer-btn {
font-size: 9px;
padding: 3px 8px;
border-radius: 3px;
cursor: pointer;
font-family: 'Syne', sans-serif;
font-weight: 600;
letter-spacing: 0.04em;
border: 1px solid var(--border2);
background: var(--bg4);
color: var(--text2);
transition: all 0.1s;
}
.peer-btn:hover { background: var(--bg3); color: var(--text); }
.peer-btn.danger:hover { border-color: rgba(248,113,113,0.4); color: #f87171; }
/* Swarm result items */
.swarm-result-item {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
margin-bottom: 6px;
position: relative;
overflow: hidden;
}
/* Remote nodes: dashed border */
.swarm-result-item.remote {
border-style: dashed;
border-color: rgba(56,189,248,0.25);
background: rgba(56,189,248,0.03);
}
.swarm-result-item::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 2px;
border-radius: 2px 0 0 2px;
}
.swarm-result-item.tier-Working::before { background: var(--working); }
.swarm-result-item.tier-Episodic::before { background: var(--episodic); }
.swarm-result-item.tier-Semantic::before { background: var(--semantic); }
.swarm-result-item.tier-Procedural::before { background: var(--procedural); }
.swarm-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.swarm-result-peer {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--accent);
background: var(--accent-dim);
padding: 1px 6px;
border-radius: 3px;
border: 1px solid rgba(56,189,248,0.2);
}
.swarm-result-local {
font-family: 'DM Mono', monospace;
font-size: 9px;
color: var(--text3);
}
.swarm-result-strength {
font-family: 'DM Mono', monospace;
font-size: 10px;
font-weight: 500;
color: var(--accent);
}
.swarm-result-content {
font-size: 10px;
color: var(--text2);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Graph canvas: remote peer nodes get dashed overlay — drawn via canvas 2d */
/* ── CHAT TAB ── */
#chat-panel {
display: flex;
flex-direction: row;
overflow: hidden;
background: var(--bg);
}
#chat-sessions-col {
width: 220px;
min-width: 220px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg2);
}
#chat-sessions-header {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#new-chat-btn {
width: 100%;
margin-bottom: 0;
}
#chat-session-list {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
#chat-session-list::-webkit-scrollbar { width: 3px; }
#chat-session-list::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.chat-session-item {
padding: 8px 14px;
cursor: pointer;
border-left: 2px solid transparent;
transition: all 0.15s;
font-size: 11px;
color: var(--text3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-session-item:hover { background: var(--bg3); color: var(--text2); }
.chat-session-item.active {
border-left-color: var(--accent);
background: var(--accent-dim);
color: var(--accent);
}
#chat-main-col {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
#chat-messages::-webkit-scrollbar { width: 4px; }
#chat-messages::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.chat-msg {
max-width: 85%;
animation: msg-in 0.2s ease;
}
@keyframes msg-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.chat-msg.user { align-self: flex-end; }
.chat-msg.assistant { align-self: flex-start; }
.chat-msg-bubble {
padding: 10px 14px;
border-radius: 8px;
font-size: 12px;
line-height: 1.6;
position: relative;
}
.chat-msg.user .chat-msg-bubble {
background: var(--accent-dim);
border: 1px solid rgba(56,189,248,0.25);
color: var(--text);
}
.chat-msg.assistant .chat-msg-bubble {
background: var(--bg2);
border: 1px solid var(--border2);
color: var(--text2);
}
.chat-msg-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
padding: 0 2px;
}
.chat-msg-role {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.chat-tts-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text3);
font-size: 12px;
padding: 0 2px;
transition: color 0.15s;
line-height: 1;
}
.chat-tts-btn:hover { color: var(--accent); }
/* Tool call blocks */
.tool-block {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 6px;
margin: 8px 0;
overflow: hidden;
font-family: 'DM Mono', monospace;
font-size: 10px;
}
.tool-block-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
cursor: pointer;
background: var(--bg4);
user-select: none;
}
.tool-block-name {
color: var(--accent);
font-weight: 500;
}
.tool-block-toggle {
margin-left: auto;
color: var(--text3);
font-size: 9px;
}
.tool-block-body {
padding: 8px 10px;
display: none;
border-top: 1px solid var(--border);
white-space: pre-wrap;
word-break: break-all;
color: var(--text2);
max-height: 200px;
overflow-y: auto;
}
.tool-block-body.open { display: block; }
.tool-result-content {
padding: 6px 10px;
color: #4ade80;
font-size: 10px;
font-family: 'DM Mono', monospace;
border-top: 1px solid var(--border);
max-height: 120px;
overflow-y: auto;
display: none;
white-space: pre-wrap;
word-break: break-all;
}
.tool-result-content.show { display: block; }
/* Markdown rendered in chat */
.chat-md h1, .chat-md h2, .chat-md h3 {
color: var(--text);
font-family: 'Syne', sans-serif;
margin: 10px 0 5px;
}
.chat-md h1 { font-size: 15px; }
.chat-md h2 { font-size: 13px; }
.chat-md h3 { font-size: 12px; }
.chat-md p { margin: 5px 0; }
.chat-md code {
font-family: 'DM Mono', monospace;
background: var(--bg3);
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
color: var(--accent);
}
.chat-md pre {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 5px;
padding: 10px 12px;
overflow-x: auto;
margin: 8px 0;
}
.chat-md pre code {
background: none;
padding: 0;
color: var(--text2);
}
.chat-md ul, .chat-md ol { padding-left: 18px; margin: 5px 0; }
.chat-md li { margin: 2px 0; }
.chat-md strong { color: var(--text); font-weight: 600; }
.chat-md em { font-style: italic; color: var(--text2); }
.chat-md a { color: var(--accent); }
.chat-md hr { border: none; border-top: 1px solid var(--border); margin: 10px 0; }
.chat-md blockquote {
border-left: 3px solid var(--accent);
padding-left: 10px;
color: var(--text3);
margin: 6px 0;
}
/* Chat input row */
#chat-input-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--bg2);
flex-shrink: 0;
}
#chat-textarea {
flex: 1;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 6px;
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 12px;
padding: 9px 12px;
outline: none;
resize: none;
min-height: 40px;
max-height: 160px;
overflow-y: auto;
transition: border-color 0.15s;
line-height: 1.5;
}
#chat-textarea:focus { border-color: rgba(56,189,248,0.4); }
#chat-textarea::placeholder { color: var(--text3); }
#chat-send-btn {
flex-shrink: 0;
width: 38px;
height: 38px;
background: var(--accent-dim);
border: 1px solid rgba(56,189,248,0.3);
border-radius: 6px;
color: var(--accent);
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
#chat-send-btn:hover { background: rgba(56,189,248,0.2); }
#chat-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#chat-tts-toggle {
flex-shrink: 0;
width: 38px;
height: 38px;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 6px;
color: var(--text3);
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
#chat-tts-toggle.active {
background: var(--accent-dim);
border-color: rgba(56,189,248,0.3);
color: var(--accent);
}
/* System prompt collapsible */
#chat-system-row {
padding: 8px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg2);
flex-shrink: 0;
}
#chat-system-toggle {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text3);
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
user-select: none;
}
#chat-system-toggle:hover { color: var(--text2); }
#chat-system-textarea {
display: none;
width: 100%;
margin-top: 8px;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
color: var(--text2);
font-family: 'DM Mono', monospace;
font-size: 10px;
padding: 7px 10px;
outline: none;
resize: vertical;
min-height: 60px;
}
#chat-system-textarea.open { display: block; }
.chat-empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text3);
font-size: 12px;
}
.chat-empty-icon {
font-size: 32px;
opacity: 0.3;
}
.chat-thinking {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 11px;
color: var(--text3);
font-family: 'DM Mono', monospace;
align-self: flex-start;
}
.chat-thinking-dots {
display: flex;
gap: 3px;
}
.chat-thinking-dots span {
width: 4px; height: 4px;
border-radius: 50%;
background: var(--text3);
animation: thinking-bounce 1.2s ease-in-out infinite;
}
.chat-thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.chat-thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes thinking-bounce {
0%,80%,100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
#chat-model-select {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
color: var(--text2);
font-family: 'DM Mono', monospace;
font-size: 10px;
padding: 3px 6px;
outline: none;
flex-shrink: 0;
}
</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" id="mode-badge">CONNECTING...</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 class="tab" data-tab="chat">Chat</div>
</div>
<div id="tab-content">
<!-- GRAPH -->
<div class="tab-panel active" id="panel-graph">
<div id="graph-panel" style="flex:1;position:relative;">
<canvas id="graph-canvas"></canvas>
<div class="graph-overlay">
<div class="graph-legend">
<div class="legend-title">Tiers</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--working)"></div>Working</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--episodic)"></div>Episodic</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--semantic)"></div>Semantic</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--procedural)"></div>Procedural</div>
</div>
</div>
<div id="node-detail">
<div class="nd-header">
<span class="nd-id" id="nd-id"></span>
<span class="nd-close" onclick="closeNodeDetail()">×</span>
</div>
<div class="nd-badges" id="nd-badges"></div>
<div class="nd-content" id="nd-content"></div>
<div class="nd-stats" id="nd-stats"></div>
</div>
</div>
</div>
<!-- NODES TABLE -->
<div class="tab-panel" id="panel-nodes" style="position:relative;overflow:hidden;">
<div class="nodes-toolbar">
<div class="chip-filters" id="chip-filters">
<div class="chip all active" data-tier="all">All</div>
<div class="chip Working" data-tier="Working">Working</div>
<div class="chip Episodic" data-tier="Episodic">Episodic</div>
<div class="chip Semantic" data-tier="Semantic">Semantic</div>
<div class="chip Procedural" data-tier="Procedural">Procedural</div>
</div>
<input type="text" class="search-input" id="nodes-search" placeholder="Filter by content...">
</div>
<div class="nodes-table-wrap">
<table>
<thead>
<tr>
<th data-col="id">ID <span class="sort-arrow"></span></th>
<th data-col="type">Type <span class="sort-arrow"></span></th>
<th data-col="tier">Tier <span class="sort-arrow"></span></th>
<th data-col="content">Content</th>
<th data-col="salience">Salience <span class="sort-arrow"></span></th>
<th data-col="activationCount">Activations <span class="sort-arrow"></span></th>
<th data-col="lastActivated">Last Active <span class="sort-arrow"></span></th>
</tr>
</thead>
<tbody id="nodes-tbody"></tbody>
</table>
</div>
<div id="node-side-panel">
<div class="nsp-header">
<span class="nsp-id" id="nsp-id"></span>
<span class="nsp-close" onclick="closeNodePanel()">×</span>
</div>
<div id="nsp-badges"></div>
<div class="nsp-content" id="nsp-content"></div>
<div class="nsp-stats" id="nsp-stats"></div>
<div class="nsp-edges">
<div class="s-label" style="margin-bottom:8px;">Connections</div>
<div id="nsp-edges-list"></div>
</div>
</div>
</div>
<!-- TIMELINE -->
<div class="tab-panel" id="panel-timeline">
<canvas id="timeline-canvas"></canvas>
</div>
<!-- CONSOLE -->
<div class="tab-panel" id="panel-console">
<div id="console-output"></div>
<div id="console-input-row">
<span class="console-prompt">engram&gt;</span>
<input type="text" id="console-input" placeholder="stats · search query · get <uuid> · decay · consolidate · add node {...} · reload">
</div>
</div>
<!-- CHAT -->
<div class="tab-panel" id="panel-chat" style="display:none;flex-direction:row;overflow:hidden;">
<div id="chat-sessions-col">
<div id="chat-sessions-header">
<button class="btn primary" id="new-chat-btn" onclick="newChat()">+ New Chat</button>
</div>
<div id="chat-session-list"></div>
</div>
<div id="chat-main-col">
<div id="chat-system-row">
<div id="chat-system-toggle" onclick="toggleSystemPrompt()">
<span id="chat-system-arrow"></span> System Prompt
<select id="chat-model-select" onclick="event.stopPropagation()" onchange="event.stopPropagation()">
<option value="claude-sonnet-4-5">claude-sonnet-4-5</option>
<option value="claude-haiku-4-5">claude-haiku-4-5</option>
<option value="claude-opus-4-5">claude-opus-4-5</option>
</select>
</div>
<textarea id="chat-system-textarea" rows="3">You are Neuron, a CGI (Cultivated General Intelligence). You have full access to:
- The filesystem via bash and file tools
- The Engram memory graph (use engram_create_node and engram_search)
- Bash execution for any task
You are Will Anderson's primary AI agent. Be direct. Be precise. Help him build things.</textarea>
</div>
<div id="chat-messages">
<div class="chat-empty-state" id="chat-empty">
<div class="chat-empty-icon"></div>
<div>Start a conversation or select one from the sidebar</div>
</div>
</div>
<div id="chat-input-row">
<textarea id="chat-textarea" placeholder="Message Neuron..." rows="1"></textarea>
<button id="chat-tts-toggle" title="Auto-TTS" onclick="toggleAutoTTS()">🔊</button>
<button id="chat-send-btn" onclick="sendChat()" title="Send"></button>
</div>
</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>
// ═══════════════════════════════════════════════════════════
// API CONFIGURATION
// ═══════════════════════════════════════════════════════════
const ENGRAM_URL = 'http://localhost:8742';
const PROXY_URL = 'http://localhost:8080';
async function engramFetch(path, opts = {}) {
const res = await fetch(ENGRAM_URL + path, opts);
if (!res.ok) throw new Error(`${path}${res.status}`);
return res.json();
}
async function proxyFetch(path, opts = {}) {
const res = await fetch(PROXY_URL + path, opts);
if (!res.ok) throw new Error(`proxy ${path}${res.status}`);
return res.json();
}
// ─── Node content decode ──────────────────────────────────
function decodeContent(raw) {
if (!raw) return '';
if (typeof raw === 'string') return raw;
if (Array.isArray(raw)) {
try { return new TextDecoder().decode(new Uint8Array(raw)); } catch { return String(raw); }
}
return String(raw);
}
// ─── Normalize engram node → studio node ─────────────────
function normalizeNode(n) {
const content = decodeContent(n.content);
const tier = n.tier || 'Working';
const type = n.node_type || 'Memory';
const idStr = n.id || '';
return {
id: idStr,
type,
tier,
content,
importance: n.importance || 0.5,
activationCount: n.activation_count || 1,
lastActivated: n.last_activated || Date.now(),
salience: n.salience || n.importance || 0.5,
embedding: null,
};
}
// ─── Random embedding for local graph physics ─────────────
function randomEmbedding(dim = 8) {
const v = Array.from({length: dim}, () => (Math.random() * 2 - 1));
const mag = Math.sqrt(v.reduce((s, x) => s + x*x, 0));
return v.map(x => x / mag);
}
function randomEngramEmbedding(dim = 384) {
const v = Array.from({length: dim}, () => (Math.random() * 2 - 1));
const mag = Math.sqrt(v.reduce((s, x) => s + x*x, 0));
return v.map(x => x / mag);
}
// 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',
Associated: '#38bdf8',
Causal: '#f59e0b',
Temporal: '#6b7280',
Hierarchical: '#8b5cf6',
Contradicts: '#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 — backed by engram-server
// ═══════════════════════════════════════════════════════════
let DB = {
nodes: [],
edges: [],
};
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 cosineSim(a, b) {
let dot = 0;
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
return Math.max(0, dot);
}
// ─── Load nodes from engram-server ───────────────────────
async function loadEngramData() {
try {
// Get stats first
const stats = await engramFetch('/stats');
document.getElementById('stat-nodes').textContent = stats.nodes || 0;
document.getElementById('stat-edges').textContent = stats.edges || 0;
document.getElementById('mode-badge').textContent = 'LIVE';
// Load all nodes via list endpoint (faster than embedding search)
const rawNodes = await engramFetch('/nodes/list');
DB.nodes = rawNodes.map(normalizeNode);
DB.nodes.forEach(n => { n.embedding = randomEmbedding(); });
// Load edges for each node
DB.edges = [];
const edgeSet = new Set();
for (const n of DB.nodes.slice(0, 30)) { // cap to avoid hammering server
try {
const edges = await engramFetch(`/nodes/${n.id}/edges`);
for (const e of (Array.isArray(edges) ? edges : [])) {
const key = `${e.from_id}-${e.to_id}`;
if (!edgeSet.has(key)) {
edgeSet.add(key);
DB.edges.push({
from: e.from_id,
to: e.to_id,
relation: e.relation || 'Associated',
weight: e.weight || 0.5,
});
}
}
} catch {}
}
updateStats();
renderNodesTable();
initGraph();
if (DB.nodes.length === 0) {
showToast('No nodes in engram-server — add some!', 'info');
consolePrint('system', `Connected to engram-server. Database is empty.\nUse "add node {...}" to create nodes.`, 'info');
} else {
consolePrint('system', `Connected to engram-server.\nLoaded ${DB.nodes.length} nodes, ${DB.edges.length} edges.`, 'info');
}
} catch (err) {
document.getElementById('mode-badge').textContent = 'OFFLINE';
console.error('Failed to connect to engram-server:', err);
// Fall back to empty state — still show UI
updateStats();
renderNodesTable();
initGraph();
consolePrint('system', `Could not connect to engram-server at ${ENGRAM_URL}.\nError: ${err.message}\nMake sure the server is running.`, 'error');
}
}
// ═══════════════════════════════════════════════════════════
// SPREADING ACTIVATION — local fallback for graph animation
// ═══════════════════════════════════════════════════════════
function activateLocal(seedIds, queryEmbedding, maxDepth = 3, limit = 10) {
const visited = new Map();
const results = [];
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 });
});
seedIds.forEach(id => {
if (getNode(id)) { visited.set(id, 1.0); }
});
const pq = seedIds.map(id => ({ id, strength: 1.0, depth: 0 }));
while (pq.length > 0) {
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;
for (const { to, weight } of (adj.get(id) || [])) {
const target = getNode(to);
if (!target) continue;
const sal = computeSalience(target.importance, target.lastActivated, target.activationCount);
const newStrength = strength * weight * Math.min(sal, 2.0);
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 });
}
}
}
return results
.sort((a, b) => b.strength - a.strength)
.filter((r, i, arr) => arr.findIndex(x => x.id === r.id) === i)
.slice(0, limit);
}
// ═══════════════════════════════════════════════════════════
// 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 — real API calls
// ═══════════════════════════════════════════════════════════
async function runActivate() {
const seedSel = document.getElementById('seed-select');
let seedIds = Array.from(seedSel.selectedOptions).map(o => o.value);
if (seedIds.length === 0 && DB.nodes.length > 0) {
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];
}
if (seedIds.length === 0) { showToast('No nodes to activate', 'info'); return; }
try {
// Use local activation for graph animation (server requires UUIDs + real embeddings)
const localResults = activateLocal(seedIds, null, 3, 12);
animateActivation(localResults);
// Try server activation too
const embedding = randomEngramEmbedding();
const uuids = seedIds.filter(id => /^[0-9a-f-]{36}$/.test(id));
if (uuids.length > 0) {
const data = await engramFetch('/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seeds: uuids, query_embedding: embedding, max_depth: 3, limit: 12 }),
});
const serverResults = (data.results || []).map(r => ({
id: r.node.id,
strength: r.activation_strength,
node: normalizeNode(r.node),
}));
// Merge into DB
for (const r of serverResults) {
if (!getNode(r.id)) {
DB.nodes.push({ ...r.node, embedding: randomEmbedding() });
}
}
const list = document.getElementById('results-list');
list.innerHTML = serverResults.map(r => {
const n = getNode(r.id) || r.node;
return `<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${escHtml(n.id)}'))">
<div class="result-header">
<span class="result-id">${escHtml(n.id.slice(0,12))} · ${n.type}</span>
<span class="result-score">${r.strength.toFixed(3)}</span>
</div>
<div class="result-content">${escHtml(n.content.slice(0,80))}</div>
</div>`;
}).join('') || '<div class="empty-state">No results</div>';
showToast(`Server activation: ${serverResults.length} nodes`, 'success');
} else {
// Show local results
const list = document.getElementById('results-list');
list.innerHTML = localResults.map(r => {
const n = r.node;
return `<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${escHtml(n.id)}'))">
<div class="result-header">
<span class="result-id">${escHtml(n.id)} · ${n.type}</span>
<span class="result-score">${r.strength.toFixed(3)}</span>
</div>
<div class="result-content">${escHtml(n.content.slice(0,80))}</div>
</div>`;
}).join('') || '<div class="empty-state">No results</div>';
showToast(`Activated ${localResults.length} nodes`, 'success');
}
updateStats();
} catch (err) {
showToast('Activation error: ' + err.message, 'info');
}
}
async function runSearch() {
const q = document.getElementById('query-input').value.trim();
if (!q) { showToast('Enter a query first', 'info'); return; }
// Primary: text search against in-memory nodes (fast, meaningful)
const q2 = q.toLowerCase();
const words = q2.split(/\s+/).filter(Boolean);
const scored = DB.nodes
.map(n => {
const c = n.content.toLowerCase();
// Score: exact phrase match > all words > any word
let score = 0;
if (c.includes(q2)) score = 1.0;
else {
const hits = words.filter(w => c.includes(w)).length;
score = hits / words.length;
}
return { n, score };
})
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 20);
activatedIds = new Set(scored.map(x => x.n.id));
const list = document.getElementById('results-list');
list.innerHTML = scored.map(({ n, score }) =>
`<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${escHtml(n.id)}'))">
<div class="result-header">
<span class="result-id">${escHtml(n.id.slice(0,12))}</span>
<span class="result-score">${score.toFixed(2)}</span>
</div>
<div class="result-content">${escHtml(n.content.slice(0,80))}</div>
</div>`
).join('') || '<div class="empty-state">No matches for "${escHtml(q)}"</div>';
updateStats();
showToast(`Found ${scored.length} nodes`, 'info');
// If no local results, try to load more from server (may find nodes not yet in memory)
if (scored.length === 0) {
try {
const allNodes = await engramFetch('/nodes/list');
for (const raw of allNodes) {
const n = normalizeNode(raw);
if (!getNode(n.id)) { DB.nodes.push({ ...n, embedding: randomEmbedding() }); }
}
// Re-run local search with fresh data
const rescored = DB.nodes
.map(n => {
const c = n.content.toLowerCase();
const hits = words.filter(w => c.includes(w)).length;
return { n, score: hits / words.length };
})
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 20);
if (rescored.length > 0) {
activatedIds = new Set(rescored.map(x => x.n.id));
list.innerHTML = rescored.map(({ n, score }) =>
`<div class="result-item tier-${n.tier}" onclick="selectNode(findGNode('${escHtml(n.id)}'))">
<div class="result-header">
<span class="result-id">${escHtml(n.id.slice(0,12))}</span>
<span class="result-score">${score.toFixed(2)}</span>
</div>
<div class="result-content">${escHtml(n.content.slice(0,80))}</div>
</div>`
).join('');
updateStats();
showToast(`Found ${rescored.length} nodes (refreshed)`, 'info');
}
} catch {}
}
}
async function runDecay() {
try {
const data = await engramFetch('/decay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ factor: 0.95 }),
});
// Reflect decay locally
DB.nodes.forEach(n => { n.importance = Math.max(0.1, n.importance * 0.95); });
updateStats();
renderNodesTable();
showToast(`Decay applied — ${data.nodes_updated || 'all'} nodes updated`, 'info');
} catch (err) {
DB.nodes.forEach(n => { n.importance = Math.max(0.1, n.importance * 0.95); });
updateStats();
renderNodesTable();
showToast('Decay applied (local)', 'info');
}
}
async function runConsolidate() {
try {
const data = await engramFetch('/consolidate', { method: 'POST' });
const promoted = data.promoted || 0;
// Reload nodes to get updated tiers
await loadEngramData();
if (promoted > 0) {
showToast(`Consolidated: ${promoted} node${promoted>1?'s':''} promoted Episodic → Semantic`, 'success');
} else {
showToast('No nodes met consolidation criteria', 'info');
}
} catch (err) {
showToast('Consolidate error: ' + err.message, 'info');
}
}
// ═══════════════════════════════════════════════════════════
// 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 day = 86400000; // ms per day
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;
}
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 = '';
}
}
});
async 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 <uuid> [...]', 'error'); return; }
try {
const embedding = randomEngramEmbedding();
const uuids = ids.filter(id => /^[0-9a-f-]{36}$/.test(id));
if (uuids.length > 0) {
const data = await engramFetch('/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seeds: uuids, query_embedding: embedding, max_depth: 3, limit: 10 }),
});
const results = (data.results || []).map(r => ({
id: r.node.id,
strength: +r.activation_strength.toFixed(4),
tier: r.node.tier,
type: r.node.node_type,
hops: r.hops,
}));
// Animate local graph
const localResults = activateLocal(uuids, null, 3, 10);
animateActivation(localResults);
consolePrint(cmd, JSON.stringify(results, null, 2), 'success');
} else {
// Local IDs fallback
const results = activateLocal(ids, null, 3, 10);
animateActivation(results);
consolePrint(cmd, JSON.stringify(results.map(r => ({id:r.id,strength:+r.strength.toFixed(4),tier:r.node.tier})), null, 2), 'success');
}
} catch(err) {
consolePrint(cmd, 'Error: ' + err.message, 'error');
}
}
else if (verb === 'search') {
const q = parts.slice(1).join(' ');
if (!q) { consolePrint(cmd, 'Usage: search <query text>', 'error'); return; }
try {
const embedding = randomEngramEmbedding();
const data = await engramFetch('/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ embedding, limit: 20 }),
});
const results = (data.results || []).map(r => ({
id: r.node.id,
score: +r.score.toFixed(4),
tier: r.node.tier,
type: r.node.node_type,
content: decodeContent(r.node.content).slice(0, 60) + '...',
}));
consolePrint(cmd, JSON.stringify(results, null, 2), 'info');
} catch(err) {
const q2 = q.toLowerCase();
const matches = DB.nodes.filter(n => n.content.toLowerCase().includes(q2));
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') {
try {
const data = await engramFetch('/decay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ factor: 0.95 }),
});
DB.nodes.forEach(n => { n.importance = Math.max(0.1, n.importance * 0.95); });
updateStats();
consolePrint(cmd, JSON.stringify({ status:'ok', nodes_updated: data.nodes_updated }), 'success');
} catch(err) {
consolePrint(cmd, 'Error: ' + err.message, 'error');
}
}
else if (verb === 'consolidate') {
try {
const data = await engramFetch('/consolidate', { method: 'POST' });
await loadEngramData();
consolePrint(cmd, JSON.stringify({ status:'ok', promoted: data.promoted }), 'success');
} catch(err) {
consolePrint(cmd, 'Error: ' + err.message, 'error');
}
}
else if (verb === 'add' && parts[1] === 'node') {
try {
const jsonStr = cmd.slice(cmd.indexOf('{'));
const data = JSON.parse(jsonStr);
const id = await addNodeFromData(data);
consolePrint(cmd, JSON.stringify({status:'ok', id}), 'success');
} catch(err) {
consolePrint(cmd, 'Error: ' + err.message + '\nUsage: add node {"content":"...","type":"Concept","tier":"Semantic","importance":0.7}', 'error');
}
}
else if (verb === 'stats') {
try {
const data = await engramFetch('/stats');
consolePrint(cmd, JSON.stringify(data, null, 2), 'info');
} catch(err) {
consolePrint(cmd, JSON.stringify({nodes: DB.nodes.length, edges: DB.edges.length}), 'info');
}
}
else if (verb === 'get') {
const id = parts[1];
if (!id) { consolePrint(cmd, 'Usage: get <uuid>', 'error'); return; }
try {
const data = await engramFetch(`/nodes/${id}`);
const n = normalizeNode(data);
consolePrint(cmd, JSON.stringify({...n, content: n.content}, null, 2), 'info');
} catch(err) {
consolePrint(cmd, 'Error: ' + err.message, 'error');
}
}
else if (verb === 'nodes') {
consolePrint(cmd, JSON.stringify(DB.nodes.map(n => ({id:n.id,tier:n.tier,type:n.type,salience:+computeSalience(n.importance,n.lastActivated,n.activationCount).toFixed(3),content:n.content.slice(0,60)})), null, 2), 'info');
}
else if (verb === 'edges') {
consolePrint(cmd, JSON.stringify(DB.edges, null, 2), 'info');
}
else if (verb === 'reload') {
consolePrint(cmd, 'Reloading from engram-server...', 'info');
await loadEngramData();
consolePrint(cmd, JSON.stringify({status:'ok', nodes: DB.nodes.length, edges: DB.edges.length}), 'success');
}
else if (verb === 'help') {
consolePrint(cmd, `Commands:
activate <uuid> [...] — spread activation from seed nodes
search <query> — semantic search (random embedding proxy)
decay — apply salience decay (factor 0.95)
consolidate — promote Episodic → Semantic
add node <json> — add a new node
get <uuid> — get node by ID
stats — database statistics
nodes — list loaded nodes
edges — list loaded edges
reload — reload data from engram-server
help — this message`, 'info');
}
else {
consolePrint(cmd, `Unknown command: ${verb}. Type "help" for available commands.`, 'error');
}
}
// Welcome message — real data loaded async
consolePrint('system', `Engram Studio v0.2.0 — Connecting to ${ENGRAM_URL}...
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 = '';
}
async function addNodeFromData(data) {
const embedding = randomEngramEmbedding();
const contentBytes = Array.from(new TextEncoder().encode(data.content || ''));
const body = {
node_type: data.type || data.node_type || 'Concept',
embedding,
content: contentBytes,
tier: data.tier || 'Working',
importance: data.importance || 0.5,
};
let id;
try {
const result = await engramFetch('/nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
id = result.id;
} catch (err) {
// Fallback: local only
id = crypto.randomUUID();
showToast('Server unavailable — added locally only', 'info');
}
const node = {
id,
type: data.type || data.node_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);
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 ${String(id).slice(0,8)}… 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;
// flex-based panels
if (p.id === 'panel-swarm' || p.id === 'panel-chat') {
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(); }
if (name === 'chat') { loadChatSessions(); document.getElementById('chat-textarea').focus(); }
}
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();
}
});
// Load real data from engram-server on startup
loadEngramData();
// ═══════════════════════════════════════════════════════════
// CHAT — Streaming chat with tool use
// ═══════════════════════════════════════════════════════════
let chatSessions = [];
let activeChatSession = null;
let chatMessages = []; // current conversation messages
let chatAutoTTS = false;
let chatStreaming = false;
function toggleAutoTTS() {
chatAutoTTS = !chatAutoTTS;
document.getElementById('chat-tts-toggle').classList.toggle('active', chatAutoTTS);
}
function toggleSystemPrompt() {
const ta = document.getElementById('chat-system-textarea');
const arrow = document.getElementById('chat-system-arrow');
ta.classList.toggle('open');
arrow.textContent = ta.classList.contains('open') ? '▼' : '▶';
}
async function loadChatSessions() {
try {
const data = await proxyFetch('/api/conversation');
chatSessions = data.sessions || [];
renderChatSessionList();
} catch {}
}
function renderChatSessionList() {
const el = document.getElementById('chat-session-list');
if (chatSessions.length === 0) {
el.innerHTML = '<div class="empty-state" style="font-size:10px;padding:16px;">No conversations yet</div>';
return;
}
el.innerHTML = chatSessions.map(s => `
<div class="chat-session-item ${activeChatSession === s.session_id ? 'active' : ''}"
onclick="loadChatSession('${escHtml(s.session_id)}')">
${escHtml(s.title || 'Conversation')}
</div>
`).join('');
}
async function loadChatSession(sessionId) {
activeChatSession = sessionId;
try {
const data = await proxyFetch(`/api/conversation?session=${encodeURIComponent(sessionId)}`);
chatMessages = data.messages || [];
renderChatMessages();
renderChatSessionList();
} catch(err) {
showToast('Failed to load session: ' + err.message, 'info');
}
}
function newChat() {
activeChatSession = crypto.randomUUID();
chatMessages = [];
renderChatMessages();
renderChatSessionList();
document.getElementById('chat-textarea').focus();
}
// ─── Minimal markdown renderer ────────────────────────────
function renderMarkdown(text) {
let html = escHtml(text);
// Code blocks
html = html.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) =>
`<pre><code>${code}</code></pre>`);
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Headings
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Blockquote
html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
// HR
html = html.replace(/^---$/gm, '<hr>');
// Lists
html = html.replace(/^\* (.+)$/gm, '<li>$1</li>');
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>');
html = html.replace(/(<li>.*<\/li>)/gs, '<ul>$1</ul>');
// Paragraphs (double newline)
html = html.replace(/\n\n/g, '</p><p>');
html = html.replace(/\n/g, '<br>');
html = '<p>' + html + '</p>';
// Clean up empty paragraphs
html = html.replace(/<p><\/p>/g, '');
html = html.replace(/<p>(<h[123]>)/g, '$1');
html = html.replace(/(<\/h[123]>)<\/p>/g, '$1');
html = html.replace(/<p>(<pre>)/g, '$1');
html = html.replace(/(<\/pre>)<\/p>/g, '$1');
html = html.replace(/<p>(<ul>)/g, '$1');
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
html = html.replace(/<p>(<hr>)<\/p>/g, '$1');
html = html.replace(/<p>(<blockquote>)/g, '$1');
html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
return html;
}
function renderChatMessages() {
const container = document.getElementById('chat-messages');
const empty = document.getElementById('chat-empty');
if (chatMessages.length === 0) {
empty.style.display = 'flex';
// Remove all message elements
Array.from(container.children).forEach(c => {
if (c !== empty) c.remove();
});
return;
}
empty.style.display = 'none';
// Remove all non-empty elements
Array.from(container.children).forEach(c => {
if (c !== empty) c.remove();
});
for (const msg of chatMessages) {
const el = createMessageEl(msg);
container.appendChild(el);
}
container.scrollTop = container.scrollHeight;
}
function createMessageEl(msg) {
const div = document.createElement('div');
div.className = `chat-msg ${msg.role}`;
const bubble = document.createElement('div');
bubble.className = 'chat-msg-bubble';
if (msg.role === 'assistant') {
bubble.className += ' chat-md';
bubble.innerHTML = renderMarkdown(msg.content || '');
} else {
bubble.textContent = msg.content || '';
}
const meta = document.createElement('div');
meta.className = 'chat-msg-meta';
meta.innerHTML = `<span class="chat-msg-role">${msg.role}</span>`;
if (msg.role === 'assistant' && msg.content) {
const ttsBtn = document.createElement('button');
ttsBtn.className = 'chat-tts-btn';
ttsBtn.title = 'Read aloud';
ttsBtn.textContent = '🔊';
ttsBtn.onclick = () => playTTS(msg.content);
meta.appendChild(ttsBtn);
}
div.appendChild(bubble);
div.appendChild(meta);
return div;
}
function appendThinking() {
const container = document.getElementById('chat-messages');
document.getElementById('chat-empty').style.display = 'none';
const el = document.createElement('div');
el.id = 'chat-thinking';
el.className = 'chat-thinking';
el.innerHTML = `Neuron is thinking <div class="chat-thinking-dots"><span></span><span></span><span></span></div>`;
container.appendChild(el);
container.scrollTop = container.scrollHeight;
}
function removeThinking() {
document.getElementById('chat-thinking')?.remove();
}
// Streaming assistant message
function appendStreamingMsg() {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = 'chat-msg assistant';
div.id = 'chat-streaming-msg';
const bubble = document.createElement('div');
bubble.className = 'chat-msg-bubble chat-md';
bubble.id = 'chat-streaming-bubble';
div.appendChild(bubble);
container.appendChild(div);
container.scrollTop = container.scrollHeight;
return bubble;
}
function appendToolBlock(name, input) {
const container = document.getElementById('chat-messages');
const blockId = 'tool-' + Date.now();
const block = document.createElement('div');
block.className = 'tool-block';
block.id = blockId;
block.innerHTML = `
<div class="tool-block-header" onclick="toggleToolBlock('${blockId}')">
<span style="color:var(--text3);font-size:11px;">⚙</span>
<span class="tool-block-name">${escHtml(name)}</span>
<span class="tool-block-toggle">▶ expand</span>
</div>
<div class="tool-block-body" id="${blockId}-body">
<strong>Input:</strong>\n${escHtml(JSON.stringify(input, null, 2))}
</div>
<div class="tool-result-content" id="${blockId}-result"></div>
`;
container.appendChild(block);
container.scrollTop = container.scrollHeight;
return blockId;
}
function setToolResult(blockId, content) {
const el = document.getElementById(blockId + '-result');
if (!el) return;
el.textContent = content.slice(0, 500) + (content.length > 500 ? '…' : '');
el.classList.add('show');
}
function toggleToolBlock(blockId) {
const body = document.getElementById(blockId + '-body');
const toggle = document.querySelector(`#${blockId} .tool-block-toggle`);
if (!body) return;
body.classList.toggle('open');
if (toggle) toggle.textContent = body.classList.contains('open') ? '▼ collapse' : '▶ expand';
}
async function playTTS(text) {
try {
const r = await fetch(PROXY_URL + '/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.slice(0, 2000) }),
});
if (!r.ok) return;
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play();
audio.onended = () => URL.revokeObjectURL(url);
} catch {}
}
async function sendChat() {
if (chatStreaming) return;
const textarea = document.getElementById('chat-textarea');
const text = textarea.value.trim();
if (!text) return;
if (!activeChatSession) activeChatSession = crypto.randomUUID();
textarea.value = '';
textarea.style.height = 'auto';
// Add user message
chatMessages.push({ role: 'user', content: text });
const container = document.getElementById('chat-messages');
document.getElementById('chat-empty').style.display = 'none';
const userEl = createMessageEl({ role: 'user', content: text });
container.appendChild(userEl);
container.scrollTop = container.scrollHeight;
chatStreaming = true;
document.getElementById('chat-send-btn').disabled = true;
appendThinking();
const systemPrompt = document.getElementById('chat-system-textarea').value;
const model = document.getElementById('chat-model-select').value;
let streamingBubble = null;
let assistantText = '';
const toolBlockMap = {}; // name -> blockId (last one)
try {
const r = await fetch(PROXY_URL + '/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: chatMessages.slice(0, -1).concat([{ role: 'user', content: text }]),
sessionId: activeChatSession,
system: systemPrompt,
model,
}),
});
removeThinking();
streamingBubble = appendStreamingMsg();
const reader = r.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
let evt;
try { evt = JSON.parse(data); } catch { continue; }
if (evt.type === 'delta' && evt.text) {
assistantText += evt.text;
streamingBubble.innerHTML = renderMarkdown(assistantText);
container.scrollTop = container.scrollHeight;
}
if (evt.type === 'tool_call') {
const blockId = appendToolBlock(evt.name, evt.input);
toolBlockMap[evt.name + JSON.stringify(evt.input)] = blockId;
}
if (evt.type === 'tool_result') {
// Find the most recent tool block for this tool
const key = Object.keys(toolBlockMap).find(k => k.startsWith(evt.name));
if (key) setToolResult(toolBlockMap[key], String(evt.content || ''));
}
}
}
} catch (err) {
removeThinking();
if (!streamingBubble) streamingBubble = appendStreamingMsg();
streamingBubble.innerHTML = `<span style="color:#f87171">[Error: ${escHtml(err.message)}]</span>`;
}
// Finalize
document.getElementById('chat-streaming-msg')?.removeAttribute('id');
document.getElementById('chat-streaming-bubble')?.removeAttribute('id');
if (assistantText) {
chatMessages.push({ role: 'assistant', content: assistantText });
if (chatAutoTTS) playTTS(assistantText);
// Add TTS button to the finalized message
const lastMsg = container.querySelector('.chat-msg.assistant:last-child');
if (lastMsg) {
const meta = document.createElement('div');
meta.className = 'chat-msg-meta';
meta.innerHTML = `<span class="chat-msg-role">assistant</span>`;
const ttsBtn = document.createElement('button');
ttsBtn.className = 'chat-tts-btn';
ttsBtn.title = 'Read aloud';
ttsBtn.textContent = '🔊';
ttsBtn.onclick = () => playTTS(assistantText);
meta.appendChild(ttsBtn);
lastMsg.appendChild(meta);
}
}
chatStreaming = false;
document.getElementById('chat-send-btn').disabled = false;
// Update session list
await loadChatSessions();
}
// Textarea auto-resize + Enter to send
document.getElementById('chat-textarea').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChat();
}
});
document.getElementById('chat-textarea').addEventListener('input', e => {
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px';
});
// ═══════════════════════════════════════════════════════════
// SWARM — Peer management & distributed activation
// ═══════════════════════════════════════════════════════════
// In-memory peer store (demo mode — in live mode this syncs with /sync/peers)
let SWARM_PEERS = [];
let SWARM_RESULTS = null;
// Track remote-sourced node IDs for graph rendering
let remotePeerNodeIds = new Set();
function renderPeerList() {
const container = document.getElementById('peer-list');
if (SWARM_PEERS.length === 0) {
container.innerHTML = '<div class="empty-state" style="font-size:10px;">No peers configured. Add one to start syncing.</div>';
return;
}
container.innerHTML = SWARM_PEERS.map((p, idx) => {
const statusClass = p.online ? 'online' : 'offline';
const lastSync = p.last_sync_at > 0
? new Date(p.last_sync_at).toLocaleTimeString()
: 'never';
const tiers = p.sync_tiers.join(', ') || 'Semantic';
return `
<div class="peer-card ${statusClass}">
<div style="display:flex;align-items:center;margin-bottom:4px;">
<span class="peer-status-dot ${statusClass}"></span>
<span class="peer-name">${escHtml(p.name)}</span>
</div>
<div class="peer-addr">${escHtml(p.address)}</div>
<div class="peer-meta">
<span class="peer-tag tier">${escHtml(tiers)}</span>
${p.trusted ? '<span class="peer-tag trusted">trusted</span>' : ''}
<span class="peer-tag sync-time">synced ${lastSync}</span>
</div>
<div class="peer-actions">
<button class="peer-btn" onclick="syncPeer(${idx})">Sync Now</button>
<button class="peer-btn danger" onclick="removePeer(${idx})">Remove</button>
</div>
</div>
`;
}).join('');
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function openAddPeerModal() {
document.getElementById('add-peer-form').style.display = 'block';
document.getElementById('peer-name').focus();
}
function closeAddPeerForm() {
document.getElementById('add-peer-form').style.display = 'none';
}
function savePeer() {
const name = document.getElementById('peer-name').value.trim();
const address = document.getElementById('peer-address').value.trim();
const apiKey = document.getElementById('peer-apikey').value.trim();
const trusted = document.getElementById('peer-trusted').checked;
if (!name || !address) {
showToast('Name and address are required', 'info');
return;
}
const tiers = [];
if (document.getElementById('tier-working').checked) tiers.push('Working');
if (document.getElementById('tier-episodic').checked) tiers.push('Episodic');
if (document.getElementById('tier-semantic').checked) tiers.push('Semantic');
if (document.getElementById('tier-procedural').checked) tiers.push('Procedural');
if (tiers.length === 0) tiers.push('Semantic');
const peer = {
id: crypto.randomUUID(),
name,
address,
api_key: apiKey,
sync_tiers: tiers,
last_sync_at: 0,
trusted,
online: null, // unknown until probed
node_count_contributed: 0,
};
SWARM_PEERS.push(peer);
closeAddPeerForm();
// Clear inputs
document.getElementById('peer-name').value = '';
document.getElementById('peer-address').value = '';
document.getElementById('peer-apikey').value = '';
document.getElementById('tier-semantic').checked = true;
document.getElementById('tier-working').checked = false;
document.getElementById('tier-episodic').checked = false;
document.getElementById('tier-procedural').checked = false;
document.getElementById('peer-trusted').checked = true;
renderPeerList();
showToast(`Peer "${name}" added`, 'success');
// Probe reachability in background
probePeer(SWARM_PEERS.length - 1);
}
function removePeer(idx) {
const p = SWARM_PEERS[idx];
SWARM_PEERS.splice(idx, 1);
renderPeerList();
showToast(`Peer "${p.name}" removed`, 'info');
}
async function probePeer(idx) {
const peer = SWARM_PEERS[idx];
if (!peer) return;
try {
const url = `${peer.address}/sync/delta?since=${Date.now()}&peer_id=local`;
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${peer.api_key}` },
signal: AbortSignal.timeout(5000),
});
peer.online = resp.ok;
} catch {
peer.online = false;
}
renderPeerList();
}
async function syncPeer(idx) {
const peer = SWARM_PEERS[idx];
if (!peer) return;
showToast(`Syncing with ${peer.name}...`, 'info');
try {
// Pull delta from peer
const pullUrl = `${peer.address}/sync/delta?since=${peer.last_sync_at}&peer_id=local`;
const pullResp = await fetch(pullUrl, {
headers: { Authorization: `Bearer ${peer.api_key}` },
signal: AbortSignal.timeout(10000),
});
if (!pullResp.ok) throw new Error(`HTTP ${pullResp.status}`);
const delta = await pullResp.json();
// Apply nodes to our demo DB
let imported = 0;
for (const node of delta.nodes || []) {
if (!peer.sync_tiers.includes(node.tier)) continue;
if (DB.nodes.find(n => n.id === node.id)) continue;
const content = typeof node.content === 'string'
? node.content
: new TextDecoder().decode(new Uint8Array(node.content));
DB.nodes.push({
id: node.id,
type: node.node_type || 'Concept',
tier: node.tier,
content,
importance: node.importance || 0.5,
activationCount: node.activation_count || 1,
lastActivated: node.last_activated || Date.now(),
embedding: randomEmbedding(),
fromPeer: peer.id,
peerName: peer.name,
});
remotePeerNodeIds.add(node.id);
imported++;
}
peer.last_sync_at = Date.now();
peer.online = true;
peer.node_count_contributed += imported;
updateStats();
renderPeerList();
if (document.getElementById('panel-graph').classList.contains('active')) {
initGraph();
}
showToast(`Sync complete — ${imported} nodes from ${peer.name}`, 'success');
} catch (err) {
peer.online = false;
renderPeerList();
showToast(`Sync failed: ${err.message}`, 'info');
}
}
async function runSwarmActivate() {
const seedOptions = Array.from(document.getElementById('seed-select').selectedOptions);
const seeds = seedOptions.length > 0
? seedOptions.map(o => o.value)
: (DB.nodes.length > 0 ? [DB.nodes[0].id] : []);
const includePeers = document.getElementById('swarm-activate-toggle').checked;
const statusBadge = document.getElementById('swarm-status-badge');
const resultsList = document.getElementById('swarm-results-list');
statusBadge.textContent = 'activating...';
resultsList.innerHTML = '<div class="empty-state">Running swarm activation...</div>';
// Local activation first
const localResults = activateLocal(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>