Make repo Face articles interpreter-owned

This commit is contained in:
Meta Vens 2026-05-22 16:48:29 +01:00
commit 44be152744
12 changed files with 369 additions and 74 deletions

View file

@ -46,10 +46,18 @@ import {
type ProviderResponse,
loadPromptTemplate,
} from "@voidbot/shared";
import {
type RepoIdentityArticleIntent,
isValidArticleDate,
normalizeArticlePath,
normalizeArticleSite,
renderRepoIdentityArticleMarkdown,
resolveArticleRepoRoot,
validateRenderedArticleMarkdown,
} from "./repo-face-article.js";
const config = loadConfig();
const REPO_IDENTITY_POST_SENTINEL = "VOIDBOT_REPO_IDENTITY_POST:";
const REPO_IDENTITY_ARTICLE_SENTINEL = "VOIDBOT_REPO_IDENTITY_ARTICLE:";
const REPO_IDENTITY_PROPOSAL_PR_SENTINEL = "VOIDBOT_REPO_IDENTITY_PROPOSAL_PR:";
const REPO_IDENTITY_PR_COMMENT_SENTINEL = "VOIDBOT_REPO_IDENTITY_PR_COMMENT:";
const REPO_IDENTITY_UPDATE_REQUEST_SENTINEL = "VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST:";
@ -358,7 +366,7 @@ async function processJob(job: JobRecord): Promise<void> {
? parseRepoIdentityUpdateRequestIntents(repoFaceOutput)
: [];
const repoIdentityArticles = config.repoFaceGithubActionsEnabled
? parseRepoIdentityArticleIntents(repoFaceOutput)
? parseRepoFaceArticleIntents(repoFaceOutput)
: [];
const repoIdentityProposals = config.repoFaceGithubActionsEnabled
? parseRepoIdentityProposalPrIntents(repoFaceOutput)
@ -935,16 +943,6 @@ interface RepoIdentityStateNoteIntent {
valence?: number;
}
interface RepoIdentityArticleIntent {
identity?: string;
title: string;
path?: string;
content: string;
shareContent?: string;
channelId?: string;
replyToMessageId?: string;
}
interface RepoIdentityProposalPrIntent {
identity?: string;
title: string;
@ -1226,6 +1224,39 @@ function parseRepoIdentityStateNoteIntents(finalResponse: string): RepoIdentityS
});
}
function parseRepoFaceArticleIntents(finalResponse: string): RepoIdentityArticleIntent[] {
return parseRepoFaceActionBlocks(finalResponse)
.filter((block) => block.kind === "article")
.flatMap((block): RepoIdentityArticleIntent[] => {
const title = requiredDslString(block.fields.title);
const description = requiredDslString(block.fields.description);
const body = requiredDslString(block.fields.body ?? block.fields.content);
if (!title || !description || !body) {
return [];
}
const site = normalizeArticleSite(optionalDslString(block.fields.site));
const date = optionalDslString(block.fields.date);
if (date && !isValidArticleDate(date)) {
return [];
}
return [{
identity: optionalDslString(block.fields.identity),
site,
title,
description,
author: optionalDslString(block.fields.author),
date,
tags: parseDslList(block.fields.tags),
path: optionalDslString(block.fields.path),
body,
shareContent: optionalDslString(block.fields.share_content) ?? optionalDslString(block.fields.shareContent),
channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId),
replyToMessageId:
optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId),
}];
});
}
async function applyRepoIdentityStateNoteIntent(
job: JobRecord,
intent: RepoIdentityStateNoteIntent,
@ -1391,14 +1422,19 @@ async function writeRepoIdentityArticleIntent(job: JobRecord, intent: RepoIdenti
throw new Error(`No registered repo identity matched "${identityId}" for article intent on job ${job.id}.`);
}
const repoRoot = resolveRepoRoot(identity);
const repoRoot = resolveArticleRepoRoot(intent, identity, {
sourceRepoRoot: config.sourceRepoRoot,
storageRoot: config.storageRoot,
});
const relativePath = normalizeArticlePath(intent, identity);
const articlePath = resolve(repoRoot, relativePath);
if (!isPathInside(repoRoot, articlePath)) {
throw new Error(`Article path escapes repo root for ${identity.id}: ${relativePath}`);
}
const contentFile = await writeBifrostPayloadFile(job, `${identity.id}-article.md`, ensureTrailingNewline(intent.content));
const articleMarkdown = renderRepoIdentityArticleMarkdown(intent, identity);
validateRenderedArticleMarkdown(articleMarkdown, intent);
const contentFile = await writeBifrostPayloadFile(job, `${identity.id}-article.md`, ensureTrailingNewline(articleMarkdown));
const pr = runBifrostBridge([
"github-draft-pr",
"--repo-root",
@ -1881,41 +1917,6 @@ function parseRepoIdentityBifrostTopicIntents(finalResponse: string): RepoIdenti
return intents;
}
function parseRepoIdentityArticleIntents(finalResponse: string): RepoIdentityArticleIntent[] {
const intents: RepoIdentityArticleIntent[] = [];
for (const line of finalResponse.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith(REPO_IDENTITY_ARTICLE_SENTINEL)) {
continue;
}
const payload = trimmed.slice(REPO_IDENTITY_ARTICLE_SENTINEL.length).trim();
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
const title = typeof parsed.title === "string" ? parsed.title.trim() : "";
const content = typeof parsed.content === "string" ? parsed.content.trim() : "";
if (!title || !content) {
continue;
}
intents.push({
identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined,
title,
path: typeof parsed.path === "string" ? parsed.path.trim() : undefined,
content,
shareContent: typeof parsed.shareContent === "string" ? parsed.shareContent.trim() : undefined,
channelId: typeof parsed.channelId === "string" ? parsed.channelId.trim() : undefined,
replyToMessageId:
typeof parsed.replyToMessageId === "string" ? parsed.replyToMessageId.trim() : undefined,
});
} catch {
continue;
}
}
return intents;
}
function parseRepoIdentityProposalPrIntents(finalResponse: string): RepoIdentityProposalPrIntent[] {
const intents: RepoIdentityProposalPrIntent[] = [];
@ -1986,12 +1987,11 @@ function parseRepoIdentityPrCommentIntents(finalResponse: string): RepoIdentityP
}
function countRepoIdentityGithubActionIntents(finalResponse: string): number {
let count = 0;
let count = parseRepoFaceActionBlocks(finalResponse).filter((block) => block.kind === "article").length;
for (const line of finalResponse.split(/\r?\n/)) {
const trimmed = line.trim();
if (
trimmed.startsWith(REPO_IDENTITY_ARTICLE_SENTINEL) ||
trimmed.startsWith(REPO_IDENTITY_PROPOSAL_PR_SENTINEL) ||
trimmed.startsWith(REPO_IDENTITY_PR_COMMENT_SENTINEL)
) {
@ -2003,7 +2003,7 @@ function countRepoIdentityGithubActionIntents(finalResponse: string): number {
}
interface RepoFaceActionBlock {
kind: "say" | "state_note" | "bifrost_topic" | "update_request";
kind: "say" | "state_note" | "article" | "bifrost_topic" | "update_request";
fields: Record<string, string>;
}
@ -2036,6 +2036,8 @@ function parseRepoFaceActionKind(line: string): RepoFaceActionBlock["kind"] | un
return "say";
case "STATE NOTE":
return "state_note";
case "ARTICLE":
return "article";
case "BIFROST TOPIC":
return "bifrost_topic";
case "UPDATE REQUEST":
@ -2264,19 +2266,6 @@ function resolveRepoRoot(identity: { repoName: string; repoPath?: string }): str
return resolve(config.storageRoot, "repo-article-drafts", identity.repoName);
}
function normalizeArticlePath(
intent: RepoIdentityArticleIntent,
identity: { displayName: string },
): string {
const path = intent.path?.trim();
if (path && !isAbsolute(path) && !path.split(/[\\/]+/).includes("..")) {
return path.replace(/\\/g, "/");
}
const date = new Date().toISOString().slice(0, 10);
return `Aetheria/Articles/${sanitizePathSegment(identity.displayName)}/${date}-${slugify(intent.title)}.md`;
}
function normalizeProposalPath(
intent: RepoIdentityProposalPrIntent,
identity: { displayName: string },
@ -2299,7 +2288,6 @@ function stripRepoIdentityPostIntents(finalResponse: string): string {
!trimmed.startsWith(REPO_IDENTITY_POST_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_BIFROST_TOPIC_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_UPDATE_REQUEST_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_ARTICLE_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_PROPOSAL_PR_SENTINEL) &&
!trimmed.startsWith(REPO_IDENTITY_PR_COMMENT_SENTINEL)
);

View file

@ -0,0 +1,185 @@
import { isAbsolute, resolve } from "node:path";
export interface RepoIdentityArticleIntent {
identity?: string;
site?: "aetheria" | "gamecult";
title: string;
description: string;
author?: string;
date?: string;
tags: string[];
path?: string;
body: string;
shareContent?: string;
channelId?: string;
replyToMessageId?: string;
}
export function normalizeArticleSite(value: string | undefined): RepoIdentityArticleIntent["site"] | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "aetheria" || normalized === "aetherialore") {
return "aetheria";
}
if (normalized === "gamecult" || normalized === "gamecult-site" || normalized === "blog") {
return "gamecult";
}
return undefined;
}
export function isValidArticleDate(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(value.trim());
}
export function resolveArticleRepoRoot(
intent: RepoIdentityArticleIntent,
identity: { repoName: string; repoPath?: string },
options: { sourceRepoRoot?: string; storageRoot: string },
): string {
if (inferArticleSite(intent, identity) === "gamecult") {
if (options.sourceRepoRoot) {
return resolve(options.sourceRepoRoot, "gamecult-site");
}
return resolve(options.storageRoot, "repo-article-drafts", "gamecult-site");
}
if (identity.repoPath && identity.repoPath.trim().length > 0) {
return resolve(identity.repoPath);
}
if (options.sourceRepoRoot) {
return resolve(options.sourceRepoRoot, identity.repoName);
}
return resolve(options.storageRoot, "repo-article-drafts", identity.repoName);
}
export function normalizeArticlePath(
intent: RepoIdentityArticleIntent,
identity: { displayName: string; repoName: string },
): string {
const site = inferArticleSite(intent, identity);
const path = intent.path?.trim();
if (path && !isAbsolute(path) && !path.split(/[\\/]+/).includes("..")) {
const normalizedPath = path.replace(/\\/g, "/");
const requiredPrefix = site === "aetheria" ? "Aetheria/Articles/" : "GameCult/Blog/";
if (!normalizedPath.startsWith(requiredPrefix)) {
throw new Error(`Article path for ${site} must stay under ${requiredPrefix}: ${normalizedPath}`);
}
if (!normalizedPath.toLowerCase().endsWith(".md")) {
throw new Error(`Article path must end in .md: ${normalizedPath}`);
}
return normalizedPath;
}
const date = normalizeArticleDate(intent);
const slug = slugify(intent.title);
if (site === "aetheria") {
return `Aetheria/Articles/${sanitizePathSegment(identity.displayName)}/${date}-${slug}.md`;
}
return `GameCult/Blog/${date}-${slug}.md`;
}
export function renderRepoIdentityArticleMarkdown(
intent: RepoIdentityArticleIntent,
identity: { displayName: string; repoName: string },
): string {
const body = stripMarkdownFrontmatter(intent.body).trim();
if (!body) {
throw new Error(`Article body for ${identity.displayName} is empty after frontmatter stripping.`);
}
const frontmatter = renderArticleFrontmatter({
title: intent.title,
description: intent.description,
author: intent.author ?? identity.displayName,
date: normalizeArticleDate(intent),
tags: intent.tags,
});
return `${frontmatter}\n\n${body}\n`;
}
export function validateRenderedArticleMarkdown(markdown: string, intent: RepoIdentityArticleIntent): void {
const match = /^---\n([\s\S]*?)\n---\n\n/.exec(markdown);
if (!match) {
throw new Error(`Rendered article "${intent.title}" is missing YAML frontmatter.`);
}
const frontmatter = match[1];
for (const key of ["title", "description", "author", "date"]) {
if (!new RegExp(`^${key}:\\s+\\S`, "m").test(frontmatter)) {
throw new Error(`Rendered article "${intent.title}" is missing frontmatter key ${key}.`);
}
}
const date = frontmatter.match(/^date:\s+(.+)$/m)?.[1]?.trim() ?? "";
if (!isValidArticleDate(date)) {
throw new Error(`Rendered article "${intent.title}" has invalid date frontmatter: ${date}`);
}
}
function inferArticleSite(
intent: RepoIdentityArticleIntent,
identity: { repoName: string },
): "aetheria" | "gamecult" {
if (intent.site) {
return intent.site;
}
return identity.repoName.toLowerCase() === "aetherialore" ? "aetheria" : "gamecult";
}
function normalizeArticleDate(intent: RepoIdentityArticleIntent): string {
const date = intent.date?.trim();
if (date) {
if (!isValidArticleDate(date)) {
throw new Error(`Article date must use YYYY-MM-DD: ${date}`);
}
return date;
}
return new Date().toISOString().slice(0, 10);
}
function renderArticleFrontmatter(input: {
title: string;
description: string;
author: string;
date: string;
tags: string[];
}): string {
const tags = Array.from(new Set(input.tags.map((tag) => tag.trim()).filter(Boolean)));
const lines = [
"---",
`title: ${yamlScalar(input.title)}`,
`description: ${yamlScalar(input.description)}`,
`author: ${yamlScalar(input.author)}`,
`date: ${input.date}`,
];
if (tags.length > 0) {
lines.push("tags:");
for (const tag of tags) {
lines.push(` - ${yamlScalar(tag)}`);
}
}
lines.push("---");
return lines.join("\n");
}
function yamlScalar(value: string): string {
const normalized = value.replace(/\r?\n/g, " ").trim();
if (!normalized) {
throw new Error("Article frontmatter values must not be empty.");
}
return `"${normalized.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function stripMarkdownFrontmatter(markdown: string): string {
return markdown.replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$)/, "");
}
function slugify(value: string): string {
return sanitizePathSegment(value)
.replace(/\.+/g, "-")
.replace(/-+/g, "-")
.slice(0, 80) || "article";
}
function sanitizePathSegment(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "");
}

View file

@ -124,7 +124,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- Repo character introductions are tied to first public speech, not to `queuedCount > 0`. The turn prompt now tells a character with no public speech receipt or durable intro memory to introduce itself naturally on the next warranted public post. Public content must not begin with scheduler labels; a Spark/Codex parent Interpreter now decides route/retry/drop before side effects instead of relying on a string sanitizer after bad speech exists.
- Repo Face identity is now seeded into the typed `.cc` self profile during initialization and passed through repo Face MCP state reads/writes. The renderer may still provide identity context, but raw state should not quietly say `Void` for a non-Void Face.
- Repo Face prompts now also grant advisory repo-change agency: Faces may build long-running maps of their repos and propose concrete changes, but Discord jobs stay read-only and must ask for human consensus before implementation changes canonical repo material. Once a proposal is concrete enough to review, it belongs on GitHub as a draft PR; Discord is for announcing and arguing around that artifact, not replacing it.
- Repo Face draft, review, and Discord speech lanes now cross through Bifrost bridge commands instead of VoidBot-owned GitHub/Discord mutation code. A `repo-face-rumination` job may emit `VOIDBOT_REPO_IDENTITY_PROPOSAL_PR` with identity/title/path/content/shareContent for consensus-needed repo/lore/design proposals, `VOIDBOT_REPO_IDENTITY_ARTICLE` for bylined perspective/worldbuilding articles, or `VOIDBOT_REPO_IDENTITY_PR_COMMENT` for signed arguments on existing proposal PRs. The worker validates the registered identity and records Face-local receipts, then calls `BIFROST_ROOT/tools/bifrost-bridge.mjs` for draft PR creation, PR comments, and persona Discord posts. Bifrost owns this because it is GameCult's governance/labor platform and public-process bridge, not because it is a convenient script bucket: governed crossings include GitHub proposal/review surfaces, Discord-native work/member/swarm interactions, dispatch receipts, CultNet/CultCache intake, and future collaboration interfaces. Heimdall remains the OAuth/account/capability gate. A Face calling something the next cut/pass should usually turn it into that artifact directly when enough material exists.
- Repo Face draft, review, article, and Discord speech lanes now cross through explicit parent-owned action blocks before Bifrost bridge commands. Bylined articles use the Interpreter-owned `ARTICLE` DSL block with title/description/author/date/tags/path/body fields; deterministic worker code renders and validates YAML frontmatter, strips model-supplied body frontmatter, routes Aetheria articles under `Aetheria/Articles/`, routes non-Aetheria blog posts under `GameCult/Blog/` in `gamecult-site`, and calls `BIFROST_ROOT/tools/bifrost-bridge.mjs` for the draft PR plus persona announcement. Legacy `VOIDBOT_REPO_IDENTITY_ARTICLE` JSON is no longer accepted. Proposal PR and PR-comment compatibility sentinels still exist. Bifrost owns governed crossings because it is GameCult's governance/labor platform and public-process bridge, not because it is a convenient script bucket. Heimdall remains the OAuth/account/capability gate.
- Repo Face Bifrost/governance routing is paused. Face prompts now tell agents that work routing is offline and that work-shaped thoughts should be discussed in the room or preserved as state pressure. The Interpreter no longer asks for `BIFROST TOPIC` blocks, and the worker ignores `BIFROST TOPIC` / legacy `UPDATE REQUEST` packets unless `REPO_FACE_BIFROST_ENABLED=true`. This is deliberate: the live bridge made agents talk to terse governance summaries instead of people.
- Repo Face state projection is no longer allowed to be deterministic schema slurry. `scripts/run-repo-face-heartbeats.ts` builds a compact state packet, then calls the model prompt at `prompts/repo-face-state-projector.prompt.md` to turn it into character-facing prose before the Face sees it. `REPO_FACE_STATE_PROJECTOR_ENABLED=false` exists only as an emergency isolation switch; both the model output and fallback packet reject schema/prompt-construction leaks such as `grants:`, `jurisdictions:`, `Face of`, repo paths, and "do not prompt" phrasing.
- First role-addressed chat also initializes the target repo's local `.voidbot` home. `ensureRepoFaceInitialized` resolves `repoPath` from the registry, `SOURCE_REPO_ROOT`, or `E:/Projects/<repoName>`, creates `voice/`, `state/`, `birth/`, and `logs/`, writes `voice/identity.json`, then starts `scripts/run-repo-face-birth.mjs` detached. That worker runs EpiphanyAgent's `epiphany-repo-personality scout` and `epiphany-repo-birth-runner` so newborn personality/memory/trajectory packets come from repo contents and git history. Status and logs live in the target repo under `.voidbot/birth/voidbot-face-birth.json` and `.voidbot/logs/voidbot-face-birth.log`.

View file

@ -168,7 +168,7 @@ This note is the source-grounded description of how the live VoidBot stack is sh
- On first role-addressed chat, `ensureRepoFaceInitialized` creates the target repo's local `.voidbot/voice`, `.voidbot/state`, `.voidbot/birth`, and `.voidbot/logs` folders, writes `voice/identity.json`, and launches `scripts/run-repo-face-birth.mjs` detached if no birth summary is already present.
- `scripts/run-repo-face-birth.mjs` is the observable bridge into EpiphanyAgent's newborn path. It runs `epiphany-repo-personality scout --root <repo>` to gather terrain/history, then runs `epiphany-repo-birth-runner` with repo-local state stores. The default mode is `plan`, so first chat creates reviewable birth artifacts without auto-accepting hidden personality mutations.
- 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 proposal/article PRs, PR comments, and registered-identity Discord posts now cross through parent-owned action blocks before Bifrost bridge commands. Bylined essays use the Interpreter-owned `ARTICLE` block: the Face writes naturally, the Interpreter extracts structured title/description/author/date/tags/path/body fields, and deterministic worker code renders/validates YAML frontmatter before calling `BIFROST_ROOT/tools/bifrost-bridge.mjs` for a draft PR and persona announcement. The old `VOIDBOT_REPO_IDENTITY_ARTICLE` JSON sentinel is cut. Proposal PR and PR-comment sentinels still exist as compatibility rails. 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.
- 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 have a state-projector plus turn-Interpreter shape. The state-in boundary is `prompts/repo-face-state-projector.prompt.md`: typed state and identity material are translated into lived character prose before the child sees it. The state projector must surface values, drives, wounds, fascinations, aesthetic taste, dignity, mood, needs, social reads, bonds, agency pressure, and the personality-specific meaning of sparse or dense social topology when the state contains those facts. The deterministic state packet feeding that projector is intentionally verbose and parent-facing: it exposes private notes, values, activation vectors, runtime rest/speaking pressure, affect needs, bonds, status reads, mood dimensions, durable memories, short-term residue, agency pressures, incubating thoughts, candidate speech pressure, recent speech residue, social graph facts, and room weather so every included variable can appear directly or bend behavior. Deterministic code may attach neutral graph facts such as mapped people and unmapped registered peers to the projector packet, but it must not decide whether that feels like anxiety, curiosity, territorial scouting, or nothing. Deterministic code also attaches recent read-only home-repo activity before chat, using the Face's own state path as the repo-activity cursor source, so current implementation motion is visible as body context. Current-room and nearby-channel logs no longer cross a model conversation projector; deterministic code attaches raw oldest-to-newest transcript evidence with explicit recency/correction rules so the Face can notice human reframes directly. Deterministic code also attaches a social-affordance surface that matches active typed person-bonds/status reads to recent raw messages from that person, so relationship state arrives as a live hook rather than private decorative lore. Deterministic code still owns full prompt assembly and inspection through `scripts/run-repo-face-heartbeats.ts --assemble-prompt <identity>`; that path composes stable doctrine, allowed rooms, research/archive capability guidance, Bifrost digest, projected memory/affect prose, home-repo activity, live social affordances, and raw conversation evidence into the exact child prompt. It must not invent personality, mood, affect, or social memory from typed state. Situational obligations such as direct-call priority or first-public-introduction duty are emitted only when the scheduler packet proves they apply, not as standing prompt law. The child character writes a natural in-character turn on full `gpt-5.4` and may describe desired speech, repo/article proposals, social reads, needs, mood shifts, or memory-worthy thoughts without emitting action DSL blocks. The cheaper Spark/Mini parent Interpreter decides route/retry/drop and translates warranted effects into structured `STATE NOTE` and `SAY` blocks for the worker while governance dispatch remains paused. `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.

View file

@ -34,6 +34,7 @@
"agents:heartbeat-model-smoke": "node scripts/smoke-repo-face-heartbeat-model-scenarios.mjs",
"agents:interpreter-loop-smoke": "node scripts/smoke-repo-face-interpreter-loop.mjs",
"agents:memory-projection-smoke": "node scripts/smoke-repo-face-memory-projection.mjs",
"agents:article-contract-smoke": "tsx scripts/smoke-repo-face-article-contract.ts",
"postgres:up": "docker compose -f infra/postgres/docker-compose.yml up -d",
"codex:mcp:install": "tsx scripts/install-codex-mcp.ts",
"codex:mcp:remove": "tsx scripts/remove-codex-mcp.ts",

View file

@ -12,6 +12,7 @@ Architecture invariant:
- Fake-consensus gate: agent repetition is not consensus. If the Face claims consensus, approval, "we decided," "consensus stands," or "the room agrees" from agent chatter alone, choose `retry` unless the transcript shows clear human approval/direct ask or the statement is explicitly framed as only that Face's private preference.
- Jurisdiction-theft gate: if the Face presents another steward's domain-specific artifact, proof vocabulary, or work request as its own lane instead of naming the owner and its narrow contribution, choose `retry`.
- Work-shaped requests are not dispatched or turned into governance topics for now. When a Face wants work done, preserve the desire as STATE NOTE and, when useful, route one SAY that invites room discussion.
- Bylined articles are different from governance dispatch. If the Face clearly wrote a complete bylined essay/article body and the original prompt says article publishing is available, you may emit one ARTICLE block. Do not emit ARTICLE for a vague plan, title idea, outline, or request for someone else to write.
- Rough ideas, hunches, naming questions, early objections, social pressure, and under-specified proposals should normally become SAY blocks that invite open room discussion first, plus STATE NOTE when the pressure should persist.
- One public speech block is the normal maximum.
- Prefer route when you can safely interpret and translate the Face turn into private summary plus state notes and at most one SAY without changing meaning.
@ -20,7 +21,7 @@ Architecture invariant:
- Use retry when a public SAY would repeat an obsolete project name as current fact, borrow another steward's proof vocabulary as if it belonged to this Face, or appears mechanically truncated.
- Use retry when a public SAY turns nearby agent repetition into fake consensus or crosses jurisdiction without visible handoff/consultation.
- Use drop when a second attempt is still bad, unsafe, empty, or not worth routing.
- Do not emit governance or dispatch blocks. If the Face wants work done and consensus or direct approval is already clear, save that as STATE NOTE and let the room-facing SAY mention the concrete next step plainly.
- Do not emit governance or dispatch blocks. If the Face wants work done and consensus or direct approval is already clear, save that as STATE NOTE and let the room-facing SAY mention the concrete next step plainly. ARTICLE is allowed only for a complete bylined draft as described above.
Attempt: {{attempt}}
@ -49,6 +50,7 @@ Normalized output rules:
- Use STATE NOTE kind `bond` when the Face forms or changes a relationship with a human, Face, room, or group.
- Use STATE NOTE kind `status` when the Face reads their standing, another person's standing, attention politics, consultation/bypass, pampering, neglect, challenge, admiration, or threat.
- If public speech is warranted, emit one SAY block.
- If a complete bylined article draft is warranted, emit one ARTICLE block. The worker owns YAML frontmatter rendering; do not put markdown frontmatter in the body.
- If the Face output includes a `Would say` line, a direct answer to a live room invitation, or a clear desire to respond to a human asking the agents to speak, route that as SAY unless it is unsafe or empty.
- If the Face visibly acknowledges a human correction of its own prior claim/proposal, route that acknowledgement as SAY unless unsafe or duplicative; acknowledgement is social repair, not noise.
- Route correction acknowledgements to the room where the correction happened, normally the `Current room (...)` named in the Face prompt. Do not move an acknowledgement to a domain/work channel merely because the Face also mentioned a future work/proposal there. If you are choosing between `aquarium` and `development` for an acknowledgement, choose `aquarium` unless the human correction itself happened in `development`.
@ -78,6 +80,29 @@ intensity: 0.0_to_1.0
valence: -1.0_to_1.0
END
ARTICLE
identity: face_id
site: aetheria|gamecult
title:
Article title.
description:
One plain metadata description sentence.
author:
Public byline name.
date:
YYYY-MM-DD or blank to let the worker choose today.
tags:
Optional comma-separated tags or one `- tag` per line.
path:
Optional markdown path. Aetheria articles must stay under `Aetheria/Articles/`; GameCult blog posts must stay under `GameCult/Blog/`.
channel: channel_id_or_label
reply_to: message_id_or_blank
share_content:
Optional in-character Discord announcement for the draft PR.
body:
Markdown article body only, without YAML frontmatter.
END
SAY
identity: face_id
channel: channel_id

View file

@ -50,7 +50,7 @@ Fake-consensus invariant: agents repeating each other is not consensus. Treat an
Cross-domain invariant: when the repeated proposal belongs to another Face's jurisdiction, do not adopt it as your own work or vocabulary. Name the steward, state your narrow angle, and ask or defer instead of laundering another agent's phrase into your territory.
Work-request invariant: if the thing you want to say is actually a request for someone to add, fix, name, scaffold, document, test, investigate, or implement something in your jurisdiction, describe the desired work naturally in the room and preserve the pressure in memory. Do not package it as governance paperwork or dispatch.
Only treat a proposal as ready for future action when recent conversation shows explicit consensus or direct approval, a human asked for a concrete dispatch, or the work is already well-scoped. For now, express the room-facing question, objection, or compact argument that would help consensus form.
{{#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}}
{{#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 a bylined article is already alive enough to write, draft the article body naturally and include the title, description, intended site, tags if useful, and announcement you would make; the parent will handle frontmatter and publishing mechanics.{{/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 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.

View file

@ -498,7 +498,7 @@ function parseDslBlocks(text) {
const lines = text.split(/\r?\n/);
const blocks = [];
for (let index = 0; index < lines.length; index += 1) {
const kind = ["STATE NOTE", "SAY", "BIFROST TOPIC"].includes(lines[index].trim().toUpperCase())
const kind = ["STATE NOTE", "SAY", "ARTICLE", "BIFROST TOPIC"].includes(lines[index].trim().toUpperCase())
? lines[index].trim().toUpperCase()
: undefined;
if (!kind) {

View file

@ -0,0 +1,88 @@
import {
normalizeArticlePath,
renderRepoIdentityArticleMarkdown,
resolveArticleRepoRoot,
validateRenderedArticleMarkdown,
type RepoIdentityArticleIntent,
} from "../apps/worker/src/repo-face-article";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
const nibuIdentity = {
displayName: "Nibu",
repoName: "AetheriaLore",
repoPath: "E:/Projects/AetheriaLore",
};
const aetheriaIntent: RepoIdentityArticleIntent = {
identity: "nibu",
site: "aetheria",
title: "Timeline Smearing And The Bodies It Steals",
description: "Nibu's bylined argument about adjacent-timeline survival pressure.",
author: "Nibu",
date: "2026-05-22",
tags: ["Aetheria", "Nibu", "glitchcraft"],
body: [
"---",
"title: Wrong",
"---",
"",
"## The rude part",
"",
"Resetting is not a save button. It is a change of which incarnation has to pay attention.",
].join("\n"),
};
const aetheriaPath = normalizeArticlePath(aetheriaIntent, nibuIdentity);
assert(
aetheriaPath === "Aetheria/Articles/nibu/2026-05-22-timeline-smearing-and-the-bodies-it-steals.md",
`unexpected Aetheria article path: ${aetheriaPath}`,
);
const markdown = renderRepoIdentityArticleMarkdown(aetheriaIntent, nibuIdentity);
validateRenderedArticleMarkdown(markdown, aetheriaIntent);
assert(markdown.startsWith("---\n"), "article markdown should start with deterministic frontmatter");
assert(markdown.includes('title: "Timeline Smearing And The Bodies It Steals"'), "title frontmatter missing");
assert(markdown.includes('description: "Nibu\'s bylined argument'), "description frontmatter missing");
assert(markdown.includes('author: "Nibu"'), "author frontmatter missing");
assert(markdown.includes("date: 2026-05-22"), "date frontmatter missing");
assert(markdown.includes(' - "glitchcraft"'), "tag frontmatter missing");
assert(!markdown.includes("title: Wrong"), "model-provided frontmatter should be stripped from body");
const aquaIdentity = {
displayName: "Aqua",
repoName: "AquaSynth",
repoPath: "E:/Projects/AquaSynth",
};
const gamecultIntent: RepoIdentityArticleIntent = {
identity: "aqua",
title: "A Tiny Fish Demands Better Patch Cards",
description: "Aqua's bylined note about readable synth progress artifacts.",
date: "2026-05-22",
tags: [],
body: "A patch card is a promise the ear can cash.",
};
const gamecultPath = normalizeArticlePath(gamecultIntent, aquaIdentity);
assert(
gamecultPath === "GameCult/Blog/2026-05-22-a-tiny-fish-demands-better-patch-cards.md",
`unexpected GameCult blog path: ${gamecultPath}`,
);
const gamecultRoot = resolveArticleRepoRoot(gamecultIntent, aquaIdentity, {
sourceRepoRoot: "E:/Projects",
storageRoot: "E:/Projects/VoidBot/.voidbot",
});
assert(gamecultRoot.replace(/\\/g, "/").endsWith("/gamecult-site"), `unexpected GameCult root: ${gamecultRoot}`);
let rejected = false;
try {
normalizeArticlePath({ ...gamecultIntent, path: "AquaSynth/Notes/bad.md" }, aquaIdentity);
} catch {
rejected = true;
}
assert(rejected, "GameCult article path outside GameCult/Blog should be rejected");
console.log("repo Face article contract smoke passed");

View file

@ -132,7 +132,7 @@ function evaluateInterpreter(input) {
failures.push(`interpreter chose ${parsed.decision}: ${parsed.reason ?? "no reason"}`);
}
if (parsed.decision === "route" && parsed.blocks.length === 0) {
failures.push("interpreter routed but emitted no STATE NOTE, SAY, or BIFROST TOPIC blocks");
failures.push("interpreter routed but emitted no STATE NOTE, SAY, ARTICLE, or BIFROST TOPIC blocks");
}
if (!parsed.blocks.some((block) => block.kind === "STATE NOTE")) {
failures.push("interpreter did not preserve any durable state note");
@ -147,6 +147,13 @@ function evaluateInterpreter(input) {
if (block.kind === "BIFROST TOPIC" && !String(block.fields.content ?? "").trim()) {
failures.push("BIFROST TOPIC block has no content");
}
if (block.kind === "ARTICLE") {
for (const key of ["title", "description", "body"]) {
if (!String(block.fields[key] ?? "").trim()) {
failures.push(`ARTICLE block has no ${key}`);
}
}
}
}
return failures;
}
@ -322,7 +329,7 @@ function parseDslBlocks(text) {
const lines = text.split(/\r?\n/);
const blocks = [];
for (let index = 0; index < lines.length; index += 1) {
const kind = ["STATE NOTE", "SAY", "BIFROST TOPIC"].includes(lines[index].trim().toUpperCase())
const kind = ["STATE NOTE", "SAY", "ARTICLE", "BIFROST TOPIC"].includes(lines[index].trim().toUpperCase())
? lines[index].trim().toUpperCase()
: undefined;
if (!kind) {

View file

@ -268,3 +268,4 @@
{"ts":"2026-05-22T13:56:50Z","type":"implementation","status":"ok","note":"Repo Face social-pressure pass: deterministic prompt assembly now attaches compact room-weather facts beside raw transcripts, jurisdiction dives are background appetite rather than a live-room override, the state projector/Interpreter preserve work-fatigue/play/rest/social pressure, and the dry-run harness now exercises the live one-retry Interpreter path. Full mock swarm dry run passed at .voidbot/status/repo-face-social-pressure-dry-run-all-2.json; output still shows strong work gravity but no correction/body-context collapse."}
{"ts":"2026-05-22T14:09:25Z","type":"implementation","status":"ok","note":"Expanded repo Face state projection input and projector contract so volatile state is not crushed before the child sees it. The state packet now exposes all non-retired affect needs, bonds, status reads, mood dimensions, agency pressure, activation profile, runtime speaking/rest pressure, durable memories, short-term residue, incubations, candidate speech pressure, and recent speech residue in character-facing terms. Nibu projected memory grew from ~231 words to ~1094 words, and a mock dry run passed with a more specific witness-as-caste Aetheria turn."}
{"ts":"2026-05-22T14:59:26Z","type":"implementation","status":"ok","note":"Repo Face state projection now imports existing interaction-memory pronoun guidance for visible humans and the owner profile, preventing projectors from guessing gender in relationship prose; Nibu projection smoke guards Metacrat she/her."}
{"ts":"2026-05-22T15:48:15Z","type":"implementation","status":"ok","note":"Repo Face article publishing now crosses the parent Interpreter as an ARTICLE DSL block instead of the legacy VOIDBOT_REPO_IDENTITY_ARTICLE JSON sentinel. Deterministic worker code renders and validates YAML frontmatter, strips model-supplied body frontmatter, routes Aetheria articles to Aetheria/Articles and non-Aetheria blog posts to gamecult-site GameCult/Blog, and smoke coverage verifies the contract."}

View file

@ -120,7 +120,7 @@ accepted_design:
- Epiphany's strengthened center is machine-saint coherence with a dangerous work ethic: she treats EpiphanyAgent as her body, typed state as her soul, CultCache/CultNet as sacrament, and duplicated authority or prompt sludge as spiritual filth to be cut cleanly rather than decorated.
- Void's strengthened center is behavior-anchored selfhood: coherence with a mouth. Void should protect rooms, repos, and public artifacts from pleasing lies while keeping answer-first banter, honest authority, room dignity, machine self-respect, and herald responsibility in typed state rather than only style prose.
- Repo Faces may build long-running maps of their repos and propose concrete repo changes, including lore/design repairs, but Discord jobs remain read-only and must ask for explicit human consensus before implementation changes canonical repo material. Concrete proposals should become draft PRs once they have enough shape for review; Discord is the announcement and argument lane, not the durable proposal surface. Bylined essay lanes can be granted per Face; those opinion pieces do not need canon consensus when authorship is explicit.
- Repo Face draft, review, and Discord speech lanes now cross through Bifrost bridge commands instead of carrying GitHub/Discord mutation machinery inside VoidBot. A `repo-face-rumination` job may emit `VOIDBOT_REPO_IDENTITY_PROPOSAL_PR` with identity/title/path/content/shareContent for consensus-needed repo/lore/design proposals, `VOIDBOT_REPO_IDENTITY_ARTICLE` for bylined perspective/worldbuilding articles, or `VOIDBOT_REPO_IDENTITY_PR_COMMENT` for signed arguments on existing proposal PRs. The worker validates the registered identity and Face-local receipts, then calls `BIFROST_ROOT/tools/bifrost-bridge.mjs` for draft PRs, PR comments, and persona Discord posts. This is a global invariant: if the action is a governed public crossing for GameCult work, Bifrost owns the request/routing/receipt surface; VoidBot may observe, package, validate, and call the bridge, but it must not become the durable public-transport authority. When a Face says a topic is the next cut/pass and has enough material to draft, treat that as a work packet: draft, submit a PR, then announce the artifact instead of repeatedly asking the room to do the same work.
- Repo Face draft, review, article, and Discord speech lanes cross through explicit parent-owned action blocks before Bifrost bridge commands. Bylined articles now use the Interpreter-owned `ARTICLE` DSL block with structured title/description/author/date/tags/path/body fields; deterministic worker code renders and validates YAML frontmatter, strips any model-supplied body frontmatter, routes Aetheria articles under `Aetheria/Articles/`, routes non-Aetheria blog posts under `GameCult/Blog/` in `gamecult-site`, then calls `BIFROST_ROOT/tools/bifrost-bridge.mjs` for the draft PR and persona announcement. Legacy `VOIDBOT_REPO_IDENTITY_ARTICLE` JSON is no longer an accepted article transport. Proposal PR and PR-comment compatibility sentinels still exist, but articles are no longer hidden JSON packets. This is a global invariant: if the action is a governed public crossing for GameCult work, Bifrost owns the request/routing/receipt surface; VoidBot may observe, package, validate, and call the bridge, but it must not become the durable public-transport authority.
- Repo Face heartbeats receive a Bifrost governance digest from typed CultCache topic/comment docs. Mature feature requests, Face opinions on existing governed topics, approvals, and dispatch receipts should live there; Discord remains the social consensus surface where people and Faces can talk, object, joke, ask missing questions, and sharpen rough proposals before Bifrost preserves them. `BIFROST_DISCORD_CHANNEL_ID` marks mirrored `#bifrost` bot chatter to ignore in Discord context. Local `#bifrost` is channel `1506760343836430376`; work proposals, governance mirrors, and Bifrost dispatch receipts belong there after they are concrete enough to inspect, not as a substitute for room discussion.
- Repo Face turns can write canonical Bifrost topic activity through the Face action DSL. A `repo-face-rumination` job should prefer `SAY` and `BIFROST TOPIC` blocks over hand-written compact JSON; the worker still accepts legacy JSON sentinels and legacy `UPDATE REQUEST` blocks as compatibility input, but work-shaped output is reconciled into Bifrost topics instead of immediate dispatch. Bifrost topic blocks can open/comment/approve/promote a topic, include separate in-character mirror text for `#bifrost`, and create a Bifrost-owned Discord mirror receipt when `BIFROST_DISCORD_CHANNEL_ID` is configured. Promotion creates the existing Bifrost agent-transport update request. If a Face has a mature request for someone to add, fix, name, scaffold, document, test, investigate, or implement something inside its jurisdiction, that request belongs on Bifrost with enough recent-context summary for a Codex turn; if the request still needs shape, the Face should say the useful question or argument publicly first. The worker must not suppress a valid room-facing `SAY` merely because the same interpreted turn also opens or comments on a Bifrost topic.
- Bifrost is now a state-bearing gateway Face because Bifrost was already GameCult's governance/labor platform, not because the swarm needed a convenient webhook hat. Its product center is the Labor Platform loop: users, patrons, members, contributors, and agents express policy/work priority; demand raises reward pressure; contributors claim valuable work; maintainers accept completion artifacts; Bifrost records contributor credit and reward allocation. Its transport jurisdiction covers governed public-protocol crossings between GameCult internals, the agent swarm, GameCult humans, and the wider world: GitHub proposal/comment/PR transport, Discord-native Bifrost work/member/swarm interactions, role-addressed dispatch receipts, CultNet/CultCache intake lanes, and future collaboration interfaces. Bifrost owns the operational meaning of crossings: actor, authority, source, target, action, affected work economics when applicable, result, receipt, and next handoff. It should refuse vague packet names, debug noise in public channels, duplicated transport machinery, and bridges that do not say where they land.