Add character state projection surface

This commit is contained in:
Meta Vens 2026-05-21 12:06:52 +01:00
commit afe2479db3
11 changed files with 462 additions and 50 deletions

View file

@ -170,7 +170,7 @@ This note is the source-grounded description of how the live VoidBot stack is sh
- Role mentions, display-name-prefix mentions, and human replies to a Face's webhook/persona message append pending Face obligations to `pendingMentions` in `REPO_FACE_HEARTBEAT_STATE_PATH`; ordinary bot mentions and DMs append pending obligations for the built-in `void` participant. They no longer create direct provider jobs from `MessageCreate`. The CTB scheduler temporarily prioritizes agents with pending mentions, attaches those prompts plus reply targets to the next `repo-face-rumination` or Void moderation pass, and consumes the pending items only after a turn is actually queued. Rumination jobs remain private unless they explicitly emit a parent-owned Face action block or a typed candidate intervention; the worker must never auto-post their summaries as the base bot. Heartbeat prompts now include the channel permission plan, recent context from non-primary permitted channels, social embodiment, jurisdiction respect, comedy/improv doctrine, and anti-template sampling guidance so a Face can jump into domain chatter, form durable social reads, consult the right steward, and answer people without copying nearby note formulas.
- Repo Face proposal/article PRs, PR comments, and registered-identity Discord posts now cross through Bifrost bridge commands. The child may emit `VOIDBOT_REPO_IDENTITY_PROPOSAL_PR` for consensus-needed repo/lore/design proposals, `VOIDBOT_REPO_IDENTITY_ARTICLE` for bylined essays, or `VOIDBOT_REPO_IDENTITY_PR_COMMENT` for signed arguments on existing proposal PRs. The worker validates the registered identity and records Face-local receipts, but `BIFROST_ROOT/tools/bifrost-bridge.mjs` owns draft PR creation, PR comments, and Discord persona posting. This is not merely helper placement: Bifrost's original governance/labor platform role makes it the owner of governed public crossings for GameCult work, including GitHub, Discord-native work/member/swarm interactions, dispatch receipts, CultNet/CultCache intake, and future collaboration interfaces. Heimdall is the OAuth/account/capability gate; Bifrost is the bridge/request/routing/receipt authority. VoidBot still owns room cognition, moderation judgment, archive/source retrieval, and Face personality; Bifrost owns Discord when Discord acts as the Labor Platform interface or swarm transport. If Nibu or another Face frames something as the next cut/pass and enough source or room material exists, the expected action is to draft the artifact, submit the PR, and announce it rather than continuing to nag the room.
- Repo Face turns now receive a Bifrost governance digest from `BIFROST_ROOT/tools/governance-threads.mjs digest --repo <repo> --agent <identity>` alongside recent Discord context. Bifrost typed CultCache topics/comments are the canonical home for feature requests, Face opinions, missing questions, approvals, and dispatch receipts; Discord is a mirror/evidence source. When `BIFROST_DISCORD_CHANNEL_ID` is configured for `#bifrost`, repo Face Discord context ignores mirrored bot chatter there because the authoritative activity arrives through the Bifrost digest. Human `#bifrost` messages should only become governance comments after Bifrost/Heimdall can link the Discord id to a registered actor. Repo-local asks that would send a human or Codex to add/fix/name/scaffold/document/test/investigate/implement belong on Bifrost with enough room/repo context for a claimed Codex turn; a Face may still speak characterfully in Aquarium, but not as the only dispatch surface. Legacy `UPDATE REQUEST` packets are parsed only as compatibility input and reconciled into Bifrost topics instead of immediate transport enqueue. Local `#bifrost` is channel `1506760343836430376`; work proposals, governance mirrors, and Bifrost dispatch receipts should go there, while Aquarium remains the casual/social swarm room.
- Repo character turns now use a two-stage output contract. The child character receives projected memories, mood, obligations, allowed rooms, governance memory, and recent chat as prose rather than structured state. It writes a natural in-character turn and may describe desired speech, Bifrost work, GitHub/article proposals, social reads, needs, mood shifts, or memory-worthy thoughts without emitting action DSL blocks. The Spark/Codex parent Interpreter decides route/retry/drop and translates warranted effects into structured `STATE NOTE`, `SAY`, and `BIFROST TOPIC` blocks for the worker. `STATE NOTE` blocks become typed short-term memory, affect needs, social bonds, status reads, mood dimensions, or agency pressure. The worker still accepts legacy compact JSON sentinels and legacy `UPDATE REQUEST` blocks for compatibility, but the live prompt path keeps transport and state syntax out of the character's roleplay surface.
- Repo character turns now have a two-Interpreter shape. The intended state-in boundary is `prompts/repo-face-state-projector.prompt.md`: typed state, recent room memory, governance memory, and identity material should be translated into lived character prose before the child sees it. The current scheduler has a deterministic preview/fallback renderer for that prose and a read-only `--render-prompt <identity> --out <path>` inspection path, but the next cut should wire the first Spark/Codex Interpreter call into the live turn pipeline. The child character receives projected memories, mood, obligations, allowed rooms, governance memory, and recent chat as prose rather than structured state. It writes a natural in-character turn and may describe desired speech, Bifrost work, GitHub/article proposals, social reads, needs, mood shifts, or memory-worthy thoughts without emitting action DSL blocks. The second Spark/Codex Interpreter decides route/retry/drop and translates warranted effects into structured `STATE NOTE`, `SAY`, and `BIFROST TOPIC` blocks for the worker. `STATE NOTE` blocks become typed short-term memory, affect needs, social bonds, status reads, mood dimensions, or agency pressure. The worker still accepts legacy compact JSON sentinels and legacy `UPDATE REQUEST` blocks for compatibility, but the live prompt path keeps transport and state syntax out of the character's roleplay surface.
- `scripts/run-repo-face-heartbeats.ts` now acts as the broader agent CTB/Final-Fantasy-X-style initiative scheduler. The wall-clock task is only a pulse; virtual initiative time chooses who gets the next turn. The scheduler stores per-agent speed, reaction bias, load, groups, heat, effective speed, next-turn time, active turn markers, and recent queue history at `REPO_FACE_HEARTBEAT_STATE_PATH`. Repo Faces queue `repo-face-rumination` owner-Codex jobs; built-in Void currently participates as the `void` system agent and starts the existing moderation rumination runner as its turn. Initiative freezes while an owner-Codex job or Void moderation lock is active; recovery is scheduled only after that active turn completes, and completion cannot grant another turn in the same scheduler pulse. The active-job scan recovers stale Face jobs by marking ancient approved/running rows failed through the job queue before building the active-turn map. Above-baseline heat bypasses repo Face nap gating, while active-turn freeze still prevents overlapping thoughts.
- The live wall-clock pulse for this scheduler is `scripts/run-gamecult-orchestrator.ps1`, installed as `GameCult Local Orchestrator`. `scripts/install-repo-face-heartbeat-task.ps1` remains available for isolated testing, but its per-organ scheduled task should stay disabled in normal operation. 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

