diff --git a/.codex/config.toml b/.codex/config.toml index d27aac6..27910be 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,2 +1,6 @@ [mcp_servers.voidbot] -url = "http://127.0.0.1:17875/mcp" +command = "C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\node\\bin\\node.exe" +args = ["E:\\Projects\\VoidBot\\scripts\\serve-voidbot-mcp-bridge.mjs"] + +[mcp_servers.voidbot.env] +VOIDBOT_MCP_HTTP_URL = "http://127.0.0.1:17875/mcp" diff --git a/README.md b/README.md index 9e1876e..f805da8 100644 --- a/README.md +++ b/README.md @@ -618,7 +618,7 @@ Moderation heartbeat, person-shaped rumination, physiology, memory maintenance, Chronological Discord evidence is acquired by the daemon's bounded evidence source. Semantic retrieval remains available for deeper context, but it does not own the heartbeat clock or cursor. -Agent access to Discord history and indexed sources executes inside the archive owner. The resident Yggdrasil worker publishes a stateless streamable-HTTP MCP endpoint on loopback `127.0.0.1:17875/mcp`, backed by the worker's existing archive repositories and retrieval service. The canonical SSH tunnel maps that endpoint to Starfire loopback, and `.codex/config.toml` connects to the URL directly. No agent tool call starts SSH, Docker, Node, or a second retrieval process. Verify the complete path with `VOIDBOT_MCP_HTTP_URL=http://127.0.0.1:17875/mcp node scripts/smoke-voidbot-resident-mcp-http.mjs`. +Agent access to Discord history and indexed sources executes inside the archive owner. The resident Yggdrasil worker publishes a stateless streamable-HTTP MCP endpoint on loopback `127.0.0.1:17875/mcp`, backed by the worker's existing archive repositories and retrieval service. The canonical SSH tunnel maps that endpoint to Starfire loopback. `.codex/config.toml` starts one task-local stdio bridge that forwards each tool request over HTTP and converts a deployment/tunnel gap into a normal tool error instead of permanently closing Codex's transport. The bridge owns no retrieval state and starts no SSH, Docker, or remote process. Verify the failure path with `QDRANT_URL=http://127.0.0.1:16333 node scripts/smoke-voidbot-mcp-bridge-recovery.mjs`. There is also a repo-weather helper: ```bash diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index f4cf8f5..6d75ea9 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -214,7 +214,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - The one-minute Persona scheduler pulse is an observation clock, not an inference cadence. With the swarm brake present it returns before registry, Discord, embedding, storage, or dispatch work; current Ygg logs show `selected: []` and `queued: []`. Unprompted speech also has a separate global admission window. Do not cool token use by making observation blind; keep turn authorization at the initiative owner. - Revision `f97e67b` adds the portable moderation-heartbeat coordinator and enforcement organ behind a separate disabled-by-default 15-minute cadence. The model returns an exact review ledger plus urgent IDs and typed case operations; missing evidence or unaccounted urgent evidence blocks cursor advancement. The attempt is receipted by latest message before inference, so one unchanged archive window cannot burn tokens repeatedly. Case enforcement is separate, scans typed pending debt on every cadence, and resolves a case only after a required Discord ban succeeds. Ygg's first `log_only` window at `0629fc7` failed safely with one 401, zero cases, zero cursor movement, and zero sanctions; both supported auth-header shapes prove the mounted MiMo credential itself is stale. `VOID_MODERATION_DAEMON_ENABLED` is false again pending a refreshed provider credential or deliberate alternate model port. - The shared Bifrost Discord mouth now lives in `packages/core/src/bifrost-discord-command.ts`. Worker repo-Face speech and resident Void candidate delivery use the same stable-id typed command, provider-store write, optional Bifrost pump, and completed-receipt check. The candidate organ selects at most one targeted queued candidate and writes its speech receipt/`spoken` transition only after transport completes. Ygg mounts the Bifrost provider store and CultLib runtime into the scheduler; `VOID_CANDIDATE_DELIVERY_ENABLED` controls this transport-only organ independently from the disabled moderation/rumination model cadence. -- Discord archive/source retrieval is a resident worker organ on Yggdrasil. `apps/worker/src/mcp-http-server.ts` publishes stateless streamable HTTP at `127.0.0.1:17875/mcp` and receives the worker's existing archive repositories, source ingester, and retrieval service by injection. The canonical workstation SSH tunnel maps the same Starfire loopback port to Ygg; repo and global Codex config use the URL directly. No tool call starts SSH, Docker, Node, or a second retrieval body. `scripts/smoke-voidbot-resident-mcp-http.mjs` proves tool advertisement, indexed repo listing, and source search through that resident endpoint. +- Discord archive/source retrieval is a resident worker organ on Yggdrasil. `apps/worker/src/mcp-http-server.ts` publishes stateless streamable HTTP at `127.0.0.1:17875/mcp` and receives the worker's existing archive repositories, source ingester, and retrieval service by injection. The canonical workstation SSH tunnel maps the same Starfire loopback port to Ygg. Codex attaches to `scripts/serve-voidbot-mcp-bridge.mjs`, a task-lifetime stdio transport bridge with no retrieval authority; each tool request uses HTTP to the daemon, and a deployment/tunnel gap returns one MCP tool error without closing the outer transport. It starts no SSH, Docker, or remote Node process. `scripts/smoke-voidbot-mcp-bridge-recovery.mjs` proves one outer client succeeds before an endpoint outage, receives an error during it, and succeeds again after recovery. - Resident person-shaped rumination is wired behind `VOID_RUMINATION_DAEMON_ENABLED` with its own bounded cadence, Discord evidence window, dedicated doctrine, rules, and voice inputs. The pressure fingerprint is receipted before the model attempt and unchanged pressure cannot retry on the observation clock. Ygg explicitly keeps this false while the MiMo credential is stale; candidate delivery may remain enabled because it performs no inference and the live queue was empty at rollout. - Revisions `c0cf875` and `1527b29` extract person-shaped rumination and candidate delivery without scheduling them. Rumination context, operation policy, advocacy accountability, tool-free text actuation, and coordinator are portable; the attempt key hashes meaning-bearing pressure/evidence and ignores elapsed clock, so unchanged state cannot create periodic thought. Candidate delivery is deterministic and parent-owned: one targeted queued candidate, duplicate-receipt refusal, injected Bifrost port, then one `mark_candidate_intervention_spoken` operation with the transport receipt. The next cut is extracting the worker's existing typed Bifrost Discord command path into a shared port rather than duplicating CultMesh mechanics. - Deterministic state-packet assembly now lives in `persona-state-packet-projector.ts`, which takes typed state, registry/room evidence, pronoun guidance, curiosity facts, and an explicit observation time. The shell no longer owns section ordering or social-context composition. The downstream regex vocabulary cleaner was deleted: it was a symptom compensator rewriting ordinary words such as `witness` and `canary` after projection. A smoke proves literal source language survives the projector unchanged. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 3f6ba01..ceca631 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -111,7 +111,7 @@ VoidBot runs on Yggdrasil. Idunn owns deployment and consumes upstream repositor 5. `scripts/reconcile-source-repos.ts` discovers local Git repos, refreshes push hooks, prunes stale repo shards, and indexes newly discovered repos. 6. `scripts/index-source-repos.ts` and `scripts/git-post-push-index.mjs` drive explicit or detached per-repo source/lore reindex work. 7. `apps/worker/src/mcp-server.ts` boots the MCP lane, while `mcp-server-resources.ts` and `mcp-server-tools.ts` expose retrieval to Codex and other sessions through `search_history`, `get_message_context`, `search_sources`, `get_source_context`, and `list_indexed_repos`. -8. The resident worker starts `mcp-http-server.ts` beside its job loop and injects the same long-lived archive repositories, source ingester, and retrieval service used by the daemon. It publishes stateless streamable HTTP at Ygg loopback `127.0.0.1:17875/mcp`. The canonical SSH tunnel maps that endpoint to Starfire loopback, and Codex connects by URL. HTTP protocol handlers are request-scoped, but no request starts a process, an SSH session, a container exec, or a second retrieval authority. Qdrant stays loopback-private on Yggdrasil; agents receive MCP tools, not a raw vector-database route. The retained stdio entrypoint exists only for bounded child-provider use inside the worker body. +8. The resident worker starts `mcp-http-server.ts` beside its job loop and injects the same long-lived archive repositories, source ingester, and retrieval service used by the daemon. It publishes stateless streamable HTTP at Ygg loopback `127.0.0.1:17875/mcp`, mapped to Starfire by the canonical SSH tunnel. Codex connects to one task-local `serve-voidbot-mcp-bridge.mjs` process over stdio. That bridge owns only outer transport continuity: it creates a lightweight HTTP MCP client per tool request, converts daemon/tunnel unavailability into an ordinary tool error, and remains alive for later calls. It cannot read archives, Qdrant, repositories, or Void state itself and starts no SSH, Docker, or remote process. Qdrant stays loopback-private on Yggdrasil. The separate retained stdio server entrypoint exists only for bounded child-provider capability isolation inside the worker body. ## Flow 4: Ops And Recovery diff --git a/scripts/serve-voidbot-mcp-bridge.mjs b/scripts/serve-voidbot-mcp-bridge.mjs new file mode 100644 index 0000000..9f25374 --- /dev/null +++ b/scripts/serve-voidbot-mcp-bridge.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +const endpoint = new URL(process.env.VOIDBOT_MCP_HTTP_URL ?? "http://127.0.0.1:17875/mcp"); +let cachedTools; + +const server = new Server( + { name: "voidbot-transport-bridge", version: "1.0.0" }, + { capabilities: { tools: {} } }, +); + +server.setRequestHandler(ListToolsRequestSchema, async () => { + try { + cachedTools = await withDaemon((client) => client.listTools()); + return cachedTools; + } catch (error) { + if (cachedTools) { + return cachedTools; + } + throw error; + } +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + return await withDaemon((client) => client.callTool(request.params)); + } catch (error) { + return { + isError: true, + content: [{ + type: "text", + text: `VoidBot daemon is temporarily unavailable: ${describeError(error)}`, + }], + }; + } +}); + +async function withDaemon(operation) { + const client = new Client({ name: "voidbot-transport-bridge-upstream", version: "1.0.0" }); + try { + await client.connect(new StreamableHTTPClientTransport(endpoint)); + return await operation(client); + } finally { + await client.close().catch(() => undefined); + } +} + +function describeError(error) { + return error instanceof Error ? error.message : String(error); +} + +await server.connect(new StdioServerTransport()); diff --git a/scripts/smoke-voidbot-mcp-bridge-recovery.mjs b/scripts/smoke-voidbot-mcp-bridge-recovery.mjs new file mode 100644 index 0000000..6e43346 --- /dev/null +++ b/scripts/smoke-voidbot-mcp-bridge-recovery.mjs @@ -0,0 +1,53 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +import { createVoidbotMcpContext } from "../apps/worker/dist/mcp-server-context.js"; +import { startVoidbotMcpHttpServer } from "../apps/worker/dist/mcp-http-server.js"; + +const port = Number.parseInt(process.env.VOIDBOT_MCP_BRIDGE_SMOKE_PORT ?? "17876", 10); +const endpoint = `http://127.0.0.1:${port}/mcp`; +const context = createVoidbotMcpContext(); +let listener = await startVoidbotMcpHttpServer(context, { host: "127.0.0.1", port }); +const client = new Client({ name: "voidbot-mcp-bridge-recovery-smoke", version: "1.0.0" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: ["scripts/serve-voidbot-mcp-bridge.mjs"], + env: { ...process.env, VOIDBOT_MCP_HTTP_URL: endpoint }, + stderr: "inherit", +}); + +try { + await client.connect(transport); + const before = await client.callTool({ name: "list_indexed_repos", arguments: {} }); + assertSuccessful(before, "before outage"); + + await closeListener(listener); + const during = await client.callTool({ name: "list_indexed_repos", arguments: {} }); + if (!during.isError) { + throw new Error("Bridge did not lower daemon outage into a tool error."); + } + + listener = await startVoidbotMcpHttpServer(context, { host: "127.0.0.1", port }); + const after = await client.callTool({ name: "list_indexed_repos", arguments: {} }); + assertSuccessful(after, "after recovery"); + + process.stdout.write(`${JSON.stringify({ + ok: true, + beforeRepoCount: before.structuredContent?.repoCount, + outageWasToolError: during.isError, + afterRepoCount: after.structuredContent?.repoCount, + })}\n`); +} finally { + await client.close().catch(() => undefined); + await closeListener(listener).catch(() => undefined); +} + +function assertSuccessful(result, phase) { + if (result.isError || !result.structuredContent?.repoCount) { + throw new Error(`Bridge call failed ${phase}.`); + } +} + +async function closeListener(server) { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 52f3ef3..53bc961 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -458,3 +458,4 @@ {"ts":"2026-07-16T16:01:14Z","type":"repair","status":"ok","note":"Agent Discord archive access failed because Codex launched a Starfire-local MCP process whose QDRANT_URL was loopback after the archive moved to Yggdrasil. Repo and global Codex MCP config now execute the stdio MCP server inside Ygg's worker container over SSH; Qdrant remains private. A real MCP client advertised 20 tools and search_history returned two archive hits."} {"ts":"2026-07-19T14:58:00Z","type":"repair","status":"ok","note":"A raw SSH docker-exec MCP stream made every open agent task inherit the lifetime of Yggdrasil's replaceable worker container; an Idunn promotion therefore left tasks at Transport closed even after the new worker was healthy. Codex now attaches to a stable local stdio relay which opens one fresh remote MCP session per request. A real relay smoke advertised 20 tools, listed 52 indexed repositories, and returned a VoidBot source hit; direct history and source retrieval against Ygg also passed."} {"ts":"2026-07-19T14:25:00Z","type":"architecture","status":"ok","note":"The per-call SSH MCP relay was identified as a transport compensator and deleted. The resident Yggdrasil worker now owns a loopback streamable-HTTP MCP organ injected with its existing archive repositories, source ingester, and retrieval service. The canonical workstation tunnel owns the sole Starfire crossing at 127.0.0.1:17875; Codex uses the URL and tool calls start no processes or alternate retrieval authorities."} +{"ts":"2026-07-19T20:10:00Z","type":"repair","status":"ok","note":"Direct Codex streamable-HTTP registration still closed permanently after a brief Idunn worker deployment or SSH-tunnel gap; stateless server sessions did not imply client transport recovery. Codex now attaches to a task-local stdio continuity bridge with no retrieval authority. Each tool call uses a lightweight HTTP client to the resident daemon, and the bridge lowers endpoint failure to a tool error. A single outer MCP client succeeded before a forced endpoint outage, received isError during the outage, and succeeded again after the same endpoint returned."} diff --git a/state/map.yaml b/state/map.yaml index 1f6520f..7cd9565 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -225,7 +225,7 @@ accepted_design: - Eve v2 live state has one owner path. CultLib dev supplies typed live-publication handles, authenticated retained provider sessions, reconnect evidence, and application receipts (`ebd8c78c`, `d4934233`, `8965f3c0`). Eve resolves component `stateBindings` and owns subscription teardown/rebind without receipt DOM writes (`85b0139`, `522df00`). Hermodr ingests provider sessions and emits ordered snapshot/update/stale/reconnected events without polling (`5cb819e`, `561e320`). VoidBot publishes accepted heat as typed control state and binds both visible heat components to it (`060da23`, `3d86b93`). Live-fire proved initial, provider-driven, and browser-command updates converge through that one path. - `voidbot.swarm_operator_view.v1` is the live authority for swarm summary, queue, selected Face detail/channels, operations, pressure, pending mentions, recent events, and route state. Eve binds those values as text/list props; the composition renderer no longer computes those browser-visible facts from its generation snapshot. The static HTML artifact may still lower a snapshot for debugging, but it cannot own live browser state. - The production Eve browser nerve is Starfire Chrome -> `http://10.77.0.1:8798` -> Hermodr on Yggdrasil -> Odin discovery and provider-owned VoidBot RUDP on Yggdrasil. Hermodr's browser listener is WireGuard-only, its authenticated retained-publication ingress is host-loopback `127.0.0.1:17874`, and VoidBot remains the state/command owner at `127.0.0.1:17873`. Starfire owns no Hermodr, Odin, or VoidBot deployment body. - - The resident Yggdrasil worker owns the streamable-HTTP MCP organ at loopback `127.0.0.1:17875/mcp` and injects its existing archive repositories, vector stores, and retrieval service into the MCP context. Codex reaches it at the same Starfire loopback port through the canonical Yggdrasil SSH tunnel. No agent tool call starts a process or opens a separate retrieval authority. Qdrant remains Yggdrasil-loopback-private; `scripts/smoke-voidbot-resident-mcp-http.mjs` proves the network MCP contract and real indexed-source retrieval. + - The resident Yggdrasil worker owns the streamable-HTTP MCP organ at loopback `127.0.0.1:17875/mcp` and injects its existing archive repositories, vector stores, and retrieval service into the MCP context. The canonical Yggdrasil SSH tunnel maps the same port to Starfire. Codex attaches to one task-local stdio bridge whose only authority is transport continuity: each tool request uses a fresh lightweight HTTP client to the resident daemon, daemon/tunnel outages become tool errors, and the outer Codex transport remains usable afterward. The bridge starts no SSH, Docker, or remote process and owns no retrieval state. `scripts/smoke-voidbot-mcp-bridge-recovery.mjs` proves success, outage-as-tool-error, and recovery on one unchanged outer client. - Long-running work should expose durable logs or status files instead of relying on one attached session and hope. - Persistent project memory must stay ruthlessly distilled; git history and artifacts carry routine proof.