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

@@ -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 {