diff --git a/apps/worker/src/mcp-server-tools.ts b/apps/worker/src/mcp-server-tools.ts index 1c8d466..daf3d92 100644 --- a/apps/worker/src/mcp-server-tools.ts +++ b/apps/worker/src/mcp-server-tools.ts @@ -555,6 +555,11 @@ export function registerVoidbotTools( const typedState = await loadVoidSelfStateTypedDocuments({ canonicalPath: resolved.faceStatePath, + identity: { + agentId: resolved.identity.id, + publicName: resolved.identity.displayName, + publicDescription: resolved.identity.description, + }, }); const rendered = buildVoidSelfStateContext(typedState, { sourcePath: resolved.faceStatePath, @@ -609,7 +614,14 @@ export function registerVoidbotTools( } const result = await applyVoidSelfStateOperation( - { canonicalPath: resolved.faceStatePath }, + { + canonicalPath: resolved.faceStatePath, + identity: { + agentId: resolved.identity.id, + publicName: resolved.identity.displayName, + publicDescription: resolved.identity.description, + }, + }, input.operation, ); diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 84dd843..c662e62 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -46,7 +46,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Agency has a typed pressure surface now: `upsert_agency_pressure` / `retire_agency_pressure` own discomforts, active tensions, self-advocacy requests, and world-advocacy requests. Candidate interventions are possible speech/actions emerging from pressure, not the pressure itself. `npm run moderation:agency-pressure:fixture` verifies the pressure enters `.cc`, renders in self-state summary, and affects mood speaking pressure. - Strong advocacy pressure now has to account for its own mouth. The rumination runner derives `speechPressureObligations` once active self/world advocacy reaches 0.55 intensity, and only a live queued candidate counts as release for that obligation. A pass may queue/defer a candidate or cool/retire the pressure, but silent `[]` fails visibly and an older spoken candidate does not buy permanent absolution. `npm run moderation:rumination:speech-pressure-fixture` covers the silent, spoken-history, and queued-candidate paths. - Deferred candidates are not delivered speech and no longer satisfy active advocacy pressure by themselves. The runner now delivers existing queued candidates with delivery targets, not only candidates proposed by the current model pass. The first live repair used Metacrat's explicit channel permission to move the stranded AquariumSynthHost candidate from deferred/no-target to queued/channel-target, then delivered it through the normal bot transport and recorded a typed speech receipt. -- Parent-owned delivery now also checks canonical typed speech receipts before using the mouth. If a queued candidate targets a Discord reply that already has a receipt, the runner retires it instead of answering the same message twice in slightly different prose. `scripts/smoke-void-rumination-speech-fixture.ps1` now seeds a prior receipt and proves both same-pass sibling retirement and already-answered target retirement. +- Parent-owned delivery now also checks canonical typed speech receipts before using the mouth. If a queued candidate targets a Discord reply that already has a receipt, the runner retires it instead of answering the same message twice in slightly different prose. Typed state writes also normalize historical receipts to keep the earliest canonical reply per `channelId + replyToMessageId`. `scripts/smoke-void-rumination-speech-fixture.ps1` now seeds a prior receipt and proves both same-pass sibling retirement and already-answered target retirement. - Most of the deterministic legacy translation and retention code is recent compensator cruft from failed attempts to manually clean an exploded state file. Do not preserve it as an earned organ. Architect the state boundary so that bad state cannot enter, and keep only narrow historical migrations for old debris. - The desired agency shape is typed state operations plus derived/model-owned policy, not a permanent sermon generator in maintenance code. - Sleep/distillation now has a runner boundary, prompt contract, fixture coverage, and one live-state pass. The invariant is fixed: concrete subject, claim/question, anchors, live tension, and future-action implication survive compression. Bulk can die. Meaning does not get put through the office shredder and called a dream. @@ -103,6 +103,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 tag the role, while Codex agents speak through `post_repo_identity_message`. The worker suppresses base-bot auto-posting for `repo-identity-mention` jobs so the registered identity tool remains the mouth. - 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 queues `REPO_FACE_RUMINATION_PASSES` owner-Codex jobs after role-addressed chats. Direct `repo-identity-mention` jobs are posted by the worker through the registered webhook persona; background Face jobs must use `read_repo_face_state` / `apply_repo_face_state_operation` and `post_repo_identity_message` if they choose to speak. Worker auto-posting stays suppressed for `repo-face-rumination`. - 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 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. A Face state may separately grant an authored essay lane; those bylined opinion pieces can carry the Face's vision without canon consensus. - 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`. - Repo Faces now also have standing heartbeats. `scripts/run-repo-face-heartbeats.ts` owns one Ghostlight-style initiative queue for all registered identities, stores it at `REPO_FACE_HEARTBEAT_STATE_PATH`, staggers newborn Faces across twice Void's moderation interval, and queues ready identities as normal `repo-face-rumination` jobs. `npm run state:install-repo-face-heartbeat-task` installs the hidden local scheduled task that ticks the scheduler. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 0a2e039..cdc979a 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -153,7 +153,7 @@ This note is the source-grounded description of how the live VoidBot stack is sh - On startup, the bot tries to find or create missing mentionable roles for registered identities in `DISCORD_GUILD_ID`, then writes the role ids back to the private registry. If Discord permissions are missing, it warns and leaves the registry unchanged. - `list_repo_discord_identities` exposes the registered address book to Codex agents, and `post_repo_identity_message` enforces identity/channel registration before posting through the shared webhook pipe. - Each repo identity has a Face state path, defaulting to `.voidbot/private/repo-faces/.cc`. `read_repo_face_state` renders that typed state, and `apply_repo_face_state_operation` applies the same typed operation contract used by Void against that Face's own file. - - Repo Face prompts now carry an explicit identity doctrine block. Void's style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, repo-local `.voidbot` home, avatar, and identity description override the speaking subject so a Face does not collapse back into base Void. + - Repo Face prompts now carry an explicit identity doctrine block. Void's style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, repo-local `.voidbot` home, avatar, and identity description override the speaking subject so a Face does not collapse back into base Void. `ensureRepoFaceInitialized` also repairs the typed `selfProfile` inside the Face `.cc` file, and repo Face MCP state reads/writes pass identity defaults into the state service, so raw state and rendered state agree. - 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. 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. diff --git a/packages/core/src/repo-face-initializer.ts b/packages/core/src/repo-face-initializer.ts index f685a26..640776b 100644 --- a/packages/core/src/repo-face-initializer.ts +++ b/packages/core/src/repo-face-initializer.ts @@ -4,6 +4,8 @@ import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import type { RepoDiscordIdentity } from "./repo-discord-identities"; +import { resolveRepoFaceStatePath } from "./repo-discord-identities"; +import { ensureVoidSelfStateIdentityProfile } from "./void-self-state-service"; export interface RepoFaceInitializationOptions { identity: RepoDiscordIdentity; @@ -48,6 +50,14 @@ export async function ensureRepoFaceInitialized( await mkdir(resolve(repoVoidbotRoot, "birth"), { recursive: true }); await mkdir(resolve(repoVoidbotRoot, "logs"), { recursive: true }); await writeRepoFaceIdentity(options.identity, repoPath, identityPath); + await ensureVoidSelfStateIdentityProfile({ + canonicalPath: resolveRepoFaceStatePath(options.identity, options.storageRoot), + identity: { + agentId: options.identity.id, + publicName: options.identity.displayName, + publicDescription: options.identity.description, + }, + }); await writeReadmeIfMissing( resolve(repoVoidbotRoot, "voice", "README.md"), renderVoiceReadme(options.identity), diff --git a/packages/core/src/void-self-state-projection.ts b/packages/core/src/void-self-state-projection.ts index 26f8dd5..7b6ded5 100644 --- a/packages/core/src/void-self-state-projection.ts +++ b/packages/core/src/void-self-state-projection.ts @@ -43,16 +43,27 @@ export interface VoidSelfStateProjectionOptions { }; } +export interface VoidSelfStateIdentityDefaults { + agentId: string; + publicName: string; + publicDescription?: string; +} + export function createEmptyVoidSelfState( - options: { createdAt?: string } = {}, + options: { createdAt?: string; identity?: VoidSelfStateIdentityDefaults } = {}, ): VoidSelfStateTypedProjection { const createdAt = options.createdAt ?? new Date().toISOString(); + const identity = options.identity ?? { + agentId: "void-moderator", + publicName: "Void", + }; return { selfProfile: voidSelfProfileSchema.parse({ schemaVersion: 1, - agentId: "void-moderator", - publicName: "Void", + agentId: identity.agentId, + publicName: identity.publicName, + publicDescription: identity.publicDescription, privateNotes: [], values: [], activationProfile: { diff --git a/packages/core/src/void-self-state-service.ts b/packages/core/src/void-self-state-service.ts index 4bfdbc9..e29c1d4 100644 --- a/packages/core/src/void-self-state-service.ts +++ b/packages/core/src/void-self-state-service.ts @@ -22,15 +22,17 @@ import { } from "./void-self-state-domain"; import { createEmptyVoidSelfState, + type VoidSelfStateIdentityDefaults, type VoidSelfStateTypedProjection, } from "./void-self-state-projection"; export interface VoidSelfStateServiceOptions { canonicalPath: string; + identity?: VoidSelfStateIdentityDefaults; } export interface VoidSelfStateOperationResult { - operation: VoidSelfStateOperation["operation"]; + operation: VoidSelfStateOperation["operation"] | "ensure_identity_profile"; canonicalPath: string; typedDocumentsWritten: number; } @@ -41,7 +43,7 @@ export async function loadVoidSelfStateTypedDocuments( const canonicalPath = resolve(options.canonicalPath); const cache = createVoidSelfStateCache(canonicalPath); await cache.pullAllBackingStores(); - return readTypedStateOrEmpty(cache); + return readTypedStateOrEmpty(cache, options.identity); } export async function applyVoidSelfStateOperation( @@ -53,7 +55,7 @@ export async function applyVoidSelfStateOperation( const cache = createVoidSelfStateCache(canonicalPath); await cache.pullAllBackingStores(); - const typedState = readTypedStateOrEmpty(cache); + const typedState = readTypedStateOrEmpty(cache, options.identity); applyTypedOperation(typedState, operation); await writeTypedState(cache, typedState); @@ -64,6 +66,24 @@ export async function applyVoidSelfStateOperation( }; } +export async function ensureVoidSelfStateIdentityProfile( + options: VoidSelfStateServiceOptions & { identity: VoidSelfStateIdentityDefaults }, +): Promise { + const canonicalPath = resolve(options.canonicalPath); + const cache = createVoidSelfStateCache(canonicalPath); + await cache.pullAllBackingStores(); + + const typedState = readTypedStateOrEmpty(cache, options.identity); + repairSelfProfileIdentity(typedState, options.identity); + await writeTypedState(cache, typedState); + + return { + operation: "ensure_identity_profile", + canonicalPath, + typedDocumentsWritten: 7, + }; +} + function createVoidSelfStateCache(canonicalPath: string): CultCache { return CultCache.builder() .withRegistry(voidSelfStateDocumentRegistry) @@ -73,9 +93,10 @@ function createVoidSelfStateCache(canonicalPath: string): CultCache { function readTypedStateOrEmpty( cache: CultCache, + identity?: VoidSelfStateIdentityDefaults, ): VoidSelfStateTypedProjection { - const empty = createEmptyVoidSelfState(); - return { + const empty = createEmptyVoidSelfState({ identity }); + const state = { selfProfile: cache.getGlobal(voidSelfProfileDocument) ?? empty.selfProfile, moderationCursor: cache.getGlobal(voidModerationCursorDocument) ?? empty.moderationCursor, speechReceipts: cache.getGlobal(voidSpeechReceiptsDocument) ?? empty.speechReceipts, @@ -85,12 +106,18 @@ function readTypedStateOrEmpty( candidateInterventions: cache.getGlobal(voidCandidateInterventionsDocument) ?? empty.candidateInterventions, }; + if (identity) { + repairSelfProfileIdentity(state, identity); + } + normalizeDeliveryReceipts(state); + return state; } async function writeTypedState( cache: CultCache, state: VoidSelfStateTypedProjection, ): Promise { + normalizeDeliveryReceipts(state); await cache.putGlobal(voidSelfProfileDocument, stripUndefined(state.selfProfile)); await cache.putGlobal(voidModerationCursorDocument, stripUndefined(state.moderationCursor)); await cache.putGlobal(voidSpeechReceiptsDocument, stripUndefined(state.speechReceipts)); @@ -100,6 +127,57 @@ async function writeTypedState( await cache.putGlobal(voidCandidateInterventionsDocument, stripUndefined(state.candidateInterventions)); } +function repairSelfProfileIdentity( + state: VoidSelfStateTypedProjection, + identity: VoidSelfStateIdentityDefaults, +): void { + let changed = false; + + if (state.selfProfile.agentId !== identity.agentId) { + state.selfProfile.agentId = identity.agentId; + changed = true; + } + if (state.selfProfile.publicName !== identity.publicName) { + state.selfProfile.publicName = identity.publicName; + changed = true; + } + if ( + identity.publicDescription && + state.selfProfile.publicDescription !== identity.publicDescription + ) { + state.selfProfile.publicDescription = identity.publicDescription; + changed = true; + } + + if (changed) { + state.selfProfile.updatedAt = new Date().toISOString(); + } +} + +function normalizeDeliveryReceipts(state: VoidSelfStateTypedProjection): void { + const byTarget = new Map(); + const noTarget: VoidSpeechReceipts["recentReceipts"] = []; + + for (const receipt of state.speechReceipts.recentReceipts) { + const targetKey = receipt.channelId && receipt.replyToMessageId + ? `${receipt.channelId}\u0000${receipt.replyToMessageId}` + : undefined; + if (!targetKey) { + noTarget.push(receipt); + continue; + } + + const existing = byTarget.get(targetKey); + if (!existing || receipt.sentAt.localeCompare(existing.sentAt) < 0) { + byTarget.set(targetKey, receipt); + } + } + + state.speechReceipts.recentReceipts = [...noTarget, ...byTarget.values()] + .sort((left, right) => left.sentAt.localeCompare(right.sentAt)) + .slice(-24); +} + function applyTypedOperation( state: VoidSelfStateTypedProjection, operation: VoidSelfStateOperation, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 91b03ff..3582eb8 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -146,3 +146,4 @@ {"ts":"2026-05-18T13:34:36Z","type":"implementation","status":"ok","note":"Repo Face prompts now frame repo-change agency as advisory and consensus-first. Nibu's Face state adds consensus-backed AetheriaLore change agency plus tangential setting fascination around embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival."} {"ts":"2026-05-18T13:39:11Z","type":"implementation","status":"ok","note":"Nibu now has a vault character note at Aetheria/Lore/Nibu.md, stewards that note in Face state, and has a separate Nibu-authored lore essay lane: canon/vault edits remain consensus-backed, while bylined Aetheria blog essays can carry her opinions without consensus."} {"ts":"2026-05-18T13:53:46Z","type":"bugfix","status":"ok","note":"Operations watchdog recovery notifications can have zero issue lines; Build-OwnerNotificationMessage now accepts an empty IssueLines array so healthy recovery DMs do not create a watchdog.notification warning."} +{"ts":"2026-05-18T14:01:49Z","type":"bugfix","status":"ok","note":"Inspection found two live-loop seams beyond the watchdog notice: repo Face state files could retain Void's default selfProfile under renderer masking, and canonical speech receipts could keep historical duplicate reply targets. Repo Face initialization now seeds/repairs typed selfProfile identity, repo Face MCP state operations pass identity defaults, and state writes normalize direct-reply receipts to keep the earliest receipt per channel/reply target."} diff --git a/state/map.yaml b/state/map.yaml index 50edb6f..6939dc8 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -91,7 +91,7 @@ accepted_design: - Public artifacts attributed to Void should be discovered through the ordinary repo-weather content-hint and retrieval path, then presented through typed candidate interventions to the configured public room when Void confirms authorship and recent receipts/candidates do not show the artifact was already shared. - Repo-scoped agents should use Discord roles as addressable identities and the shared webhook persona pipe as the mouth. Registered repo identities live behind one registry, can be discovered through MCP, and post through `post_repo_identity_message` instead of arbitrary persona-name strings. - A repo identity's Face is a typed `.cc` state file resolved from the identity registry. Role-addressed messages create direct repo-identity jobs whose final text is posted through the registered webhook persona, then queue a bounded series of owner-Codex Face rumination jobs; those rumination jobs may read/apply Face state operations and post through the registered repo identity, but the worker must not auto-post their final summaries as the base bot. - - Repo Face prompts carry explicit identity doctrine. The base Void style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, repo-local `.voidbot` home, avatar, and identity description override the speaking subject so each Face can keep its own priorities and texture. + - Repo Face prompts carry explicit identity doctrine. The base Void style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, repo-local `.voidbot` home, avatar, and identity 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. - 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. Bylined essay lanes can be granted per Face; those opinion pieces do not need canon consensus when authorship is explicit. - 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. - Nibu now stewards `Aetheria/Lore/Nibu.md` and may produce Nibu-authored Aetheria lore essays as opinionated perspective. Character-note/canon edits still need consensus; bylined essays do not. @@ -164,7 +164,7 @@ landed_baseline: - Agency pressure is typed state now. `upsert_agency_pressure` / `retire_agency_pressure` own discomforts, active tensions, self-advocacy requests, and world-advocacy requests; mood speaking pressure can derive from them, but candidate interventions remain separate possible speech/actions. - The agency-to-speech seam now has an accountability gate. `scripts/run-void-moderator-rumination.ps1` projects `speechPressureObligations` once active self/world advocacy reaches 0.55 intensity, rejects silent operation output unless those pressures become live queued candidate interventions or are explicitly cooled/retired, and does not treat old spoken candidates as ongoing satisfaction. `npm run moderation:rumination:speech-pressure-fixture` proves the silent-failure path, the spoken-history failure path, and the queued-candidate path. - Deferred candidate interventions are not speech. A pressure is not resolved until it becomes a queued/spoken candidate or is explicitly cooled/retired, and the rumination runner now delivers already-queued candidates with delivery targets instead of only delivering candidates proposed in the current pass. - - Parent-owned candidate delivery must trust canonical speech receipts more than fresh enthusiasm. If a queued candidate points at a Discord reply target that already has a typed receipt, the runner should retire it before delivery instead of answering the same message twice with a slightly different haircut. + - Parent-owned candidate delivery must trust canonical speech receipts more than fresh enthusiasm. If a queued candidate points at a Discord reply target that already has a typed receipt, the runner should retire it before delivery instead of answering the same message twice with a slightly different haircut. Typed state writes also normalize receipts to keep only the earliest canonical receipt per `channelId + replyToMessageId`. - `npm run moderation:agency-pressure:fixture` verifies typed agency pressure enters CultCache, renders in the self-state summary, and contributes to mood speaking pressure without reviving hard-wired agency behavior. - Memory cleanup now has to be meaningfully destructive. Sleep-side consolidation should translate old thought fossils into the live claim/question/fascination surface, drop templated slogan-slop, trim repo/archive/runtime residue down to a small recent anchor surface, and keep the freshest surviving records instead of accidentally embalming the oldest ones after a sort. - Durably weird semantic kinds are guilty until proven useful. `recent-preoccupation`, `quiet-room-status`, person-singleton labels, and `live seam` boilerplate are state poison, not doctrine; typed memory ingress should reject or avoid them instead of relying on cleanup to mop later.