Extract Persona turn context source
This commit is contained in:
parent
00213cc6b2
commit
0ec63bf035
10 changed files with 258 additions and 224 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue