diff --git a/apps/persona-scheduler/src/void-moderation-turn-actuator.ts b/apps/persona-scheduler/src/void-moderation-turn-actuator.ts new file mode 100644 index 0000000..4a36b90 --- /dev/null +++ b/apps/persona-scheduler/src/void-moderation-turn-actuator.ts @@ -0,0 +1,121 @@ +import { spawn } from "node:child_process"; +import { mkdir, stat, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import type { RepoFacePendingMention } from "@voidbot/core"; + +export interface VoidModerationTurnInput { + queuedAt: string; + storageRoot: string; + pendingMentions: RepoFacePendingMention[]; + workspaceRoot?: string; + handshakeTimeoutMs?: number; +} + +export interface VoidModerationTurnReceipt { + created: boolean; + activeJobId?: string; + requestMessageId: string; + failureReason?: string; +} + +interface VoidModerationActuatorDependencies { + launch?: (command: string, args: string[], options: { cwd: string; stdio: "ignore"; windowsHide: true }) => { pid?: number; unref(): void }; + now?: () => number; + pause?: (milliseconds: number) => Promise; + touchedAfter?: (path: string, timestampMs: number) => Promise; +} + +export async function launchVoidModerationTurn( + input: VoidModerationTurnInput, + dependencies: VoidModerationActuatorDependencies = {}, +): Promise { + const workspaceRoot = resolve(input.workspaceRoot ?? process.cwd()); + const runnerScript = resolve(workspaceRoot, "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 now = dependencies.now ?? Date.now; + const launchedAt = now(); + await mkdir(statusDir, { recursive: true }); + await writeFile(pendingMentionsPath, `${JSON.stringify({ generatedAt: input.queuedAt, pendingMentions: input.pendingMentions }, null, 2)}\n`, "utf8"); + + const launchCommand = buildVoidModerationLaunchCommand({ pendingMentionsPath, runnerScript, workspaceRoot }); + const launch = dependencies.launch ?? ((command, args, options) => spawn(command, args, options)); + const child = launch("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + launchCommand, + ], { cwd: workspaceRoot, stdio: "ignore", windowsHide: true }); + child.unref(); + + const handshake = await waitForVoidModerationHandshake({ + lockPath, + statusPath, + launchedAt, + timeoutMs: input.handshakeTimeoutMs ?? 60_000, + now, + pause: dependencies.pause, + touchedAfter: dependencies.touchedAfter, + }); + const requestMessageId = `agent-turn:void:${input.queuedAt}`; + if (!handshake.started) return { + created: false, + activeJobId: child.pid ? `launcher-process:${child.pid}` : undefined, + requestMessageId, + failureReason: handshake.reason, + }; + return { created: true, activeJobId: `process:void-moderation:${input.queuedAt}`, requestMessageId }; +} + +export function buildVoidModerationLaunchCommand(input: { + pendingMentionsPath: string; + runnerScript: string; + workspaceRoot: string; +}): string { + const argumentsList = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", input.runnerScript] + .map(toPowerShellSingleQuotedString) + .join(", "); + return [ + `$env:VOID_RUMINATION_PENDING_MENTIONS_PATH = ${toPowerShellSingleQuotedString(input.pendingMentionsPath)};`, + `$arguments = @(${argumentsList});`, + `Start-Process -FilePath 'powershell.exe' -ArgumentList $arguments -WorkingDirectory ${toPowerShellSingleQuotedString(input.workspaceRoot)} -WindowStyle Hidden;`, + ].join(" "); +} + +export async function waitForVoidModerationHandshake(input: { + lockPath: string; + statusPath: string; + launchedAt: number; + timeoutMs: number; + now?: () => number; + pause?: (milliseconds: number) => Promise; + touchedAfter?: (path: string, timestampMs: number) => Promise; +}): Promise<{ started: true } | { started: false; reason: string }> { + const now = input.now ?? Date.now; + const pause = input.pause ?? ((milliseconds) => new Promise((resolvePause) => setTimeout(resolvePause, milliseconds))); + const touchedAfter = input.touchedAfter ?? wasTouchedAfter; + const deadline = now() + input.timeoutMs; + while (now() < deadline) { + if (await touchedAfter(input.lockPath, input.launchedAt)) return { started: true }; + if (await touchedAfter(input.statusPath, input.launchedAt)) return { started: true }; + await pause(250); + } + return { started: false, reason: "void_moderation_launch_handshake_missing" }; +} + +function toPowerShellSingleQuotedString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +async function wasTouchedAfter(path: string, timestampMs: number): Promise { + try { + return (await stat(path)).mtimeMs >= timestampMs - 500; + } catch { + return false; + } +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index a91e09a..ac65c91 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -213,6 +213,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Deterministic state-packet assembly now lives in `persona-state-packet-projector.ts`, which takes typed state, registry/room evidence, pronoun guidance, curiosity facts, and an explicit observation time. The shell no longer owns section ordering or social-context composition. The downstream regex vocabulary cleaner was deleted: it was a symptom compensator rewriting ordinary words such as `witness` and `canary` after projection. A smoke proves literal source language survives the projector unchanged. - Optional model-backed state projection now crosses `persona-text-projection-actuator.ts`. That actuator alone owns Codex args/process lifetime, ordered fallback models, timeout, output decoding, retry classification, and model-output JSONL telemetry. Focused smoke proves it chooses the last visible agent message, retries capacity/429 failures, and refuses to classify prompt-contract failures as model availability problems. The heartbeat shell lost another 182 lines and now only supplies the completed projection prompt plus runtime config. - Final Persona turn composition now crosses `persona-turn-assembler.ts` for both queued turns and `--assemble-prompt` inspection. It owns conversation projection, jurisdiction-dive cadence, final prompt composition, and the deduplicated eight-image evidence budget. The shell acquires facts and the turn actuator queues the assembled result; neither may maintain a parallel opinion about the final prompt or evidence bundle. +- Built-in Void CTB launch now crosses `void-moderation-turn-actuator.ts`. It alone writes the pending-mention handoff, quotes and launches the hidden PowerShell runner, polls lock/status witnesses, applies the handshake timeout, and returns the scheduler-facing receipt. The Persona shell decides only which participant kind to dispatch; it does not own platform process mechanics. - 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/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index f25fe3c..ae2b19a 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -1,7 +1,6 @@ import "dotenv/config"; -import { spawn } from "node:child_process"; -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; @@ -79,6 +78,7 @@ 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 { buildPersonaChannelPlan as buildChannelPlan, newestPendingMentionChannel, @@ -362,7 +362,7 @@ async function queueParticipantTurn(input: { case "repo_face_rumination": return queueRepoFaceTurn(input); case "void_moderation": - return startVoidModerationTurn({ + return launchVoidModerationTurn({ queuedAt: input.queuedAt, storageRoot: input.config.storageRoot, pendingMentions: input.pendingMentions, @@ -467,118 +467,6 @@ async function queueRepoFaceTurn(input: { }; } -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 { - try { - const info = await stat(path); - return info.mtimeMs >= timestampMs - 500; - } catch { - return false; - } -} - -function sleep(ms: number): Promise { - return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); -} - function renderInitiativeAffinityCard(spec: ParticipantSpec): string { const identity = spec.identity; return [ diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts index c6e18a0..36a5b80 100644 --- a/scripts/smoke-persona-initiative-engine.ts +++ b/scripts/smoke-persona-initiative-engine.ts @@ -71,6 +71,7 @@ import { projectPersonaCuriosityContext } from "../apps/persona-scheduler/dist/p import { projectPersonaConversation, renderPersonaRoomTopicSaturation } from "../apps/persona-scheduler/dist/persona-conversation-projector.js"; import { buildPersonaJurisdictionDiveDirective, buildPersonaTurnPrompt } from "../apps/persona-scheduler/dist/persona-turn-prompt-projector.js"; import { assemblePersonaTurn } from "../apps/persona-scheduler/dist/persona-turn-assembler.js"; +import { buildVoidModerationLaunchCommand, launchVoidModerationTurn, waitForVoidModerationHandshake } from "../apps/persona-scheduler/dist/void-moderation-turn-actuator.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"; @@ -717,6 +718,45 @@ assert.doesNotMatch(assembledTurn.prompt, /Visible cross-channel chronology/, "t assert.equal(assembledTurn.imageAttachments.length, 8, "the assembler owns the bounded image evidence budget"); assert.equal(assembledTurn.imageAttachments.filter((attachment) => attachment.localPath === "C:/cache/same.png").length, 1, "the assembler deduplicates image evidence across room and nearby snapshots"); assert.equal(assembledTurn.conversation.focus?.channelId, "aquarium", "the assembler returns the same newest-evidence conversation projection supplied to the turn actuator"); +const moderationLaunchCommand = buildVoidModerationLaunchCommand({ + pendingMentionsPath: "C:/Void's state/pending.json", + runnerScript: "C:/VoidBot/scripts/run.ps1", + workspaceRoot: "C:/VoidBot", +}); +assert.match(moderationLaunchCommand, /Void''s state/, "the moderation actuator owns PowerShell-safe path quoting"); +assert.match(moderationLaunchCommand, /Start-Process[\s\S]*-WindowStyle Hidden/, "the moderation actuator owns detached hidden launch mechanics"); +let handshakeNow = 0; +const missingHandshake = await waitForVoidModerationHandshake({ + lockPath: "lock", + statusPath: "status", + launchedAt: 0, + timeoutMs: 2, + now: () => handshakeNow, + pause: async () => { handshakeNow += 1; }, + touchedAfter: async () => false, +}); +assert.deepEqual(missingHandshake, { started: false, reason: "void_moderation_launch_handshake_missing" }, "a missing launch witness fails explicitly instead of pretending the turn started"); +const moderationActuatorDirectory = await mkdtemp(join(tmpdir(), "voidbot-moderation-actuator-")); +try { + let launchWitness: { command?: string; args?: string[]; cwd?: string; unref?: boolean } = {}; + const receipt = await launchVoidModerationTurn({ + queuedAt: "2026-07-15T21:10:00.000Z", + storageRoot: moderationActuatorDirectory, + workspaceRoot: moderationActuatorDirectory, + pendingMentions: [{ messageId: "mention", channelId: "aquarium", authorId: "human-2", authorName: "Neighbor", visiblePrompt: "Void, answer this", queuedAt: "2026-07-15T21:10:00.000Z" }], + }, { + now: () => 1000, + touchedAfter: async (path) => path.endsWith("moderation-rumination.lock"), + launch: (command, args, options) => ({ pid: 42, unref: () => { launchWitness = { command, args, cwd: options.cwd, unref: true }; } }), + }); + assert.deepEqual(receipt, { created: true, activeJobId: "process:void-moderation:2026-07-15T21:10:00.000Z", requestMessageId: "agent-turn:void:2026-07-15T21:10:00.000Z" }, "the moderation actuator returns only a scheduler-facing start receipt after handshake"); + assert.equal(launchWitness.command, "powershell.exe", "the moderation actuator owns the platform process choice"); + assert.equal(launchWitness.unref, true, "the moderation actuator detaches the launcher"); + const pendingMentionEnvelope = JSON.parse(await readFile(join(moderationActuatorDirectory, "status", "void-moderation-pending-mentions.json"), "utf8")); + assert.equal(pendingMentionEnvelope.pendingMentions[0].messageId, "mention", "the moderation actuator writes the exact pending-attention handoff consumed by the runner"); +} finally { + await rm(moderationActuatorDirectory, { recursive: true, force: true }); +} assert.equal(personaChannelSpeedMultiplier(routedIdentity), 3, "scheduler speed projection uses the routing organ's bounded channel policy"); assert.equal(newestPendingMentionChannel([ { identityId: "nibu", channelId: "older", queuedAt: "2026-07-15T20:00:00.000Z" }, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 3577758..08cd2f9 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -431,3 +431,4 @@ {"ts":"2026-07-16T03:50:00Z","type":"implementation","status":"ok","note":"Deterministic Persona state-packet assembly moved from the heartbeat shell into persona-state-packet-projector.ts with explicit typed inputs and observation time. The old regex vocabulary cleaner plus dead narrative helpers were deleted; focused smoke proves literal source language containing 'witness artifact' is not rewritten downstream."} {"ts":"2026-07-16T04:00:00Z","type":"implementation","status":"ok","note":"Codex-backed Persona text projection moved into persona-text-projection-actuator.ts, which owns model ordering, process arguments/lifetime, timeouts, output decoding, retry classification, and telemetry. Focused smoke proves last-agent-message extraction plus retry only for capacity/tool availability, and the heartbeat shell fell from 1250 to 1068 lines."} {"ts":"2026-07-16T04:20:00Z","type":"implementation","status":"ok","note":"Queued and inspection Persona turns now share persona-turn-assembler.ts as the single owner of conversation projection, jurisdiction cadence, final prompt construction, and bounded deduplicated image evidence. The duplicate shell attachment collector was deleted; typecheck plus initiative and migration smokes pass."} +{"ts":"2026-07-16T04:35:00Z","type":"implementation","status":"ok","note":"Built-in Void CTB process mechanics moved into void-moderation-turn-actuator.ts: pending-mention handoff, PowerShell quoting, detached launch, handshake polling, timeout, and scheduler receipt. The shell lost 115 lines; focused smoke proves escaping, fail-closed missing handshake, exact mention handoff, and receipt semantics."} diff --git a/state/map.yaml b/state/map.yaml index 88bcabc..678bc41 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -72,6 +72,7 @@ active_subgoals: - `persona-state-packet-projector.ts` owns pure assembly of typed memory sections, social context, topic attraction, curiosity facts, pressure sections, and human-clarity pressure into the deterministic Persona state packet. The orchestration shell supplies observations and one explicit observation time. It cannot rewrite projected language: the old regex vocabulary cleaner and its dead narrative helpers were deleted rather than moved. - `persona-text-projection-actuator.ts` owns Codex process execution for optional model-backed Persona memory projection: ordered model fallback, read-only arguments, timeout, stdout/stderr capture, final-message decoding, retry classification, and model-output telemetry. It receives a complete prompt and returns text; it cannot acquire Persona/Discord state, select initiative, or queue work. Capacity/tool-availability failures may try the next configured model; prompt/contract failures do not. - `persona-turn-assembler.ts` is the single final-composition owner for queued and inspection Persona turns. It combines supplied observations through conversation projection and turn-prompt policy, derives jurisdiction cadence, and returns one prompt plus one bounded deduplicated image bundle. It cannot acquire source state, select initiative, or queue work; the shell and actuator cannot maintain parallel prompt opinions. + - `void-moderation-turn-actuator.ts` owns the Windows-specific built-in Void turn launch boundary: pending-attention handoff, PowerShell quoting and detached process launch, lock/status handshake polling, timeout, and scheduler receipt. The orchestration shell chooses the participant kind but cannot launch, poll, sleep, or infer success from process creation alone. - `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. @@ -285,4 +286,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. Extract the remaining Windows-specific Void moderation launch and handshake mechanics from `scripts/run-repo-face-heartbeats.ts` into a narrow actuator. The shell may decide when the built-in Void participant needs a turn, but process launch, PowerShell encoding, handshake polling, timeout, and completion witness belong to one actuator. Do not unpause without an explicit controlled-live-fire decision. + next_action: Keep the Yggdrasil swarm paused. Extract global agent doctrine acquisition from `scripts/run-repo-face-heartbeats.ts` into a read-only source with explicit missing/unavailable facts, then let the assembler consume the resulting doctrine text. Do not move prompt composition into the source and do not unpause without an explicit controlled-live-fire decision.