Implement Sophia MVP stages 4-7 (introspection, queries, persistence, polish)

Brings the system from "engrams cluster + form synapses" to a complete
end-to-end demo: ingest text, watch it cluster, ask questions, restart
with state intact.

- Stage 4: birth introspection (taxonomy/goals/open_questions via LLM,
  bounded by the global parallel-op semaphore), per-engram memory log,
  click-to-inspect side panel.
- Stage 5: queries as conversations. POST /api/galaxy/:id/query embeds
  the question, materializes a pinned Query-Engram at the donut center,
  runs broadcast retrieval (global cosine scan + 1-hop synaptic
  expansion with attenuation) and fans out responder LLM calls. The
  integrator runs every 2s on accumulated snippets and streams the
  refining answer back over SSE; responders briefly transition to
  Conversing on the WS bus so the right dots light up.
- Stage 6: snapshot persistence. sled-backed store keyed by galaxy id,
  JSON-encoded values (bincode chokes on internally-tagged enums like
  Manifest/MemoryKind), 60s periodic snapshot task, hydrate-on-boot,
  DELETE /api/galaxy/:id wired through. State survives kill -9.
- Stage 7: HUD additions (sim ticks/sec, LLM queue depth, FPS) via a
  new GET /api/stats polled at 1Hz. `sophia demo` subcommand boots the
  server then auto-ingests a 50-paragraph corpus baked into the binary
  with include_str!. README quickstart added.

Token caps for query_responder/integrator bumped (gemma-4-e4b is a
thinking model — output budget must cover hidden reasoning + visible
answer, otherwise content comes back empty). Pinned engrams skip
physics; their tick scheduling is also skipped at materialization so
they stay perfectly still at the donut center.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 11:53:11 +02:00
parent 8688f632bf
commit bae084cd76
36 changed files with 3137 additions and 58 deletions

View File

@@ -94,6 +94,127 @@
}
#ingest button:hover { background: #232c36; }
#ingest button:disabled { opacity: 0.5; cursor: progress; }
#query {
position: fixed;
bottom: 12px;
left: 12px;
width: 380px;
background: rgba(0,0,0,0.55);
padding: 10px;
border-radius: 4px;
font-size: 12px;
}
#query-form { display: flex; gap: 6px; }
#query-input {
flex: 1;
font: inherit;
background: #0c1014;
color: #cfd6df;
border: 1px solid #2a323d;
border-radius: 3px;
padding: 5px 8px;
}
#query-submit {
font: inherit;
background: #1a2129;
color: #cfd6df;
border: 1px solid #2a323d;
padding: 4px 12px;
border-radius: 3px;
cursor: pointer;
}
#query-submit:hover { background: #232c36; }
#query-submit:disabled { opacity: 0.5; cursor: progress; }
#query-status {
margin-top: 6px;
opacity: 0.55;
font-size: 11px;
min-height: 1.2em;
}
#query-answer {
margin-top: 4px;
white-space: pre-wrap;
line-height: 1.45;
max-height: 180px;
overflow-y: auto;
padding: 6px 8px;
background: rgba(255,255,255,0.03);
border-radius: 3px;
min-height: 2em;
}
#query-answer:empty::before {
content: "ask what your knowledge knows…";
opacity: 0.4;
}
#inspector {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
width: 460px;
max-height: 70vh;
overflow-y: auto;
background: rgba(0,0,0,0.78);
padding: 12px 14px;
border-radius: 4px;
border: 1px solid #2a323d;
font-size: 12px;
line-height: 1.45;
display: none;
}
#inspector.open { display: block; }
#inspector h3 {
margin: 0 0 6px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.6;
font-weight: 500;
}
#inspector .field { margin-bottom: 8px; }
#inspector .label {
opacity: 0.45;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 2px;
}
#inspector .value { color: #e1e6ec; }
#inspector .muted { color: #6b7480; }
#inspector ul {
margin: 2px 0 0 14px;
padding: 0;
}
#inspector ul li { margin-bottom: 1px; }
#inspector .row {
display: flex;
justify-content: space-between;
align-items: center;
}
#inspector .close {
cursor: pointer;
background: transparent;
border: none;
color: #cfd6df;
font-size: 16px;
line-height: 1;
padding: 0 4px;
opacity: 0.7;
}
#inspector .close:hover { opacity: 1; }
#inspector .manifest {
white-space: pre-wrap;
max-height: 8em;
overflow-y: auto;
font-size: 11px;
opacity: 0.85;
}
#inspector .id {
font-family: ui-monospace, monospace;
font-size: 10px;
opacity: 0.5;
word-break: break-all;
}
</style>
</head>
<body>
@@ -105,6 +226,9 @@
<div><span class="k">torus</span> <span id="hud-torus"></span></div>
<div><span class="k">engrams</span> <span id="hud-count">0</span></div>
<div><span class="k">synapses</span> <span id="hud-synapses">0</span></div>
<div><span class="k">sim</span> <span id="hud-ticks"></span></div>
<div><span class="k">llm queue</span> <span id="hud-llm-queue"></span></div>
<div><span class="k">fps</span> <span id="hud-fps"></span></div>
<div><span class="k">lm studio</span> <span id="hud-lm"></span></div>
<div><span class="k">health</span> <span id="hud-health"></span></div>
</div>
@@ -119,6 +243,27 @@
<button id="resize-btn">resize</button>
</div>
</div>
<div id="inspector">
<div class="row">
<h3>engram</h3>
<button class="close" id="inspector-close" title="close">×</button>
</div>
<div class="field id" id="inspector-id"></div>
<div class="field"><span class="label">state</span><div class="value" id="inspector-state"></div></div>
<div class="field"><span class="label">manifest</span><div class="manifest" id="inspector-manifest"></div></div>
<div class="field"><span class="label">taxonomy</span><div class="value" id="inspector-taxonomy"></div></div>
<div class="field"><span class="label">goals</span><div class="value" id="inspector-goals"></div></div>
<div class="field"><span class="label">open questions</span><div class="value" id="inspector-questions"></div></div>
<div class="field"><span class="label">memories</span><div class="value" id="inspector-memories"></div></div>
</div>
<div id="query">
<form id="query-form">
<input id="query-input" type="text" placeholder="What did we ingest about…?" autocomplete="off" />
<button id="query-submit" type="submit">ask</button>
</form>
<div id="query-status"></div>
<div id="query-answer"></div>
</div>
<div id="ingest">
<textarea id="ingest-text" placeholder="One engram per line. Empty lines are skipped.&#10;e.g.&#10;Pasta carbonara uses guanciale, eggs, pecorino, and pepper.&#10;Backpropagation computes gradients of a loss with respect to weights.&#10;The fall of Constantinople occurred in 1453."></textarea>
<div class="row">

