Nico a2bc6347fc v0.13.0: Graph engine, versioned nodes, S3* audit, DB tools, Cytoscape
Architecture:
- Graph engine (engine.py) loads graph definitions, instantiates nodes
- Versioned nodes: input_v1, thinker_v1, output_v1, memorizer_v1, director_v1
- NODE_REGISTRY for dynamic node lookup by name
- Graph API: /api/graph/active, /api/graph/list, /api/graph/switch
- Graph definition: graphs/v1_current.py (7 nodes, 13 edges, 3 edge types)

S3* Audit system:
- Workspace mismatch detection (server vs browser controls)
- Code-without-tools retry (Thinker wrote code but no tool calls)
- Intent-without-action retry (request intent but Thinker only produced text)
- Dashboard feedback: browser sends workspace state on every message
- Sensor continuous comparison on 5s tick

State machines:
- create_machine / add_state / reset_machine / destroy_machine via function calling
- Local transitions (go:) resolve without LLM round-trip
- Button persistence across turns

Database tools:
- query_db tool via pymysql to MariaDB K3s pod (eras2_production)
- Table rendering in workspace (tab-separated parsing)
- Director pre-planning with Opus for complex data requests
- Error retry with corrected SQL

Frontend:
- Cytoscape.js pipeline graph with real-time node animations
- Overlay scrollbars (CSS-only, no reflow)
- Tool call/result trace events
- S3* audit events in trace

Testing:
- 167 integration tests (11 test suites)
- 22 node-level unit tests (test_nodes/)
- Three test levels: node unit, graph integration, scenario

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:18:45 +01:00

802 lines
31 KiB
JavaScript
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.

