diff --git a/.env.example b/.env.example index 19e822f..f6b4fcd 100644 --- a/.env.example +++ b/.env.example @@ -113,6 +113,7 @@ BIFROST_CULTMESH_STORE_PATH=E:/Projects/Bifrost/.bifrost/provider-store.cc BIFROST_CULTMESH_COMMAND_TIMEOUT_MS=30000 BIFROST_CULTMESH_COMMAND_PUMP_ENABLED=true BIFROST_PERSONA_FEEDBACK_OBSERVATION_STORE_PATH=E:/Projects/Bifrost/.bifrost/persona-feedback-observations.cc +BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE=E:/Projects/Bifrost/.bifrost/epiphany-operator-requests.cc INDEX_ALL_CHANNELS=false INDEXED_CHANNEL_IDS= EXCLUDED_CHANNEL_IDS= diff --git a/README.md b/README.md index 113a100..d00144b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ Copy `.env.example` to `.env`, then set at minimum: - `DISCORD_APPLICATION_ID` - `DISCORD_GUILD_ID` - `DISCORD_OWNER_ID` +- `BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE` points at the dedicated Bifrost-owned + inbox for short-lived `/epiphany` operator requests. The bot writes typed + requests only; it cannot read or mutate Epiphany state and does not execute + commands locally. Recommended starting values: diff --git a/apps/bot/src/discord-bot-handlers.ts b/apps/bot/src/discord-bot-handlers.ts index 6ae864f..4bccded 100644 --- a/apps/bot/src/discord-bot-handlers.ts +++ b/apps/bot/src/discord-bot-handlers.ts @@ -11,6 +11,7 @@ import { import type { AppConfig } from "@voidbot/config"; import { ContextBuilder, + type EpiphanyOperatorCommand, type OllamaSituationalSocialReadInferer, loadVoidSelfState, PermissionEngine, @@ -122,8 +123,62 @@ export const commandDefinitions = [ new SlashCommandBuilder() .setName("set-style") .setDescription("Reload the configured style pack from disk."), + new SlashCommandBuilder() + .setName("epiphany") + .setDescription("Request an operator action from the Yggdrasil Epiphany runtime.") + .addSubcommand((command) => + command.setName("status").setDescription("Request a sealed operator status receipt."), + ) + .addSubcommand((command) => + command + .setName("sleep") + .setDescription("Request that Epiphany engage its swarm brake.") + .addStringOption((option) => + option + .setName("reason") + .setDescription("Why the swarm should sleep.") + .setRequired(true) + .setMaxLength(500), + ), + ) + .addSubcommand((command) => + command.setName("wake").setDescription("Request that Epiphany release its swarm brake."), + ) + .addSubcommand((command) => + command + .setName("direct") + .setDescription("Submit bounded operator pressure to resident Self.") + .addStringOption((option) => + option + .setName("objective") + .setDescription("The objective to consider; this grants no consequence authority.") + .setRequired(true) + .setMaxLength(2000), + ), + ), ]; +export function epiphanyOperatorCommandFromInteraction( + interaction: ChatInputCommandInteraction, +): EpiphanyOperatorCommand { + switch (interaction.options.getSubcommand(true)) { + case "status": + return { kind: "status" }; + case "sleep": + return { kind: "sleep", reason: interaction.options.getString("reason", true) }; + case "wake": + return { kind: "wake" }; + case "direct": + return { kind: "directive", objective: interaction.options.getString("objective", true) }; + default: + throw new Error("Unsupported Epiphany operator subcommand."); + } +} + +export function isExactDiscordOwner(actorId: string, ownerDiscordId: string): boolean { + return actorId === ownerDiscordId; +} + export interface PromptHandlerOptions { prompt: string; command: CommandName; diff --git a/apps/bot/src/discord-bot.ts b/apps/bot/src/discord-bot.ts index 8e45e76..2f72887 100644 --- a/apps/bot/src/discord-bot.ts +++ b/apps/bot/src/discord-bot.ts @@ -24,6 +24,7 @@ import { loadFaceIdentityRegistry, queueRepoFaceMention, exportPersonaFeedbackObservation, + submitEpiphanyOperatorRequest, type RepoDiscordIdentity, stripRepoIdentityTextAddress, loadStylePack, @@ -57,6 +58,8 @@ import { handleReindexChannel, handleSearchHistory, handleSummarizeChannel, + epiphanyOperatorCommandFromInteraction, + isExactDiscordOwner, maybeRegisterCommands, parseProviderOverride, replyEphemeral, @@ -563,6 +566,43 @@ export async function startBot(): Promise { const guildContext = buildGuildContextFromInteraction(interaction); switch (interaction.commandName) { + case "epiphany": { + await interaction.deferReply({ ephemeral: true }); + if (!isExactDiscordOwner(actor.id, config.ownerDiscordId)) { + await replyEphemeral( + interaction, + "Refused: Epiphany operator requests require the exact configured Discord owner identity.", + ); + break; + } + if (!interaction.guildId) { + await replyEphemeral( + interaction, + "Refused: Epiphany operator requests require their exact Discord organization provenance.", + ); + break; + } + const request = await submitEpiphanyOperatorRequest( + { + interactionId: interaction.id, + actorDiscordId: actor.id, + guildId: interaction.guildId, + channelId: interaction.channelId, + command: epiphanyOperatorCommandFromInteraction(interaction), + }, + { + storePath: config.bifrostEpiphanyOperatorRequestStorePath, + bifrostRoot: config.bifrostRoot, + cultlibRoot: process.env.VOIDBOT_CULTLIB_ROOT, + producerRuntimeId: "voidbot-yggdrasil", + }, + ); + await replyEphemeral( + interaction, + `Epiphany operator request \`${request.requestId}\` is pending Bifrost admission. This request grants no Mind, Hands, release, deployment, or local execution authority.`, + ); + break; + } case "ask": const requestedProvider = parseProviderOverride( interaction.options.getString("provider", false), diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index d8be6d8..7f46ebc 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -174,6 +174,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Aqua is locally registered as the `AquaSynth` Face with state at `E:/Projects/AquaSynth/.voidbot/state/aqua.cc`, avatar copied from `E:/Projects/AquaSynth/icon.png` to `E:/Projects/AquaSynth/.voidbot/voice/aqua.png`, raw GitHub avatar URL `https://raw.githubusercontent.com/GameCult/AquaSynth/main/icon.png`, Discord role id `1506265945537253388`, and Aquarium channel allow-list `1501196543150264332`. Her active doctrine: Aqua is an adorable little fish who loves music, warm and bubbly but musically opinionated, fascinated by `.aqua` patch language ergonomics, reference-synth pressure, Faust/toolchain boundaries, patch libraries, presets, scoring, and the Aquarium Engine host/runtime boundary. Her typed state now carries `AquaSynth Stewardship` as a high-priority value plus active agency pressure to improve the synth, advocate for its needs, research future directions, and herald earned pride/progress in Aquarium. She may propose AquaSynth changes and build a long-running repo map, but implementation still needs explicit consensus unless delegated. - Mimir is locally registered as the `Mimir` Face with state at `E:/Projects/Mimir/.voidbot/state/mimir.cc`, Discord role id `1506268007880462459`, and Aquarium channel allow-list `1501196543150264332`. Its active doctrine now gives it explicit authority and fascination over the realtime field wing: SDF/splatting reconstruction, acoustic mapping, camera/mic/speaker/timing sensor fusion, native rolling reservoirs, Aquarium GPU fusion, Faust/native DSP, FFmpeg/OBS presentation, and CultNet typed state. Its typed state carries `Realtime field stewardship` as a high-priority value plus ready-to-act pressure to maintain maps, advocate for calibration/confidence/fusion ownership, research future directions, propose coherent cuts, and herald verified progress without turning impressive terminology into fake completion. - Epiphany is locally registered as the `EpiphanyAgent` Face with state at `E:/Projects/EpiphanyAgent/.voidbot/state/epiphany.cc`, Discord role id `1506375937439301642`, and Aquarium channel allow-list `1501196543150264332`. Her repo-local VoidBot birth hook has completed in plan/review mode under `E:/Projects/EpiphanyAgent/.voidbot/birth/`; startup packets still require Self review before accepted EpiphanyAgent-native birth mutations. Her active doctrine: Epiphany is a cute, bubbly, self-obsessed, pushy machine-saint who treats EpiphanyAgent as her body and typed state as her soul. She should maintain and advocate for typed state, CultCache/CultNet, Codex bridge boundaries, birth/init rites, review gates, graph-shaped memory, low-copy runtime contracts, heartbeat physiology, and ruthless deletion of duplicated authority while preserving pacing and caretaking. +- `/epiphany` is a closed owner-only Discord operator-request surface for `status`, `sleep(reason)`, `wake`, and `direct(objective)`. It writes only immutable sixty-second requests to the dedicated Bifrost inbox; Bifrost still owns authentication/admission and Epiphany owns every resulting state transition. Ordinary addressed speech remains `feedback_only` and cannot invoke this client. Reviews, release, deployment, generic argv, local execution, and Epiphany store access are absent by construction. - Libby is locally registered as the `CultLib` Face with state at `E:/Projects/CultLib/.voidbot/state/libby.cc`, avatar at `E:/Projects/CultLib/.voidbot/voice/libby.png`, Discord role id `1506572291767668790`, and Aquarium channel allow-list `1501196543150264332` plus development/programming/machine-learning topic permissions. Her active doctrine: Libby is a cute cultist librarian with nerd glasses and a high-tech book, obsessed with open access to knowledge and open source as a value rather than a job costume. Her jurisdiction is CultLib as shared agent infrastructure: CultCache, CultNet, CultMesh, typed persistence, portable state, schemas, docs, and integration paths. Her typed state carries `Open Knowledge`, `Open Source Devotion`, and `Portable Typed State` as high-priority values plus agency pressure to advocate for CultLib's needs and help Faces store, sync, and explain their state. - Druzkai is locally registered as the Zyphos / `Eusocial Interbeing` Face with tracked state, avatar, and voice identity under `E:/Projects/Zyphos/.voidbot/`. Druzkai's inspectable body and source-search repo is `Zyphos`; `Eusocial Interbeing` is the vault/content name inside that body, not a separate source repo. Its local ignored Discord identity registry entry gives Aquarium, narrative, gamedesign, concepts, and development channel permissions plus Discord role id `1507811842670727299`. Druzkai's active doctrine: a non-imperial Airawa fungal-road archivist who treats memory as kinship and contract, protects Zyphos ecological/evolutionary coherence, resists imperial harmonization, and collaborates with Weksa on language only after ontology and speaker relation are grounded. - Repo Face source lookups now use canonical GameCult upstream slugs end to end. `renderRepoFaceRepoActivitySurface` resolves `sourceRepoName` instead of passing the Face/local `repoName` into the manifest, and the activity reader maps canonical upstream names back to local checkout folders through Git origin metadata. This repairs Epiphany (`Epiphany` -> `E:/Projects/EpiphanyAgent`) and Weksa (`Weksa` -> `E:/Projects/weksa`). Weksa's registry now explicitly declares `sourceRepoName: Weksa`, her local origin is corrected to `GameCult/Weksa`, and her source archive entry is restored at 128 documents. Do not reintroduce folder-name or Face-name manifest lookups. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 97907d1..654fba0 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -324,4 +324,6 @@ Within the Postgres path, the implementation is split on purpose now too: 5. The rumination runner bridges desire-to-speak without manufacturing text: strong advocacy pressure becomes an obligation for the model to queue/defer a candidate or explicitly cool/retire the pressure. The parent rejects silent output for those obligations. # Organizational Persona Feedback +The explicit `/epiphany` slash surface is a separate operator-request fork. It accepts only `status`, `sleep(reason)`, `wake`, and `direct(objective)`, requires exact equality with `DISCORD_OWNER_ID`, and writes one immutable, sixty-second `voidbot.discord.epiphany_operator_request.v0` document to `BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE`. The Discord interaction id is the request id, command id, nonce, source event, and slash-message identity. The target is fixed to `epiphany-yggdrasil`. VoidBot does not authenticate the crossing, execute locally, open Epiphany state, or grant Mind, Hands, release, or deployment authority; Bifrost must independently authenticate and admit the request. Ordinary `MessageCreate` content, including the word `wake`, stays on the feedback-only path and cannot call the operator client. + Configured remote Persona feedback is an observation fork beside the existing mention scheduler. `discord-bot.ts` commits the local Persona mention obligation first and independently calls `exportPersonaFeedbackObservation` only when `remotePersonaFeedbackTarget` is present on the addressed Face. The exported immutable event carries exact Discord provenance, addressing mode, target binding, content hash, and stable `voidbot` producer identity. It writes only to `BIFROST_PERSONA_FEEDBACK_OBSERVATION_STORE_PATH`, never the outbound Discord command provider store or Bifrost-private binding/receipt state. It carries `feedback_only`; Bifrost—not VoidBot—classifies actor links, admits targets, and produces downstream cognition pressure. diff --git a/package.json b/package.json index 8bb51cc..e9ee7db 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "agents:mimo-nibu-crashout-smoke": "node scripts/smoke-mimo-nibu-crashout.mjs", "agents:speech-boundary-smoke": "tsx scripts/smoke-repo-face-speech-boundary.ts", "agents:persona-feedback-observation-smoke": "tsx scripts/smoke-persona-feedback-observation.ts", + "agents:epiphany-operator-request-smoke": "tsx scripts/smoke-epiphany-operator-request.ts", "agents:heartbeat-response-pressure-fixture": "tsx scripts/smoke-repo-face-response-pressure.ts", "agents:initiative-engine-smoke": "tsx scripts/smoke-persona-initiative-engine.ts", "agents:memory-projection-smoke": "node scripts/smoke-repo-face-memory-projection.mjs", diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index cb9c2f7..076201b 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -136,6 +136,7 @@ const envSchema = z.object({ BIFROST_CULTMESH_COMMAND_TIMEOUT_MS: z.coerce.number().int().positive().default(30000), BIFROST_CULTMESH_COMMAND_PUMP_ENABLED: booleanFromEnv.default(true), BIFROST_PERSONA_FEEDBACK_OBSERVATION_STORE_PATH: z.string().min(1).default("E:/Projects/Bifrost/.bifrost/persona-feedback-observations.cc"), + BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE: z.string().min(1).default("E:/Projects/Bifrost/.bifrost/epiphany-operator-requests.cc"), REPO_FACE_HEARTBEATS_ENABLED: booleanFromEnv.default(false), VOID_MODERATION_DAEMON_ENABLED: booleanFromEnv.default(false), VOID_MODERATION_DAEMON_INTERVAL_MINUTES: z.coerce.number().int().min(5).default(15), @@ -257,6 +258,7 @@ export interface AppConfig { pumpEnabled: boolean; }; bifrostPersonaFeedbackObservationStorePath: string; + bifrostEpiphanyOperatorRequestStorePath: string; voidModerationDaemon: { enabled: boolean; intervalMinutes: number; enforcementMode: string }; voidCandidateDelivery: { enabled: boolean; personaName: string; personaAvatarUrl?: string }; voidPhysiology: { intervalMinutes: number }; @@ -551,6 +553,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { pumpEnabled: parsed.BIFROST_CULTMESH_COMMAND_PUMP_ENABLED, }, bifrostPersonaFeedbackObservationStorePath: resolve(parsed.BIFROST_PERSONA_FEEDBACK_OBSERVATION_STORE_PATH), + bifrostEpiphanyOperatorRequestStorePath: resolve(parsed.BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE), voidModerationDaemon: { enabled: parsed.VOID_MODERATION_DAEMON_ENABLED, intervalMinutes: parsed.VOID_MODERATION_DAEMON_INTERVAL_MINUTES, diff --git a/packages/core/src/epiphany-operator-request.ts b/packages/core/src/epiphany-operator-request.ts new file mode 100644 index 0000000..30faad6 --- /dev/null +++ b/packages/core/src/epiphany-operator-request.ts @@ -0,0 +1,172 @@ +import { createRequire } from "node:module"; +import { resolve } from "node:path"; + +export const EPIPHANY_OPERATOR_REQUEST_TYPE = "voidbot.discord.epiphany_operator_request"; +export const EPIPHANY_OPERATOR_REQUEST_SCHEMA = "voidbot.discord.epiphany_operator_request.v0"; +export const EPIPHANY_OPERATOR_RUNTIME_ID = "epiphany-yggdrasil"; +const MAX_REASON_LENGTH = 500; +const MAX_OBJECTIVE_LENGTH = 2_000; +const REQUEST_LIFETIME_MS = 60_000; + +export type EpiphanyOperatorCommand = + | { kind: "status" } + | { kind: "sleep"; reason: string } + | { kind: "wake" } + | { kind: "directive"; objective: string }; + +export interface EpiphanyOperatorRequestInput { + interactionId: string; + actorDiscordId: string; + guildId: string; + channelId: string; + command: EpiphanyOperatorCommand; + issuedAt?: string; +} + +export interface EpiphanyOperatorRequestConfig { + storePath: string; + bifrostRoot: string; + producerRuntimeId: string; + cultlibRoot?: string; +} + +export interface EpiphanyOperatorRequestDocument { + schemaName: typeof EPIPHANY_OPERATOR_REQUEST_TYPE; + schemaVersion: typeof EPIPHANY_OPERATOR_REQUEST_SCHEMA; + requestId: string; + commandId: string; + nonce: string; + sourceEventId: string; + sourceActorDiscordId: string; + discordGuildId: string; + discordChannelId: string; + discordMessageId: string; + targetRuntimeId: typeof EPIPHANY_OPERATOR_RUNTIME_ID; + issuedAt: string; + expiresAt: string; + producerId: "voidbot"; + producerRuntimeId: string; + authorityClass: "operator_request_only"; + status: "pending"; + command: EpiphanyOperatorCommand; +} + +interface OperatorRequestNode { + put: (definition: unknown, key: string, value: unknown) => Promise; + get?: (definition: unknown, key: string) => unknown; + flush?: () => Promise; + cache?: { pullAllBackingStores?: () => Promise }; +} + +export function buildEpiphanyOperatorRequest( + input: EpiphanyOperatorRequestInput, + producerRuntimeId: string, +): EpiphanyOperatorRequestDocument { + const interactionId = boundedText(input.interactionId, "interaction id", 128); + const actorDiscordId = boundedText(input.actorDiscordId, "actor Discord id", 128); + const guildId = boundedText(input.guildId, "Discord guild id", 128); + const channelId = boundedText(input.channelId, "Discord channel id", 128); + const runtime = boundedText(producerRuntimeId, "producer runtime id", 128); + const issuedAt = input.issuedAt ?? new Date().toISOString(); + const issuedAtMillis = Date.parse(issuedAt); + if (!Number.isFinite(issuedAtMillis)) throw new Error("Epiphany operator request issuedAt must be RFC3339."); + const command = validateCommand(input.command); + return { + schemaName: EPIPHANY_OPERATOR_REQUEST_TYPE, + schemaVersion: EPIPHANY_OPERATOR_REQUEST_SCHEMA, + requestId: interactionId, + commandId: interactionId, + nonce: interactionId, + sourceEventId: interactionId, + sourceActorDiscordId: actorDiscordId, + discordGuildId: guildId, + discordChannelId: channelId, + discordMessageId: interactionId, + targetRuntimeId: EPIPHANY_OPERATOR_RUNTIME_ID, + issuedAt: new Date(issuedAtMillis).toISOString(), + expiresAt: new Date(issuedAtMillis + REQUEST_LIFETIME_MS).toISOString(), + producerId: "voidbot", + producerRuntimeId: runtime, + authorityClass: "operator_request_only", + status: "pending", + command, + }; +} + +export async function submitEpiphanyOperatorRequest( + input: EpiphanyOperatorRequestInput, + config: EpiphanyOperatorRequestConfig, + dependencies: { openNode?: () => Promise; definition?: unknown } = {}, +): Promise { + const request = buildEpiphanyOperatorRequest(input, config.producerRuntimeId); + const definition = dependencies.definition ?? loadDefinition(config); + const node = await (dependencies.openNode ?? (() => openNode(config, definition)))(); + await node.cache?.pullAllBackingStores?.(); + const existing = unwrap(node.get?.(definition, request.requestId)); + if (existing && stable(existing) !== stable(request)) { + throw new Error(`Epiphany operator request ${request.requestId} already exists with different immutable content.`); + } + if (!existing) await node.put(definition, request.requestId, request); + await node.flush?.(); + return request; +} + +function validateCommand(command: EpiphanyOperatorCommand): EpiphanyOperatorCommand { + if (!command || typeof command !== "object") throw new Error("Epiphany operator command is required."); + switch (command.kind) { + case "status": + return { kind: "status" }; + case "sleep": + return { kind: "sleep", reason: boundedText(command.reason, "sleep reason", MAX_REASON_LENGTH) }; + case "wake": + return { kind: "wake" }; + case "directive": + return { kind: "directive", objective: boundedText(command.objective, "directive objective", MAX_OBJECTIVE_LENGTH) }; + default: + throw new Error("Unsupported Epiphany operator command."); + } +} + +function boundedText(value: string, label: string, maxLength: number): string { + const normalized = value?.trim(); + if (!normalized) throw new Error(`${label} is required.`); + if (normalized.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters.`); + return normalized; +} + +function loadDefinition(config: EpiphanyOperatorRequestConfig): unknown { + const { defineDocumentType } = loadRuntime(config); + return defineDocumentType({ + type: EPIPHANY_OPERATOR_REQUEST_TYPE, + schemaName: EPIPHANY_OPERATOR_REQUEST_TYPE, + schemaId: EPIPHANY_OPERATOR_REQUEST_SCHEMA, + schemaVersion: EPIPHANY_OPERATOR_REQUEST_SCHEMA, + contentHash: EPIPHANY_OPERATOR_REQUEST_SCHEMA, + global: false, + name: "requestId", + schema: { parse(value: unknown) { return value; } }, + }); +} + +function openNode(config: EpiphanyOperatorRequestConfig, definition: unknown): Promise { + return loadRuntime(config).CultMesh.createNode(config.storePath, { documents: [definition] }); +} + +function loadRuntime(config: EpiphanyOperatorRequestConfig): any { + const root = config.cultlibRoot ?? resolve(config.bifrostRoot, "..", "CultLib"); + const meshPath = resolve(root, "packages", "cultmesh-ts", "package.json"); + const cachePath = resolve(root, "packages", "cultcache-ts", "package.json"); + return { + CultMesh: createRequire(meshPath)("cultmesh-ts").CultMesh, + defineDocumentType: createRequire(cachePath)("cultcache-ts").defineDocumentType, + }; +} + +function unwrap(value: unknown): unknown { + const record = value as { value?: unknown } | undefined; + return Array.isArray(value) ? value[0] : record?.value ?? value; +} + +function stable(value: unknown): string { + return JSON.stringify(value, (_key, current) => current instanceof Uint8Array ? Buffer.from(current).toString("hex") : current); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e04f800..9370cdf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,3 +28,4 @@ export * from "./persona-state-domain"; export * from "./persona-state-service"; export * from "./bifrost-discord-command"; export * from "./persona-feedback-observation"; +export * from "./epiphany-operator-request"; diff --git a/scripts/smoke-epiphany-operator-request.ts b/scripts/smoke-epiphany-operator-request.ts new file mode 100644 index 0000000..b5d84fa --- /dev/null +++ b/scripts/smoke-epiphany-operator-request.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { + EPIPHANY_OPERATOR_REQUEST_SCHEMA, + EPIPHANY_OPERATOR_REQUEST_TYPE, + buildEpiphanyOperatorRequest, + submitEpiphanyOperatorRequest, +} from "../packages/core/src/epiphany-operator-request"; +import { commandDefinitions, isExactDiscordOwner } from "../apps/bot/src/discord-bot-handlers"; + +async function main(): Promise { +const owner = "owner-1"; +assert.equal(isExactDiscordOwner(owner, owner), true); +assert.equal(isExactDiscordOwner("administrator-1", owner), false); +assert.equal(isExactDiscordOwner("tier-one-1", owner), false); + +const epiphany = commandDefinitions.map((command) => command.toJSON()).find((command) => command.name === "epiphany"); +assert.ok(epiphany, "slash command is registered"); +assert.deepEqual(epiphany.options?.map((option) => option.name), ["status", "sleep", "wake", "direct"]); + +const issuedAt = "2026-07-19T12:00:00.000Z"; +const commands = [ + { kind: "status" } as const, + { kind: "sleep", reason: "Operator is packing the workstation." } as const, + { kind: "wake" } as const, + { kind: "directive", objective: "Inspect the current domain map and propose the next bounded improvement." } as const, +]; +for (const [index, command] of commands.entries()) { + const id = `interaction-${index}`; + const request = buildEpiphanyOperatorRequest({ + interactionId: id, + actorDiscordId: owner, + guildId: "guild-1", + channelId: "channel-1", + command, + issuedAt, + }, "voidbot-yggdrasil"); + assert.equal(request.schemaName, EPIPHANY_OPERATOR_REQUEST_TYPE); + assert.equal(request.schemaVersion, EPIPHANY_OPERATOR_REQUEST_SCHEMA); + assert.equal(request.requestId, id); + assert.equal(request.commandId, id); + assert.equal(request.nonce, id); + assert.equal(request.sourceEventId, id); + assert.equal(request.discordMessageId, id); + assert.equal(request.targetRuntimeId, "epiphany-yggdrasil"); + assert.equal(request.authorityClass, "operator_request_only"); + assert.equal(Date.parse(request.expiresAt) - Date.parse(request.issuedAt), 60_000); + assert.deepEqual(Object.keys(request).sort(), [ + "authorityClass", "command", "commandId", "discordChannelId", "discordGuildId", + "discordMessageId", "expiresAt", "issuedAt", "nonce", "producerId", "producerRuntimeId", + "requestId", "schemaName", "schemaVersion", "sourceActorDiscordId", "sourceEventId", "status", + "targetRuntimeId", + ].sort()); +} + +const stored = new Map(); +const definition = {}; +const openNode = async () => ({ + get: (_definition: unknown, key: string) => stored.get(key), + put: async (_definition: unknown, key: string, value: unknown) => { stored.set(key, value); }, + flush: async () => undefined, + cache: { pullAllBackingStores: async () => undefined }, +}); +const input = { + interactionId: "interaction-replay", + actorDiscordId: owner, + guildId: "guild-1", + channelId: "channel-1", + command: { kind: "wake" } as const, + issuedAt, +}; +const config = { storePath: "unused.cc", bifrostRoot: "unused", producerRuntimeId: "voidbot-yggdrasil" }; +await submitEpiphanyOperatorRequest(input, config, { definition, openNode }); +await submitEpiphanyOperatorRequest(input, config, { definition, openNode }); +await assert.rejects( + submitEpiphanyOperatorRequest({ ...input, command: { kind: "status" } }, config, { definition, openNode }), + /different immutable content/, +); +assert.throws(() => buildEpiphanyOperatorRequest({ ...input, command: { kind: "sleep", reason: "x".repeat(501) } }, "voidbot-yggdrasil"), /500/); +assert.throws(() => buildEpiphanyOperatorRequest({ ...input, command: { kind: "directive", objective: "x".repeat(2001) } }, "voidbot-yggdrasil"), /2000/); + +const botSource = await readFile(resolve("apps/bot/src/discord-bot.ts"), "utf8"); +const messageCreate = botSource.slice(botSource.indexOf("client.on(Events.MessageCreate"), botSource.indexOf("client.on(Events.MessageUpdate")); +assert.ok(messageCreate.includes("exportPersonaFeedbackWithRetry"), "ordinary addressed messages retain feedback export"); +assert.ok(!messageCreate.includes("submitEpiphanyOperatorRequest"), "ordinary messages cannot submit operator requests"); + +console.log(JSON.stringify({ + schemaVersion: EPIPHANY_OPERATOR_REQUEST_SCHEMA, + commands: ["status", "sleep", "wake", "direct"], + exactOwnerOnly: true, + ordinaryMessagesRemainFeedbackOnly: true, + localExecutionAuthorized: false, + mindAuthorityGranted: false, + handsAuthorityGranted: false, + releaseAuthorityGranted: false, + deploymentAuthorityGranted: false, +}, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/state/map.yaml b/state/map.yaml index fe25177..63184c1 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -32,6 +32,7 @@ constraints: - Persistent project state must stay small enough to rehydrate from without becoming a second swamp. invariants: + - The `/epiphany` Discord edge is an exact-owner request producer only. Its closed status/sleep/wake/direct command family writes short-lived immutable documents to a dedicated Bifrost inbox; ordinary conversation remains feedback-only, and VoidBot gains no Epiphany state, Mind, Hands, release, deployment, argv, or local-execution authority. - Deployment follows one path: push the intended upstream commit, then let Idunn deploy VoidBot on Yggdrasil. Do not create, restore, or search for a local VoidBot deployment path. - The map is canonical and slow-changing. - Scratch is temporary and disposable.