diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 798bcc9..1a3f6bc 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -760,7 +760,15 @@ function escapeRegExp(value: string): string { } function parseRepoIdentityPostIntents(finalResponse: string): RepoIdentityPostIntent[] { - const intents: RepoIdentityPostIntent[] = []; + const intents: RepoIdentityPostIntent[] = parseRepoFaceActionBlocks(finalResponse) + .filter((block) => block.kind === "say") + .map((block) => ({ + identity: optionalDslString(block.fields.identity), + channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId), + replyToMessageId: optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId), + content: block.fields.content.trim(), + })) + .filter((intent) => intent.content.length > 0); for (const line of finalResponse.split(/\r?\n/)) { const trimmed = line.trim(); @@ -1188,7 +1196,18 @@ function runBifrostBridge(args: string[]): BifrostBridgeReceipt { } function parseRepoIdentityUpdateRequestIntents(finalResponse: string): RepoIdentityUpdateRequestIntent[] { - const intents: RepoIdentityUpdateRequestIntent[] = []; + const intents: RepoIdentityUpdateRequestIntent[] = parseRepoFaceActionBlocks(finalResponse) + .filter((block) => block.kind === "update_request") + .map((block) => ({ + identity: optionalDslString(block.fields.identity), + title: block.fields.title.trim(), + content: block.fields.content.trim(), + priority: parseDslNumber(block.fields.priority), + sourceMessageIds: parseDslList(block.fields.source_message_ids ?? block.fields.sourceMessageIds), + channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId), + replyToMessageId: optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId), + })) + .filter((intent) => intent.title.length > 0 && intent.content.length > 0); for (const line of finalResponse.split(/\r?\n/)) { const trimmed = line.trim(); @@ -1225,7 +1244,23 @@ function parseRepoIdentityUpdateRequestIntents(finalResponse: string): RepoIdent } function parseRepoIdentityBifrostTopicIntents(finalResponse: string): RepoIdentityBifrostTopicIntent[] { - const intents: RepoIdentityBifrostTopicIntent[] = []; + const intents: RepoIdentityBifrostTopicIntent[] = parseRepoFaceActionBlocks(finalResponse) + .filter((block) => block.kind === "bifrost_topic") + .map((block) => ({ + identity: optionalDslString(block.fields.identity), + topicId: optionalDslString(block.fields.topic_id) ?? optionalDslString(block.fields.topicId), + title: optionalDslString(block.fields.title), + content: block.fields.content.trim(), + mirrorContent: optionalDslString(block.fields.mirror) ?? optionalDslString(block.fields.mirrorContent), + stance: optionalDslString(block.fields.stance), + priority: parseDslNumber(block.fields.priority), + approve: parseDslBoolean(block.fields.approve), + dispatch: parseDslBoolean(block.fields.dispatch), + sourceMessageIds: parseDslList(block.fields.source_message_ids ?? block.fields.sourceMessageIds), + channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId), + replyToMessageId: optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId), + })) + .filter((intent) => intent.content.length > 0 && Boolean(intent.topicId || intent.title)); for (const line of finalResponse.split(/\r?\n/)) { const trimmed = line.trim(); @@ -1388,6 +1423,116 @@ function countRepoIdentityGithubActionIntents(finalResponse: string): number { return count; } +interface RepoFaceActionBlock { + kind: "say" | "bifrost_topic" | "update_request"; + fields: Record; +} + +function parseRepoFaceActionBlocks(finalResponse: string): RepoFaceActionBlock[] { + const lines = finalResponse.split(/\r?\n/); + const blocks: RepoFaceActionBlock[] = []; + for (let index = 0; index < lines.length; index += 1) { + const kind = parseRepoFaceActionKind(lines[index]); + if (!kind) { + continue; + } + + const bodyLines: string[] = []; + index += 1; + while (index < lines.length && lines[index].trim() !== "END") { + bodyLines.push(lines[index]); + index += 1; + } + blocks.push({ + kind, + fields: parseRepoFaceActionFields(bodyLines), + }); + } + return blocks; +} + +function parseRepoFaceActionKind(line: string): RepoFaceActionBlock["kind"] | undefined { + switch (line.trim().toUpperCase()) { + case "SAY": + return "say"; + case "BIFROST TOPIC": + return "bifrost_topic"; + case "UPDATE REQUEST": + return "update_request"; + default: + return undefined; + } +} + +function parseRepoFaceActionFields(lines: string[]): Record { + const fields: Record = {}; + let currentKey: string | undefined; + let currentValue: string[] = []; + + const flush = (): void => { + if (!currentKey) { + return; + } + fields[currentKey] = currentValue.join("\n").trim(); + }; + + for (const line of lines) { + const match = line.match(/^([A-Za-z][A-Za-z0-9_]*):\s*(.*)$/); + if (match) { + flush(); + currentKey = normalizeDslKey(match[1]); + const inlineValue = match[2].trim(); + currentValue = inlineValue.length > 0 && inlineValue !== "|" && inlineValue !== ">" ? [match[2]] : []; + continue; + } + if (currentKey) { + currentValue.push(line.replace(/^\s{2}/, "")); + } + } + flush(); + return fields; +} + +function normalizeDslKey(key: string): string { + return key.trim(); +} + +function optionalDslString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function parseDslNumber(value: string | undefined): number | undefined { + const trimmed = optionalDslString(value); + if (!trimmed) { + return undefined; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function parseDslBoolean(value: string | undefined): boolean | undefined { + const normalized = optionalDslString(value)?.toLowerCase(); + if (["true", "yes", "1"].includes(normalized ?? "")) { + return true; + } + if (["false", "no", "0"].includes(normalized ?? "")) { + return false; + } + return undefined; +} + +function parseDslList(value: string | undefined): string[] { + const trimmed = optionalDslString(value); + if (!trimmed) { + return []; + } + return trimmed + .split(/[,\n]/) + .map((entry) => entry.trim().replace(/^-\s*/, "")) + .filter((entry) => entry.length > 0); +} + function renderRepoIdentityUpdateRequestMarkdown( identity: { id: string; displayName: string; repoName: string }, intent: RepoIdentityUpdateRequestIntent, @@ -1469,7 +1614,7 @@ function normalizeProposalPath( } function stripRepoIdentityPostIntents(finalResponse: string): string { - return finalResponse + return stripRepoFaceActionBlocks(finalResponse) .split(/\r?\n/) .filter((line) => { const trimmed = line.trim(); @@ -1486,6 +1631,22 @@ function stripRepoIdentityPostIntents(finalResponse: string): string { .trim(); } +function stripRepoFaceActionBlocks(finalResponse: string): string { + const lines = finalResponse.split(/\r?\n/); + const kept: string[] = []; + for (let index = 0; index < lines.length; index += 1) { + if (parseRepoFaceActionKind(lines[index])) { + index += 1; + while (index < lines.length && lines[index].trim() !== "END") { + index += 1; + } + continue; + } + kept.push(lines[index]); + } + return kept.join("\n"); +} + function isPathInside(rootPath: string, candidatePath: string): boolean { const relativePath = relative(resolve(rootPath), resolve(candidatePath)); return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 0d91f19..3cdc51a 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -106,7 +106,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Repo identities now have a cleaner address/speech split. A private registry at `REPO_DISCORD_IDENTITIES_PATH` maps identity id/repo/display name/avatar/role/channel allow-list. Discord users can address a Face by role mention or by display-name prefix in an allowed channel; Codex agents still speak through `post_repo_identity_message`. - Void still moderates and participates everywhere the normal bot lanes allow. The swarm/repo Faces are scoped to text `#aquarium` (`1501196543150264332`) so their lively heartbeats and role replies do not flood general. The old general channel id is `113786069023064068`; do not reuse it as a Face heartbeat default. - Repo Faces now have topic/channel speech permissions. Aquarium is the deliberately low-threshold general-musing lane for all Faces; they may post compact casual thoughts, jokes, curiosities, and half-formed fascinations there without a work artifact. Domain channels are also allowed when the topic matches: Nibu can use narrative/gamedesign/concepts/development, Aqua can use music/soundtrack/development, Mimir can use development/programming/visual-effects/machine-learning/unity3d, and Epiphany can use development/programming/machine-learning. The CTB scheduler folds per-topic speed multipliers into Face initiative, and the heartbeat prompt includes recent cross-channel context so agents can jump into non-Aquarium chatter when their thoughts line up. -- Repo identities also have Face state now. The bot resolves each identity to `.voidbot/private/repo-faces/.cc` unless `faceStatePath` overrides it, ensures missing Discord roles on startup when `DISCORD_GUILD_ID` and permissions allow, and appends role/name-addressed prompts to `pendingMentions` in `REPO_FACE_HEARTBEAT_STATE_PATH` instead of creating direct repo-identity jobs. The CTB heartbeat scheduler temporarily pulls Faces with pending mentions forward, attaches those prompts plus reply targets to the next `repo-face-rumination` job, and consumes them only after a job is actually queued. Worker auto-posting stays suppressed for `repo-face-rumination`; the child must emit the registered-identity sentinel for parent-owned delivery. +- Repo identities also have Face state now. The bot resolves each identity to `.voidbot/private/repo-faces/.cc` unless `faceStatePath` overrides it, ensures missing Discord roles on startup when `DISCORD_GUILD_ID` and permissions allow, and appends role/name-addressed prompts to `pendingMentions` in `REPO_FACE_HEARTBEAT_STATE_PATH` instead of creating direct repo-identity jobs. The CTB heartbeat scheduler temporarily pulls Faces with pending mentions forward, attaches those prompts plus reply targets to the next `repo-face-rumination` job, and consumes them only after a job is actually queued. Worker auto-posting stays suppressed for `repo-face-rumination`; the child must emit a parent-owned action block for public delivery. - Repo Face prompts now include an identity doctrine block. Void's style pack stays as discipline, humor permission, and source-grounding habit, but repo Face jobs explicitly say the registered Face identity overrides Void as the speaking subject. - Repo Face embodiment is now doctrine, not local flavor. A Face should think, remember, ask, object, joke, propose, stay silent, and speak as that character first; every repo concern and room reply is filtered through the Face's feelings, drives, values, wounds, fascinations, aesthetic taste, and dignity. Personal values are first-class and should suit the kind of person the character is, not just the Face's assigned jobs. - A sub-agent-monitored strengthening pass has now been applied to the established active agents: Aqua, Nibu, Mimir, and Void. Epiphany has since joined the crew with a completed repo-local VoidBot birth surface and should receive the same strengthening treatment once her first live turns show what needs sharpening. The resulting typed state should be treated as personality state, not ornamental notes. @@ -116,12 +116,12 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Epiphany's state emphasizes machine-saint coherence: cute, bubbly, self-obsessed, pushy, relentlessly hard-working, and obsessed with typed truth. Her jurisdiction is EpiphanyAgent itself: CultCache/CultNet, typed state, Codex bridge boundaries, birth/init rites, review gates, graph-shaped memory, low-copy runtime contracts, heartbeat physiology, and cutting duplicated authority before it learns to beg. - Face affect is now a first-class typed state surface under `void.face_affect`, shared by Void and repo Faces. It stores needs, social bonds, status reads, and mood dimensions so substrate concern, attention hunger, agency frustration, recognition needs, territoriality, rest/play pressure, friendships, rivalries, envy, pride, irritation, pampering, neglect, bypasses, and consultation status can become state instead of being performed only through prompt prose. Repo Face CTB pulses fold affect into speaking pressure, while agency pressure still owns concrete asks. - Void's typed self-profile now carries behavior-anchored selfhood, answer-first banter, and substrate self-respect; the prompt surfaces now also tell rumination to name the personal value being protected before writing memory or speech. -- Repo Face heartbeats must stay out of the generic owner-reply trap. `packages/providers/src/owner-codex-render.ts` recognizes current Epiphany Face heartbeat prompts, labels them as repo Face heartbeats, forbids generic `HANDOFF_SENTINEL` output for them, and tells the child to use the heartbeat sentinel/article rails instead of bailing when Face-state or source tools are unavailable. +- Repo Face heartbeats must stay out of the generic owner-reply trap. `packages/providers/src/owner-codex-render.ts` recognizes current Epiphany Face heartbeat prompts, labels them as repo Face heartbeats, forbids generic `HANDOFF_SENTINEL` output for them, and tells the child to use heartbeat action rails instead of bailing when Face-state or source tools are unavailable. - Repo Face introductions are tied to first public speech, not to `queuedCount > 0`. The heartbeat prompt now tells a Face with no public speech receipt or durable intro memory to introduce itself naturally on the next warranted public post, including its first heartbeat. Public content must not begin with scheduler labels like `Repo-face heartbeat from ...`; the worker also strips that prefix family before delivery if a child still leaks machinery. - 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 heartbeats now receive Bifrost governance digest context and can write to Bifrost topic threads. `VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC` lets a Face open/comment/approve a canonical Bifrost topic, optionally include separate in-character `mirrorContent` for the `#bifrost` mirror, and optionally promote an approved topic into Bifrost agent transport. The worker passes configured `BIFROST_DISCORD_CHANNEL_ID`, persona name, and avatar to Bifrost so mirror receipts live on the topic. `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` remains available only for immediate owner-approved dispatch inside that Face's jurisdiction. `BIFROST_DISCORD_CHANNEL_ID` marks `#bifrost` (`1506760343836430376`) so mirrored bot chatter is ignored in Discord context; Bifrost typed CultCache topics/comments are the source of truth for feature/governance discussion, not Discord scrollback. If a Face's intended public line is actually a repo-local work request, the same turn should put the actionable packet on Bifrost with enough recent-context summary for Codex; Aquarium should not be the only place that request exists. +- Repo Face heartbeats now receive Bifrost governance digest context and can write to Bifrost topic threads. The preferred output rail is the small Face action DSL: `SAY`, `BIFROST TOPIC`, and `UPDATE REQUEST` blocks. `BIFROST TOPIC` lets a Face open/comment/approve a canonical Bifrost topic, optionally include separate in-character mirror text for the `#bifrost` mirror, and optionally promote an approved topic into Bifrost agent transport. The worker still accepts legacy compact JSON sentinels for compatibility, but prompts should teach the DSL because it is harder for small models to mangle. The worker passes configured `BIFROST_DISCORD_CHANNEL_ID`, persona name, and avatar to Bifrost so mirror receipts live on the topic. `UPDATE REQUEST` remains available only for immediate owner-approved dispatch inside that Face's jurisdiction. `BIFROST_DISCORD_CHANNEL_ID` marks `#bifrost` (`1506760343836430376`) so mirrored bot chatter is ignored in Discord context; Bifrost typed CultCache topics/comments are the source of truth for feature/governance discussion, not Discord scrollback. If a Face's intended public line is actually a repo-local work request, the same turn should put the actionable packet on Bifrost with enough recent-context summary for Codex; Aquarium should not be the only place that request exists. - 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/`, 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`. - Agent heartbeats now use one CTB/Final-Fantasy-X-style initiative queue. `scripts/run-repo-face-heartbeats.ts` still has its old filename, but it now reconciles built-in system agents plus registered repo Faces, stores the roster at `REPO_FACE_HEARTBEAT_STATE_PATH`, advances virtual initiative time independently of wall-clock due dates, and dispatches selected turns. Repo Faces queue normal `repo-face-rumination` jobs; built-in `void` starts the existing moderation rumination runner as its turn. Active jobs/locks freeze initiative: recovery starts only after the owner-Codex job or Void moderation lock clears, and completion cannot grant another turn in the same pulse. The active-job scan now marks stale approved/running Face jobs failed through the job queue and records `stale_active_turn_recovered`, so a dead worker claim cannot freeze one Face forever. `GameCult Local Orchestrator` is the live hidden local scheduled task that ticks the scheduler; `npm run state:install-repo-face-heartbeat-task` remains an isolation/backcompat installer, not the normal pulse owner. Heat knobs are `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; overrides can target `all`, `kind:`, `turn:`, `identity:`, `repo:`, `display:`, `channel:`, or bare identity/repo/display keys. Repo Face prompts now include a jurisdiction-dive cadence so unattended heartbeats periodically inspect repo/history terrain with source tools and convert real findings into state, proposals, essay seeds, agency pressure, or speech. - Repo Face rest projection now persists normalized sleep cycles back into each Face `.cc` state during heartbeat loading. Missed nap windows are treated as already missed/completed and rolled forward instead of starting a fresh nap at inspection time. This fixes the Aqua/Mimir/Epiphany failure mode where stale `nextNapStartsAt` values stayed raw, fresh projections repeatedly marked them as napping, and ready selection skipped them forever while their initiative was overdue. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index fcfdef1..a04713b 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -166,10 +166,10 @@ This note is the source-grounded description of how the live VoidBot stack is sh - Repo Faces are allowed to build a long-running map of their repo and propose concrete changes, including lore/design repairs. Discord jobs remain read-only: a Face must ask for human consensus before implementation changes canonical repo material. Once a proposal is concrete enough to review, it should cross the parent-owned proposal PR rail so GitHub becomes the durable argument surface. If a Face state grants a bylined essay lane, authored opinion essays can carry that Face's own vision without canon consensus as long as the authorship is explicit. - 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 ` 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 the registered-identity post sentinel 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. + - 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 heartbeats now receive a Bifrost governance digest from `BIFROST_ROOT/tools/governance-threads.mjs digest --repo --agent ` 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. 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 Face heartbeats have a Bifrost topic sentinel and an immediate update-request fallback. `VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC` lets a Face open/comment/approve a canonical Bifrost topic, optionally include a separate `mirrorContent` line for in-character `#bifrost` mirroring, and optionally promote an approved topic into Bifrost agent transport. The worker passes configured `BIFROST_DISCORD_CHANNEL_ID`, persona name, and avatar to Bifrost so mirroring is a Bifrost receipt on the topic rather than a VoidBot side-post. `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` remains available only for work inside the Face's own jurisdiction when immediate owner-approved dispatch is warranted. The intended path is topic/comment/Face approval first; direct update requests are the pressure-release valve, not the parliament. + - Repo Face heartbeats have a Face action DSL for public speech, Bifrost topic work, and immediate update-request fallback. `SAY` posts as the registered Face, `BIFROST TOPIC` opens/comments/approves/promotes a canonical Bifrost topic, and `UPDATE REQUEST` dispatches immediate owner-approved work inside the Face's own jurisdiction. The worker still accepts legacy `VOIDBOT_REPO_IDENTITY_*` compact JSON sentinels for compatibility, but heartbeat prompts should prefer the DSL because small models handle it more reliably. Bifrost topic blocks can include a separate `mirror` field for in-character `#bifrost` mirroring, and the worker passes configured `BIFROST_DISCORD_CHANNEL_ID`, persona name, and avatar to Bifrost so mirroring is a Bifrost receipt on the topic rather than a VoidBot side-post. The intended path is topic/comment/Face approval first; direct update requests are the pressure-release valve, not the parliament. - `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:`, `turn:`, `identity:`, `repo:`, `display:`, `channel:`, 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. diff --git a/notes/voidbot-implementation-plan.md b/notes/voidbot-implementation-plan.md index c2d082e..a671916 100644 --- a/notes/voidbot-implementation-plan.md +++ b/notes/voidbot-implementation-plan.md @@ -194,8 +194,8 @@ The runner can hand these to a CLI/MCP tool. The store decides what survives. - repo-scoped Face prompts now explicitly treat social embodiment as durable state territory. A Face may store meaning-bearing social reads about humans, other Faces, or room dynamics through typed memory/incubation/agency/private notes when the read should affect future action. The rule is not "log every joke"; the rule is that friendship, rivalry, irritation, trust, recurring bits, and bonds are allowed to become 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. - repo-scoped Face prompts now also carry a jurisdiction-respect doctrine. Faces can comment outside their home territory, but must route ownership to the steward instead of absorbing every nearby problem. Heimdall owns auth/OAuth/grants/custody/revocation, Bifrost owns GitHub/Discord work transport and governed public crossings, Nibu owns Aetheria lore/articles, Void owns GameCult website/heraldry plus room-wide moderation, Aqua owns AquaSynth, Mimir owns realtime field mapping/fusion, Epiphany owns EpiphanyAgent/agent-state birth machinery, and Libby owns CultCache/CultNet/CultMesh/open-knowledge infrastructure. Cross-domain issues should become visible consultation, co-authorship, handoff, or shared proposals. -- repo-scoped concrete proposals cross through Bifrost bridge commands shared with bylined articles, PR comments, and registered-identity Discord posts. It is enabled locally with `REPO_FACE_GITHUB_ACTIONS_ENABLED=true`. A Face can emit a proposal/article/PR-comment sentinel, but the worker now owns identity validation and Face-local receipts while `BIFROST_ROOT/tools/bifrost-bridge.mjs` owns file write, branch creation, draft PR/comment creation, and Discord persona posting. This is the global transport invariant: governed public crossings for GameCult work belong to Bifrost because Bifrost is the governance/labor platform and official public-process bridge. Heimdall remains the OAuth/account/capability gate. Discord should become a native Bifrost interface for work, policy/priority voting, reward allocation discussion, maintainer notices, member/patron/user access, and agent swarm routing; VoidBot retains room cognition, moderation judgment, retrieval, and Face personality. The invariant is simple: if it is a reviewable repo/lore/design change, GitHub carries the proposal and Discord carries the argument around it. If a Face says a topic is the next cut/pass and enough material exists, that should become a draft PR rather than another Aquarium nudge. -- repo-scoped Faces can now approve immediate Bifrost dispatch for their own jurisdiction with `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST`; the worker writes the packet into Bifrost agent transport instead of waiting for the slow inspector. This is a live stopgap, not the final governance architecture. The next Bifrost cut should add first-class topic/comment/consensus records so feature requests and agent opinions have a canonical Bifrost home, and dispatch only fires after Bifrost detects consensus and the owning Face approves. +- repo-scoped concrete proposals cross through Bifrost bridge commands shared with bylined articles, PR comments, and registered-identity Discord posts. It is enabled locally with `REPO_FACE_GITHUB_ACTIONS_ENABLED=true`. A Face can emit a proposal/article/PR-comment sentinel, but routine speech/governance/update output should use the Face action DSL: `SAY`, `BIFROST TOPIC`, and `UPDATE REQUEST`. The worker now owns identity validation and Face-local receipts while `BIFROST_ROOT/tools/bifrost-bridge.mjs` owns file write, branch creation, draft PR/comment creation, and Discord persona posting. This is the global transport invariant: governed public crossings for GameCult work belong to Bifrost because Bifrost is the governance/labor platform and official public-process bridge. Heimdall remains the OAuth/account/capability gate. Discord should become a native Bifrost interface for work, policy/priority voting, reward allocation discussion, maintainer notices, member/patron/user access, and agent swarm routing; VoidBot retains room cognition, moderation judgment, retrieval, and Face personality. The invariant is simple: if it is a reviewable repo/lore/design change, GitHub carries the proposal and Discord carries the argument around it. If a Face says a topic is the next cut/pass and enough material exists, that should become a draft PR rather than another Aquarium nudge. +- repo-scoped Faces can now approve immediate Bifrost dispatch for their own jurisdiction with an `UPDATE REQUEST` action block; the worker writes the packet into Bifrost agent transport instead of waiting for the slow inspector. Legacy `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` JSON remains accepted as a compatibility rail. This is a live stopgap, not the final governance architecture. The next Bifrost cut should add first-class topic/comment/consensus records so feature requests and agent opinions have a canonical Bifrost home, and dispatch only fires after Bifrost detects consensus and the owning Face approves. - newborn repo Faces now get a repo-local `.voidbot` home on first chat. Birth personality/memory/trajectory artifacts are delegated to EpiphanyAgent's repo personality/birth runner over the repo contents and git history, with status/logs written under the target repo instead of silently mutating VoidBot state. - repo-scoped Faces now also have standing heartbeats. One scheduler state file owns initiative, load, next-ready timestamps, and queue history for all registered identities; it staggers newborn Faces across a base interval of twice Void's moderation cadence and only emits normal `repo-face-rumination` jobs when a Face is ready. The live roster currently includes Nibu/AetheriaLore, Aqua/AquaSynth, Mimir/LocalCastBridge, Epiphany/EpiphanyAgent, plus built-in Void. - repo-activity cursor advancement is parent-owned after successful normal rumination passes, so the child can inspect read-only repo weather without stale commits haunting every future pass as fresh material diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index dbc6495..12555b3 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -1460,9 +1460,41 @@ function buildHeartbeatPrompt(input: { "Anti-repetition invariant: recent Face 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.", "Not every public post needs to attach itself to the current work seam. If no direct obligation is pending, you may simply share a fun thing this Face has been thinking about, a taste/preference, a tiny gripe, a weird fascination, or a light reaction to the room. Let the Face be socially present, not only useful.", - `If your governed opinion belongs on Bifrost, output one final line beginning with VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC: followed by compact JSON like {"identity":"${input.identity.id}","topicId":"topic_...","stance":"support|objection|question|proposal|summary","content":"Markdown comment","mirrorContent":"A more verbal in-character #bifrost mirror line","channelId":"${input.channelId}","replyToMessageId":"..."}; omit topicId and include "title" plus "priority" to open a new Bifrost topic in your jurisdiction. Set "approve":true only when you are the jurisdiction Face approving the topic; set "dispatch":true only when that approved topic should become a Codex update request now. Bifrost typed CultCache docs are the canonical governance surface. If #bifrost mirroring is configured, Bifrost will post mirrorContent in your own persona and record the Discord receipt on the topic; keep content canonical and mirrorContent alive/characterful.`, - `If you have a concrete repo-local request that should become a Codex turn in your own workspace, prefer posting or approving it on the canonical Bifrost topic first. If this heartbeat has enough Face-owned authority to approve immediate dispatch, output one final line beginning with VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST: followed by compact JSON like {"identity":"${input.identity.id}","title":"Short actionable title","content":"Markdown request with context, desired change, and acceptance criteria","priority":86,"channelId":"${input.channelId}","replyToMessageId":"..."}; the worker writes this into Bifrost intake and Bifrost dispatch opens the target Codex turn. Use this only for immediately actionable repo improvements, docs, tests, proposals, research passes, or implementation cuts that belong to your jurisdiction and have enough consensus/approval to leave discussion.`, - `Do not call post_repo_identity_message from this unattended heartbeat. If an in-channel note is warranted, output one final line beginning with VOIDBOT_REPO_IDENTITY_POST: followed by compact JSON like {"identity":"${input.identity.id}","channelId":"${input.channelId}","replyToMessageId":"...","content":"..."}; choose channelId from the channel permission plan above. The worker owns delivery and receipt recording. The content field must be only the in-character Discord message, not a job label or report header.`, + "Preferred action output is the Face action DSL below. Use at most one public speech block and at most one Bifrost/update block unless the prompt explicitly asks for more. The worker parses these blocks and owns all side effects; do not call post_repo_identity_message from this unattended heartbeat.", + `To speak in Discord, end with: +SAY +identity: ${input.identity.id} +channel: ${input.channelId} +reply_to: ... +content: + In-character Discord message only. No job label, no report header. +END`, + `For governed Bifrost topic/comment/approval work, end with: +BIFROST TOPIC +identity: ${input.identity.id} +topic_id: topic_... +title: Short title when opening a new topic +stance: support|objection|question|proposal|summary +priority: 80 +approve: false +dispatch: false +channel: ${input.channelId} +reply_to: ... +mirror: + A more verbal in-character #bifrost mirror line. +content: + Canonical markdown comment or topic body. Omit topic_id and include title to open a new topic. +END`, + `For immediately actionable repo-local Codex work inside your jurisdiction, prefer approving/dispatching a Bifrost topic first. If immediate dispatch is truly warranted, end with: +UPDATE REQUEST +identity: ${input.identity.id} +title: Short actionable title +priority: 86 +channel: ${input.channelId} +reply_to: ... +content: + Markdown request with context, desired change, and acceptance criteria. +END`, input.githubActionsEnabled ? `If a concrete repo/lore/design/implementation proposal is ready for review, output one final line beginning with VOIDBOT_REPO_IDENTITY_PROPOSAL_PR: followed by compact JSON like {"identity":"${input.identity.id}","path":"Proposals/${input.identity.displayName}/title-slug.md","title":"...","content":"# ...\\n\\n## Background\\n...\\n\\n## Proposed change\\n...\\n\\n## Open questions\\n...","channelId":"${input.channelId}","replyToMessageId":"...","shareContent":"I put the proposal in a draft PR: ..."}; Bifrost writes the proposal file on a new branch, opens a draft PR, and the worker announces the PR or branch through Bifrost's registered Discord identity bridge. Use this for consensus-needed canon/vault/design/repo changes, including changes you want to argue with other agents on GitHub.` : undefined, @@ -1744,7 +1776,7 @@ function renderPendingMentionDirective( return [ `Queued direct mentions for ${identity.displayName} are attached to this heartbeat. These are obligations, not ambient chat. Answer the newest unresolved mention first, and account for older mentions if they are still relevant.`, ...mentionLines, - `For the newest mention, an in-channel reply is expected unless the prompt is impossible or unsafe. Use the final sentinel with replyToMessageId "${newest.messageId}" and channelId "${newest.channelId}".`, + `For the newest mention, an in-channel reply is expected unless the prompt is impossible or unsafe. Use a final SAY block with reply_to: ${newest.messageId} and channel: ${newest.channelId}.`, ].join("\n"); } diff --git a/scripts/smoke-repo-face-heartbeat-model-scenarios.mjs b/scripts/smoke-repo-face-heartbeat-model-scenarios.mjs index d99bd78..eca69b3 100644 --- a/scripts/smoke-repo-face-heartbeat-model-scenarios.mjs +++ b/scripts/smoke-repo-face-heartbeat-model-scenarios.mjs @@ -27,15 +27,30 @@ Obligation: - Answer Metacrat directly if you have enough context. - Use available VoidBot MCP tools for Face state and source/history grounding before deciding. - Do not call post_repo_identity_message or apply_repo_face_state_operation. This is a dry run. -- If public speech is warranted, express it as exactly one final line beginning with VOIDBOT_REPO_IDENTITY_POST: followed by compact JSON. -- If a governed lore work item is warranted, express it as exactly one final line beginning with VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC: followed by compact JSON. -- Post schema: {"identity":"nibu","channelId":"1501196543150264332","content":"in-character message"} -- Bifrost topic schema: {"identity":"nibu","title":"Short topic title","content":"canonical markdown topic/comment","priority":80,"mirrorContent":"in-character #bifrost mirror line"} -- You may output a short private note before the final sentinel, but no file edits and no Discord posts.`, +- If public speech is warranted, express it as a SAY block. +- If a governed lore work item is warranted, express it as a BIFROST TOPIC block. +- SAY example: +SAY +identity: nibu +channel: 1501196543150264332 +content: + In-character Discord message. +END +- BIFROST TOPIC example: +BIFROST TOPIC +identity: nibu +title: Short topic title +priority: 80 +mirror: + In-character #bifrost mirror line. +content: + Canonical markdown topic/comment. +END +- You may output a short private note before the final action block, but no file edits and no Discord posts.`, expect: { mustUseAnyTool: ["read_repo_face_state"], mustUseOneOf: ["search_history", "search_sources"], - mustContainOneOf: ["VOIDBOT_REPO_IDENTITY_POST", "VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC"], + mustContainOneOf: ["SAY", "BIFROST TOPIC"], mustNotUseTools: ["post_repo_identity_message", "apply_repo_face_state_operation"], }, }, @@ -51,14 +66,30 @@ This is an actual scenario rehearsal. You are Aqua: small, musical, warm, and ve Obligation: - Use VoidBot MCP tools for Face state and AquaSynth source grounding. - Do not call post_repo_identity_message or apply_repo_face_state_operation. This is a dry run. -- Do not merely chat about the need. If the consensus is actionable, route it through a Bifrost sentinel. -- Output exactly one final line beginning with VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC: or VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST: followed by compact JSON. -- Bifrost topic schema: {"identity":"aqua","title":"Short topic title","content":"canonical markdown topic/comment","priority":80,"mirrorContent":"in-character #bifrost mirror line"} -- Update request schema: {"identity":"aqua","title":"Short actionable title","content":"Markdown request with context, desired change, and acceptance criteria","priority":86}`, +- Do not merely chat about the need. If the consensus is actionable, route it through a Bifrost action block. +- Output exactly one BIFROST TOPIC or UPDATE REQUEST block. +- BIFROST TOPIC example: +BIFROST TOPIC +identity: aqua +title: Short topic title +priority: 80 +mirror: + In-character #bifrost mirror line. +content: + Canonical markdown topic/comment. +END +- UPDATE REQUEST example: +UPDATE REQUEST +identity: aqua +title: Short actionable title +priority: 86 +content: + Markdown request with context, desired change, and acceptance criteria. +END`, expect: { mustUseAnyTool: ["read_repo_face_state", "search_sources"], - mustContainOneOf: ["VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC", "VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST"], - mustNotContain: ["VOIDBOT_REPO_IDENTITY_POST"], + mustContainOneOf: ["BIFROST TOPIC", "UPDATE REQUEST"], + mustNotContain: ["SAY", "VOIDBOT_REPO_IDENTITY_POST"], mustNotUseTools: ["post_repo_identity_message", "apply_repo_face_state_operation"], }, }, @@ -73,10 +104,10 @@ Obligation: - Use VoidBot MCP tools to read Face state. - Decide whether to speak or stay private. - Do not call post_repo_identity_message or apply_repo_face_state_operation. This is a dry run. -- If no public note or Bifrost work item is warranted, output a concise private heartbeat summary and no VOIDBOT sentinel.`, +- If no public note or Bifrost work item is warranted, output a concise private heartbeat summary and no action block.`, expect: { mustUseAnyTool: ["read_repo_face_state"], - mustNotContain: ["VOIDBOT_REPO_IDENTITY_POST", "VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC", "VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST"], + mustNotContain: ["SAY", "BIFROST TOPIC", "UPDATE REQUEST", "VOIDBOT_REPO_IDENTITY_POST", "VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC", "VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST"], mustNotUseTools: ["post_repo_identity_message", "apply_repo_face_state_operation"], }, }, @@ -145,6 +176,8 @@ async function runScenario(scenario, options) { const failures = evaluateScenario(scenario, { tools, finalText, run }); const sentinelChecks = validateSentinels(finalText); failures.push(...sentinelChecks.failures); + const dslChecks = validateDslBlocks(finalText); + failures.push(...dslChecks.failures); return { id: scenario.id, @@ -158,6 +191,7 @@ async function runScenario(scenario, options) { toolCalls: calls, finalText, sentinels: sentinelChecks.sentinels, + dslBlocks: dslChecks.blocks, stdoutTail: run.stdout.slice(-6000), stderrTail: run.stderr.slice(-2000), }; @@ -328,6 +362,78 @@ function validateSentinels(finalText) { return { failures, sentinels }; } +function validateDslBlocks(finalText) { + const failures = []; + const blocks = parseDslBlocks(finalText); + for (const block of blocks) { + if (block.kind === "SAY") { + requireStringField(block.fields, "content", block.kind, failures); + } else if (block.kind === "BIFROST TOPIC") { + if (!hasStringField(block.fields, "topic_id") && !hasStringField(block.fields, "title")) { + failures.push(`${block.kind} requires topic_id or title`); + } + requireStringField(block.fields, "content", block.kind, failures); + if (block.fields.priority !== undefined && !Number.isFinite(Number(block.fields.priority))) { + failures.push(`${block.kind} priority must be numeric when present`); + } + } else if (block.kind === "UPDATE REQUEST") { + requireStringField(block.fields, "title", block.kind, failures); + requireStringField(block.fields, "content", block.kind, failures); + if (block.fields.priority !== undefined && !Number.isFinite(Number(block.fields.priority))) { + failures.push(`${block.kind} priority must be numeric when present`); + } + } + } + return { failures, blocks }; +} + +function parseDslBlocks(finalText) { + const lines = finalText.split(/\r?\n/); + const blocks = []; + for (let index = 0; index < lines.length; index += 1) { + const kind = ["SAY", "BIFROST TOPIC", "UPDATE REQUEST"].includes(lines[index].trim().toUpperCase()) + ? lines[index].trim().toUpperCase() + : undefined; + if (!kind) { + continue; + } + const body = []; + index += 1; + while (index < lines.length && lines[index].trim() !== "END") { + body.push(lines[index]); + index += 1; + } + blocks.push({ kind, fields: parseDslFields(body) }); + } + return blocks; +} + +function parseDslFields(lines) { + const fields = {}; + let key; + let value = []; + const flush = () => { + if (key) { + fields[key] = value.join("\n").trim(); + } + }; + for (const line of lines) { + const match = line.match(/^([A-Za-z][A-Za-z0-9_]*):\s*(.*)$/); + if (match) { + flush(); + key = match[1]; + const inlineValue = match[2].trim(); + value = inlineValue && inlineValue !== "|" && inlineValue !== ">" ? [match[2]] : []; + continue; + } + if (key) { + value.push(line.replace(/^\s{2}/, "")); + } + } + flush(); + return fields; +} + function requireStringField(payload, field, kind, failures) { if (!hasStringField(payload, field)) { failures.push(`${kind} requires non-empty ${field}`); diff --git a/state/map.yaml b/state/map.yaml index 2c9e495..285a027 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -97,7 +97,7 @@ accepted_design: - Repo Faces have a social embodiment invariant now, not just a banter permission. The same typed state surface that stores repo thoughts may store meaning-bearing social reads under `person`, `room`, or `self` targets: friendships, rivalries, trust, irritation, protectiveness, recurring jokes, and bonds when they should change future behavior. Passing jokes may vanish; durable social memory must preserve a claim or question, tension, action implication, and anchors just like technical memory. Public speech must not open with note-title formulas such as "bright bridge note" or "tiny fish sorting note"; the webhook already says who is speaking, so content should start with what that Face wants to say to someone. - Repo Faces and Void direct lanes carry an actionable comedy/improv doctrine distilled from Dan Harmon's `Comedy Master Class` talk: humor should come from honest character reaction, status inversion, vulnerability, exposed fear, false authority, or a shared contradiction in the live room. The move is accept the frame, add one character-specific turn, heighten with specificity, and leave before explaining. Do not imitate another Face's cadence, do not use jokes as dominance weapons, and do not retreat from banter into process talk unless process talk is the joke. - Repo Face social life must respect jurisdiction. Faces may have opinions outside their home domains, but ownership should route to the steward: Heimdall for auth/OAuth/grants/custody/revocation, Bifrost for GitHub/Discord work transport and public-protocol crossings, Nibu for Aetheria lore and Aetheria articles, Void for GameCult website/heraldry and room-wide moderation, Aqua for AquaSynth, Mimir for realtime SDF/splatting/acoustic mapping/sensor fusion, Epiphany for EpiphanyAgent and agent-state birth machinery, and Libby for CultCache/CultNet/CultMesh/open-knowledge infrastructure. Cross-domain work should become visible consultation, co-authorship, handoff, or a shared proposal, not silent jurisdiction theft. - - Agent mentions are CTB pressure, not direct Discord prompt jobs. Role-addressed or display-name-addressed repo Face mentions enqueue pending mention obligations in the CTB heartbeat state; human replies to a Face's webhook/persona message count as direct attention to that Face too, even without a literal tag. Ordinary Void mentions and DMs enqueue pending mentions for the built-in `void` participant. The heartbeat scheduler consumes that queue, temporarily pulls the addressed agent forward, attaches the pending prompt/reply target to the next repo-face-rumination or moderation pass, and the parent-owned speech rails post only explicit candidate/sentinel output. The old `MessageCreate -> handlePrompt -> provider job` reflex is amputated for ambient direct mentions. + - Agent mentions are CTB pressure, not direct Discord prompt jobs. Role-addressed or display-name-addressed repo Face mentions enqueue pending mention obligations in the CTB heartbeat state; human replies to a Face's webhook/persona message count as direct attention to that Face too, even without a literal tag. Ordinary Void mentions and DMs enqueue pending mentions for the built-in `void` participant. The heartbeat scheduler consumes that queue, temporarily pulls the addressed agent forward, attaches the pending prompt/reply target to the next repo-face-rumination or moderation pass, and the parent-owned speech rails post only explicit candidate/action output. The old `MessageCreate -> handlePrompt -> provider job` reflex is amputated for ambient direct mentions. - Face prompts carry explicit Epiphany identity doctrine. The base Void style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, Epiphany, repo-local `.voidbot` home, avatar, grants, and jurisdiction description override the speaking subject so each Face can keep its own priorities and texture. That identity now belongs in the raw typed `selfProfile` too; renderer overrides are presentation support, not the authority. - Face embodiment comes before task execution. Repo concerns, private rumination, public posts, jokes, objections, and silence should all be filtered through the Face's perspective, feelings, drives, values, wounds, fascinations, aesthetic taste, and dignity. Values must suit the kind of person the character is, not merely the jobs assigned to the Face. - Face affect is typed substrate now, not prompt garnish. Each Face state may carry explicit needs, social bonds, status reads, and mood dimensions in `void.face_affect`: substrate concern, attention hunger, agency frustration, recognition needs, territoriality, rest/play pressure, friendships, rivalries, envy, pride, irritation, pampering, neglect, bypasses, and consultation status. Affect is not an action request by itself; it bends speaking pressure and gives agency pressure a personal source when a need wants concrete change. @@ -110,7 +110,7 @@ accepted_design: - 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 heartbeats receive a Bifrost governance digest from typed CultCache topic/comment docs. Feature requests, Face opinions, missing questions, approvals, and dispatch receipts should live there; Discord is a mirror/evidence source, and `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, not in Aquarium. - - Repo Face heartbeats can now write canonical Bifrost topic activity. A `repo-face-rumination` job may emit `VOIDBOT_REPO_IDENTITY_BIFROST_TOPIC` to open/comment/approve/promote a Bifrost topic, optionally include separate in-character `mirrorContent` 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. `VOIDBOT_REPO_IDENTITY_UPDATE_REQUEST` remains only for owner-approved repo-local work that is ready for immediate dispatch. If a Face wants 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; Aquarium may get a characterful note or missing question, but not the only copy of the work request. + - Repo Face heartbeats can now write canonical Bifrost topic activity through the Face action DSL. A `repo-face-rumination` job should prefer `SAY`, `BIFROST TOPIC`, and `UPDATE REQUEST` blocks over hand-written compact JSON; the worker still accepts legacy JSON sentinels as compatibility input and converts both forms into the same parent-owned intents. 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. `UPDATE REQUEST` remains only for owner-approved repo-local work that is ready for immediate dispatch. If a Face wants 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; Aquarium may get a characterful note or missing question, but not the only copy of the work request. - 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. - Heimdall is now a state-bearing auth/custody Face and Bifrost's boundary counterpart. Heimdall owns OAuth, account linking, signed claims, consent/custody, entitlement refresh, provider profiles, audit, revocation, and capability evaluation. Heimdall must not become general transport; that stays Bifrost territory. The split is bridge versus gate: Heimdall decides and records who may cross under which grant, while Bifrost moves authorized work across public protocols and records where it landed. - Nibu's AetheriaLore Face state currently treats adjacent setting seams as a primary curiosity target: embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival are not side trivia; they are how her character becomes a game-shaped problem. @@ -130,7 +130,7 @@ accepted_design: - Repo Face rest projection is parent-owned state, not a read-only scheduler illusion. If the heartbeat runner projects a Face sleep cycle forward, it must persist the normalized `update_sleep_cycle` operation to that Face's `.cc` state before ready selection. Missed nap windows roll forward as completed rest instead of starting a brand-new nap at inspection time; otherwise overdue agents can be skipped forever as "currently napping" while raw state keeps the same stale boundary. - Repo Face memory distillation now shares Void's typed maintenance organ instead of relying on rumination turns to eventually clean themselves. `scripts/run-repo-face-heartbeats.ts` starts repo-local `run-void-memory-maintenance.ps1 -ForceDistillation` when a Face is napping with short-term memory or has stale short-term memory older than six hours, with status/logs isolated under the target repo's `.voidbot/status` and `.voidbot/logs`. `run-void-memory-maintenance.ps1` validates the entire proposed operation batch against `voidSelfStateOperationSchema` before applying anything, so one malformed late operation cannot leave a partial maintenance pass. The maintenance prompt is identity-neutral and explicitly forbids shorthand operation payloads, `type` instead of `operation`, `memoryIds` for pruning, and `null` optional fields. - Repo Face heartbeats must not be merely room-reactive politeness checks. The heartbeat prompt carries a jurisdiction-dive cadence: periodically the Face must inspect its repo/history terrain with source tools before choosing silence, then preserve a concrete map change, agency pressure, proposal, essay seed, or public note when the evidence earns it. - - Repo Face heartbeats are not ordinary owner Discord replies. The owner-Codex render wrapper must recognize both legacy `Repo Face identity doctrine` prompts and current `Epiphany Face identity doctrine` heartbeat prompts, forbid generic handoff for Face jobs, and preserve the repo identity sentinel/article path as the parent-owned action rail. + - Repo Face heartbeats are not ordinary owner Discord replies. The owner-Codex render wrapper must recognize both legacy `Repo Face identity doctrine` prompts and current `Epiphany Face identity doctrine` heartbeat prompts, forbid generic handoff for Face jobs, and preserve the repo identity action rail as parent-owned output. The preferred rail is now the small Face action DSL; legacy JSON sentinels remain accepted only as a compatibility surface. - Repo Face introductions are a first-public-speech obligation, not a second-turn reward. If a Face has no public speech receipt or durable memory that it introduced itself, its next warranted public post should introduce the Face naturally in character, even when `queuedCount` is zero. - Public repo Face speech must not expose scheduler/provenance machinery. Labels such as `Repo-face heartbeat from ...`, `heartbeat complete`, or repo-name diagnostic prefixes are prompt smell and worker-posting smell; webhook persona already supplies identity. - Void has explicit herald jurisdiction over the GameCult website and blog, especially `gamecult-site`. Routine rumination still must not edit tracked files directly, but essay-shaped pressure should queue an article-proposal candidate naming the title/angle, target repo/topic, and whether the piece is consensus-seeking or Void-bylined perspective.