3408 lines
117 KiB
TypeScript
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;
|
|
});
|