Extract Persona turn routing

This commit is contained in:
Meta Vens 2026-07-15 23:31:25 +01:00
commit c2f1e58fce
7 changed files with 144 additions and 104 deletions

View file

@ -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))));
}

View file

@ -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 <repo> --state-path <face-state> --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.

View file

@ -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:<participantKind>`, `turn:<turnKind>`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, 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.

View file

@ -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<void> {
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]));
}

View file

@ -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");

View file

@ -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."}

View file

@ -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.