Unify typed agent transport and shared state
This commit is contained in:
parent
2059411e85
commit
c4376ee129
23 changed files with 1434 additions and 538 deletions
|
|
@ -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=
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,8 +2,11 @@ node_modules/
|
|||
dist/
|
||||
.env
|
||||
.voidbot/
|
||||
.bifrost/
|
||||
coverage/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
**/bin/
|
||||
**/obj/
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<BifrostBridgeReceipt> {
|
||||
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<void>;
|
||||
get: (definition: unknown, key: string) => unknown;
|
||||
flush?: () => Promise<void>;
|
||||
cache?: { pullAllBackingStores?: () => Promise<void> };
|
||||
}> {
|
||||
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<void> } },
|
||||
commandId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
} | 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<void>;
|
||||
get: (definition: unknown, key: string) => unknown;
|
||||
flush?: () => Promise<void>;
|
||||
cache?: { pullAllBackingStores?: () => Promise<void> };
|
||||
}> };
|
||||
defineDocumentType: (definition: Record<string, unknown>) => 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<void>;
|
||||
get: (definition: unknown, key: string) => unknown;
|
||||
flush?: () => Promise<void>;
|
||||
cache?: { pullAllBackingStores?: () => Promise<void> };
|
||||
}> };
|
||||
};
|
||||
const { defineDocumentType } = requireCult("cultcache-ts") as {
|
||||
defineDocumentType?: (definition: Record<string, unknown>) => 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<string, unknown> | 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<string, unknown> {
|
||||
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<void> {
|
||||
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
||||
}
|
||||
|
||||
async function resolveRepoIdentityForJobIntent(
|
||||
job: JobRecord,
|
||||
identitySelector?: string,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
frame?: Record<string, unknown>;
|
||||
expectReceiptMs?: number;
|
||||
}
|
||||
|
||||
export interface ListIndexedReposArgs {
|
||||
|
|
|
|||
|
|
@ -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<CallToolResult> => {
|
||||
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<CallToolResult> => {
|
||||
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<CallToolResult> => {
|
||||
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<CallToolResult> => {
|
||||
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<CallToolResult> => {
|
||||
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<CallToolResult> => {
|
||||
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<CallToolResult> => {
|
||||
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<CallToolResult> => {
|
||||
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<ReturnType<typeof findRepoDiscordIdentity>>;
|
||||
|
|
@ -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<Record<string, unknown>> }> {
|
||||
const response = await fetch(`${odinBaseUrl}/eve/deck/providers`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Odin provider catalog failed: HTTP ${response.status}`);
|
||||
}
|
||||
const payload = await response.json() as { providers?: unknown };
|
||||
const providers = Array.isArray(payload.providers)
|
||||
? payload.providers.filter(isRecord)
|
||||
: [];
|
||||
async function fetchOdinProviderCatalog(odinStorePath: string): Promise<{ providers: Array<Record<string, unknown>> }> {
|
||||
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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown>,
|
||||
providerId: string,
|
||||
): Record<string, unknown> | 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<string, unknown>) {
|
||||
|
|
@ -1297,13 +1461,13 @@ interface LoadedOdinInterface {
|
|||
manifest: Record<string, unknown> | null;
|
||||
snapshot: Record<string, unknown>;
|
||||
root?: Record<string, unknown>;
|
||||
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<LoadedOdinInterface> {
|
||||
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<Record<string, unknown>> {
|
||||
const deckUrl = new URL(deckUrlText);
|
||||
const connection = await openWebSocketSnapshotSocket(deckUrl);
|
||||
const { socket } = connection;
|
||||
let pendingBuffer = connection.initialBuffer;
|
||||
try {
|
||||
sendClientWebSocketText(socket, JSON.stringify({ type: "open-provider", providerId }));
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const frame = await readServerWebSocketText(socket, 3000, pendingBuffer);
|
||||
pendingBuffer = frame.remaining;
|
||||
const parsed = JSON.parse(frame.text) as unknown;
|
||||
if (isRecord(parsed) && parsed.providerId === providerId) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
throw new Error(`Provider deck did not publish ${providerId}.`);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function findOdinInterfaceNode(
|
||||
odinSnapshot: Record<string, unknown>,
|
||||
providerId: string,
|
||||
|
|
@ -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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (isRecord(command.frameTemplate)) {
|
||||
return materializeFrameTemplate(command.frameTemplate, payload);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "surface-command",
|
||||
schema: "gamecult.eve.command.v1",
|
||||
providerId,
|
||||
command: command.command,
|
||||
payload,
|
||||
nodeId: command.nodeId,
|
||||
};
|
||||
}
|
||||
|
||||
function materializeFrameTemplate(
|
||||
template: Record<string, unknown>,
|
||||
payload: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const materialized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
materialized[key] = materializeTemplateValue(value, payload);
|
||||
}
|
||||
return materialized;
|
||||
}
|
||||
|
||||
function materializeTemplateValue(value: unknown, payload: Record<string, unknown>): unknown {
|
||||
if (typeof value === "string") {
|
||||
const exact = value.match(/^\$\{([A-Za-z0-9_.-]+)\??\}$/);
|
||||
if (exact) {
|
||||
return payload[exact[1]] ?? "";
|
||||
}
|
||||
return value.replace(/\$\{([A-Za-z0-9_.-]+)\??\}/g, (_match, key: string) =>
|
||||
payload[key] === undefined || payload[key] === null ? "" : String(payload[key]),
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => materializeTemplateValue(entry, payload));
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return materializeFrameTemplate(value, payload);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function sendProviderCommandFrame(
|
||||
deckUrlText: string,
|
||||
frame: Record<string, unknown>,
|
||||
expectReceiptMs: number,
|
||||
) {
|
||||
const deckUrl = new URL(deckUrlText);
|
||||
const connection = await openWebSocketSnapshotSocket(deckUrl);
|
||||
const { socket } = connection;
|
||||
let pendingBuffer = connection.initialBuffer;
|
||||
try {
|
||||
try {
|
||||
const initial = await readServerWebSocketText(socket, 1000, pendingBuffer);
|
||||
pendingBuffer = initial.remaining;
|
||||
} catch {
|
||||
// Some providers do not send an initial deck frame before commands.
|
||||
}
|
||||
sendClientWebSocketText(socket, JSON.stringify(frame));
|
||||
if (expectReceiptMs <= 0) {
|
||||
return {
|
||||
received: false,
|
||||
detail: "Command frame sent; receipt wait disabled.",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const response = await readServerWebSocketText(socket, expectReceiptMs, pendingBuffer);
|
||||
pendingBuffer = response.remaining;
|
||||
const parsed = JSON.parse(response.text) as unknown;
|
||||
return {
|
||||
received: true,
|
||||
frame: isRecord(parsed) ? parsed : response.text,
|
||||
remainingBytes: pendingBuffer.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
received: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function getSurfaceRoot(snapshot: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const surface = isRecord(snapshot.surface) ? snapshot.surface : undefined;
|
||||
return isRecord(surface?.root) ? surface.root : undefined;
|
||||
|
|
@ -1662,146 +1681,3 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
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<ArrayBufferLike> = Buffer.alloc(0),
|
||||
): Promise<{ text: string; remaining: Buffer<ArrayBufferLike> }> {
|
||||
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<ArrayBufferLike> }) {
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
112
packages/core/src/repo-face-shared-documents.ts
Normal file
112
packages/core/src/repo-face-shared-documents.ts
Normal file
|
|
@ -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<typeof documentSchema>;
|
||||
|
||||
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<RepoFaceSharedDocument[]> {
|
||||
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<RepoFaceSharedDocument> {
|
||||
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<RepoFaceSharedDocument> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<WeksaMimoReceipt> {
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<string, unknown>,
|
||||
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(/\/+$/, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<OwnerCodexProviderOptions["mcpServers"]>,
|
||||
identity?: string,
|
||||
role?: string,
|
||||
): NonNullable<OwnerCodexProviderOptions["mcpServers"]> {
|
||||
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 } : {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
75
scripts/smoke-repo-face-shared-documents-mcp.mjs
Normal file
75
scripts/smoke-repo-face-shared-documents-mcp.mjs
Normal file
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
66
scripts/smoke-repo-face-shared-documents.ts
Normal file
66
scripts/smoke-repo-face-shared-documents.ts
Normal file
|
|
@ -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<void> {
|
||||
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();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
2
vendor/cultcache-ts
vendored
2
vendor/cultcache-ts
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 526a47e06a4f040cc40db24de4d969d42eb61fbe
|
||||
Subproject commit 3eef1f1f1b7d2b493ac46a8c3f8f207b3a907844
|
||||
Loading…
Reference in a new issue