Extract Persona turn routing
This commit is contained in:
parent
a7ac9b51e9
commit
c2f1e58fce
7 changed files with 144 additions and 104 deletions
97
apps/persona-scheduler/src/turn-routing.ts
Normal file
97
apps/persona-scheduler/src/turn-routing.ts
Normal 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))));
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue