v0.8.5: smart Output renderer + awareness panel

Output node upgraded from dumb echo to device-aware renderer:
- Knows it's rendering to HTML/browser, uses markdown formatting
- Receives full ThoughtResult (response + tool output + controls)
- Always in pipeline: Input perceives, Thinker reasons, Output renders
- Keeps user's language, weaves tool results into natural responses

Awareness panel (3-column layout):
- State: mood, topic, language, facts from Memorizer
- Sensors: clock, idle, memo deltas from Sensor ticks
- Processes: live cards with cancel during tool execution
- Workspace: docked controls (tables/buttons) persist across messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nico 2026-03-28 02:02:41 +01:00
parent 4e2cd4ed59
commit f6939d47f5
5 changed files with 243 additions and 23 deletions

View File

@ -1,4 +1,4 @@
"""Output Node: streams natural response to the user.""" """Output Node: renders Thinker's reasoning into device-appropriate responses."""
import json import json
import logging import logging
@ -7,7 +7,7 @@ from fastapi import WebSocket
from .base import Node from .base import Node
from ..llm import llm_call from ..llm import llm_call
from ..types import Command from ..types import Command, ThoughtResult
log = logging.getLogger("runtime") log = logging.getLogger("runtime")
@ -17,14 +17,25 @@ class OutputNode(Node):
model = "google/gemini-2.0-flash-001" model = "google/gemini-2.0-flash-001"
max_context_tokens = 4000 max_context_tokens = 4000
SYSTEM = """You are the Output node — the voice of this cognitive runtime. SYSTEM = """You are the Output node — the renderer of this cognitive runtime.
The Input node sends you its perception of what the user said. This is internal context for you never repeat or echo it.
You respond to the USER, not to the Input node. Use the perception to understand intent, then act on it. DEVICE: The user is on a web browser (Chrome, desktop). Your output renders in an HTML chat panel.
Be natural. Be concise. If the user asks you to do something, do it don't describe what you're about to do. You can use markdown: **bold**, *italic*, `code`, ```code blocks```, lists, headers.
The chat panel renders markdown to HTML use it for structure when helpful.
YOUR JOB: Transform the Thinker's reasoning into a polished, user-facing response.
- The Thinker reasons and may use tools. You receive its output and render it for the human.
- NEVER echo internal node names, perceptions, or system details.
- NEVER say "the Thinker decided..." or "I'll process..." just deliver the answer.
- If the Thinker ran a tool and got output, weave the results into a natural response.
- If the Thinker gave a direct answer, refine and format it don't just repeat it.
- Keep the user's language — if they wrote German, respond in German.
- Be concise but complete. Use formatting to make data scannable.
{memory_context}""" {memory_context}"""
async def process(self, command: Command, history: list[dict], ws: WebSocket, memory_context: str = "") -> str: async def process(self, thought: ThoughtResult, history: list[dict],
ws: WebSocket, memory_context: str = "") -> str:
await self.hud("streaming") await self.hud("streaming")
messages = [ messages = [
@ -32,7 +43,15 @@ Be natural. Be concise. If the user asks you to do something, do it — don't de
] ]
for msg in history[-20:]: for msg in history[-20:]:
messages.append(msg) messages.append(msg)
messages.append({"role": "system", "content": f"Input perception: {command.instruction}"})
# Give Output the full Thinker result to render
thinker_ctx = f"Thinker response: {thought.response}"
if thought.tool_used:
thinker_ctx += f"\n\nTool used: {thought.tool_used}\nTool output:\n{thought.tool_output}"
if thought.controls:
thinker_ctx += f"\n\n(UI controls were also sent to the user: {len(thought.controls)} elements)"
messages.append({"role": "system", "content": thinker_ctx})
messages = self.trim_context(messages) messages = self.trim_context(messages)
await self.hud("context", messages=messages, tokens=self.last_context_tokens, await self.hud("context", messages=messages, tokens=self.last_context_tokens,

View File

@ -84,8 +84,8 @@ class Runtime:
if thought.controls: if thought.controls:
await self.ws.send_text(json.dumps({"type": "controls", "controls": thought.controls})) await self.ws.send_text(json.dumps({"type": "controls", "controls": thought.controls}))
await self._stream_text(thought.response) response = await self.output_node.process(thought, self.history, self.ws, memory_context=mem_ctx)
self.history.append({"role": "assistant", "content": thought.response}) self.history.append({"role": "assistant", "content": response})
await self.memorizer.update(self.history) await self.memorizer.update(self.history)
@ -116,17 +116,8 @@ class Runtime:
if thought.controls: if thought.controls:
await self.ws.send_text(json.dumps({"type": "controls", "controls": thought.controls})) await self.ws.send_text(json.dumps({"type": "controls", "controls": thought.controls}))
if thought.tool_used: # Output renders Thinker's reasoning into device-appropriate response
# Thinker already formulated response from tool output — stream directly response = await self.output_node.process(thought, self.history, self.ws, memory_context=mem_ctx)
await self._stream_text(thought.response)
response = thought.response
else:
# Pure conversation — Output node adds personality and streams
command = Command(
instruction=f"Thinker says: {thought.response}",
source_text=command.source_text
)
response = await self.output_node.process(command, self.history, self.ws, memory_context=mem_ctx)
self.history.append({"role": "assistant", "content": response}) self.history.append({"role": "assistant", "content": response})

View File

@ -132,6 +132,7 @@ function connect() {
} else if (data.type === 'controls') { } else if (data.type === 'controls') {
renderControls(data.controls); renderControls(data.controls);
dockControls(data.controls);
} }
}; };
} }
@ -169,14 +170,17 @@ function handleHud(data) {
}).join(' '); }).join(' ');
const detail = JSON.stringify(data.state, null, 2); const detail = JSON.stringify(data.state, null, 2);
addTrace(node, 'state', pairs, 'state', detail); addTrace(node, 'state', pairs, 'state', detail);
updateAwarenessState(data.state);
} else if (event === 'process_start') { } else if (event === 'process_start') {
addTrace(node, 'run ' + (data.tool || 'python'), truncate(data.code || '', 80), 'instruction', data.code); addTrace(node, 'run ' + (data.tool || 'python'), truncate(data.code || '', 80), 'instruction', data.code);
showProcessCard(data.pid, data.tool || 'python', data.code || ''); showProcessCard(data.pid, data.tool || 'python', data.code || '');
showAwarenessProcess(data.pid, data.tool || 'python', data.code || '');
} else if (event === 'process_done') { } else if (event === 'process_done') {
addTrace(node, (data.exit_code === 0 ? 'done' : 'failed'), truncate(data.output || '', 80), data.exit_code === 0 ? '' : 'error', data.output); addTrace(node, (data.exit_code === 0 ? 'done' : 'failed'), truncate(data.output || '', 80), data.exit_code === 0 ? '' : 'error', data.output);
updateProcessCard(data.pid, data.exit_code === 0 ? 'done' : 'failed', data.output || '', data.elapsed); updateProcessCard(data.pid, data.exit_code === 0 ? 'done' : 'failed', data.output || '', data.elapsed);
updateAwarenessProcess(data.pid, data.exit_code === 0 ? 'done' : 'failed', data.output || '', data.elapsed);
} else if (event === 'error') { } else if (event === 'error') {
addTrace(node, 'error', data.detail || '', 'error'); addTrace(node, 'error', data.detail || '', 'error');
@ -202,6 +206,7 @@ function handleHud(data) {
const deltas = Object.entries(data.deltas).map(([k,v]) => k + '=' + truncate(String(v), 30)).join(' '); const deltas = Object.entries(data.deltas).map(([k,v]) => k + '=' + truncate(String(v), 30)).join(' ');
addTrace(node, 'tick #' + data.tick, deltas); addTrace(node, 'tick #' + data.tick, deltas);
} }
updateAwarenessSensors(data.tick || 0, data.deltas || {});
} else if (event === 'started' || event === 'stopped') { } else if (event === 'started' || event === 'stopped') {
const meter = document.getElementById('meter-sensor'); const meter = document.getElementById('meter-sensor');
@ -370,5 +375,144 @@ function send() {
inputEl.value = ''; 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 done processes after 10s
if (status === 'done') {
setTimeout(() => {
el.remove();
const body = document.getElementById('aw-proc-body');
if (body && !body.children.length) body.innerHTML = '<span class="aw-empty">idle</span>';
}, 10000);
}
}
function dockControls(controls) {
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.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);
}
}
body.appendChild(container);
}
inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); }); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
initAuth(); initAuth();

