Extract Persona turn context source

This commit is contained in:
Meta Vens 2026-07-15 23:13:53 +01:00
commit 0ec63bf035
10 changed files with 258 additions and 224 deletions

View file

@ -1,11 +1,10 @@
import "dotenv/config";
import { createHash } from "node:crypto";
import { spawn, spawnSync } from "node:child_process";
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { dirname, extname, resolve } from "node:path";
import { dirname, resolve } from "node:path";
import { loadConfig } from "@voidbot/config";
import {
@ -34,7 +33,6 @@ import {
type RepoFaceConversationFocus,
type RetrievalResult,
type SourceMessage,
type SourceMessageAttachment,
} from "@voidbot/shared";
import {
advanceInitiativeClockFromWallClock,
@ -62,9 +60,13 @@ import {
type IdleCoolingSnapshot,
} from "../apps/persona-scheduler/dist/discord-activity-source.js";
import { readSemanticPressure } from "../apps/persona-scheduler/dist/semantic-pressure-source.js";
import {
fetchChannelSnapshots,
fetchRecentDiscordMessages,
type ChannelSnapshot,
} from "../apps/persona-scheduler/dist/turn-context-source.js";
const HEARTBEAT_COMMAND = "repo-face-rumination";
const MAX_FACE_IMAGE_ATTACHMENT_BYTES = 12 * 1024 * 1024;
interface RepoFaceChannelPlan {
primaryChannelId?: string;
@ -110,11 +112,6 @@ interface BifrostGovernanceComment {
createdAt: string;
}
interface ChannelSnapshot {
channelId: string;
messages: SourceMessage[];
}
async function main(): Promise<void> {
const config = loadConfig();
const dryRun = process.argv.includes("--dry-run");
@ -773,191 +770,6 @@ function sleepCyclesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
async function fetchRecentDiscordMessages(input: {
botToken?: string;
channelId: string;
limit: number;
ignoreBotMessages?: boolean;
}): Promise<SourceMessage[]> {
if (!input.botToken) {
return [];
}
const url = new URL(`https://discord.com/api/v10/channels/${input.channelId}/messages`);
url.searchParams.set("limit", String(Math.max(1, Math.min(input.limit, 25))));
const response = await fetch(url, {
headers: {
Authorization: `Bot ${input.botToken}`,
},
});
if (!response.ok) {
throw new Error(`Discord recent message fetch failed with ${response.status}: ${await response.text()}`);
}
const messages = await response.json() as DiscordApiMessage[];
const sourceMessages = await Promise.all(messages
.filter((message) => !(input.ignoreBotMessages && message.author.bot === true))
.map(async (message) => {
const attachments = await materializeDiscordAttachments({
channelId: input.channelId,
message,
});
return {
id: message.id,
authorId: message.author.id,
authorName: message.author.global_name ?? message.member?.nick ?? message.author.username,
content: message.content,
timestamp: message.timestamp,
isBot: message.author.bot === true,
...(attachments.length > 0 ? { attachments } : {}),
};
}));
return sourceMessages
.sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp));
}
async function fetchChannelSnapshots(input: {
botToken?: string;
channelIds: string[];
primaryChannelId: string;
limit: number;
bifrostDiscordChannelId?: string;
}): Promise<ChannelSnapshot[]> {
const snapshots: ChannelSnapshot[] = [];
for (const channelId of input.channelIds.filter((entry) => entry !== input.primaryChannelId).slice(0, 5)) {
try {
snapshots.push({
channelId,
messages: await fetchRecentDiscordMessages({
botToken: input.botToken,
channelId,
limit: input.limit,
ignoreBotMessages: channelId === input.bifrostDiscordChannelId,
}),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
snapshots.push({
channelId,
messages: [{
id: `snapshot-error:${channelId}`,
authorId: "voidbot",
authorName: "VoidBot",
content: `Could not read recent channel context: ${message}`,
timestamp: new Date().toISOString(),
isBot: true,
}],
});
}
}
return snapshots;
}
async function materializeDiscordAttachments(input: {
channelId: string;
message: DiscordApiMessage;
}): Promise<SourceMessageAttachment[]> {
const attachments = input.message.attachments ?? [];
if (attachments.length === 0) {
return [];
}
const materialized: SourceMessageAttachment[] = [];
for (const attachment of attachments.slice(0, 4)) {
const kind = isDiscordImageAttachment(attachment) ? "image" : "other";
let localPath: string | undefined;
if (kind === "image" && isWithinFaceImageSizeLimit(attachment)) {
localPath = await cacheDiscordImageAttachment({
channelId: input.channelId,
messageId: input.message.id,
attachment,
});
}
materialized.push({
kind,
id: attachment.id,
filename: attachment.filename,
contentType: attachment.content_type,
url: attachment.url,
proxyUrl: attachment.proxy_url,
size: typeof attachment.size === "number" ? attachment.size : undefined,
width: typeof attachment.width === "number" ? attachment.width : undefined,
height: typeof attachment.height === "number" ? attachment.height : undefined,
localPath,
});
}
return materialized;
}
function isWithinFaceImageSizeLimit(attachment: DiscordApiAttachment): boolean {
return typeof attachment.size !== "number" || attachment.size <= MAX_FACE_IMAGE_ATTACHMENT_BYTES;
}
function isDiscordImageAttachment(attachment: DiscordApiAttachment): boolean {
const contentType = attachment.content_type?.toLowerCase() ?? "";
if (contentType.startsWith("image/")) {
return true;
}
return /\.(png|jpe?g|gif|webp)$/i.test(attachment.filename ?? attachment.url ?? "");
}
async function cacheDiscordImageAttachment(input: {
channelId: string;
messageId: string;
attachment: DiscordApiAttachment;
}): Promise<string | undefined> {
const sourceUrl = input.attachment.url ?? input.attachment.proxy_url;
if (!sourceUrl) {
return undefined;
}
const safeExtension = normalizedImageExtension(input.attachment);
const fileStem = [
input.messageId,
input.attachment.id ?? createHash("sha256").update(sourceUrl).digest("hex").slice(0, 12),
].join("-");
const directory = resolve(".voidbot", "media", "discord-images", input.channelId);
const localPath = resolve(directory, `${fileStem}${safeExtension}`);
try {
await stat(localPath);
return localPath;
} catch {
// Cache miss. Fall through to fetch.
}
try {
const response = await fetch(sourceUrl);
if (!response.ok) {
return undefined;
}
const arrayBuffer = await response.arrayBuffer();
await mkdir(directory, { recursive: true });
await writeFile(localPath, Buffer.from(arrayBuffer));
return localPath;
} catch {
return undefined;
}
}
function normalizedImageExtension(attachment: DiscordApiAttachment): string {
const fromName = extname(attachment.filename ?? "").toLowerCase();
if (/^\.(png|jpe?g|gif|webp)$/.test(fromName)) {
return fromName;
}
const contentType = attachment.content_type?.toLowerCase();
switch (contentType) {
case "image/jpeg":
return ".jpg";
case "image/gif":
return ".gif";
case "image/webp":
return ".webp";
case "image/png":
default:
return ".png";
}
}
async function fetchBifrostGovernanceDigest(input: {
bifrostRoot: string;
repoName: string;
@ -1020,33 +832,6 @@ async function fetchBifrostGovernanceDigest(input: {
}
}
interface DiscordApiMessage {
id: string;
content: string;
timestamp: string;
attachments?: DiscordApiAttachment[];
author: {
id: string;
username: string;
global_name?: string | null;
bot?: boolean;
};
member?: {
nick?: string | null;
};
}
interface DiscordApiAttachment {
id?: string;
filename?: string;
content_type?: string;
size?: number;
url?: string;
proxy_url?: string;
width?: number | null;
height?: number | null;
}
async function startVoidModerationTurn(input: {
queuedAt: string;
storageRoot: string;

View file

@ -33,6 +33,10 @@ import {
newestUnpromptedTurnQueuedAt,
readDiscordActivitySnapshot,
} from "../apps/persona-scheduler/dist/discord-activity-source.js";
import {
fetchChannelSnapshots,
fetchRecentDiscordMessages,
} from "../apps/persona-scheduler/dist/turn-context-source.js";
function participant(identityId: string, nextTurnAt: number): InitiativeParticipant {
return {
@ -233,6 +237,47 @@ assert.deepEqual(pressureState.participants[0].semanticInterruptReceipts, ["pres
assert.equal(pressureState.participants[1].responsePressure, 0, "missing projections explicitly cool stale pressure");
assert.equal(pressureState.history.at(-1)?.type, "semantic_response_interrupt", "the state owner records the semantic transition");
const contextMessages = await fetchRecentDiscordMessages({
botToken: "test-token",
channelId: "aquarium",
limit: 15,
ignoreBotMessages: true,
fetchImpl: async () => new Response(JSON.stringify([
{
id: "newer",
author: { id: "human-2", username: "Second" },
content: "second",
timestamp: "2026-07-15T21:00:00.000Z",
attachments: [{ id: "text", filename: "note.txt", url: "https://example.invalid/note.txt" }],
},
{
id: "ignored-bot",
author: { id: "bot", username: "Mirror", bot: true },
content: "mirror",
timestamp: "2026-07-15T20:59:00.000Z",
},
{
id: "older",
author: { id: "human-1", username: "First", global_name: "First Human" },
content: "first",
timestamp: "2026-07-15T20:58:00.000Z",
},
]), { status: 200, headers: { "content-type": "application/json" } }),
});
assert.deepEqual(contextMessages.map((message) => message.id), ["older", "newer"], "turn context is raw chronological Discord evidence with configured bot mirrors removed");
assert.equal(contextMessages[1].attachments?.[0].kind, "other", "non-image attachments remain inspectable without becoming media-cache writes");
const failedSnapshot = await fetchChannelSnapshots({
botToken: "test-token",
channelIds: ["primary", "nearby"],
primaryChannelId: "primary",
limit: 6,
now: new Date("2026-07-15T21:00:00.000Z"),
fetchImpl: async () => new Response("nope", { status: 503 }),
});
assert.equal(failedSnapshot[0].messages[0].id, "snapshot-error:nearby", "nearby-channel failure is returned as explicit evidence rather than aborting the turn");
assert.equal(failedSnapshot[0].messages[0].timestamp, "2026-07-15T21:00:00.000Z", "context-source failure witnesses use the caller's observation clock");
const stateDirectory = await mkdtemp(join(tmpdir(), "voidbot-persona-scheduler-"));
try {
const statePath = join(stateDirectory, "scheduler.json");