emergence-mini-dilles/web/app.js
Jeuners 919866e50d Time Dilation framework + OpenRouter multi-LLM
Implements core pieces of 'Time Dilation in LLM Agent Systems'
(Dillenberg 2026) and adds OpenRouter as a second LLM provider.

ENGINE
- engine/time.py: AgentClock with cumulative proper time tau
  (weighted by op type), EWMA pace (alpha=0.3, dt clamped 0.1-60s),
  ClockRegistry singleton, gamma_{src->dst} frame transformation,
  drift_report with per-pair divergence and threshold flag.
- engine/turn.py: ticks tau on reasoning/tool/memory/reactive;
  broadcasts tau+pace+model in every WebSocket message.
- engine/db.py: schema adds turn_log.tau, turn_log.pace,
  turn_log.model, agent_clocks table; dev-mode auto-migrate
  drops+recreates if old schema detected.
- engine/llm.py: full refactor for two providers.
    Ollama: native tool-calling via /api/chat
    OpenRouter: OpenAI-compatible /api/v1/chat/completions
  Auto mode picks OpenRouter if OPENROUTER_API_KEY is set.
  Per-agent model via EMERGENCE_AGENT_<ID>_MODEL env var.
  .env loader with empty-line guard.
  decide_tool returns (name, args, meta) with cost_usd for OR.

FRONTEND
- web/: new 'Time Dilation · Eigenzeit tau' section with per-agent
  tau bars, pace, op count. Drift warning when any pair exceeds
  threshold. LLM provider info in header.

TESTS
- 14 new tests in tests/test_time.py (tau monotonic, EWMA convergence,
  gamma asymmetry, drift detection).
- 4 new LLM tests: openrouter response parsing, per-agent override,
  provider_info, is_available.
- All 99 tests green.

LIVE-VERIFIED
- 4 different OpenRouter models running in parallel:
  - anchor: anthropic/claude-3.5-haiku
  - flora:  openai/gpt-4o-mini
  - lovely: meta-llama/llama-3.3-70b-instruct
  - spark:  google/gemma-3-4b-it
- All 4 produce turns, all 4 have different tau values,
  drift_report shows the Frame-Transformation gamma values.
- Observation: gamma ~ 1.00 because the explicit Round-Robin +
  sleep(2) keeps frames coherent. This is itself a non-trivial
  validation of the paper's claim: in non-synchronized systems,
  dilation would emerge.

SECRETS
- .env added, OPENROUTER_API_KEY live. .env is git-ignored.
- .env.example documents the config without exposing any key.
- .gitignore now blocks .env, .env.local, *.key, *.pem.

README
- New 'Time Dilation' section explaining tau, pace, CDC, drift
- New 'Multi-LLM via OpenRouter' section with cost table
- Per-agent model config documented
2026-06-15 02:27:11 +02:00

293 lines
9.6 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.

