From c2f1e58fce0d77d1efcbdb80686d0b7d50292abd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 23:31:25 +0100 Subject: [PATCH] Extract Persona turn routing --- apps/persona-scheduler/src/turn-routing.ts | 97 ++++++++++++++++++ notes/fresh-workspace-handoff.md | 1 + notes/voidbot-current-system-map.md | 1 + scripts/run-repo-face-heartbeats.ts | 111 ++------------------- scripts/smoke-persona-initiative-engine.ts | 36 +++++++ state/evidence.jsonl | 1 + state/map.yaml | 1 + 7 files changed, 144 insertions(+), 104 deletions(-) create mode 100644 apps/persona-scheduler/src/turn-routing.ts diff --git a/apps/persona-scheduler/src/turn-routing.ts b/apps/persona-scheduler/src/turn-routing.ts new file mode 100644 index 0000000..92e9edb --- /dev/null +++ b/apps/persona-scheduler/src/turn-routing.ts @@ -0,0 +1,97 @@ +import type { RepoDiscordIdentity, RepoFacePendingMention } from "@voidbot/core"; + +export interface PersonaChannelPlan { + primaryChannelId?: string; + snapshotChannelIds: string[]; + options: PersonaChannelOption[]; + lowThresholdTopics: string[]; +} + +export interface PersonaChannelOption { + channelId: string; + label: string; + topic: string; + speechThreshold: "very_low" | "low" | "medium" | "high"; + speedMultiplier: number; + posture?: string; +} + +export function buildPersonaChannelPlan( + identity: RepoDiscordIdentity, + defaultChannelId?: string, + preferredChannelId?: string, +): PersonaChannelPlan { + const explicit = identity.channelPermissions.map((permission): PersonaChannelOption => ({ + 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): PersonaChannelOption => ({ + 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: PersonaChannelOption[] = explicit.length === 0 && legacy.length === 0 && defaultChannelId + ? [{ + channelId: defaultChannelId, + label: "aquarium", + topic: "casual Aquarium musing", + speechThreshold: "very_low", + 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 preferred = preferredChannelId ? options.find((option) => option.channelId === preferredChannelId) : undefined; + const defaultOption = defaultChannelId ? options.find((option) => option.channelId === defaultChannelId) : undefined; + const primary = preferred ?? defaultOption ?? options + .slice() + .sort((left, right) => thresholdRank(left.speechThreshold) - thresholdRank(right.speechThreshold))[0]; + + return { + primaryChannelId: primary?.channelId, + snapshotChannelIds: [...new Set([ + ...options.map((option) => option.channelId), + ...(preferredChannelId ? [preferredChannelId] : []), + ])], + options, + lowThresholdTopics: options + .filter((option) => thresholdRank(option.speechThreshold) <= thresholdRank("low")) + .map((option) => option.topic), + }; +} + +export function newestPendingMentionChannel(pendingMentions: RepoFacePendingMention[]): string | undefined { + return pendingMentions + .slice() + .sort((left, right) => Date.parse(right.queuedAt) - Date.parse(left.queuedAt))[0]?.channelId; +} + +export function personaChannelSpeedMultiplier(identity: RepoDiscordIdentity): number { + const multipliers = identity.channelPermissions.map((permission) => permission.speedMultiplier); + return multipliers.length > 0 ? clamp(Math.max(...multipliers), 0.5, 3) : 1; +} + +function thresholdRank(threshold: PersonaChannelOption["speechThreshold"]): number { + switch (threshold) { + case "very_low": return 0; + case "low": return 1; + case "medium": return 2; + case "high": return 3; + } +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Number(value.toFixed(3)))); +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index b421e99..d78361c 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -147,6 +147,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Bifrost governance acquisition is isolated in `bifrost-governance-source.ts`. That adapter alone owns the remaining `governance-threads.mjs digest` CLI contract and converts provider command/parse failures into typed error topics; prompt assembly receives governance facts and does not know the subprocess. - Scheduler state mutation is centralized. `initiative-engine.ts` owns reconciliation, controls, skip/stale witnesses, pressure, mention consumption, queue transitions, and tick finalization through named commits. The orchestration shell has no direct scheduler state/history writes; queue and context adapters only return facts. - Persona job submission is isolated in `turn-actuator.ts`. That output port alone owns worker actor/guild context construction, `ContextBundle` lowering, the `repo-face-rumination` command, approval defaults, request IDs, and `createJob`; it returns a queue receipt and has no state-selection or prompt-composition authority. +- Persona channel policy is isolated in `turn-routing.ts`. It owns pending-attention room preference, default/threshold fallback, snapshot neighborhoods, low-threshold topic projection, and bounded channel-speed projection; prompt/context code receives a route plan and does not choose rooms independently. - Dynamic heat is live as derived semantic response pressure. `semantic-pressure-source.ts` batches canonical Face affinity cards and recent permitted-channel human-message embeddings through the existing RAG embedder, applies recency decay/noisy-OR aggregation, and returns bounded projections for at most three scheduler-eligible Faces. It does not write scheduler state. `initiative-engine.ts` alone commits inspectable evidence, `dynamicHeat`, one-time semantic interrupt receipts, and readiness changes. Direct mentions remain obligations, and model/Discord failures fail cool without bypassing rest, active jobs, or scheduler capacity. - Repo Face heartbeat prompt assembly now has one model projection boundary before the child Face sees context: `repo-face-state-projector.prompt.md` turns typed Face state into character-facing memory/affect/social prose. `turn-context-source.ts` owns raw Discord acquisition and its bounded attachment cache; it returns current-room and nearby-channel messages oldest-to-newest plus explicit nearby-channel failure witnesses, without reading Persona state or composing prompts. Pending mentions remain scheduler-owned input. The deleted conversation projector was turning evidence into mushy consensus; do not revive it. The Face model must read human corrections directly. A separate deterministic social-affordance surface connects active typed person-bonds/status reads to recent raw messages by that person, so relationship state becomes a live hook instead of private flavor text. Model ownership is split: Face turns default to full `gpt-5.4`; state projection and parent Interpreter passes use the cheaper Spark/Mini support chain. - Repo Face prompts now include read-only recent home-repo activity before Discord transcript. `scripts/run-repo-face-heartbeats.ts` calls `scripts/export-recent-repo-activity.mjs --repos --state-path --read-only --hours 96 --max-commits 5`, so Faces see current body/jurisdiction motion before reacting to room pressure. This is the fix for Mimir missing live camera-runtime work, not a downstream compensator telling it to apologize harder. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index e5d6100..5abdab2 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -186,6 +186,7 @@ VoidBot runs on Yggdrasil. Idunn owns deployment and consumes upstream repositor - `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. +- `apps/persona-scheduler/src/turn-routing.ts` owns channel selection and projection. It turns registered channel permissions, the configured default, and the newest pending-attention channel into one `PersonaChannelPlan` containing the primary output room, nearby snapshot rooms, normalized options, and low-threshold topics. It also owns the bounded channel-speed multiplier used by participant specs. Context readers and prompt renderers consume this plan without deciding routes. - The same scheduler derives semantic `responsePressure` for scheduler-eligible Personas by embedding canonical Face identity-card projections and recent human messages from channels each Face may enter. Pressure decays by message age, aggregates with noisy-OR, awakens at most three eligible Faces, and exposes its message-scoped cosine evidence. It produces bounded `dynamicHeat` for the next scheduled recovery calculation; an unseen message crossing the Persona's interrupt threshold may pull readiness to the current initiative clock exactly once. It cannot create a job, bypass rest or active-turn brakes, impersonate a direct mention, or write inferred desire into Persona state. - The Windows `GameCult Local Orchestrator` and per-organ scheduled-task wrapper no longer own Persona pulses. `voidbot.service` deploys `persona-scheduler` beside bot, worker, Qdrant, and the CultMesh publisher. Turn frequency is controlled by `REPO_FACE_HEARTBEAT_BASE_RECOVERY_MINUTES`, `REPO_FACE_HEARTBEAT_GLOBAL_HEAT`, `REPO_FACE_HEARTBEAT_SPEED_OVERRIDES`, and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`. Heat keys can target `all`, `kind:`, `turn:`, `identity:`, `repo:`, `display:`, `channel:`, or the bare identity/repo/display key. - The local ignored registry currently includes Nibu for `AetheriaLore`, with Face state in `E:/Projects/AetheriaLore/.voidbot/state/nibu.cc`, Discord role id `1505918221453103247`, and an Aquarium channel allow-list. Nibu's Face state now treats AetheriaLore-first map-building, abrasive character voice, consensus-backed change agency, her own `Aetheria/Lore/Nibu.md` character-note stewardship, Nibu-authored lore essays, and tangential setting fascination as separate active doctrines. That tangential fascination points at embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival. The repo Face heartbeat organ is now pulsed by `GameCult Local Orchestrator`; after restarting the stack onto the new worker code, forced heartbeat job `76020f1e-0c55-424b-9e35-89ba7f77fcc3` completed without base-bot auto-posting. diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index 6094efb..4be8984 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -73,22 +73,13 @@ import { type BifrostGovernanceDigest, } from "../apps/persona-scheduler/dist/bifrost-governance-source.js"; import { submitPersonaTurn } from "../apps/persona-scheduler/dist/turn-actuator.js"; - -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; -} +import { + buildPersonaChannelPlan as buildChannelPlan, + newestPendingMentionChannel, + personaChannelSpeedMultiplier as channelSpeedMultiplierFor, + type PersonaChannelOption as RepoFaceChannelOption, + type PersonaChannelPlan as RepoFaceChannelPlan, +} from "../apps/persona-scheduler/dist/turn-routing.js"; async function main(): Promise { const config = loadConfig(); @@ -3930,76 +3921,6 @@ function buildInspectionParticipant( }; } -function buildChannelPlan( - identity: RepoDiscordIdentity, - defaultChannelId?: string, - preferredChannelId?: 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 preferred = preferredChannelId - ? options.find((option) => option.channelId === preferredChannelId) - : undefined; - const defaultOption = defaultChannelId - ? options.find((option) => option.channelId === defaultChannelId) - : undefined; - const primary = preferred ?? defaultOption ?? options - .slice() - .sort((left, right) => thresholdRank(left.speechThreshold) - thresholdRank(right.speechThreshold)) - [0]; - - return { - primaryChannelId: primary?.channelId, - snapshotChannelIds: [ - ...new Set([ - ...options.map((option) => option.channelId), - ...(preferredChannelId ? [preferredChannelId] : []), - ]), - ], - options, - lowThresholdTopics: options - .filter((option) => thresholdRank(option.speechThreshold) <= thresholdRank("low")) - .map((option) => option.topic), - }; -} - -function newestPendingMentionChannel(pendingMentions: RepoFacePendingMention[]): string | undefined { - return pendingMentions - .slice() - .sort((left, right) => Date.parse(right.queuedAt) - Date.parse(left.queuedAt)) - [0]?.channelId; -} - function renderChannelPermissionDirective(plan: RepoFaceChannelPlan): string { const options = plan.options.length > 0 ? plan.options.map((option) => @@ -4391,19 +4312,6 @@ function normalizeForRepetition(value: string): string { .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 @@ -4568,11 +4476,6 @@ async function readSwarmControlState(): Promise<{ globalHeat: number } | null> { } } -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 mergeStrings(values: string[], value: string): string[] { return Array.from(new Set([...values, value])); } diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts index eb2062c..6fe7f1e 100644 --- a/scripts/smoke-persona-initiative-engine.ts +++ b/scripts/smoke-persona-initiative-engine.ts @@ -8,6 +8,8 @@ import { readRepoFaceMentionInbox, type CreateJobInput, type JobQueue, + type RepoDiscordIdentity, + type RepoFacePendingMention, } from "@voidbot/core"; import type { JobRecord } from "@voidbot/shared"; @@ -47,6 +49,11 @@ import { } 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 { + buildPersonaChannelPlan, + newestPendingMentionChannel, + personaChannelSpeedMultiplier, +} from "../apps/persona-scheduler/dist/turn-routing.js"; function participant(identityId: string, nextTurnAt: number): InitiativeParticipant { return { @@ -375,6 +382,35 @@ assert.equal(submittedJobs[0].initialState, "approved", "Persona turns enter the assert.equal(submittedJobs[0].contextBundle.recentMessages.length, 2, "the actuator lowers supplied evidence into the worker context bundle"); assert.equal(submittedJobs[0].requester.id, "voidbot-agent-turn", "the actuator owns the worker-facing actor identity"); +const routedIdentity = { + id: "nibu", + repoName: "AetheriaLore", + displayName: "Nibu", + allowedChannelIds: ["legacy"], + channelPermissions: [{ + channelId: "aquarium", + label: "Aquarium", + topic: "casual conversation", + speechThreshold: "very_low", + speedMultiplier: 4, + }, { + channelId: "lore", + label: "Lore", + topic: "setting work", + speechThreshold: "medium", + speedMultiplier: 1, + }], +} as RepoDiscordIdentity; +const channelPlan = buildPersonaChannelPlan(routedIdentity, "aquarium", "lore"); +assert.equal(channelPlan.primaryChannelId, "lore", "fresh direct attention chooses its permitted source room"); +assert.deepEqual(channelPlan.snapshotChannelIds, ["aquarium", "lore", "legacy"], "routing exposes one deduplicated evidence neighborhood"); +assert.deepEqual(channelPlan.lowThresholdTopics, ["casual conversation"], "prompt renderers consume routing policy instead of recomputing thresholds"); +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" }, + { identityId: "nibu", channelId: "newer", queuedAt: "2026-07-15T21:00:00.000Z" }, +] as RepoFacePendingMention[]), "newer", "routing follows the newest pending room obligation"); + const stateDirectory = await mkdtemp(join(tmpdir(), "voidbot-persona-scheduler-")); try { const statePath = join(stateDirectory, "scheduler.json"); diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 4783c27..addef21 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -399,3 +399,4 @@ {"ts":"2026-07-15T22:17:01Z","type":"implementation","status":"ok","note":"Bifrost governance acquisition now lives in bifrost-governance-source.ts; it alone owns the digest CLI contract and maps command/parse failures into typed topics, leaving prompt assembly with provider facts rather than process mechanics."} {"ts":"2026-07-15T22:19:44Z","type":"implementation","status":"ok","note":"Scheduler state now has one write authority: initiative-engine.ts owns reconciliation, controls, skip/stale witnesses, semantic pressure, mention consumption, queue outcomes, and tick finalization; the heartbeat shell has no direct state assignment or history append."} {"ts":"2026-07-15T22:28:38Z","type":"implementation","status":"ok","note":"Persona worker submission now lives in turn-actuator.ts; it alone lowers assembled prompt/evidence into ContextBundle and approved repo-face-rumination jobs, while the orchestration shell has no ContextBuilder or createJob authority."} +{"ts":"2026-07-15T22:31:18Z","type":"implementation","status":"ok","note":"Persona channel policy now lives in turn-routing.ts; pending-attention preference, fallback selection, snapshot neighborhoods, low-threshold topics, and channel-speed projection share one deterministic route plan."} diff --git a/state/map.yaml b/state/map.yaml index 0a6dd07..d9b1dda 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -69,6 +69,7 @@ active_subgoals: - `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. + - `turn-routing.ts` owns deterministic Persona channel policy: newest pending-attention preference, default/threshold fallback, snapshot neighborhoods, low-threshold topic projection, and bounded channel-speed projection. Prompt renderers consume a `PersonaChannelPlan`; they do not recompute routing or scheduler speed. - Keep legacy moderation mirrors, whole-state projection commits, and deleted memory-organ compensators from re-entering the active path before adding more moderation, mood, agency, or public-lane behavior. - Keep public-lane limits, moderation, and provider behavior constrained enough that one 4060 Ti box does not get flayed for sport. - Preserve boring observability and startup discipline around Postgres, Qdrant, Ollama, Docker, backups, scheduled tasks, watchdogs, and any private local fire-signal extensions.