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

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