From 87f7e91ea658375abf7f45498136f94ec3f90d77 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 2 Jun 2026 16:07:51 +0100 Subject: [PATCH] Lower Odin interfaces for Face control --- apps/worker/src/mcp-server-shared.ts | 32 ++ apps/worker/src/mcp-server-tools.ts | 487 ++++++++++++++++++ .../providers/src/owner-codex-provider.ts | 2 + prompts/owner-discord-reply.prompt.md | 2 +- .../repo-face-research-capabilities.prompt.md | 8 +- state/evidence.jsonl | 1 + 6 files changed, 528 insertions(+), 4 deletions(-) diff --git a/apps/worker/src/mcp-server-shared.ts b/apps/worker/src/mcp-server-shared.ts index ba60f12..cb8ce1d 100644 --- a/apps/worker/src/mcp-server-shared.ts +++ b/apps/worker/src/mcp-server-shared.ts @@ -59,6 +59,22 @@ export const odinSurfaceInputSchema = { providerId: z.string().min(1).optional(), }; +export const odinInterfaceContextInputSchema = { + odinBaseUrl: z.string().url().optional(), + providerId: z.string().min(1), + maxTextItems: z.number().int().min(1).max(80).optional(), + maxTreeItems: z.number().int().min(1).max(160).optional(), +}; + +export const odinInterfaceCommandInputSchema = { + odinBaseUrl: z.string().url().optional(), + providerId: z.string().min(1), + command: z.string().min(1), + payload: z.record(z.unknown()).optional(), + frame: z.record(z.unknown()).optional(), + expectReceiptMs: z.number().int().min(0).max(15000).optional(), +}; + export const searchSourcesInputSchema = { query: z.string().min(1).max(240), limit: z.number().int().min(1).max(MAX_RETRIEVAL_RESULT_LIMIT).optional(), @@ -129,6 +145,22 @@ export interface OdinSurfaceArgs { providerId?: string; } +export interface OdinInterfaceContextArgs { + odinBaseUrl?: string; + providerId: string; + maxTextItems?: number; + maxTreeItems?: number; +} + +export interface OdinInterfaceCommandArgs { + odinBaseUrl?: string; + providerId: string; + command: string; + payload?: Record; + frame?: Record; + expectReceiptMs?: number; +} + 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 a2a9616..4f2457d 100644 --- a/apps/worker/src/mcp-server-tools.ts +++ b/apps/worker/src/mcp-server-tools.ts @@ -28,6 +28,8 @@ import { type MessageContextArgs, type NotifyOwnerArgs, type OdinEndpointArgs, + type OdinInterfaceCommandArgs, + type OdinInterfaceContextArgs, type OdinSurfaceArgs, type ApplyRepoFaceStateOperationArgs, type PostDiscordMessageArgs, @@ -44,6 +46,8 @@ import { messageContextInputSchema, notifyOwnerInputSchema, odinEndpointInputSchema, + odinInterfaceCommandInputSchema, + odinInterfaceContextInputSchema, odinSurfaceInputSchema, postDiscordMessageInputSchema, postRepoIdentityMessageInputSchema, @@ -239,6 +243,127 @@ export function registerVoidbotTools( }, ); + registerIfAllowed( + "load_odin_interface_context", + { + title: "Load Odin Interface Context", + description: + "Load one provider-owned CultMesh/Eve interface visible through Odin and lower it into compact text, tree, and command context for an agent. This is token-efficient TUI access, not raw dashboard dumping.", + inputSchema: odinInterfaceContextInputSchema, + annotations: READ_ONLY_ANNOTATIONS, + }, + async (input: OdinInterfaceContextArgs): Promise => { + const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl); + const loaded = await loadOdinProviderInterface(odinBaseUrl, input.providerId); + const contextSummary = summarizeProviderInterface(loaded, { + maxTextItems: input.maxTextItems ?? 32, + maxTreeItems: input.maxTreeItems ?? 80, + }); + + return { + content: [ + { + type: "text", + text: renderJsonBlock({ + odinBaseUrl, + ...contextSummary, + }), + }, + ], + structuredContent: { + odinBaseUrl, + ...contextSummary, + }, + }; + }, + ); + + registerIfAllowed( + "invoke_odin_interface_command", + { + title: "Invoke Odin Interface Command", + description: + "Send an explicit command through a provider-owned Eve/CultUI command boundary discovered via Odin. The command must be advertised by the provider interface. Odin is not the side-effect owner; this relays to the provider endpoint when the interface exposes a WebSocket command transport.", + inputSchema: odinInterfaceCommandInputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (input: OdinInterfaceCommandArgs): Promise => { + const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl); + const loaded = await loadOdinProviderInterface(odinBaseUrl, input.providerId); + const command = findAdvertisedCommand(loaded, input.command); + + if (!command) { + return { + content: [ + { + type: "text", + text: `Provider ${input.providerId} does not advertise command "${input.command}". Load the interface context first and use one of its advertised commands.`, + }, + ], + structuredContent: { + sent: false, + reason: "command_not_advertised", + providerId: input.providerId, + command: input.command, + advertisedCommands: extractProviderCommands(loaded).map((entry) => entry.command), + }, + isError: true, + }; + } + + if (!loaded.source.startsWith("ws://")) { + return { + content: [ + { + type: "text", + text: `Provider ${input.providerId} is visible through ${loaded.source || "an unknown source"}, but this MCP relay only supports provider WebSocket command endpoints right now.`, + }, + ], + structuredContent: { + sent: false, + reason: "unsupported_command_transport", + providerId: input.providerId, + command: input.command, + source: loaded.source, + }, + isError: true, + }; + } + + const frame = input.frame ?? buildCommandFrame(input.providerId, command, input.payload ?? {}); + const receipt = await sendProviderCommandFrame(loaded.source, frame, input.expectReceiptMs ?? 5000); + + return { + content: [ + { + type: "text", + text: renderJsonBlock({ + sent: true, + providerId: input.providerId, + source: loaded.source, + command: input.command, + frame, + receipt, + }), + }, + ], + structuredContent: { + sent: true, + providerId: input.providerId, + source: loaded.source, + command: input.command, + frame, + receipt, + }, + }; + }, + ); + registerIfAllowed( "search_history", { @@ -1163,6 +1288,368 @@ function summarizeOdinSurface(snapshot: Record) { }; } +interface LoadedOdinInterface { + providerId: string; + title: string; + source: string; + status: string; + detail: string; + manifest: Record | null; + snapshot: Record; + root?: Record; + loadedFrom: "provider-websocket" | "odin-embedded-interface"; +} + +interface InterfaceCommandSummary { + command: string; + label: string; + source: "surface.commands" | "node.action" | "node.command"; + nodeId?: string; + transport?: string; + frameTemplate?: unknown; + payloadSchema?: unknown; + raw: Record; +} + +async function loadOdinProviderInterface( + odinBaseUrl: string, + providerId: string, +): Promise { + const odinSnapshot = await fetchOdinDeckSnapshot(odinBaseUrl); + const interfaceNode = findOdinInterfaceNode(odinSnapshot, providerId); + if (!interfaceNode) { + throw new Error(`Odin does not currently expose provider interface ${providerId}.`); + } + + const props = isRecord(interfaceNode.props) ? interfaceNode.props : {}; + const source = stringValue(props.source); + if (source.startsWith("ws://")) { + try { + const providerSnapshot = await fetchProviderDeckSnapshot(source, providerId); + return { + providerId, + title: stringValue(providerSnapshot.title) || stringValue(props.title) || providerId, + source, + status: stringValue(props.status), + detail: stringValue(props.detail), + manifest: isRecord(props.manifest) ? props.manifest : null, + snapshot: providerSnapshot, + root: getSurfaceRoot(providerSnapshot), + loadedFrom: "provider-websocket", + }; + } catch (error) { + const embedded = embeddedInterfaceRoot(interfaceNode); + if (!embedded) { + throw error; + } + return { + providerId, + title: stringValue(props.title) || providerId, + source, + status: stringValue(props.status), + detail: `provider websocket failed; using Odin embedded root: ${error instanceof Error ? error.message : String(error)}`, + manifest: isRecord(props.manifest) ? props.manifest : null, + snapshot: odinSnapshot, + root: embedded, + loadedFrom: "odin-embedded-interface", + }; + } + } + + return { + providerId, + title: stringValue(props.title) || providerId, + source, + status: stringValue(props.status), + detail: stringValue(props.detail), + manifest: isRecord(props.manifest) ? props.manifest : null, + snapshot: odinSnapshot, + root: embeddedInterfaceRoot(interfaceNode), + loadedFrom: "odin-embedded-interface", + }; +} + +async function fetchProviderDeckSnapshot( + deckUrlText: string, + providerId: string, +): Promise> { + const deckUrl = new URL(deckUrlText); + const connection = await openWebSocketSnapshotSocket(deckUrl); + const { socket } = connection; + let pendingBuffer = connection.initialBuffer; + try { + sendClientWebSocketText(socket, JSON.stringify({ type: "open-provider", providerId })); + for (let attempt = 0; attempt < 8; attempt += 1) { + const frame = await readServerWebSocketText(socket, 3000, pendingBuffer); + pendingBuffer = frame.remaining; + const parsed = JSON.parse(frame.text) as unknown; + if (isRecord(parsed) && parsed.providerId === providerId) { + return parsed; + } + } + throw new Error(`Provider deck did not publish ${providerId}.`); + } finally { + socket.destroy(); + } +} + +function findOdinInterfaceNode( + odinSnapshot: Record, + providerId: string, +): Record | undefined { + const root = getSurfaceRoot(odinSnapshot); + const children = Array.isArray(root?.children) ? root.children.filter(isRecord) : []; + return children.find((child) => + child.kind === "interface" && + isRecord(child.props) && + child.props.providerId === providerId, + ); +} + +function embeddedInterfaceRoot(interfaceNode: Record): Record | undefined { + const children = Array.isArray(interfaceNode.children) ? interfaceNode.children.filter(isRecord) : []; + return children[0]; +} + +function summarizeProviderInterface( + loaded: LoadedOdinInterface, + limits: { + maxTextItems: number; + maxTreeItems: number; + }, +) { + const surface = isRecord(loaded.snapshot.surface) ? loaded.snapshot.surface : {}; + const root = loaded.root; + const commands = extractProviderCommands(loaded); + const flatNodes = flattenInterfaceNodes(root, limits.maxTreeItems); + const textItems = flatNodes + .map((entry) => entry.text) + .filter((entry): entry is string => Boolean(entry)) + .slice(0, limits.maxTextItems); + + return { + providerId: loaded.providerId, + title: loaded.title, + source: loaded.source, + status: loaded.status, + detail: loaded.detail, + loadedFrom: loaded.loadedFrom, + updatedAt: stringValue(loaded.snapshot.updatedAt), + surface: { + schema: stringValue(surface.schema), + rootId: stringValue(root?.id), + rootKind: stringValue(root?.kind), + summary: isRecord(root?.props) ? stringValue(root.props.summary) : "", + }, + commands, + textItems, + tree: flatNodes, + }; +} + +function flattenInterfaceNodes( + root: Record | undefined, + limit: number, +) { + const output: Array<{ + id: string; + kind: string; + title: string; + text?: string; + status?: string; + command?: string; + depth: number; + }> = []; + + function visit(node: Record | undefined, depth: number): void { + if (!node || output.length >= limit) { + return; + } + const props = isRecord(node.props) ? node.props : {}; + const action = isRecord(props.action) ? props.action : undefined; + const command = stringValue(props.command) || stringValue(node.command) || stringValue(action?.command) || stringValue(action?.type); + output.push({ + id: stringValue(node.id), + kind: stringValue(node.kind), + title: stringValue(props.title) || stringValue(props.label), + text: stringValue(props.text) || stringValue(props.detail) || undefined, + status: stringValue(props.status) || stringValue(props.tone) || undefined, + command: command || undefined, + depth, + }); + const children = Array.isArray(node.children) ? node.children.filter(isRecord) : []; + for (const child of children) { + visit(child, depth + 1); + } + } + + visit(root, 0); + return output; +} + +function extractProviderCommands(loaded: LoadedOdinInterface): InterfaceCommandSummary[] { + const commands: InterfaceCommandSummary[] = []; + const surface = isRecord(loaded.snapshot.surface) ? loaded.snapshot.surface : {}; + const surfaceCommands = Array.isArray(surface.commands) + ? surface.commands.filter(isRecord) + : Array.isArray(loaded.snapshot.commands) + ? loaded.snapshot.commands.filter(isRecord) + : []; + + for (const command of surfaceCommands) { + const commandName = stringValue(command.command) || stringValue(command.id); + if (!commandName) { + continue; + } + commands.push({ + command: commandName, + label: stringValue(command.label) || commandName, + source: "surface.commands", + transport: stringValue(command.transport), + frameTemplate: command.frameTemplate, + payloadSchema: command.payloadSchema, + raw: command, + }); + } + + for (const nodeCommand of extractNodeCommands(loaded.root)) { + if (!commands.some((entry) => entry.command === nodeCommand.command && entry.nodeId === nodeCommand.nodeId)) { + commands.push(nodeCommand); + } + } + + return commands; +} + +function extractNodeCommands(root: Record | undefined): InterfaceCommandSummary[] { + const commands: InterfaceCommandSummary[] = []; + function visit(node: Record | undefined): void { + if (!node) { + return; + } + const props = isRecord(node.props) ? node.props : {}; + const action = isRecord(props.action) ? props.action : undefined; + const commandName = stringValue(props.command) || stringValue(node.command) || stringValue(action?.command) || stringValue(action?.type); + if (commandName) { + commands.push({ + command: commandName, + label: stringValue(props.label) || stringValue(props.title) || commandName, + source: action ? "node.action" : "node.command", + nodeId: stringValue(node.id), + transport: stringValue(action?.transport), + frameTemplate: action?.frameTemplate, + payloadSchema: action?.payloadSchema, + raw: action ?? node, + }); + } + const children = Array.isArray(node.children) ? node.children.filter(isRecord) : []; + for (const child of children) { + visit(child); + } + } + visit(root); + return commands; +} + +function findAdvertisedCommand( + loaded: LoadedOdinInterface, + command: string, +): InterfaceCommandSummary | undefined { + return extractProviderCommands(loaded).find((entry) => entry.command === command); +} + +function buildCommandFrame( + providerId: string, + command: InterfaceCommandSummary, + payload: Record, +): Record { + if (isRecord(command.frameTemplate)) { + return materializeFrameTemplate(command.frameTemplate, payload); + } + + return { + type: "surface-command", + schema: "gamecult.eve.command.v1", + providerId, + command: command.command, + payload, + nodeId: command.nodeId, + }; +} + +function materializeFrameTemplate( + template: Record, + payload: Record, +): Record { + const materialized: Record = {}; + for (const [key, value] of Object.entries(template)) { + materialized[key] = materializeTemplateValue(value, payload); + } + return materialized; +} + +function materializeTemplateValue(value: unknown, payload: Record): unknown { + if (typeof value === "string") { + const exact = value.match(/^\$\{([A-Za-z0-9_.-]+)\??\}$/); + if (exact) { + return payload[exact[1]] ?? ""; + } + return value.replace(/\$\{([A-Za-z0-9_.-]+)\??\}/g, (_match, key: string) => + payload[key] === undefined || payload[key] === null ? "" : String(payload[key]), + ); + } + if (Array.isArray(value)) { + return value.map((entry) => materializeTemplateValue(entry, payload)); + } + if (isRecord(value)) { + return materializeFrameTemplate(value, payload); + } + return value; +} + +async function sendProviderCommandFrame( + deckUrlText: string, + frame: Record, + expectReceiptMs: number, +) { + const deckUrl = new URL(deckUrlText); + const connection = await openWebSocketSnapshotSocket(deckUrl); + const { socket } = connection; + let pendingBuffer = connection.initialBuffer; + try { + try { + const initial = await readServerWebSocketText(socket, 1000, pendingBuffer); + pendingBuffer = initial.remaining; + } catch { + // Some providers do not send an initial deck frame before commands. + } + sendClientWebSocketText(socket, JSON.stringify(frame)); + if (expectReceiptMs <= 0) { + return { + received: false, + detail: "Command frame sent; receipt wait disabled.", + }; + } + try { + const response = await readServerWebSocketText(socket, expectReceiptMs, pendingBuffer); + pendingBuffer = response.remaining; + const parsed = JSON.parse(response.text) as unknown; + return { + received: true, + frame: isRecord(parsed) ? parsed : response.text, + remainingBytes: pendingBuffer.length, + }; + } catch (error) { + return { + received: false, + detail: error instanceof Error ? error.message : String(error), + }; + } + } finally { + socket.destroy(); + } +} + function getSurfaceRoot(snapshot: Record): Record | undefined { const surface = isRecord(snapshot.surface) ? snapshot.surface : undefined; return isRecord(surface?.root) ? surface.root : undefined; diff --git a/packages/providers/src/owner-codex-provider.ts b/packages/providers/src/owner-codex-provider.ts index b571f5d..a4c69e7 100644 --- a/packages/providers/src/owner-codex-provider.ts +++ b/packages/providers/src/owner-codex-provider.ts @@ -554,6 +554,8 @@ function restrictMcpServersToRepoFaceExploration( "list_odin_providers", "list_odin_verses", "get_odin_surface", + "load_odin_interface_context", + "invoke_odin_interface_command", ].join(","); return servers.map((server) => ({ diff --git a/prompts/owner-discord-reply.prompt.md b/prompts/owner-discord-reply.prompt.md index 68d3fa6..ae6cc48 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, get_source_context, list_odin_providers, list_odin_verses, and get_odin_surface.{{/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, get_odin_surface, load_odin_interface_context, and invoke_odin_interface_command.{{/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 7ab6815..3a6acf9 100644 --- a/prompts/repo-face-research-capabilities.prompt.md +++ b/prompts/repo-face-research-capabilities.prompt.md @@ -1,11 +1,13 @@ 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`, `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. +- 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`, `load_odin_interface_context`, `invoke_odin_interface_command`. +- Use Odin tools for live CultMesh Verse/interface access: what Odin can currently see, which provider-owned surfaces are present, what the all-seer surface reports, and the compact TUI/action context for a specific provider. +- Use `load_odin_interface_context` before trying to operate a service. It lowers the provider-owned CultMesh/Eve surface into compact text, tree, and advertised commands so you do not spend the turn swallowing a whole dashboard raw. +- Use `invoke_odin_interface_command` only for an explicit advertised command from that loaded interface, and only when the command is appropriate for your role, consent, and the current room/task. This is provider command authority, not Odin 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. +- Use Odin discovery/interface context when your thought depends on live Verse/provider presence, service/interface status, whether a new adapter daemon is visible to the mesh, or operating a provider through its compact TUI controls. - 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 473fcbe..029279f 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -321,3 +321,4 @@ {"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."} {"ts":"2026-06-02T14:52:23Z","type":"implementation","status":"ok","note":"Swarm silence on 2026-06-02 was not scheduler death: main lacked native_persona handling and the CultMesh swarm publisher, and worker speech parsing ignored natural 'Would say:' drafts. Fixed native Persona routing, restored swarm CultMesh/Eve publication, normalized draft speech to repo Face posts, restarted stack, and verified Aqua/Bifrost posted while Odin surface refreshed."} +{"ts":"2026-06-02T15:07:31Z","type":"implementation","status":"ok","note":"Verse access for Faces now means compact provider interface lowering plus advertised command relay, not only Odin visibility. VoidBot MCP exposes load_odin_interface_context and invoke_odin_interface_command; Spotiverse advertises spotiverse.add_to_queue through its Eve surface; negative relay smoke received a provider command receipt without mutating a real queue."}