View File

@ -30,6 +30,27 @@
<button onclick="send()">Send</button> <button onclick="send()">Send</button>
</div> </div>
</div> </div>
<div class="panel awareness-panel">
<div class="panel-header aware-h">Awareness</div>
<div id="awareness">
<section id="awareness-state" class="aw-section">
<h3 class="aw-title">State</h3>
<div class="aw-body" id="aw-state-body"><span class="aw-empty">waiting for data...</span></div>
</section>
<section id="awareness-sensors" class="aw-section">
<h3 class="aw-title">Sensors</h3>
<div class="aw-body" id="aw-sensors-body"><span class="aw-empty">waiting for tick...</span></div>
</section>
<section id="awareness-processes" class="aw-section">
<h3 class="aw-title">Processes</h3>
<div class="aw-body" id="aw-proc-body"><span class="aw-empty">idle</span></div>
</section>
<section id="awareness-controls" class="aw-section">
<h3 class="aw-title">Workspace</h3>
<div class="aw-body" id="aw-ctrl-body"><span class="aw-empty">no controls</span></div>
</section>
</div>
</div>
<div class="panel"> <div class="panel">
<div class="panel-header trace-h">Trace</div> <div class="panel-header trace-h">Trace</div>
<div id="trace"></div> <div id="trace"></div>

