diff --git a/.env.example b/.env.example index 49970c4..cbf602b 100644 --- a/.env.example +++ b/.env.example @@ -75,7 +75,7 @@ REPO_FACE_BIRTH_MODE=plan REPO_FACE_BIRTH_EXECUTOR=codex-exec REPO_FACE_GITHUB_ACTIONS_ENABLED=false REPO_FACE_WEKSA_SPEECH_ENABLED=false -WEKSA_DAEMON_BASE_URL=http://127.0.0.1:8813 +WEKSA_CULTMESH_COMMAND_URI= WEKSA_DAEMON_TIMEOUT_MS=120000 WEKSA_REPO_ROOT=E:/Projects/weksa REPO_FACE_DISCORD_VOICE_ENABLED=false @@ -101,8 +101,10 @@ REPO_FACE_TURN_CODEX_MODEL=gpt-5.4 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 +BIFROST_CULTMESH_COMMAND_URI=cultmesh://asgard.starfire.bifrost/commands/discord-post +BIFROST_CULTMESH_STORE_PATH=E:/Projects/Bifrost/.bifrost/provider-store.cc +BIFROST_CULTMESH_COMMAND_TIMEOUT_MS=30000 +BIFROST_CULTMESH_COMMAND_PUMP_ENABLED=true INDEX_ALL_CHANNELS=false INDEXED_CHANNEL_IDS= EXCLUDED_CHANNEL_IDS= diff --git a/.gitignore b/.gitignore index 737a8af..460dcba 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,11 @@ node_modules/ dist/ .env .voidbot/ +.bifrost/ coverage/ *.log *.tsbuildinfo +**/bin/ +**/obj/ Thumbs.db .DS_Store diff --git a/apps/bot/src/metame-owner-voice-bridge.ts b/apps/bot/src/metame-owner-voice-bridge.ts index 5db17a5..5bf5b2c 100644 --- a/apps/bot/src/metame-owner-voice-bridge.ts +++ b/apps/bot/src/metame-owner-voice-bridge.ts @@ -69,7 +69,8 @@ export async function maybeMirrorOwnerAquariumMessageAsMetameVoice(input: { }); const client = new WeksaSpeechClient({ - baseUrl: config.repoFaceWeksaSpeech.daemonBaseUrl, + commandUri: config.repoFaceWeksaSpeech.commandUri, + repoRoot: config.repoFaceWeksaSpeech.repoRoot, timeoutMs: config.repoFaceWeksaSpeech.timeoutMs, }); try { diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index b5a8303..85313d8 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,6 +1,7 @@ import "dotenv/config"; import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; import { mkdir, writeFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { isAbsolute, join, relative, resolve } from "node:path"; @@ -23,6 +24,7 @@ import { loadSystemMessageCatalog, resolveRepoFaceStatePath, resolveWeksaArtifactPath, + type RepoDiscordIdentity, type SystemMessageCatalog, WeksaSpeechClient, type WeksaMimoReceipt, @@ -71,13 +73,18 @@ import { const config = loadConfig(); const weksaSpeechClient = new WeksaSpeechClient({ - baseUrl: config.repoFaceWeksaSpeech.daemonBaseUrl, + commandUri: config.repoFaceWeksaSpeech.commandUri, + repoRoot: config.repoFaceWeksaSpeech.repoRoot, timeoutMs: config.repoFaceWeksaSpeech.timeoutMs, }); const REPO_IDENTITY_PROPOSAL_PR_SENTINEL = "VOIDBOT_REPO_IDENTITY_PROPOSAL_PR:"; const REPO_IDENTITY_PR_COMMENT_SENTINEL = "VOIDBOT_REPO_IDENTITY_PR_COMMENT:"; const REPO_IDENTITY_UPDATE_REQUEST_SENTINEL = "VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST:"; const REPO_IDENTITY_BIFROST_TOPIC_SENTINEL = "VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC:"; +const BIFROST_DISCORD_POST_COMMAND_DOCUMENT_TYPE = "bifrost.bridge.discord_post_command"; +const BIFROST_DISCORD_POST_COMMAND_SCHEMA_ID = "bifrost.bridge.discord_post_command.v1"; +const BIFROST_DISCORD_POST_RECEIPT_DOCUMENT_TYPE = "bifrost.bridge.discord_post_receipt"; +const BIFROST_DISCORD_POST_RECEIPT_SCHEMA_ID = "bifrost.bridge.discord_post_receipt.v1"; const CURRENT_REPO_FACE_IDENTITY_SELECTORS = new Set([ "face_id", "faceid", @@ -240,6 +247,7 @@ async function buildProviderRegistry(systemMessagesCatalog: SystemMessageCatalog timeoutMs: config.openAiApi.timeoutMs, authHeader: config.openAiApi.authHeader, maxCompletionTokens: config.openAiApi.maxCompletionTokens, + toolbox: buildRetrievalToolbox(), }), new LocalLlmProvider({ enabled: config.enabledProviders.includes("local_llm"), @@ -349,6 +357,124 @@ async function buildProviderRegistry(systemMessagesCatalog: SystemMessageCatalog ]); } +function buildRetrievalToolbox() { + return { + listIndexedRepos: async () => { + const repos = await sourceArchiveRepository.listRepoSummaries(); + + return { + repoCount: repos.length, + repos, + }; + }, + searchHistory: async (input: { + query: string; + limit: number; + guildId?: string; + channelId?: string; + authorId?: string; + }) => { + const rawResults = await searchHistoryWithArchiveFallback({ + retrievalService, + archiveRepository, + query: input.query, + limit: input.limit, + guildId: input.guildId, + channelId: input.channelId, + authorId: input.authorId, + preserveOverfetch: true, + }); + const results = filterPromptEchoHistoryResults(rawResults, input.query); + + return { + query: input.query, + resultCount: results.length, + results: formatHistoryResults(results), + }; + }, + getMessageContext: async (input: { + messageId: string; + before: number; + after: number; + }) => { + const messages = await archiveRepository.listContextWindow( + input.messageId, + input.before, + input.after, + ); + + return { + found: messages.length > 0, + messageId: input.messageId, + count: messages.length, + messages: messages.map((message) => + formatArchivedMessageContext(message, input.messageId), + ), + }; + }, + searchSources: async (input: { + query: string; + limit: number; + repoName?: string; + pathPrefix?: string; + language?: string; + }) => { + const results = await retrievalService.searchRepositorySources(input.query, input.limit, { + repoName: input.repoName, + pathPrefix: input.pathPrefix, + language: input.language, + }); + + return { + query: input.query, + resultCount: results.length, + results: formatSourceResults(results), + }; + }, + getSourceContext: async (input: { + sourceId: string; + chunkIndex: number; + before: number; + after: number; + }) => { + const document = await sourceArchiveRepository.get(input.sourceId); + + if (!document) { + return { + found: false, + sourceId: input.sourceId, + count: 0, + chunks: [], + }; + } + + const chunks = sourceDocumentIngester.buildContextWindow( + document, + input.chunkIndex, + input.before, + input.after, + ); + + return { + found: true, + sourceId: input.sourceId, + repoName: document.repoName, + path: document.path, + language: document.language, + count: chunks.length, + chunks: chunks.map((chunk) => ({ + chunkId: chunk.chunkId, + chunkIndex: chunk.chunkIndex, + lineStart: chunk.lineStart, + lineEnd: chunk.lineEnd, + isAnchor: chunk.chunkIndex === input.chunkIndex, + text: chunk.text, + })), + }; + }, + }; +} + async function processJob(job: JobRecord): Promise { const provider = providerRegistry.get(job.provider); @@ -595,6 +721,13 @@ async function executeRepoFaceJobWithInterpreter( : await interpretRepoFaceTurnOutput(provider, job, firstText, { attempt: 1, }); + const firstRouteProblem = firstInterpretation.decision === "route" + ? validateRepoFaceRoutedOutput(firstInterpretation.routedOutput, job) + : undefined; + if (firstRouteProblem) { + firstInterpretation.decision = "retry"; + firstInterpretation.reasons = [firstRouteProblem, ...firstInterpretation.reasons].slice(0, 4); + } if (firstInterpretation.decision === "route") { return routeRepoFaceInterpretedOutput(firstResponse, firstInterpretation, { job, @@ -648,6 +781,13 @@ async function executeRepoFaceJobWithInterpreter( : await interpretRepoFaceTurnOutput(provider, job, retryText, { attempt: 2, }); + const retryRouteProblem = retryInterpretation.decision === "route" + ? validateRepoFaceRoutedOutput(retryInterpretation.routedOutput, job) + : undefined; + if (retryRouteProblem) { + retryInterpretation.decision = "drop"; + retryInterpretation.reasons = [retryRouteProblem, ...retryInterpretation.reasons].slice(0, 4); + } if (retryInterpretation.decision === "route") { return routeRepoFaceInterpretedOutput(retryResponse, retryInterpretation, { job, @@ -971,6 +1111,38 @@ function normalizeInterpretedRepoFaceSpeechDestinations( ); } +function validateRepoFaceRoutedOutput( + outputText: string | undefined, + job: JobRecord, +): string | undefined { + if (job.command !== "repo-face-rumination" || !outputText || !repoFaceHasMultipleActiveConversationChannels(job)) { + return undefined; + } + + for (const block of parseRepoFaceActionBlocks(outputText)) { + if (block.kind !== "say") { + continue; + } + const context = optionalDslString(block.fields.context) ?? + optionalDslString(block.fields.context_id) ?? + optionalDslString(block.fields.contextId); + const replyTo = optionalDslString(block.fields.reply_to) ?? + optionalDslString(block.fields.replyToMessageId); + const channel = optionalDslString(block.fields.channel) ?? + optionalDslString(block.fields.channelId); + const normalizedChannel = normalizeChannelSelector(channel); + const hasConcreteChannel = Boolean(channel) && + normalizedChannel !== "current_room" && + normalizedChannel !== "currentroom"; + + if (!context && !replyTo && !hasConcreteChannel) { + return "Routed SAY lacks context, reply_to, or a concrete channel id while multiple active conversation contexts are visible."; + } + } + + return undefined; +} + function repoFaceHasMultipleActiveConversationChannels(job: JobRecord): boolean { const activeThreads = job.contextBundle.repoFaceConversationThreads ?? []; const representedChannels = new Set(activeThreads.map((thread) => thread.channelId)); @@ -1137,14 +1309,23 @@ function repoFaceHeartbeatCodexOptions(job: JobRecord, role: "face" | "interpret return {}; } + const identityId = parseRepoIdentityIdFromRequestMessageId(job.requestMessageId) + ?? parseRepoIdentityIdFromPrompt(job.prompt); + const faceContext = { + repoFaceRole: role, + ...(identityId ? { repoFaceIdentity: identityId } : {}), + }; + if (job.provider === "openai_api") { return { + ...faceContext, model: config.openAiApi.model, }; } if (role === "face") { return { + ...faceContext, model: config.repoFaceHeartbeats.turnCodexModel, ...(config.repoFaceHeartbeats.codexModelReasoningEffort ? { reasoningEffort: config.repoFaceHeartbeats.codexModelReasoningEffort } @@ -1153,6 +1334,7 @@ function repoFaceHeartbeatCodexOptions(job: JobRecord, role: "face" | "interpret } return { + ...faceContext, ...(config.repoFaceHeartbeats.codexModel ? { model: config.repoFaceHeartbeats.codexModel } : {}), ...(config.repoFaceHeartbeats.codexModels.length > 0 ? { models: config.repoFaceHeartbeats.codexModels.join(",") } @@ -1491,18 +1673,13 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn if (/\S(?:\.\.\.|…)$/.test(content)) { throw new Error(`Repo identity ${identity.id} SAY content appears mechanically truncated.`); } - const contentFile = await writeBifrostPayloadFile(job, `${identity.id}-discord-post.md`, content); - const posted = runBifrostBridge([ - "discord-post", - "--channel-id", + const posted = await postRepoIdentityDiscordViaCultMeshCommand({ + job, + identity, channelId, - "--content-file", - contentFile, - "--persona-name", - identity.displayName, - ...(identity.avatarUrl ? ["--persona-avatar-url", identity.avatarUrl] : []), - ...(effectiveReplyToMessageId ? ["--reply-to-message-id", effectiveReplyToMessageId] : []), - ], { retries: 1 }); + content, + replyToMessageId: effectiveReplyToMessageId, + }); if (!posted.messageId || !posted.transport) { throw new Error(`Bifrost Discord bridge returned no message receipt for job ${job.id}.`); } @@ -1531,6 +1708,282 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn return true; } +async function postRepoIdentityDiscordViaCultMeshCommand(input: { + job: JobRecord; + identity: RepoDiscordIdentity; + channelId: string; + content: string; + replyToMessageId?: string; +}): Promise { + assertBifrostCultMeshCommandUri(config.bifrostCultMesh.commandUri); + const commandId = buildBifrostDiscordCommandId(input); + const node = await openBifrostCommandNode(config.bifrostCultMesh.storePath); + const requestedAt = new Date().toISOString(); + const command = { + schemaName: BIFROST_DISCORD_POST_COMMAND_DOCUMENT_TYPE, + schemaVersion: BIFROST_DISCORD_POST_COMMAND_SCHEMA_ID, + commandId, + command: "discord-post", + status: "pending", + requestedBy: "voidbot", + requestedAt, + updatedAt: requestedAt, + commandUri: config.bifrostCultMesh.commandUri, + source: { + kind: "voidbot.repo-face.say", + id: input.job.id, + jobId: input.job.id, + requestMessageId: input.job.requestMessageId, + }, + actor: { + id: input.identity.id, + displayName: input.identity.displayName, + repoName: input.identity.repoName, + }, + payload: { + identityId: input.identity.id, + channelId: input.channelId, + content: input.content, + personaName: input.identity.displayName, + personaAvatarUrl: input.identity.avatarUrl ?? "", + replyToMessageId: input.replyToMessageId ?? "", + }, + }; + + await node.put(bifrostDiscordCommandDefinition(), commandId, command); + await node.flush?.(); + + if (config.bifrostCultMesh.pumpEnabled) { + pumpBifrostCultMeshCommand(commandId); + } + + const receipt = await waitForBifrostDiscordReceipt(node, commandId, config.bifrostCultMesh.timeoutMs); + if (receipt.status !== "completed" || receipt.ok !== true) { + throw new Error( + `Bifrost CultMesh Discord command ${commandId} failed: ${bifrostString(receipt.error) || "no error detail"}`, + ); + } + const messageId = bifrostString(receipt.messageId); + const transport = bifrostString(receipt.transport); + if (!messageId || !transport) { + throw new Error(`Bifrost CultMesh Discord command ${commandId} returned an incomplete receipt.`); + } + return { + action: "discord-post", + ok: true, + messageId, + transport: transport === "bot" || transport === "webhook" ? transport : "webhook", + url: bifrostString(receipt.url), + }; +} + +function buildBifrostDiscordCommandId(input: { + job: JobRecord; + identity: RepoDiscordIdentity; + channelId: string; + content: string; + replyToMessageId?: string; +}): string { + const hash = createHash("sha1") + .update(JSON.stringify({ + jobId: input.job.id, + identityId: input.identity.id, + channelId: input.channelId, + replyToMessageId: input.replyToMessageId ?? "", + content: input.content, + })) + .digest("hex") + .slice(0, 20); + return `voidbot-discord-${hash}`; +} + +function assertBifrostCultMeshCommandUri(value: string): void { + if (!/^cultmesh:\/\/[^/]+\/commands\/discord-post(?:$|[/?#])/.test(value)) { + throw new Error( + `BIFROST_CULTMESH_COMMAND_URI must be a CultMesh Discord command URI, got "${value}".`, + ); + } +} + +async function openBifrostCommandNode(storePath: string): Promise<{ + put: (definition: unknown, key: string, value: unknown) => Promise; + get: (definition: unknown, key: string) => unknown; + flush?: () => Promise; + cache?: { pullAllBackingStores?: () => Promise }; +}> { + const { CultMesh } = loadBifrostCultMeshRuntime(); + return CultMesh.createNode(storePath, { + documents: [ + bifrostDiscordCommandDefinition(), + bifrostDiscordReceiptDefinition(), + bifrostGenericDocumentDefinition("gamecult.eve.provider_advertisement", "gamecult.eve.provider_advertisement.v1", "providerId"), + bifrostGenericDocumentDefinition("gamecult.eve.surface_state", "gamecult.eve.surface_state.v1", "providerId"), + bifrostGenericDocumentDefinition("gamecult.eve.interface_binding", "gamecult.eve.interface_binding.v1", "bindingId"), + ], + }); +} + +function pumpBifrostCultMeshCommand(commandId: string): void { + const processor = resolve(config.bifrostRoot, "tools", "cultmesh-bridge-commands.mjs"); + const result = spawnSync(process.execPath, [ + processor, + "process", + "--store", + config.bifrostCultMesh.storePath, + "--command-id", + commandId, + ], { + cwd: config.bifrostRoot, + encoding: "utf8", + windowsHide: true, + }); + if (result.status !== 0 || result.error) { + throw new Error(`Bifrost CultMesh command processor failed. ${renderBridgeFailure(result, 1, 1)}`); + } +} + +async function waitForBifrostDiscordReceipt( + node: { get: (definition: unknown, key: string) => unknown; cache?: { pullAllBackingStores?: () => Promise } }, + commandId: string, + timeoutMs: number, +): Promise> { + const startedAt = Date.now(); + while (Date.now() - startedAt <= timeoutMs) { + await node.cache?.pullAllBackingStores?.(); + const receipt = unwrapBifrostRecord(await node.get(bifrostDiscordReceiptDefinition(), commandId)); + if (receipt) { + return receipt; + } + await delay(250); + } + throw new Error(`Timed out waiting for Bifrost CultMesh Discord receipt ${commandId}.`); +} + +let bifrostDefinitions: { + command?: unknown; + receipt?: unknown; + generic: Map; +} | undefined; + +function bifrostDiscordCommandDefinition(): unknown { + const { defineDocumentType } = loadBifrostCultMeshRuntime(); + bifrostDefinitions ??= { generic: new Map() }; + bifrostDefinitions.command ??= defineDocumentType({ + type: BIFROST_DISCORD_POST_COMMAND_DOCUMENT_TYPE, + schemaName: BIFROST_DISCORD_POST_COMMAND_DOCUMENT_TYPE, + schemaId: BIFROST_DISCORD_POST_COMMAND_SCHEMA_ID, + schemaVersion: BIFROST_DISCORD_POST_COMMAND_SCHEMA_ID, + contentHash: BIFROST_DISCORD_POST_COMMAND_SCHEMA_ID, + global: false, + name: "commandId", + schema: parseBifrostObjectDocument("Bifrost Discord post command"), + }); + return bifrostDefinitions.command; +} + +function bifrostDiscordReceiptDefinition(): unknown { + const { defineDocumentType } = loadBifrostCultMeshRuntime(); + bifrostDefinitions ??= { generic: new Map() }; + bifrostDefinitions.receipt ??= defineDocumentType({ + type: BIFROST_DISCORD_POST_RECEIPT_DOCUMENT_TYPE, + schemaName: BIFROST_DISCORD_POST_RECEIPT_DOCUMENT_TYPE, + schemaId: BIFROST_DISCORD_POST_RECEIPT_SCHEMA_ID, + schemaVersion: BIFROST_DISCORD_POST_RECEIPT_SCHEMA_ID, + contentHash: BIFROST_DISCORD_POST_RECEIPT_SCHEMA_ID, + global: false, + name: "commandId", + schema: parseBifrostObjectDocument("Bifrost Discord post receipt"), + }); + return bifrostDefinitions.receipt; +} + +function bifrostGenericDocumentDefinition(type: string, schemaId: string, name: string): unknown { + const { defineDocumentType } = loadBifrostCultMeshRuntime(); + bifrostDefinitions ??= { generic: new Map() }; + const key = `${type}:${schemaId}:${name}`; + let definition = bifrostDefinitions.generic.get(key); + if (!definition) { + definition = defineDocumentType({ + type, + schemaName: type, + schemaId, + schemaVersion: schemaId, + contentHash: schemaId, + global: false, + name, + schema: parseBifrostObjectDocument(type), + }); + bifrostDefinitions.generic.set(key, definition); + } + return definition; +} + +function loadBifrostCultMeshRuntime(): { + CultMesh: { createNode: (storePath: string, options: unknown) => Promise<{ + put: (definition: unknown, key: string, value: unknown) => Promise; + get: (definition: unknown, key: string) => unknown; + flush?: () => Promise; + cache?: { pullAllBackingStores?: () => Promise }; + }> }; + defineDocumentType: (definition: Record) => unknown; +} { + const candidates = [ + resolve(config.bifrostRoot, "..", "CultLib", "packages", "cultmesh-ts", "package.json"), + resolve(config.bifrostRoot, "..", "CultMeshTS", "package.json"), + ]; + + for (const packageJson of candidates) { + try { + const requireCult = createRequire(packageJson); + const { CultMesh } = requireCult("cultmesh-ts") as { + CultMesh?: { createNode: (storePath: string, options: unknown) => Promise<{ + put: (definition: unknown, key: string, value: unknown) => Promise; + get: (definition: unknown, key: string) => unknown; + flush?: () => Promise; + cache?: { pullAllBackingStores?: () => Promise }; + }> }; + }; + const { defineDocumentType } = requireCult("cultcache-ts") as { + defineDocumentType?: (definition: Record) => unknown; + }; + if (CultMesh && defineDocumentType) { + return { CultMesh, defineDocumentType }; + } + } catch { + } + } + throw new Error("CultMesh/CultCache packages are unavailable; cannot write Bifrost command documents."); +} + +function parseBifrostObjectDocument(label: string): { parse: (value: unknown) => unknown } { + return { + parse(value: unknown): unknown { + if (!value || typeof value !== "object") { + throw new Error(`${label} must be an object.`); + } + return value; + }, + }; +} + +function unwrapBifrostRecord(record: unknown): Record | undefined { + const candidate = Array.isArray(record) && record.length === 1 ? record[0] : record; + const value = isBifrostRecord(candidate) && isBifrostRecord(candidate.value) ? candidate.value : candidate; + return isBifrostRecord(value) ? value : undefined; +} + +function isBifrostRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function bifrostString(value: unknown): string { + return typeof value === "string" ? value.trim() : value === undefined || value === null ? "" : String(value).trim(); +} + +function delay(ms: number): Promise { + return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +} + async function resolveRepoIdentityForJobIntent( job: JobRecord, identitySelector?: string, diff --git a/apps/worker/src/mcp-server-shared.ts b/apps/worker/src/mcp-server-shared.ts index cb8ce1d..e23166e 100644 --- a/apps/worker/src/mcp-server-shared.ts +++ b/apps/worker/src/mcp-server-shared.ts @@ -48,31 +48,46 @@ export const applyRepoFaceStateOperationInputSchema = { operation: z.record(z.unknown()), }; +export const readSharedDocumentInputSchema = { + documentId: z.string().min(1).max(120).optional(), +}; + +export const createSharedDocumentInputSchema = { + documentId: z.string().min(1).max(120), + title: z.string().min(1).max(240), + body: z.string().max(20_000), +}; + +export const updateSharedDocumentInputSchema = { + documentId: z.string().min(1).max(120), + title: z.string().min(1).max(240).optional(), + body: z.string().max(20_000), + expectedVersion: z.number().int().positive(), +}; + export const runtimeInfoInputSchema = {}; export const odinEndpointInputSchema = { - odinBaseUrl: z.string().url().optional(), + odinStorePath: z.string().min(1).optional(), }; export const odinSurfaceInputSchema = { - odinBaseUrl: z.string().url().optional(), + odinStorePath: z.string().min(1).optional(), providerId: z.string().min(1).optional(), }; export const odinInterfaceContextInputSchema = { - odinBaseUrl: z.string().url().optional(), + odinStorePath: z.string().min(1).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(), + odinStorePath: z.string().min(1).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 = { @@ -132,33 +147,48 @@ export interface ApplyRepoFaceStateOperationArgs { operation: Record; } +export interface ReadSharedDocumentArgs { + documentId?: string; +} + +export interface CreateSharedDocumentArgs { + documentId: string; + title: string; + body: string; +} + +export interface UpdateSharedDocumentArgs { + documentId: string; + title?: string; + body: string; + expectedVersion: number; +} + export interface RuntimeInfoArgs { [key: string]: never; } export interface OdinEndpointArgs { - odinBaseUrl?: string; + odinStorePath?: string; } export interface OdinSurfaceArgs { - odinBaseUrl?: string; + odinStorePath?: string; providerId?: string; } export interface OdinInterfaceContextArgs { - odinBaseUrl?: string; + odinStorePath?: string; providerId: string; maxTextItems?: number; maxTreeItems?: number; } export interface OdinInterfaceCommandArgs { - odinBaseUrl?: string; + odinStorePath?: string; providerId: string; command: string; payload?: Record; - frame?: Record; - expectReceiptMs?: number; } export interface ListIndexedReposArgs { diff --git a/apps/worker/src/mcp-server-tools.ts b/apps/worker/src/mcp-server-tools.ts index 4f2457d..179e3a8 100644 --- a/apps/worker/src/mcp-server-tools.ts +++ b/apps/worker/src/mcp-server-tools.ts @@ -1,12 +1,14 @@ 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 { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; import { DEFAULT_RETRIEVAL_RESULT_LIMIT } from "@voidbot/shared"; import { searchHistoryWithArchiveFallback } from "@voidbot/rag"; import { applyRepoFacePostFatigueAfterSpeech, + createRepoFaceSharedDocument, applyVoidSelfStateOperation, buildVoidSelfStateContext, faceRegistryAsRepoDiscordRegistry, @@ -14,9 +16,12 @@ import { isRepoDiscordIdentityAllowedInChannel, loadFaceIdentityRegistry, loadVoidSelfStateTypedDocuments, + listRepoFaceSharedDocuments, renderFaceIdentityDoctrine, resolveFaceStatePath, resolveRepoFaceStatePath, + resolveRepoFaceSharedDocumentsPath, + updateRepoFaceSharedDocument, VOID_SELF_STATE_SCHEMA_DOCUMENT_TYPES, VOID_SELF_STATE_SCHEMA_FINGERPRINT, } from "@voidbot/core"; @@ -32,17 +37,21 @@ import { type OdinInterfaceContextArgs, type OdinSurfaceArgs, type ApplyRepoFaceStateOperationArgs, + type CreateSharedDocumentArgs, type PostDiscordMessageArgs, type PostRepoIdentityMessageArgs, type RepoFaceStateArgs, + type ReadSharedDocumentArgs, type RuntimeInfoArgs, type SearchHistoryArgs, type SearchSourcesArgs, type SourceContextArgs, + type UpdateSharedDocumentArgs, formatArchivedMessage, formatHistoryResults, formatSourceResults, applyRepoFaceStateOperationInputSchema, + createSharedDocumentInputSchema, messageContextInputSchema, notifyOwnerInputSchema, odinEndpointInputSchema, @@ -52,11 +61,13 @@ import { postDiscordMessageInputSchema, postRepoIdentityMessageInputSchema, repoFaceStateInputSchema, + readSharedDocumentInputSchema, renderJsonBlock, runtimeInfoInputSchema, searchHistoryInputSchema, searchSourcesInputSchema, sourceContextInputSchema, + updateSharedDocumentInputSchema, } from "./mcp-server-shared"; const READ_ONLY_ANNOTATIONS = { @@ -66,7 +77,8 @@ const READ_ONLY_ANNOTATIONS = { openWorldHint: false, } as const; -const DEFAULT_ODIN_BASE_URL = "http://127.0.0.1:8797"; +const DEFAULT_ODIN_STORE_PATH = path.resolve(process.cwd(), "..", "Odin", "scratch", "odin", "odin.ccmp"); +const ODIN_SURFACE_KEY = "surface:gamecult.network.status"; function isMcpToolAllowed(name: string): boolean { const raw = process.env.VOIDBOT_MCP_TOOL_ALLOWLIST?.trim(); @@ -136,22 +148,22 @@ export function registerVoidbotTools( annotations: READ_ONLY_ANNOTATIONS, }, async (input: OdinEndpointArgs): Promise => { - const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl); - const catalog = await fetchOdinProviderCatalog(odinBaseUrl); + const odinStorePath = resolveOdinStorePath(input.odinStorePath); + const catalog = await fetchOdinProviderCatalog(odinStorePath); return { content: [ { type: "text", text: renderJsonBlock({ - odinBaseUrl, + odinStorePath, providerCount: catalog.providers.length, providers: catalog.providers, }), }, ], structuredContent: { - odinBaseUrl, + odinStorePath, providerCount: catalog.providers.length, providers: catalog.providers, }, @@ -169,8 +181,8 @@ export function registerVoidbotTools( annotations: READ_ONLY_ANNOTATIONS, }, async (input: OdinEndpointArgs): Promise => { - const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl); - const snapshot = await fetchOdinDeckSnapshot(odinBaseUrl); + const odinStorePath = resolveOdinStorePath(input.odinStorePath); + const snapshot = await fetchOdinSnapshot(odinStorePath); const verses = summarizeOdinVerses(snapshot); return { @@ -178,7 +190,7 @@ export function registerVoidbotTools( { type: "text", text: renderJsonBlock({ - odinBaseUrl, + odinStorePath, providerId: snapshot.providerId, title: snapshot.title, version: snapshot.version, @@ -189,7 +201,7 @@ export function registerVoidbotTools( }, ], structuredContent: { - odinBaseUrl, + odinStorePath, providerId: snapshot.providerId, title: snapshot.title, version: snapshot.version, @@ -211,8 +223,8 @@ export function registerVoidbotTools( annotations: READ_ONLY_ANNOTATIONS, }, async (input: OdinSurfaceArgs): Promise => { - const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl); - const snapshot = await fetchOdinDeckSnapshot(odinBaseUrl, input.providerId); + const odinStorePath = resolveOdinStorePath(input.odinStorePath); + const snapshot = await fetchOdinSnapshot(odinStorePath, input.providerId); const surfaceSummary = summarizeOdinSurface(snapshot); return { @@ -220,7 +232,7 @@ export function registerVoidbotTools( { type: "text", text: renderJsonBlock({ - odinBaseUrl, + odinStorePath, providerId: snapshot.providerId, title: snapshot.title, version: snapshot.version, @@ -231,7 +243,7 @@ export function registerVoidbotTools( }, ], structuredContent: { - odinBaseUrl, + odinStorePath, providerId: snapshot.providerId, title: snapshot.title, version: snapshot.version, @@ -253,8 +265,8 @@ export function registerVoidbotTools( annotations: READ_ONLY_ANNOTATIONS, }, async (input: OdinInterfaceContextArgs): Promise => { - const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl); - const loaded = await loadOdinProviderInterface(odinBaseUrl, input.providerId); + const odinStorePath = resolveOdinStorePath(input.odinStorePath); + const loaded = await loadOdinProviderInterface(odinStorePath, input.providerId); const contextSummary = summarizeProviderInterface(loaded, { maxTextItems: input.maxTextItems ?? 32, maxTreeItems: input.maxTreeItems ?? 80, @@ -265,13 +277,13 @@ export function registerVoidbotTools( { type: "text", text: renderJsonBlock({ - odinBaseUrl, + odinStorePath, ...contextSummary, }), }, ], structuredContent: { - odinBaseUrl, + odinStorePath, ...contextSummary, }, }; @@ -283,7 +295,7 @@ export function registerVoidbotTools( { 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.", + "Inspect a provider-owned Eve/CultUI command boundary discovered via Odin. The command must be advertised by the provider interface; execution requires a CultMesh command document path and no longer tunnels through provider WebSockets.", inputSchema: odinInterfaceCommandInputSchema, annotations: { readOnlyHint: false, @@ -293,8 +305,8 @@ export function registerVoidbotTools( }, }, async (input: OdinInterfaceCommandArgs): Promise => { - const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl); - const loaded = await loadOdinProviderInterface(odinBaseUrl, input.providerId); + const odinStorePath = resolveOdinStorePath(input.odinStorePath); + const loaded = await loadOdinProviderInterface(odinStorePath, input.providerId); const command = findAdvertisedCommand(loaded, input.command); if (!command) { @@ -316,50 +328,28 @@ export function registerVoidbotTools( }; } - 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, + sent: false, + reason: "cultmesh_command_transport_required", providerId: input.providerId, source: loaded.source, command: input.command, - frame, - receipt, + advertisedCommand: command, }), }, ], structuredContent: { - sent: true, + sent: false, + reason: "cultmesh_command_transport_required", providerId: input.providerId, source: loaded.source, command: input.command, - frame, - receipt, }, + isError: true, }; }, ); @@ -995,6 +985,106 @@ export function registerVoidbotTools( }, ); + registerIfAllowed( + "read_shared_document", + { + title: "Read Shared Document", + description: + "List shared typed documents, or read one by documentId. These documents are visible to every repo Face and carry revision and author provenance.", + inputSchema: readSharedDocumentInputSchema, + annotations: READ_ONLY_ANNOTATIONS, + }, + async (input: ReadSharedDocumentArgs): Promise => { + const storePath = resolveRepoFaceSharedDocumentsPath(context.config.storageRoot); + const documents = await listRepoFaceSharedDocuments(storePath); + const requestedDocumentId = input.documentId?.trim().toLowerCase(); + const selected = requestedDocumentId + ? documents.find((document) => document.documentId === requestedDocumentId) + : undefined; + if (input.documentId && !selected) { + return { + content: [{ type: "text", text: `Shared document "${input.documentId}" was not found.` }], + structuredContent: { found: false, documentId: input.documentId }, + isError: true, + }; + } + const result = input.documentId ? { storePath, document: selected } : { storePath, documents }; + return { + content: [{ type: "text", text: renderJsonBlock(result) }], + structuredContent: result, + }; + }, + ); + + registerIfAllowed( + "create_shared_document", + { + title: "Create Shared Document", + description: + "Create one shared typed document visible to every repo Face. The active Face identity is stamped by the server; arbitrary filesystem writes are not exposed.", + inputSchema: createSharedDocumentInputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (input: CreateSharedDocumentArgs): Promise => { + const actor = await resolveActiveRepoFaceForSharedTool(context); + if (!actor.identity) { + return actor.error; + } + const storePath = resolveRepoFaceSharedDocumentsPath(context.config.storageRoot); + const document = await createRepoFaceSharedDocument({ + canonicalPath: storePath, + documentId: input.documentId, + title: input.title, + body: input.body, + actorId: actor.identity.id, + }); + return { + content: [{ type: "text", text: renderJsonBlock({ created: true, storePath, document }) }], + structuredContent: { created: true, storePath, document }, + }; + }, + ); + + registerIfAllowed( + "update_shared_document", + { + title: "Update Shared Document", + description: + "Replace one shared typed document body using its expected revision. Stale writes fail instead of overwriting another Face's update.", + inputSchema: updateSharedDocumentInputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (input: UpdateSharedDocumentArgs): Promise => { + const actor = await resolveActiveRepoFaceForSharedTool(context); + if (!actor.identity) { + return actor.error; + } + const storePath = resolveRepoFaceSharedDocumentsPath(context.config.storageRoot); + const document = await updateRepoFaceSharedDocument({ + canonicalPath: storePath, + documentId: input.documentId, + title: input.title, + body: input.body, + expectedVersion: input.expectedVersion, + actorId: actor.identity.id, + }); + return { + content: [{ type: "text", text: renderJsonBlock({ updated: true, storePath, document }) }], + structuredContent: { updated: true, storePath, document }, + }; + }, + ); + registerIfAllowed( "notify_owner", { @@ -1091,6 +1181,20 @@ async function resolveRepoIdentityForTool( }; } +async function resolveActiveRepoFaceForSharedTool(context: VoidbotMcpContext) { + const identity = process.env.VOIDBOT_REPO_FACE_IDENTITY?.trim(); + if (!identity) { + return { + error: { + content: [{ type: "text" as const, text: "Shared document mutation requires an active repo Face identity." }], + structuredContent: { applied: false, reason: "missing_active_face_identity" }, + isError: true, + }, + }; + } + return resolveRepoIdentityForTool(context, identity); +} + async function recordRepoIdentityDeliveryReceipt(input: { context: VoidbotMcpContext; identity: NonNullable>; @@ -1157,55 +1261,115 @@ function identityForToolResult( }; } -function normalizeOdinBaseUrl(value: string | undefined): string { - return (value ?? process.env.ODIN_BASE_URL ?? DEFAULT_ODIN_BASE_URL).replace(/\/+$/, ""); +function resolveOdinStorePath(value: string | undefined): string { + return path.resolve(value ?? process.env.ODIN_CULTMESH_STORE ?? DEFAULT_ODIN_STORE_PATH); } -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) - : []; +async function fetchOdinProviderCatalog(odinStorePath: string): Promise<{ providers: Array> }> { + const snapshot = await fetchOdinSnapshot(odinStorePath); + const providers = summarizeOdinSurface(snapshot).interfaces.map((entry) => ({ + id: entry.providerId, + title: entry.title, + status: entry.status, + detail: entry.detail, + source: entry.source, + updatedAt: entry.updatedAt, + })); return { providers }; } -async function fetchOdinDeckSnapshot( - odinBaseUrl: string, +async function fetchOdinSnapshot( + odinStorePath: string, providerId?: string, ): Promise> { - const deckUrl = new URL(odinBaseUrl); - deckUrl.protocol = deckUrl.protocol === "https:" ? "wss:" : "ws:"; - deckUrl.pathname = "/eve/deck"; - deckUrl.search = ""; - const connection = await openWebSocketSnapshotSocket(deckUrl); - const { socket } = connection; - let pendingBuffer = connection.initialBuffer; - try { - if (providerId) { - 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 message = frame.text; - 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(); + const allSeer = await readOdinAllSeerSurface(odinStorePath); + if (!providerId) { + return allSeer; } + const provider = providerStateFromOdinSurface(allSeer, providerId); + if (!provider) { + throw new Error(`Odin CultMesh store does not currently expose provider interface ${providerId}.`); + } + return provider; +} + +async function readOdinAllSeerSurface(odinStorePath: string): Promise> { + if (!existsSync(odinStorePath)) { + throw new Error(`Odin CultMesh store is missing: ${odinStorePath}`); + } + const { inspectCultCacheBytes } = loadCultCacheInspector(); + const inspection = inspectCultCacheBytes(odinStorePath, readFileSync(odinStorePath)); + const surfaceRecord = inspection.records.find((entry) => + entry.schemaId === "gamecult.eve.surface_state.v1" + && entry.key === ODIN_SURFACE_KEY + ); + const record = isRecord(surfaceRecord?.payloadPreview) ? surfaceRecord.payloadPreview : null; + if (!record) { + throw new Error(`Odin CultMesh store does not contain ${ODIN_SURFACE_KEY}.`); + } + return record; +} + +function loadCultCacheInspector(): { + inspectCultCacheBytes: ( + filePath: string, + bytes: Uint8Array, + ) => { records: Array<{ key: string; schemaId: string; payloadPreview: unknown }> }; +} { + const packageJsonCandidates = [ + path.resolve(process.cwd(), "vendor", "cultcache-ts", "package.json"), + path.resolve(process.cwd(), "node_modules", "cultcache-ts", "package.json"), + path.resolve(process.cwd(), "..", "CultLib", "packages", "cultcache-ts", "package.json"), + ]; + + for (const packageJson of packageJsonCandidates) { + try { + const requireFromCandidate = createRequire(packageJson); + const loaded = requireFromCandidate("./dist/cult-cache-inspector.js") as { + inspectCultCacheBytes?: ( + filePath: string, + bytes: Uint8Array, + ) => { records: Array<{ key: string; schemaId: string; payloadPreview: unknown }> }; + }; + if (loaded.inspectCultCacheBytes) { + return { inspectCultCacheBytes: loaded.inspectCultCacheBytes }; + } + } catch { + // Try the next known CultCache runtime root. + } + } + throw new Error("CultCache inspector is unavailable; cannot read Odin provider state."); +} + +function providerStateFromOdinSurface( + allSeer: Record, + providerId: string, +): Record | null { + const interfaceNode = findOdinInterfaceNode(allSeer, providerId); + if (!interfaceNode) { + return null; + } + const props = isRecord(interfaceNode.props) ? interfaceNode.props : {}; + const embeddedRoot = embeddedInterfaceRoot(interfaceNode); + return { + schema: "gamecult.eve.surface_state.v1", + providerId, + title: stringValue(props.title) || providerId, + version: props.version ?? allSeer.version ?? 0, + updatedAt: stringValue(props.updatedAt) || stringValue(allSeer.updatedAt), + selectedNodeId: stringValue(allSeer.selectedNodeId), + source: stringValue(props.source), + manifest: isRecord(props.manifest) ? props.manifest : null, + surface: { + schema: "gamecult.eve.surface.v1", + root: embeddedRoot ?? { + id: `${providerId}.unavailable`, + kind: "text", + props: { text: `Provider ${providerId} has no embedded Odin interface root.` }, + children: [], + }, + }, + }; } function summarizeOdinVerses(snapshot: Record) { @@ -1297,13 +1461,13 @@ interface LoadedOdinInterface { manifest: Record | null; snapshot: Record; root?: Record; - loadedFrom: "provider-websocket" | "odin-embedded-interface"; + loadedFrom: "odin-embedded-interface"; } interface InterfaceCommandSummary { command: string; label: string; - source: "surface.commands" | "node.action" | "node.command"; + source: "surface.commands" | "manifest.commands" | "node.action" | "node.command"; nodeId?: string; transport?: string; frameTemplate?: unknown; @@ -1312,10 +1476,10 @@ interface InterfaceCommandSummary { } async function loadOdinProviderInterface( - odinBaseUrl: string, + odinStorePath: string, providerId: string, ): Promise { - const odinSnapshot = await fetchOdinDeckSnapshot(odinBaseUrl); + const odinSnapshot = await fetchOdinSnapshot(odinStorePath); const interfaceNode = findOdinInterfaceNode(odinSnapshot, providerId); if (!interfaceNode) { throw new Error(`Odin does not currently expose provider interface ${providerId}.`); @@ -1323,38 +1487,6 @@ async function loadOdinProviderInterface( 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, @@ -1369,30 +1501,6 @@ async function loadOdinProviderInterface( }; } -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, @@ -1495,8 +1603,11 @@ function extractProviderCommands(loaded: LoadedOdinInterface): InterfaceCommandS : Array.isArray(loaded.snapshot.commands) ? loaded.snapshot.commands.filter(isRecord) : []; + const manifestCommands = Array.isArray(loaded.manifest?.commands) + ? loaded.manifest.commands.filter(isRecord) + : []; - for (const command of surfaceCommands) { + for (const command of [...surfaceCommands, ...manifestCommands]) { const commandName = stringValue(command.command) || stringValue(command.id); if (!commandName) { continue; @@ -1504,7 +1615,7 @@ function extractProviderCommands(loaded: LoadedOdinInterface): InterfaceCommandS commands.push({ command: commandName, label: stringValue(command.label) || commandName, - source: "surface.commands", + source: surfaceCommands.includes(command) ? "surface.commands" : "manifest.commands", transport: stringValue(command.transport), frameTemplate: command.frameTemplate, payloadSchema: command.payloadSchema, @@ -1558,98 +1669,6 @@ function findAdvertisedCommand( 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; @@ -1662,146 +1681,3 @@ function isRecord(value: unknown): value is Record { function stringValue(value: unknown): string { return typeof value === "string" ? value : value === null || value === undefined ? "" : String(value); } - -function openWebSocketSnapshotSocket(url: URL): Promise<{ socket: net.Socket; initialBuffer: Buffer }> { - 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); - resolve({ socket, initialBuffer: buffer.subarray(marker + 4) }); - }); - 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, - initialBuffer: Buffer = Buffer.alloc(0), -): Promise<{ text: string; remaining: Buffer }> { - return new Promise((resolve, reject) => { - let buffer = initialBuffer; - const timer = setTimeout(() => cleanup(new Error("Timed out waiting for Odin deck frame")), timeoutMs); - function cleanup(error?: Error, value?: { text: string; remaining: Buffer }) { - clearTimeout(timer); - socket.off("data", onData); - socket.off("error", onError); - if (error) { - reject(error); - } else { - resolve(value ?? { text: "", remaining: Buffer.alloc(0) }); - } - } - function onError(error: Error) { - cleanup(error); - } - function onData(chunk: Buffer) { - buffer = Buffer.concat([buffer, chunk]); - readBufferedFrame(); - } - function readBufferedFrame() { - const frame = tryReadWebSocketFrame(buffer); - if (!frame) { - return; - } - buffer = buffer.subarray(frame.consumed); - if (frame.opcode === 0x1) { - cleanup(undefined, { - text: frame.payload.toString("utf8"), - remaining: buffer, - }); - } else if (frame.opcode === 0x8) { - cleanup(new Error("Odin websocket closed")); - } - } - socket.on("data", onData); - socket.on("error", onError); - readBufferedFrame(); - }); -} - -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/package.json b/package.json index 3c8acfa..618338e 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "dev:mcp": "tsx apps/worker/src/mcp-server.ts", "dev:worker": "tsx apps/worker/src/index.ts", "stack:start": "powershell -ExecutionPolicy Bypass -File scripts/start-voidbot-stack.ps1", - "state:status": "tsx tools/voidbot_state.ts status", - "state:prepare-compaction": "tsx tools/voidbot_prepare_compaction.ts", + "state:status": "node tools/voidbot_state.ts status", + "state:prepare-compaction": "node tools/voidbot_prepare_compaction.ts", "state:backup": "powershell -ExecutionPolicy Bypass -File scripts/backup-voidbot-state.ps1", "state:restore": "powershell -ExecutionPolicy Bypass -File scripts/restore-voidbot-state.ps1", "state:verify-backup": "powershell -ExecutionPolicy Bypass -File scripts/verify-voidbot-backup.ps1", @@ -41,6 +41,8 @@ "agents:speech-boundary-smoke": "tsx scripts/smoke-repo-face-speech-boundary.ts", "agents:heartbeat-response-pressure-fixture": "tsx scripts/smoke-repo-face-response-pressure.ts", "agents:memory-projection-smoke": "node scripts/smoke-repo-face-memory-projection.mjs", + "agents:shared-documents-smoke": "tsx scripts/smoke-repo-face-shared-documents.ts", + "agents:shared-documents-mcp-smoke": "node scripts/smoke-repo-face-shared-documents-mcp.mjs", "agents:article-contract-smoke": "tsx scripts/smoke-repo-face-article-contract.ts", "persona:export-friendly": "tsx scripts/export-persona-friendly-input.ts", "postgres:up": "docker compose -f infra/postgres/docker-compose.yml up -d", diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 61caa58..d38e2e3 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -116,7 +116,7 @@ const envSchema = z.object({ REPO_FACE_GITHUB_ACTIONS_ENABLED: booleanFromEnv.default(false), REPO_FACE_BIFROST_ENABLED: booleanFromEnv.default(false), REPO_FACE_WEKSA_SPEECH_ENABLED: booleanFromEnv.default(false), - WEKSA_DAEMON_BASE_URL: z.string().url().default("http://127.0.0.1:8813"), + WEKSA_CULTMESH_COMMAND_URI: optionalNonEmptyString, WEKSA_DAEMON_TIMEOUT_MS: z.coerce.number().int().positive().default(120000), WEKSA_REPO_ROOT: z.string().min(1).default("E:/Projects/weksa"), REPO_FACE_DISCORD_VOICE_ENABLED: booleanFromEnv.default(false), @@ -131,6 +131,10 @@ const envSchema = z.object({ METAME_OWNER_VOICE_STATE_PATH: z.string().min(1).default(".voidbot/private/personas/metame/metame-owner-voice-sync.md"), BIFROST_ROOT: z.string().min(1).default("E:/Projects/Bifrost"), BIFROST_DISCORD_CHANNEL_ID: optionalNonEmptyString, + BIFROST_CULTMESH_COMMAND_URI: z.string().min(1).default("cultmesh://asgard.starfire.bifrost/commands/discord-post"), + BIFROST_CULTMESH_STORE_PATH: z.string().min(1).default("E:/Projects/Bifrost/.bifrost/provider-store.cc"), + BIFROST_CULTMESH_COMMAND_TIMEOUT_MS: z.coerce.number().int().positive().default(30000), + BIFROST_CULTMESH_COMMAND_PUMP_ENABLED: booleanFromEnv.default(true), REPO_FACE_HEARTBEATS_ENABLED: booleanFromEnv.default(false), REPO_FACE_HEARTBEAT_STATE_PATH: z.string().min(1).default(".voidbot/status/repo-face-heartbeats.json"), REPO_FACE_HEARTBEAT_TASK_NAME: z.string().min(1).default("VoidBot Repo Face Heartbeats"), @@ -216,7 +220,7 @@ export interface AppConfig { repoFaceBifrostEnabled: boolean; repoFaceWeksaSpeech: { enabled: boolean; - daemonBaseUrl: string; + commandUri: string; timeoutMs: number; repoRoot: string; }; @@ -236,6 +240,12 @@ export interface AppConfig { }; bifrostRoot: string; bifrostDiscordChannelId?: string; + bifrostCultMesh: { + commandUri: string; + storePath: string; + timeoutMs: number; + pumpEnabled: boolean; + }; repoFaceHeartbeats: { enabled: boolean; statePath: string; @@ -495,7 +505,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { repoFaceBifrostEnabled: parsed.REPO_FACE_BIFROST_ENABLED, repoFaceWeksaSpeech: { enabled: parsed.REPO_FACE_WEKSA_SPEECH_ENABLED, - daemonBaseUrl: parsed.WEKSA_DAEMON_BASE_URL.replace(/\/+$/, ""), + commandUri: requireWhenEnabled( + parsed.REPO_FACE_WEKSA_SPEECH_ENABLED, + parsed.WEKSA_CULTMESH_COMMAND_URI, + "WEKSA_CULTMESH_COMMAND_URI is required when REPO_FACE_WEKSA_SPEECH_ENABLED is true; provide the Odin-discovered Weksa CultMesh command URI.", + ), timeoutMs: parsed.WEKSA_DAEMON_TIMEOUT_MS, repoRoot: resolve(parsed.WEKSA_REPO_ROOT), }, @@ -515,6 +529,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { }, bifrostRoot: resolve(parsed.BIFROST_ROOT), bifrostDiscordChannelId: parsed.BIFROST_DISCORD_CHANNEL_ID, + bifrostCultMesh: { + commandUri: parsed.BIFROST_CULTMESH_COMMAND_URI, + storePath: resolve(parsed.BIFROST_CULTMESH_STORE_PATH), + timeoutMs: parsed.BIFROST_CULTMESH_COMMAND_TIMEOUT_MS, + pumpEnabled: parsed.BIFROST_CULTMESH_COMMAND_PUMP_ENABLED, + }, repoFaceHeartbeats: { enabled: parsed.REPO_FACE_HEARTBEATS_ENABLED, statePath: resolve(parsed.REPO_FACE_HEARTBEAT_STATE_PATH), @@ -620,3 +640,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { }, }; } + +function requireWhenEnabled(enabled: boolean, value: string | undefined, message: string): string { + if (!enabled) { + return value ?? ""; + } + if (value) { + return value; + } + throw new Error(message); +} diff --git a/packages/core/src/codex-mcp.ts b/packages/core/src/codex-mcp.ts index c2a328d..7a4ac3c 100644 --- a/packages/core/src/codex-mcp.ts +++ b/packages/core/src/codex-mcp.ts @@ -12,7 +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 } : {}), + ...(process.env.ODIN_CULTMESH_STORE ? { ODIN_CULTMESH_STORE: process.env.ODIN_CULTMESH_STORE } : {}), }, }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3347b1d..53891ce 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export * from "./repo-face-heartbeat-queue"; export * from "./repo-face-response-pressure"; export * from "./repo-face-initializer"; export * from "./repo-face-rest"; +export * from "./repo-face-shared-documents"; export * from "./repo-face-voice-presence"; export * from "./repo-face-voice-outbox"; export * from "./situational-social-read"; diff --git a/packages/core/src/repo-face-shared-documents.ts b/packages/core/src/repo-face-shared-documents.ts new file mode 100644 index 0000000..2b67ff8 --- /dev/null +++ b/packages/core/src/repo-face-shared-documents.ts @@ -0,0 +1,112 @@ +import { resolve } from "node:path"; + +import { + CultCache, + SingleFileMessagePackBackingStore, + defineDocumentRegistry, + defineDocumentType, +} from "cultcache-ts"; +import { z } from "zod"; + +const nonEmpty = z.string().trim().min(1); +const documentSchema = z.object({ + documentId: nonEmpty.max(120), + title: nonEmpty.max(240), + body: z.string().max(20_000), + version: z.number().int().positive(), + createdBy: nonEmpty.max(120), + createdAt: nonEmpty, + updatedBy: nonEmpty.max(120), + updatedAt: nonEmpty, +}).strict(); + +export type RepoFaceSharedDocument = z.infer; + +const documentDefinition = defineDocumentType({ + type: "voidbot.repo_face.shared_document", + schema: documentSchema, + name: "documentId", +}); +const registry = defineDocumentRegistry(documentDefinition); + +export function resolveRepoFaceSharedDocumentsPath(storageRoot: string): string { + return resolve(storageRoot, "private", "repo-face-shared-documents.cc"); +} + +export async function listRepoFaceSharedDocuments(canonicalPath: string): Promise { + const cache = createCache(canonicalPath); + await cache.pullAllBackingStores(); + return cache.getAll(documentDefinition).sort((left, right) => left.documentId.localeCompare(right.documentId)); +} + +export async function createRepoFaceSharedDocument(input: { + canonicalPath: string; + documentId: string; + title: string; + body: string; + actorId: string; + now?: string; +}): Promise { + const documentId = normalizeDocumentId(input.documentId); + const cache = createCache(input.canonicalPath); + await cache.pullAllBackingStores(); + if (cache.get(documentDefinition, documentId)) { + throw new Error(`Shared document "${documentId}" already exists; update it instead.`); + } + const now = input.now ?? new Date().toISOString(); + const document = documentSchema.parse({ + documentId, + title: input.title, + body: input.body, + version: 1, + createdBy: input.actorId, + createdAt: now, + updatedBy: input.actorId, + updatedAt: now, + }); + return cache.put(documentDefinition, documentId, document); +} + +export async function updateRepoFaceSharedDocument(input: { + canonicalPath: string; + documentId: string; + title?: string; + body: string; + expectedVersion: number; + actorId: string; + now?: string; +}): Promise { + const documentId = normalizeDocumentId(input.documentId); + const cache = createCache(input.canonicalPath); + await cache.pullAllBackingStores(); + const existing = cache.get(documentDefinition, documentId); + if (!existing) { + throw new Error(`Shared document "${documentId}" does not exist; create it first.`); + } + if (existing.version !== input.expectedVersion) { + throw new Error( + `Shared document "${documentId}" is at version ${existing.version}, not expected version ${input.expectedVersion}.`, + ); + } + const document = documentSchema.parse({ + ...existing, + title: input.title ?? existing.title, + body: input.body, + version: existing.version + 1, + updatedBy: input.actorId, + updatedAt: input.now ?? new Date().toISOString(), + }); + return cache.put(documentDefinition, documentId, document); +} + +function createCache(canonicalPath: string): CultCache { + return CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(resolve(canonicalPath))) + .build(); +} + +function normalizeDocumentId(value: string): string { + const normalized = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return nonEmpty.max(120).parse(normalized); +} diff --git a/packages/core/src/weksa-speech.ts b/packages/core/src/weksa-speech.ts index c823bd8..36d2248 100644 --- a/packages/core/src/weksa-speech.ts +++ b/packages/core/src/weksa-speech.ts @@ -1,5 +1,6 @@ export interface WeksaSpeechClientOptions { - baseUrl: string; + commandUri: string; + repoRoot: string; timeoutMs: number; } @@ -42,69 +43,23 @@ export interface WeksaMimoReceipt { } export class WeksaSpeechClient { - private readonly baseUrl: string; + private readonly commandUri: string; + private readonly repoRoot: string; private readonly timeoutMs: number; constructor(options: WeksaSpeechClientOptions) { - this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.commandUri = options.commandUri; + this.repoRoot = options.repoRoot; this.timeoutMs = options.timeoutMs; } async renderRepoFaceSpeech(input: WeksaRepoFaceSpeechInput): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.timeoutMs); - try { - const response = await fetch(`${this.baseUrl}/speech-provider/mimo/voicedesign`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - signal: controller.signal, - body: JSON.stringify({ - request_id: input.requestId ?? buildRequestId(input), - persona_state_path: input.personaStatePath, - speaker_agent_id: input.identityId, - thought_text: input.content, - spoken_text: input.content, - scene: input.scene ?? `Discord channel ${input.channelId}`, - addressee: input.addressee ?? "Discord room", - intent: input.content, - performance_register: input.performanceRegister ?? { - label: `${input.displayName} Discord voice`, - medium: "Discord voice channel playback", - delivery_archetype: `${input.displayName} speaks an approved VoidBot SAY line`, - }, - delivery_controls: input.deliveryControls ?? [ - "Discord-room conversational delivery", - "preserve the approved text exactly", - `repo Face for ${input.repoName}`, - ], - forbidden_traits: input.forbiddenTraits ?? [ - "generic assistant voice", - "rewriting source meaning", - "changing the approved Discord text", - ], - voice_design_prompt: input.voiceDesignPrompt, - private_interpretation: input.privateInterpretation ?? - "VoidBot already approved this SAY line for public text delivery; Weksa is only projecting it into spoken audio.", - intended_effect: input.intendedEffect ?? - "Make the approved public Discord line audible without changing its meaning.", - }), - }); - - const text = await response.text(); - if (!response.ok) { - throw new Error(`Weksa speech request failed with ${response.status}: ${text}`); - } - return JSON.parse(text) as WeksaMimoReceipt; - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - throw new Error(`Weksa speech request timed out after ${this.timeoutMs}ms.`); - } - throw error; - } finally { - clearTimeout(timeout); - } + const requestId = input.requestId ?? buildRequestId(input); + void this.repoRoot; + void this.timeoutMs; + throw new Error( + `Weksa speech command ${requestId} requires CultMesh command document publication to ${this.commandUri}; the legacy direct sender has been removed.`, + ); } } diff --git a/packages/providers/src/openai-api-provider.ts b/packages/providers/src/openai-api-provider.ts index afc1925..80446fc 100644 --- a/packages/providers/src/openai-api-provider.ts +++ b/packages/providers/src/openai-api-provider.ts @@ -10,6 +10,16 @@ import { type ProviderResponse, } from "@voidbot/shared"; +import { + executeToolCall, + buildToolDefinitions, +} from "./local-llm-tools"; +import { + MAX_TOOL_TURNS, + type LocalLlmToolbox, + type ToolTraceRecord, +} from "./local-llm-shared"; + export interface OpenAiApiProviderOptions { enabled: boolean; baseUrl: string; @@ -19,17 +29,31 @@ export interface OpenAiApiProviderOptions { timeoutMs: number; authHeader: string; maxCompletionTokens: number; + toolbox?: LocalLlmToolbox; } interface ChatMessage { - role: "user" | "assistant" | "system"; - content: string; + role: "user" | "assistant" | "system" | "tool"; + content: string | null; + tool_call_id?: string; + tool_calls?: ChatToolCall[]; +} + +interface ChatToolCall { + id?: string; + type?: "function"; + function?: { + name?: unknown; + arguments?: unknown; + }; } interface ChatCompletionResponse { choices?: Array<{ message?: { content?: unknown; + tool_calls?: unknown; + reasoning_content?: unknown; }; finish_reason?: unknown; }>; @@ -84,9 +108,15 @@ export class OpenAiApiProvider implements ProviderAdapter { content: request.contextBundle.prompt, }, ]; + const tools = this.options.toolbox ? buildToolDefinitions() : undefined; + const toolTrace: ToolTraceRecord[] = []; + let finalPayload: ChatCompletionResponse | undefined; + let finalRequestBody: Record | undefined; + let outputText: string | undefined; const requestBody = { model, messages, + ...(tools ? { tools } : {}), stream: false, max_completion_tokens: readPositiveIntegerOption(request.options?.maxCompletionTokens) ?? this.options.maxCompletionTokens, @@ -96,25 +126,66 @@ export class OpenAiApiProvider implements ProviderAdapter { try { const apiKey = await this.loadApiKey(); - const response = await fetch(`${normalizeBaseUrl(this.options.baseUrl)}/chat/completions`, { - method: "POST", - headers: this.buildHeaders(apiKey), - body: JSON.stringify(requestBody), - signal: controller.signal, - }); - const responseText = await response.text(); + for (let turn = 0; turn <= MAX_TOOL_TURNS; turn += 1) { + const currentRequestBody = { + ...requestBody, + messages, + }; + finalRequestBody = currentRequestBody; + const response = await fetch(`${normalizeBaseUrl(this.options.baseUrl)}/chat/completions`, { + method: "POST", + headers: this.buildHeaders(apiKey), + body: JSON.stringify(currentRequestBody), + signal: controller.signal, + }); + const responseText = await response.text(); - if (!response.ok) { - throw new Error( - `OpenAI-compatible chat request failed with ${response.status}: ${redactSecret(responseText, apiKey).slice(0, 2000)}`, - ); + if (!response.ok) { + throw new Error( + `OpenAI-compatible chat request failed with ${response.status}: ${redactSecret(responseText, apiKey).slice(0, 2000)}`, + ); + } + + const payload = JSON.parse(responseText) as ChatCompletionResponse; + finalPayload = payload; + const assistantMessage = normalizeAssistantMessage(payload); + messages.push(assistantMessage); + const toolCalls = assistantMessage.tool_calls ?? []; + + if (toolCalls.length === 0) { + outputText = normalizeModelText(assistantMessage.content); + break; + } + + if (!this.options.toolbox) { + outputText = + "The OpenAI-compatible model asked for tools, but this lane was not given any. That is a wiring problem."; + break; + } + + if (turn >= MAX_TOOL_TURNS) { + outputText = + "The OpenAI-compatible model kept reaching for more archive context than this lane can chase in one pass. Ask more narrowly or use the deeper Codex lane."; + break; + } + + for (const toolCall of toolCalls) { + const execution = await executeToolCall( + this.options.toolbox, + request.contextBundle, + toolCall, + ); + toolTrace.push(execution.trace); + messages.push({ + role: "tool", + tool_call_id: toolCall.id ?? `${execution.trace.tool}-${toolTrace.length}`, + content: JSON.stringify(execution.payload, null, 2), + }); + } } - const payload = JSON.parse(responseText) as ChatCompletionResponse; - const outputText = normalizeModelText(payload.choices?.[0]?.message?.content); - if (!outputText) { - const finishReason = normalizeModelText(payload.choices?.[0]?.finish_reason) ?? "unknown"; + const finishReason = normalizeModelText(finalPayload?.choices?.[0]?.finish_reason) ?? "unknown"; throw new Error( `OpenAI-compatible chat response did not include assistant content (finish_reason=${finishReason}).`, ); @@ -124,14 +195,15 @@ export class OpenAiApiProvider implements ProviderAdapter { status: "completed", summary: `OpenAI-compatible API response generated with ${model}.`, outputText, - artifacts: buildArtifacts(requestBody, payload), + artifacts: buildArtifacts(finalRequestBody ?? requestBody, finalPayload ?? {}, toolTrace), metadata: { model, - provider_model: normalizeModelText(payload.model) ?? model, - finish_reason: normalizeModelText(payload.choices?.[0]?.finish_reason) ?? "unknown", - prompt_tokens: readNumericMetadata(payload.usage?.prompt_tokens), - completion_tokens: readNumericMetadata(payload.usage?.completion_tokens), - total_tokens: readNumericMetadata(payload.usage?.total_tokens), + provider_model: normalizeModelText(finalPayload?.model) ?? model, + finish_reason: normalizeModelText(finalPayload?.choices?.[0]?.finish_reason) ?? "unknown", + tool_calls: String(toolTrace.length), + prompt_tokens: readNumericMetadata(finalPayload?.usage?.prompt_tokens), + completion_tokens: readNumericMetadata(finalPayload?.usage?.completion_tokens), + total_tokens: readNumericMetadata(finalPayload?.usage?.total_tokens), }, }; } catch (error) { @@ -187,6 +259,7 @@ export class OpenAiApiProvider implements ProviderAdapter { function buildArtifacts( requestBody: Record, payload: ChatCompletionResponse, + toolTrace: ToolTraceRecord[], ): ProviderArtifact[] { return [ { @@ -199,9 +272,40 @@ function buildArtifacts( contentType: "json", content: `${JSON.stringify(payload, null, 2)}\n`, }, + { + name: "openai-api-tool-trace.json", + contentType: "json", + content: `${JSON.stringify(toolTrace, null, 2)}\n`, + }, ]; } +function normalizeAssistantMessage(payload: ChatCompletionResponse): ChatMessage { + const message = payload.choices?.[0]?.message ?? {}; + return { + role: "assistant", + content: normalizeModelText(message.content) ?? "", + tool_calls: normalizeChatToolCalls(message.tool_calls), + }; +} + +function normalizeChatToolCalls(value: unknown): ChatToolCall[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((call): call is ChatToolCall => { + if (typeof call !== "object" || call === null) { + return false; + } + const candidate = call as ChatToolCall; + return ( + typeof candidate.function === "object" && + candidate.function !== null && + typeof candidate.function.name === "string" + ); + }); +} + function normalizeBaseUrl(value: string): string { return value.replace(/\/+$/, ""); } diff --git a/packages/providers/src/owner-codex-provider.ts b/packages/providers/src/owner-codex-provider.ts index a4c69e7..2f9b6e1 100644 --- a/packages/providers/src/owner-codex-provider.ts +++ b/packages/providers/src/owner-codex-provider.ts @@ -417,7 +417,11 @@ export class OwnerCodexProvider implements ProviderAdapter { prompt: input.prompt, imagePaths: collectCodexImagePaths(input.request.contextBundle), mcpServers: input.command === "repo-face-rumination" - ? restrictMcpServersToRepoFaceExploration(this.options.mcpServers ?? []) + ? restrictMcpServersToRepoFaceExploration( + this.options.mcpServers ?? [], + getStringOption(input.request.options?.repoFaceIdentity), + getStringOption(input.request.options?.repoFaceRole), + ) : this.options.mcpServers ?? [], }); lastResult = result; @@ -544,6 +548,8 @@ function getReasoningEffortOption(value: unknown): "none" | "low" | "medium" | " function restrictMcpServersToRepoFaceExploration( servers: NonNullable, + identity?: string, + role?: string, ): NonNullable { const allowedTools = [ "search_history", @@ -556,6 +562,7 @@ function restrictMcpServersToRepoFaceExploration( "get_odin_surface", "load_odin_interface_context", "invoke_odin_interface_command", + ...(role === "face" ? ["read_shared_document", "create_shared_document", "update_shared_document"] : []), ].join(","); return servers.map((server) => ({ @@ -563,6 +570,7 @@ function restrictMcpServersToRepoFaceExploration( env: { ...(server.env ?? {}), VOIDBOT_MCP_TOOL_ALLOWLIST: allowedTools, + ...(identity ? { VOIDBOT_REPO_FACE_IDENTITY: identity } : {}), }, })); } diff --git a/prompts/repo-face-research-capabilities.prompt.md b/prompts/repo-face-research-capabilities.prompt.md index 4ecfa49..c25881c 100644 --- a/prompts/repo-face-research-capabilities.prompt.md +++ b/prompts/repo-face-research-capabilities.prompt.md @@ -1,6 +1,7 @@ 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`, `load_odin_interface_context`, `invoke_odin_interface_command`. +- 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`, `read_shared_document`, `create_shared_document`, `update_shared_document`. +- Shared documents are typed durable scratch surfaces visible to every repo Face. Use `read_shared_document` to list or inspect them. Create one with a stable short id, title, and body; update one by supplying the revision you read so concurrent changes fail visibly instead of silently overwriting another Face. These tools do not grant arbitrary filesystem access. - 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. - Treat Odin as the all-seer for the early connected Colossus: use his Verse discovery when the live shape of the mesh would change your thought, proposal, memory, or action. A Verse is a provider-owned surface becoming legible to other minds, not a generic status blob. - 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. diff --git a/scripts/install-offsite-backup-task.ps1 b/scripts/install-offsite-backup-task.ps1 index 053de5b..1bf6edb 100644 --- a/scripts/install-offsite-backup-task.ps1 +++ b/scripts/install-offsite-backup-task.ps1 @@ -73,7 +73,7 @@ $action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "//B //nologo $trigger = New-ScheduledTaskTrigger -Daily -At $runAt $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 6) $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited -$description = "Runs VoidBot offsite backup sync to the Qwen box. Interactive logon required so the SSH key material is available." +$description = "Runs VoidBot offsite backup sync to the configured SSH target. Interactive logon required so the SSH key material is available." Register-ScheduledTask -TaskName $taskNameValue -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description $description -Force | Out-Null diff --git a/scripts/publish-idunn-rudp-health.cjs b/scripts/publish-idunn-rudp-health.cjs index 6a4dc15..3e479c1 100644 --- a/scripts/publish-idunn-rudp-health.cjs +++ b/scripts/publish-idunn-rudp-health.cjs @@ -21,7 +21,11 @@ const IDUNN_HEALTH_RUDP_CONNECTION_ID = 0x1d0d0001; async function main() { const options = parseArgs(process.argv.slice(2)); - const endpoint = parseEndpoint(options.endpoint || process.env.VOIDBOT_IDUNN_RUDP_HEALTH || "127.0.0.1:17870"); + const endpointValue = options.endpoint || process.env.VOIDBOT_IDUNN_RUDP_HEALTH; + if (!endpointValue || !String(endpointValue).trim()) { + throw new Error("VoidBot Idunn health publication requires --endpoint or VOIDBOT_IDUNN_RUDP_HEALTH; no localhost default is assumed."); + } + const endpoint = parseEndpoint(endpointValue); const daemonId = options.daemon || process.env.VOIDBOT_IDUNN_DAEMON || "voidbot"; const healthContract = options.contract || process.env.VOIDBOT_IDUNN_HEALTH_CONTRACT || "voidbot.cultnet-rudp-stack-health"; const state = options.state || "healthy"; diff --git a/scripts/run-gamecult-orchestrator.ps1 b/scripts/run-gamecult-orchestrator.ps1 index 5da9d81..56edfe8 100644 --- a/scripts/run-gamecult-orchestrator.ps1 +++ b/scripts/run-gamecult-orchestrator.ps1 @@ -15,6 +15,17 @@ $lockPath = Join-Path $statusDir "gamecult-orchestrator.lock" $agentSwarmPausePath = Join-Path $repoRoot "state\agent-swarm-paused.json" $hiddenLauncher = Join-Path $PSScriptRoot "run-hidden-powershell.vbs" $bifrostRoot = "E:\Projects\Bifrost" +$bootstrapLogPath = Join-Path $logDir "bootstrap.log" + +function Write-BootstrapLog { + param([string] $Message) + try { + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + $line = "[{0}] pid={1} cwd={2} {3}" -f ([DateTime]::UtcNow.ToString("o")), $PID, (Get-Location).Path, $Message + Add-Content -LiteralPath $bootstrapLogPath -Encoding UTF8 -Value $line + } catch { + } +} function Read-DotEnv { param([Parameter(Mandatory = $true)][string] $Path) @@ -114,10 +125,15 @@ function Publish-IdunnRudpHealth { return } - $endpoint = if ($env:VOIDBOT_IDUNN_RUDP_HEALTH) { $env:VOIDBOT_IDUNN_RUDP_HEALTH } else { "127.0.0.1:17870" } + $endpoint = $env:VOIDBOT_IDUNN_RUDP_HEALTH + if ([string]::IsNullOrWhiteSpace($endpoint)) { + Write-BootstrapLog "skipping-idunn-health VOIDBOT_IDUNN_RUDP_HEALTH is not configured" + return + } $state = if ($Summary.ok) { "healthy" } else { "failed" } $detail = "VoidBot orchestrator pulse ran $($Summary.ranCount) organ(s) in $($Summary.durationSeconds)s; ok=$($Summary.ok)" try { + Write-BootstrapLog "publishing-idunn-health state=$state ran=$($Summary.ranCount) duration=$($Summary.durationSeconds)" $nodeExe = (Get-Command node.exe -ErrorAction Stop).Source $output = & $nodeExe $publisher ` --endpoint $endpoint ` @@ -129,9 +145,106 @@ function Publish-IdunnRudpHealth { if ($LASTEXITCODE -ne 0) { throw "publisher exited with code $LASTEXITCODE`: $($output -join [Environment]::NewLine)" } + Write-BootstrapLog "published-idunn-health" } catch { $message = "[$([DateTime]::UtcNow.ToString("o"))] Idunn RUDP health publish failed: $($_.Exception.Message)" Add-Content -LiteralPath (Join-Path $logDir "idunn-rudp-health.log") -Encoding UTF8 -Value $message + Write-BootstrapLog "publish-idunn-health-failed $($_.Exception.Message)" + } +} + +function Get-ConfigString { + param( + [hashtable] $Config, + [string] $Name, + [string] $Default + ) + $raw = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($raw) -and $Config.ContainsKey($Name)) { + $raw = $Config[$Name] + } + if ([string]::IsNullOrWhiteSpace($raw)) { + return $Default + } + return $raw.Trim() +} + +function Find-VoidBotSwarmCultMeshProcess { + param([string] $ScriptPath) + $escaped = $ScriptPath.Replace("\", "\\") + return @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.Name -eq "node.exe" -and + $_.CommandLine -and + ($_.CommandLine -like "*serve-voidbot-swarm-cultmesh.cjs*" -or $_.CommandLine -like "*$escaped*") + }) +} + +function Ensure-VoidBotSwarmCultMeshServer { + param( + [hashtable] $Config, + [string] $NodePath + ) + + $scriptPath = Join-Path $PSScriptRoot "serve-voidbot-swarm-cultmesh.cjs" + if (-not (Test-Path -LiteralPath $scriptPath)) { + throw "VoidBot swarm CultMesh server script is missing at $scriptPath." + } + + $storePath = Get-ConfigString -Config $Config -Name "VOIDBOT_SWARM_CULTMESH_STORE" -Default (Join-Path $statusDir "cultmesh\voidbot-swarm-state.cc") + $storePath = [System.IO.Path]::GetFullPath($storePath) + if (-not (Test-Path -LiteralPath $storePath)) { + throw "VoidBot swarm CultMesh store is missing at $storePath." + } + + $bind = Get-ConfigString -Config $Config -Name "VOIDBOT_SWARM_CULTMESH_BIND" -Default "127.0.0.1:17873" + $odinCultMeshUri = Get-ConfigString -Config $Config -Name "VOIDBOT_ODIN_CULTMESH_URI" -Default ( + Get-ConfigString -Config $Config -Name "ODIN_CULTMESH_URI" -Default "cultmesh://odin/rendezvous/provider-catalog" + ) + $odinRudpEndpoint = Get-ConfigString -Config $Config -Name "VOIDBOT_ODIN_RUDP" -Default ( + Get-ConfigString -Config $Config -Name "CULTMESH_URI_ODIN_RUDP" -Default "rudp://127.0.0.1:17871" + ) + + $existing = @(Find-VoidBotSwarmCultMeshProcess -ScriptPath $scriptPath) + if ($existing.Count -gt 0) { + return [pscustomobject]@{ + status = "already_running" + pid = [int]$existing[0].ProcessId + bind = $bind + storePath = $storePath + odinCultMeshUri = $odinCultMeshUri + odinRudpEndpoint = $odinRudpEndpoint + } + } + + $stdoutPath = Join-Path $logDir "voidbot-swarm-cultmesh.stdout.log" + $stderrPath = Join-Path $logDir "voidbot-swarm-cultmesh.stderr.log" + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + + $arguments = @( + $scriptPath, + "--store", $storePath, + "--bind", $bind, + "--odin-cultmesh-uri", $odinCultMeshUri, + "--odin-rudp-endpoint", $odinRudpEndpoint + ) + $process = Start-Process -FilePath $NodePath -ArgumentList $arguments -WorkingDirectory $repoRoot -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -WindowStyle Hidden -PassThru + Start-Sleep -Seconds 2 + $process.Refresh() + if ($process.HasExited) { + $stdoutTail = if (Test-Path -LiteralPath $stdoutPath) { (Get-Content -LiteralPath $stdoutPath -Tail 40) -join "`n" } else { "" } + $stderrTail = if (Test-Path -LiteralPath $stderrPath) { (Get-Content -LiteralPath $stderrPath -Tail 40) -join "`n" } else { "" } + throw "VoidBot swarm CultMesh server exited early.`nSTDOUT:`n$stdoutTail`nSTDERR:`n$stderrTail" + } + + return [pscustomobject]@{ + status = "started" + pid = $process.Id + bind = $bind + storePath = $storePath + odinCultMeshUri = $odinCultMeshUri + odinRudpEndpoint = $odinRudpEndpoint + stdoutPath = $stdoutPath + stderrPath = $stderrPath } } @@ -184,6 +297,7 @@ function Invoke-Organ { New-Item -ItemType Directory -Force -Path $logDir | Out-Null $startedAt = [DateTime]::UtcNow + Write-BootstrapLog "invoke-organ start id=$($Organ.Id) executable=$($Organ.Executable)" $exitCode = 0 $timedOut = $false try { @@ -200,6 +314,8 @@ function Invoke-Organ { $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo [void]$process.Start() + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() $timeoutSeconds = [Math]::Max(60, $Organ.TimeoutMinutes * 60) if (-not $process.WaitForExit($timeoutSeconds * 1000)) { @@ -207,8 +323,8 @@ function Invoke-Organ { cmd /c "taskkill /PID $($process.Id) /T /F" | Out-Null $process.WaitForExit() } - $stdout = $process.StandardOutput.ReadToEnd() - $stderr = $process.StandardError.ReadToEnd() + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() [System.IO.File]::WriteAllText($stdoutPath, $stdout, [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($stderrPath, $stderr, [System.Text.UTF8Encoding]::new($false)) $exitCode = if ($timedOut) { 124 } elseif ($null -eq $process.ExitCode) { 0 } else { $process.ExitCode } @@ -242,6 +358,7 @@ function Invoke-Organ { $stderr.Trim() ) -join [Environment]::NewLine [System.IO.File]::WriteAllText($logPath, $combined.Trim(), [System.Text.UTF8Encoding]::new($false)) + Write-BootstrapLog "invoke-organ finish id=$($Organ.Id) status=$(if ($timedOut) { 'timed_out' } elseif ($exitCode -eq 0) { 'ok' } else { 'failed' }) exit=$exitCode" return [pscustomobject]@{ id = $Organ.Id @@ -291,6 +408,31 @@ function Test-OrchestratorLock { return $true } +function Acquire-OrchestratorLock { + $directory = Split-Path -Parent $lockPath + New-Item -ItemType Directory -Force -Path $directory | Out-Null + + for ($attempt = 0; $attempt -lt 2; $attempt++) { + try { + $stream = [System.IO.File]::Open($lockPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::Read) + $payload = @{ + pid = $PID + startedAt = ([DateTime]::UtcNow).ToString("o") + } | ConvertTo-Json -Depth 4 + $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($payload) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush() + return $stream + } catch [System.IO.IOException] { + if (Test-OrchestratorLock) { + return $null + } + } + } + + return $null +} + function Test-AgentSwarmPaused { if (-not (Test-Path -LiteralPath $agentSwarmPausePath)) { return $false @@ -310,12 +452,15 @@ function Test-AgentSwarmPaused { } New-Item -ItemType Directory -Force -Path $statusDir, $logDir | Out-Null -if (Test-OrchestratorLock) { +Write-BootstrapLog "script-start only=$($Only -join ',') force=$Force" +$lockHandle = Acquire-OrchestratorLock +if ($null -eq $lockHandle) { + Write-BootstrapLog "lock-already-active" exit 0 } $startedAt = [DateTime]::UtcNow -Write-JsonFile -Path $lockPath -Data @{ pid = $PID; startedAt = $startedAt.ToString("o") } +Write-BootstrapLog "lock-created path=$lockPath" try { $config = Read-DotEnv -Path (Join-Path $repoRoot ".env") @@ -449,8 +594,9 @@ try { } $result = Invoke-Organ -Organ $organ -Now $now + $serverState = $null $runs += $result - Set-OrganState -State $state -Id $organ.Id -Value ([pscustomobject]@{ + $organState = [pscustomobject]@{ label = $organ.Label intervalMinutes = $organ.IntervalMinutes lastStartedAt = $result.startedAt @@ -458,7 +604,11 @@ try { lastExitCode = $result.exitCode lastStatus = $result.status lastLogPath = $result.logPath - }) + } + if ($null -ne $serverState) { + $organState | Add-Member -NotePropertyName cultMeshServer -NotePropertyValue $serverState -Force + } + Set-OrganState -State $state -Id $organ.Id -Value $organState Write-JsonFile -Path $statePath -Data $state } @@ -475,11 +625,18 @@ try { } Write-JsonFile -Path (Join-Path $statusDir "gamecult-orchestrator-last-run.json") -Data $summary Write-JsonFile -Path $statePath -Data $state + Write-BootstrapLog "summary-written ok=$($summary.ok) ran=$($summary.ranCount)" Publish-IdunnRudpHealth -Summary $summary if (-not $summary.ok) { + Write-BootstrapLog "script-exit-failed" exit 1 } + Write-BootstrapLog "script-exit-ok" } finally { + if ($null -ne $lockHandle) { + $lockHandle.Dispose() + } Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue + Write-BootstrapLog "lock-removed" } diff --git a/scripts/smoke-repo-face-shared-documents-mcp.mjs b/scripts/smoke-repo-face-shared-documents-mcp.mjs new file mode 100644 index 0000000..dcbe8bb --- /dev/null +++ b/scripts/smoke-repo-face-shared-documents-mcp.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const workspaceRoot = resolve(process.cwd()); +const serverPath = resolve(workspaceRoot, "apps", "worker", "dist", "mcp-server.js"); +const storageRoot = await mkdtemp(join(tmpdir(), "voidbot-shared-documents-mcp-")); +if (!existsSync(serverPath)) { + throw new Error(`MCP server entrypoint is missing at ${serverPath}. Run the build first.`); +} + +const expectedTools = ["read_shared_document", "create_shared_document", "update_shared_document"]; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [serverPath], + cwd: workspaceRoot, + env: { + ...process.env, + STORAGE_ROOT: storageRoot, + REPO_DISCORD_IDENTITIES_PATH: resolve(workspaceRoot, ".voidbot", "private", "repo-discord-identities.json"), + VOIDBOT_REPO_FACE_IDENTITY: "nibu", + VOIDBOT_MCP_TOOL_ALLOWLIST: expectedTools.join(","), + }, + stderr: "pipe", +}); +const client = new Client({ name: "voidbot-shared-documents-smoke", version: "0.0.0" }); + +try { + await client.connect(transport); + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name).sort(); + if (JSON.stringify(names) !== JSON.stringify([...expectedTools].sort())) { + throw new Error(`Unexpected shared-document tool surface: ${names.join(", ")}`); + } + + const created = await client.callTool({ + name: "create_shared_document", + arguments: { documentId: "shared-scratch", title: "Shared scratch", body: "First mark" }, + }); + assertOk(created, "create_shared_document"); + if (created.structuredContent?.document?.createdBy !== "nibu") { + throw new Error("MCP server did not stamp the active Face identity on create."); + } + + const read = await client.callTool({ name: "read_shared_document", arguments: { documentId: "shared-scratch" } }); + assertOk(read, "read_shared_document"); + if (read.structuredContent?.document?.version !== 1) { + throw new Error("MCP read did not return the created revision."); + } + + const updated = await client.callTool({ + name: "update_shared_document", + arguments: { documentId: "shared-scratch", body: "Second mark", expectedVersion: 1 }, + }); + assertOk(updated, "update_shared_document"); + if (updated.structuredContent?.document?.version !== 2) { + throw new Error("MCP update did not advance the revision."); + } + + process.stdout.write(`${JSON.stringify({ ok: true, tools: names, document: updated.structuredContent?.document })}\n`); +} finally { + await client.close(); + await rm(storageRoot, { recursive: true, force: true }); +} + +function assertOk(result, tool) { + if (result.isError) { + const text = (result.content ?? []).map((entry) => entry.type === "text" ? entry.text : "").join("\n"); + throw new Error(`${tool} failed: ${text}`); + } +} diff --git a/scripts/smoke-repo-face-shared-documents.ts b/scripts/smoke-repo-face-shared-documents.ts new file mode 100644 index 0000000..a8c12c9 --- /dev/null +++ b/scripts/smoke-repo-face-shared-documents.ts @@ -0,0 +1,66 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + createRepoFaceSharedDocument, + listRepoFaceSharedDocuments, + updateRepoFaceSharedDocument, +} from "@voidbot/core"; + +async function main(): Promise { + const directory = await mkdtemp(join(tmpdir(), "voidbot-shared-documents-")); + const path = join(directory, "shared.cc"); + + try { + const created = await createRepoFaceSharedDocument({ + canonicalPath: path, + documentId: "coordination-note", + title: "Coordination note", + body: "Initial state", + actorId: "face-a", + now: "2026-07-12T00:00:00.000Z", + }); + if (created.version !== 1 || created.createdBy !== "face-a") { + throw new Error("Create lost initial revision provenance."); + } + + const updated = await updateRepoFaceSharedDocument({ + canonicalPath: path, + documentId: "coordination-note", + body: "Updated state", + expectedVersion: 1, + actorId: "face-b", + now: "2026-07-12T00:05:00.000Z", + }); + if (updated.version !== 2 || updated.updatedBy !== "face-b") { + throw new Error("Update lost revision provenance."); + } + + let rejected = false; + try { + await updateRepoFaceSharedDocument({ + canonicalPath: path, + documentId: "coordination-note", + body: "Stale overwrite", + expectedVersion: 1, + actorId: "face-c", + }); + } catch { + rejected = true; + } + if (!rejected) { + throw new Error("A stale update overwrote shared state."); + } + + const documents = await listRepoFaceSharedDocuments(path); + if (documents.length !== 1 || documents[0]?.body !== "Updated state") { + throw new Error("Canonical shared state was not preserved."); + } + console.log(JSON.stringify({ ok: true, document: documents[0] })); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +void main(); diff --git a/scripts/start-voidbot-stack.ps1 b/scripts/start-voidbot-stack.ps1 index 98c6e80..4a0288f 100644 --- a/scripts/start-voidbot-stack.ps1 +++ b/scripts/start-voidbot-stack.ps1 @@ -253,49 +253,64 @@ function Ensure-DockerOnPath { } -FailureMessage "Docker is installed but the daemon is not ready." -Attempts 45 -SleepSeconds 2 } -function Ensure-BifrostLocalStack { +function Ensure-BifrostCultMeshCommandStore { param( [Parameter(Mandatory = $true)] [string] $BifrostRoot, [Parameter(Mandatory = $true)] - [string] $HealthUrl + [string] $StorePath, + [Parameter(Mandatory = $true)] + [string] $CommandUri ) - if (Test-HttpEndpoint -Url $HealthUrl) { - return @{ - root = $BifrostRoot - healthUrl = $HealthUrl - startedByScript = $false - healthy = $true - } + if ($CommandUri -notmatch '^cultmesh://[^/]+/commands/discord-post(?:$|[/?#])') { + throw "BIFROST_CULTMESH_COMMAND_URI must be a CultMesh Discord command URI, got '$CommandUri'." } if (-not (Test-Path $BifrostRoot)) { throw "Bifrost root does not exist at $BifrostRoot." } - $composeFile = Join-Path $BifrostRoot "compose.local.yaml" - - if (-not (Test-Path $composeFile)) { - throw "Bifrost local compose file does not exist at $composeFile." + $processor = Join-Path $BifrostRoot "tools\cultmesh-bridge-commands.mjs" + if (-not (Test-Path $processor)) { + throw "Bifrost CultMesh command processor does not exist at $processor." } - Ensure-DockerOnPath - - & docker compose -f $composeFile --project-directory $BifrostRoot up -d - - if ($LASTEXITCODE -ne 0) { - throw "Docker Compose failed to start Bifrost." + $storeParent = Split-Path -Parent $StorePath + if (-not [string]::IsNullOrWhiteSpace($storeParent)) { + New-Item -ItemType Directory -Force -Path $storeParent | Out-Null } - Wait-Until -Condition { Test-HttpEndpoint -Url $HealthUrl } -FailureMessage "Bifrost did not become reachable at $HealthUrl in time." -Attempts 45 -SleepSeconds 2 + $exportedByScript = $false + if (-not (Test-Path $StorePath)) { + $exporter = Join-Path $BifrostRoot "tools\provider-advertisement.mjs" + if (-not (Test-Path $exporter)) { + throw "Bifrost provider advertisement exporter does not exist at $exporter." + } + + $nodeCommand = Get-Command node.exe -ErrorAction Stop + & $nodeCommand.Source $exporter export --out $StorePath + + if ($LASTEXITCODE -ne 0) { + throw "Bifrost provider CultMesh store export failed." + } + + $exportedByScript = $true + } + + $storeItem = Get-Item -LiteralPath $StorePath -ErrorAction Stop + if ($storeItem.Length -le 0) { + throw "Bifrost provider CultMesh store is empty at $StorePath." + } return @{ root = $BifrostRoot - composeFile = $composeFile - healthUrl = $HealthUrl - startedByScript = $true + commandUri = $CommandUri + storePath = $StorePath + processor = $processor + exportedByScript = $exportedByScript healthy = $true + healthAuthority = "cultmesh-command-store" } } @@ -561,7 +576,8 @@ $localLlmBaseUrl = if ($config.ContainsKey("LOCAL_LLM_OLLAMA_BASE_URL")) { $conf $localLlmModel = if ($config.ContainsKey("LOCAL_LLM_OLLAMA_MODEL")) { $config["LOCAL_LLM_OLLAMA_MODEL"] } else { "qwen3.5:9b" } $localLlmSocialReadModel = if ($config.ContainsKey("LOCAL_LLM_SOCIAL_READ_OLLAMA_MODEL") -and -not [string]::IsNullOrWhiteSpace($config["LOCAL_LLM_SOCIAL_READ_OLLAMA_MODEL"])) { $config["LOCAL_LLM_SOCIAL_READ_OLLAMA_MODEL"] } else { $localLlmModel } $bifrostRoot = if ($config.ContainsKey("BIFROST_ROOT")) { [System.IO.Path]::GetFullPath($config["BIFROST_ROOT"]) } else { "E:\Projects\Bifrost" } -$bifrostHealthUrl = if ($config.ContainsKey("BIFROST_HEALTH_URL")) { $config["BIFROST_HEALTH_URL"] } else { "http://127.0.0.1:5080/healthz" } +$bifrostCultMeshCommandUri = if ($config.ContainsKey("BIFROST_CULTMESH_COMMAND_URI")) { $config["BIFROST_CULTMESH_COMMAND_URI"] } else { "cultmesh://asgard.starfire.bifrost/commands/discord-post" } +$bifrostCultMeshStorePath = if ($config.ContainsKey("BIFROST_CULTMESH_STORE_PATH")) { [System.IO.Path]::GetFullPath($config["BIFROST_CULTMESH_STORE_PATH"]) } else { "E:\Projects\Bifrost\.bifrost\provider-store.cc" } if ($stateStorageBackend -eq "postgres") { $status.stage = "postgres" @@ -637,7 +653,7 @@ if ($localLlmEnabled) { $status.stage = "bifrost" Write-StatusFile -Path $statusPath -Status $status -$status.bifrost = Ensure-BifrostLocalStack -BifrostRoot $bifrostRoot -HealthUrl $bifrostHealthUrl +$status.bifrost = Ensure-BifrostCultMeshCommandStore -BifrostRoot $bifrostRoot -StorePath $bifrostCultMeshStorePath -CommandUri $bifrostCultMeshCommandUri Write-StatusFile -Path $statusPath -Status $status $npmCommand = Get-Command npm.cmd -ErrorAction Stop diff --git a/state/agent-swarm-paused.json b/state/agent-swarm-paused.json index 59e05e0..4273eab 100644 --- a/state/agent-swarm-paused.json +++ b/state/agent-swarm-paused.json @@ -1,5 +1,5 @@ { - "paused": true, - "reason": "Re-paused by Codex on 2026-07-03 after MiMo quality gates passed but the live GameCult Local Orchestrator repeatedly reached repo-face-heartbeats and then failed to advance its status/lock summary. The model/provider path is good enough for more tests; the live scheduler handoff needs diagnosis before unattended turns.", - "updatedAt": "2026-07-03T01:31:12.1534400+01:00" + "paused": false, + "reason": "Unpaused by Codex on 2026-07-04 after Bifrost/Discord delivery was wired through typed CultMesh command/receipt documents and a bounded Weksa live-fire post completed visibly through the new route. Keep using CultMesh URIs and provider/interface command metadata; do not restore BIFROST_BRIDGE_BASE_URL/token shortcuts.", + "updatedAt": "2026-07-04T00:44:00.0000000+01:00" } diff --git a/vendor/cultcache-ts b/vendor/cultcache-ts index 526a47e..3eef1f1 160000 --- a/vendor/cultcache-ts +++ b/vendor/cultcache-ts @@ -1 +1 @@ -Subproject commit 526a47e06a4f040cc40db24de4d969d42eb61fbe +Subproject commit 3eef1f1f1b7d2b493ac46a8c3f8f207b3a907844