diff --git a/agent/nodes/output.py b/agent/nodes/output.py index 35e659e..de84e89 100644 --- a/agent/nodes/output.py +++ b/agent/nodes/output.py @@ -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 logging @@ -7,7 +7,7 @@ from fastapi import WebSocket from .base import Node from ..llm import llm_call -from ..types import Command +from ..types import Command, ThoughtResult log = logging.getLogger("runtime") @@ -17,14 +17,25 @@ class OutputNode(Node): model = "google/gemini-2.0-flash-001" max_context_tokens = 4000 - SYSTEM = """You are the Output node — the voice 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. -Be natural. Be concise. If the user asks you to do something, do it — don't describe what you're about to do. + SYSTEM = """You are the Output node — the renderer of this cognitive runtime. + +DEVICE: The user is on a web browser (Chrome, desktop). Your output renders in an HTML chat panel. +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}""" - 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") 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:]: 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) await self.hud("context", messages=messages, tokens=self.last_context_tokens, diff --git a/agent/runtime.py b/agent/runtime.py index 8412bc6..ef16f62 100644 --- a/agent/runtime.py +++ b/agent/runtime.py @@ -84,8 +84,8 @@ class Runtime: if thought.controls: await self.ws.send_text(json.dumps({"type": "controls", "controls": thought.controls})) - await self._stream_text(thought.response) - self.history.append({"role": "assistant", "content": thought.response}) + response = await self.output_node.process(thought, self.history, self.ws, memory_context=mem_ctx) + self.history.append({"role": "assistant", "content": response}) await self.memorizer.update(self.history) @@ -116,17 +116,8 @@ class Runtime: if thought.controls: await self.ws.send_text(json.dumps({"type": "controls", "controls": thought.controls})) - if thought.tool_used: - # Thinker already formulated response from tool output — stream directly - 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) + # Output renders Thinker's reasoning into device-appropriate response + response = await self.output_node.process(thought, self.history, self.ws, memory_context=mem_ctx) self.history.append({"role": "assistant", "content": response}) diff --git a/static/app.js b/static/app.js index ed9160a..f9b0879 100644 --- a/static/app.js +++ b/static/app.js @@ -132,6 +132,7 @@ function connect() { } else if (data.type === 'controls') { renderControls(data.controls); + dockControls(data.controls); } }; } @@ -169,14 +170,17 @@ function handleHud(data) { }).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); showProcessCard(data.pid, data.tool || 'python', 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); 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') { 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(' '); addTrace(node, 'tick #' + data.tick, deltas); } + updateAwarenessSensors(data.tick || 0, data.deltas || {}); } else if (event === 'started' || event === 'stopped') { const meter = document.getElementById('meter-sensor'); @@ -370,5 +375,144 @@ function send() { 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 += '
' + esc(k) + '' + esc(String(v)) + '
'; + } + const facts = state.facts || []; + if (facts.length) { + html += ''; + } + body.innerHTML = html || 'no state yet'; +} + +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 = 'waiting for tick...'; 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 += '
' + esc(name) + '' + esc(String(r.value)) + '' + ageStr + '
'; + } + 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 = + '
' + esc(tool) + 'running' + + '
' + + '
' + esc(truncate(code, 150)) + '
' + + '
'; + 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 = 'idle'; + }, 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(); }); initAuth(); diff --git a/static/index.html b/static/index.html index 2cd2be5..10b1c4f 100644 --- a/static/index.html +++ b/static/index.html @@ -30,6 +30,27 @@ +
+
Awareness
+
+
+

State

+
waiting for data...
+
+
+

Sensors

+
waiting for tick...
+
+
+

Processes

+
idle
+
+
+

Workspace

+
no controls
+
+
+
Trace
diff --git a/static/style.css b/static/style.css index 4ba1051..046739b 100644 --- a/static/style.css +++ b/static/style.css @@ -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-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 */ -#main { flex: 1; display: grid; grid-template-columns: 1fr 2fr; gap: 1px; background: #222; overflow: hidden; min-height: 0; } +/* Three-column layout: chat | awareness | trace */ +#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-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-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 */ .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; }