/* ZHILA · agent the choir: a model on the local server, acting through the same verbs as the hand. without a server or a key it falls back to a local conductor — still using the tools. */ (function () { 'use strict'; const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s)); const clamp = (v, a, b) => v < a ? a : v > b ? b : v; const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const API = location.protocol === 'file:' ? 'http://127.0.0.1:4173' : ''; const E = window.ZHILA_ENGINE; const Z = window.ZHILA; const Q = window.ZHILA_QUARRY; const logEl = $('#log'); const stateEl = $('#agent-state'); const stateText = $('#agent-state-text'); const netText = $('#net-text'); const TOOL_NAMES = ['get_state', 'list_cores', 'extract_core', 'fracture_core', 'strike_face', 'place_stone', 'move_stone', 'delete_stone', 'set_weather', 'set_rings', 'set_tempo', 'transport', 'open_core', 'set_macros']; let live = false; let busy = false; let history = []; let welcomed = false; function timeNow() { const d = new Date(); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); } function scrollLog() { if (logEl) logEl.scrollTop = logEl.scrollHeight; } function setStatus(text, thinking) { if (!stateText) return; stateText.textContent = text; if (stateEl) stateEl.classList.toggle('is-thinking', !!thinking); } function addSay(text) { if (!logEl || !text) return; const li = document.createElement('li'); li.className = 'call call--say'; li.innerHTML = '

'; li.querySelector('.call-t').textContent = timeNow(); li.querySelector('p').textContent = text; logEl.appendChild(li); scrollLog(); } function addYou(text) { if (!logEl) return; const li = document.createElement('li'); li.className = 'call call--you'; li.innerHTML = '

'; li.querySelector('.call-t').textContent = timeNow(); li.querySelector('p').textContent = '\u201C' + text + '\u201D'; logEl.appendChild(li); scrollLog(); } function argsHTML(args) { const keys = Object.keys(args || {}); if (!keys.length) return '{ }'; return '{ ' + keys.map(function (k) { let v = args[k]; let val; if (typeof v === 'string') val = '"' + esc(v) + '"'; else if (typeof v === 'object' && v !== null) val = esc(JSON.stringify(v)); else val = esc(String(v)); return '"' + esc(k) + '": ' + val + ''; }).join(', ') + ' }'; } function argLine(name, a) { const pad = n => String(n).padStart(2, '0'); const pitch = n => n ? (n > 0 ? '+' : '') + n + 'st' : ''; switch (name) { case 'place_stone': return [a.vein, a.core_id + ' f' + pad(a.face), a.start_bar + '–' + (+a.start_bar + +a.length_bars), a.ring_bars ? 'ring ' + a.ring_bars : 'once', pitch(a.pitch), a.gain ? 'gain ' + a.gain : ''].filter(Boolean).join(' · '); case 'strike_face': return a.core_id + ' f' + pad(a.face); case 'extract_core': return a.name || 'new core'; case 'fracture_core': case 'open_core': return a.core_id; case 'move_stone': return 'stone ' + a.stone_id + ' → bar ' + a.start_bar; case 'delete_stone': return 'stone ' + a.stone_id; case 'set_weather': return ['age', 'pressure', 'air', 'heat'].filter(function (k) { return a[k] != null; }) .map(function (k) { return k + ' ' + a[k]; }).join(' · '); case 'set_rings': return a.count + ' rings'; case 'set_tempo': return a.bpm + ' bpm'; case 'transport': return a.action + (a.bar ? ' · bar ' + a.bar : ''); case 'set_macros': return ['warmth', 'dust', 'threshold'].filter(function (k) { return a[k] != null; }) .map(function (k) { return k + ' ' + a[k]; }).join(' · '); default: return ''; } } function addTool(name, args, source) { const li = document.createElement('li'); li.className = 'call call--tool'; li.innerHTML = '
' + '' + timeNow() + '' + '' + '' + (source || 'api') + '' + '…' + '
' + '
' + esc(argLine(name, args)) + '
'; li.querySelector('.call-name').textContent = name; li.title = JSON.stringify(args); logEl.appendChild(li); scrollLog(); return li; } function summarize(name, res) { if (!res) return ''; if (res.error) return 'error: ' + res.error; switch (name) { case 'get_state': return (res.stones ? res.stones.length : 0) + ' stones · bar ' + res.transport.bar + ' · ' + res.cores.length + ' cores · ' + res.view; case 'list_cores': return res.cores.length + ' cores: ' + res.cores.map(function (c) { return c.id; }).join(', '); case 'extract_core': return res.name + ' · ' + res.dur + 's · ' + res.seal; case 'fracture_core': return res.id + ' · re-cut · ' + res.loud.slice(0, 3).join(', '); case 'strike_face': return 'struck ' + res.core + ' f' + String(res.face + 1).padStart(2, '0'); case 'place_stone': return res.vein + ' · bars ' + res.bars[0] + '–' + res.bars[1] + (res.ring ? ' · ring ' + res.ring : ' · once'); case 'move_stone': return 'bars ' + res.bars[0] + '–' + res.bars[1]; case 'delete_stone': return 'buried'; case 'set_weather': return Object.keys(res.weather).map(function (k) { return k + ' ' + (+res.weather[k]).toFixed(2); }).join(' · '); case 'set_rings': return 'rings ' + res.rings; case 'set_tempo': return res.bpm + ' bpm'; case 'transport': return (res.playing ? 'playing' : 'paused') + ' · bar ' + res.bar; case 'open_core': return 'quarry · ' + res.core; case 'set_macros': return Object.keys(res).map(function (k) { return k + ' ' + (+res[k]).toFixed(2); }).join(' · '); default: return JSON.stringify(res).slice(0, 140); } } function finishTool(li, ms, res) { if (!li) return; const t = li.querySelector('.call-ms'); if (t) t.textContent = ms; const name = li.querySelector('.call-name').textContent; const read = name === 'get_state' || name === 'list_cores'; const line = document.createElement('div'); line.className = 'call-result'; line.textContent = (res && res.error ? 'error · ' : (read ? '' : 'ok · ')) + summarize(name, res); if (res && res.error) line.style.color = 'rgba(232,64,42,.8)'; li.appendChild(line); scrollLog(); } /* ————— the verbs ————— */ async function exec(name, a) { a = a || {}; if (!E) throw new Error('no engine'); switch (name) { case 'get_state': { return { transport: { playing: E.isPlaying(), bar: +E.getPos().toFixed(2), bpm: E.getBpm() }, weather: Q ? Q.weather() : E.weather, rings: Q ? Q.rings() : E.rings, cores: E.cores.map(function (c) { return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, loud: c.ranked.slice(0, 4) }; }), stones: Z.stones(), shape: Z.shape(), view: document.body.dataset.view, selected_core: Q && Q.selectedCore() ? Q.selectedCore().id : null }; } case 'list_cores': { return { cores: E.cores.map(function (c) { return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, faces: 16, loud: c.ranked.slice(0, 5), at: c.ranked.slice(0, 5).map(function (i) { return +c.faces[i].start.toFixed(2); }) }; }) }; } case 'extract_core': { const c = E.newCore({ name: (a.name || 'a core from the quarry').slice(0, 40), seal: a.seal || 'found' }); E.addLog('cores.extract', c.name, c.seal, 'api'); return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, loud: c.ranked.slice(0, 4) }; } case 'fracture_core': { const c = E.refracture(a.core_id); if (!c) throw new Error('no such core: ' + a.core_id); if (Q) Q.refresh(); return { id: c.id, loud: c.ranked.slice(0, 4), faces: c.faces.length }; } case 'strike_face': { const core = E.byId(a.core_id); if (!core) throw new Error('no such core: ' + a.core_id); const face = clamp(Math.round(+a.face || 0), 0, 15); E.strike(core.id, face, { pitch: 0, gain: 0.55 + 0.4 * (a.velocity == null ? 1 : a.velocity), ghost: true }); return { core: core.id, face: face, struck: true }; } case 'place_stone': { const r = Z.addStoneAt({ vein: a.vein, core: a.core_id, face: a.face, start: a.start_bar, length: a.length_bars, ring: a.ring_bars, pitch: a.pitch, gain: a.gain, name: a.name, actor: 'api' }); if (r.error) throw new Error(r.error); return r; } case 'move_stone': { const r = Z.moveStoneAt(a.stone_id, a.start_bar); if (r.error) throw new Error(r.error); return r; } case 'delete_stone': { const r = Z.deleteStoneAt(a.stone_id); if (r.error) throw new Error(r.error); return r; } case 'set_weather': { const w = Q ? Q.setWeatherUI({ age: a.age, pressure: a.pressure, air: a.air, heat: a.heat }) : E.weather; return { weather: w }; } case 'set_rings': { return { rings: Q ? Q.setRingsUI(a.count) : E.rings }; } case 'set_tempo': return { bpm: Z.setTempo(a.bpm) }; case 'transport': return Z.transport(a.action, a.bar); case 'open_core': { if (Q) { Q.setView('quarry'); Q.focusCore(a.core_id); } return { core: a.core_id, view: 'quarry' }; } case 'set_macros': { const out = {}; ['warmth', 'dust', 'threshold'].forEach(function (k) { if (a[k] != null) out[k] = Z.setMacroUI(k, a[k]); }); return out; } default: throw new Error('unknown tool: ' + name); } } async function invokeTool(name, args, source) { const li = addTool(name, args, source); const t0 = performance.now(); let res; try { res = await exec(name, args); } catch (e) { res = { error: String(e && e.message || e) }; } finishTool(li, ((performance.now() - t0) / 1000).toFixed(1) + 's', res); return res; } /* ————— the model loop ————— */ function trimHistory() { const h = history.slice(); while (h.length > 28) { const i = h.findIndex(function (m) { return m.role === 'user'; }); if (i < 0) break; h.splice(0, i + 1); } return h; } async function modelSay(text) { history.push({ role: 'user', content: text }); setStatus('thinking', true); let ran = 0; let lastError = null; let wireDown = false; for (let step = 0; step < 6; step++) { let r, j; try { r = await fetch(API + '/api/agent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: trimHistory() }), signal: AbortSignal.timeout(95000) }); j = await r.json().catch(function () { return null; }); } catch (e) { lastError = e; wireDown = true; break; } if (!r.ok) { lastError = new Error((j && j.error) || ('agent ' + r.status)); if (j && j.mode === 'local') { wireDown = true; live = false; } break; } const m = j && j.message; if (!m) { lastError = new Error('empty reply'); break; } history.push(m); if (m.content && m.content.trim()) addSay(m.content.trim()); if (!m.tool_calls || !m.tool_calls.length) { lastError = null; break; } for (let k = 0; k < m.tool_calls.length; k++) { const tc = m.tool_calls[k]; const name = tc.function && tc.function.name; let args = {}; try { args = JSON.parse((tc.function && tc.function.arguments) || '{}'); } catch (e) {} setStatus('calling ' + name, true); const res = await invokeTool(name, args, 'api'); ran++; history.push({ role: 'tool', tool_call_id: tc.id, name: name, content: JSON.stringify(res) }); } } if (lastError) { if (wireDown) { live = false; document.body.classList.add('agent-offline'); if (netText) netText.textContent = 'local'; } addSay('the choir lost the wire: ' + lastError.message + '.'); if (!ran) localSay(text, true); } } /* ————— local conductor (no model) ————— */ async function localSay(text, afterError) { const t = text.toLowerCase(); const core = (Q && Q.selectedCore()) || E.cores[0]; if (!core) { addSay('there is no material in the archive yet.'); return; } const done = []; const wantsStop = /(stop|стоп|пауза|pause)/.test(t); const wantsPlay = /(play|играй|пуск|запусти|start)/.test(t); if (wantsStop) { await invokeTool('transport', { action: 'pause' }, 'local'); done.push('paused'); } else if (wantsPlay) { await invokeTool('transport', { action: 'play' }, 'local'); done.push('the wheels are turning'); } if (/(fracture|cut|реж|трещин|разрез)/.test(t)) { await invokeTool('fracture_core', { core_id: core.id }, 'local'); done.push('re-cut ' + core.id); } else if (/(take|bring|extract|найд|принес|достань|новый|room|поле|запись)/.test(t)) { const c = await invokeTool('extract_core', { name: text.slice(0, 42), seal: 'found' }, 'local'); done.push('the choir brought ' + c.name); } else if (/(dark|темн|стар|old|fossil|истощ)/.test(t)) { const w = await invokeTool('set_weather', { age: 0.72, air: 0.3 }, 'local'); done.push('weather: age 0.72, air 0.30'); } else if (/(wet|мокр|room|воздух|air|простор)/.test(t)) { await invokeTool('set_weather', { air: 0.74, pressure: 0.3 }, 'local'); done.push('weather: air 0.74, pressure 0.30'); } const wantsPlace = /(place|add|layer|слой|добавь|постав|loop|петл|пульс|bass|низ|голос|шум|звук|дыш)/.test(t); if (wantsPlace || !done.length) { let vein = 'silt'; if (/(bass|низ|бас|пол)/.test(t)) vein = 'bass'; else if (/(voice|голос|хор|петь)/.test(t)) vein = 'voice'; else if (/(noise|шум|воздух|ветер)/.test(t)) vein = 'noise'; else if (/(crack|трещ|грит)/.test(t)) vein = 'crack'; const face = E.rankFace(core.id, vein === 'bass' ? 0 : 1); const start = Math.min(30, Math.ceil(E.getPos()) + 1); const ring = /(once|один|раз|one)/.test(t) ? 0 : 2; const pitch = vein === 'bass' ? -5 : 0; const res = await invokeTool('place_stone', { vein: vein, core_id: core.id, face: face, start_bar: start, length_bars: 8, ring_bars: ring, pitch: pitch, gain: 0.8 }, 'local'); if (!res.error) done.push('laid ' + res.name + ' in ' + res.vein + ', bars ' + res.bars[0] + '–' + res.bars[1]); } addSay((afterError ? 'the wire is down, so i worked by hand. ' : '') + (done.length ? done.join('. ') + '.' : 'i listened, but found nothing to change.')); } async function say(text) { text = String(text || '').trim(); if (!text) return; if (busy) { addSay('one at a time — the choir is still working.'); return; } busy = true; addYou(text); try { if (live) { await modelSay(text); } else { setStatus('local', false); await localSay(text, false); } } catch (e) { addSay('the choir failed: ' + (e && e.message || e)); } finally { busy = false; setStatus(live ? 'listening' : 'local', false); } } /* ————— boot ————— */ async function init() { const meta = $('.state-meta'); if (meta) meta.textContent = TOOL_NAMES.length + ' tools'; try { const r = await fetch(API + '/api/agent', { signal: AbortSignal.timeout(3000) }); const j = await r.json(); live = !!j.live; } catch (e) { live = false; } document.body.classList.toggle('agent-offline', !live); if (netText) netText.textContent = live ? 'live' : 'local'; const chip = $('.chip'); if (chip) { chip.classList.toggle('is-live', live); chip.title = live ? 'Embedded agent: live model on the local server' : 'Embedded agent: local conductor (no model on the wire)'; } setStatus(live ? 'listening' : 'local', false); if (!welcomed) { welcomed = true; addSay(live ? 'the choir is listening. say what should be heard — i can take cores, cut faces, lay stones, or change the weather.' : 'the choir is local: no model on the wire. i can still take, cut, and lay by rule.'); } } window.ZHILA_AGENT = { say: say, isLive: function () { return live; }, isBusy: function () { return busy; }, tools: TOOL_NAMES.length }; if (document.readyState === 'complete' || document.readyState === 'interactive') { window.setTimeout(init, 250); } else { document.addEventListener('DOMContentLoaded', function () { window.setTimeout(init, 250); }); } })();