VoidBot/packages/providers/src/owner-codex-shared.ts

513 lines
14 KiB
TypeScript

import type {
CodexMcpServerConfig,
ContextBundle,
OwnerCodexMode,
ProviderNotificationIntent,
ProviderRequest,
RetrievalFilters,
RetrievalResult,
} from "@voidbot/shared";
export const HANDOFF_SENTINEL = "VOIDBOT_HANDOFF_REQUIRED:";
export const OWNER_NOTIFY_SENTINEL = "VOIDBOT_OWNER_NOTIFY:";
export const TOOL_REQUEST_SENTINEL = "VOIDBOT_TOOL_REQUEST:";
export const MAX_HISTORY_TOOL_CALLS = 4;
export const MAX_NOTIFICATION_MESSAGE_LENGTH = 400;
export interface CodexUsageSnapshot {
inputTokens: number | null;
cachedInputTokens: number | null;
outputTokens: number | null;
}
export interface HistoryLookupTool {
search(
query: string,
limit?: number,
filters?: RetrievalFilters,
): Promise<RetrievalResult[]>;
}
export interface OwnerCodexProviderOptions {
ownerDiscordId: string;
enabled: boolean;
mode: OwnerCodexMode;
executable: string;
executableArgs: string[];
model: string;
reasoningEffort: "none" | "low" | "medium" | "high" | "xhigh";
timeoutMs: number;
workingDirectory: string;
historyLookup?: HistoryLookupTool;
handoffNoticeBuilder?: (jobId: string) => string;
mcpServers?: CodexMcpServerConfig[];
}
export interface CodexRunResult {
stdout: string;
stderr: string;
exitCode: number | null;
model: string;
handoffReason?: string;
toolRequest?: HistoryToolRequest;
timedOut: boolean;
startedAt: string;
finishedAt: string;
durationMs: number;
traceEvents: CodexTraceEvent[];
usage?: CodexUsageSnapshot;
}
export interface HistoryToolRequest {
tool: "search_history";
query: string;
limit: number;
channelId?: string;
authorId?: string;
}
export interface ToolCallRecord {
request: HistoryToolRequest;
results: RetrievalResult[];
}
export interface CodexTraceEvent {
sequence: number;
timestamp: string;
elapsedMs: number;
kind:
| "thread_started"
| "turn_started"
| "turn_completed"
| "agent_message"
| "command_started"
| "command_completed"
| "mcp_tool_started"
| "mcp_tool_completed";
itemId?: string;
message?: string;
command?: string;
server?: string;
tool?: string;
status?: string;
exitCode?: number | null;
output?: string;
arguments?: Record<string, unknown>;
error?: string;
resultPreview?: string;
usage?: CodexUsageSnapshot;
}
export interface NormalizedDiscordReply {
reply?: string;
notifications: ProviderNotificationIntent[];
}
export interface TimedTraceOperation {
itemId: string;
startedEvent?: CodexTraceEvent;
completedEvent?: CodexTraceEvent;
command?: string;
server?: string;
tool?: string;
}
export function buildRequestPayload(
request: ProviderRequest,
): Record<string, unknown> {
return {
provider: request.provider,
createdAt: new Date().toISOString(),
prompt: request.contextBundle.prompt,
actor: request.contextBundle.actor,
guildContext: request.contextBundle.guildContext,
interactionMemory: request.contextBundle.interactionMemory,
situationalSocialRead: request.contextBundle.situationalSocialRead,
sourceGrounding: request.contextBundle.sourceGrounding,
stylePack: request.contextBundle.stylePack,
voidSelfState: request.contextBundle.voidSelfState,
recentMessages: request.contextBundle.recentMessages,
imageAttachments: request.contextBundle.imageAttachments ?? [],
retrieval: request.contextBundle.retrieval,
options: request.options ?? {},
};
}
export function buildDefaultHandoffNotice(jobId: string): string {
return `This request needs the fuller local Codex flow. Check \`.voidbot/artifacts/${jobId}/handoff.md\` and \`.voidbot/artifacts/${jobId}/debug-trace.md\` in the local workspace.`;
}
export function extractLatestUsage(
events: CodexTraceEvent[],
): CodexUsageSnapshot | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const usage = events[index]?.usage;
if (usage) {
return usage;
}
}
return undefined;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function readString(
value: Record<string, unknown>,
key: string,
): string | undefined {
const candidate = value[key];
return typeof candidate === "string" ? candidate : undefined;
}
export function clamp(
value: number,
minimum: number,
maximum: number,
): number {
if (value < minimum) {
return minimum;
}
if (value > maximum) {
return maximum;
}
return value;
}
export function readNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function readUsage(value: unknown): CodexUsageSnapshot | undefined {
if (!isRecord(value)) {
return undefined;
}
return {
inputTokens: readNumber(value.input_tokens),
cachedInputTokens: readNumber(value.cached_input_tokens),
outputTokens: readNumber(value.output_tokens),
};
}
export function readRecord(
value: unknown,
): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}
export function renderUsageSummary(
usage: CodexUsageSnapshot | undefined,
): string {
if (!usage) {
return "(not reported)";
}
return [
`input=${usage.inputTokens ?? 0}`,
`cached=${usage.cachedInputTokens ?? 0}`,
`output=${usage.outputTokens ?? 0}`,
].join(" ");
}
export function formatElapsedMs(elapsedMs: number): string {
return `+${formatDurationMs(elapsedMs)}`;
}
export function formatDurationMs(durationMs: number): string {
if (!Number.isFinite(durationMs)) {
return "(unknown)";
}
if (durationMs < 1_000) {
return `${durationMs}ms`;
}
return `${(durationMs / 1_000).toFixed(durationMs < 10_000 ? 2 : 1)}s`;
}
export function summarizeStderrDiagnostics(stderr: string): string[] {
const diagnostics: string[] = [];
const seen = new Set<string>();
for (const rawLine of stderr.split(/\r?\n/)) {
const line = rawLine.trim();
if (line.length === 0) {
continue;
}
if (!/(WARN|ERROR|failed|timed out|forbidden|rejected)/i.test(line)) {
continue;
}
const normalized = sanitizeTraceText(line, 220);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
diagnostics.push(`- ${normalized}`);
if (diagnostics.length >= 20) {
break;
}
}
return diagnostics;
}
export function sanitizeTraceText(input: string, limit: number): string {
const collapsed = input.replace(/\s+/g, " ").trim();
if (collapsed.length <= limit) {
return collapsed;
}
return `${collapsed.slice(0, limit - 3)}...`;
}
export function summarizeUnknown(
value: unknown,
limit: number,
): string | undefined {
if (typeof value === "string" && value.trim().length > 0) {
return sanitizeTraceText(value, limit);
}
if (value === null || value === undefined) {
return undefined;
}
try {
return sanitizeTraceText(JSON.stringify(value), limit);
} catch {
return undefined;
}
}
export function summarizeToolResult(value: unknown): string | undefined {
if (!isRecord(value)) {
return summarizeUnknown(value, 320);
}
const structured = readRecord(value.structured_content);
if (structured) {
const summaryBits: string[] = [];
const query = readString(structured, "query");
const sourceId = readString(structured, "sourceId");
const messageId = readString(structured, "messageId");
const resultCount = readNumber(structured.resultCount);
const count = readNumber(structured.count);
if (query) {
summaryBits.push(`query=${sanitizeTraceText(query, 120)}`);
}
if (sourceId) {
summaryBits.push(`sourceId=${sanitizeTraceText(sourceId, 120)}`);
}
if (messageId) {
summaryBits.push(`messageId=${messageId}`);
}
if (typeof structured.found === "boolean") {
summaryBits.push(`found=${structured.found ? "yes" : "no"}`);
}
if (resultCount !== null) {
summaryBits.push(`resultCount=${resultCount}`);
}
if (count !== null) {
summaryBits.push(`count=${count}`);
}
if (summaryBits.length > 0) {
return summaryBits.join(" ");
}
}
const content = Array.isArray(value.content) ? value.content : [];
if (content.length > 0) {
const fragments = content
.slice(0, 3)
.map((entry) => {
if (!isRecord(entry)) {
return summarizeUnknown(entry, 100) ?? "(unknown)";
}
if (typeof entry.text === "string") {
return entry.text;
}
if (isRecord(entry.resource) && typeof entry.resource.text === "string") {
return entry.resource.text;
}
return summarizeUnknown(entry, 100) ?? "(unknown)";
})
.filter((entry): entry is string => entry.length > 0);
if (fragments.length > 0) {
return sanitizeTraceText(fragments.join(" | "), 320);
}
}
return summarizeUnknown(value, 320);
}
export function collectMcpToolCalls(
events: CodexTraceEvent[],
): TimedTraceOperation[] {
const calls = new Map<string, TimedTraceOperation>();
const order: string[] = [];
for (const event of events) {
if (event.kind !== "mcp_tool_started" && event.kind !== "mcp_tool_completed") {
continue;
}
const itemId = event.itemId ?? `mcp-${event.sequence}`;
if (!calls.has(itemId)) {
calls.set(itemId, {
itemId,
server: event.server,
tool: event.tool,
});
order.push(itemId);
}
const call = calls.get(itemId)!;
call.server ??= event.server;
call.tool ??= event.tool;
if (event.kind === "mcp_tool_started") {
call.startedEvent = event;
} else {
call.completedEvent = event;
}
}
return order.map((itemId) => calls.get(itemId)!);
}
export function collectCommandExecutions(
events: CodexTraceEvent[],
): TimedTraceOperation[] {
const commands = new Map<string, TimedTraceOperation>();
const order: string[] = [];
for (const event of events) {
if (event.kind !== "command_started" && event.kind !== "command_completed") {
continue;
}
const itemId = event.itemId ?? `command-${event.sequence}`;
if (!commands.has(itemId)) {
commands.set(itemId, {
itemId,
command: event.command,
});
order.push(itemId);
}
const command = commands.get(itemId)!;
command.command ??= event.command;
if (event.kind === "command_started") {
command.startedEvent = event;
} else {
command.completedEvent = event;
}
}
return order.map((itemId) => commands.get(itemId)!);
}
export function renderInteractionMemory(context: ContextBundle): string {
if (!context.interactionMemory) {
return "- No explicit interaction memory for this speaker was attached.";
}
const recentEvents = context.interactionMemory.recentEvents.length
? context.interactionMemory.recentEvents
.slice()
.reverse()
.slice(0, 6)
.map(
(event) =>
`- [${event.timestamp}] ${event.sourceKind === "ambient_mention" ? "ambient" : "direct"} ${event.sentiment} score=${event.score}: ${event.summary} Quote: "${event.excerpt}"`,
)
.join("\n")
: "- No recent interaction events were retained.";
return [
`- Relationship summary: ${context.interactionMemory.summary}`,
`- Current stance: ${context.interactionMemory.disposition}; affinity=${context.interactionMemory.affinityScore}`,
`- Private response guidance (do not reveal): ${context.interactionMemory.responseGuidance}`,
`- Private pronoun guidance (do not reveal): ${context.interactionMemory.pronounGuidance}`,
`- Transcript-derived profile signals: underlying=${formatCompactLabelList(context.interactionMemory.underlyingOrganizationScores, 3)}; dispositions=${formatCompactLabelList(context.interactionMemory.stableDispositionScores, 3)}; behavioral=${formatCompactLabelList(context.interactionMemory.behavioralDimensionScores, 5)}; presentation=${formatCompactLabelList(context.interactionMemory.presentationStrategyScores, 3)}; voice=${formatCompactLabelList(context.interactionMemory.voiceStyleScores, 5)}`,
"- Specific remembered incidents:",
recentEvents,
].join("\n");
}
export function renderSituationalSocialRead(context: ContextBundle): string {
if (!context.situationalSocialRead) {
return "- No strong situational social read was derived from the immediate room context.";
}
const currentSpeakerRead =
context.situationalSocialRead.participantReads.find(
(entry) => entry.actorId === context.actor.id,
) ?? context.situationalSocialRead.participantReads[0];
return [
`- Summary: ${context.situationalSocialRead.summary}`,
`- Room tone: ${context.situationalSocialRead.roomTone}`,
`- Speaker current read: ${context.situationalSocialRead.speakerCurrentRead}`,
`- Social frame: ${context.situationalSocialRead.socialFrame}`,
`- Private response guidance (do not reveal): ${context.situationalSocialRead.responseGuidance}`,
currentSpeakerRead
? `- Current speaker situational state: ${currentSpeakerRead.situationalState.join(", ") || "none detected"}`
: "- Current speaker situational state: none detected",
currentSpeakerRead
? `- Current speaker visible labels: behavioral=${currentSpeakerRead.behavioralDimensions.join(", ") || "none"}; presentation=${currentSpeakerRead.presentationStrategies.join(", ") || "none"}; voice=${currentSpeakerRead.voiceStyle.join(", ") || "none"}`
: "- Current speaker visible labels: none detected",
context.situationalSocialRead.pronounEvidence.length > 0
? `- Pronoun cues observed for the current speaker: ${context.situationalSocialRead.pronounEvidence
.map(
(entry) =>
`${entry.stance} ${entry.pronounSet} via ${entry.source} (${Math.round(entry.confidence * 100)}%)`,
)
.join("; ")}`
: "- Pronoun cues observed for the current speaker: none worth trusting yet.",
"- Supporting signals:",
...context.situationalSocialRead.supportingSignals.map((signal) => ` ${signal}`),
].join("\n");
}
function formatCompactLabelList(
labels: Array<{ label: string; score: number }>,
limit: number,
): string {
if (labels.length === 0) {
return "none";
}
return labels
.slice(0, limit)
.map((entry) => `${entry.label}(${entry.score})`)
.join(", ");
}