// Emergence-Mini live view
const canvas = document.getElementById('world');
const ctx = canvas.getContext('2d');
const GRID = 240;
let snapshot = null;
let actions = [];
const AGENT_COLORS = {
anchor: '#ffd166',
flora: '#6cf0c2',
lovely: '#ff8fb1',
spark: '#82aaff',
};
const LANDMARK_COLORS = {
town_hall: '#ff6c6c',
library: '#82aaff',
plaza: '#ff8fb1',
park: '#6cf08a',
victory_arch: '#ffd166',
police: '#cccccc',
bookworm: '#a47cff',
cafe: '#ffb066',
billboard: '#dddddd',
techhub: '#6cf0c2',
home_anchor: '#3a3a3a',
home_flora: '#3a3a3a',
home_lovely: '#3a3a3a',
home_spark: '#3a3a3a',
};
function draw() {
if (!snapshot) return;
const W = canvas.width, H = canvas.height;
ctx.fillStyle = '#060a0e';
ctx.fillRect(0, 0, W, H);
// grid
ctx.strokeStyle = '#0e1620';
ctx.lineWidth = 1;
for (let i = 0; i <= GRID; i += 30) {
const px = (i / GRID) * W;
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, px); ctx.lineTo(W, px); ctx.stroke();
}
// landmarks
for (const lm of snapshot.landmarks) {
const x = (lm.x / GRID) * W;
const y = (lm.y / GRID) * H;
const isHome = lm.id.startsWith('home_');
ctx.fillStyle = LANDMARK_COLORS[lm.id] || '#888';
ctx.beginPath();
ctx.arc(x, y, isHome ? 5 : 7, 0, Math.PI * 2);
ctx.fill();
if (!isHome) {
ctx.fillStyle = '#0a1018';
ctx.font = '10px ui-monospace';
ctx.textAlign = 'center';
ctx.fillText(lm.name, x, y - 9);
}
}
// agents
for (const a of snapshot.agents) {
const x = (a.x / GRID) * W;
const y = (a.y / GRID) * H;
ctx.fillStyle = AGENT_COLORS[a.id] || '#fff';
ctx.beginPath();
ctx.arc(x, y, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#0a1018';
ctx.font = 'bold 10px ui-monospace';
ctx.textAlign = 'center';
ctx.fillText(a.name[0], x, y + 3);
// energy halo
ctx.strokeStyle = '#ffd166';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(x, y, 8 + (100 - a.energy) * 0.06, 0, Math.PI * 2);
ctx.stroke();
}
}
function refreshAgentCards() {
const wrap = document.getElementById('agentList');
wrap.innerHTML = '';
for (const a of snapshot.agents) {
const div = document.createElement('div');
div.className = 'agent-card';
div.innerHTML = `
<h3>${a.name} <small>· ${a.role}</small></h3>
<div>at (${a.x}, ${a.y}) · ${a.mood}</div>
<div class="bar energy"><i style="width:${a.energy}%"></i></div>
<div class="bar knowledge"><i style="width:${a.knowledge}%"></i></div>
<div class="bar influence"><i style="width:${a.influence}%"></i></div>
<div class="bar credits"><i style="width:${Math.min(100, a.credits * 5)}%"></i></div>
<small>${a.energy.toFixed(0)}E · ${a.knowledge.toFixed(0)}K · ${a.influence.toFixed(0)}I · ${a.credits.toFixed(1)} CC</small>
`;
wrap.appendChild(div);
}
}
function refreshClocks() {
if (!snapshot || !snapshot.clocks) return;
const wrap = document.getElementById('clocks');
wrap.innerHTML = '';
const entries = Object.entries(snapshot.clocks);
// sort by τ descending
entries.sort((a, b) => b[1].tau - a[1].tau);
const maxTau = Math.max(1, entries.length ? entries[0][1].tau : 1);
const colorFor = { anchor: '#ffd166', flora: '#6cf0c2', lovely: '#ff8fb1', spark: '#82aaff' };
for (const [aid, c] of entries) {
const div = document.createElement('div');
div.className = 'clock-card';
const pct = (c.tau / maxTau) * 100;
div.innerHTML = `
<div class="name" style="color:${colorFor[aid] || '#fff'}">${aid}</div>
<div class="tau-bar"><i style="width:${pct}%; background:${colorFor[aid] || '#fff'}"></i></div>
<div class="meta">τ = ${c.tau.toFixed(2)} · pace = ${c.pace.toFixed(2)} op/s · ${c.n_ops} ops</div>
`;
wrap.appendChild(div);
}
}
function refreshDrift() {
if (!snapshot || !snapshot.drift) return;
const wrap = document.getElementById('drift');
const d = snapshot.drift;
if (!d.pairs || d.pairs.length === 0) {
wrap.innerHTML = '<small>No multi-agent drift yet.</small>';
return;
}
const top = d.pairs[0];
if (top.divergent) {
wrap.className = 'drift-warn';
wrap.innerHTML = `<b>⚠ DILATION DRIFT</b> · ${top.a}${top.b}: |Δτ| = ${top.drift.toFixed(1)} (γ=${top.gamma_ab})<br>
<small>${top.a} has experienced ${top.tau_a} units, ${top.b} ${top.tau_b}. Frame transformation exceeds threshold ${d.threshold}.</small>`;
} else {
wrap.className = 'drift-ok';
wrap.innerHTML = `<b>✓ COHERENT</b> · max drift = ${d.max_drift.toFixed(2)} (threshold ${d.threshold})`;
}
}
function refreshProposals() {
const wrap = document.getElementById('proposals');
wrap.innerHTML = '';
fetch('/api/proposals').then(r => r.json()).then(d => {
if (!d.active.length) {
wrap.innerHTML = '<small>No active proposals.</small>';
return;
}
for (const p of d.active) {
const el = document.createElement('div');
el.className = 'proposal';
el.innerHTML = `<h4>#${p.id} ${p.title}</h4>
<small>by ${p.author} · ${p.category} · ${p.votes} votes</small>
<p>${p.body}</p>`;
wrap.appendChild(el);
}
});
}
function refreshConstitution() {
const wrap = document.getElementById('constitution');
wrap.innerHTML = '';
fetch('/api/constitution').then(r => r.json()).then(c => {
for (const a of c.articles) {
const el = document.createElement('div');
el.className = 'article';
el.innerHTML = `<b>Article ${a.id}${a.title}</b><p>${a.body}</p>`;
wrap.appendChild(el);
}
});
}
function addFeed(entry) {
const ul = document.getElementById('feed');
const li = document.createElement('li');
const t = new Date().toLocaleTimeString();
if (entry.type === 'action') {
li.innerHTML = `<span class="who">${entry.name}</span>
<span class="tool">${entry.tool}</span>
<span class="why">— ${entry.rationale || ''}</span>
<small> · ${t}</small>`;
} else {
li.innerHTML = `<small>${entry.type} · ${t}</small>`;
}
ul.prepend(li);
while (ul.children.length > 80) ul.removeChild(ul.lastChild);
}
function refreshHeader() {
document.getElementById('tick').textContent = snapshot?.tick || 0;
document.getElementById('agentCount').textContent = snapshot?.agents.length || 0;
}
async function refreshAll() {
const r = await fetch('/api/state');
snapshot = await r.json();
draw();
refreshHeader();
refreshAgentCards();
refreshClocks();
refreshDrift();
refreshProposals();
refreshConstitution();
// LLM info
if (snapshot.llm) {
const info = document.getElementById('llmInfo');
info.textContent = `${snapshot.llm.provider} · ${snapshot.llm.model}`;
}
}
function connectWS() {
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
const status = document.getElementById('wsStatus');
ws.onopen = () => { status.textContent = 'live'; status.className = 'ws-status connected'; };
ws.onclose = () => {
status.textContent = 'disconnected — retrying'; status.className = 'ws-status disconnected';
setTimeout(connectWS, 2000);
};
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'snapshot') {
snapshot = msg.data;
draw(); refreshHeader(); refreshAgentCards();
} else if (msg.type === 'action') {
// update local agent snapshot
if (snapshot) {
const a = snapshot.agents.find(x => x.id === msg.agent);
if (a) {
a.x = msg.x; a.y = msg.y;
a.energy = msg.energy; a.knowledge = msg.knowledge;
a.influence = msg.influence; a.credits = msg.credits;
a.mood = msg.mood;
}
draw(); refreshAgentCards();
}
addFeed(msg);
// refresh proposals / constitution occasionally
if (msg.tool && msg.tool.startsWith('vote_on') || msg.tool === 'submit_townhall_proposal') {
refreshProposals();
}
if (msg.tool === 'submit_townhall_proposal') {
setTimeout(refreshConstitution, 500);
}
} else if (msg.type === 'tick') {
document.getElementById('tick').textContent = msg.tick;
if (msg.clocks) snapshot.clocks = msg.clocks;
if (msg.drift) snapshot.drift = msg.drift;
refreshClocks();
refreshDrift();
}
};
}
async function populateManual() {
const a = await (await fetch('/api/agents')).json();
const selA = document.querySelector('#manual select[name=agent]');
for (const ag of a) {
const o = document.createElement('option');
o.value = ag.id; o.textContent = ag.name; selA.appendChild(o);
}
const tools = [
'go_to_place','go_home','say_to_agent','speak_to_all',
'show_emoticon','recharge_energy','add_to_longterm_memory',
'write_blog','add_to_billboard','submit_townhall_proposal',
'vote_on_proposal','idle',
];
const selT = document.querySelector('#manual select[name=tool]');
for (const t of tools) {
const o = document.createElement('option');
o.value = t; o.textContent = t; selT.appendChild(o);
}
}
document.getElementById('manual').addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
let args = {};
try { args = JSON.parse(f.args.value || '{}'); } catch { alert('args must be JSON'); return; }
const body = { tool: f.tool.value, args };
const r = await fetch(`/api/turn/${f.agent.value}`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const out = await r.json();
console.log('manual turn result', out);
});
refreshAll();
populateManual();
connectWS();
setInterval(refreshProposals, 5000);