Lower Odin interfaces for Face control

This commit is contained in:
Meta Vens 2026-06-02 16:07:51 +01:00
commit 87f7e91ea6
6 changed files with 528 additions and 4 deletions

View file

@ -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<string, unknown>;
frame?: Record<string, unknown>;
expectReceiptMs?: number;
}
export interface ListIndexedReposArgs {
[key: string]: never;
}

View file

@ -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<CallToolResult> => {
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<CallToolResult> => {
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<string, unknown>) {
};
}
interface LoadedOdinInterface {
providerId: string;
title: string;
source: string;
status: string;
detail: string;
manifest: Record<string, unknown> | null;
snapshot: Record<string, unknown>;
root?: Record<string, unknown>;
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<string, unknown>;
}
async function loadOdinProviderInterface(
odinBaseUrl: string,
providerId: string,
): Promise<LoadedOdinInterface> {
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<Record<string, unknown>> {
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<string, unknown>,
providerId: string,
): Record<string, unknown> | 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<string, unknown>): Record<string, unknown> | 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<string, unknown> | undefined,
limit: number,
) {
const output: Array<{
id: string;
kind: string;
title: string;
text?: string;
status?: string;
command?: string;
depth: number;
}> = [];
function visit(node: Record<string, unknown> | 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<string, unknown> | undefined): InterfaceCommandSummary[] {
const commands: InterfaceCommandSummary[] = [];
function visit(node: Record<string, unknown> | 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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
payload: Record<string, unknown>,
): Record<string, unknown> {
const materialized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(template)) {
materialized[key] = materializeTemplateValue(value, payload);
}
return materialized;
}
function materializeTemplateValue(value: unknown, payload: Record<string, unknown>): 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<string, unknown>,
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<string, unknown>): Record<string, unknown> | undefined {
const surface = isRecord(snapshot.surface) ? snapshot.surface : undefined;
return isRecord(surface?.root) ? surface.root : undefined;

View file

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

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

View file

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

View file

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