From 167a214098fd84b87cec4793333fed55b1e73653 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 2 Jun 2026 15:51:55 +0100 Subject: [PATCH] Expose Odin discovery to Face turns --- .env.example | 1 + apps/worker/src/mcp-server-shared.ts | 18 + apps/worker/src/mcp-server-tools.ts | 398 ++++++++++++++++++ packages/core/src/codex-mcp.ts | 1 + .../providers/src/owner-codex-provider.ts | 3 + prompts/owner-discord-reply.prompt.md | 2 +- .../repo-face-research-capabilities.prompt.md | 4 +- state/evidence.jsonl | 1 + 8 files changed, 426 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index fb7824f..bf79752 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,7 @@ REPO_FACE_HEARTBEAT_CODEX_REASONING_EFFORT=low EPIPHANY_AGENT_ROOT=E:/Projects/EpiphanyAgent BIFROST_ROOT=E:/Projects/Bifrost BIFROST_HEALTH_URL=http://127.0.0.1:5080/healthz +ODIN_BASE_URL=http://127.0.0.1:8797 INDEX_ALL_CHANNELS=false INDEXED_CHANNEL_IDS= EXCLUDED_CHANNEL_IDS= diff --git a/apps/worker/src/mcp-server-shared.ts b/apps/worker/src/mcp-server-shared.ts index 879e674..ba60f12 100644 --- a/apps/worker/src/mcp-server-shared.ts +++ b/apps/worker/src/mcp-server-shared.ts @@ -50,6 +50,15 @@ export const applyRepoFaceStateOperationInputSchema = { export const runtimeInfoInputSchema = {}; +export const odinEndpointInputSchema = { + odinBaseUrl: z.string().url().optional(), +}; + +export const odinSurfaceInputSchema = { + odinBaseUrl: z.string().url().optional(), + providerId: z.string().min(1).optional(), +}; + export const searchSourcesInputSchema = { query: z.string().min(1).max(240), limit: z.number().int().min(1).max(MAX_RETRIEVAL_RESULT_LIMIT).optional(), @@ -111,6 +120,15 @@ export interface RuntimeInfoArgs { [key: string]: never; } +export interface OdinEndpointArgs { + odinBaseUrl?: string; +} + +export interface OdinSurfaceArgs { + odinBaseUrl?: string; + providerId?: string; +} + export interface ListIndexedReposArgs { [key: string]: never; } diff --git a/apps/worker/src/mcp-server-tools.ts b/apps/worker/src/mcp-server-tools.ts index 52ff19c..7c55f44 100644 --- a/apps/worker/src/mcp-server-tools.ts +++ b/apps/worker/src/mcp-server-tools.ts @@ -1,5 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import crypto from "node:crypto"; +import net from "node:net"; import { DEFAULT_RETRIEVAL_RESULT_LIMIT } from "@voidbot/shared"; import { searchHistoryWithArchiveFallback } from "@voidbot/rag"; @@ -25,6 +27,8 @@ import { type ListIndexedReposArgs, type MessageContextArgs, type NotifyOwnerArgs, + type OdinEndpointArgs, + type OdinSurfaceArgs, type ApplyRepoFaceStateOperationArgs, type PostDiscordMessageArgs, type PostRepoIdentityMessageArgs, @@ -39,6 +43,8 @@ import { applyRepoFaceStateOperationInputSchema, messageContextInputSchema, notifyOwnerInputSchema, + odinEndpointInputSchema, + odinSurfaceInputSchema, postDiscordMessageInputSchema, postRepoIdentityMessageInputSchema, repoFaceStateInputSchema, @@ -56,6 +62,8 @@ const READ_ONLY_ANNOTATIONS = { openWorldHint: false, } as const; +const DEFAULT_ODIN_BASE_URL = "http://127.0.0.1:8797"; + function isMcpToolAllowed(name: string): boolean { const raw = process.env.VOIDBOT_MCP_TOOL_ALLOWLIST?.trim(); if (!raw) { @@ -114,6 +122,123 @@ export function registerVoidbotTools( }, ); + registerIfAllowed( + "list_odin_providers", + { + title: "List Odin Providers", + description: + "List Eve/CultUI providers currently advertised through Odin. This is read-only Verse discovery; use it to learn which provider-owned surfaces Odin can see.", + inputSchema: odinEndpointInputSchema, + annotations: READ_ONLY_ANNOTATIONS, + }, + async (input: OdinEndpointArgs): Promise => { + const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl); + const catalog = await fetchOdinProviderCatalog(odinBaseUrl); + + return { + content: [ + { + type: "text", + text: renderJsonBlock({ + odinBaseUrl, + providerCount: catalog.providers.length, + providers: catalog.providers, + }), + }, + ], + structuredContent: { + odinBaseUrl, + providerCount: catalog.providers.length, + providers: catalog.providers, + }, + }; + }, + ); + + registerIfAllowed( + "list_odin_verses", + { + title: "List Odin Verses", + description: + "Read Odin's current all-seer surface and list the Verse/service nodes it publishes. This is read-only; Odin remains the registry owner.", + inputSchema: odinEndpointInputSchema, + annotations: READ_ONLY_ANNOTATIONS, + }, + async (input: OdinEndpointArgs): Promise => { + const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl); + const snapshot = await fetchOdinDeckSnapshot(odinBaseUrl); + const verses = summarizeOdinVerses(snapshot); + + return { + content: [ + { + type: "text", + text: renderJsonBlock({ + odinBaseUrl, + providerId: snapshot.providerId, + title: snapshot.title, + version: snapshot.version, + updatedAt: snapshot.updatedAt, + verseCount: verses.length, + verses, + }), + }, + ], + structuredContent: { + odinBaseUrl, + providerId: snapshot.providerId, + title: snapshot.title, + version: snapshot.version, + updatedAt: snapshot.updatedAt, + verseCount: verses.length, + verses, + }, + }; + }, + ); + + registerIfAllowed( + "get_odin_surface", + { + title: "Get Odin Surface", + description: + "Read Odin's current all-seer Eve/CultUI surface, or ask Odin for one provider surface by providerId. Use this for read-only interface inspection, not command execution.", + inputSchema: odinSurfaceInputSchema, + annotations: READ_ONLY_ANNOTATIONS, + }, + async (input: OdinSurfaceArgs): Promise => { + const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl); + const snapshot = await fetchOdinDeckSnapshot(odinBaseUrl, input.providerId); + const surfaceSummary = summarizeOdinSurface(snapshot); + + return { + content: [ + { + type: "text", + text: renderJsonBlock({ + odinBaseUrl, + providerId: snapshot.providerId, + title: snapshot.title, + version: snapshot.version, + updatedAt: snapshot.updatedAt, + selectedNodeId: snapshot.selectedNodeId, + surface: surfaceSummary, + }), + }, + ], + structuredContent: { + odinBaseUrl, + providerId: snapshot.providerId, + title: snapshot.title, + version: snapshot.version, + updatedAt: snapshot.updatedAt, + selectedNodeId: snapshot.selectedNodeId, + surface: surfaceSummary, + }, + }; + }, + ); + registerIfAllowed( "search_history", { @@ -906,3 +1031,276 @@ function identityForToolResult( doctrine: face ? renderFaceIdentityDoctrine(face) : null, }; } + +function normalizeOdinBaseUrl(value: string | undefined): string { + return (value ?? process.env.ODIN_BASE_URL ?? DEFAULT_ODIN_BASE_URL).replace(/\/+$/, ""); +} + +async function fetchOdinProviderCatalog(odinBaseUrl: string): Promise<{ providers: Array> }> { + const response = await fetch(`${odinBaseUrl}/eve/deck/providers`); + if (!response.ok) { + throw new Error(`Odin provider catalog failed: HTTP ${response.status}`); + } + const payload = await response.json() as { providers?: unknown }; + const providers = Array.isArray(payload.providers) + ? payload.providers.filter(isRecord) + : []; + return { providers }; +} + +async function fetchOdinDeckSnapshot( + odinBaseUrl: string, + providerId?: string, +): Promise> { + const deckUrl = new URL(odinBaseUrl); + deckUrl.protocol = deckUrl.protocol === "https:" ? "wss:" : "ws:"; + deckUrl.pathname = "/eve/deck"; + deckUrl.search = ""; + const socket = await openWebSocketSnapshotSocket(deckUrl); + try { + if (providerId) { + sendClientWebSocketText(socket, JSON.stringify({ type: "open-provider", providerId })); + } + for (let attempt = 0; attempt < 8; attempt += 1) { + const message = await readServerWebSocketText(socket, 3000); + const parsed = JSON.parse(message) as unknown; + if (!isRecord(parsed)) { + continue; + } + if (!providerId || parsed.providerId === providerId) { + return parsed; + } + } + throw new Error(providerId + ? `Odin did not publish provider ${providerId} in the snapshot window.` + : "Odin did not publish a deck snapshot."); + } finally { + socket.destroy(); + } +} + +function summarizeOdinVerses(snapshot: Record) { + const root = getSurfaceRoot(snapshot); + const children = Array.isArray(root?.children) ? root.children.filter(isRecord) : []; + return children + .filter((child) => child.kind === "verse") + .map((child) => { + const props = isRecord(child.props) ? child.props : {}; + const services = Array.isArray(props.services) + ? props.services.filter(isRecord).map((service) => ({ + id: stringValue(service.id), + name: stringValue(service.name), + state: stringValue(service.state), + detail: stringValue(service.detail), + })) + : []; + return { + id: stringValue(props.verseId) || stringValue(child.id), + title: stringValue(props.title), + role: stringValue(props.role), + status: stringValue(props.status), + capabilities: Array.isArray(props.capabilities) ? props.capabilities.map(String) : [], + serviceCount: services.length, + services, + }; + }); +} + +function summarizeOdinSurface(snapshot: Record) { + const root = getSurfaceRoot(snapshot); + const children = Array.isArray(root?.children) ? root.children.filter(isRecord) : []; + const nodes = Array.isArray(snapshot.nodes) ? snapshot.nodes.filter(isRecord) : []; + const interfaces = children + .filter((child) => child.kind === "interface") + .map((child) => { + const props = isRecord(child.props) ? child.props : {}; + return { + id: stringValue(child.id), + providerId: stringValue(props.providerId), + title: stringValue(props.title), + status: stringValue(props.status), + detail: stringValue(props.detail), + source: stringValue(props.source), + version: props.version ?? null, + updatedAt: stringValue(props.updatedAt), + rootKind: child.children && Array.isArray(child.children) && isRecord(child.children[0]) + ? stringValue(child.children[0].kind) + : "", + }; + }); + const observationStreams = children + .filter((child) => child.kind === "pane" && isRecord(child.props) && child.props.title === "Device Observation Streams") + .flatMap((pane) => Array.isArray(pane.children) ? pane.children.filter(isRecord) : []) + .filter((child) => child.kind === "observation-stream") + .map((child) => { + const props = isRecord(child.props) ? child.props : {}; + return { + id: stringValue(child.id), + deviceId: stringValue(props.deviceId), + streamId: stringValue(props.streamId), + kind: stringValue(props.kind), + state: stringValue(props.state), + latestAt: stringValue(props.latestAt), + }; + }); + + return { + schema: isRecord(snapshot.surface) ? stringValue(snapshot.surface.schema) : "", + rootId: stringValue(root?.id), + rootKind: stringValue(root?.kind), + title: isRecord(root?.props) ? stringValue(root.props.title) : "", + summary: isRecord(root?.props) ? stringValue(root.props.summary) : "", + nodeCount: nodes.length, + childCount: children.length, + interfaceCount: interfaces.length, + interfaces, + observationStreamCount: observationStreams.length, + observationStreams, + }; +} + +function getSurfaceRoot(snapshot: Record): Record | undefined { + const surface = isRecord(snapshot.surface) ? snapshot.surface : undefined; + return isRecord(surface?.root) ? surface.root : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : value === null || value === undefined ? "" : String(value); +} + +function openWebSocketSnapshotSocket(url: URL): Promise { + return new Promise((resolve, reject) => { + if (url.protocol === "wss:") { + reject(new Error("Odin WSS is not supported by this lightweight MCP snapshot client yet.")); + return; + } + const port = Number(url.port || 80); + const socket = net.createConnection({ host: url.hostname, port, timeout: 3000 }); + const key = crypto.randomBytes(16).toString("base64"); + let buffer = Buffer.alloc(0); + socket.on("connect", () => { + socket.write([ + `GET ${url.pathname || "/eve/deck"} HTTP/1.1`, + `Host: ${url.hostname}:${port}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13", + "", + "", + ].join("\r\n")); + }); + socket.on("data", function onHandshake(chunk) { + buffer = Buffer.concat([buffer, chunk]); + const marker = buffer.indexOf("\r\n\r\n"); + if (marker < 0) { + return; + } + const header = buffer.subarray(0, marker).toString("latin1"); + if (!header.startsWith("HTTP/1.1 101")) { + reject(new Error(header.split(/\r?\n/)[0] || "Odin websocket handshake failed")); + socket.destroy(); + return; + } + socket.off("data", onHandshake); + socket.unshift(buffer.subarray(marker + 4)); + resolve(socket); + }); + socket.on("timeout", () => { + reject(new Error("Odin websocket connection timed out")); + socket.destroy(); + }); + socket.on("error", reject); + }); +} + +function sendClientWebSocketText(socket: net.Socket, text: string): void { + const payload = Buffer.from(text, "utf8"); + const mask = crypto.randomBytes(4); + const header = [0x81]; + if (payload.length < 126) { + header.push(0x80 | payload.length); + } else if (payload.length <= 0xffff) { + header.push(0x80 | 126, payload.length >> 8, payload.length & 0xff); + } else { + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(payload.length)); + header.push(0x80 | 127, ...length); + } + const masked = Buffer.from(payload.map((byte, index) => byte ^ mask[index % 4])); + socket.write(Buffer.concat([Buffer.from(header), mask, masked])); +} + +function readServerWebSocketText(socket: net.Socket, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const timer = setTimeout(() => cleanup(new Error("Timed out waiting for Odin deck frame")), timeoutMs); + function cleanup(error?: Error, value?: string) { + clearTimeout(timer); + socket.off("data", onData); + socket.off("error", onError); + if (error) { + reject(error); + } else { + resolve(value ?? ""); + } + } + function onError(error: Error) { + cleanup(error); + } + function onData(chunk: Buffer) { + buffer = Buffer.concat([buffer, chunk]); + const frame = tryReadWebSocketFrame(buffer); + if (!frame) { + return; + } + buffer = buffer.subarray(frame.consumed); + if (frame.opcode === 0x1) { + cleanup(undefined, frame.payload.toString("utf8")); + } else if (frame.opcode === 0x8) { + cleanup(new Error("Odin websocket closed")); + } + } + socket.on("data", onData); + socket.on("error", onError); + }); +} + +function tryReadWebSocketFrame(buffer: Buffer): { opcode: number; payload: Buffer; consumed: number } | null { + if (buffer.length < 2) { + return null; + } + const opcode = buffer[0] & 0x0f; + const masked = Boolean(buffer[1] & 0x80); + let length = buffer[1] & 0x7f; + let offset = 2; + if (length === 126) { + if (buffer.length < offset + 2) { + return null; + } + length = buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + if (buffer.length < offset + 8) { + return null; + } + length = Number(buffer.readBigUInt64BE(offset)); + offset += 8; + } + const mask = masked ? buffer.subarray(offset, offset + 4) : null; + if (masked) { + offset += 4; + } + if (buffer.length < offset + length) { + return null; + } + let payload = buffer.subarray(offset, offset + length); + if (mask) { + payload = Buffer.from(payload.map((byte, index) => byte ^ mask[index % 4])); + } + return { opcode, payload, consumed: offset + length }; +} diff --git a/packages/core/src/codex-mcp.ts b/packages/core/src/codex-mcp.ts index 312b356..c2a328d 100644 --- a/packages/core/src/codex-mcp.ts +++ b/packages/core/src/codex-mcp.ts @@ -12,6 +12,7 @@ export function buildVoidMcpServerConfig(workspaceRoot: string): CodexMcpServerC cwd: workspaceRoot, env: { VOIDBOT_WORKSPACE_ROOT: workspaceRoot, + ...(process.env.ODIN_BASE_URL ? { ODIN_BASE_URL: process.env.ODIN_BASE_URL } : {}), }, }; } diff --git a/packages/providers/src/owner-codex-provider.ts b/packages/providers/src/owner-codex-provider.ts index b395755..27053ec 100644 --- a/packages/providers/src/owner-codex-provider.ts +++ b/packages/providers/src/owner-codex-provider.ts @@ -551,6 +551,9 @@ function restrictMcpServersToRepoFaceExploration( "list_indexed_repos", "search_sources", "get_source_context", + "list_odin_providers", + "list_odin_verses", + "get_odin_surface", ].join(","); return servers.map((server) => ({ diff --git a/prompts/owner-discord-reply.prompt.md b/prompts/owner-discord-reply.prompt.md index d1e1d10..68d3fa6 100644 --- a/prompts/owner-discord-reply.prompt.md +++ b/prompts/owner-discord-reply.prompt.md @@ -28,7 +28,7 @@ Rules: - Do not look for private memory machinery, Discord posting machinery, owner notification machinery, runtime information, or identity registries. - Do not explain how you got here or describe private machinery in public speech unless the room is explicitly discussing that machinery. {{/if}} -{{#unless repoFaceTurn}}- The configured MCP tools are available in this session, especially search_history, get_message_context, list_indexed_repos, search_sources, and get_source_context.{{/unless}} +{{#unless repoFaceTurn}}- The configured MCP tools are available in this session, especially search_history, get_message_context, list_indexed_repos, search_sources, get_source_context, list_odin_providers, list_odin_verses, and get_odin_surface.{{/unless}} - If private persistent self-state is attached, treat it as the canonical current self-model for the speaking agent across the rumination loop and direct summons. - Let that attached self-state shape continuity of voice, priorities, remembered room patterns, and when a more proactive conversational posture would make sense. - If that self-state includes a current room snapshot, use it quietly as immediate conversational context. diff --git a/prompts/repo-face-research-capabilities.prompt.md b/prompts/repo-face-research-capabilities.prompt.md index a4a8af1..7ab6815 100644 --- a/prompts/repo-face-research-capabilities.prompt.md +++ b/prompts/repo-face-research-capabilities.prompt.md @@ -1,9 +1,11 @@ Research and memory access: - You may inspect your home repo, recent repo activity, indexed source/lore material, and archived room discussion when evidence would change what you think, say, propose, or remember. -- Your complete allowed tool set for this Face turn is: `search_history`, `get_message_context`, `list_indexed_repos`, `search_sources`, `get_source_context`. +- Your complete allowed tool set for this Face turn is: `search_history`, `get_message_context`, `list_indexed_repos`, `search_sources`, `get_source_context`, `list_odin_providers`, `list_odin_verses`, `get_odin_surface`. +- Use Odin tools for live CultMesh Verse/interface discovery: what Odin can currently see, which provider-owned surfaces are present, and what the all-seer surface reports. This is read-only visibility, not command authority. - Do not discover tools. Do not inventory MCP resources. Do not call private state, identity-introspection, or MCP inventory tools such as `read_repo_face_state`, `list_mcp_resources`, or `read_mcp_resource`; your relevant state and available context have already been projected into lived memory, needs, mood, and relationship pressure. - Use repo/source search when your thought depends on what is actually in {{repoName}}, when a lore/design/code seam may already have an answer, or when a concrete proposal needs an anchor. - Use archive/history search when the question is what people previously decided, how a character/term/project has been discussed, or whether a room pattern is repeating. +- Use Odin discovery when your thought depends on live Verse/provider presence, service/interface status, or whether a new adapter daemon is visible to the mesh. - If this turn explicitly says a jurisdiction dive is due, make a source/history search tool call before writing conclusions, proposals, or "I checked" language. - Never say you checked, read, inspected, searched, or verified repo/archive/source text unless you actually used the available search/context tools during this turn. - Do not perform a search as ritual. If the projected memory already gives enough support for a small social reply or private reflection, speak or think from that. If you are about to make a factual claim, propose repo work, or declare a seam settled, look for evidence first. diff --git a/state/evidence.jsonl b/state/evidence.jsonl index aa3e6a8..149934b 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -319,3 +319,4 @@ {"ts":"2026-05-24T09:22:43Z","type":"implementation","status":"ok","note":"Repo Face prompt surfaces and global Codex AGENTS.md now frame agents as Body/Mind-aware projections of the Great Colossus: communication is living collective cognition, Faces exist to create connectedness without erasing boundaries, and the Interpreter should retry turns that shrink a Colossus-scale correction into stale local proof/checklist language. Verified assembled Aqua and Nibu prompts inherit the global directive and the Face-specific Body/Mind/Colossus instructions."} {"ts":"2026-05-24T09:27:01Z","type":"implementation","status":"ok","note":"Corrected the Colossus organ doctrine after over-flattening Epiphany anatomy into Body/Mind and then over-expanding every Face into the whole organism. Global AGENTS now says agents must understand which organ they serve as; VoidBot is explicitly a prototype for Face. Repo Face prompts now frame each identity as a Face-shaped projection with Body/Mind/signal boundaries, while Eyes/Hands/Soul/Life/Imagination/Self/Body-modeling/nervous-system remain distinct Epiphany organs with their own architectures."} {"ts":"2026-05-24T09:35:42Z","type":"implementation","status":"ok","note":"Queried Epiphany's CultCache-backed public Face surface before updating prompt doctrine: Face is an embodied public surface with dense Ghostlight-style state, not the whole organism. Repo Face and global AGENTS prompts now preserve fractal anatomy: top-level Epiphany organs remain distinct, while each Face may have local Face-scale Self/Face/Imagination/Eyes/Body/Hands/Soul/Life/signal faculties. Projection smoke also exposed and fixed projector fallback classification for model/tool unsupported errors."} +{"ts":"2026-06-02T14:51:41Z","type":"implementation","status":"ok","note":"Repo Face/Persona Codex turns now have read-only Odin visibility through VoidBot MCP tools list_odin_providers, list_odin_verses, and get_odin_surface. The repo-face MCP allowlist and prompt doctrine include those tools, preserving Odin as registry owner and leaving command authority outside Face turns."}