const msgs = document.getElementById('messages');
const inputEl = document.getElementById('input');
const statusEl = document.getElementById('status');
const traceEl = document.getElementById('trace');
let ws, currentEl;
let _currentDashboard = []; // S3*: tracks what user sees in workspace
let authToken = localStorage.getItem('cog_token');
let authConfig = null;
let cy = null; // Cytoscape instance
// --- Pipeline Graph ---
function initGraph() {
const container = document.getElementById('pipeline-graph');
if (!container) { console.error('[graph] no #pipeline-graph container'); return; }
if (typeof cytoscape === 'undefined') { console.error('[graph] cytoscape not loaded'); return; }
// Force dimensions — flexbox may not have resolved yet
const rect = container.getBoundingClientRect();
const W = rect.width || container.offsetWidth || 900;
const H = rect.height || container.offsetHeight || 180;
console.log('[graph] init', W, 'x', H);
// Layout: group by real data flow
// Col 0: user (external)
// Col 1: input (perceive) + sensor (environment) — both feed INTO the core
// Col 2: director (plans) + thinker (executes) + S3* (audits) — the CORE
// Col 3: output (voice) + ui (dashboard) — RENDER to user
// Col 4: memorizer (remembers) — feeds BACK to core
const mx = W * 0.07;
const cw = (W - mx * 2) / 4;
const row1 = H * 0.25;
const mid = H * 0.5;
const row2 = H * 0.75;
cy = cytoscape({
container,
elements: [
// Col 0 — external
{ data: { id: 'user', label: 'user' }, position: { x: mx, y: mid } },
// Col 1 — perception
{ data: { id: 'input', label: 'input' }, position: { x: mx + cw, y: row1 + 5 } },
{ data: { id: 'sensor', label: 'sensor' }, position: { x: mx + cw, y: row2 - 5 } },
// Col 2 — core (plan + execute + audit)
{ data: { id: 'director', label: 'director' }, position: { x: mx + cw * 1.8, y: row1 - 10 } },
{ data: { id: 'thinker', label: 'thinker' }, position: { x: mx + cw * 2, y: mid } },
{ data: { id: 's3_audit', label: 'S3*' }, position: { x: mx + cw * 1.8, y: row2 + 10 } },
// Col 3 — render
{ data: { id: 'output', label: 'output' }, position: { x: mx + cw * 3, y: row1 + 5 } },
{ data: { id: 'ui', label: 'ui' }, position: { x: mx + cw * 3, y: row2 - 5 } },
// Col 4 — memory (feedback)
{ data: { id: 'memorizer', label: 'memo' }, position: { x: mx + cw * 4, y: mid } },
// Edges — main pipeline
{ data: { id: 'e-user-input', source: 'user', target: 'input' } },
{ data: { id: 'e-input-thinker', source: 'input', target: 'thinker' } },
{ data: { id: 'e-input-output', source: 'input', target: 'output', reflex: true } },
{ data: { id: 'e-thinker-output', source: 'thinker', target: 'output' } },
{ data: { id: 'e-thinker-ui', source: 'thinker', target: 'ui' } },
// Memory feedback loop
{ data: { id: 'e-output-memo', source: 'output', target: 'memorizer' } },
{ data: { id: 'e-memo-director', source: 'memorizer', target: 'director' } },
// Director plans, Thinker executes
{ data: { id: 'e-director-thinker', source: 'director', target: 'thinker' } },
// S3* audit loop
{ data: { id: 'e-thinker-audit', source: 'thinker', target: 's3_audit' } },
{ data: { id: 'e-audit-thinker', source: 's3_audit', target: 'thinker', ctx: true } },
// Context feeds
{ data: { id: 'e-sensor-ctx', source: 'sensor', target: 'thinker', ctx: true } },
],
style: [
{ selector: 'node', style: {
'label': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'font-size': '10px',
'font-family': 'system-ui, sans-serif',
'font-weight': 700,
'color': '#aaa',
'background-color': '#222',
'border-width': 2,
'border-color': '#444',
'width': 48,
'height': 48,
'transition-property': 'background-color, border-color, width, height',
'transition-duration': '0.3s',
}},
// Node colors
{ selector: '#user', style: { 'border-color': '#666', 'color': '#888' } },
{ selector: '#input', style: { 'border-color': '#f59e0b', 'color': '#f59e0b' } },
{ selector: '#thinker', style: { 'border-color': '#f97316', 'color': '#f97316' } },
{ selector: '#output', style: { 'border-color': '#10b981', 'color': '#10b981' } },
{ selector: '#ui', style: { 'border-color': '#10b981', 'color': '#10b981' } },
{ selector: '#memorizer', style: { 'border-color': '#a855f7', 'color': '#a855f7' } },
{ selector: '#director', style: { 'border-color': '#a855f7', 'color': '#a855f7' } },
{ selector: '#sensor', style: { 'border-color': '#3b82f6', 'color': '#3b82f6', 'width': 36, 'height': 36, 'font-size': '9px' } },
{ selector: '#s3_audit', style: { 'border-color': '#ef4444', 'color': '#ef4444', 'width': 32, 'height': 32, 'font-size': '8px', 'border-style': 'dashed' } },
// Active node (pulsed)
{ selector: 'node.active', style: {
'background-color': '#333',
'border-width': 3,
'width': 56,
'height': 56,
}},
{ selector: '#input.active', style: { 'background-color': '#3d2800', 'border-color': '#fbbf24' } },
{ selector: '#thinker.active', style: { 'background-color': '#3d1f00', 'border-color': '#fb923c' } },
{ selector: '#output.active', style: { 'background-color': '#003d2a', 'border-color': '#34d399' } },
{ selector: '#ui.active', style: { 'background-color': '#003d2a', 'border-color': '#34d399' } },
{ selector: '#memorizer.active', style: { 'background-color': '#2a003d', 'border-color': '#c084fc' } },
{ selector: '#director.active', style: { 'background-color': '#2a003d', 'border-color': '#c084fc' } },
{ selector: '#sensor.active', style: { 'background-color': '#00203d', 'border-color': '#60a5fa', 'width': 44, 'height': 44 } },
{ selector: '#s3_audit.active', style: { 'background-color': '#3d0000', 'border-color': '#f87171', 'width': 40, 'height': 40 } },
// Edges
{ selector: 'edge', style: {
'width': 1.5,
'line-color': '#333',
'target-arrow-color': '#333',
'target-arrow-shape': 'triangle',
'arrow-scale': 0.7,
'curve-style': 'bezier',
'transition-property': 'line-color, target-arrow-color, width',
'transition-duration': '0.3s',
}},
{ selector: 'edge[?reflex]', style: { 'line-style': 'dashed', 'line-dash-pattern': [4, 4], 'line-color': '#2a2a2a' } },
{ selector: 'edge[?ctx]', style: { 'line-style': 'dotted', 'line-color': '#1a1a2e', 'width': 1 } },
{ selector: 'edge.active', style: { 'line-color': '#888', 'target-arrow-color': '#888', 'width': 2.5 } },
],
layout: { name: 'preset' },
userZoomingEnabled: false,
userPanningEnabled: false,
boxSelectionEnabled: false,
autoungrabify: true,
});
}
function pulseNode(id) {
if (!cy) return;
const node = cy.getElementById(id);
if (!node.length) return;
node.addClass('active');
setTimeout(() => node.removeClass('active'), 1500);
}
function flashEdge(sourceId, targetId) {
if (!cy) return;
const edge = cy.edges().filter(e => e.data('source') === sourceId && e.data('target') === targetId);
if (!edge.length) return;
edge.addClass('active');
setTimeout(() => edge.removeClass('active'), 1000);
}
function graphAnimate(event, node) {
if (!cy) return;
switch (event) {
case 'perceived':
pulseNode('input'); flashEdge('user', 'input');
break;
case 'decided':
pulseNode('thinker'); flashEdge('input', 'thinker'); flashEdge('thinker', 'output');
break;
case 'reflex_path':
pulseNode('input'); flashEdge('input', 'output');
break;
case 'streaming':
if (node === 'output') pulseNode('output');
break;
case 'controls':
case 'machine_created':
case 'machine_transition':
case 'machine_state_added':
case 'machine_reset':
case 'machine_destroyed':
pulseNode('ui'); flashEdge('thinker', 'ui');
break;
case 'updated':
pulseNode('memorizer'); flashEdge('output', 'memorizer');
break;
case 'director_updated':
pulseNode('director'); flashEdge('memorizer', 'director');
break;
case 'director_plan':
pulseNode('director'); flashEdge('director', 'thinker');
break;
case 'tick':
pulseNode('sensor');
break;
case 'thinking':
pulseNode('thinker');
break;
case 'tool_call':
pulseNode('thinker'); flashEdge('thinker', 'ui');
break;
case 's3_audit':
pulseNode('s3_audit'); flashEdge('thinker', 's3_audit'); flashEdge('s3_audit', 'thinker');
break;
}
}
// --- OIDC Auth ---
async function initAuth() {
try {
const resp = await fetch('/auth/config');
authConfig = await resp.json();
} catch { authConfig = { enabled: false }; }
if (!authConfig.enabled) { connect(); return; }
// Handle OIDC callback
if (location.pathname === '/callback') {
const params = new URLSearchParams(location.search);
const code = params.get('code');
const verifier = sessionStorage.getItem('pkce_verifier');
if (code && verifier) {
const tokenResp = await fetch(authConfig.issuer + '/oauth/v2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: authConfig.clientId,
code,
redirect_uri: location.origin + '/callback',
code_verifier: verifier,
}),
});
const tokens = await tokenResp.json();
if (tokens.access_token) {
// Store access token for userinfo, id_token for JWT validation
localStorage.setItem('cog_access_token', tokens.access_token);
authToken = tokens.id_token || tokens.access_token;
localStorage.setItem('cog_token', authToken);
sessionStorage.removeItem('pkce_verifier');
}
}
history.replaceState(null, '', '/');
}
if (authToken) {
connect();
} else {
showLogin();
}
}
function showLogin() {
statusEl.textContent = 'not authenticated';
statusEl.style.color = '#f59e0b';
const btn = document.createElement('button');
btn.textContent = 'Log in with loop42';
btn.className = 'login-btn';
btn.onclick = startLogin;
document.getElementById('input-bar').replaceChildren(btn);
}
async function startLogin() {
// PKCE: generate code_verifier + code_challenge
const verifier = randomString(64);
sessionStorage.setItem('pkce_verifier', verifier);
const encoder = new TextEncoder();
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const params = new URLSearchParams({
response_type: 'code',
client_id: authConfig.clientId,
redirect_uri: location.origin + '/callback',
scope: 'openid profile email',
code_challenge: challenge,
code_challenge_method: 'S256',
});
location.href = authConfig.issuer + '/oauth/v2/authorize?' + params;
}
function randomString(len) {
const arr = new Uint8Array(len);
crypto.getRandomValues(arr);
return btoa(String.fromCharCode(...arr)).replace(/[^a-zA-Z0-9]/g, '').slice(0, len);
}
// --- WebSocket ---
let _authFailed = false;
function connect() {
if (_authFailed) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let wsUrl = proto + '//' + location.host + '/ws';
if (authToken) {
const accessToken = localStorage.getItem('cog_access_token') || '';
wsUrl += '?token=' + encodeURIComponent(authToken) + '&access_token=' + encodeURIComponent(accessToken);
}
ws = new WebSocket(wsUrl);
ws.onopen = () => {
statusEl.textContent = 'connected';
statusEl.style.color = '#22c55e';
addTrace('runtime', 'connected', 'ws open');
};
ws.onerror = () => {}; // swallow — onclose handles it
ws.onclose = (e) => {
// 4001 = explicit auth rejection, 1006 = HTTP 403 before upgrade
if (e.code === 4001 || e.code === 1006) {
_authFailed = true;
localStorage.removeItem('cog_token');
localStorage.removeItem('cog_access_token');
authToken = null;
statusEl.textContent = 'session expired';
statusEl.style.color = '#ef4444';
addTrace('runtime', 'auth expired', 'please log in again');
showLogin();
return;
}
statusEl.textContent = 'disconnected';
statusEl.style.color = '#666';
addTrace('runtime', 'disconnected', 'ws closed');
setTimeout(connect, 2000);
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'hud') {
handleHud(data);
} else if (data.type === 'delta') {
if (!currentEl) {
currentEl = addMsg('assistant', '');
currentEl.classList.add('streaming');
}
currentEl.textContent += data.content;
scroll(msgs);
} else if (data.type === 'done') {
if (currentEl) {
currentEl.classList.remove('streaming');
// Render markdown now that streaming is complete
currentEl.innerHTML = renderMarkdown(currentEl.textContent);
}
currentEl = null;
} else if (data.type === 'controls') {
dockControls(data.controls);
}
};
}
function handleHud(data) {
const node = data.node || 'unknown';
const event = data.event || '';
// Animate pipeline graph
graphAnimate(event, node);
if (event === 'context') {
// Update node meter
if (data.tokens !== undefined) {
updateMeter(node, data.tokens, data.max_tokens, data.fill_pct);
}
// Expandable: show message count + token info
const count = (data.messages || []).length;
const tokenInfo = data.tokens ? ` [${data.tokens}/${data.max_tokens}t ${data.fill_pct}%]` : '';
const summary = count + ' msgs' + tokenInfo + ': ' + (data.messages || []).map(m =>
m.role[0].toUpperCase() + ':' + truncate(m.content, 30)
).join(' | ');
const detail = (data.messages || []).map((m, i) =>
i + ' [' + m.role + '] ' + m.content
).join('\n');
addTrace(node, 'context', summary, 'context', detail);
} else if (event === 'perceived') {
// v0.11: Input sends structured analysis, not prose instruction
const text = data.analysis
? Object.entries(data.analysis).map(([k,v]) => k + '=' + v).join(' ')
: (data.instruction || '');
const detail = data.analysis ? JSON.stringify(data.analysis, null, 2) : null;
addTrace(node, 'perceived', text, 'instruction', detail);
} else if (event === 'decided') {
addTrace(node, 'decided', data.instruction, 'instruction');
} else if (event === 'updated' && data.state) {
const pairs = Object.entries(data.state).map(([k, v]) => {
const val = Array.isArray(v) ? v.join(', ') : (v || 'null');
return k + '=' + truncate(val, 25);
}).join(' ');
const detail = JSON.stringify(data.state, null, 2);
addTrace(node, 'state', pairs, 'state', detail);
updateAwarenessState(data.state);
} else if (event === 'process_start') {
addTrace(node, 'run ' + (data.tool || 'python'), truncate(data.code || '', 80), 'instruction', data.code);
showAwarenessProcess(data.pid, data.tool || 'python', data.code || '');
} else if (event === 'process_done') {
addTrace(node, (data.exit_code === 0 ? 'done' : 'failed'), truncate(data.output || '', 80), data.exit_code === 0 ? '' : 'error', data.output);
updateAwarenessProcess(data.pid, data.exit_code === 0 ? 'done' : 'failed', data.output || '', data.elapsed);
} else if (event === 'error') {
addTrace(node, 'error', data.detail || '', 'error');
} else if (event === 's3_audit') {
addTrace(node, 'S3* ' + (data.check || ''), data.detail || '', data.detail && data.detail.includes('failed') ? 'error' : 'instruction');
} else if (event === 'director_plan') {
const steps = (data.steps || []).join(' → ');
addTrace(node, 'plan', data.goal + ': ' + steps, 'instruction', JSON.stringify(data, null, 2));
} else if (event === 'tool_call') {
addTrace(node, 'tool: ' + (data.tool || '?'), data.input || '', 'instruction');
} else if (event === 'tool_result') {
const rows = data.rows !== undefined ? ` (${data.rows} rows)` : '';
addTrace(node, 'result: ' + (data.tool || '?'), truncate(data.output || '', 100) + rows, '', data.output);
} else if (event === 'thinking') {
addTrace(node, 'thinking', data.detail || '');
} else if (event === 'streaming') {
addTrace(node, 'streaming', '');
} else if (event === 'done') {
addTrace(node, 'done', '');
} else if (event === 'tick') {
// Update sensor meter with tick count
const meter = document.getElementById('meter-sensor');
if (meter) {
const text = meter.querySelector('.nm-text');
const deltas = Object.entries(data.deltas || {}).map(([k,v]) => k + '=' + v).join(' ');
text.textContent = 'tick #' + (data.tick || 0) + (deltas ? ' | ' + deltas : '');
}
if (data.deltas && Object.keys(data.deltas).length) {
const deltas = Object.entries(data.deltas).map(([k,v]) => k + '=' + truncate(String(v), 30)).join(' ');
addTrace(node, 'tick #' + data.tick, deltas);
}
updateAwarenessSensors(data.tick || 0, data.deltas || {});
} else if (event === 'started' || event === 'stopped') {
const meter = document.getElementById('meter-sensor');
if (meter) meter.querySelector('.nm-text').textContent = event;
addTrace(node, event, '');
} else {
// Generic fallback
const detail = JSON.stringify(data, null, 2);
addTrace(node, event, '', '', detail);
}
}
function addTrace(node, event, text, cls, detail) {
const line = document.createElement('div');
line.className = 'trace-line' + (detail ? ' expandable' : '');
const ts = new Date().toLocaleTimeString('de-DE', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 1 });
line.innerHTML =
'<span class="trace-ts">' + ts + '</span>' +
'<span class="trace-node ' + esc(node) + '">' + esc(node) + '</span>' +
'<span class="trace-event">' + esc(event) + '</span>' +
'<span class="trace-data' + (cls ? ' ' + cls : '') + '">' + esc(text) + '</span>';
traceEl.appendChild(line);
if (detail) {
const detailEl = document.createElement('div');
detailEl.className = 'trace-detail';
detailEl.textContent = detail;
traceEl.appendChild(detailEl);
line.addEventListener('click', () => detailEl.classList.toggle('open'));
}
scroll(traceEl);
}
function renderControls(controls) {
const container = document.createElement('div');
container.className = 'controls-container';
for (const ctrl of controls) {
if (ctrl.type === 'button') {
const btn = document.createElement('button');
btn.className = 'control-btn';
btn.textContent = ctrl.label;
btn.onclick = () => {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'action', action: ctrl.action, data: ctrl.payload || ctrl.data || {} }));
addTrace('runtime', 'action', ctrl.action);
}
};
container.appendChild(btn);
} else if (ctrl.type === 'table') {
const table = document.createElement('table');
table.className = 'control-table';
// Header
if (ctrl.columns) {
const thead = document.createElement('tr');
for (const col of ctrl.columns) {
const th = document.createElement('th');
th.textContent = col;
thead.appendChild(th);
}
table.appendChild(thead);
}
// Rows
for (const row of (ctrl.data || [])) {
const tr = document.createElement('tr');
if (Array.isArray(row)) {
for (const cell of row) {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
}
} else if (typeof row === 'object') {
for (const col of (ctrl.columns || Object.keys(row))) {
const td = document.createElement('td');
td.textContent = row[col] ?? '';
tr.appendChild(td);
}
}
table.appendChild(tr);
}
container.appendChild(table);
} else if (ctrl.type === 'label') {
const lbl = document.createElement('div');
lbl.className = 'control-label';
lbl.innerHTML = '<span class="cl-text">' + esc(ctrl.text || '') + '</span><span class="cl-value">' + esc(String(ctrl.value ?? '')) + '</span>';
container.appendChild(lbl);
} else if (ctrl.type === 'process') {
const card = document.createElement('div');
card.className = 'process-card ' + (ctrl.status || 'running');
card.innerHTML =
'<span class="pc-tool">' + esc(ctrl.tool || 'python') + '</span>' +
'<span class="pc-status">' + esc(ctrl.status || 'running') + '</span>' +
(ctrl.status === 'running' ? '<button class="pc-stop" onclick="cancelProcess(' + (ctrl.pid || 0) + ')">Stop</button>' : '') +
'<pre class="pc-output">' + esc(ctrl.output || '') + '</pre>';
container.appendChild(card);
}
}
msgs.appendChild(container);
scroll(msgs);
}
function cancelProcess(pid) {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'cancel_process', pid }));
}
}
function showProcessCard(pid, tool, code) {
const card = document.createElement('div');
card.className = 'process-card running';
card.id = 'proc-' + pid;
card.innerHTML =
'<span class="pc-tool">' + esc(tool) + '</span>' +
'<span class="pc-status">running</span>' +
'<button class="pc-stop" onclick="cancelProcess(' + pid + ')">Stop</button>' +
'<pre class="pc-code">' + esc(truncate(code, 200)) + '</pre>' +
'<pre class="pc-output"></pre>';
msgs.appendChild(card);
scroll(msgs);
}
function updateProcessCard(pid, status, output, elapsed) {
const card = document.getElementById('proc-' + pid);
if (!card) return;
card.className = 'process-card ' + status;
const statusEl = card.querySelector('.pc-status');
if (statusEl) statusEl.textContent = status + (elapsed ? ' (' + elapsed + 's)' : '');
const stopBtn = card.querySelector('.pc-stop');
if (stopBtn) stopBtn.remove();
const outEl = card.querySelector('.pc-output');
if (outEl && output) outEl.textContent = output;
}
function updateMeter(node, tokens, maxTokens, fillPct) {
const meter = document.getElementById('meter-' + node);
if (!meter) return;
const fill = meter.querySelector('.nm-fill');
const text = meter.querySelector('.nm-text');
fill.style.width = fillPct + '%';
fill.style.backgroundColor = fillPct > 80 ? '#ef4444' : fillPct > 50 ? '#f59e0b' : '#22c55e';
text.textContent = tokens + ' / ' + maxTokens + 't (' + fillPct + '%)';
}
function scroll(el) { el.scrollTop = el.scrollHeight; }
function esc(s) { const d = document.createElement('span'); d.textContent = s; return d.innerHTML; }
function renderMarkdown(text) {
// Escape HTML first
let html = esc(text);
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => '<pre><code>' + code.trim() + '</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>');
// Headers
html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
// Unordered lists
html = html.replace(/^[*-] (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>\n?)+/g, m => '<ul>' + m + '</ul>');
// Line breaks (double newline = paragraph break)
html = html.replace(/\n\n/g, '<br><br>');
html = html.replace(/\n/g, '<br>');
return html;
}
function truncate(s, n) { return s.length > n ? s.slice(0, n) + '\u2026' : s; }
function addMsg(role, text) {
const el = document.createElement('div');
el.className = 'msg ' + role;
el.textContent = text;
msgs.appendChild(el);
scroll(msgs);
return el;
}
function send() {
const text = inputEl.value.trim();
if (!text || !ws || ws.readyState !== 1) return;
addMsg('user', text);
addTrace('runtime', 'user_msg', truncate(text, 60));
// S3*: attach current workspace state so pipeline knows what user sees
ws.send(JSON.stringify({ text, dashboard: _currentDashboard }));
inputEl.value = '';
}
// --- Awareness panel updates ---
let _sensorReadings = {};
function updateAwarenessState(state) {
const body = document.getElementById('aw-state-body');
if (!body) return;
const display = [
['user', state.user_name],
['mood', state.user_mood],
['topic', state.topic],
['language', state.language],
['style', state.style_hint],
['situation', state.situation],
];
let html = '';
for (const [k, v] of display) {
if (!v) continue;
const moodCls = k === 'mood' ? ' mood-' + v : '';
html += '<div class="aw-row"><span class="aw-key">' + esc(k) + '</span><span class="aw-val' + moodCls + '">' + esc(String(v)) + '</span></div>';
}
const facts = state.facts || [];
if (facts.length) {
html += '<ul class="aw-facts">';
for (const f of facts) html += '<li>' + esc(f) + '</li>';
html += '</ul>';
}
body.innerHTML = html || '<span class="aw-empty">no state yet</span>';
}
function updateAwarenessSensors(tick, deltas) {
// Merge deltas into persistent readings
for (const [k, v] of Object.entries(deltas)) {
_sensorReadings[k] = { value: v, at: Date.now() };
}
const body = document.getElementById('aw-sensors-body');
if (!body) return;
const entries = Object.entries(_sensorReadings);
if (!entries.length) { body.innerHTML = '<span class="aw-empty">waiting for tick...</span>'; return; }
let html = '';
for (const [name, r] of entries) {
const age = Math.round((Date.now() - r.at) / 1000);
const ageStr = age < 5 ? 'now' : age < 60 ? age + 's' : Math.floor(age / 60) + 'm';
html += '<div class="aw-sensor"><span class="aw-sensor-name">' + esc(name) + '</span><span class="aw-sensor-val">' + esc(String(r.value)) + '</span><span class="aw-sensor-age">' + ageStr + '</span></div>';
}
body.innerHTML = html;
}
function showAwarenessProcess(pid, tool, code) {
const body = document.getElementById('aw-proc-body');
if (!body) return;
// Remove "idle" placeholder
const empty = body.querySelector('.aw-empty');
if (empty) empty.remove();
const el = document.createElement('div');
el.className = 'aw-proc running';
el.id = 'aw-proc-' + pid;
el.innerHTML =
'<div class="aw-proc-header"><span class="aw-proc-tool">' + esc(tool) + '</span><span class="aw-proc-status">running</span>' +
'<button class="aw-proc-stop" onclick="cancelProcess(' + pid + ')">Stop</button></div>' +
'<div class="aw-proc-code">' + esc(truncate(code, 150)) + '</div>' +
'<div class="aw-proc-output"></div>';
body.appendChild(el);
}
function updateAwarenessProcess(pid, status, output, elapsed) {
const el = document.getElementById('aw-proc-' + pid);
if (!el) return;
el.className = 'aw-proc ' + status;
const st = el.querySelector('.aw-proc-status');
if (st) st.textContent = status + (elapsed ? ' (' + elapsed + 's)' : '');
const stop = el.querySelector('.aw-proc-stop');
if (stop) stop.remove();
const out = el.querySelector('.aw-proc-output');
if (out && output) out.textContent = output;
// Auto-remove completed processes (done: 10s, failed: 30s)
const delay = status === 'done' ? 10000 : 30000;
setTimeout(() => {
el.remove();
const body = document.getElementById('aw-proc-body');
if (body && !body.children.length) body.innerHTML = '<span class="aw-empty">idle</span>';
}, delay);
}
function dockControls(controls) {
_currentDashboard = controls; // S3*: remember what's rendered
const body = document.getElementById('aw-ctrl-body');
if (!body) return;
// Replace previous controls with new ones
body.innerHTML = '';
const container = document.createElement('div');
container.className = 'controls-container';
for (const ctrl of controls) {
if (ctrl.type === 'button') {
const btn = document.createElement('button');
btn.className = 'control-btn';
btn.textContent = ctrl.label;
btn.onclick = () => {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'action', action: ctrl.action, data: ctrl.payload || ctrl.data || {} }));
addTrace('runtime', 'action', ctrl.action);
}
};
container.appendChild(btn);
} else if (ctrl.type === 'table') {
const table = document.createElement('table');
table.className = 'control-table';
if (ctrl.columns) {
const thead = document.createElement('tr');
for (const col of ctrl.columns) {
const th = document.createElement('th');
th.textContent = col;
thead.appendChild(th);
}
table.appendChild(thead);
}
for (const row of (ctrl.data || [])) {
const tr = document.createElement('tr');
if (Array.isArray(row)) {
for (const cell of row) {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
}
} else if (typeof row === 'object') {
for (const col of (ctrl.columns || Object.keys(row))) {
const td = document.createElement('td');
td.textContent = row[col] ?? '';
tr.appendChild(td);
}
}
table.appendChild(tr);
}
container.appendChild(table);
} else if (ctrl.type === 'label') {
const lbl = document.createElement('div');
lbl.className = 'control-label';
lbl.innerHTML = '<span class="cl-text">' + esc(ctrl.text || '') + '</span><span class="cl-value">' + esc(String(ctrl.value ?? '')) + '</span>';
container.appendChild(lbl);
} else if (ctrl.type === 'display') {
const disp = document.createElement('div');
const dt = ctrl.display_type || 'text';
const style = ctrl.style ? ' display-' + ctrl.style : '';
disp.className = 'control-display display-' + dt + style;
if (dt === 'progress') {
const pct = Math.min(100, Math.max(0, Number(ctrl.value) || 0));
disp.innerHTML = '<span class="cd-label">' + esc(ctrl.label) + '</span>' +
'<div class="cd-bar"><div class="cd-fill" style="width:' + pct + '%"></div></div>' +
'<span class="cd-pct">' + pct + '%</span>';
} else if (dt === 'status') {
disp.innerHTML = '<span class="cd-icon">' + (ctrl.style === 'success' ? '✓' : ctrl.style === 'error' ? '✗' : ctrl.style === 'warning' ? '⚠' : '') + '</span>' +
'<span class="cd-label">' + esc(ctrl.label) + '</span>';
} else {
disp.innerHTML = '<span class="cd-label">' + esc(ctrl.label) + '</span>' +
(ctrl.value ? '<span class="cd-value">' + esc(String(ctrl.value)) + '</span>' : '');
}
container.appendChild(disp);
}
}
body.appendChild(container);
}
inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
window.addEventListener('load', initGraph);
initAuth();