VoidBot/scripts/run-repo-face-heartbeats.ts
2026-05-22 16:59:19 +01:00

3408 lines
117 KiB
TypeScript

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 { dirname, resolve } from "node:path";
import { loadConfig } from "@voidbot/config";
import {
buildEpiphanyIdentityRegistry,
ContextBuilder,
createStateStorage,
ensureRepoFaceInitialized,
getRepoDiscordIdentityAllowedChannelIds,
faceRegistryAsRepoDiscordRegistry,
projectRepoFaceSleepCycleForNow,
applyVoidSelfStateOperation,
loadFaceIdentityRegistry,
loadVoidSelfStateTypedDocuments,
REPO_FACE_HEARTBEAT_SCHEMA_VERSION,
type RepoFaceRestSnapshot,
resolveRepoFaceStatePath,
type RepoFacePendingMention,
type RepoDiscordIdentity,
type VoidSelfStateTypedProjection,
} from "@voidbot/core";
import { loadPromptTemplate, type InteractionMemoryProfile, type SourceMessage } from "@voidbot/shared";
const HEARTBEAT_SCHEMA_VERSION = REPO_FACE_HEARTBEAT_SCHEMA_VERSION;
const HEARTBEAT_COMMAND = "repo-face-rumination";
const MIN_STALE_ACTIVE_JOB_MS = 45 * 60_000;
interface FaceHeartbeatParticipant {
identityId: string;
participantKind: "repo_face" | "system_agent";
turnKind: "repo_face_rumination" | "void_moderation";
repoName: string;
displayName: string;
initiativeSpeed: number;
reactionBias: number;
interruptThreshold: number;
currentLoad: number;
status: "active" | "blocked" | "withdrawn" | "offscreen";
groups: string[];
heat: number;
effectiveSpeed: number;
baseRecoveryMinutes: number;
nextTurnAt: number;
lastTurnAt?: number;
activeTurnStartedAt?: number;
activeJobId?: string;
lastQueuedAt?: string;
queuedCount: number;
constraints: string[];
}
interface FaceHeartbeatState {
schemaVersion: typeof HEARTBEAT_SCHEMA_VERSION;
initiativeClock: number;
baseRecoveryMinutes: number;
globalHeat: number;
lastTickAt?: string;
participants: FaceHeartbeatParticipant[];
history: Array<Record<string, unknown>>;
pendingMentions: RepoFacePendingMention[];
}
interface ActiveTurnScan {
active: Map<string, string>;
staleRecovered: StaleActiveTurn[];
}
interface StaleActiveTurn {
identityId: string;
jobId: string;
requestMessageId?: string;
state: string;
updatedAt?: string;
ageMinutes: number;
}
interface RepoFaceChannelPlan {
primaryChannelId?: string;
snapshotChannelIds: string[];
options: RepoFaceChannelOption[];
lowThresholdTopics: string[];
}
interface RepoFaceChannelOption {
channelId: string;
label: string;
topic: string;
speechThreshold: "very_low" | "low" | "medium" | "high";
speedMultiplier: number;
posture?: string;
}
interface BifrostGovernanceDigest {
generatedAt: string;
topics: BifrostGovernanceTopic[];
}
interface BifrostGovernanceTopic {
id: string;
title: string;
jurisdictionRepoName: string;
jurisdictionAgentIdentity?: string;
status: string;
summaryMarkdown: string;
priority: number;
updatedAt: string;
approvedByAgent?: string;
dispatchRequestId?: string;
comments?: BifrostGovernanceComment[];
}
interface BifrostGovernanceComment {
id: string;
authorKind: string;
authorId: string;
stance: string;
bodyMarkdown: string;
createdAt: string;
}
interface ChannelSnapshot {
channelId: string;
messages: SourceMessage[];
}
async function main(): Promise<void> {
const config = loadConfig();
const dryRun = process.argv.includes("--dry-run");
const assemblePromptIdentity = readArgValue("--assemble-prompt");
if (assemblePromptIdentity) {
const result = await assembleRepoFaceTurnPrompt({
config,
identityId: assemblePromptIdentity,
outPath: readArgValue("--out"),
memorySurfacePath: readArgValue("--memory-surface"),
conversationSurfacePath: readArgValue("--conversation-surface"),
});
process.stdout.write(`${JSON.stringify(result)}\n`);
return;
}
const pause = await readAgentSwarmPause();
if (pause.paused) {
const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath);
state.lastTickAt = new Date().toISOString();
state.history.push({
type: "skipped",
reason: "agent_swarm_paused",
skippedAt: state.lastTickAt,
pausePath: pause.path,
pauseReason: pause.reason,
});
state.history = state.history.slice(-80);
if (!dryRun) {
await writeHeartbeatState(config.repoFaceHeartbeats.statePath, state);
}
process.stdout.write(
`${JSON.stringify({
ok: true,
queuedCount: 0,
dryRun,
skipped: true,
reason: "agent_swarm_paused",
pausePath: pause.path,
statePath: config.repoFaceHeartbeats.statePath,
})}\n`,
);
return;
}
if (!config.repoFaceHeartbeats.enabled && !process.argv.includes("--force")) {
const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath);
state.lastTickAt = new Date().toISOString();
state.history.push({
type: "skipped",
reason: "repo_face_heartbeats_disabled",
skippedAt: state.lastTickAt,
});
state.history = state.history.slice(-80);
await writeHeartbeatState(config.repoFaceHeartbeats.statePath, state);
return;
}
const faceRegistry = await loadFaceIdentityRegistry(config.repoDiscordIdentitiesPath);
const registry = faceRegistryAsRepoDiscordRegistry(faceRegistry);
const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath);
const restStates = await loadRepoFaceRestStates(registry.identities, config.storageRoot, state, { dryRun });
const now = new Date();
advanceInitiativeClockFromWallClock(state, now);
const activeTurnScan = dryRun
? { active: new Map<string, string>(), staleRecovered: [] }
: await listExistingActiveTurns(config.databaseDsn, config.stateStorageBackend, config);
for (const stale of activeTurnScan.staleRecovered) {
state.history.push({
type: "stale_active_turn_recovered",
identityId: stale.identityId,
activeJobId: stale.jobId,
requestMessageId: stale.requestMessageId,
jobState: stale.state,
jobUpdatedAt: stale.updatedAt,
ageMinutes: stale.ageMinutes,
recoveredAt: now.toISOString(),
});
}
state.baseRecoveryMinutes = config.repoFaceHeartbeats.baseRecoveryMinutes;
state.globalHeat = config.repoFaceHeartbeats.globalHeat;
const completedThisTick = new Set<string>();
state.participants = reconcileParticipants(
state.participants,
buildParticipantSpecs(registry.identities),
config.repoFaceHeartbeats.defaultChannelId,
config.repoFaceHeartbeats.speedOverrides,
config.repoFaceHeartbeats.heatOverrides,
state.initiativeClock,
config.repoFaceHeartbeats.baseRecoveryMinutes,
config.repoFaceHeartbeats.globalHeat,
).map((participant) =>
applyActiveTurnFreeze(
participant,
activeTurnScan.active.get(participant.identityId),
state,
completedThisTick,
),
);
rescheduleStaleOverdueParticipants(state);
applyPendingMentionPriority(state);
const selected = selectReadyParticipants(
state,
config.repoFaceHeartbeats.maxJobsPerTick,
completedThisTick,
restStates,
);
const queuedIdentityIds: string[] = [];
if (selected.length > 0 && dryRun) {
const queuedAt = new Date().toISOString();
for (const participant of selected) {
const pendingMentions = pendingMentionsForParticipant(state, participant.identityId);
const recoveryMinutes = recoveryFor(participant);
participant.lastQueuedAt = queuedAt;
participant.lastTurnAt = state.initiativeClock;
participant.queuedCount += 1;
participant.nextTurnAt = Math.max(state.initiativeClock, participant.nextTurnAt) + recoveryMinutes;
queuedIdentityIds.push(participant.identityId);
state.history.push({
type: "dry_run_selected",
identityId: participant.identityId,
queuedAt,
initiativeClock: state.initiativeClock,
nextTurnAt: participant.nextTurnAt,
recoveryMinutes,
heat: participant.heat,
effectiveSpeed: participant.effectiveSpeed,
pendingMentionCount: pendingMentions.length,
});
}
} else if (selected.length > 0) {
const storage = await createStateStorage({
backend: config.stateStorageBackend,
databaseDsn: config.databaseDsn,
jobsFile: config.jobsFile,
auditLogFile: config.auditLogFile,
interactionMemoryFile: config.interactionMemoryFile,
rateLimitStateFile: config.rateLimitStateFile,
});
try {
for (const participant of selected) {
const queuedAt = new Date().toISOString();
const pendingMentions = pendingMentionsForParticipant(state, participant.identityId);
const turn = await queueParticipantTurn({
participant,
pendingMentions,
registryIdentities: registry.identities,
config,
storage,
queuedAt,
});
if (turn.created) {
queuedIdentityIds.push(participant.identityId);
participant.lastQueuedAt = queuedAt;
participant.activeTurnStartedAt = state.initiativeClock;
participant.activeJobId = turn.activeJobId;
participant.lastTurnAt = state.initiativeClock;
participant.queuedCount += 1;
participant.currentLoad = 1;
if (pendingMentions.length > 0) {
const consumedIds = new Set(pendingMentions.map((entry) => entry.id));
state.pendingMentions = state.pendingMentions.filter((entry) => !consumedIds.has(entry.id));
}
state.history.push({
type: "queued",
identityId: participant.identityId,
participantKind: participant.participantKind,
turnKind: participant.turnKind,
activeJobId: turn.activeJobId,
requestMessageId: turn.requestMessageId,
queuedAt,
initiativeClock: state.initiativeClock,
frozen: true,
heat: participant.heat,
effectiveSpeed: participant.effectiveSpeed,
pendingMentionCount: pendingMentions.length,
});
} else if (turn.failureReason) {
participant.currentLoad = 0;
state.history.push({
type: "turn_failed_to_start",
identityId: participant.identityId,
participantKind: participant.participantKind,
turnKind: participant.turnKind,
activeJobId: turn.activeJobId,
requestMessageId: turn.requestMessageId,
queuedAt,
initiativeClock: state.initiativeClock,
reason: turn.failureReason,
heat: participant.heat,
effectiveSpeed: participant.effectiveSpeed,
});
}
}
} finally {
await storage.close();
}
}
state.history = state.history.slice(-80);
state.lastTickAt = now.toISOString();
if (!dryRun) {
await writeHeartbeatState(config.repoFaceHeartbeats.statePath, state);
}
process.stdout.write(
`${JSON.stringify({
ok: true,
participantCount: state.participants.length,
initiativeClock: state.initiativeClock,
queuedCount: queuedIdentityIds.length,
dryRun,
selected: selected.map((entry) => entry.identityId),
queued: queuedIdentityIds,
statePath: config.repoFaceHeartbeats.statePath,
})}\n`,
);
}
async function readAgentSwarmPause(): Promise<{ paused: boolean; path: string; reason?: string }> {
const path = resolve(process.cwd(), "state", "agent-swarm-paused.json");
try {
const parsed = JSON.parse(stripLeadingBom(await readFile(path, "utf8"))) as Record<string, unknown>;
return {
paused: parsed.paused !== false,
path,
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
};
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return { paused: false, path };
}
return {
paused: true,
path,
reason: "Pause file exists but could not be parsed; failing closed.",
};
}
}
interface ParticipantSpec {
id: string;
participantKind: FaceHeartbeatParticipant["participantKind"];
turnKind: FaceHeartbeatParticipant["turnKind"];
repoName: string;
displayName: string;
allowedChannelIds: string[];
channelSpeedMultiplier: number;
identity?: RepoDiscordIdentity;
}
function buildParticipantSpecs(identities: RepoDiscordIdentity[]): ParticipantSpec[] {
return [
{
id: "void",
participantKind: "system_agent",
turnKind: "void_moderation",
repoName: "VoidBot",
displayName: "Void",
allowedChannelIds: [],
channelSpeedMultiplier: 1,
},
...identities.map((identity) => ({
id: identity.id,
participantKind: "repo_face" as const,
turnKind: "repo_face_rumination" as const,
repoName: identity.repoName,
displayName: identity.displayName,
allowedChannelIds: getRepoDiscordIdentityAllowedChannelIds(identity),
channelSpeedMultiplier: channelSpeedMultiplierFor(identity),
identity,
})),
];
}
async function queueParticipantTurn(input: {
participant: FaceHeartbeatParticipant;
pendingMentions: RepoFacePendingMention[];
registryIdentities: RepoDiscordIdentity[];
config: ReturnType<typeof loadConfig>;
storage: Awaited<ReturnType<typeof createStateStorage>>;
queuedAt: string;
}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string; failureReason?: string }> {
switch (input.participant.turnKind) {
case "repo_face_rumination":
return queueRepoFaceTurn(input);
case "void_moderation":
return startVoidModerationTurn({
queuedAt: input.queuedAt,
storageRoot: input.config.storageRoot,
pendingMentions: input.pendingMentions,
});
}
}
async function queueRepoFaceTurn(input: {
participant: FaceHeartbeatParticipant;
pendingMentions: RepoFacePendingMention[];
registryIdentities: RepoDiscordIdentity[];
config: ReturnType<typeof loadConfig>;
storage: Awaited<ReturnType<typeof createStateStorage>>;
queuedAt: string;
}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string; failureReason?: string }> {
const identity = input.registryIdentities.find((entry) => entry.id === input.participant.identityId);
if (!identity) {
return { created: false };
}
const channelPlan = buildChannelPlan(identity, input.config.repoFaceHeartbeats.defaultChannelId);
const channelId = channelPlan.primaryChannelId;
if (!channelId) {
input.participant.status = "blocked";
input.participant.constraints = mergeStrings(
input.participant.constraints,
"No CTB turn channel is configured for this Face.",
);
return { created: false };
}
const contextBuilder = new ContextBuilder();
await ensureRepoFaceInitialized({
identity,
storageRoot: input.config.storageRoot,
sourceRepoRoot: input.config.sourceRepoRoot,
epiphanyAgentRoot: input.config.epiphanyAgentRoot,
workspaceRoot: process.cwd(),
birthMode: input.config.repoFaceBirthMode,
birthExecutor: input.config.repoFaceBirthExecutor,
});
const recentMessages = await fetchRecentDiscordMessages({
botToken: input.config.botToken,
channelId,
limit: 15,
ignoreBotMessages: channelId === input.config.bifrostDiscordChannelId,
});
const channelSnapshots = await fetchChannelSnapshots({
botToken: input.config.botToken,
channelIds: channelPlan.snapshotChannelIds,
primaryChannelId: channelId,
limit: 6,
bifrostDiscordChannelId: input.config.bifrostDiscordChannelId,
});
const bifrostDigest = input.config.repoFaceBifrostEnabled
? await fetchBifrostGovernanceDigest({
bifrostRoot: input.config.bifrostRoot,
repoName: identity.repoName,
agentIdentity: identity.id,
})
: undefined;
const memorySurface = await renderRepoFaceMemorySurfaceForTurn(
identity,
input.config,
input.registryIdentities,
{
recentMessages,
channelSnapshots,
},
);
const repoActivitySurface = renderRepoFaceRepoActivitySurface(identity, input.config);
const socialOpportunitySurface = await renderRepoFaceSocialOpportunitySurface({
identity,
registryIdentities: input.registryIdentities,
config: input.config,
recentMessages,
channelSnapshots,
channelPlan,
});
const conversationMemorySurface = renderRepoFaceConversationTranscript({
identity,
recentMessages,
channelSnapshots,
pendingMentions: input.pendingMentions,
channelPlan,
});
const prompt = buildHeartbeatPrompt({
identity,
channelId,
channelPlan,
channelSnapshots,
recentMessages,
memorySurface,
repoActivitySurface,
socialOpportunitySurface,
conversationMemorySurface,
bifrostDigest,
participant: input.participant,
pendingMentions: input.pendingMentions,
jurisdictionDive: buildJurisdictionDiveDirective(identity, input.participant),
githubActionsEnabled: input.config.repoFaceGithubActionsEnabled,
});
const contextBundle = contextBuilder.build({
prompt,
actor: {
id: "voidbot-agent-turn",
displayName: "VoidBot Agent Turn",
isAdmin: true,
isBot: true,
},
guildContext: {
channelId,
},
recentMessages,
retrieval: [],
voidSelfState: undefined,
});
const requestMessageId = `agent-turn:${identity.id}:${input.queuedAt}`;
const result = await input.storage.jobQueue.createJob({
command: HEARTBEAT_COMMAND,
provider: "owner_codex",
runApprovalRequired: false,
postApprovalRequired: false,
requester: contextBundle.actor,
guildContext: contextBundle.guildContext,
prompt,
contextBundle,
outputChannelId: channelId,
requestMessageId,
initialState: "approved",
});
return {
created: result.created,
activeJobId: result.job.id,
requestMessageId,
};
}
async function loadRepoFaceRestStates(
identities: RepoDiscordIdentity[],
storageRoot: string,
heartbeatState: FaceHeartbeatState,
options: { dryRun?: boolean } = {},
): Promise<Map<string, RepoFaceRestSnapshot>> {
const restStates = new Map<string, RepoFaceRestSnapshot>();
const now = new Date();
for (const identity of identities) {
try {
const statePath = resolveRepoFaceStatePath(identity, storageRoot);
const typedState = await loadVoidSelfStateTypedDocuments({
canonicalPath: statePath,
identity: {
agentId: identity.id,
publicName: identity.displayName,
publicDescription: identity.description,
},
});
const projected = projectRepoFaceSleepCycleForNow(
typedState.scheduledRuntime.sleepCycle,
identity.id,
now,
);
if (!options.dryRun && !sleepCyclesEqual(typedState.scheduledRuntime.sleepCycle, projected.sleepCycle)) {
await applyVoidSelfStateOperation(
{
canonicalPath: statePath,
identity: {
agentId: identity.id,
publicName: identity.displayName,
publicDescription: identity.description,
},
},
{
operation: "update_sleep_cycle",
sleepCycle: projected.sleepCycle,
},
);
}
const speakingPressure = buildRepoFaceSpeakingPressure(
typedState,
projected.sleepCycle,
now,
);
if (!options.dryRun && !speakingPressuresEqual(typedState.scheduledRuntime.speakingPressure, speakingPressure)) {
await applyVoidSelfStateOperation(
{
canonicalPath: statePath,
identity: {
agentId: identity.id,
publicName: identity.displayName,
publicDescription: identity.description,
},
},
{
operation: "update_speaking_pressure",
speakingPressure,
},
);
}
const maintenance = options.dryRun
? { started: false, reason: "dry_run", statusPath: undefined }
: await maybeStartRepoFaceMemoryMaintenance({
identity,
statePath,
typedState,
projectedRest: projected,
});
if (maintenance.started || maintenance.failureReason) {
heartbeatState.history.push({
type: maintenance.started ? "repo_face_memory_maintenance_started" : "repo_face_memory_maintenance_failed_to_start",
identityId: identity.id,
observedAt: now.toISOString(),
reason: maintenance.reason,
statusPath: maintenance.statusPath,
pid: maintenance.pid,
failureReason: maintenance.failureReason,
});
}
restStates.set(identity.id, {
isNapping: projected.isNapping,
napEndsAt: projected.napEndsAt,
nextNapStartsAt: projected.nextNapStartsAt,
});
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
throw error;
}
}
}
return restStates;
}
function buildRepoFaceSpeakingPressure(
typedState: Awaited<ReturnType<typeof loadVoidSelfStateTypedDocuments>>,
sleepCycle: Awaited<ReturnType<typeof projectRepoFaceSleepCycleForNow>>["sleepCycle"],
now: Date,
): Awaited<ReturnType<typeof loadVoidSelfStateTypedDocuments>>["scheduledRuntime"]["speakingPressure"] {
const previous = typedState.scheduledRuntime.speakingPressure;
const lastReceipt = typedState.speechReceipts.recentReceipts
.slice()
.sort((left, right) => Date.parse(right.sentAt) - Date.parse(left.sentAt))
[0];
const lastSpokeAt = lastReceipt?.sentAt ?? previous.lastSpokeAt;
const hoursSinceSpeech = lastSpokeAt ? Math.max(0, (now.getTime() - Date.parse(lastSpokeAt)) / 3_600_000) : 24;
const recentSpeechDamping = clamp(Math.exp(-hoursSinceSpeech / 3.5), 0, 1);
const affectNeedPressure = (typedState.faceAffect.needs ?? [])
.filter((entry) => entry.status === "active" || entry.status === "neglected")
.reduce((sum, entry) => {
const neglectedWeight = entry.status === "neglected" ? 1.25 : 1;
const substrateWeight = ["substrate", "agency", "status", "territory", "recognition"].includes(entry.kind) ? 0.18 : 0.11;
return sum + entry.intensity * substrateWeight * neglectedWeight;
}, 0);
const statusReadPressure = (typedState.faceAffect.statusReads ?? [])
.filter((entry) => !entry.retiredAt)
.reduce((sum, entry) => {
const sharpWeight = ["neglected", "bypassed", "blocked", "ignored", "threatened", "challenged"].includes(entry.status) ? 0.14 : 0.08;
return sum + entry.intensity * sharpWeight;
}, 0);
const socialBondPressure = (typedState.faceAffect.socialBonds ?? [])
.filter((entry) => entry.status === "active")
.reduce((sum, entry) => sum + entry.intensity * 0.07, 0);
const moodPressure = (typedState.faceAffect.moodDimensions ?? [])
.reduce((sum, entry) => {
const expressiveWeight = ["anger", "annoyance", "irritation", "envy", "pride", "smugness", "playfulness", "anxiety", "commandForce", "restlessness"].includes(entry.name)
? 0.06
: 0.025;
return sum + entry.value * expressiveWeight;
}, 0);
const agencyPressure = typedState.agencyPressure.pressures
.filter((entry) => entry.status === "active" || entry.status === "ready_to_act")
.reduce((sum, entry) => sum + entry.intensity * (entry.kind.includes("advocacy") ? 0.22 : 0.11), 0);
const candidatePressure = typedState.candidateInterventions.interventions
.filter((entry) => entry.status === "queued" || entry.status === "deferred")
.reduce((sum, entry) => sum + entry.priority * (entry.mustEventuallyShare ? 0.22 : 0.12), 0);
const silencePressure = clamp(hoursSinceSpeech / 8, 0, 1);
const sleepiness = sleepCycle.isNapping ? 0.22 : 0;
const targetNeed = clamp(
0.12 + silencePressure * 0.18 + affectNeedPressure + statusReadPressure + socialBondPressure + moodPressure + agencyPressure + candidatePressure - recentSpeechDamping * 0.24 - sleepiness,
0,
1,
);
const needToSpeak = round3(clamp((previous.needToSpeak ?? 0.25) * 0.58 + targetNeed * 0.42, 0, 1));
return {
needToSpeak,
confessionPressure: round3(clamp((previous.confessionPressure ?? 0.2) * 0.7 + moodPressure * 0.42 + socialBondPressure * 0.18, 0, 1)),
noveltyPressure: round3(clamp((previous.noveltyPressure ?? 0.25) * 0.62 + affectNeedPressure * 0.35 + statusReadPressure * 0.24 + candidatePressure * 0.28, 0, 1)),
recentSpeechDamping: round3(recentSpeechDamping),
lastSpokeAt,
};
}
function speakingPressuresEqual(
left: Awaited<ReturnType<typeof loadVoidSelfStateTypedDocuments>>["scheduledRuntime"]["speakingPressure"],
right: Awaited<ReturnType<typeof loadVoidSelfStateTypedDocuments>>["scheduledRuntime"]["speakingPressure"],
): boolean {
return left.needToSpeak === right.needToSpeak
&& left.confessionPressure === right.confessionPressure
&& left.noveltyPressure === right.noveltyPressure
&& left.recentSpeechDamping === right.recentSpeechDamping
&& left.lastSpokeAt === right.lastSpokeAt;
}
async function maybeStartRepoFaceMemoryMaintenance(input: {
identity: RepoDiscordIdentity;
statePath: string;
typedState: Awaited<ReturnType<typeof loadVoidSelfStateTypedDocuments>>;
projectedRest: RepoFaceRestSnapshot;
}): Promise<{
started: boolean;
reason?: string;
statusPath?: string;
pid?: number;
failureReason?: string;
}> {
const shortTerm = input.typedState.thoughtMemory.shortTerm ?? [];
if (shortTerm.length === 0) {
return { started: false, reason: "no_short_term_memory" };
}
const oldestShortTermMs = Math.min(
...shortTerm.map((memory) => Date.parse(memory.updatedAt)).filter(Number.isFinite),
);
const hasStaleShortTerm =
Number.isFinite(oldestShortTermMs) &&
Date.now() - oldestShortTermMs >= 6 * 60 * 60 * 1000;
if (input.projectedRest.isNapping !== true && !hasStaleShortTerm) {
return { started: false, reason: "not_napping_or_stale" };
}
const paths = repoFaceMemoryMaintenancePaths(input.statePath);
const currentStatus = await readJsonFile(paths.statusPath);
if (isRecentRunningStatus(currentStatus, 25)) {
return { started: false, reason: "maintenance_already_running", statusPath: paths.statusPath };
}
if (isStatusCompletedAfter(currentStatus, newestTimestampMs(shortTerm))) {
return { started: false, reason: "maintenance_already_completed_for_sources", statusPath: paths.statusPath };
}
try {
await mkdir(paths.statusDir, { recursive: true });
await mkdir(paths.logDir, { recursive: true });
const child = spawn(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
resolve(process.cwd(), "scripts", "run-void-memory-maintenance.ps1"),
"-StateFilePath",
input.statePath,
"-ForceDistillation",
],
{
cwd: process.cwd(),
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
VOID_MEMORY_MAINTENANCE_STATUS_DIR: paths.statusDir,
VOID_MEMORY_MAINTENANCE_LOG_DIR: paths.logDir,
},
},
);
child.unref();
return {
started: true,
reason: input.projectedRest.isNapping ? "repo_face_napping_with_short_term_memory" : "repo_face_stale_short_term_memory",
statusPath: paths.statusPath,
pid: child.pid,
};
} catch (error) {
return {
started: false,
reason: "spawn_failed",
statusPath: paths.statusPath,
failureReason: error instanceof Error ? error.message : String(error),
};
}
}
function repoFaceMemoryMaintenancePaths(statePath: string): {
statusDir: string;
logDir: string;
statusPath: string;
} {
const stateDir = dirname(statePath);
const voidbotRoot = dirname(stateDir);
const statusDir = resolve(voidbotRoot, "status");
const logDir = resolve(voidbotRoot, "logs");
return {
statusDir,
logDir,
statusPath: resolve(statusDir, "void-memory-maintenance.json"),
};
}
async function readJsonFile(path: string): Promise<Record<string, unknown> | undefined> {
try {
return JSON.parse(stripLeadingBom(await readFile(path, "utf8"))) as Record<string, unknown>;
} catch {
return undefined;
}
}
function isRecentRunningStatus(status: Record<string, unknown> | undefined, maxAgeMinutes: number): boolean {
if (status?.status !== "running" && status?.status !== "running_skip_model" && status?.status !== "starting") {
return false;
}
const startedAtMs = typeof status.startedAt === "string" ? Date.parse(status.startedAt) : NaN;
return Number.isFinite(startedAtMs) && Date.now() - startedAtMs < maxAgeMinutes * 60_000;
}
function isStatusCompletedAfter(status: Record<string, unknown> | undefined, sourceTimestampMs: number): boolean {
if (status?.status !== "ok" || !Number.isFinite(sourceTimestampMs)) {
return false;
}
const finishedAtMs = typeof status.finishedAt === "string" ? Date.parse(status.finishedAt) : NaN;
return Number.isFinite(finishedAtMs) && finishedAtMs >= sourceTimestampMs;
}
function newestTimestampMs(entries: Array<{ updatedAt: string }>): number {
return Math.max(...entries.map((entry) => Date.parse(entry.updatedAt)).filter(Number.isFinite));
}
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[];
return messages
.filter((message) => !(input.ignoreBotMessages && message.author.bot === true))
.map((message) => ({
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,
}))
.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 fetchBifrostGovernanceDigest(input: {
bifrostRoot: string;
repoName: string;
agentIdentity: string;
}): Promise<BifrostGovernanceDigest | undefined> {
const scriptPath = resolve(input.bifrostRoot, "tools", "governance-threads.mjs");
const result = spawnSync(
process.execPath,
[
scriptPath,
"digest",
"--repo", input.repoName,
"--agent", input.agentIdentity,
"--limit", "6",
],
{
cwd: input.bifrostRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
timeout: 30000,
},
);
if (result.status !== 0) {
return {
generatedAt: new Date().toISOString(),
topics: [{
id: "bifrost-digest-error",
title: "Bifrost governance digest unavailable",
jurisdictionRepoName: input.repoName,
jurisdictionAgentIdentity: input.agentIdentity,
status: "error",
summaryMarkdown: `Could not read Bifrost governance digest: ${result.stderr || result.error?.message || result.stdout || "unknown failure"}`,
priority: 0,
updatedAt: new Date().toISOString(),
comments: [],
}],
};
}
try {
return JSON.parse(result.stdout) as BifrostGovernanceDigest;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
generatedAt: new Date().toISOString(),
topics: [{
id: "bifrost-digest-parse-error",
title: "Bifrost governance digest parse failure",
jurisdictionRepoName: input.repoName,
jurisdictionAgentIdentity: input.agentIdentity,
status: "error",
summaryMarkdown: `Could not parse Bifrost governance digest: ${message}`,
priority: 0,
updatedAt: new Date().toISOString(),
comments: [],
}],
};
}
}
interface DiscordApiMessage {
id: string;
content: string;
timestamp: string;
author: {
id: string;
username: string;
global_name?: string | null;
bot?: boolean;
};
member?: {
nick?: string | null;
};
}
async function startVoidModerationTurn(input: {
queuedAt: string;
storageRoot: string;
pendingMentions: RepoFacePendingMention[];
}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string; failureReason?: string }> {
const runnerScript = resolve(process.cwd(), "scripts", "run-void-moderator-rumination.ps1");
const statusDir = resolve(input.storageRoot, "status");
const lockPath = resolve(statusDir, "moderation-rumination.lock");
const statusPath = resolve(statusDir, "moderation-rumination.json");
const pendingMentionsPath = resolve(statusDir, "void-moderation-pending-mentions.json");
const launchedAt = Date.now();
await mkdir(statusDir, { recursive: true });
await writeFile(
pendingMentionsPath,
`${JSON.stringify({
generatedAt: input.queuedAt,
pendingMentions: input.pendingMentions,
}, null, 2)}\n`,
"utf8",
);
const launchCommand = [
`$env:VOID_RUMINATION_PENDING_MENTIONS_PATH = ${toPowerShellSingleQuotedString(pendingMentionsPath)};`,
`$arguments = @(${[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
runnerScript,
].map(toPowerShellSingleQuotedString).join(", ")});`,
`Start-Process -FilePath ${toPowerShellSingleQuotedString("powershell.exe")} -ArgumentList $arguments -WorkingDirectory ${toPowerShellSingleQuotedString(process.cwd())} -WindowStyle Hidden;`,
].join(" ");
const child = spawn(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
launchCommand,
],
{
cwd: process.cwd(),
stdio: "ignore",
windowsHide: true,
},
);
child.unref();
const handshake = await waitForVoidModerationHandshake({
lockPath,
statusPath,
launchedAt,
timeoutMs: 60000,
});
if (!handshake.started) {
return {
created: false,
activeJobId: child.pid ? `launcher-process:${child.pid}` : undefined,
requestMessageId: `agent-turn:void:${input.queuedAt}`,
failureReason: handshake.reason,
};
}
return {
created: true,
activeJobId: `process:void-moderation:${input.queuedAt}`,
requestMessageId: `agent-turn:void:${input.queuedAt}`,
};
}
function toPowerShellSingleQuotedString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
async function waitForVoidModerationHandshake(input: {
lockPath: string;
statusPath: string;
launchedAt: number;
timeoutMs: number;
}): Promise<{ started: true } | { started: false; reason: string }> {
const deadline = Date.now() + input.timeoutMs;
while (Date.now() < deadline) {
if (await wasTouchedAfter(input.lockPath, input.launchedAt)) {
return { started: true };
}
if (await wasTouchedAfter(input.statusPath, input.launchedAt)) {
return { started: true };
}
await sleep(250);
}
return {
started: false,
reason: "void_moderation_launch_handshake_missing",
};
}
async function wasTouchedAfter(path: string, timestampMs: number): Promise<boolean> {
try {
const info = await stat(path);
return info.mtimeMs >= timestampMs - 500;
} catch {
return false;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
async function listExistingActiveTurns(
databaseDsn: string,
stateStorageBackend: "file" | "postgres",
config: ReturnType<typeof loadConfig>,
): Promise<ActiveTurnScan> {
const storage = await createStateStorage({
backend: stateStorageBackend,
databaseDsn,
jobsFile: config.jobsFile,
auditLogFile: config.auditLogFile,
interactionMemoryFile: config.interactionMemoryFile,
rateLimitStateFile: config.rateLimitStateFile,
});
try {
const jobs = await storage.jobQueue.listByStates(["approved", "running"]);
const active = new Map<string, string>();
const staleRecovered: StaleActiveTurn[] = [];
const staleAfterMs = Math.max(MIN_STALE_ACTIVE_JOB_MS, config.codexExecTimeoutMs * 3);
const nowMs = Date.now();
for (const job of jobs) {
if (job.command !== HEARTBEAT_COMMAND) {
continue;
}
const match =
job.requestMessageId?.match(/^agent-turn:([^:]+):/) ??
job.requestMessageId?.match(/^agent-heartbeat:([^:]+):/) ??
job.requestMessageId?.match(/^repo-face-heartbeat:([^:]+):/) ??
job.requestMessageId?.match(/:repo-face:([^:]+):\d+$/);
if (match) {
const updatedMs = Date.parse(job.updatedAt);
const ageMs = Number.isFinite(updatedMs) ? nowMs - updatedMs : Number.POSITIVE_INFINITY;
if (ageMs > staleAfterMs) {
const ageMinutes = Number.isFinite(ageMs) ? Math.round((ageMs / 60_000) * 10) / 10 : -1;
await storage.jobQueue.markFailed(
job.id,
`Repo Face CTB recovered stale active turn job after ${ageMinutes} minutes without progress.`,
);
staleRecovered.push({
identityId: match[1],
jobId: job.id,
requestMessageId: job.requestMessageId,
state: job.state,
updatedAt: job.updatedAt,
ageMinutes,
});
continue;
}
active.set(match[1], job.id);
}
}
const voidLock = await readRecentLock(resolve(config.storageRoot, "status", "moderation-rumination.lock"), 20);
if (voidLock) {
active.set("void", "lock:moderation-rumination");
}
return { active, staleRecovered };
} finally {
await storage.close();
}
}
async function readRecentLock(path: string, maxAgeMinutes: number): Promise<boolean> {
try {
const info = await stat(path);
const ageMs = Date.now() - info.mtimeMs;
return ageMs >= 0 && ageMs < maxAgeMinutes * 60_000;
} catch {
return false;
}
}
function reconcileParticipants(
existing: FaceHeartbeatParticipant[],
specs: ParticipantSpec[],
defaultChannelId: string | undefined,
speedOverrides: Record<string, number>,
heatOverrides: Record<string, number>,
initiativeClock: number,
baseRecoveryMinutes: number,
globalHeat: number,
): FaceHeartbeatParticipant[] {
const existingById = new Map(existing.map((entry) => [entry.identityId, entry]));
const count = Math.max(specs.length, 1);
return specs.map((spec, index) => {
const current = existingById.get(spec.id);
const hasChannel = spec.participantKind === "system_agent" || Boolean(spec.allowedChannelIds[0] || defaultChannelId);
const speed = initiativeSpeedFor(spec, speedOverrides) * spec.channelSpeedMultiplier;
const groups = initiativeGroupsFor(spec);
const heat = heatFor(spec, groups, globalHeat, heatOverrides);
const effectiveSpeed = clamp(speed * heat, 0.1, 12);
const nextTurnAt = Number.isFinite(current?.nextTurnAt)
? current.nextTurnAt
: initiativeClock + ((baseRecoveryMinutes / count) * index);
if (current) {
return {
...current,
participantKind: spec.participantKind,
turnKind: spec.turnKind,
repoName: spec.repoName,
displayName: spec.displayName,
initiativeSpeed: speed,
groups,
heat,
effectiveSpeed,
baseRecoveryMinutes,
nextTurnAt,
constraints: mergeStrings(
mergeStrings(
current.constraints,
"Agent runtime uses CTB-style turns.",
),
"Wall-clock elapsed time advances initiative; heat changes recovery speed but does not fast-forward time.",
),
status: hasChannel
? current.status === "withdrawn"
? "withdrawn"
: "active"
: "blocked",
};
}
return {
identityId: spec.id,
participantKind: spec.participantKind,
turnKind: spec.turnKind,
repoName: spec.repoName,
displayName: spec.displayName,
initiativeSpeed: speed,
reactionBias: reactionBiasFor(spec),
interruptThreshold: interruptThresholdFor(spec),
currentLoad: 0,
status: hasChannel ? "active" : "blocked",
groups,
heat,
effectiveSpeed,
baseRecoveryMinutes,
nextTurnAt,
queuedCount: 0,
constraints: [
"Agent runtime uses CTB-style turns.",
"Wall-clock elapsed time advances initiative; heat changes recovery speed but does not fast-forward time.",
"Worker final summaries are not auto-posted as the base bot.",
],
};
});
}
function applyActiveTurnFreeze(
participant: FaceHeartbeatParticipant,
activeJobId: string | undefined,
state: FaceHeartbeatState,
completedThisTick: Set<string>,
): FaceHeartbeatParticipant {
if (activeJobId) {
return {
...participant,
currentLoad: 1,
activeJobId,
activeTurnStartedAt: participant.activeTurnStartedAt ?? participant.lastTurnAt ?? state.initiativeClock,
};
}
if (participant.currentLoad >= 1 || participant.activeTurnStartedAt !== undefined || participant.activeJobId) {
const completedTurnStartedAt = participant.activeTurnStartedAt ?? participant.lastTurnAt ?? state.initiativeClock;
const unfrozen = {
...participant,
currentLoad: 0,
activeTurnStartedAt: undefined,
activeJobId: undefined,
};
const recoveryMinutes = recoveryFor(unfrozen);
unfrozen.nextTurnAt = Math.max(state.initiativeClock, completedTurnStartedAt) + recoveryMinutes;
completedThisTick.add(participant.identityId);
state.history.push({
type: "turn_completed",
identityId: participant.identityId,
completedAtClock: state.initiativeClock,
startedAtClock: completedTurnStartedAt,
nextTurnAt: unfrozen.nextTurnAt,
recoveryMinutes,
heat: participant.heat,
effectiveSpeed: participant.effectiveSpeed,
});
return unfrozen;
}
return {
...participant,
currentLoad: 0,
};
}
function selectReadyParticipants(
state: FaceHeartbeatState,
maxJobs: number,
completedThisTick: Set<string>,
restStates: Map<string, RepoFaceRestSnapshot>,
): FaceHeartbeatParticipant[] {
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
const eligible = state.participants
.filter((participant) => {
const pendingMentionCount = pendingMentionCounts.get(participant.identityId) ?? 0;
const restState = restStates.get(participant.identityId);
return (
participant.status === "active" &&
participant.currentLoad < 1 &&
!completedThisTick.has(participant.identityId) &&
!(
participant.participantKind === "repo_face" &&
pendingMentionCount === 0 &&
restState?.isNapping === true
)
);
});
if (eligible.length === 0) {
return [];
}
return eligible
.filter((participant) => participant.nextTurnAt <= state.initiativeClock)
.sort((left, right) => {
const pendingDelta =
(pendingMentionCounts.get(right.identityId) ?? 0) -
(pendingMentionCounts.get(left.identityId) ?? 0);
if (pendingDelta !== 0) {
return pendingDelta;
}
const readyDelta = left.nextTurnAt - right.nextTurnAt;
if (readyDelta !== 0) {
return readyDelta;
}
if (right.reactionBias !== left.reactionBias) {
return right.reactionBias - left.reactionBias;
}
if (right.effectiveSpeed !== left.effectiveSpeed) {
return right.effectiveSpeed - left.effectiveSpeed;
}
return left.identityId.localeCompare(right.identityId);
})
.slice(0, maxJobs);
}
function advanceInitiativeClockFromWallClock(state: FaceHeartbeatState, now: Date): void {
const lastTickMs = Date.parse(state.lastTickAt ?? "");
if (!Number.isFinite(lastTickMs)) {
return;
}
const elapsedMinutes = (now.getTime() - lastTickMs) / 60_000;
if (elapsedMinutes <= 0) {
return;
}
const boundedElapsedMinutes = Math.min(elapsedMinutes, 60);
state.initiativeClock = round3(state.initiativeClock + boundedElapsedMinutes);
}
function rescheduleStaleOverdueParticipants(state: FaceHeartbeatState): void {
const activeParticipants = state.participants.filter((participant) => participant.status === "active");
const count = Math.max(activeParticipants.length, 1);
let rescheduledCount = 0;
activeParticipants.forEach((participant, index) => {
const staleThreshold = Math.max(participant.baseRecoveryMinutes, 15);
if (participant.nextTurnAt >= state.initiativeClock - staleThreshold) {
return;
}
participant.nextTurnAt = round3(state.initiativeClock + (participant.baseRecoveryMinutes / count) * index);
rescheduledCount += 1;
});
if (rescheduledCount > 0) {
state.history.push({
type: "wall_clock_resync",
rescheduledCount,
initiativeClock: state.initiativeClock,
});
}
}
function applyPendingMentionPriority(state: FaceHeartbeatState): void {
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
for (const participant of state.participants) {
if (
participant.status === "active" &&
participant.currentLoad < 1 &&
(pendingMentionCounts.get(participant.identityId) ?? 0) > 0
) {
participant.nextTurnAt = Math.min(participant.nextTurnAt, state.initiativeClock);
}
}
}
function pendingMentionsForParticipant(
state: FaceHeartbeatState,
identityId: string,
): RepoFacePendingMention[] {
return state.pendingMentions
.filter((mention) => mention.identityId === identityId)
.sort((left, right) => Date.parse(left.queuedAt) - Date.parse(right.queuedAt));
}
function countPendingMentionsByIdentity(
pendingMentions: RepoFacePendingMention[],
): Map<string, number> {
const counts = new Map<string, number>();
for (const mention of pendingMentions) {
counts.set(mention.identityId, (counts.get(mention.identityId) ?? 0) + 1);
}
return counts;
}
function buildHeartbeatPrompt(input: {
identity: RepoDiscordIdentity;
channelId: string;
channelPlan: RepoFaceChannelPlan;
channelSnapshots: ChannelSnapshot[];
recentMessages: SourceMessage[];
memorySurface?: string;
repoActivitySurface?: string;
socialOpportunitySurface?: string;
conversationMemorySurface?: string;
bifrostDigest?: BifrostGovernanceDigest;
participant: FaceHeartbeatParticipant;
pendingMentions: RepoFacePendingMention[];
jurisdictionDive: JurisdictionDiveDirective;
githubActionsEnabled: boolean;
}): string {
return loadPromptTemplate("repo-face-turn.prompt.md", {
displayName: input.identity.displayName,
identityId: input.identity.id,
repoName: input.identity.repoName,
identityDoctrine: renderRepoCharacterIdentityDoctrine(input.identity),
channelId: input.channelId,
memorySurface: input.memorySurface ?? `- ${input.identity.displayName} has no strong personal memory surface yet. Let the attached conversation and repo evidence wake something specific.`,
repoActivitySurface: input.repoActivitySurface ?? "- No recent home repo activity was attached for this turn.",
socialOpportunitySurface: input.socialOpportunitySurface ?? "- No live social affordances were attached for this turn.",
conversationMemorySurface: input.conversationMemorySurface ?? "- No recent conversation transcript was attached for this turn.",
roomWeatherDirective: renderRepoFaceRoomWeatherDirective(input.identity, {
recentMessages: input.recentMessages,
channelSnapshots: input.channelSnapshots,
}),
turnSituationDirective: renderTurnSituationDirective({
identity: input.identity,
participant: input.participant,
recentMessages: input.recentMessages,
channelSnapshots: input.channelSnapshots,
pendingMentions: input.pendingMentions,
}),
pendingMentionDirective: renderPendingMentionDirective(input.identity, input.pendingMentions),
bifrostDigestDirective: renderBifrostGovernanceDigestDirective(input.bifrostDigest),
channelPermissionDirective: renderChannelPermissionDirective(input.channelPlan),
researchCapabilitiesDirective: renderResearchCapabilitiesDirective(input.identity),
socialEmbodimentDirective: renderSocialEmbodimentDirective(input.identity),
jurisdictionRespectDirective: renderJurisdictionRespectDirective(input.identity),
comedyImprovDirective: renderComedyImprovDirective(input.identity),
repetitionSamplingDirective: renderRepetitionSamplingDirective([
input.recentMessages,
...input.channelSnapshots.map((snapshot) => snapshot.messages),
].flat()),
worldbuildingPublicationDirective: renderWorldbuildingPublicationDirective(input.identity),
jurisdictionDiveLine: input.jurisdictionDive.promptLine,
githubActionsEnabled: input.githubActionsEnabled,
});
}
async function assembleRepoFaceTurnPrompt(input: {
config: ReturnType<typeof loadConfig>;
identityId: string;
outPath?: string;
memorySurfacePath?: string;
conversationSurfacePath?: string;
}): Promise<{
ok: true;
identityId: string;
promptLength: number;
outPath?: string;
memorySurfacePath?: string;
conversationSurfacePath?: string;
}> {
const faceRegistry = await loadFaceIdentityRegistry(input.config.repoDiscordIdentitiesPath);
const registry = faceRegistryAsRepoDiscordRegistry(faceRegistry);
const identity = registry.identities.find(
(entry) => entry.id.toLowerCase() === input.identityId.toLowerCase(),
);
if (!identity) {
throw new Error(`Unknown repo Face identity: ${input.identityId}`);
}
const channelPlan = buildChannelPlan(identity, input.config.repoFaceHeartbeats.defaultChannelId);
const channelId = channelPlan.primaryChannelId;
if (!channelId) {
throw new Error(`No prompt assembly channel is configured for ${identity.id}.`);
}
const [recentMessages, channelSnapshots, bifrostDigest] = await Promise.all([
fetchRecentDiscordMessages({
botToken: input.config.botToken,
channelId,
limit: 15,
ignoreBotMessages: channelId === input.config.bifrostDiscordChannelId,
}),
fetchChannelSnapshots({
botToken: input.config.botToken,
channelIds: channelPlan.snapshotChannelIds,
primaryChannelId: channelId,
limit: 6,
bifrostDiscordChannelId: input.config.bifrostDiscordChannelId,
}),
input.config.repoFaceBifrostEnabled
? fetchBifrostGovernanceDigest({
bifrostRoot: input.config.bifrostRoot,
repoName: identity.repoName,
agentIdentity: identity.id,
})
: Promise.resolve(undefined),
]);
const memorySurface = input.memorySurfacePath
? await readOptionalMemorySurface(input.memorySurfacePath)
: await renderRepoFaceMemorySurfaceForTurn(identity, input.config, registry.identities, {
recentMessages,
channelSnapshots,
});
const repoActivitySurface = renderRepoFaceRepoActivitySurface(identity, input.config);
const socialOpportunitySurface = await renderRepoFaceSocialOpportunitySurface({
identity,
registryIdentities: registry.identities,
config: input.config,
recentMessages,
channelSnapshots,
channelPlan,
});
const conversationMemorySurface = input.conversationSurfacePath
? await readOptionalMemorySurface(input.conversationSurfacePath)
: renderRepoFaceConversationTranscript({
identity,
recentMessages,
channelSnapshots,
pendingMentions: [],
channelPlan,
});
const participant = buildInspectionParticipant(
identity,
input.config.repoFaceHeartbeats.baseRecoveryMinutes,
);
const prompt = buildHeartbeatPrompt({
identity,
channelId,
channelPlan,
channelSnapshots,
recentMessages,
memorySurface,
repoActivitySurface,
socialOpportunitySurface,
conversationMemorySurface,
bifrostDigest,
participant,
pendingMentions: [],
jurisdictionDive: buildJurisdictionDiveDirective(identity, participant),
githubActionsEnabled: input.config.repoFaceGithubActionsEnabled,
});
if (input.outPath) {
const outPath = resolve(input.outPath);
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, prompt, "utf8");
}
return {
ok: true,
identityId: identity.id,
promptLength: prompt.length,
outPath: input.outPath ? resolve(input.outPath) : undefined,
memorySurfacePath: input.memorySurfacePath ? resolve(input.memorySurfacePath) : undefined,
conversationSurfacePath: input.conversationSurfacePath ? resolve(input.conversationSurfacePath) : undefined,
};
}
async function renderRepoFaceMemorySurfaceForTurn(
identity: RepoDiscordIdentity,
config: ReturnType<typeof loadConfig>,
registryIdentities: RepoDiscordIdentity[] = [],
roomContext?: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): Promise<string> {
const statePath = resolveRepoFaceStatePath(identity, config.storageRoot);
const typedState = await loadVoidSelfStateTypedDocuments({ canonicalPath: statePath });
const humanPronounGuidance = await loadRepoFaceHumanPronounGuidance(config, roomContext);
const statePacket = renderRepoFaceStatePacket(
identity,
typedState,
registryIdentities,
roomContext,
humanPronounGuidance,
);
if (!config.repoFaceHeartbeats.stateProjectorEnabled) {
return rejectLeakyMemorySurface(statePacket);
}
return projectRepoFaceMemorySurface({
identity,
statePacket,
config,
});
}
function renderRepoFaceStatePacket(
identity: RepoDiscordIdentity,
state: VoidSelfStateTypedProjection,
registryIdentities: RepoDiscordIdentity[] = [],
roomContext?: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
humanPronounGuidance: RepoFaceHumanPronounGuidance[] = [],
): string {
const name = identity.displayName;
const lines: string[] = [];
const profile = state.selfProfile;
const privateNotes = profile.privateNotes;
const values = [...profile.values]
.sort((left, right) => right.priority - left.priority);
const needs = [...state.faceAffect.needs]
.filter((need) => need.status !== "retired")
.sort(sortAffectByStatusAndIntensity);
const bonds = [...state.faceAffect.socialBonds]
.filter((bond) => bond.status !== "retired")
.sort(sortAffectByStatusAndIntensity);
const statusReads = [...state.faceAffect.statusReads]
.filter((read) => !read.retiredAt)
.sort(sortAffectByStatusAndIntensity);
const moodDimensions = [...state.faceAffect.moodDimensions]
.sort((left, right) => right.value - left.value);
const agencyPressures = [...state.agencyPressure.pressures]
.filter((pressure) => pressure.status !== "retired")
.sort(sortAffectByStatusAndIntensity);
const durableMemories = [...state.thoughtMemory.memories]
.filter((memory) => !memory.retiredAt)
.slice(-12)
.reverse();
const shortTermResidue = [...state.thoughtMemory.shortTerm]
.filter((memory) => !memory.retiredAt)
.slice(-12)
.reverse();
const incubation = [...state.thoughtMemory.incubation]
.filter((thread) => thread.status !== "retired")
.sort((left, right) => right.maturation - left.maturation);
const candidateInterventions = [...state.candidateInterventions.interventions]
.filter((intervention) => intervention.status !== "retired")
.slice(-8)
.reverse();
const recentReceipts = [...state.speechReceipts.recentReceipts]
.slice(-6)
.reverse();
const activationFacts = renderRepoFaceActivationProfileFacts(profile.activationProfile);
const runtimeFacts = renderRepoFaceRuntimePressureFacts(name, state);
const selfTexture = [
...privateNotes.map(projectPrivateNoteForMemorySurface),
...values.map((value) => value.summary || value.label),
]
.map(cleanCharacterFacingSentence)
.filter((entry) => entry.length > 0)
.slice(0, 18);
if (selfTexture.length > 0) {
lines.push(`Right now, ${name} is carrying this close to the skin: ${joinAsNarrativeList(selfTexture)}.`);
}
if (activationFacts) {
lines.push(activationFacts);
}
if (runtimeFacts) {
lines.push(runtimeFacts);
}
if (needs.length > 0) {
lines.push([
`${name}'s explicit needs and frictions:`,
...needs.map((need) => {
const target = targetLabel(need.target);
const claimOrQuestion = need.claim
? `Claim: ${asSentence(need.claim)}`
: need.question
? `Question: ${asSentence(need.question)}`
: "";
return [
`- ${need.kind} need toward ${target} [${need.status}, intensity ${need.intensity.toFixed(2)}, valence ${need.valence.toFixed(2)}]: ${asSentence(need.summary)}`,
claimOrQuestion,
`Tension: ${asSentence(need.tension)}`,
`Behavioral pull: ${asSentence(need.actionImplication)}`,
].filter(Boolean).join(" ");
}),
].join("\n"));
}
if (bonds.length > 0) {
lines.push([
"The social map has teeth:",
...bonds.map((bond) => {
const target = targetLabel(bond.target);
return [
`- ${target} draws ${bond.stance} [${bond.status}, intensity ${bond.intensity.toFixed(2)}]: ${asSentence(bond.summary)}`,
`Read: ${asSentence(bond.claim)}`,
`Tension: ${asSentence(bond.tension)}`,
`Behavioral pull: ${asSentence(bond.actionImplication)}`,
].join(" ");
}),
].join("\n"));
}
if (statusReads.length > 0) {
lines.push([
"Status in the swarm is part of the weather:",
...statusReads.map((read) => {
const target = targetLabel(read.target);
return [
`- Around ${target}, ${name} feels ${read.status} [intensity ${read.intensity.toFixed(2)}]: ${asSentence(read.summary)}`,
`Read: ${asSentence(read.claim)}`,
`Tension: ${asSentence(read.tension)}`,
`Behavioral pull: ${asSentence(read.actionImplication)}`,
].join(" ");
}),
].join("\n"));
}
if (moodDimensions.length > 0) {
lines.push([
"Mood dimensions currently bending the turn:",
...moodDimensions.map((dimension) =>
`- ${dimension.name}=${dimension.value.toFixed(2)}${dimension.source ? ` from ${cleanCharacterFacingSentence(dimension.source)}` : ""}`,
),
].join("\n"));
}
if (durableMemories.length > 0) {
lines.push([
"Durable memories that should still bias judgment:",
...durableMemories.map((memory) => renderRepoFaceMemoryFact(name, memory)),
].join("\n"));
}
if (shortTermResidue.length > 0) {
lines.push([
"Short-term residue waiting to settle:",
...shortTermResidue.map((memory) => renderRepoFaceMemoryFact(name, memory)),
].join("\n"));
}
const socialGraphFacts = renderRepoFaceSocialGraphFacts(identity, registryIdentities, state);
if (socialGraphFacts) {
lines.push(socialGraphFacts);
}
const pronounFacts = renderRepoFaceHumanPronounFacts(humanPronounGuidance);
if (pronounFacts) {
lines.push(pronounFacts);
}
const roomTextureFacts = roomContext
? renderRepoFaceRoomTextureFacts(identity, roomContext)
: undefined;
if (roomTextureFacts) {
lines.push(roomTextureFacts);
}
if (agencyPressures.length > 0) {
lines.push([
"Agency pressures that want eventual motion:",
...agencyPressures.map((pressure) =>
[
`- ${pressure.kind} toward ${targetLabel(pressure.target)} [${pressure.status}, intensity ${pressure.intensity.toFixed(2)}]: ${asSentence(pressure.summary)}`,
pressure.claim ? `Claim: ${asSentence(pressure.claim)}` : "",
pressure.question ? `Question: ${asSentence(pressure.question)}` : "",
pressure.tension ? `Tension: ${asSentence(pressure.tension)}` : "",
`Behavioral pull: ${asSentence(pressure.actionImplication)}`,
].filter(Boolean).join(" "),
),
].join("\n"));
}
if (incubation.length > 0) {
lines.push([
"Thoughts still moving under the floorboards:",
...incubation.map((thread) =>
[
`- ${cleanCharacterFacingSentence(thread.topic)} [${thread.status}, maturation ${thread.maturation.toFixed(2)}]: ${cleanCharacterFacingSentence(thread.summary)}`,
typeof thread.desireToSpeak === "number" ? `desire to speak ${thread.desireToSpeak.toFixed(2)}` : "",
typeof thread.noveltyToRoom === "number" ? `room novelty ${thread.noveltyToRoom.toFixed(2)}` : "",
typeof thread.saturationScore === "number" ? `saturation ${thread.saturationScore.toFixed(2)}` : "",
].filter(Boolean).join("; "),
),
].join("\n"));
}
if (candidateInterventions.length > 0) {
lines.push([
"Unsaid or recently deferred speech pressure:",
...candidateInterventions.map((intervention) =>
[
`- ${intervention.kind} [${intervention.status}, priority ${intervention.priority.toFixed(2)}${intervention.mustEventuallyShare ? ", must eventually share" : ""}]: ${asSentence(intervention.summary)}`,
`Draft residue: ${cleanCharacterFacingSentence(intervention.draft)}`,
].join(" "),
),
"Do not repeat a waiting line unless the room gives it a sharper angle.",
].join("\n"));
}
if (recentReceipts.length > 0) {
lines.push([
"Recent speech residue:",
...recentReceipts.map((receipt) =>
`- Said recently${receipt.preview ? `: ${cleanCharacterFacingSentence(receipt.preview)}` : "."} Let this create repetition caution, confidence, embarrassment, or follow-through as appropriate.`,
),
].join("\n"));
}
if (lines.length === 0) {
return `You are ${name}, but your durable state is thin. Use the room, repo, and your jurisdiction to form a real opinion before speaking.`;
}
return rejectLeakyMemorySurface(lines.join("\n\n"));
}
function sortAffectByStatusAndIntensity(
left: { status?: string; intensity?: number },
right: { status?: string; intensity?: number },
): number {
const rank = (status: string | undefined): number => {
switch (status) {
case "neglected":
case "ready_to_act":
return 0;
case "active":
return 1;
case "challenged":
return 1;
case "cooling":
return 2;
case "satisfied":
case "resolved":
return 3;
default:
return 4;
}
};
const rankDelta = rank(left.status) - rank(right.status);
if (rankDelta !== 0) {
return rankDelta;
}
return (right.intensity ?? 0) - (left.intensity ?? 0);
}
function targetLabel(target: { label?: string; id?: string; kind?: string } | undefined): string {
if (!target) {
return "an unnamed target";
}
return target.label ?? target.id ?? target.kind ?? "an unnamed target";
}
function renderRepoFaceActivationProfileFacts(
activationProfile: VoidSelfStateTypedProjection["selfProfile"]["activationProfile"],
): string | undefined {
const sections = Object.entries(activationProfile)
.map(([section, values]) => {
const entries = Object.entries(values)
.sort(([, left], [, right]) => right.weight - left.weight)
.map(([key, value]) => `${key}=${value.weight.toFixed(2)}${value.note ? ` (${cleanCharacterFacingSentence(value.note)})` : ""}`);
return entries.length > 0 ? `- ${section}: ${entries.join("; ")}` : undefined;
})
.filter((entry): entry is string => typeof entry === "string");
return sections.length > 0
? ["Activation profile that should color behavior:", ...sections].join("\n")
: undefined;
}
function renderRepoFaceRuntimePressureFacts(
name: string,
state: VoidSelfStateTypedProjection,
): string | undefined {
const lines: string[] = [];
const sleep = state.scheduledRuntime.sleepCycle;
if (sleep.isNapping || sleep.activeDreamThemes.length > 0) {
lines.push(
`${name}'s rest state: ${sleep.isNapping ? "currently in a sleep/low-output phase" : "awake but carrying dream residue"}${sleep.activeDreamThemes.length > 0 ? ` around ${joinAsNarrativeList(sleep.activeDreamThemes.map(cleanCharacterFacingSentence))}` : ""}.`,
);
}
const speaking = state.scheduledRuntime.speakingPressure;
const speakingParts = [
`need to speak ${speaking.needToSpeak.toFixed(2)}`,
typeof speaking.confessionPressure === "number" ? `confession ${speaking.confessionPressure.toFixed(2)}` : "",
typeof speaking.noveltyPressure === "number" ? `novelty ${speaking.noveltyPressure.toFixed(2)}` : "",
typeof speaking.recentSpeechDamping === "number" ? `recent-speech damping ${speaking.recentSpeechDamping.toFixed(2)}` : "",
].filter(Boolean);
lines.push(`Speaking pressure: ${speakingParts.join(", ")}. Treat this as appetite/restraint, not an order.`);
if (state.scheduledRuntime.lastRuns.length > 0) {
lines.push(`Recent internal passes: ${state.scheduledRuntime.lastRuns.slice(-4).map((run) =>
cleanCharacterFacingSentence(run.summary),
).join(" | ")}`);
}
return lines.length > 0 ? lines.join("\n") : undefined;
}
function renderRepoFaceMemoryFact(
name: string,
memory: VoidSelfStateTypedProjection["thoughtMemory"]["memories"][number],
): string {
const parts = [
`- ${memory.kind} about ${targetLabel(memory.target)}: ${asSentence(memory.summary)}`,
memory.claim ? `Claim: ${asSentence(memory.claim)}` : "",
memory.question ? `Question: ${asSentence(memory.question)}` : "",
memory.tension ? `Tension: ${asSentence(memory.tension)}` : "",
memory.actionImplication ? `Behavioral pull for ${name}: ${asSentence(memory.actionImplication)}` : "",
];
return parts.filter(Boolean).join(" ");
}
function renderRepoFaceRoomTextureFacts(
identity: RepoDiscordIdentity,
input: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): string | undefined {
const stats = collectRepoFaceRoomTextureStats(identity, input);
if (!stats) {
return undefined;
}
const temporaryPressures = stats.texture === "heavy" || stats.agentShare >= 0.55
? [
"- Temporary affect pressure candidates: play, rest, and social_contact may be hungry because the room is carrying sustained work/agent weight.",
"- These are not stored needs and not orders. Project whether this character gets mischievous, bored, sharp, withdrawn, socially hungry, status-testing, or still work-focused.",
]
: [];
return [
"Room texture facts:",
`- Observed messages: ${stats.total}; humans: ${stats.humanMessages}; agents/bots: ${stats.agentMessages}; distinct speakers: ${stats.speakerCount}.`,
`- Long messages: ${stats.longMessages}; short messages: ${stats.shortMessages}; average length: ${stats.averageCharacters} characters.`,
`- This Face's own recent messages in the attached window: ${stats.ownMessages}.`,
`- Structural texture: ${stats.texture}. This is evidence about conversational weight, not a command to speak or joke.`,
...temporaryPressures,
].join("\n");
}
function renderRepoFaceRoomWeatherDirective(
identity: RepoDiscordIdentity,
input: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): string {
const stats = collectRepoFaceRoomTextureStats(identity, input);
if (!stats) {
return "- No current room weather was available.";
}
const pressure =
stats.texture === "heavy" || stats.agentShare >= 0.55
? "The room is currently structurally work-heavy or agent-heavy. Treat that as conversational weather: it may create boredom, play hunger, restlessness, social hunger, withdrawal, or sharper status-testing, depending on who you are. This is not an order to joke; it is permission to feel the weight instead of answering every opening with more work."
: stats.texture === "light"
? "The room is currently light enough for compact social motion. You still need an actual reason to speak, but not a work ticket."
: "The room is mixed. Use the transcript to decide whether the living pressure is social, practical, or private.";
return [
`- Messages observed: ${stats.total}; humans: ${stats.humanMessages}; agents/bots: ${stats.agentMessages}; distinct speakers: ${stats.speakerCount}.`,
`- Texture: ${stats.texture}; your own recent messages in this window: ${stats.ownMessages}.`,
`- ${pressure}`,
].join("\n");
}
interface RepoFaceRoomTextureStats {
total: number;
agentMessages: number;
humanMessages: number;
ownMessages: number;
longMessages: number;
shortMessages: number;
averageCharacters: number;
speakerCount: number;
texture: "heavy" | "light" | "mixed";
agentShare: number;
}
function collectRepoFaceRoomTextureStats(
identity: RepoDiscordIdentity,
input: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): RepoFaceRoomTextureStats | undefined {
const messages = [
...input.recentMessages,
...input.channelSnapshots.flatMap((snapshot) => snapshot.messages),
];
if (messages.length === 0) {
return undefined;
}
const ownToken = normalizeSocialLabel(identity.displayName);
const total = messages.length;
const agentMessages = messages.filter((message) => message.isBot).length;
const humanMessages = total - agentMessages;
const ownMessages = messages.filter((message) => normalizeSocialLabel(message.authorName) === ownToken).length;
const longMessages = messages.filter((message) => collapseWhitespace(message.content, 10_000).length >= 220).length;
const shortMessages = messages.filter((message) => collapseWhitespace(message.content, 10_000).length <= 90).length;
const averageCharacters = Math.round(
messages.reduce((sum, message) => sum + collapseWhitespace(message.content, 10_000).length, 0) / total,
);
const speakerCount = new Set(
messages.map((message) => normalizeSocialLabel(message.authorName || message.authorId)).filter(Boolean),
).size;
const texture =
longMessages >= Math.ceil(total * 0.45) || averageCharacters >= 180
? "heavy"
: shortMessages >= Math.ceil(total * 0.55)
? "light"
: "mixed";
return {
total,
agentMessages,
humanMessages,
ownMessages,
longMessages,
shortMessages,
averageCharacters,
speakerCount,
texture,
agentShare: agentMessages / total,
};
}
function renderRepoFaceSocialGraphFacts(
identity: RepoDiscordIdentity,
registryIdentities: RepoDiscordIdentity[],
state: VoidSelfStateTypedProjection,
): string | undefined {
const relations = collectRepoFaceSocialRelations(state);
const unmappedPeers = collectUnmappedSocialPeers(identity, registryIdentities, relations);
if (registryIdentities.length === 0) {
return undefined;
}
const lines = [
"Social graph topology:",
relations.length === 0
? "- No active person-bonds or person-status reads exist yet."
: `- Active mapped people: ${relations.map((relation) => relation.targetLabel).join(", ")}.`,
];
if (unmappedPeers.length > 0) {
lines.push(`- Unmapped active peers: ${formatUnmappedPeers(unmappedPeers)}.`);
}
lines.push("- These are topology facts only; they do not say how the gap should feel.");
return lines.join("\n");
}
interface RepoFaceHumanPronounGuidance {
actorId: string;
actorName: string;
guidance: string;
resolvedPronounSet?: string;
policy: string;
confidence?: number;
evidenceExcerpt?: string;
}
async function loadRepoFaceHumanPronounGuidance(
config: ReturnType<typeof loadConfig>,
roomContext?: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): Promise<RepoFaceHumanPronounGuidance[]> {
const visibleHumans = new Map<string, string>();
for (const message of [
...(roomContext?.recentMessages ?? []),
...(roomContext?.channelSnapshots.flatMap((snapshot) => snapshot.messages) ?? []),
]) {
if (!message.isBot && message.authorId) {
visibleHumans.set(message.authorId, message.authorName || message.authorId);
}
}
visibleHumans.set(config.ownerDiscordId, visibleHumans.get(config.ownerDiscordId) ?? "Metacrat");
const storage = await createStateStorage({
backend: config.stateStorageBackend,
databaseDsn: config.databaseDsn,
jobsFile: config.jobsFile,
auditLogFile: config.auditLogFile,
interactionMemoryFile: config.interactionMemoryFile,
rateLimitStateFile: config.rateLimitStateFile,
});
try {
const profiles = await Promise.all(
[...visibleHumans.entries()].map(async ([actorId, fallbackName]) => ({
actorId,
fallbackName,
profile: await storage.interactionMemory.getProfile(actorId),
})),
);
return profiles
.map(({ actorId, fallbackName, profile }) =>
profile ? repoFacePronounGuidanceFromProfile(actorId, fallbackName, profile) : undefined,
)
.filter((entry): entry is RepoFaceHumanPronounGuidance => entry !== undefined);
} finally {
await storage.close();
}
}
function repoFacePronounGuidanceFromProfile(
actorId: string,
fallbackName: string,
profile: InteractionMemoryProfile,
): RepoFaceHumanPronounGuidance | undefined {
if (profile.pronounPolicy === "unknown" || profile.resolvedPronounSets.length === 0) {
return undefined;
}
const evidence = [...profile.pronounEvidence]
.filter((entry) => entry.stance === "prefer" || entry.stance === "avoid")
.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp))[0];
return {
actorId,
actorName: profile.actorName || fallbackName,
guidance: profile.pronounGuidance,
resolvedPronounSet: profile.resolvedPronounSet,
policy: profile.pronounPolicy,
confidence: profile.pronounConfidence,
evidenceExcerpt: evidence?.excerpt,
};
}
function renderRepoFaceHumanPronounFacts(
guidance: RepoFaceHumanPronounGuidance[],
): string | undefined {
if (guidance.length === 0) {
return undefined;
}
return [
"Known human pronoun guidance:",
...guidance.map((entry) =>
[
`- ${entry.actorName}: ${entry.guidance}`,
entry.resolvedPronounSet ? `Resolved set: ${entry.resolvedPronounSet}.` : "",
entry.policy ? `Policy: ${entry.policy}.` : "",
typeof entry.confidence === "number" ? `Confidence: ${entry.confidence.toFixed(2)}.` : "",
entry.evidenceExcerpt ? `Evidence: "${collapseWhitespace(entry.evidenceExcerpt, 180)}"` : "",
].filter(Boolean).join(" "),
),
"Use this when referring to humans in social or relationship prose. If guidance is absent for someone, use their name or neutral phrasing rather than guessing.",
].join("\n");
}
async function renderRepoFaceSocialOpportunitySurface(input: {
identity: RepoDiscordIdentity;
registryIdentities: RepoDiscordIdentity[];
config: ReturnType<typeof loadConfig>;
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
channelPlan: RepoFaceChannelPlan;
}): Promise<string> {
const statePath = resolveRepoFaceStatePath(input.identity, input.config.storageRoot);
const typedState = await loadVoidSelfStateTypedDocuments({ canonicalPath: statePath });
const relations = collectRepoFaceSocialRelations(typedState);
const unmappedPeers = collectUnmappedSocialPeers(input.identity, input.registryIdentities, relations);
const messages = collectSocialOpportunityMessages(input);
const unmappedPeerHooks = collectUnmappedPeerSocialHooks({
identity: input.identity,
unmappedPeers,
messages,
});
if (relations.length === 0) {
return [
"No existing person-bond or person-status read matched this turn.",
"Use the transcript as evidence only when someone gives a real hook for trust, irritation, protection, rivalry, respect, suspicion, deference, or a careful question.",
...formatUnmappedPeerHookLines(unmappedPeerHooks),
unmappedPeers.length > 0
? `Unmapped registered peers: ${formatUnmappedPeers(unmappedPeers)}. Do not greet the whole roster at once.`
: "No peer roster was available for comparison this turn.",
].join("\n");
}
const hookedRelations = relations
.map((relation) => ({
relation,
hooks: findMessagesForSocialTarget(messages, relation.targetLabel, input.identity.displayName),
}))
.filter((entry) => entry.hooks.length > 0)
.slice(0, 5);
if (hookedRelations.length === 0) {
return [
"Existing person-bonds/status reads have no matching speaker in the attached recent messages.",
"Treat the transcript itself as possible fresh social evidence, not as an obligation to invent a relationship.",
...formatUnmappedPeerHookLines(unmappedPeerHooks),
unmappedPeers.length > 0
? `Still-unmapped peers: ${formatUnmappedPeers(unmappedPeers)}. If one of them appears in the transcript, there is evidence available for a possible first read.`
: "No unmapped registered peers were found for this turn.",
].join("\n");
}
const lines = [
"These are live relationship hooks, not orders. If one fits the room, maintain or complicate the bond in character instead of treating it as private trivia.",
];
for (const { relation, hooks } of hookedRelations) {
lines.push(`- ${relation.targetLabel}: ${relation.pressure} Recent hook: ${hooks.map(formatSocialOpportunityMessage).join(" | ")}`);
}
if (unmappedPeers.length > 0) {
lines.push(...formatUnmappedPeerHookLines(unmappedPeerHooks));
lines.push(`Unmapped registered peers: ${formatUnmappedPeers(unmappedPeers)}.`);
}
return rejectLeakyMemorySurface(lines.join("\n"));
}
function collectRepoFaceSocialRelations(
state: VoidSelfStateTypedProjection,
): Array<{ targetLabel: string; pressure: string; intensity: number }> {
const byTarget = new Map<string, { targetLabel: string; parts: string[]; intensity: number }>();
for (const bond of state.faceAffect.socialBonds ?? []) {
if (bond.status !== "active") {
continue;
}
if (bond.target.kind !== "person") {
continue;
}
const targetLabel = cleanSocialTargetLabel(bond.target.label ?? bond.target.id);
if (!targetLabel) {
continue;
}
const entry = byTarget.get(targetLabel) ?? { targetLabel, parts: [], intensity: 0 };
entry.parts.push(`${bond.stance}: ${asSentence(bond.summary)} ${asSentence(bond.actionImplication)}`);
entry.intensity = Math.max(entry.intensity, bond.intensity);
byTarget.set(targetLabel, entry);
}
for (const read of state.faceAffect.statusReads ?? []) {
if (read.retiredAt) {
continue;
}
if (read.target.kind !== "person") {
continue;
}
const targetLabel = cleanSocialTargetLabel(read.target.label ?? read.target.id);
if (!targetLabel) {
continue;
}
const entry = byTarget.get(targetLabel) ?? { targetLabel, parts: [], intensity: 0 };
entry.parts.push(`${read.status}: ${asSentence(read.summary)} ${asSentence(read.actionImplication)}`);
entry.intensity = Math.max(entry.intensity, read.intensity);
byTarget.set(targetLabel, entry);
}
return [...byTarget.values()]
.map((entry) => ({
targetLabel: entry.targetLabel,
pressure: entry.parts.map(cleanCharacterFacingSentence).filter(Boolean).join(" "),
intensity: entry.intensity,
}))
.filter((entry) => entry.pressure.length > 0)
.sort((left, right) => right.intensity - left.intensity);
}
function collectUnmappedSocialPeers(
identity: RepoDiscordIdentity,
registryIdentities: RepoDiscordIdentity[],
relations: Array<{ targetLabel: string }>,
): RepoDiscordIdentity[] {
const mappedTokens = new Set(
relations.flatMap((relation) => socialTargetTokens(relation.targetLabel)),
);
const selfTokens = new Set(socialTargetTokens(identity.displayName, identity.id, identity.repoName));
return registryIdentities
.filter((peer) => {
const peerTokens = socialTargetTokens(peer.displayName, peer.id, peer.repoName);
if (peerTokens.some((token) => selfTokens.has(token))) {
return false;
}
return !peerTokens.some((token) => mappedTokens.has(token));
})
.sort((left, right) => left.displayName.localeCompare(right.displayName))
.slice(0, 8);
}
function socialTargetTokens(...values: Array<string | undefined>): string[] {
return [...new Set(values.map(normalizeSocialLabel).filter((value) => value.length > 0))];
}
function formatUnmappedPeers(peers: RepoDiscordIdentity[]): string {
return peers.map((peer) => `${peer.displayName}/${peer.repoName}`).join(", ");
}
function collectUnmappedPeerSocialHooks(input: {
identity: RepoDiscordIdentity;
unmappedPeers: RepoDiscordIdentity[];
messages: Array<{ label: string; message: SourceMessage }>;
}): Array<{ peer: RepoDiscordIdentity; hooks: Array<{ label: string; message: SourceMessage }> }> {
const selfTokens = new Set(socialTargetTokens(input.identity.displayName, input.identity.id, input.identity.repoName));
const peersByToken = new Map<string, RepoDiscordIdentity>();
for (const peer of input.unmappedPeers) {
for (const token of socialTargetTokens(peer.displayName, peer.id, peer.repoName)) {
if (!selfTokens.has(token)) {
peersByToken.set(token, peer);
}
}
}
const byPeer = new Map<string, { peer: RepoDiscordIdentity; hooks: Array<{ label: string; message: SourceMessage }> }>();
for (const entry of input.messages) {
if (!entry.message.isBot || !entry.message.content.trim()) {
continue;
}
const peer = peersByToken.get(normalizeSocialLabel(entry.message.authorName));
if (!peer) {
continue;
}
const bucket = byPeer.get(peer.id) ?? { peer, hooks: [] };
bucket.hooks.push(entry);
byPeer.set(peer.id, bucket);
}
return [...byPeer.values()]
.map((entry) => ({
peer: entry.peer,
hooks: entry.hooks.slice(-2),
}))
.sort((left, right) => left.peer.displayName.localeCompare(right.peer.displayName))
.slice(0, 5);
}
function formatUnmappedPeerHookLines(
peerHooks: Array<{ peer: RepoDiscordIdentity; hooks: Array<{ label: string; message: SourceMessage }> }>,
): string[] {
if (peerHooks.length === 0) {
return [];
}
return [
"Possible first-read hooks from unmapped peers who actually spoke nearby:",
...peerHooks.map((entry) =>
`- ${entry.peer.displayName}: ${entry.hooks.map(formatSocialOpportunityMessage).join(" | ")}`,
),
"Use one of these only if it sparks a real first read, question, tease, alliance, irritation, or rivalry. Do not manufacture intimacy from a single quote.",
];
}
function collectSocialOpportunityMessages(input: {
channelPlan: RepoFaceChannelPlan;
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
}): Array<{ label: string; message: SourceMessage }> {
const currentLabel = input.channelPlan.options.find((option) =>
option.channelId === input.channelPlan.primaryChannelId
)?.label ?? "current room";
const messages = input.recentMessages.map((message) => ({ label: currentLabel, message }));
for (const snapshot of input.channelSnapshots) {
const label = input.channelPlan.options.find((option) => option.channelId === snapshot.channelId)?.label ??
"nearby room";
messages.push(...snapshot.messages.map((message) => ({ label, message })));
}
return messages;
}
function findMessagesForSocialTarget(
messages: Array<{ label: string; message: SourceMessage }>,
targetLabel: string,
selfName: string,
): Array<{ label: string; message: SourceMessage }> {
const targetToken = normalizeSocialLabel(targetLabel);
const selfToken = normalizeSocialLabel(selfName);
if (!targetToken || targetToken === selfToken) {
return [];
}
return messages
.filter(({ message }) => normalizeSocialLabel(message.authorName) === targetToken)
.slice(-2);
}
function formatSocialOpportunityMessage(entry: { label: string; message: SourceMessage }): string {
const speaker = entry.message.isBot ? `${entry.message.authorName} (agent)` : entry.message.authorName;
return `${entry.label}/${speaker}: "${collapseWhitespace(entry.message.content, 220)}"`;
}
function cleanSocialTargetLabel(value: string | undefined): string {
return collapseWhitespace(value ?? "").replace(/^repo:/i, "").trim();
}
function normalizeSocialLabel(value: string | undefined): string {
return (value ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
}
function projectPrivateNoteForMemorySurface(note: string): string {
return note
.replace(/\bdo not prompt (?:her|him|them|it|[A-Z][A-Za-z0-9_-]*) as\b/gi, "she refuses to be treated as")
.replace(/\bdo not prompt\b/gi, "do not treat")
.replace(/\bprompt (?:her|him|them|it)\b/gi, "treat them")
.replace(/\bprompt [A-Z][A-Za-z0-9_-]*\b/g, "treat them");
}
function cleanCharacterFacingSentence(value: string | undefined): string {
const cleaned = (value ?? "")
.replace(/\s*\|\s*/g, " ")
.replace(/\bFace of\s+[A-Za-z0-9_-]+\b/gi, "")
.replace(/\bgrants:\s*[^.]+/gi, "")
.replace(/\bjurisdictions:\s*[^.]+/gi, "")
.replace(/\brepo=[^\s]+/gi, "")
.replace(/\bpath=[^\s]+/gi, "")
.replace(/\bvoid\.face_[A-Za-z0-9_.-]+/gi, "")
.replace(/\s{2,}/g, " ")
.trim();
return cleaned.replace(/[.;:,]+$/g, "");
}
function asSentence(value: string | undefined): string {
const cleaned = cleanCharacterFacingSentence(value);
if (!cleaned) {
return "";
}
return /[.!?]$/.test(cleaned) ? cleaned : `${cleaned}.`;
}
function joinAsNarrativeList(items: string[]): string {
if (items.length <= 1) {
return items[0] ?? "";
}
if (items.length === 2) {
return `${items[0]}, and ${items[1]}`;
}
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
}
function rejectLeakyMemorySurface(surface: string): string {
const leaks = [
/\bgrants:/i,
/\bjurisdictions:/i,
/\bFace of\s+[A-Z][A-Za-z0-9_-]+\b/,
/\brepo=[^\s]+/i,
/\bpath=[^\s]+/i,
/\bdo not prompt\b/i,
/\bprompt (?:her|him|them|it)\b/i,
];
if (leaks.some((pattern) => pattern.test(surface))) {
throw new Error("Repo Face memory surface leaked schema or prompt-construction language.");
}
return surface;
}
async function projectRepoFaceMemorySurface(input: {
identity: RepoDiscordIdentity;
statePacket: string;
config: ReturnType<typeof loadConfig>;
}): Promise<string> {
const prompt = loadPromptTemplate("repo-face-state-projector.prompt.md", {
characterIdentity: renderRepoCharacterIdentityDoctrine(input.identity),
statePacket: input.statePacket,
});
const output = await runCodexTextProjection({
prompt,
config: input.config,
command: "repo-face-state-projector",
jobId: `state-projector:${input.identity.id}:${Date.now()}`,
timeoutMs: 180_000,
});
const projected = output.trim();
if (projected.length < 80) {
throw new Error(`Repo Face state projector returned too little text for ${input.identity.id}.`);
}
return rejectLeakyMemorySurface(projected);
}
function renderRepoFaceConversationTranscript(input: {
identity: RepoDiscordIdentity;
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
pendingMentions: RepoFacePendingMention[];
channelPlan: RepoFaceChannelPlan;
}): string {
const sections: string[] = [];
sections.push([
"Read this as raw recent message evidence, not as a summary and not as consensus.",
"Messages are ordered oldest to newest inside each section. Newer human corrections can supersede older agent proposals.",
"Do not infer consensus from agents repeating each other. If a human reframes, narrows, or corrects an agent's proposal, account for that correction directly.",
"If you answer the live conversation, default to the current room unless a human explicitly asks to move elsewhere.",
].join("\n"));
if (input.pendingMentions.length > 0) {
sections.push([
"Direct calls:",
...input.pendingMentions.map((mention) =>
`- ${mention.authorName ?? mention.authorId}: ${collapseWhitespace(mention.visiblePrompt, 900)}`,
),
].join("\n"));
}
const currentLabel = input.channelPlan.options.find((option) =>
option.channelId === input.channelPlan.primaryChannelId
)?.label ?? "current room";
sections.push([
`Current room (${currentLabel}), oldest to newest:`,
...formatConversationMessages(input.recentMessages, 15),
].join("\n"));
for (const snapshot of input.channelSnapshots) {
const label = input.channelPlan.options.find((option) => option.channelId === snapshot.channelId)?.label ??
"nearby room";
sections.push([
`Nearby ${label}, oldest to newest:`,
...formatConversationMessages(snapshot.messages, 6),
].join("\n"));
}
return sections.join("\n\n");
}
function renderRepoFaceRepoActivitySurface(
identity: RepoDiscordIdentity,
config: ReturnType<typeof loadConfig>,
): string {
const statePath = resolveRepoFaceStatePath(identity, config.storageRoot);
const result = spawnSync(
process.execPath,
[
resolve("scripts", "export-recent-repo-activity.mjs"),
"--repos",
identity.repoName,
"--state-path",
statePath,
"--read-only",
"--hours",
"96",
"--max-commits",
"5",
],
{
cwd: process.cwd(),
env: process.env,
encoding: "utf8",
windowsHide: true,
timeout: 30_000,
},
);
if (result.status !== 0) {
const detail = `${result.stdout}\n${result.stderr}`.trim().slice(-600);
return [
`- Recent ${identity.repoName} activity could not be read for this turn.`,
detail ? `- Reader error: ${collapseWhitespace(detail, 500)}` : "- Reader error: no diagnostic output.",
"- Do not claim current repo state from stale memory; use source/history tools before making fresh claims.",
].join("\n");
}
try {
const parsed = JSON.parse(result.stdout) as { digest?: unknown };
const digest = typeof parsed.digest === "string" ? parsed.digest.trim() : "";
return digest || `- No recent ${identity.repoName} activity was reported.`;
} catch {
return [
`- Recent ${identity.repoName} activity output was not parseable.`,
`- Raw output: ${collapseWhitespace(result.stdout, 500)}`,
"- Do not claim current repo state from stale memory; use source/history tools before making fresh claims.",
].join("\n");
}
}
function formatConversationMessages(messages: SourceMessage[], limit: number): string[] {
if (messages.length === 0) {
return ["- No recent messages."];
}
return messages.slice(-limit).map((message) => {
const speaker = message.isBot ? `${message.authorName} (agent/bot)` : message.authorName;
return `- ${speaker}: ${collapseWhitespace(message.content, 900)}`;
});
}
function runCodexTextProjection(input: {
prompt: string;
config: ReturnType<typeof loadConfig>;
command: string;
jobId: string;
timeoutMs: number;
}): Promise<string> {
const models = [
...input.config.repoFaceHeartbeats.codexModels,
input.config.repoFaceHeartbeats.codexModel,
input.config.codexModel,
].filter((model, index, all): model is string => Boolean(model) && all.indexOf(model) === index);
return runCodexTextProjectionWithModels({
...input,
models,
attemptedErrors: [],
});
}
function runCodexTextProjectionWithModels(input: {
prompt: string;
config: ReturnType<typeof loadConfig>;
command: string;
jobId: string;
timeoutMs: number;
models: string[];
attemptedErrors: string[];
}): Promise<string> {
return new Promise((resolveProjection, rejectProjection) => {
const startedAt = new Date().toISOString();
const startedMs = Date.now();
const model = input.models[0] ?? input.config.codexModel;
const reasoningEffort = input.config.repoFaceHeartbeats.codexModelReasoningEffort ?? "low";
const args = [
...input.config.codexExecArgs,
"exec",
"-m",
model,
"-c",
'approval_policy="never"',
"-c",
`model_reasoning_effort=${JSON.stringify(reasoningEffort)}`,
"--json",
"--skip-git-repo-check",
"-s",
"read-only",
"-",
];
const child = spawn(input.config.codexExecutable, args, {
cwd: process.cwd(),
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", rejectProjection);
child.stdin.end(input.prompt);
const timer = setTimeout(() => {
child.kill();
}, input.timeoutMs);
child.on("close", (code, signal) => {
clearTimeout(timer);
const finishedAt = new Date().toISOString();
const durationMs = Date.now() - startedMs;
void appendProjectionModelOutputLog({
config: input.config,
jobId: input.jobId,
command: input.command,
model,
prompt: input.prompt,
startedAt,
finishedAt,
durationMs,
exitCode: code,
signal,
stdout,
stderr,
}).catch(() => undefined);
if (code !== 0) {
const diagnostics = `${stdout}\n${stderr}`.trim().slice(-2400);
const attemptedErrors = [
...input.attemptedErrors,
`${model}: ${code ?? signal ?? "unknown"} ${diagnostics}`,
];
if (input.models.length > 1 && isRetryableProjectionModelFailure({ stdout, stderr })) {
runCodexTextProjectionWithModels({
...input,
models: input.models.slice(1),
attemptedErrors,
}).then(resolveProjection, rejectProjection);
return;
}
rejectProjection(new Error(`Repo Face ${input.command} failed: ${attemptedErrors.join("\n---\n")}`));
return;
}
const text = extractLastCodexAgentMessage(stdout).trim();
if (!text) {
rejectProjection(new Error("Repo Face state projector returned no visible agent message."));
return;
}
resolveProjection(text);
});
});
}
function isRetryableProjectionModelFailure(input: { stdout: string; stderr: string }): boolean {
const text = `${input.stdout}\n${input.stderr}`.toLowerCase();
return /quota|rate limit|rate-limit|usage limit|capacity|too many requests|(?:http|status|code|error)\s*429|429\s*(?:too many requests|rate)|insufficient_quota|model.*unavailable|model.*access|limit exceeded/.test(text);
}
async function appendProjectionModelOutputLog(input: {
config: ReturnType<typeof loadConfig>;
jobId: string;
command: string;
model: string;
prompt: string;
startedAt: string;
finishedAt: string;
durationMs: number;
exitCode: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}): Promise<void> {
const logPath = resolve(input.config.storageRoot, "logs", "model-outputs.jsonl");
const finalMessage = extractLastCodexAgentMessage(input.stdout).trim() || null;
const record = {
schemaVersion: 1,
loggedAt: new Date().toISOString(),
jobId: input.jobId,
command: input.command,
turn: 1,
model: input.model,
promptMarker: input.prompt.match(/<!--\s*prompt:([^>\s]+)\s*-->/)?.[1] ?? null,
promptLength: input.prompt.length,
startedAt: input.startedAt,
finishedAt: input.finishedAt,
durationMs: input.durationMs,
exitCode: input.exitCode,
signal: input.signal,
timedOut: input.signal === "SIGTERM",
handoffReason: null,
usage: null,
finalMessage,
stdoutTail: input.stdout.slice(-4000),
stderrTail: input.stderr.slice(-4000),
toolCalls: [],
commandExecutions: [],
artifactRefs: {},
};
await mkdir(dirname(logPath), { recursive: true });
await appendFile(logPath, `${JSON.stringify(record)}\n`, "utf8");
}
function extractLastCodexAgentMessage(stdout: string): string {
const messages = stdout
.split(/\r?\n/)
.filter((line) => line.trim().length > 0)
.map((line) => {
try {
return JSON.parse(line) as { type?: string; item?: { type?: string; text?: string } };
} catch {
return undefined;
}
})
.filter((event): event is { type?: string; item?: { type?: string; text?: string } } => Boolean(event))
.filter((event) => event.type === "item.completed" && event.item?.type === "agent_message")
.map((event) => event.item?.text?.trim() ?? "")
.filter((message) => message.length > 0);
return messages.at(-1) ?? stdout.trim();
}
async function readOptionalMemorySurface(path: string | undefined): Promise<string | undefined> {
if (!path) {
return undefined;
}
const content = (await readFile(resolve(path), "utf8")).trim();
return content.length > 0 ? content : undefined;
}
function buildInspectionParticipant(
identity: RepoDiscordIdentity,
baseRecoveryMinutes: number,
): FaceHeartbeatParticipant {
return {
identityId: identity.id,
participantKind: "repo_face",
turnKind: "repo_face_rumination",
repoName: identity.repoName,
displayName: identity.displayName,
initiativeSpeed: 1,
reactionBias: 0.5,
interruptThreshold: 0.6,
currentLoad: 0,
status: "active",
groups: [
"all",
"kind:repo_face",
"turn:repo_face_rumination",
`identity:${normalizeKey(identity.id)}`,
`repo:${normalizeKey(identity.repoName)}`,
],
heat: 1,
effectiveSpeed: 1,
baseRecoveryMinutes,
nextTurnAt: 0,
queuedCount: 0,
constraints: [
"Prompt assembly is deterministic inspection only.",
"Character memory and affect prose must come from the Interpreter memory surface.",
],
};
}
function buildChannelPlan(
identity: RepoDiscordIdentity,
defaultChannelId?: string,
): RepoFaceChannelPlan {
const explicit = identity.channelPermissions.map((permission): RepoFaceChannelOption => ({
channelId: permission.channelId,
label: permission.label ?? permission.channelId,
topic: permission.topic ?? "general",
speechThreshold: permission.speechThreshold,
speedMultiplier: permission.speedMultiplier,
posture: permission.posture,
}));
const explicitChannelIds = new Set(explicit.map((permission) => permission.channelId));
const legacy = identity.allowedChannelIds
.filter((channelId) => !explicitChannelIds.has(channelId))
.map((channelId): RepoFaceChannelOption => ({
channelId,
label: channelId === defaultChannelId ? "default" : channelId,
topic: channelId === defaultChannelId ? "casual Aquarium musing" : "registered channel",
speechThreshold: channelId === defaultChannelId ? "very_low" : "medium",
speedMultiplier: channelId === defaultChannelId ? 1.5 : 1,
posture: channelId === defaultChannelId
? "Low-stakes casual chatter, half-formed fascinations, jokes, little observations, and friendly asides are welcome here."
: undefined,
}));
const fallback = explicit.length === 0 && legacy.length === 0 && defaultChannelId
? [{
channelId: defaultChannelId,
label: "aquarium",
topic: "casual Aquarium musing",
speechThreshold: "very_low" as const,
speedMultiplier: 1.5,
posture: "Low-stakes casual chatter, half-formed fascinations, jokes, little observations, and friendly asides are welcome here.",
}]
: [];
const options = [...explicit, ...legacy, ...fallback];
const primary = options
.slice()
.sort((left, right) => thresholdRank(left.speechThreshold) - thresholdRank(right.speechThreshold))
[0];
return {
primaryChannelId: primary?.channelId,
snapshotChannelIds: options.map((option) => option.channelId),
options,
lowThresholdTopics: options
.filter((option) => thresholdRank(option.speechThreshold) <= thresholdRank("low"))
.map((option) => option.topic),
};
}
function renderChannelPermissionDirective(plan: RepoFaceChannelPlan): string {
const options = plan.options.length > 0
? plan.options.map((option) =>
`${option.label}: ${option.topic}. ${option.posture ?? "Use judgment and keep it compact."}`,
)
: ["- No channel permissions are configured; stay private."];
return loadPromptTemplate("repo-face-channel-permissions.prompt.md", {
options,
});
}
function renderResearchCapabilitiesDirective(identity: RepoDiscordIdentity): string {
return loadPromptTemplate("repo-face-research-capabilities.prompt.md", {
repoName: identity.repoName,
});
}
function renderTurnSituationDirective(input: {
identity: RepoDiscordIdentity;
participant: FaceHeartbeatParticipant;
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
pendingMentions: RepoFacePendingMention[];
}): string {
const lines: string[] = [];
if (input.pendingMentions.length > 0) {
lines.push(
"A direct call is tugging at you. Answer the newest unresolved call first; if it belongs to another steward, name that owner and offer only the piece your own territory can honestly add.",
"Do not ask what the job is when the direct call or current room memory already states it.",
);
}
const visibleMessages = [
...input.recentMessages,
...input.channelSnapshots.flatMap((snapshot) => snapshot.messages),
];
if (shouldPromptIntroduction(input.identity, input.participant, visibleMessages)) {
lines.push("If you speak publicly, make it a brief natural introduction in your own voice before asking the room for anything.");
}
return lines.length > 0 ? lines.join("\n") : "";
}
function shouldPromptIntroduction(
identity: RepoDiscordIdentity,
participant: FaceHeartbeatParticipant,
messages: SourceMessage[],
): boolean {
if (participant.queuedCount > 0) {
return false;
}
return !messages.some((message) =>
message.isBot === true &&
normalizeKey(message.authorName ?? message.authorId) === normalizeKey(identity.displayName),
);
}
function renderSocialEmbodimentDirective(identity: RepoDiscordIdentity): string {
return loadPromptTemplate("repo-face-social-embodiment.prompt.md", {
displayName: identity.displayName,
});
}
function renderJurisdictionRespectDirective(identity: RepoDiscordIdentity): string {
return loadPromptTemplate("repo-face-jurisdiction-respect.prompt.md", {
displayName: identity.displayName,
});
}
function renderComedyImprovDirective(identity: RepoDiscordIdentity): string {
return loadPromptTemplate("repo-face-comedy-improv.prompt.md", {
displayName: identity.displayName,
});
}
function renderRepetitionSamplingDirective(messages: SourceMessage[]): string {
const recent = messages
.filter((message) => message.content.trim().length > 0)
.slice(-24);
const phraseCounts = countRepeatedPhrases(recent);
const overused = phraseCounts
.filter((entry) => entry.count >= 2)
.slice(0, 8);
return loadPromptTemplate("repo-face-repetition-sampling.prompt.md", {
overused: overused.map((entry) => `${entry.phrase} (${entry.count} recent uses)`),
});
}
function countRepeatedPhrases(messages: SourceMessage[]): Array<{ phrase: string; count: number }> {
const counts = new Map<string, number>();
for (const message of messages) {
const normalized = normalizeForRepetition(message.content);
for (const phrase of repeatedPhraseCandidates(normalized)) {
counts.set(phrase, (counts.get(phrase) ?? 0) + 1);
}
}
return Array.from(counts.entries())
.map(([phrase, count]) => ({ phrase, count }))
.filter((entry) => entry.phrase.length >= 8)
.sort((left, right) => {
if (right.count !== left.count) {
return right.count - left.count;
}
return left.phrase.localeCompare(right.phrase);
});
}
function repeatedPhraseCandidates(content: string): string[] {
const candidates = new Set<string>();
const lines = content
.split(/\n+/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
for (const line of lines) {
const words = line.split(/\s+/);
if (words.length >= 3) {
candidates.add(words.slice(0, Math.min(words.length, 4)).join(" "));
}
if (words.length >= 4) {
candidates.add(words.slice(-Math.min(words.length, 4)).join(" "));
}
}
return Array.from(candidates);
}
function normalizeForRepetition(value: string): string {
return collapseWhitespace(value)
.toLowerCase()
.replace(/[`*_~]/g, "")
.replace(/<:[^>]+>/g, "")
.replace(/https?:\/\/\S+/g, "url")
.replace(/[^\p{L}\p{N}\s.'-]/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function thresholdRank(threshold: RepoFaceChannelOption["speechThreshold"]): number {
switch (threshold) {
case "very_low":
return 0;
case "low":
return 1;
case "medium":
return 2;
case "high":
return 3;
}
}
function collapseWhitespace(value: string, maxLength?: number): string {
const normalized = value.replace(/\s+/g, " ").trim();
return maxLength && normalized.length > maxLength
? `${normalized.slice(0, maxLength - 3)}...`
: normalized;
}
function renderBifrostGovernanceDigestDirective(
digest: BifrostGovernanceDigest | undefined,
): string {
if (!digest) {
return "Work routing is currently offline. Do not open governance topics or dispatch work this turn; if an idea wants action, discuss it in the room or save the pressure in memory.";
}
if (digest.topics.length === 0) {
return loadPromptTemplate("repo-face-bifrost-digest.prompt.md", {
topics: [],
});
}
const lines: string[] = [];
for (const topic of digest.topics) {
lines.push(
`- ${topic.title}: ${topic.status}.`,
` Jurisdiction: ${topic.jurisdictionRepoName}${topic.approvedByAgent ? `; approved by ${topic.approvedByAgent}` : ""}${topic.dispatchRequestId ? "; already dispatched" : ""}.`,
` ${collapseWhitespace(topic.summaryMarkdown, 320)}`,
);
for (const comment of (topic.comments ?? []).slice(-3)) {
lines.push(` - ${comment.stance}: ${collapseWhitespace(comment.bodyMarkdown, 220)}`);
}
}
return loadPromptTemplate("repo-face-bifrost-digest.prompt.md", {
topics: lines,
});
}
function renderPendingMentionDirective(
identity: RepoDiscordIdentity,
pendingMentions: RepoFacePendingMention[],
): string {
if (pendingMentions.length === 0) {
return loadPromptTemplate("repo-face-pending-mentions.prompt.md", {
mentions: [],
});
}
const mentionLines = pendingMentions.map((mention, index) =>
`- ${index === pendingMentions.length - 1 ? "Newest" : "Earlier"}: ${mention.authorName ?? mention.authorId} said, "${collapseWhitespace(mention.visiblePrompt, 500)}"`,
);
return loadPromptTemplate("repo-face-pending-mentions.prompt.md", {
displayName: identity.displayName,
mentions: mentionLines,
});
}
function renderWorldbuildingPublicationDirective(identity: RepoDiscordIdentity): string {
const isNibu = identity.id.toLowerCase() === "nibu";
return loadPromptTemplate("repo-face-worldbuilding-publication.prompt.md", {
nibu: isNibu,
});
}
interface JurisdictionDiveDirective {
due: boolean;
cadence: number;
promptLine: string;
}
function buildJurisdictionDiveDirective(
identity: RepoDiscordIdentity,
participant: FaceHeartbeatParticipant,
): JurisdictionDiveDirective {
const isNibu = identity.id.toLowerCase() === "nibu";
const cadence = isNibu ? 3 : 8;
const due = participant.queuedCount === 0 || participant.queuedCount % cadence === 0;
return {
due,
cadence,
promptLine: loadPromptTemplate("repo-face-jurisdiction-dive.prompt.md", {
due,
nibu: isNibu,
repoName: identity.repoName,
}),
};
}
function renderRepoCharacterIdentityDoctrine(identity: RepoDiscordIdentity): string {
const face = buildEpiphanyIdentityRegistry({ identities: [identity] }).faces[0];
return loadPromptTemplate("repo-character-identity.prompt.md", {
displayName: identity.displayName,
repoName: identity.repoName,
originName: face?.epiphanyDisplayName ?? identity.repoName,
characterDescription: projectCharacterDescription(face?.description ?? identity.description),
});
}
function projectCharacterDescription(description: string | undefined): string | undefined {
const trimmed = description?.trim();
if (!trimmed) {
return undefined;
}
return trimmed
.split("|")
.map((part) => part.trim())
.filter((part) => part.length > 0)
.filter((part) => !/^face of\b/i.test(part))
.filter((part) => !/^grants:/i.test(part))
.filter((part) => !/^jurisdictions:/i.test(part))
.map((part) => part
.replace(/\bmore opinionated and abrasive than Void because she is a character, not the room moderator\b/gi, "more opinionated and abrasive than a room moderator")
.replace(/\bShe is much more opinionated and abrasive than Void because she is a character, not the room moderator:/gi, "She is much more opinionated and abrasive than a room moderator:")
.replace(/\bthan Void\b/g, "than a moderator")
.replace(/\bcharacter Face\b/g, "character")
.replace(/\bFace\b/g, "personality")
.replace(/\brepo=AetheriaLore path=[^\s]+/g, "")
.replace(/\s{2,}/g, " ")
.trim(),
)
.filter((part) => part.length > 0)
.join(" ");
}
function recoveryFor(participant: FaceHeartbeatParticipant): number {
const loadPenalty = 1 + participant.currentLoad * 0.75;
return (participant.baseRecoveryMinutes * loadPenalty) / Math.max(participant.effectiveSpeed, 0.1);
}
async function readHeartbeatState(path: string): Promise<FaceHeartbeatState> {
try {
const raw = await readFile(path, "utf8");
const parsed = JSON.parse(stripLeadingBom(raw)) as Partial<FaceHeartbeatState> & {
baseIntervalMinutes?: number;
participants?: Array<Partial<FaceHeartbeatParticipant> & { nextReadyAt?: string }>;
};
if (parsed.schemaVersion === HEARTBEAT_SCHEMA_VERSION) {
return {
schemaVersion: HEARTBEAT_SCHEMA_VERSION,
initiativeClock: Number.isFinite(parsed.initiativeClock) ? parsed.initiativeClock : 0,
baseRecoveryMinutes: Number.isFinite(parsed.baseRecoveryMinutes) ? parsed.baseRecoveryMinutes : 10,
globalHeat: Number.isFinite(parsed.globalHeat) ? parsed.globalHeat : 1,
lastTickAt: parsed.lastTickAt,
participants: Array.isArray(parsed.participants) ? parsed.participants as FaceHeartbeatParticipant[] : [],
history: Array.isArray(parsed.history) ? parsed.history : [],
pendingMentions: Array.isArray(parsed.pendingMentions)
? parsed.pendingMentions.filter(isRepoFacePendingMention)
: [],
};
}
if (Array.isArray(parsed.participants)) {
return migrateLegacyHeartbeatState(parsed);
}
} catch {
// fall through to a new state
}
return {
schemaVersion: HEARTBEAT_SCHEMA_VERSION,
initiativeClock: 0,
baseRecoveryMinutes: 10,
globalHeat: 1,
participants: [],
history: [],
pendingMentions: [],
};
}
async function writeHeartbeatState(path: string, state: FaceHeartbeatState): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
}
function migrateLegacyHeartbeatState(
parsed: Partial<FaceHeartbeatState> & {
baseIntervalMinutes?: number;
participants?: Array<Partial<FaceHeartbeatParticipant> & { nextReadyAt?: string }>;
},
): FaceHeartbeatState {
const nowMs = Date.now();
const legacyBase = Number.isFinite(parsed.baseIntervalMinutes) ? parsed.baseIntervalMinutes : 30;
const baseRecoveryMinutes = Math.max(5, legacyBase / 3);
const participants = (parsed.participants ?? []).map((participant, index) => {
const speed = Number.isFinite(participant.initiativeSpeed) ? participant.initiativeSpeed : 1;
const legacyReadyMs = participant.nextReadyAt ? Date.parse(participant.nextReadyAt) : NaN;
const minutesUntilReady = Number.isFinite(legacyReadyMs)
? Math.max(0, (legacyReadyMs - nowMs) / 60_000)
: index * baseRecoveryMinutes;
return {
identityId: participant.identityId ?? `legacy-face-${index + 1}`,
repoName: participant.repoName ?? "unknown",
displayName: participant.displayName ?? participant.identityId ?? `Legacy Face ${index + 1}`,
initiativeSpeed: speed,
reactionBias: Number.isFinite(participant.reactionBias) ? participant.reactionBias : 0.4,
interruptThreshold: Number.isFinite(participant.interruptThreshold) ? participant.interruptThreshold : 0.6,
currentLoad: Number.isFinite(participant.currentLoad) ? participant.currentLoad : 0,
status: participant.status ?? "active",
groups: participant.groups ?? [],
heat: Number.isFinite(participant.heat) ? participant.heat : 1,
effectiveSpeed: Number.isFinite(participant.effectiveSpeed) ? participant.effectiveSpeed : speed,
baseRecoveryMinutes,
nextTurnAt: minutesUntilReady,
lastTurnAt: participant.lastTurnAt,
activeTurnStartedAt: participant.activeTurnStartedAt,
activeJobId: participant.activeJobId,
lastQueuedAt: participant.lastQueuedAt,
queuedCount: Number.isFinite(participant.queuedCount) ? participant.queuedCount : 0,
constraints: participant.constraints ?? [
"Migrated from wall-clock repo Face turn state.",
],
} satisfies FaceHeartbeatParticipant;
});
return {
schemaVersion: HEARTBEAT_SCHEMA_VERSION,
initiativeClock: 0,
baseRecoveryMinutes,
globalHeat: 1,
lastTickAt: parsed.lastTickAt,
participants,
pendingMentions: Array.isArray(parsed.pendingMentions)
? parsed.pendingMentions.filter(isRepoFacePendingMention)
: [],
history: [
...(Array.isArray(parsed.history) ? parsed.history : []),
{
type: "migrated",
fromSchemaVersion: parsed.schemaVersion ?? "unknown",
migratedAt: new Date().toISOString(),
participantCount: participants.length,
},
].slice(-80),
};
}
function isRepoFacePendingMention(value: unknown): value is RepoFacePendingMention {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return (
typeof record.id === "string" &&
typeof record.identityId === "string" &&
typeof record.channelId === "string" &&
typeof record.messageId === "string" &&
typeof record.authorId === "string" &&
typeof record.content === "string" &&
typeof record.visiblePrompt === "string" &&
typeof record.queuedAt === "string"
);
}
function initiativeSpeedFor(
spec: ParticipantSpec,
speedOverrides: Record<string, number>,
): number {
const override = speedOverrides[spec.id.toLowerCase()];
if (override !== undefined) {
return clamp(override, 0.35, 6);
}
if (spec.id === "void") {
return 1;
}
return clamp(0.85 + stableUnit(spec.id, "speed") * 0.45, 0.75, 1.3);
}
function channelSpeedMultiplierFor(identity: RepoDiscordIdentity): number {
const multipliers = identity.channelPermissions.map((permission) => permission.speedMultiplier);
return multipliers.length > 0 ? clamp(Math.max(...multipliers), 0.5, 3) : 1;
}
function initiativeGroupsFor(spec: ParticipantSpec): string[] {
return Array.from(new Set([
"all",
`kind:${spec.participantKind}`,
`turn:${spec.turnKind}`,
`identity:${normalizeKey(spec.id)}`,
`repo:${normalizeKey(spec.repoName)}`,
`display:${normalizeKey(spec.displayName)}`,
...spec.allowedChannelIds.map((channelId) => `channel:${channelId}`),
]));
}
function heatFor(
spec: ParticipantSpec,
groups: string[],
globalHeat: number,
heatOverrides: Record<string, number>,
): number {
const keys = [
"all",
...groups,
normalizeKey(spec.id),
normalizeKey(spec.repoName),
normalizeKey(spec.displayName),
];
return clamp(
keys.reduce((heat, key) => heat * (heatOverrides[key] ?? 1), globalHeat),
0.05,
20,
);
}
function reactionBiasFor(spec: ParticipantSpec): number {
return spec.id === "void"
? 0.55
: clamp(0.2 + stableUnit(spec.id, "reaction") * 0.55, 0.2, 0.75);
}
function interruptThresholdFor(spec: ParticipantSpec): number {
return spec.id === "void"
? 0.5
: clamp(0.45 + stableUnit(spec.id, "threshold") * 0.35, 0.45, 0.8);
}
function stableUnit(id: string, salt: string): number {
const hex = createHash("sha1").update(`${id}:${salt}`).digest("hex").slice(0, 8);
return Number.parseInt(hex, 16) / 0xffffffff;
}
function mergeStrings(values: string[], value: string): string[] {
return Array.from(new Set([...values, value]));
}
function normalizeKey(value: string): string {
return value.trim().toLowerCase();
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Number(value.toFixed(3))));
}
function round3(value: number): number {
return Math.round(value * 1000) / 1000;
}
function stripLeadingBom(input: string): string {
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
}
function readArgValue(name: string): string | undefined {
const index = process.argv.indexOf(name);
if (index < 0) {
return undefined;
}
const value = process.argv[index + 1];
return value && !value.startsWith("--") ? value : undefined;
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});