diff --git a/apps/persona-scheduler/src/index.ts b/apps/persona-scheduler/src/index.ts index 7a77936..6c35b5e 100644 --- a/apps/persona-scheduler/src/index.ts +++ b/apps/persona-scheduler/src/index.ts @@ -1,14 +1,10 @@ import "dotenv/config"; -import { spawn } from "node:child_process"; -import { resolve } from "node:path"; - import { loadConfig } from "@voidbot/config"; +import { runPersonaSchedulerTick } from "./persona-scheduler-runner.js"; const config = loadConfig(); const intervalMs = Math.max(1, config.repoFaceHeartbeats.intervalMinutes) * 60_000; -const turnEnginePath = resolve(process.cwd(), "scripts", "run-repo-face-heartbeats.ts"); - let stopping = false; let activeTick: Promise | undefined; let timer: NodeJS.Timeout | undefined; @@ -18,46 +14,17 @@ function runTick(): Promise { console.warn("Persona scheduler skipped a pulse because the previous turn-selection tick is still active."); return activeTick; } - - activeTick = new Promise((done) => { - const startedAt = new Date().toISOString(); - const child = spawn(process.execPath, [turnEnginePath], { - cwd: process.cwd(), - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { stdout += chunk; }); - child.stderr.on("data", (chunk: string) => { stderr += chunk; }); - child.once("error", (error) => { - console.error(`Persona scheduler tick failed to start: ${error.message}`); - }); - child.once("close", (code, signal) => { - const output = stdout.trim(); - if (code === 0) { - console.log(`Persona scheduler tick completed (started ${startedAt}).${output ? ` ${output}` : ""}`); - } else { - console.error( - `Persona scheduler tick failed (started ${startedAt}, code ${String(code)}, signal ${String(signal)}).${stderr.trim() ? ` ${stderr.trim()}` : ""}`, - ); - } - done(); - }); - }).finally(() => { - activeTick = undefined; - }); + const startedAt = new Date().toISOString(); + activeTick = runPersonaSchedulerTick({ config }) + .then((result) => { console.log(`Persona scheduler tick completed (started ${startedAt}). ${JSON.stringify(result)}`); }) + .catch((error) => { console.error(`Persona scheduler tick failed (started ${startedAt}). ${error instanceof Error ? error.stack ?? error.message : String(error)}`); }) + .finally(() => { activeTick = undefined; }); return activeTick; } function scheduleNext(): void { if (stopping) return; - timer = setTimeout(async () => { - await runTick(); - scheduleNext(); - }, intervalMs); + timer = setTimeout(async () => { await runTick(); scheduleNext(); }, intervalMs); } async function shutdown(signal: string): Promise { @@ -70,6 +37,5 @@ async function shutdown(signal: string): Promise { process.once("SIGINT", () => { void shutdown("SIGINT"); }); process.once("SIGTERM", () => { void shutdown("SIGTERM"); }); - -console.log(`Persona scheduler owns the resident ${config.repoFaceHeartbeats.intervalMinutes}-minute turn pulse.`); +console.log(`Persona scheduler owns the resident ${config.repoFaceHeartbeats.intervalMinutes}-minute observation pulse.`); void runTick().then(scheduleNext); diff --git a/apps/persona-scheduler/src/initiative-engine.ts b/apps/persona-scheduler/src/initiative-engine.ts index 224b9f6..23f71a7 100644 --- a/apps/persona-scheduler/src/initiative-engine.ts +++ b/apps/persona-scheduler/src/initiative-engine.ts @@ -383,7 +383,7 @@ export function recordStaleActiveTurnRecoveries(input: { jobId: string; requestMessageId?: string; state: string; - updatedAt: string; + updatedAt?: string; ageMinutes: number; }>; }): void { diff --git a/apps/persona-scheduler/src/persona-participant-dispatcher.ts b/apps/persona-scheduler/src/persona-participant-dispatcher.ts new file mode 100644 index 0000000..eda5fc5 --- /dev/null +++ b/apps/persona-scheduler/src/persona-participant-dispatcher.ts @@ -0,0 +1,104 @@ +import type { loadConfig } from "@voidbot/config"; +import { + createStateStorage, + getRepoDiscordIdentityAllowedChannelIds, + type RepoDiscordIdentity, + type RepoFacePendingMention, +} from "@voidbot/core"; + +import type { InitiativeParticipant, ParticipantSpec as SchedulerParticipantSpec } from "./initiative-engine.js"; +import type { PersonaStateObservation } from "./persona-state-source.js"; +import { fetchChannelSnapshots, fetchRecentDiscordMessages } from "./turn-context-source.js"; +import { readBifrostGovernanceDigest } from "./bifrost-governance-source.js"; +import { coordinatePersonaMemoryTurn } from "./persona-memory-turn-coordinator.js"; +import { readStoredPersonaHumanPronounGuidance } from "./persona-social-context-source.js"; +import { projectNativePersonaBody } from "./persona-standard-state-projector.js"; +import { readGlobalAgentDoctrine } from "./global-agent-doctrine-source.js"; +import { assemblePersonaTurn } from "./persona-turn-assembler.js"; +import { submitPersonaTurn } from "./turn-actuator.js"; +import { launchVoidModerationTurn } from "./void-moderation-turn-actuator.js"; +import { readRepoActivity } from "./repo-activity-source.js"; +import { projectRepoActivityObservation } from "./repo-activity-projector.js"; +import { buildPersonaChannelPlan, newestPendingMentionChannel, personaChannelSpeedMultiplier } from "./turn-routing.js"; + +export interface PersonaParticipantSpec extends SchedulerParticipantSpec { + identity?: RepoDiscordIdentity; +} + +export interface PersonaTurnStartReceipt { + created: boolean; + activeJobId?: string; + requestMessageId?: string; + failureReason?: string; +} + +export function buildPersonaParticipantSpecs(identities: RepoDiscordIdentity[], includeVoid: boolean): PersonaParticipantSpec[] { + return [ + ...(includeVoid ? [{ id: "void", participantKind: "system_agent" as const, turnKind: "void_moderation" as const, repoName: "VoidBot", displayName: "Void", allowedChannelIds: [], channelSpeedMultiplier: 1 }] : []), + ...identities.map((identity) => ({ + id: identity.id, + participantKind: identity.identityKind === "native_persona" ? "native_persona" as const : "repo_face" as const, + turnKind: "repo_face_rumination" as const, + repoName: identity.repoName, + displayName: identity.displayName, + allowedChannelIds: getRepoDiscordIdentityAllowedChannelIds(identity), + channelSpeedMultiplier: personaChannelSpeedMultiplier(identity), + identity, + })), + ]; +} + +export function pendingMentionsForPersona(state: { pendingMentions: RepoFacePendingMention[] }, identityId: string): RepoFacePendingMention[] { + return state.pendingMentions.filter((mention) => mention.identityId === identityId).sort((left, right) => Date.parse(left.queuedAt) - Date.parse(right.queuedAt)); +} + +export function renderPersonaInitiativeAffinity(spec: PersonaParticipantSpec): string { + return [ + `${spec.displayName} is the Persona steward of ${spec.repoName}.`, + spec.identity?.description, + ...(spec.identity?.channelPermissions ?? []).flatMap((permission) => [permission.topic, permission.label, permission.posture]), + ].filter((value): value is string => typeof value === "string" && value.trim().length > 0).join("\n"); +} + +export async function dispatchPersonaParticipantTurn(input: { + participant: InitiativeParticipant; + pendingMentions: RepoFacePendingMention[]; + registryIdentities: RepoDiscordIdentity[]; + config: ReturnType; + storage: Awaited>; + queuedAt: string; + personaStateObservation?: PersonaStateObservation; +}): Promise { + if (input.participant.turnKind === "void_moderation") return launchVoidModerationTurn({ queuedAt: input.queuedAt, storageRoot: input.config.storageRoot, pendingMentions: input.pendingMentions }); + const identity = input.registryIdentities.find((entry) => entry.id === input.participant.identityId); + if (!identity) return { created: false, failureReason: `No registered Persona identity exists for ${input.participant.identityId}.` }; + const channelPlan = buildPersonaChannelPlan(identity, input.config.repoFaceHeartbeats.defaultChannelId, newestPendingMentionChannel(input.pendingMentions)); + const channelId = channelPlan.primaryChannelId; + if (!channelId) return { created: false, failureReason: "No CTB turn channel is configured for this Persona." }; + 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 && identity.identityKind !== "native_persona" + ? readBifrostGovernanceDigest({ bifrostRoot: input.config.bifrostRoot, repoName: identity.repoName, agentIdentity: identity.id }) + : Promise.resolve(undefined), + ]); + const roomContext = { recentMessages, channelSnapshots }; + const humanPronounGuidance = await readStoredPersonaHumanPronounGuidance({ config: input.config, ...roomContext }); + const memoryContext = await coordinatePersonaMemoryTurn({ identity, config: input.config, registryIdentities: input.registryIdentities, ...roomContext, humanPronounGuidance, stateObservation: input.personaStateObservation }); + const repoActivitySurface = identity.identityKind === "native_persona" + ? projectNativePersonaBody(identity) + : projectRepoActivityObservation(readRepoActivity({ identity, storageRoot: input.config.storageRoot })); + const globalAgentDoctrine = await readGlobalAgentDoctrine({ codexHome: process.env.CODEX_HOME, userProfile: process.env.USERPROFILE }); + const assembly = assemblePersonaTurn({ + identity, channelId, channelPlan, recentMessages, channelSnapshots, participant: input.participant, + pendingMentions: input.pendingMentions, memorySurface: memoryContext.memorySurface, + semanticMemoryRecallSurface: memoryContext.semanticMemoryRecallSurface, repoActivitySurface, + humanPronounGuidance, bifrostDigest, githubActionsEnabled: input.config.repoFaceGithubActionsEnabled, globalAgentDoctrine, + }); + return submitPersonaTurn({ + jobQueue: input.storage.jobQueue, provider: input.config.repoFaceHeartbeats.provider, identityId: identity.id, + queuedAt: input.queuedAt, channelId, prompt: assembly.prompt, recentMessages, + conversationFocus: assembly.conversation.focus, conversationThreads: assembly.conversation.threads, + imageAttachments: assembly.imageAttachments, + }); +} diff --git a/apps/persona-scheduler/src/persona-prompt-inspection.ts b/apps/persona-scheduler/src/persona-prompt-inspection.ts new file mode 100644 index 0000000..638a973 --- /dev/null +++ b/apps/persona-scheduler/src/persona-prompt-inspection.ts @@ -0,0 +1,71 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import type { loadConfig } from "@voidbot/config"; +import { faceRegistryAsRepoDiscordRegistry, loadFaceIdentityRegistry } from "@voidbot/core"; +import { readBifrostGovernanceDigest } from "./bifrost-governance-source.js"; +import { readGlobalAgentDoctrine } from "./global-agent-doctrine-source.js"; +import { buildInspectionParticipant } from "./inspection-participant-factory.js"; +import { coordinatePersonaMemoryTurn } from "./persona-memory-turn-coordinator.js"; +import { readPersonaStateObservation } from "./persona-state-source.js"; +import { readStoredPersonaHumanPronounGuidance } from "./persona-social-context-source.js"; +import { assemblePersonaTurn } from "./persona-turn-assembler.js"; +import { fetchChannelSnapshots, fetchRecentDiscordMessages } from "./turn-context-source.js"; +import { buildPersonaChannelPlan } from "./turn-routing.js"; +import { projectRepoActivityObservation } from "./repo-activity-projector.js"; +import { readRepoActivity } from "./repo-activity-source.js"; + +export async function inspectPersonaTurnPrompt(input: { + config: ReturnType; + identityId: string; + outPath?: string; + memorySurfacePath?: string; + conversationSurfacePath?: string; +}): Promise<{ ok: true; identityId: string; promptLength: number; outPath?: string; memorySurfacePath?: string; conversationSurfacePath?: string }> { + const registry = faceRegistryAsRepoDiscordRegistry(await loadFaceIdentityRegistry(input.config.repoDiscordIdentitiesPath)); + 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 = buildPersonaChannelPlan(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 ? readBifrostGovernanceDigest({ bifrostRoot: input.config.bifrostRoot, repoName: identity.repoName, agentIdentity: identity.id }) : Promise.resolve(undefined), + ]); + const roomContext = { recentMessages, channelSnapshots }; + const humanPronounGuidance = await readStoredPersonaHumanPronounGuidance({ config: input.config, ...roomContext }); + const stateObservation = await readPersonaStateObservation({ identity, storageRoot: input.config.storageRoot }); + const memoryContext = await coordinatePersonaMemoryTurn({ + identity, config: input.config, registryIdentities: registry.identities, ...roomContext, + humanPronounGuidance, stateObservation, + projectedMemoryOverride: await readOptionalSurface(input.memorySurfacePath), + }); + const assembly = assemblePersonaTurn({ + identity, channelId, channelPlan, channelSnapshots, recentMessages, + memorySurface: memoryContext.memorySurface, + semanticMemoryRecallSurface: memoryContext.semanticMemoryRecallSurface, + repoActivitySurface: projectRepoActivityObservation(readRepoActivity({ identity, storageRoot: input.config.storageRoot })), + conversationMemorySurface: await readOptionalSurface(input.conversationSurfacePath), + humanPronounGuidance, bifrostDigest, + participant: buildInspectionParticipant(identity, input.config.repoFaceHeartbeats.baseRecoveryMinutes), + pendingMentions: [], githubActionsEnabled: input.config.repoFaceGithubActionsEnabled, + globalAgentDoctrine: await readGlobalAgentDoctrine({ codexHome: process.env.CODEX_HOME, userProfile: process.env.USERPROFILE }), + }); + if (input.outPath) { + const outPath = resolve(input.outPath); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, assembly.prompt, "utf8"); + } + return { + ok: true, identityId: identity.id, promptLength: assembly.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 readOptionalSurface(path: string | undefined): Promise { + if (!path) return undefined; + const content = (await readFile(resolve(path), "utf8")).trim(); + return content || undefined; +} diff --git a/apps/persona-scheduler/src/persona-scheduler-runner.ts b/apps/persona-scheduler/src/persona-scheduler-runner.ts new file mode 100644 index 0000000..0f89212 --- /dev/null +++ b/apps/persona-scheduler/src/persona-scheduler-runner.ts @@ -0,0 +1,132 @@ +import type { loadConfig } from "@voidbot/config"; +import { createStateStorage, faceRegistryAsRepoDiscordRegistry, getRepoDiscordIdentityAllowedChannelIds, loadFaceIdentityRegistry } from "@voidbot/core"; + +import { scanActivePersonaTurns } from "./active-turn-source.js"; +import { readAgentSwarmPause, readSwarmControlState } from "./control-source.js"; +import { readDiscordActivitySnapshot } from "./discord-activity-source.js"; +import { + advanceInitiativeClockFromWallClock, applyPendingMentionPriority, applySchedulerControls, + applySemanticPressureProjection, consumePendingMentions, finalizeSchedulerTick, + reconcileInitiativeParticipants, recordDryRunSelection, recordSchedulerSkip, + recordStaleActiveTurnRecoveries, recordTurnFailedToStart, recordTurnStarted, + rescheduleStaleOverdueParticipants, selectReadyParticipants, +} from "./initiative-engine.js"; +import { + buildPersonaParticipantSpecs, dispatchPersonaParticipantTurn, pendingMentionsForPersona, + renderPersonaInitiativeAffinity, +} from "./persona-participant-dispatcher.js"; +import { readPersonaStateObservations } from "./persona-state-source.js"; +import { readSemanticPressure } from "./semantic-pressure-source.js"; +import { readPersonaSchedulerState, writePersonaSchedulerState } from "./state-store.js"; + +export interface PersonaSchedulerTickResult { + ok: true; + participantCount: number; + initiativeClock: number; + queuedCount: number; + dryRun: boolean; + selected: string[]; + queued: string[]; + statePath: string; + skipped?: true; + reason?: "agent_swarm_paused" | "repo_face_heartbeats_disabled"; + pausePath?: string; + idleCooling?: Record; +} + +interface PersonaSchedulerRunnerDependencies { + readPause?: typeof readAgentSwarmPause; + readState?: typeof readPersonaSchedulerState; + writeState?: typeof writePersonaSchedulerState; +} + +export async function runPersonaSchedulerTick(input: { + config: ReturnType; + dryRun?: boolean; + force?: boolean; + now?: Date; +}, dependencies: PersonaSchedulerRunnerDependencies = {}): Promise { + const { config } = input; + const dryRun = input.dryRun ?? false; + const readState = dependencies.readState ?? readPersonaSchedulerState; + const writeState = dependencies.writeState ?? writePersonaSchedulerState; + const pause = await (dependencies.readPause ?? readAgentSwarmPause)(); + if (pause.paused) { + const state = await readState(config.repoFaceHeartbeats.statePath); + recordSchedulerSkip({ state, skippedAt: input.now ?? new Date(), reason: "agent_swarm_paused", details: { pausePath: pause.path, pauseReason: pause.reason } }); + if (!dryRun) await writeState(config.repoFaceHeartbeats.statePath, state); + return { ok: true, participantCount: state.participants.length, initiativeClock: state.initiativeClock, queuedCount: 0, dryRun, selected: [], queued: [], skipped: true, reason: "agent_swarm_paused", pausePath: pause.path, statePath: config.repoFaceHeartbeats.statePath }; + } + if (!config.repoFaceHeartbeats.enabled && !input.force) { + const state = await readState(config.repoFaceHeartbeats.statePath); + recordSchedulerSkip({ state, skippedAt: input.now ?? new Date(), reason: "repo_face_heartbeats_disabled" }); + if (!dryRun) await writeState(config.repoFaceHeartbeats.statePath, state); + return { ok: true, participantCount: state.participants.length, initiativeClock: state.initiativeClock, queuedCount: 0, dryRun, selected: [], queued: [], skipped: true, reason: "repo_face_heartbeats_disabled", statePath: config.repoFaceHeartbeats.statePath }; + } + + const registry = faceRegistryAsRepoDiscordRegistry(await loadFaceIdentityRegistry(config.repoDiscordIdentitiesPath)); + const state = await readState(config.repoFaceHeartbeats.statePath); + const now = input.now ?? new Date(); + const personaStateObservations = await readPersonaStateObservations({ identities: registry.identities, storageRoot: config.storageRoot, now }); + const restStates = new Map(Array.from(personaStateObservations.entries()).flatMap(([identityId, observation]) => observation.status === "ok" && "rest" in observation && observation.rest ? [[identityId, observation.rest] as const] : [])); + advanceInitiativeClockFromWallClock(state, now); + const activeTurnScan = dryRun ? { active: new Map(), staleRecovered: [] } : await scanActivePersonaTurns(config); + recordStaleActiveTurnRecoveries({ state, recoveries: activeTurnScan.staleRecovered, recoveredAt: now }); + const globalHeat = (await readSwarmControlState())?.globalHeat ?? config.repoFaceHeartbeats.globalHeat; + applySchedulerControls({ state, baseRecoveryMinutes: config.repoFaceHeartbeats.baseRecoveryMinutes, globalHeat }); + const completedThisTick = new Set(); + const participantSpecs = buildPersonaParticipantSpecs(registry.identities, config.voidModerationHeartbeatEnabled); + reconcileInitiativeParticipants({ state, specs: participantSpecs, defaultChannelId: config.repoFaceHeartbeats.defaultChannelId, speedOverrides: config.repoFaceHeartbeats.speedOverrides, heatOverrides: config.repoFaceHeartbeats.heatOverrides, baseRecoveryMinutes: config.repoFaceHeartbeats.baseRecoveryMinutes, globalHeat, activeTurns: activeTurnScan.active, completedThisTick }); + rescheduleStaleOverdueParticipants(state); + applyPendingMentionPriority(state); + const idleCooling = await readDiscordActivitySnapshot({ + botToken: config.botToken, + channelIds: [config.repoFaceHeartbeats.defaultChannelId, config.bifrostDiscordChannelId, ...config.indexedChannelIds, ...registry.identities.flatMap((identity) => getRepoDiscordIdentityAllowedChannelIds(identity))], + policy: config.repoFaceHeartbeats.idleCooling, + history: state.history, + now, + }); + const pressure = await readSemanticPressure({ + candidates: participantSpecs.flatMap((spec) => { + const participant = state.participants.find((entry) => entry.identityId === spec.id); + if (!spec.identity || spec.participantKind === "system_agent" || participant?.status !== "active" || participant.currentLoad >= 1 || completedThisTick.has(participant.identityId) || restStates.get(participant.identityId)?.isNapping === true) return []; + return [{ identityId: participant.identityId, interruptThreshold: participant.interruptThreshold, affinityText: renderPersonaInitiativeAffinity(spec), allowedChannelIds: spec.allowedChannelIds }]; + }), + messages: idleCooling.observedHumanMessages, + now, + embedding: { backend: config.ragEmbeddingBackend, hashDimensions: config.ragEmbeddingDimensions, ollamaBaseUrl: config.ragOllamaBaseUrl, ollamaModel: config.ragOllamaModel, ollamaTimeoutMs: config.ragOllamaTimeoutMs }, + }); + applySemanticPressureProjection({ state, projections: pressure.projections, projectedAt: now, unavailableReason: pressure.unavailableReason }); + const selected = selectReadyParticipants(state, config.repoFaceHeartbeats.maxJobsPerTick, completedThisTick, restStates, idleCooling); + const queued: string[] = []; + if (dryRun) { + const queuedAt = now.toISOString(); + for (const participant of selected) { + const mentions = pendingMentionsForPersona(state, participant.identityId); + recordDryRunSelection(participant, state, queuedAt, mentions.length); + queued.push(participant.identityId); + } + } 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 mentions = pendingMentionsForPersona(state, participant.identityId); + const turn = await dispatchPersonaParticipantTurn({ participant, pendingMentions: mentions, registryIdentities: registry.identities, config, storage, queuedAt, personaStateObservation: personaStateObservations.get(participant.identityId) }); + if (turn.created) { + queued.push(participant.identityId); + recordTurnStarted({ participant, state, queuedAt, activeJobId: turn.activeJobId, requestMessageId: turn.requestMessageId, pendingMentionCount: mentions.length }); + consumePendingMentions({ state, participant, mentions, consumedAt: queuedAt, activeJobId: turn.activeJobId, requestMessageId: turn.requestMessageId }); + } else if (turn.failureReason) recordTurnFailedToStart({ participant, state, queuedAt, activeJobId: turn.activeJobId, requestMessageId: turn.requestMessageId, reason: turn.failureReason }); + } + } finally { await storage.close(); } + } + finalizeSchedulerTick(state, now); + if (!dryRun) await writeState(config.repoFaceHeartbeats.statePath, state); + return { + ok: true, participantCount: state.participants.length, initiativeClock: state.initiativeClock, + queuedCount: queued.length, dryRun, selected: selected.map((entry) => entry.identityId), queued, + statePath: config.repoFaceHeartbeats.statePath, + idleCooling: { ...idleCooling, observedHumanMessages: undefined, observedHumanMessageCount: idleCooling.observedHumanMessages.length }, + }; +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index c5e101d..876eb1a 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -220,6 +220,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Repo-activity observation lowering now lives in pure `repo-activity-projector.ts`; `repo-activity-source.ts` retains exporter execution and parsing. Both live and inspection assembly use the same projector for `ok`, `unconfigured`, `unavailable`, and `malformed` evidence, and the shell's dead text helpers were deleted. - Inspection-only initiative synthesis now lives in pure `inspection-participant-factory.ts`, including explicit zero semantic pressure and empty evidence/receipt state. The prompt-inspection CLI no longer fabricates a partial participant inside the orchestration shell. - The remaining-shell audit found the resident daemon still spawns `scripts/run-repo-face-heartbeats.ts` as a fresh child process every observation pulse. This keeps the script as hidden runtime authority and pays Node/TypeScript bootstrap cost once per minute even while inference is paused. The next rebuild must move one typed tick into the app runtime, call it directly from `apps/persona-scheduler/src/index.ts`, and demote the script to a CLI/inspection wrapper. +- Resident tick ownership is now in-process. `persona-scheduler-runner.ts` owns one typed tick; `index.ts` serializes direct calls without spawning Node/tsx children; `persona-participant-dispatcher.ts` owns participant projection and live turn dispatch; `persona-prompt-inspection.ts` owns explicit inspection. `scripts/run-repo-face-heartbeats.ts` is a 31-line CLI adapter. A focused negative smoke proves paused dry runs return before any downstream source can become necessary. - Direct Face `UPDATE REQUEST` and `BIFROST TOPIC` packets are ignored while `REPO_FACE_BIFROST_ENABLED=false`. Do not debug missing Bifrost jobs as a live failure unless that flag has been deliberately enabled again. - Live model-facing prompt prose has started moving out of executable code. The shared loader is `packages/shared/src/prompt-template.ts`; extracted prompt surfaces live under `prompts/`, including repo Face turn/reviewer prompts, provider wrapper prompts, situational social read prompts, source-grounding reminders, consensus-feed prompts, and repo Face model-smoke scenarios. Code should project data and select prompt names; prompt files should own behavioral prose. - Identity collapse has started. Active bot, worker, MCP repo-identity tools, and Face CTB scheduler paths now load through the canonical Face registry and treat repo Discord identities as compatibility projections. Next bounded move is to finish removing remaining direct conceptual dependence on repo identities, then split the Face turn loop into scheduler, context assembler, prompt renderer, candidate parser, policy gate, and transport adapter. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index d07c50e..782986b 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -190,6 +190,7 @@ VoidBot runs on Yggdrasil. Idunn owns deployment and consumes upstream repositor - `persona-conversation-projector.ts` is the pure owner of conversation projection. Given routing, raw current/nearby messages, pending mentions, and identity, it emits one transcript plus the exact thread/default-focus objects sent to the actuator, and derives shared room-saturation/topic-attractor facts. It cannot read Discord, state, vectors, or the queue; `turn-context-source.ts` remains the evidence owner. - `persona-turn-prompt-projector.ts` is the final pure turn-policy owner. It composes the repo-Face prompt template and owns identity doctrine, situation/mention/channel/research/social/governance/repetition/worldbuilding directives, plus deterministic jurisdiction-dive cadence. The heartbeat shell passes acquired facts and sends the returned prompt to the actuator; it does not load prompt templates or decide prompt policy. - Typed native Personas and repo Personas share the same `persona-state-source`/memory-projector path. Legacy `gamecult.persona_state.v0` JSON remains live for Muninn, Sleipnir, Hermodr, and Tengu behind `persona-portable-state-source.ts` and `persona-portable-state-projector.ts`; the source validates schema/read failures and the projector renders bounded imported state without exposing its path. This compatibility organ is not canonical state ownership and should disappear after a provenance-aware `.cc` migration. +- `apps/persona-scheduler/src/persona-scheduler-runner.ts` owns one complete typed scheduler tick in-process. The resident `index.ts` serializes calls to that runner directly; it does not spawn a TypeScript script per pulse. Pause/disabled checks happen before registry, Discord, embedding, provider, or dispatch work. `persona-participant-dispatcher.ts` owns participant-spec projection plus repo-Persona/Void dispatch, while `persona-prompt-inspection.ts` owns the explicit prompt-inspection workflow. `scripts/run-repo-face-heartbeats.ts` is only a 31-line CLI adapter over the runner or inspection organ. - `apps/persona-scheduler/src/bifrost-governance-source.ts` owns the remaining Bifrost governance digest subprocess contract. It supplies the CLI path, arguments, timeout, JSON parsing, and typed command/parse failure topics; Persona prompt composition receives a `BifrostGovernanceDigest` and cannot invoke Bifrost directly. - The scheduler state has one write authority. `initiative-engine.ts` commits participant reconciliation, operator controls, pause/disabled skips, stale-turn recovery witnesses, semantic pressure, pending-attention consumption, queue outcomes, and tick finalization. `run-repo-face-heartbeats.ts` contains no direct participant/state assignment or history append; its sources and actuators return facts to engine commits. - `apps/persona-scheduler/src/turn-actuator.ts` is the worker-queue output port. Given a completed prompt, output channel, evidence fields, provider, identity, timestamp, and narrow `createJob` capability, it builds the canonical worker actor/guild `ContextBundle`, creates one approved `repo-face-rumination` job, and returns `{created, activeJobId, requestMessageId}`. It cannot choose a Persona, mutate scheduler state, or compose prompt content. The orchestration shell no longer knows `ContextBuilder`, job approval defaults, worker command names, or `createJob` mechanics. diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index f8b88c6..cb8412d 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -1,614 +1,27 @@ import "dotenv/config"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; - import { loadConfig } from "@voidbot/config"; -import { - createStateStorage, - getRepoDiscordIdentityAllowedChannelIds, - faceRegistryAsRepoDiscordRegistry, - loadFaceIdentityRegistry, - resolveRepoFaceStatePath, - type RepoFacePendingMention, - type RepoDiscordIdentity, -} from "@voidbot/core"; -import { - type SourceMessage, -} from "@voidbot/shared"; -import { - advanceInitiativeClockFromWallClock, - applyPendingMentionPriority, - applySchedulerControls, - applySemanticPressureProjection, - consumePendingMentions, - finalizeSchedulerTick, - countPendingMentionsByIdentity, - reconcileInitiativeParticipants, - recordDryRunSelection, - recordSchedulerSkip, - recordStaleActiveTurnRecoveries, - recordTurnFailedToStart, - recordTurnStarted, - rescheduleStaleOverdueParticipants, - selectReadyParticipants, - type InitiativeParticipant as FaceHeartbeatParticipant, - type ParticipantSpec as SchedulerParticipantSpec, -} from "../apps/persona-scheduler/dist/initiative-engine.js"; -import { - readPersonaSchedulerState as readHeartbeatState, - writePersonaSchedulerState as writeHeartbeatState, - type PersonaSchedulerState as FaceHeartbeatState, -} from "../apps/persona-scheduler/dist/state-store.js"; -import { scanActivePersonaTurns } from "../apps/persona-scheduler/dist/active-turn-source.js"; -import { - readPersonaStateObservation, - readPersonaStateObservations, - type PersonaStateObservation, -} from "../apps/persona-scheduler/dist/persona-state-source.js"; -import { projectNativePersonaBody } from "../apps/persona-scheduler/dist/persona-standard-state-projector.js"; -import { assemblePersonaTurn } from "../apps/persona-scheduler/dist/persona-turn-assembler.js"; -import { coordinatePersonaMemoryTurn } from "../apps/persona-scheduler/dist/persona-memory-turn-coordinator.js"; -import { readGlobalAgentDoctrine } from "../apps/persona-scheduler/dist/global-agent-doctrine-source.js"; -import { readStoredPersonaHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-source.js"; -import { - readDiscordActivitySnapshot, - type IdleCoolingSnapshot, -} from "../apps/persona-scheduler/dist/discord-activity-source.js"; -import { readSemanticPressure } from "../apps/persona-scheduler/dist/semantic-pressure-source.js"; -import { - fetchChannelSnapshots, - fetchRecentDiscordMessages, - type ChannelSnapshot, -} from "../apps/persona-scheduler/dist/turn-context-source.js"; -import { - readBifrostGovernanceDigest, -} from "../apps/persona-scheduler/dist/bifrost-governance-source.js"; -import { submitPersonaTurn } from "../apps/persona-scheduler/dist/turn-actuator.js"; -import { launchVoidModerationTurn } from "../apps/persona-scheduler/dist/void-moderation-turn-actuator.js"; -import { buildInspectionParticipant } from "../apps/persona-scheduler/dist/inspection-participant-factory.js"; -import { - buildPersonaChannelPlan as buildChannelPlan, - newestPendingMentionChannel, - personaChannelSpeedMultiplier as channelSpeedMultiplierFor, - type PersonaChannelOption as RepoFaceChannelOption, - type PersonaChannelPlan as RepoFaceChannelPlan, -} from "../apps/persona-scheduler/dist/turn-routing.js"; -import { - readAgentSwarmPause, - readSwarmControlState, -} from "../apps/persona-scheduler/dist/control-source.js"; -import { - readRepoActivity, -} from "../apps/persona-scheduler/dist/repo-activity-source.js"; -import { projectRepoActivityObservation } from "../apps/persona-scheduler/dist/repo-activity-projector.js"; +import { inspectPersonaTurnPrompt } from "../apps/persona-scheduler/dist/persona-prompt-inspection.js"; +import { runPersonaSchedulerTick } from "../apps/persona-scheduler/dist/persona-scheduler-runner.js"; async function main(): Promise { 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); - recordSchedulerSkip({ - state, - skippedAt: new Date(), - reason: "agent_swarm_paused", - details: { pausePath: pause.path, pauseReason: pause.reason }, - }); - 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); - recordSchedulerSkip({ - state, - skippedAt: new Date(), - reason: "repo_face_heartbeats_disabled", - }); - 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 now = new Date(); - const personaStateObservations = await readPersonaStateObservations({ identities: registry.identities, storageRoot: config.storageRoot, now }); - const restStates = new Map(Array.from(personaStateObservations.entries()).flatMap(([identityId, observation]) => - observation.status === "ok" && observation.rest ? [[identityId, observation.rest] as const] : [])); - advanceInitiativeClockFromWallClock(state, now); - const activeTurnScan = dryRun - ? { active: new Map(), staleRecovered: [] } - : await scanActivePersonaTurns(config); - recordStaleActiveTurnRecoveries({ state, recoveries: activeTurnScan.staleRecovered, recoveredAt: now }); - - const swarmControl = await readSwarmControlState(); - const globalHeat = swarmControl?.globalHeat ?? config.repoFaceHeartbeats.globalHeat; - applySchedulerControls({ state, baseRecoveryMinutes: config.repoFaceHeartbeats.baseRecoveryMinutes, globalHeat }); - const completedThisTick = new Set(); - const participantSpecs = buildParticipantSpecs(registry.identities, config.voidModerationHeartbeatEnabled); - reconcileInitiativeParticipants({ - state, - specs: participantSpecs, - defaultChannelId: config.repoFaceHeartbeats.defaultChannelId, - speedOverrides: config.repoFaceHeartbeats.speedOverrides, - heatOverrides: config.repoFaceHeartbeats.heatOverrides, - baseRecoveryMinutes: config.repoFaceHeartbeats.baseRecoveryMinutes, - globalHeat, - activeTurns: activeTurnScan.active, - completedThisTick, - }); - rescheduleStaleOverdueParticipants(state); - applyPendingMentionPriority(state); - const idleCooling = await readDiscordActivitySnapshot({ - botToken: config.botToken, - channelIds: [ - config.repoFaceHeartbeats.defaultChannelId, - config.bifrostDiscordChannelId, - ...config.indexedChannelIds, - ...registry.identities.flatMap((identity) => getRepoDiscordIdentityAllowedChannelIds(identity)), - ], - policy: config.repoFaceHeartbeats.idleCooling, - history: state.history, - now, - }); - const pressureCandidates = participantSpecs.flatMap((spec) => { - const participant = state.participants.find((entry) => entry.identityId === spec.id); - if (!spec.identity || spec.participantKind === "system_agent" || participant?.status !== "active" - || participant.currentLoad >= 1 || completedThisTick.has(participant.identityId) - || restStates.get(participant.identityId)?.isNapping === true) return []; - return [{ - identityId: participant.identityId, - interruptThreshold: participant.interruptThreshold, - affinityText: renderInitiativeAffinityCard(spec), - allowedChannelIds: spec.allowedChannelIds, - }]; - }); - const pressure = await readSemanticPressure({ - candidates: pressureCandidates, - messages: idleCooling.observedHumanMessages, - now, - embedding: { - backend: config.ragEmbeddingBackend, - hashDimensions: config.ragEmbeddingDimensions, - ollamaBaseUrl: config.ragOllamaBaseUrl, - ollamaModel: config.ragOllamaModel, - ollamaTimeoutMs: config.ragOllamaTimeoutMs, - }, - }); - applySemanticPressureProjection({ - state, - projections: pressure.projections, - projectedAt: now, - unavailableReason: pressure.unavailableReason, - }); - - const selected = selectReadyParticipants( - state, - config.repoFaceHeartbeats.maxJobsPerTick, - completedThisTick, - restStates, - idleCooling, - ); - const queuedIdentityIds: string[] = []; - - if (selected.length > 0 && dryRun) { - const queuedAt = new Date().toISOString(); - for (const participant of selected) { - const pendingMentions = pendingMentionsForParticipant(state, participant.identityId); - recordDryRunSelection(participant, state, queuedAt, pendingMentions.length); - queuedIdentityIds.push(participant.identityId); - } - } 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, - personaStateObservation: personaStateObservations.get(participant.identityId), - }); - - if (turn.created) { - queuedIdentityIds.push(participant.identityId); - recordTurnStarted({ - participant, - state, - queuedAt, - activeJobId: turn.activeJobId, - requestMessageId: turn.requestMessageId, - pendingMentionCount: pendingMentions.length, - }); - consumePendingMentions({ - state, - participant, - mentions: pendingMentions, - consumedAt: queuedAt, - activeJobId: turn.activeJobId, - requestMessageId: turn.requestMessageId, - }); - } else if (turn.failureReason) { - recordTurnFailedToStart({ - participant, - state, - queuedAt, - activeJobId: turn.activeJobId, - requestMessageId: turn.requestMessageId, - reason: turn.failureReason, - }); - } - } - } finally { - await storage.close(); - } - } - - finalizeSchedulerTick(state, now); - 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, - idleCooling: { - ...idleCooling, - observedHumanMessages: undefined, - observedHumanMessageCount: idleCooling.observedHumanMessages.length, - }, - })}\n`, - ); -} - -interface ParticipantSpec extends SchedulerParticipantSpec { - identity?: RepoDiscordIdentity; -} - -function buildParticipantSpecs(identities: RepoDiscordIdentity[], includeVoid: boolean): ParticipantSpec[] { - return [ - ...(includeVoid ? [{ - id: "void", - participantKind: "system_agent" as const, - turnKind: "void_moderation" as const, - repoName: "VoidBot", - displayName: "Void", - allowedChannelIds: [], - channelSpeedMultiplier: 1, - }] : []), - ...identities.map((identity) => ({ - id: identity.id, - participantKind: identity.identityKind === "native_persona" - ? "native_persona" as const - : "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; - storage: Awaited>; - queuedAt: string; - personaStateObservation?: PersonaStateObservation; -}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string; failureReason?: string }> { - switch (input.participant.turnKind) { - case "repo_face_rumination": - return queueRepoFaceTurn(input); - case "void_moderation": - return launchVoidModerationTurn({ - queuedAt: input.queuedAt, - storageRoot: input.config.storageRoot, - pendingMentions: input.pendingMentions, - }); - } -} - -async function queueRepoFaceTurn(input: { - participant: FaceHeartbeatParticipant; - pendingMentions: RepoFacePendingMention[]; - registryIdentities: RepoDiscordIdentity[]; - config: ReturnType; - storage: Awaited>; - queuedAt: string; - personaStateObservation?: PersonaStateObservation; -}): 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, failureReason: `No registered Persona identity exists for ${input.participant.identityId}.` }; - } - - const preferredChannelId = newestPendingMentionChannel(input.pendingMentions); - const channelPlan = buildChannelPlan( - identity, - input.config.repoFaceHeartbeats.defaultChannelId, - preferredChannelId, - ); - const channelId = channelPlan.primaryChannelId; - if (!channelId) { - return { created: false, failureReason: "No CTB turn channel is configured for this Persona." }; - } - - 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 && identity.identityKind !== "native_persona" - ? await readBifrostGovernanceDigest({ - bifrostRoot: input.config.bifrostRoot, - repoName: identity.repoName, - agentIdentity: identity.id, - }) - : undefined; - const roomContext = { - recentMessages, - channelSnapshots, - }; - const humanPronounGuidance = await readStoredPersonaHumanPronounGuidance({ config: input.config, ...roomContext }); - const memoryContext = await coordinatePersonaMemoryTurn({ - identity, - config: input.config, - registryIdentities: input.registryIdentities, - ...roomContext, - humanPronounGuidance, - stateObservation: input.personaStateObservation, - }); - const repoActivitySurface = identity.identityKind === "native_persona" - ? projectNativePersonaBody(identity) - : projectRepoActivityObservation(readRepoActivity({ identity, storageRoot: input.config.storageRoot })); - const globalAgentDoctrine = await readGlobalAgentDoctrine({ codexHome: process.env.CODEX_HOME, userProfile: process.env.USERPROFILE }); - const assembly = assemblePersonaTurn({ - identity, - channelId, - channelPlan, - recentMessages, - channelSnapshots, - participant: input.participant, - pendingMentions: input.pendingMentions, - memorySurface: memoryContext.memorySurface, - semanticMemoryRecallSurface: memoryContext.semanticMemoryRecallSurface, - repoActivitySurface, - humanPronounGuidance, - bifrostDigest, - githubActionsEnabled: input.config.repoFaceGithubActionsEnabled, - globalAgentDoctrine, - }); - const result = await submitPersonaTurn({ - jobQueue: input.storage.jobQueue, - provider: input.config.repoFaceHeartbeats.provider, - identityId: identity.id, - queuedAt: input.queuedAt, - channelId, - prompt: assembly.prompt, - recentMessages, - conversationFocus: assembly.conversation.focus, - conversationThreads: assembly.conversation.threads, - imageAttachments: assembly.imageAttachments, - }); - - return { - created: result.created, - activeJobId: result.activeJobId, - requestMessageId: result.requestMessageId, - }; -} - -function renderInitiativeAffinityCard(spec: ParticipantSpec): string { - const identity = spec.identity; - return [ - `${spec.displayName} is the Persona steward of ${spec.repoName}.`, - identity?.description, - ...(identity?.channelPermissions ?? []).flatMap((permission) => [ - permission.topic, - permission.label, - permission.posture, - ]), - ].filter((value): value is string => typeof value === "string" && value.trim().length > 0).join("\n"); -} - -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)); -} - -async function assembleRepoFaceTurnPrompt(input: { - config: ReturnType; - 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 - ? readBifrostGovernanceDigest({ - bifrostRoot: input.config.bifrostRoot, - repoName: identity.repoName, - agentIdentity: identity.id, - }) - : Promise.resolve(undefined), - ]); - const roomContext = { - recentMessages, - channelSnapshots, - }; - const humanPronounGuidance = await readStoredPersonaHumanPronounGuidance({ config: input.config, ...roomContext }); - const personaStateObservation = await readPersonaStateObservation({ identity, storageRoot: input.config.storageRoot }); - const projectedMemoryOverride = input.memorySurfacePath - ? await readOptionalMemorySurface(input.memorySurfacePath) - : undefined; - const memoryContext = await coordinatePersonaMemoryTurn({ - identity, - config: input.config, - registryIdentities: registry.identities, - ...roomContext, - humanPronounGuidance, - stateObservation: personaStateObservation, - projectedMemoryOverride, - }); - const repoActivitySurface = projectRepoActivityObservation( - readRepoActivity({ identity, storageRoot: input.config.storageRoot }), - ); - const globalAgentDoctrine = await readGlobalAgentDoctrine({ codexHome: process.env.CODEX_HOME, userProfile: process.env.USERPROFILE }); - const conversationMemorySurface = input.conversationSurfacePath - ? await readOptionalMemorySurface(input.conversationSurfacePath) - : undefined; - const participant = buildInspectionParticipant( - identity, - input.config.repoFaceHeartbeats.baseRecoveryMinutes, - ); - const assembly = assemblePersonaTurn({ - identity, - channelId, - channelPlan, - channelSnapshots, - recentMessages, - memorySurface: memoryContext.memorySurface, - semanticMemoryRecallSurface: memoryContext.semanticMemoryRecallSurface, - repoActivitySurface, - conversationMemorySurface, - humanPronounGuidance, - bifrostDigest, - participant, - pendingMentions: [], - githubActionsEnabled: input.config.repoFaceGithubActionsEnabled, - globalAgentDoctrine, - }); - - if (input.outPath) { - const outPath = resolve(input.outPath); - await mkdir(dirname(outPath), { recursive: true }); - await writeFile(outPath, assembly.prompt, "utf8"); - } - - return { - ok: true, - identityId: identity.id, - promptLength: assembly.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 readOptionalMemorySurface(path: string | undefined): Promise { - if (!path) { - return undefined; - } - - const content = (await readFile(resolve(path), "utf8")).trim(); - return content.length > 0 ? content : undefined; + const identityId = readArgValue("--assemble-prompt"); + const result = identityId + ? await inspectPersonaTurnPrompt({ + config, + identityId, + outPath: readArgValue("--out"), + memorySurfacePath: readArgValue("--memory-surface"), + conversationSurfacePath: readArgValue("--conversation-surface"), + }) + : await runPersonaSchedulerTick({ config, dryRun: process.argv.includes("--dry-run"), force: process.argv.includes("--force") }); + process.stdout.write(`${JSON.stringify(result)}\n`); } function readArgValue(name: string): string | undefined { const index = process.argv.indexOf(name); - if (index < 0) { - return undefined; - } - - const value = process.argv[index + 1]; + const value = index >= 0 ? process.argv[index + 1] : undefined; return value && !value.startsWith("--") ? value : undefined; } diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts index 926f00f..7761aea 100644 --- a/scripts/smoke-persona-initiative-engine.ts +++ b/scripts/smoke-persona-initiative-engine.ts @@ -77,6 +77,7 @@ import { assemblePersonaTurn } from "../apps/persona-scheduler/dist/persona-turn import { buildVoidModerationLaunchCommand, launchVoidModerationTurn, waitForVoidModerationHandshake } from "../apps/persona-scheduler/dist/void-moderation-turn-actuator.js"; import { readGlobalAgentDoctrine } from "../apps/persona-scheduler/dist/global-agent-doctrine-source.js"; import { buildInspectionParticipant } from "../apps/persona-scheduler/dist/inspection-participant-factory.js"; +import { runPersonaSchedulerTick } from "../apps/persona-scheduler/dist/persona-scheduler-runner.js"; import { projectGamecultPersonaState } from "../apps/persona-scheduler/dist/persona-standard-state-projector.js"; import { projectPersonaStatePacket } from "../apps/persona-scheduler/dist/persona-state-packet-projector.js"; import { extractLastPersonaProjectionMessage, isRetryablePersonaProjectionFailure } from "../apps/persona-scheduler/dist/persona-text-projection-actuator.js"; @@ -848,6 +849,14 @@ try { assert.deepEqual(await readAgentSwarmPause({ path: pausePath }), { paused: false, path: pausePath, reason: "operator resumed" }, "the pause source returns operator facts without touching scheduler state"); assert.deepEqual(await readSwarmControlState({ loadControl: () => ({ globalHeat: 1.75 }) }), { globalHeat: 1.75 }, "typed CultMesh heat is projected through the control source"); assert.equal(await readSwarmControlState({ loadControl: () => ({ globalHeat: 4 }) }), null, "out-of-contract heat fails closed before the engine sees it"); + let pausedRunnerWrite = false; + const pausedRunnerResult = await runPersonaSchedulerTick({ config: { repoFaceHeartbeats: { statePath: "paused.cc" } } as never, dryRun: true, now: new Date("2026-07-15T21:00:00.000Z") }, { + readPause: async () => ({ paused: true, path: "pause.json", reason: "operator brake" }), + readState: async () => newPersonaSchedulerState(), + writeState: async () => { pausedRunnerWrite = true; }, + }); + assert.equal(pausedRunnerResult.reason, "agent_swarm_paused", "the in-process runner owns the pause-before-expensive-work short circuit"); + assert.equal(pausedRunnerWrite, false, "a dry-run paused pulse cannot persist scheduler state"); } finally { await rm(controlDirectory, { recursive: true, force: true }); } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 9cb361c..b7633fe 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -438,3 +438,4 @@ {"ts":"2026-07-16T05:20:00Z","type":"implementation","status":"ok","note":"Repo activity prompt lowering moved into pure repo-activity-projector.ts while repo-activity-source.ts retains exporter execution/parsing. Live and inspection paths share the projector; unavailable evidence smoke preserves failure detail and anti-stale-claim guidance. Three dead shell text/number helpers were deleted."} {"ts":"2026-07-16T05:25:00Z","type":"implementation","status":"ok","note":"Inspection-only participant synthesis moved into inspection-participant-factory.ts with explicit zero dynamic heat/response pressure and empty evidence/receipt state. The heartbeat shell is now 618 lines and no longer fabricates a partial initiative participant for prompt inspection."} {"ts":"2026-07-16T05:30:00Z","type":"architecture","status":"ok","note":"Remaining-shell authority audit found apps/persona-scheduler/index.ts still spawns scripts/run-repo-face-heartbeats.ts as a new Node/TypeScript child every one-minute pulse. The script therefore remains hidden runtime authority and imposes process/bootstrap churn even while paused. Next cut is a direct in-process typed scheduler runner; the script must become CLI/inspection only."} +{"ts":"2026-07-16T05:45:00Z","type":"architecture","status":"ok","note":"Resident Persona scheduling rebuilt around persona-scheduler-runner.ts as one in-process typed tick. index.ts no longer spawns a Node/tsx child per minute; participant dispatch and prompt inspection have named organs; run-repo-face-heartbeats.ts is a 31-line CLI adapter. Pause short-circuit is dependency-injected and smoke-proven before downstream sources, while typecheck, initiative, migration, and CLI dry-run checks pass."} diff --git a/state/map.yaml b/state/map.yaml index c08ce5f..8c7080f 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -78,6 +78,7 @@ active_subgoals: - `persona-social-context-source.ts` owns both the injected interaction-profile reader contract and the concrete configured-storage adapter. The adapter opens the provider and delegates visible-human selection, evidence precedence, and guaranteed close semantics to the source; the orchestration shell cannot open profile storage or duplicate pronoun policy. - `repo-activity-projector.ts` is the pure prompt-facing owner for typed repo-activity observations. `repo-activity-source.ts` alone executes and parses the exporter; live and inspection turns share the projector and the shell cannot reinterpret failures or raw exporter output. - `inspection-participant-factory.ts` owns the inert synthetic initiative participant used only by `--assemble-prompt`. It explicitly carries zero dynamic heat/response pressure and empty evidence/receipt state, reads no live scheduler state, and cannot queue work. + - `persona-scheduler-runner.ts` owns one complete typed observation/selection/dispatch tick in-process. Resident `index.ts` serializes direct runner calls and no longer spawns a TypeScript child per minute. Pause and disabled controls return before registry, Discord, embedding, provider, or dispatch work. `persona-participant-dispatcher.ts` owns participant-spec projection and live repo-Persona/Void dispatch; `persona-prompt-inspection.ts` owns explicit inspection; `scripts/run-repo-face-heartbeats.ts` is CLI only. - `bifrost-governance-source.ts` owns the remaining Bifrost digest CLI adapter and converts command or parse failures into typed error topics. Persona prompt composition receives governance facts without knowing Bifrost process mechanics; `scripts/run-repo-face-heartbeats.ts` may not invoke the Bifrost CLI directly. - `initiative-engine.ts` is the only writer of scheduler state fields and history. Reconciliation, controls, pause/disabled skips, stale-turn recovery witnesses, semantic pressure, attention consumption, queue transitions, and tick finalization all cross named engine commit primitives. Queue/context actuators return results and cannot patch participants or history directly. - `turn-actuator.ts` owns lowering an assembled Persona prompt and evidence bundle into the canonical worker `ContextBundle` and approved `repo-face-rumination` job. It owns the worker actor, command, request ID, approval defaults, and `createJob` call, then returns only a queue receipt. The orchestration shell chooses no queue mechanics and the actuator cannot read Persona or initiative state. @@ -291,4 +292,4 @@ rejected_paths: current_status: summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live. Repo Face/persona heartbeat jobs run through the direct `openai_api` provider configured for MiMo `mimo-v2.5-pro`, with the expensive state-projector pass still disabled. The 2026-07-04 transport repair replaced the old Bifrost HTTP/token bridge shortcut with typed Bifrost CultMesh Discord command and receipt documents advertised in the Bifrost provider store at `cultmesh://asgard.starfire.bifrost/commands/discord-post`. VoidBot stores the Bifrost target as a CultMesh URI, writes command docs to `E:/Projects/Bifrost/.bifrost/provider-store.cc`, optionally pumps Bifrost's command processor, and waits for typed receipts before completing repo Face Discord delivery. Bifrost's provider advertisement exposes `discord.post` command metadata for Odin/Eve agents, and a bounded Weksa live-fire completed visibly. Eve live state now runs end to end through CultLib dev retained publications, Hermodr ordered SSE, Eve component bindings, and VoidBot typed control state. Receipts acknowledge intent only. Heat initial load, provider-driven change, and browser-command change were visibly verified without reload through the same subscription. - next_action: Keep the Yggdrasil swarm paused. Rebuild resident tick ownership: move one typed scheduler tick from `scripts/run-repo-face-heartbeats.ts` into `apps/persona-scheduler/src/persona-scheduler-runner.ts`, make `apps/persona-scheduler/src/index.ts` invoke it directly instead of spawning a fresh TypeScript child every minute, and demote the script to CLI/inspection only. Preserve serialized pulse ownership, typed result reporting, dry-run behavior, and the pause-before-expensive-work invariant. Do not unpause without an explicit controlled-live-fire decision. + next_action: Keep the Yggdrasil swarm paused. Verify the in-process runner rollout on Yggdrasil: served revision, deployed initiative smoke, repeated paused ticks with zero queueing, and absence of per-pulse child Node/tsx processes. Then map the still-separate Void moderation heartbeat, rumination, mood, and memory-maintenance ownership before moving those organs into the daemon. Do not unpause without an explicit controlled-live-fire decision.