View File

@@ -105,6 +105,18 @@ export class EngramMesh {
/** Optional hook fired when an engram's base color is set/updated. The
* trail renderer subscribes so head + tail share the same colour. */
public onColorAssigned: ((idx: number, r: number, g: number, b: number) => void) | null = null;
/** Reverse lookup: instance_idx → engram UUID. Filled on upsert; used by
* the click-picker to map a raycast hit back to an engram id. */
public readonly idxToId: string[] = [];
/** Forward lookup: engram UUID → instance_idx. Mirrors `idxToId` so live
* state-change events (which carry the UUID, not the slot index) can find
* the right vertex to re-paint. */
private readonly idToIdx = new Map<string, number>();
/** Expose the underlying `THREE.Points` so the scene can raycast against it. */
pointsObject(): THREE.Points {
return this.points;
}
constructor(scene: THREE.Scene, capacity = 5000) {
this.capacity = capacity;
@@ -125,6 +137,12 @@ export class EngramMesh {
geom.setAttribute("aSize", this.sizeAttr);
geom.setAttribute("aHash", this.hashAttr);
geom.setDrawRange(0, 0);
// Set a permanent oversized bounding sphere. Without this, three.js
// computes one once based on the initial all-zero positions (radius 0)
// and Points.raycast() short-circuits — every click misses. Recomputing
// per frame is expensive; a giant fixed sphere always passes the early
// reject and the per-vertex test then runs normally.
geom.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), 100_000);
this.material = new THREE.ShaderMaterial({
vertexShader: VERT_SHADER,
@@ -197,6 +215,28 @@ export class EngramMesh {
if (idx + 1 > this.maxIdx) this.maxIdx = idx + 1;
this.points.geometry.setDrawRange(0, this.maxIdx);
this.idxToId[idx] = e.id;
this.idToIdx.set(e.id, idx);
}
/**
* Re-paint the dot for an existing engram when its lifecycle state
* changes (Stage 5: responder lights up to Conversing while answering a
* query, Query-Engram transitions Searching→Memorize at completion).
* Silently ignored if the engram isn't known yet — state-change events
* for unfamiliar ids can race ahead of the corresponding `engram_created`
* over the WS bus during a reconnect window.
*/
setStateById(id: string, state: string): void {
const idx = this.idToIdx.get(id);
if (idx === undefined) return;
const color = STATE_COLOR[state] ?? STATE_COLOR.idle;
const colorArr = this.colorAttr.array as Float32Array;
colorArr[idx * 3] = color[0];
colorArr[idx * 3 + 1] = color[1];
colorArr[idx * 3 + 2] = color[2];
this.colorAttr.needsUpdate = true;
this.onColorAssigned?.(idx, color[0], color[1], color[2]);
}
applyPositionFrame(frame: PositionFrame): void {

179
web/src/inspector.ts Normal file
View File

@@ -0,0 +1,179 @@
import * as THREE from "three";
import type { EngramMesh } from "./engram_mesh";
/**
* Click-to-inspect: raycast the mouse into the engram point-cloud, fetch
* `/api/galaxy/:gid/engrams/:eid` for the picked engram, and render the
* result in the side panel. Re-clicking an already-open engram refreshes
* the panel (handy because introspection arrives async after birth).
*/
type EngramDetail = {
id: string;
instance_idx: number;
position: [number, number, number];
size: number;
state: string;
age: number;
manifest: { kind: string; content?: string } | null;
slate_dim: number | null;
slate_norm: number | null;
introspection: {
taxonomy: string[];
goals: string[];
open_questions: string[];
};
memories: Array<{
at_ms: number;
kind: { kind: string; with?: string; weight?: number };
}>;
};
// World-space radius around each engram vertex that counts as a "hit" when
// raycasting. Has to be generous because the rendered dot is enlarged by
// bloom + sprite scaling — the user expects to be able to click anywhere
// the dot *appears*, not the exact 1-unit vertex position.
const PICK_THRESHOLD = 12;
// Pixels of cursor movement between mousedown and mouseup that still count
// as a "click" rather than a drag. Trackpads in particular need slack.
const CLICK_DRAG_TOLERANCE_PX = 8;
const panel = document.getElementById("inspector");
const idEl = document.getElementById("inspector-id");
const stateEl = document.getElementById("inspector-state");
const manifestEl = document.getElementById("inspector-manifest");
const taxonomyEl = document.getElementById("inspector-taxonomy");
const goalsEl = document.getElementById("inspector-goals");
const questionsEl = document.getElementById("inspector-questions");
const memoriesEl = document.getElementById("inspector-memories");
const closeBtn = document.getElementById("inspector-close");
closeBtn?.addEventListener("click", () => {
panel?.classList.remove("open");
});
let lastPickedId: string | null = null;
function shortId(id: string): string {
return id.length > 12 ? `${id.slice(0, 8)}${id.slice(-4)}` : id;
}
function bullets(items: string[]): string {
if (!items || items.length === 0) return `<span class="muted">…awaiting LLM…</span>`;
return `<ul>${items.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}</ul>`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtMemories(mems: EngramDetail["memories"]): string {
if (!mems || mems.length === 0) return `<span class="muted">none yet</span>`;
const items = mems.map((m) => {
const t = (m.at_ms / 1000).toFixed(1) + "s";
let body: string;
switch (m.kind.kind) {
case "born":
body = "born";
break;
case "introspected":
body = "introspected (LLM)";
break;
case "synapse_formed":
body = `${shortId(m.kind.with ?? "")} (sim ${(m.kind.weight ?? 0).toFixed(2)})`;
break;
default:
body = m.kind.kind;
}
return `<li><span class="muted">+${t}</span> ${escapeHtml(body)}</li>`;
});
return `<ul>${items.join("")}</ul>`;
}
function render(d: EngramDetail): void {
if (!panel) return;
if (idEl) idEl.textContent = d.id;
if (stateEl) stateEl.textContent = `${d.state} · age ${d.age} · slot ${d.instance_idx}`;
if (manifestEl) {
manifestEl.textContent = d.manifest?.content ?? "(synthetic seed — no manifest)";
}
if (taxonomyEl) taxonomyEl.innerHTML = bullets(d.introspection?.taxonomy ?? []);
if (goalsEl) goalsEl.innerHTML = bullets(d.introspection?.goals ?? []);
if (questionsEl) questionsEl.innerHTML = bullets(d.introspection?.open_questions ?? []);
if (memoriesEl) memoriesEl.innerHTML = fmtMemories(d.memories ?? []);
panel.classList.add("open");
}
async function fetchAndRender(galaxyId: string, engramId: string): Promise<void> {
try {
const res = await fetch(`/api/galaxy/${galaxyId}/engrams/${engramId}`);
if (!res.ok) {
console.warn(`engram fetch ${res.status}`);
return;
}
const detail = (await res.json()) as EngramDetail;
render(detail);
} catch (e) {
console.warn("engram fetch failed", e);
}
}
/**
* Wire up click-picking on the canvas. The engram point cloud is already in
* the scene; we raycast against it and look up the engram id from the hit
* vertex index.
*/
export function attachInspector(opts: {
canvas: HTMLCanvasElement;
camera: THREE.Camera;
engrams: EngramMesh;
galaxyId: string;
}): void {
const raycaster = new THREE.Raycaster();
raycaster.params.Points = { threshold: PICK_THRESHOLD };
const mouse = new THREE.Vector2();
let downX = 0, downY = 0;
// Use mousedown + mouseup with a tiny movement tolerance so dragging the
// OrbitControls doesn't trigger a pick.
opts.canvas.addEventListener("mousedown", (ev) => {
downX = ev.clientX;
downY = ev.clientY;
});
opts.canvas.addEventListener("mouseup", (ev) => {
const moved = Math.hypot(ev.clientX - downX, ev.clientY - downY);
if (moved > CLICK_DRAG_TOLERANCE_PX) return;
const rect = opts.canvas.getBoundingClientRect();
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, opts.camera);
const hits = raycaster.intersectObject(opts.engrams.pointsObject(), false);
console.debug(`[inspector] click ndc=(${mouse.x.toFixed(2)},${mouse.y.toFixed(2)}) hits=${hits.length}`);
if (hits.length === 0) return;
// Pick the closest hit.
const hit = hits[0];
const idx = hit.index;
if (idx === undefined) return;
const id = opts.engrams.idxToId[idx];
if (!id) {
console.warn(`[inspector] hit at idx ${idx} has no engram id`);
return;
}
console.debug(`[inspector] picked engram ${id}`);
lastPickedId = id;
void fetchAndRender(opts.galaxyId, id);
});
// Light auto-refresh — re-fetch the currently-open engram every 3s so the
// user sees introspection populate and memories grow without re-clicking.
setInterval(() => {
if (!panel?.classList.contains("open")) return;
if (!lastPickedId) return;
void fetchAndRender(opts.galaxyId, lastPickedId);
}, 3000);
}

View File

@@ -11,6 +11,9 @@ const ingestBtn = document.getElementById("ingest-btn") as HTMLButtonElement | n
const ingestText = document.getElementById("ingest-text") as HTMLTextAreaElement | null;
const torusEl = document.getElementById("hud-torus");
const synapsesEl = document.getElementById("hud-synapses");
const ticksEl = document.getElementById("hud-ticks");
const llmQueueEl = document.getElementById("hud-llm-queue");
const fpsEl = document.getElementById("hud-fps");
const resizeBtn = document.getElementById("resize-btn") as HTMLButtonElement | null;
const majorInput = document.getElementById("resize-major") as HTMLInputElement | null;
const minorInput = document.getElementById("resize-minor") as HTMLInputElement | null;
@@ -93,8 +96,36 @@ async function bootstrap(): Promise<void> {
if (minorInput && document.activeElement !== minorInput) minorInput.value = String(minorR);
},
onSynapses: (n) => { if (synapsesEl) synapsesEl.textContent = String(n); },
onFps: (fps) => { if (fpsEl) fpsEl.textContent = fps.toFixed(0); },
});
// Poll /api/stats once a second for sim ticks/sec + LLM queue depth.
// The HUD numbers it drives (sim, llm queue) are server-side state, so
// the browser can't compute them locally — keep this loop lightweight
// and silent on transient fetch errors so a brief network hiccup doesn't
// pollute the console.
type StatsResponse = {
ticks_per_sec: number;
engrams_total: number;
synapses_total: number;
galaxies: number;
llm_queue_depth: number;
llm_parallel_cap: number;
};
setInterval(async () => {
try {
const r = await fetch("/api/stats");
if (!r.ok) return;
const s = (await r.json()) as StatsResponse;
if (ticksEl) ticksEl.textContent = `${s.ticks_per_sec}/s`;
if (llmQueueEl) {
llmQueueEl.textContent = `${s.llm_queue_depth} / ${s.llm_parallel_cap}`;
}
} catch {
// ignore — server might be restarting
}
}, 1000);
if (seedBtn && seedNInput) {
seedBtn.addEventListener("click", async () => {
const n = Math.max(1, Math.min(5000, parseInt(seedNInput.value, 10) || 200));

128
web/src/query_panel.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Stage 5 query panel.
*
* Owns the bottom-left text input + answer area + SSE consumer. Submission
* does `POST /api/galaxy/:id/query` and immediately opens an EventSource on
* `/api/query/:id/stream`; the running answer streams in as the integrator
* re-runs. Closes the EventSource on terminal status (`done` / `failed`).
*
* The visual feedback in the 3D scene (responder light-up, Query-Engram
* appearing at the donut center) lives in `scene.ts` — driven by separate
* WS events. This module only manages the side panel.
*/
import type { QueryStatus } from "./ws_client";
type QueryUpdate = {
query_id: string;
version: number;
status: QueryStatus;
answer: string;
responder_count: number;
};
export type QueryPanelOptions = {
galaxyId: string;
};
const TERMINAL: QueryStatus[] = ["done", "failed"];
export function attachQueryPanel(opts: QueryPanelOptions): void {
const form = document.getElementById("query-form") as HTMLFormElement | null;
const input = document.getElementById("query-input") as HTMLInputElement | null;
const submit = document.getElementById("query-submit") as HTMLButtonElement | null;
const answerEl = document.getElementById("query-answer");
const statusEl = document.getElementById("query-status");
if (!form || !input || !submit || !answerEl || !statusEl) {
console.warn("query panel markup missing — skipping wireup");
return;
}
let activeStream: EventSource | null = null;
function setBusy(busy: boolean): void {
submit!.disabled = busy;
input!.disabled = busy;
}
function renderUpdate(u: QueryUpdate): void {
answerEl!.textContent = u.answer || "(thinking…)";
const phase =
u.status === "pending"
? "broadcasting"
: u.status === "responding"
? `synthesising · ${u.responder_count} perspective${u.responder_count === 1 ? "" : "s"}`
: u.status === "integrating"
? "integrating"
: u.status === "done"
? `done · ${u.responder_count} perspective${u.responder_count === 1 ? "" : "s"}`
: "failed";
statusEl!.textContent = `v${u.version} · ${phase}`;
}
function closeStream(): void {
if (activeStream) {
activeStream.close();
activeStream = null;
}
}
async function submitQuery(text: string): Promise<void> {
setBusy(true);
closeStream();
answerEl!.textContent = "(asking…)";
statusEl!.textContent = "submitting";
let queryId: string;
try {
const res = await fetch(`/api/galaxy/${opts.galaxyId}/query`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`HTTP ${res.status}: ${body}`);
}
const body = (await res.json()) as { query_id: string };
queryId = body.query_id;
} catch (e) {
statusEl!.textContent = "failed";
answerEl!.textContent = `submit failed: ${(e as Error).message}`;
setBusy(false);
return;
}
activeStream = new EventSource(`/api/query/${queryId}/stream`);
activeStream.onmessage = (ev) => {
try {
const update = JSON.parse(ev.data) as QueryUpdate;
renderUpdate(update);
if (TERMINAL.includes(update.status)) {
closeStream();
setBusy(false);
}
} catch (err) {
console.warn("query SSE decode failed", err);
}
};
activeStream.onerror = () => {
// EventSource auto-reconnects on transient errors; but once the
// server has finished and closed the channel, the reconnect loop
// would just pile up 404s. The terminal-status check above already
// closed the stream in the success path; this handler covers the
// failure path where the channel never opened cleanly.
if (activeStream && activeStream.readyState === EventSource.CLOSED) {
statusEl!.textContent = "stream closed";
setBusy(false);
}
};
}
form.addEventListener("submit", (ev) => {
ev.preventDefault();
const text = input!.value.trim();
if (!text) return;
void submitQuery(text);
});
}

View File

@@ -8,6 +8,8 @@ import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
import { EngramMesh } from "./engram_mesh";
import { EngramTrails } from "./engram_trails";
import { SynapseMesh } from "./synapse_mesh";
import { attachInspector } from "./inspector";
import { attachQueryPanel } from "./query_panel";
import type { EngramSnapshot, EventHandlers, SynapseDto } from "./ws_client";
import { connectGalaxy } from "./ws_client";
@@ -16,6 +18,8 @@ export type SceneHooks = {
onGalaxyName: (name: string) => void;
onTorus: (majorRadius: number, minorRadius: number) => void;
onSynapses?: (count: number) => void;
/** Rolling FPS, updated about once per second from inside the rAF loop. */
onFps?: (fps: number) => void;
};
// State-color table mirrored from EngramMesh; needed here so we can pre-paint
@@ -174,6 +178,11 @@ export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: S
synapses.applyPositionFrame(frame);
hooks.onCount(engrams.count());
},
onEngramStateChanged: (id, state) => {
// Re-paint the dot for an engram whose state flipped (Stage 5
// responder light-up + Query-Engram → Memorize on completion).
engrams.setStateById(id, state);
},
onClose: () => {
console.warn("ws closed — refresh to reconnect");
},
@@ -181,6 +190,9 @@ export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: S
};
connectGalaxy(galaxyId, handlers);
attachInspector({ canvas, camera, engrams, galaxyId });
attachQueryPanel({ galaxyId });
function onResize(): void {
const w = window.innerWidth;
const h = window.innerHeight;
@@ -193,10 +205,22 @@ export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: S
window.addEventListener("resize", onResize);
const startedAt = performance.now();
// FPS: rolling counter — accumulate frames, emit roughly once a second.
let fpsWindowStart = startedAt;
let fpsFrames = 0;
function animate(): void {
controls.update();
engrams.tick((performance.now() - startedAt) / 1000);
const nowMs = performance.now();
engrams.tick((nowMs - startedAt) / 1000);
composer.render();
fpsFrames += 1;
const elapsed = nowMs - fpsWindowStart;
if (elapsed >= 1000) {
const fps = (fpsFrames * 1000) / elapsed;
hooks.onFps?.(fps);
fpsFrames = 0;
fpsWindowStart = nowMs;
}
requestAnimationFrame(animate);
}
animate();

View File

@@ -33,6 +33,13 @@ export type SynapseDto = {
weight: number;
};
export type QueryStatus =
| "pending"
| "responding"
| "integrating"
| "done"
| "failed";
export type SimEventMsg =
| {
type: "hello";
@@ -47,6 +54,27 @@ export type SimEventMsg =
center: [number, number, number];
major_radius: number;
minor_radius: number;
}
| { type: "engram_state_changed"; id: string; state: string }
| {
type: "query_started";
query: string;
engram: string;
position: [number, number, number];
text: string;
}
| {
type: "query_answer_updated";
query: string;
version: number;
status: QueryStatus;
answer: string;
}
| {
type: "query_finished";
query: string;
status: QueryStatus;
responder_count: number;
};
export type PositionFrame = {
@@ -69,6 +97,20 @@ export type EventHandlers = {
majorRadius: number,
minorRadius: number,
) => void;
onEngramStateChanged?: (id: string, state: string) => void;
onQueryStarted?: (
queryId: string,
engramId: string,
position: [number, number, number],
text: string,
) => void;
onQueryAnswerUpdated?: (
queryId: string,
version: number,
status: QueryStatus,
answer: string,
) => void;
onQueryFinished?: (queryId: string, status: QueryStatus, responderCount: number) => void;
onPositionFrame?: (frame: PositionFrame) => void;
onClose?: (ev: CloseEvent) => void;
onError?: (ev: Event) => void;
@@ -97,6 +139,18 @@ export function connectGalaxy(galaxyId: string, handlers: EventHandlers): WebSoc
case "torus_updated":
handlers.onTorusUpdated?.(msg.center, msg.major_radius, msg.minor_radius);
break;
case "engram_state_changed":
handlers.onEngramStateChanged?.(msg.id, msg.state);
break;
case "query_started":
handlers.onQueryStarted?.(msg.query, msg.engram, msg.position, msg.text);
break;
case "query_answer_updated":
handlers.onQueryAnswerUpdated?.(msg.query, msg.version, msg.status, msg.answer);
break;
case "query_finished":
handlers.onQueryFinished?.(msg.query, msg.status, msg.responder_count);
break;
}
} else {
const frame = decodePositionFrame(ev.data as ArrayBuffer);