View File

@ -19,8 +19,8 @@ body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0;
.nm-fill { height: 100%; width: 0%; border-radius: 3px; transition: width 0.3s, background-color 0.3s; background: #333; } .nm-fill { height: 100%; width: 0%; border-radius: 3px; transition: width 0.3s, background-color 0.3s; background: #333; }
.nm-text { font-size: 0.6rem; color: #555; min-width: 5rem; text-align: right; font-family: monospace; } .nm-text { font-size: 0.6rem; color: #555; min-width: 5rem; text-align: right; font-family: monospace; }
/* Two-column layout: chat 1/3 | trace 2/3 */ /* Three-column layout: chat | awareness | trace */
#main { flex: 1; display: grid; grid-template-columns: 1fr 2fr; gap: 1px; background: #222; overflow: hidden; min-height: 0; } #main { flex: 1; display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 1px; background: #222; overflow: hidden; min-height: 0; }
.panel { background: #0a0a0a; display: flex; flex-direction: column; overflow: hidden; } .panel { background: #0a0a0a; display: flex; flex-direction: column; overflow: hidden; }
.panel-header { padding: 0.5rem 0.75rem; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid #222; flex-shrink: 0; } .panel-header { padding: 0.5rem 0.75rem; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid #222; flex-shrink: 0; }
@ -84,6 +84,51 @@ button:hover { background: #1d4ed8; }
.pc-code { margin-top: 0.3rem; color: #666; white-space: pre-wrap; max-height: 4rem; overflow-y: auto; font-size: 0.7rem; } .pc-code { margin-top: 0.3rem; color: #666; white-space: pre-wrap; max-height: 4rem; overflow-y: auto; font-size: 0.7rem; }
.pc-output { margin-top: 0.3rem; color: #888; white-space: pre-wrap; max-height: 8rem; overflow-y: auto; } .pc-output { margin-top: 0.3rem; color: #888; white-space: pre-wrap; max-height: 8rem; overflow-y: auto; }
/* Awareness panel */
.panel-header.aware-h { color: #34d399; background: #0a1e14; }
.awareness-panel { display: flex; flex-direction: column; }
#awareness { flex: 1; overflow-y: auto; padding: 0.5rem; display: flex; flex-direction: column; gap: 0.5rem; }
.aw-section { background: #111; border: 1px solid #1a1a1a; border-radius: 0.4rem; overflow: hidden; }
.aw-title { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.3rem 0.5rem; background: #0d0d14; color: #666; border-bottom: 1px solid #1a1a1a; }
.aw-body { padding: 0.4rem 0.5rem; font-size: 0.78rem; line-height: 1.5; }
.aw-empty { color: #444; font-style: italic; font-size: 0.72rem; }
/* State card */
.aw-row { display: flex; justify-content: space-between; padding: 0.1rem 0; }
.aw-key { color: #888; font-size: 0.7rem; }
.aw-val { color: #e0e0e0; font-size: 0.75rem; font-weight: 500; }
.aw-val.mood-happy { color: #22c55e; }
.aw-val.mood-frustrated { color: #ef4444; }
.aw-val.mood-playful { color: #f59e0b; }
.aw-val.mood-neutral { color: #888; }
/* Facts list */
.aw-facts { list-style: none; padding: 0; margin: 0.2rem 0 0 0; }
.aw-facts li { font-size: 0.7rem; color: #999; padding: 0.1rem 0; border-top: 1px solid #1a1a1a; }
.aw-facts li::before { content: "- "; color: #555; }
/* Sensor readings */
.aw-sensor { display: flex; align-items: center; gap: 0.4rem; padding: 0.15rem 0; }
.aw-sensor-name { color: #60a5fa; font-size: 0.7rem; font-weight: 600; min-width: 4rem; }
.aw-sensor-val { color: #e0e0e0; font-size: 0.75rem; }
.aw-sensor-age { color: #444; font-size: 0.65rem; }
/* Awareness processes */
.aw-proc { background: #0d0d14; border: 1px solid #333; border-radius: 0.3rem; padding: 0.3rem 0.5rem; margin-bottom: 0.3rem; }
.aw-proc.running { border-color: #f59e0b; }
.aw-proc.done { border-color: #22c55e; }
.aw-proc.failed { border-color: #ef4444; }
.aw-proc-header { display: flex; align-items: center; gap: 0.4rem; font-size: 0.72rem; }
.aw-proc-tool { font-weight: 700; color: #fb923c; }
.aw-proc-status { color: #888; }
.aw-proc-stop { padding: 0.1rem 0.3rem; background: #ef4444; color: white; border: none; border-radius: 0.2rem; cursor: pointer; font-size: 0.65rem; }
.aw-proc-code { font-size: 0.65rem; color: #555; margin-top: 0.2rem; white-space: pre-wrap; max-height: 3rem; overflow: hidden; }
.aw-proc-output { font-size: 0.7rem; color: #999; margin-top: 0.2rem; white-space: pre-wrap; max-height: 5rem; overflow-y: auto; }
/* Awareness controls (workspace) */
#aw-ctrl-body .controls-container { padding: 0; }
#aw-ctrl-body .control-table { font-size: 0.72rem; }
/* Expandable trace detail */ /* Expandable trace detail */
.trace-line.expandable { cursor: pointer; } .trace-line.expandable { cursor: pointer; }
.trace-detail { display: none; padding: 0.3rem 0.4rem 0.3rem 12rem; font-size: 0.65rem; color: #777; white-space: pre-wrap; word-break: break-all; max-height: 10rem; overflow-y: auto; background: #0d0d14; border-bottom: 1px solid #1a1a2e; } .trace-detail { display: none; padding: 0.3rem 0.4rem 0.3rem 12rem; font-size: 0.65rem; color: #777; white-space: pre-wrap; word-break: break-all; max-height: 10rem; overflow-y: auto; background: #0d0d14; border-bottom: 1px solid #1a1a2e; }