Expose Odin discovery to Face turns

This commit is contained in:
Meta Vens 2026-06-02 15:51:55 +01:00
commit 167a214098
8 changed files with 426 additions and 2 deletions

View file

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

View file

@ -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;
}

View file

@ -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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<Record<string, unknown>> }> {
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<Record<string, unknown>> {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>): Record<string, unknown> | undefined {
const surface = isRecord(snapshot.surface) ? snapshot.surface : undefined;
return isRecord(surface?.root) ? surface.root : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<net.Socket> {
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<string> {
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 };
}

View file

@ -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 } : {}),
},
};
}

View file

@ -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) => ({

View file

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

View file

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

View file

@ -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."}