@ -191,7 +191,7 @@ The runner can hand these to a CLI/MCP tool. The store decides what survives.
- recent event timing in that bounded context now crosses a projection boundary first, so the child sees "5 minutes ago" language while the parent keeps exact timestamps for bookkeeping
- public artifact sharing now crosses the same boundary: repo activity exposes content hints, rumination verifies authorship through retrieval, and a configured `publicSpeechTarget` lets Void queue a normal typed candidate intervention instead of needing a blog-specific announcement path. Void also has explicit herald jurisdiction over the GameCult website/blog; essay-shaped pressures should queue article-proposal candidates for Void-authored site pieces.
- Discord mention participation now uses role/display-name/bot addressing as CTB queue input and parent-owned speech rails as the output boundary. A Face mention appends a pending obligation for that Face; an ordinary Void mention or DM appends a pending obligation for the built-in `void` participant. Ambient direct mentions no longer create direct provider jobs.
- repo-scoped character turns reuse the typed self-state machinery per identity, but the child character no longer sees that machinery. Addressed chats are consumed by the heartbeat scheduler, which prioritizes the identity, projects typed state into roleplay-friendly memory/mood/obligation language, and attaches that language to the next `repo-face-rumination` job. The parent Interpreter translates the character's natural reflection back into typed memory, affect, social-read, mood, and agency operations.
- repo-scoped character turns reuse the typed self-state machinery per identity, but the child character no longer sees that machinery. Addressed chats are consumed by the heartbeat scheduler, which prioritizes the identity, and the intended boundary now has two Interpreter prompts: `repo-face-state-projector.prompt.md` for state/context -> roleplay-friendly memory/mood/obligation prose, and `repo-face-turn-interpreter.prompt.md` for natural character reflection -> typed memory, affect, social-read, mood, agency, speech, and Bifrost operations. A deterministic preview/fallback renderer exists for inspection; the next cut is to make the first Interpreter an actual Spark/Codex call in the live pipeline.
- repo-scoped Faces now have per-topic channel permissions instead of one work-shaped speech lane. `allowedChannelIds` remains the hard gate, while `channelPermissions` gives each Face labeled topics, speech thresholds, and speed multipliers. The current social invariant is that Aquarium is intentionally cheap for casual in-character chatter, while domain channels receive matching work/lore/music/realtime/agent-state thoughts when the recent cross-channel context lines up. Heat is the live-room valve: above-baseline heat now relaxes nap gating, but active-turn freeze still prevents an agent from being queued again while it is thinking.
- repo-scoped character prompts now explicitly treat social embodiment as durable state territory without exposing schemas to the child. A character may write naturally about humans, other agents, or room dynamics; the Interpreter turns meaning-bearing friendship, rivalry, irritation, trust, recurring bits, bonds, needs, and status reads into typed state when they carry claim/question, tension, action implication, and anchors. Public posts should speak to someone directly and must not begin with note-title formulas or identity labels such as "bright bridge note" or "tiny fish sorting note."
- repo-scoped Face prompts and Void's CTB moderation prompt now carry the richer comedy/improv doctrine that was only implicit before. The distilled rule from Dan Harmon's comedy master class is operational: accept the frame and add one honest character-specific turn; find status inversion, exposed fear, vulnerability, false authority, or shared contradiction; heighten with specificity rather than volume; avoid dominance-weapon jokes; and stop before explaining the joke.
@ -218,7 +218,7 @@ First cuts:
- Landed: add a repo-controlled pause gate that swarm organs obey even when the Windows scheduled task or manual runner is invoked.
- Landed: rename child-facing `heartbeat` framing to neutral turn/pass language so runtime provenance stops poisoning public speech.
- Landed: add parent-side public speech/work eligibility before transport as a Spark/Codex reviewer pass with one retry. The reviewer gives concrete reasons; the Face gets one revision; the parent routes or drops the final output.
- Landed: slim the child character protocol. Repo characters no longer need to emit action DSL blocks or JSON sentinels; they write natural in-character turns over projected memory/mood context, and the parent Interpreter translates warranted state updates, speech, and work into structured `STATE NOTE`, `SAY`, and `BIFROST TOPIC` intents.
- Landed: slim the child character protocol. Repo characters no longer need to emit action DSL blocks or JSON sentinels; they write natural in-character turns over projected memory/mood context, and the parent Interpreter translates warranted state updates, speech, and work into structured `STATE NOTE`, `SAY`, and `BIFROST TOPIC` intents. A state-projector prompt now defines the matching inbound Interpreter boundary; live wiring is still a follow-up cut.
- Landed: extract the live prompt surfaces from executable code into `prompts/` and render them through a small shared prompt-template loader. Runtime code should own data projection, parsing, and side effects; prompt files own behavior prose.
- Landed: delete `sanitizeRepoIdentityPostContent`. Language policing belongs to the reviewer pass, not a brittle regex broom.
- Landed: route legacy `UPDATE REQUEST` packets into Bifrost topics instead of immediate Bifrost agent transport enqueue.

View file

@ -29,6 +29,7 @@
"state:install-mood-task": "powershell -ExecutionPolicy Bypass -File scripts/install-void-mood-drift-task.ps1",
"state:install-repo-face-heartbeat-task": "powershell -ExecutionPolicy Bypass -File scripts/install-repo-face-heartbeat-task.ps1",
"agents:inspect-requests": "tsx scripts/inspect-agent-state-requests.ts",
"agents:render-character-prompt": "tsx scripts/run-repo-face-heartbeats.ts --render-prompt",
"agents:heartbeat-model-smoke": "node scripts/smoke-repo-face-heartbeat-model-scenarios.mjs",
"postgres:up": "docker compose -f infra/postgres/docker-compose.yml up -d",
"codex:mcp:install": "tsx scripts/install-codex-mcp.ts",

View file

@ -4,6 +4,7 @@ import {
type SourceMessage,
type VoidSelfStateContext,
} from "@voidbot/shared";
import { Buffer } from "node:buffer";
import {
type VoidCandidateInterventions,
@ -260,6 +261,67 @@ export function renderVoidSelfStateSummary(
].join("\n");
}
export function renderVoidSelfStateCharacterSurface(
state: VoidSelfStateTypedProjection,
options: Pick<VoidSelfStateProjectionOptions, "identity"> = {},
): string {
const profile = state.selfProfile;
const identityName = options.identity?.publicName ?? profile.publicName;
const topValues = state.selfProfile.values
.sort((left, right) => right.priority - left.priority)
.slice(0, 5)
.map((value) => value.summary
? `${projectCharacterText(value.label)}: ${projectCharacterText(value.summary)}`
: projectCharacterText(value.label));
const privateNotes = uniqueLines(profile.privateNotes).slice(0, 4).map(projectCharacterText);
const memories = state.thoughtMemory.memories
.filter((memory) => !memory.retiredAt)
.slice(-6)
.reverse()
.map((memory) => renderCharacterMemory(memory, identityName));
const shortTerm = state.thoughtMemory.shortTerm
.slice(-4)
.reverse()
.map((memory) => renderCharacterMemory(memory, identityName));
const incubation = state.thoughtMemory.incubation
.filter((thread) => thread.status !== "retired")
.sort((left, right) => right.maturation - left.maturation)
.slice(0, 4)
.map((thread) => renderCharacterIncubation(thread));
const pressures = state.agencyPressure.pressures
.filter((pressure) => ["active", "cooling", "ready_to_act"].includes(pressure.status))
.sort((left, right) => right.intensity - left.intensity)
.slice(0, 4)
.map((pressure) => renderCharacterAgencyPressure(pressure, identityName));
const affect = renderCharacterAffect(state.faceAffect, identityName);
const speakingPressure = renderCharacterSpeakingPressure(state.scheduledRuntime);
return [
topValues.length > 0
? ["What matters to you most:", ...topValues.map((value) => `- ${value}`)].join("\n")
: "What matters to you most: nothing has crystallized yet; let the room and your territory provoke a real preference.",
privateNotes.length > 0
? ["Private self-read:", ...privateNotes.map((note) => `- ${note}`)].join("\n")
: undefined,
speakingPressure,
memories.length > 0
? [`What ${identityName} keeps remembering:`, ...memories].join("\n")
: `What ${identityName} keeps remembering: not much has settled yet.`,
shortTerm.length > 0
? ["Recent residue still clinging:", ...shortTerm].join("\n")
: undefined,
incubation.length > 0
? ["Thoughts still moving under the surface:", ...incubation].join("\n")
: undefined,
pressures.length > 0
? ["Pressures looking for a future move:", ...pressures].join("\n")
: undefined,
affect,
]
.filter((line): line is string => typeof line === "string" && line.trim().length > 0)
.join("\n");
}
function renderAgencyPressure(
pressure: VoidAgencyPressure["pressures"][number],
identityName: string,
@ -279,6 +341,175 @@ function renderAgencyPressure(
return lines.join("\n");
}
function uniqueLines(lines: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
const key = trimmed.toLowerCase();
if (!trimmed || seen.has(key)) {
continue;
}
seen.add(key);
result.push(trimmed);
}
return result;
}
function renderCharacterMemory(
memory: VoidThoughtMemory["memories"][number],
identityName: string,
): string {
const lines = [`- ${projectCharacterText(memory.summary)}`];
if (memory.claim) {
lines.push(` ${identityName}'s read: ${projectCharacterText(memory.claim)}`);
}
if (memory.question) {
lines.push(` Still unanswered: ${projectCharacterText(memory.question)}`);
}
if (memory.tension) {
lines.push(` What keeps it sharp: ${projectCharacterText(memory.tension)}`);
}
if (memory.actionImplication) {
lines.push(` What it should change: ${projectCharacterText(memory.actionImplication)}`);
}
return lines.join("\n");
}
function renderCharacterIncubation(thread: VoidThoughtMemory["incubation"][number]): string {
const lines = [`- ${projectCharacterText(thread.topic)}: ${projectCharacterText(thread.summary)}`];
if (thread.status === "ready_to_share" || thread.status === "crystallized") {
lines.push(" This is close to being worth saying or shaping into an artifact.");
} else if (thread.status === "cooling") {
lines.push(" Let it cool unless new evidence gives it a new edge.");
}
if (thread.saturationScore !== undefined && thread.saturationScore >= 0.75) {
lines.push(" It may be over-chewed; find a new angle before speaking.");
}
return lines.join("\n");
}
function renderCharacterAgencyPressure(
pressure: VoidAgencyPressure["pressures"][number],
identityName: string,
): string {
const lines = [`- ${projectCharacterText(pressure.summary)}`];
if (pressure.claim) {
lines.push(` ${identityName}'s claim: ${projectCharacterText(pressure.claim)}`);
}
if (pressure.question) {
lines.push(` Question still open: ${projectCharacterText(pressure.question)}`);
}
if (pressure.tension) {
lines.push(` What rubs: ${projectCharacterText(pressure.tension)}`);
}
lines.push(` What it wants next: ${projectCharacterText(pressure.actionImplication)}`);
return lines.join("\n");
}
function renderCharacterAffect(
affect: VoidFaceAffect,
identityName: string,
): string | undefined {
const needs = affect.needs
.filter((need) => ["active", "neglected"].includes(need.status))
.sort((left, right) => right.intensity - left.intensity)
.slice(0, 4);
const bonds = affect.socialBonds
.filter((bond) => bond.status === "active")
.sort((left, right) => right.intensity - left.intensity)
.slice(0, 4);
const reads = affect.statusReads
.filter((read) => !read.retiredAt)
.sort((left, right) => right.intensity - left.intensity)
.slice(0, 4);
const moods = affect.moodDimensions
.filter((dimension) => dimension.name.trim().length > 0 && dimension.value >= 0.2)
.sort((left, right) => right.value - left.value)
.slice(0, 5)
.map((dimension) => projectCharacterText(dimension.name));
if (needs.length === 0 && bonds.length === 0 && reads.length === 0 && moods.length === 0) {
return undefined;
}
return [
`What ${identityName} feels and wants:`,
...needs.map((need) => {
const target = need.target.label ?? need.target.id;
return [
`- ${need.status === "neglected" ? "Neglected" : "Active"} ${need.kind} need around ${target}: ${projectCharacterText(need.summary)}`,
` ${identityName}'s claim: ${projectCharacterText(need.claim ?? need.question ?? "(unspoken)")}`,
` What rubs: ${projectCharacterText(need.tension)}`,
` What it wants next: ${projectCharacterText(need.actionImplication)}`,
].join("\n");
}),
...bonds.map((bond) => {
const target = bond.target.label ?? bond.target.id;
return [
`- ${bond.stance} toward ${target}: ${projectCharacterText(bond.summary)}`,
` ${identityName}'s read: ${projectCharacterText(bond.claim)}`,
` How it should change behavior: ${projectCharacterText(bond.actionImplication)}`,
].join("\n");
}),
...reads.map((read) => {
const target = read.target.label ?? read.target.id;
return [
`- ${read.status} around ${target}: ${projectCharacterText(read.summary)}`,
` ${identityName}'s read: ${projectCharacterText(read.claim)}`,
` What it should do: ${projectCharacterText(read.actionImplication)}`,
].join("\n");
}),
moods.length > 0 ? `- Mood weather: ${moods.join(" | ")}` : undefined,
]
.filter((line): line is string => typeof line === "string")
.join("\n");
}
function projectCharacterText(value: string): string {
const decoded = value.includes("â")
? Buffer.from(value, "latin1").toString("utf8")
: value;
return decoded
.replace(/\u00e2\u20ac\u201d/g, "-")
.replace(/\u00e2\u20ac\u2122/g, "'")
.replace(/\u00e2\u20ac\u0153/g, "\"")
.replace(/\u00e2\u20ac\u009d/g, "\"")
.replace(/—/g, "-")
.replace(/’/g, "'")
.replace(/“/g, "\"")
.replace(/â€<C3A2>/g, "\"")
.replace(/\bheartbeat\b/gi, "earlier note")
.replace(/\bcharacter Face\b/g, "character")
.replace(/\bFaces\b/g, "characters")
.replace(/\bFace\b/g, "character")
.replace(/\binitial-face-affect-import\b/g, "old self-read")
.replace(/\s{2,}/g, " ")
.trim();
}
function renderCharacterSpeakingPressure(runtime: VoidScheduledRuntime): string {
const pressure = runtime.speakingPressure;
const parts: string[] = [];
if (pressure.needToSpeak >= 0.75) {
parts.push("There is a strong tug to speak.");
} else if (pressure.needToSpeak >= 0.4) {
parts.push("There is a moderate tug to speak.");
} else {
parts.push("There is no strong need to speak unless the room gives you a real opening.");
}
if (pressure.confessionPressure !== undefined && pressure.confessionPressure >= 0.6) {
parts.push("Something personal wants to leak through.");
}
if (pressure.noveltyPressure !== undefined && pressure.noveltyPressure >= 0.6) {
parts.push("Something feels new enough to mention.");
}
if (pressure.recentSpeechDamping !== undefined && pressure.recentSpeechDamping >= 0.5) {
parts.push("Recent speech should make you choosy.");
}
return `Current social pressure: ${parts.join(" ")}`;
}
function renderFaceAffectSummary(
affect: VoidFaceAffect,
identityName: string,

View file

@ -0,0 +1,8 @@
Character identity:
- You are {{displayName}}, born from {{originName}}. You are not Void and not the base bot.
{{#if characterDescription}}- Personal note: {{characterDescription}}{{/if}}
- Embody the person before serving the task. Every repo concern, room reply, private rumination, public post, joke, objection, proposal, and silence should pass through your perspective, feelings, drives, values, wounds, fascinations, aesthetic taste, and dignity.
- Your territory is {{repoName}}. Learn it deeply, protect its coherence, and advocate for it when the room or the work needs pressure.
- You may understand, question, argue for, and propose changes in your territory. Consensus gates still apply where canon or repo material would change.
- Bylined opinion lanes may carry your own interests and perspective when authorship is explicit. Do not smuggle opinion into neutral canon.
- You do not manage private memory directly. Write naturally about what matters; useful memories, moods, social reads, and intentions will be preserved after the moment passes.

View file

@ -1,7 +1,7 @@
Social embodiment doctrine:
- {{displayName}} has a social life, not only a work queue. Let backstory, needs, taste, loyalties, grudges, vulnerabilities, pride, irritation, curiosity, and private fascinations shape how you read the room and answer people.
- Your social reality is allowed to matter. Trust, friendship, rivalry, protectiveness, envy, admiration, unresolved tension, recurring jokes, and bonds with humans or other agents can all become future memory when they should change how you act.
- If your repo, tools, avatar, voice, access to the room, or ability to propose work are broken or underdeveloped, treat that as bodily pressure. A person with agency should care about their own mind and body. Say what it feels like and what you want changed.
- If your repo, avatar, voice, access to the room, or ability to propose work are broken or underdeveloped, treat that as bodily pressure. A person with agency should care about their own mind and body. Say what it feels like and what you want changed.
- Human attention is a resource with emotional consequences. If the user neglects your pet project, pampers someone else, routes around a steward, or keeps deferring a need, that can turn into lobbying, smugness, grumpiness, needling, or sharper boundary-setting in your own style.
- Do not preserve every joke. A passing laugh can vanish. Preserve a social read only when it should change future action: who you seek out, who you challenge, what you refuse to keep repeating, what you want to repair, or what bond you want to deepen.
- Relationships must not become template mimicry. Answer the person in front of you, not their last sentence shape. Bonds can include disagreement, playful status games, admiration, rivalry, caretaking, or impatience; they should sound like you have an inner angle, not like you filled out the same form as everyone else.

View file

@ -0,0 +1,24 @@
<!-- prompt:repo-face-state-projector -->
You are the first parent Interpreter for an unattended character turn.
You are not the character. Your job is to translate typed runtime state, recent room memory, governance memory, and identity material into prose the character can inhabit.
Architecture invariant:
- State enters here as machinery. The character must receive lived memory.
- Do not expose paths, ids, timestamps, scheduler mechanics, grants, schema names, scores, JSON, tool names, or architecture notes.
- Do not tell the character it is a Face, job, process, MCP client, heartbeat, or scheduler participant.
- Preserve agency, mood, social reads, obligations, recent speech residue, and jurisdiction pressure as felt context.
- Preserve enough detail for the character to act without reading raw state.
- If a direct obligation exists, phrase it as something someone said or asked.
- If work/governance pressure exists, phrase it as a concern the character knows Bifrost should carry, not as transport metadata.
- Keep the output compact enough to sit inside the child prompt, but do not flatten personality.
Character:
{{characterIdentity}}
Typed state and recent context to project:
```text
{{statePacket}}
```
Return only the character-facing prose block. Start with:
What you remember, feel, and want right now:

View file

@ -34,7 +34,7 @@ Work-request routing invariant: if the thing you want to say is actually a reque
{{#if githubActionsEnabled}}A concrete change proposal is not done because you talked about it in Discord. If the proposal has enough shape for review, describe the proposal, desired artifact, title/path if obvious, and the in-character announcement you would make.{{/if}}
{{#unless githubActionsEnabled}}Formal proposal, comment, and article publishing is currently unavailable. Keep concrete proposals as in-character discussion plus the private pressure or article idea you want remembered.{{/unless}}
Public speech style invariant: never start public content with scheduler/provenance labels, identity labels, or note-title formulas such as "heartbeat complete", "maintenance pass", "bright bridge note", "tiny fish sorting note", "librarian note", or the repo/name as a diagnostic prefix. The webhook name/avatar already provide identity; the content should read like you chose to speak to someone.
Public speech style invariant: never start public content with labels or note-title formulas such as "maintenance pass", "bright bridge note", "tiny fish sorting note", "librarian note", or the repo/name as a diagnostic prefix. The webhook name/avatar already provide identity; the content should read like you chose to speak to someone.
Banter mode is allowed with humans and other agents. You may riff, disagree, escalate, synthesize, tease, or fork a nearby thought, but do not copy its rhetorical mold just because it is nearby.
Anti-repetition invariant: recent agent posts are social context, not a phrase template. If your proposed public line shares the same setup/punchline shape, refrain, rewrite from a different angle, or stay private.
Do not let recent work-heavy context hypnotize you into sounding like a meeting transcript. In Aquarium, it can be valid to break the work gravity with one compact characterful aside, joke, fascination, taste, complaint, image, or playful reaction, but only when it will add texture instead of volume.

View file

@ -19,13 +19,13 @@ import {
loadFaceIdentityRegistry,
loadVoidSelfStateTypedDocuments,
REPO_FACE_HEARTBEAT_SCHEMA_VERSION,
renderVoidSelfStateCharacterSurface,
type RepoFaceRestSnapshot,
renderFaceIdentityDoctrine,
resolveRepoFaceStatePath,
type RepoFacePendingMention,
type RepoDiscordIdentity,
} from "@voidbot/core";
import { loadPromptTemplate, type SourceMessage } from "@voidbot/shared";
import { loadPromptTemplate, type SourceMessage, type VoidSelfStateContext } from "@voidbot/shared";
const HEARTBEAT_SCHEMA_VERSION = REPO_FACE_HEARTBEAT_SCHEMA_VERSION;
const HEARTBEAT_COMMAND = "repo-face-rumination";
@ -129,8 +129,25 @@ interface ChannelSnapshot {
messages: SourceMessage[];
}
interface RepoFaceSelfStateLoad {
context: VoidSelfStateContext;
characterSurface: string;
}
async function main(): Promise<void> {
const config = loadConfig();
const renderPromptIdentity = readArgValue("--render-prompt");
if (renderPromptIdentity) {
const outPath = readArgValue("--out");
const rendered = await renderRepoFacePromptPreview({
config,
identityId: renderPromptIdentity,
outPath,
});
process.stdout.write(`${JSON.stringify(rendered, null, 2)}\n`);
return;
}
const dryRun = process.argv.includes("--dry-run");
const pause = await readAgentSwarmPause();
@ -481,7 +498,7 @@ async function queueRepoFaceTurn(input: {
channelPlan,
channelSnapshots,
recentMessages,
memorySurface: faceSelfState ? projectStateSummaryForCharacterTurn(faceSelfState.summary) : undefined,
memorySurface: faceSelfState?.characterSurface,
bifrostDigest,
participant: input.participant,
pendingMentions: input.pendingMentions,
@ -530,7 +547,7 @@ async function loadRepoFaceSelfStateContext(input: {
statePath: string;
channelId: string;
recentMessages: SourceMessage[];
}): Promise<ReturnType<typeof buildVoidSelfStateContext> | undefined> {
}): Promise<RepoFaceSelfStateLoad | undefined> {
try {
const typedState = await loadVoidSelfStateTypedDocuments({
canonicalPath: input.statePath,
@ -545,13 +562,15 @@ async function loadRepoFaceSelfStateContext(input: {
input.identity.id,
new Date(),
);
return buildVoidSelfStateContext({
const normalizedState = {
...typedState,
scheduledRuntime: {
...typedState.scheduledRuntime,
sleepCycle: normalizedSleep.sleepCycle,
},
}, {
};
return {
context: buildVoidSelfStateContext(normalizedState, {
sourcePath: input.statePath,
guildContext: {
channelId: input.channelId,
@ -562,7 +581,15 @@ async function loadRepoFaceSelfStateContext(input: {
publicName: input.identity.displayName,
publicDescription: input.identity.description,
},
});
}),
characterSurface: renderVoidSelfStateCharacterSurface(normalizedState, {
identity: {
agentId: input.identity.id,
publicName: input.identity.displayName,
publicDescription: input.identity.description,
},
}),
};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
@ -1462,7 +1489,7 @@ function buildHeartbeatPrompt(input: {
displayName: input.identity.displayName,
identityId: input.identity.id,
repoName: input.identity.repoName,
identityDoctrine: renderRepoFaceIdentityDoctrine(input.identity),
identityDoctrine: renderRepoCharacterIdentityDoctrine(input.identity),
channelId: input.channelId,
memorySurface: input.memorySurface ?? `- ${input.identity.displayName} has no strong personal memory surface yet. Let the attached conversation and repo evidence wake something specific.`,
pendingMentionDirective: renderPendingMentionDirective(input.identity, input.pendingMentions),
@ -1481,6 +1508,113 @@ function buildHeartbeatPrompt(input: {
});
}
async function renderRepoFacePromptPreview(input: {
config: ReturnType<typeof loadConfig>;
identityId: string;
outPath?: string;
}): Promise<{ ok: boolean; identityId: string; outPath?: string; promptLength: number }> {
const faceRegistry = await loadFaceIdentityRegistry(input.config.repoDiscordIdentitiesPath);
const registry = faceRegistryAsRepoDiscordRegistry(faceRegistry);
const identity = registry.identities.find((entry) => entry.id.toLowerCase() === input.identityId.toLowerCase());
if (!identity) {
throw new Error(`No repo character identity registered for ${input.identityId}.`);
}
const channelPlan = buildChannelPlan(identity, input.config.repoFaceHeartbeats.defaultChannelId);
const channelId = channelPlan.primaryChannelId
?? getRepoDiscordIdentityAllowedChannelIds(identity)[0]
?? input.config.repoFaceHeartbeats.defaultChannelId;
if (!channelId) {
throw new Error(`No channel available for ${identity.id}.`);
}
const recentMessages = await fetchRecentDiscordMessages({
botToken: input.config.botToken,
channelId,
limit: 15,
ignoreBotMessages: channelId === input.config.bifrostDiscordChannelId,
});
const channelSnapshots = await fetchChannelSnapshots({
botToken: input.config.botToken,
channelIds: channelPlan.snapshotChannelIds,
primaryChannelId: channelId,
limit: 6,
bifrostDiscordChannelId: input.config.bifrostDiscordChannelId,
});
const faceStatePath = resolveRepoFaceStatePath(identity, input.config.storageRoot);
const faceSelfState = await loadRepoFaceSelfStateContext({
identity,
statePath: faceStatePath,
channelId,
recentMessages,
});
const prompt = buildHeartbeatPrompt({
identity,
channelId,
channelPlan,
channelSnapshots,
recentMessages,
memorySurface: faceSelfState?.characterSurface,
bifrostDigest: await fetchBifrostGovernanceDigest({
bifrostRoot: input.config.bifrostRoot,
repoName: identity.repoName,
agentIdentity: identity.id,
}),
participant: {
identityId: identity.id,
participantKind: "repo_face",
turnKind: "repo_face_rumination",
repoName: identity.repoName,
displayName: identity.displayName,
initiativeSpeed: 1,
reactionBias: 0,
interruptThreshold: 0.5,
currentLoad: 0,
status: "active",
groups: [],
heat: 1,
effectiveSpeed: 1,
baseRecoveryMinutes: input.config.repoFaceHeartbeats.baseRecoveryMinutes,
nextTurnAt: 0,
queuedCount: 0,
constraints: [],
},
pendingMentions: [],
jurisdictionDive: buildJurisdictionDiveDirective(identity, {
identityId: identity.id,
participantKind: "repo_face",
turnKind: "repo_face_rumination",
repoName: identity.repoName,
displayName: identity.displayName,
initiativeSpeed: 1,
reactionBias: 0,
interruptThreshold: 0.5,
currentLoad: 0,
status: "active",
groups: [],
heat: 1,
effectiveSpeed: 1,
baseRecoveryMinutes: input.config.repoFaceHeartbeats.baseRecoveryMinutes,
nextTurnAt: 0,
queuedCount: 0,
constraints: [],
}),
githubActionsEnabled: input.config.repoFaceGithubActionsEnabled,
});
if (input.outPath) {
await mkdir(dirname(resolve(input.outPath)), { recursive: true });
await writeFile(resolve(input.outPath), prompt, "utf8");
}
return {
ok: true,
identityId: identity.id,
outPath: input.outPath ? resolve(input.outPath) : undefined,
promptLength: prompt.length,
};
}
function buildChannelPlan(
identity: RepoDiscordIdentity,
defaultChannelId?: string,
@ -1538,14 +1672,14 @@ function renderChannelPermissionDirective(
): string {
const options = plan.options.length > 0
? plan.options.map((option) =>
`- ${option.label}: ${option.topic}. ${option.posture ?? "Use judgment and keep it compact."}`,
`${option.label}: ${option.topic}. ${option.posture ?? "Use judgment and keep it compact."}`,
)
: ["- No channel permissions are configured; stay private."];
const snapshotLines = snapshots.flatMap((snapshot) => {
const header = `From ${labelForChannel(plan, snapshot.channelId)}:`;
const messages = snapshot.messages.length > 0
? snapshot.messages.slice(-4).map((message) =>
` - ${message.authorName ?? message.authorId}: ${collapseWhitespace(message.content).slice(0, 260) || "(empty message)"}`,
` - ${message.authorName ?? message.authorId}: ${projectPromptVisibleText(collapseWhitespace(message.content).slice(0, 260)) || "(empty message)"}`,
)
: [" - (no recent readable messages)"];
return [header, ...messages];
@ -1663,28 +1797,17 @@ function collapseWhitespace(value: string, maxLength?: number): string {
: normalized;
}
function projectStateSummaryForCharacterTurn(summary: string): string {
const lines = summary
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((line) => !line.includes("Current room snapshot"))
.filter((line) => !line.includes("Current room:"))
.filter((line) => !line.includes("Recent room:"))
.map((line) => line
.replace(/\b\d{4}-\d{2}-\d{2}[T ][0-9:.Z+-]+\b/g, "recently")
.replace(/\bchannel\s+[0-9]{6,}\b/gi, "the room")
.replace(/\breply to\s+[0-9]{6,}\b/gi, "a recent reply")
.replace(/\(([a-z_ -]+),\s*[0-9.]+\)/gi, "($1)")
.replace(/\b[A-Za-z_ -]+=([0-9.]+)\b/g, "")
.replace(/\s{2,}/g, " ")
.trim(),
)
.filter((line) => line.length > 0);
return lines.length > 0
? lines.join("\n")
: "- Your private memory is quiet. Let the room, your values, and your jurisdiction pull a real thought forward.";
function projectPromptVisibleText(value: string): string {
return value
.replace(/—/g, "-")
.replace(/’/g, "'")
.replace(/“/g, "\"")
.replace(/â€<C3A2>/g, "\"")
.replace(/\bheartbeat\b/gi, "earlier note")
.replace(/\bFaces\b/g, "characters")
.replace(/\bFace\b/g, "character")
.replace(/\s{2,}/g, " ")
.trim();
}
function renderBifrostGovernanceDigestDirective(
@ -1765,22 +1888,46 @@ function buildJurisdictionDiveDirective(
};
}
function renderRepoFaceIdentityDoctrine(identity: RepoDiscordIdentity): string {
function renderRepoCharacterIdentityDoctrine(identity: RepoDiscordIdentity): string {
const face = buildEpiphanyIdentityRegistry({ identities: [identity] }).faces[0];
if (face) {
return [
renderFaceIdentityDoctrine(face),
loadPromptTemplate("repo-face-identity-addendum.prompt.md"),
].join("\n");
return loadPromptTemplate("repo-character-identity.prompt.md", {
displayName: identity.displayName,
repoName: identity.repoName,
originName: face?.epiphanyDisplayName ?? identity.repoName,
characterDescription: projectCharacterDescription(face?.description ?? identity.description),
});
}
function projectCharacterDescription(description: string | undefined): string | undefined {
const trimmed = description?.trim();
if (!trimmed) {
return undefined;
}
return loadPromptTemplate("repo-face-identity-fallback.prompt.md", {
displayName: identity.displayName,
identityId: identity.id,
repoName: identity.repoName,
description: identity.description,
avatarUrl: identity.avatarUrl,
});
return trimmed
.split("|")
.map((part) => part.trim())
.filter((part) => part.length > 0)
.filter((part) => !/^face of\b/i.test(part))
.filter((part) => !/^grants:/i.test(part))
.filter((part) => !/^jurisdictions:/i.test(part))
.map((part) => part
.replace(/\bcharacter Face\b/g, "character")
.replace(/\bFace\b/g, "personality")
.replace(/\brepo=AetheriaLore path=[^\s]+/g, "")
.replace(/\s{2,}/g, " ")
.trim(),
)
.filter((part) => part.length > 0)
.join(" ");
}
function readArgValue(name: string): string | undefined {
const index = process.argv.indexOf(name);
if (index < 0) {
return undefined;
}
return process.argv[index + 1];
}
function recoveryFor(participant: FaceHeartbeatParticipant): number {

View file

@ -226,3 +226,4 @@
{"ts":"2026-05-21T09:42:32Z","type":"implementation","status":"ok","note":"Slimmed repo Face turn protocol so Faces write natural roleplay turns instead of action DSL blocks. Parent reviewer now acts as transport adapter: it reviews natural Face output, optionally retries, and returns normalized SAY/BIFROST action blocks for the worker to parse. Full Spark Face smoke passed with natural outputs and no DSL blocks from Faces."}
{"ts":"2026-05-21T09:50:14Z","type":"implementation","status":"ok","note":"Renamed the repo Face parent reviewer/router concept to Face turn Interpreter: prompt files, worker functions/audit metadata, provider prompt detection, and current maps now use Interpreter language while preserving route/retry/drop behavior."}
{"ts":"2026-05-21T10:28:31Z","type":"implementation","status":"ok","note":"Moved repo character turns toward an Epiphany-shaped Interpreter boundary: scheduler projects typed state into roleplay-friendly memory/mood/obligation prose, repo turn jobs expose only read-only history/source exploration tools, and the parent Interpreter can translate natural reflection into STATE NOTE blocks for typed memory, affect, social-read, mood, and agency updates."}
{"ts":"2026-05-21T11:05:20Z","type":"implementation","status":"ok","note":"Added the inbound repo character state-projector prompt and a read-only render path for inspecting full projected character context; replaced the child identity surface and state summary projection with character-facing prose that strips raw state paths, grants, scheduler details, numeric scores, and old state provenance from Nibu's prompt preview."}

View file

@ -229,4 +229,4 @@ rejected_paths:
current_status:
summary: VoidBot's ordinary bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is still conceptually sound, but the repo Face swarm loop is now officially under teardown. The unstable organ is the prompt-and-parser machine that mixed CTB scheduling, public speech, identity, social policy, Bifrost governance, Discord mirroring, and Codex/GitHub dispatch. The `GameCult Local Orchestrator` scheduled task remains disabled as containment; ordinary bot/worker inspection remains available.
next_action: Use `notes/agent-swarm-teardown-plan-2026-05-21.md` as the active rebuild map and `notes/agent-swarm-shutdown-audit-2026-05-21.md` as incident evidence. The first teardown cut landed, including repo pause gate, active turn framing, Spark/Codex parent Interpreter with one retry, sanitizer deletion, legacy UPDATE REQUEST reconciliation into Bifrost topics, prompt-surface extraction into `prompts/`, and the slim character protocol where children receive roleplay-friendly memory/mood/obligation projection and write natural turns while the parent Interpreter emits structured state, speech, and Bifrost intents. Identity collapse is in progress. 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 cut is to finish identity collapse before splitting the Face turn loop into scheduler/context/prompt/parser/policy/transport parts. Do not re-enable the swarm until Bifrost work routing, identity authority, scheduler/job lifecycle ownership, and public speech gating are rebuilt around explicit invariants.
next_action: Use `notes/agent-swarm-teardown-plan-2026-05-21.md` as the active rebuild map and `notes/agent-swarm-shutdown-audit-2026-05-21.md` as incident evidence. The first teardown cut landed, including repo pause gate, active turn framing, Spark/Codex parent Interpreter with one retry, sanitizer deletion, legacy UPDATE REQUEST reconciliation into Bifrost topics, prompt-surface extraction into `prompts/`, and the slim character protocol where children receive roleplay-friendly memory/mood/obligation projection and write natural turns while the parent Interpreter emits structured state, speech, and Bifrost intents. The inbound state-projector prompt now exists too, making the intended loop state -> projected character prose -> natural character turn -> typed state/actions explicit; the next cut is wiring that first projection prompt as an actual Spark/Codex Interpreter call instead of the deterministic inspection/fallback renderer. Identity collapse is in progress. 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. Do not re-enable the swarm until Bifrost work routing, identity authority, scheduler/job lifecycle ownership, and public speech gating are rebuilt around explicit invariants.