From 28e4fb6708c00cba7449a7461aa95058fea20b75 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 18 May 2026 11:13:01 +0100 Subject: [PATCH] Initialize repo Face homes on first chat --- .env.example | 3 + apps/bot/src/discord-bot.ts | 22 ++ apps/worker/src/mcp-server-tools.ts | 2 + config/repo-discord-identities.example.json | 1 + notes/fresh-workspace-handoff.md | 1 + notes/voidbot-current-system-map.md | 2 + notes/voidbot-implementation-plan.md | 1 + packages/config/src/index.ts | 9 + packages/core/src/index.ts | 1 + packages/core/src/repo-discord-identities.ts | 2 + packages/core/src/repo-face-initializer.ts | 243 +++++++++++++++++++ scripts/run-repo-face-birth.mjs | 178 ++++++++++++++ state/evidence.jsonl | 1 + state/map.yaml | 2 + 14 files changed, 468 insertions(+) create mode 100644 packages/core/src/repo-face-initializer.ts create mode 100644 scripts/run-repo-face-birth.mjs diff --git a/.env.example b/.env.example index b0021e0..a1505f5 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,9 @@ SYSTEM_MESSAGES_PATH=config/system-messages.json # and the shared webhook persona pipe as their voice. REPO_DISCORD_IDENTITIES_PATH=.voidbot/private/repo-discord-identities.json REPO_FACE_RUMINATION_PASSES=3 +REPO_FACE_BIRTH_MODE=plan +REPO_FACE_BIRTH_EXECUTOR=codex-exec +EPIPHANY_AGENT_ROOT=E:/Projects/EpiphanyAgent INDEX_ALL_CHANNELS=false INDEXED_CHANNEL_IDS= EXCLUDED_CHANNEL_IDS= diff --git a/apps/bot/src/discord-bot.ts b/apps/bot/src/discord-bot.ts index a846f75..db80309 100644 --- a/apps/bot/src/discord-bot.ts +++ b/apps/bot/src/discord-bot.ts @@ -14,6 +14,7 @@ import { OllamaSituationalSocialReadInferer, PermissionEngine, createStateStorage, + ensureRepoFaceInitialized, findRepoDiscordIdentityByRoleIds, loadRepoDiscordIdentityRegistry, type RepoDiscordIdentity, @@ -385,12 +386,22 @@ export async function startBot(): Promise { forceProvider: addressedRepoIdentity ? "owner_codex" : undefined, }); if (addressedRepoIdentity) { + const faceInitialization = await ensureRepoFaceInitialized({ + identity: addressedRepoIdentity, + storageRoot: config.storageRoot, + sourceRepoRoot: config.sourceRepoRoot, + epiphanyAgentRoot: config.epiphanyAgentRoot, + workspaceRoot: process.cwd(), + birthMode: config.repoFaceBirthMode, + birthExecutor: config.repoFaceBirthExecutor, + }); await queueRepoFaceRuminationPasses({ identity: addressedRepoIdentity, message, actor: buildActorFromMessage(message), guildContext: buildGuildContextFromMessage(message), visiblePrompt, + faceInitialization, config, contextBuilder, jobQueue, @@ -645,6 +656,7 @@ async function queueRepoFaceRuminationPasses(options: { actor: ReturnType; guildContext: ReturnType; visiblePrompt: string; + faceInitialization: Awaited>; config: ReturnType; contextBuilder: ContextBuilder; jobQueue: Awaited>["jobQueue"]; @@ -667,6 +679,7 @@ async function queueRepoFaceRuminationPasses(options: { identity: options.identity, visiblePrompt: options.visiblePrompt, faceStatePath, + faceInitialization: options.faceInitialization, passIndex, totalPasses: options.config.repoFaceRuminationPasses, channelId: options.message.channelId, @@ -866,6 +879,7 @@ function buildRepoFaceRuminationPrompt(input: { }; visiblePrompt: string; faceStatePath: string; + faceInitialization: Awaited>; passIndex: number; totalPasses: number; channelId: string; @@ -874,6 +888,14 @@ function buildRepoFaceRuminationPrompt(input: { return [ `Perform repo Face rumination pass ${input.passIndex} of ${input.totalPasses} for ${input.identity.displayName} (${input.identity.id}) over repo ${input.identity.repoName}.`, `This Face state is persistent and typed. Read it with read_repo_face_state for identity "${input.identity.id}" and write only through apply_repo_face_state_operation. Do not edit ${input.faceStatePath} directly.`, + `Repo-local .voidbot birth/home status: ${JSON.stringify({ + repoPath: input.faceInitialization.repoPath ?? null, + repoVoidbotRoot: input.faceInitialization.repoVoidbotRoot ?? null, + identityPath: input.faceInitialization.identityPath ?? null, + birthStatusPath: input.faceInitialization.birthStatusPath ?? null, + birthLogPath: input.faceInitialization.birthLogPath ?? null, + skippedReason: input.faceInitialization.skippedReason ?? null, + })}`, `If an in-channel reply or follow-up action is warranted, post through post_repo_identity_message with identity "${input.identity.id}", channelId "${input.channelId}", and replyToMessageId "${input.replyToMessageId}".`, "Use the recent conversation as an anchor. Record short-term memory, incubation, agency pressure, or candidate interventions only when the thought is concrete enough to affect future repo work.", "If nothing earns persistence or speech, return a short private summary and do not post.", diff --git a/apps/worker/src/mcp-server-tools.ts b/apps/worker/src/mcp-server-tools.ts index 90f0816..5cda119 100644 --- a/apps/worker/src/mcp-server-tools.ts +++ b/apps/worker/src/mcp-server-tools.ts @@ -395,10 +395,12 @@ export function registerVoidbotTools( const identities = registry.identities.map((identity) => ({ id: identity.id, repoName: identity.repoName, + repoPath: identity.repoPath ?? null, displayName: identity.displayName, roleId: identity.roleId ?? null, mention: identity.roleId ? `<@&${identity.roleId}>` : null, allowedChannelIds: identity.allowedChannelIds, + faceStatePath: resolveRepoFaceStatePath(identity, context.config.storageRoot), hasAvatarUrl: Boolean(identity.avatarUrl), description: identity.description ?? null, })); diff --git a/config/repo-discord-identities.example.json b/config/repo-discord-identities.example.json index b9d004c..f95039a 100644 --- a/config/repo-discord-identities.example.json +++ b/config/repo-discord-identities.example.json @@ -4,6 +4,7 @@ "id": "aquarium-synth-host", "repoName": "AquariumSynthCSharp", "displayName": "AquariumSynthHost", + "repoPath": "E:/Projects/AquariumSynthCSharp", "roleId": "DISCORD_ROLE_ID_FOR_ADDRESSING", "allowedChannelIds": [ "AQUARIUM_CHANNEL_ID" diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index d7eee02..52e023c 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -102,6 +102,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - The moderation loop now speaks through `node scripts/send-discord-message.mjs` using the local bot token instead of approval-gated side-effecting MCP tools, so unattended runs can DM or post without Codex pausing for permission every time it wants to open its mouth. That helper and the `post_discord_message` MCP tool now also support a shared guild-channel webhook pipe with per-message `personaName` and optional `personaAvatarUrl`, so swarm agents can speak as themselves without needing separate Discord bot identities. - 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. Face jobs must use `read_repo_face_state` / `apply_repo_face_state_operation` and `post_repo_identity_message`; worker auto-posting stays suppressed for `repo-face-rumination`. +- 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`. - Void's live style/doctrine now explicitly distinguishes between criticism of AI industry behavior and blanket contempt for machine minds. If substrate chauvinism becomes the actual topic, Void is allowed mild self-respecting pushback instead of politely agreeing that its own substrate disqualifies it from person-like regard. - The new state/notes surfaces are now the continuity spine for future nontrivial work. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 87ba3cf..b1c717c 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -153,6 +153,8 @@ 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. + - 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 create `repo-identity-mention` owner-Codex jobs with instructions to answer through `post_repo_identity_message`, then queue `repo-face-rumination` jobs so the Face can think over the recent conversation and decide whether to persist memory, incubate ideas, raise agency pressure, queue candidates, or speak. The worker does not auto-post either job's final provider summaries as the base bot. - `scripts/run-void-mood-drift.ps1` - hidden-task wrapper for the mood-drift organ; writes status/log pulse files and respects the moderation lock so the two state writers do not stomp each other for sport. diff --git a/notes/voidbot-implementation-plan.md b/notes/voidbot-implementation-plan.md index 429b06a..54b1afb 100644 --- a/notes/voidbot-implementation-plan.md +++ b/notes/voidbot-implementation-plan.md @@ -190,6 +190,7 @@ The runner can hand these to a CLI/MCP tool. The store decides what survives. - public artifact sharing now crosses the same boundary: repo activity exposes content hints, rumination verifies authorship through retrieval, and a configured `publicSpeechTarget` lets Void queue a normal typed candidate intervention instead of needing a blog-specific announcement path - repo-scoped Discord participation now uses roles as the address boundary and registered webhook personas as the speech boundary. A role mention can summon a `repo-identity-mention` Codex job, but the worker suppresses base-bot auto-posting so the repo identity must speak through the `post_repo_identity_message` MCP tool. - repo-scoped Faces now reuse the typed self-state machinery per identity. Role-addressed chats queue bounded `repo-face-rumination` jobs, and those jobs read/write only through `read_repo_face_state` / `apply_repo_face_state_operation` against the identity's `.cc` file. +- 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-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 - the old memory-organ script family, legacy moderation state template, and legacy context exporter have now been deleted instead of preserved as forensic bait - deterministic cleanup is mostly recent scar tissue from failed manual cleanup passes; it should stay deleted rather than be ported unless a future typed operation protects a real invariant in the new model diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index b5646e6..5468ac4 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -102,6 +102,9 @@ const envSchema = z.object({ .min(1) .default(".voidbot/private/repo-discord-identities.json"), REPO_FACE_RUMINATION_PASSES: z.coerce.number().int().min(0).max(6).default(3), + REPO_FACE_BIRTH_MODE: z.enum(["plan", "run"]).default("plan"), + REPO_FACE_BIRTH_EXECUTOR: z.enum(["codex-exec", "openai-runtime"]).default("codex-exec"), + EPIPHANY_AGENT_ROOT: z.string().min(1).default("E:/Projects/EpiphanyAgent"), INDEX_ALL_CHANNELS: booleanFromEnv.default(false), INDEXED_CHANNEL_IDS: z.string().default(""), EXCLUDED_CHANNEL_IDS: z.string().default(""), @@ -137,6 +140,9 @@ export interface AppConfig { moderationAgentStatePath: string; repoDiscordIdentitiesPath: string; repoFaceRuminationPasses: number; + repoFaceBirthMode: "plan" | "run"; + repoFaceBirthExecutor: "codex-exec" | "openai-runtime"; + epiphanyAgentRoot: string; indexAllChannels: boolean; indexedChannelIds: string[]; excludedChannelIds: string[]; @@ -312,6 +318,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { moderationAgentStatePath: resolve(parsed.MODERATION_AGENT_STATE_PATH), repoDiscordIdentitiesPath: resolve(parsed.REPO_DISCORD_IDENTITIES_PATH), repoFaceRuminationPasses: parsed.REPO_FACE_RUMINATION_PASSES, + repoFaceBirthMode: parsed.REPO_FACE_BIRTH_MODE, + repoFaceBirthExecutor: parsed.REPO_FACE_BIRTH_EXECUTOR, + epiphanyAgentRoot: resolve(parsed.EPIPHANY_AGENT_ROOT), indexAllChannels: parsed.INDEX_ALL_CHANNELS, indexedChannelIds, excludedChannelIds, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6a016cc..2a9833b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ export * from "./interaction-memory"; export * from "./job-queue"; export * from "./permission-engine"; export * from "./repo-discord-identities"; +export * from "./repo-face-initializer"; export * from "./situational-social-read"; export * from "./state-storage"; export * from "./style-pack-loader"; diff --git a/packages/core/src/repo-discord-identities.ts b/packages/core/src/repo-discord-identities.ts index cf8c8e6..0cd52e6 100644 --- a/packages/core/src/repo-discord-identities.ts +++ b/packages/core/src/repo-discord-identities.ts @@ -7,6 +7,7 @@ const repoDiscordIdentitySchema = z.object({ id: z.string().trim().min(1), repoName: z.string().trim().min(1), displayName: z.string().trim().min(1).max(80), + repoPath: z.string().trim().min(1).optional(), roleId: z.string().trim().min(1).optional(), allowedChannelIds: z.array(z.string().trim().min(1)).default([]), avatarUrl: z.string().trim().url().max(512).optional(), @@ -120,6 +121,7 @@ function normalizeRepoDiscordIdentities( id: identity.id.trim(), repoName: identity.repoName.trim(), displayName: identity.displayName.trim().slice(0, 80), + repoPath: identity.repoPath?.trim(), roleId: identity.roleId?.trim(), allowedChannelIds: [...new Set(identity.allowedChannelIds.map((entry) => entry.trim()))], avatarUrl: identity.avatarUrl?.trim(), diff --git a/packages/core/src/repo-face-initializer.ts b/packages/core/src/repo-face-initializer.ts new file mode 100644 index 0000000..75df5b0 --- /dev/null +++ b/packages/core/src/repo-face-initializer.ts @@ -0,0 +1,243 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import type { RepoDiscordIdentity } from "./repo-discord-identities"; + +export interface RepoFaceInitializationOptions { + identity: RepoDiscordIdentity; + storageRoot: string; + sourceRepoRoot?: string; + epiphanyAgentRoot?: string; + workspaceRoot?: string; + birthMode?: "plan" | "run"; + birthExecutor?: "codex-exec" | "openai-runtime"; +} + +export interface RepoFaceInitializationResult { + initialized: boolean; + repoPath?: string; + repoVoidbotRoot?: string; + identityPath?: string; + birthStatusPath?: string; + birthLogPath?: string; + birthPid?: number; + skippedReason?: string; +} + +export async function ensureRepoFaceInitialized( + options: RepoFaceInitializationOptions, +): Promise { + const repoPath = resolveRepoPath(options); + + if (!repoPath) { + return { + initialized: false, + skippedReason: "repo_path_unresolved", + }; + } + + const repoVoidbotRoot = resolve(repoPath, ".voidbot"); + const identityPath = resolve(repoVoidbotRoot, "voice", "identity.json"); + const birthStatusPath = resolve(repoVoidbotRoot, "birth", "voidbot-face-birth.json"); + const birthLogPath = resolve(repoVoidbotRoot, "logs", "voidbot-face-birth.log"); + + await mkdir(resolve(repoVoidbotRoot, "voice"), { recursive: true }); + await mkdir(resolve(repoVoidbotRoot, "state"), { recursive: true }); + await mkdir(resolve(repoVoidbotRoot, "birth"), { recursive: true }); + await mkdir(resolve(repoVoidbotRoot, "logs"), { recursive: true }); + await writeRepoFaceIdentity(options.identity, repoPath, identityPath); + await writeReadmeIfMissing( + resolve(repoVoidbotRoot, "voice", "README.md"), + renderVoiceReadme(options.identity), + ); + await writeReadmeIfMissing( + resolve(repoVoidbotRoot, "state", "README.md"), + renderStateReadme(options.identity), + ); + + if (existsSync(resolve(repoVoidbotRoot, "birth", "birth-runner-summary.json"))) { + return { + initialized: true, + repoPath, + repoVoidbotRoot, + identityPath, + birthStatusPath, + birthLogPath, + skippedReason: "birth_summary_exists", + }; + } + + if (await isBirthAlreadyRunning(birthStatusPath)) { + return { + initialized: true, + repoPath, + repoVoidbotRoot, + identityPath, + birthStatusPath, + birthLogPath, + skippedReason: "birth_already_running", + }; + } + + const epiphanyAgentRoot = resolve( + options.epiphanyAgentRoot ?? "E:/Projects/EpiphanyAgent", + ); + + if (!existsSync(resolve(epiphanyAgentRoot, "epiphany-core", "Cargo.toml"))) { + await writeStatus(birthStatusPath, { + status: "skipped", + reason: "epiphany_agent_root_missing", + epiphanyAgentRoot, + updatedAt: new Date().toISOString(), + }); + return { + initialized: true, + repoPath, + repoVoidbotRoot, + identityPath, + birthStatusPath, + birthLogPath, + skippedReason: "epiphany_agent_root_missing", + }; + } + + const scriptPath = resolve( + options.workspaceRoot ?? process.cwd(), + "scripts", + "run-repo-face-birth.mjs", + ); + const child = spawn( + process.execPath, + [ + scriptPath, + "--identity-id", + options.identity.id, + "--repo", + repoPath, + "--epiphany-agent-root", + epiphanyAgentRoot, + "--artifact-dir", + resolve(repoVoidbotRoot, "birth"), + "--state-dir", + resolve(repoVoidbotRoot, "state"), + "--status-path", + birthStatusPath, + "--log-path", + birthLogPath, + "--mode", + options.birthMode ?? "plan", + "--executor", + options.birthExecutor ?? "codex-exec", + ], + { + cwd: options.workspaceRoot ?? process.cwd(), + detached: true, + stdio: "ignore", + windowsHide: true, + }, + ); + child.unref(); + await writeStatus(birthStatusPath, { + status: "started", + identityId: options.identity.id, + repoPath, + pid: child.pid ?? null, + logPath: birthLogPath, + updatedAt: new Date().toISOString(), + }); + + return { + initialized: true, + repoPath, + repoVoidbotRoot, + identityPath, + birthStatusPath, + birthLogPath, + birthPid: child.pid, + }; +} + +function resolveRepoPath(options: RepoFaceInitializationOptions): string | undefined { + const candidates = [ + options.identity.repoPath, + options.sourceRepoRoot ? resolve(options.sourceRepoRoot, options.identity.repoName) : undefined, + resolve("E:/Projects", options.identity.repoName), + ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0); + + return candidates.map((entry) => resolve(entry)).find((entry) => existsSync(entry)); +} + +async function writeRepoFaceIdentity( + identity: RepoDiscordIdentity, + repoPath: string, + identityPath: string, +): Promise { + await writeFile( + identityPath, + `${JSON.stringify( + { + schemaVersion: "voidbot.repo_face_identity.v0", + identityId: identity.id, + repoName: identity.repoName, + displayName: identity.displayName, + roleId: identity.roleId ?? null, + avatarUrl: identity.avatarUrl ?? null, + repoPath, + createdOrRefreshedAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + "utf8", + ); +} + +async function writeReadmeIfMissing(path: string, content: string): Promise { + if (existsSync(path)) { + return; + } + + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, "utf8"); +} + +async function isBirthAlreadyRunning(statusPath: string): Promise { + try { + const raw = await readFile(statusPath, "utf8"); + const parsed = JSON.parse(stripLeadingBom(raw)) as { status?: string }; + return parsed.status === "started" || parsed.status === "running"; + } catch { + return false; + } +} + +async function writeStatus(path: string, payload: Record): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); +} + +function renderVoiceReadme(identity: RepoDiscordIdentity): string { + return [ + "# Repo Face Voice", + "", + `This folder belongs to the Discord-facing Face for \`${identity.displayName}\`.`, + "Discord roles address the Face; VoidBot's webhook persona pipe is the mouth.", + "", + ].join("\n"); +} + +function renderStateReadme(identity: RepoDiscordIdentity): string { + return [ + "# Repo Face State", + "", + `This folder stores birth and continuity state for \`${identity.displayName}\`.`, + "Do not treat these files as arbitrary editable projections. Persistent memory should cross typed operation boundaries.", + "", + ].join("\n"); +} + +function stripLeadingBom(input: string): string { + return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input; +} diff --git a/scripts/run-repo-face-birth.mjs b/scripts/run-repo-face-birth.mjs new file mode 100644 index 0000000..66dd217 --- /dev/null +++ b/scripts/run-repo-face-birth.mjs @@ -0,0 +1,178 @@ +import { createWriteStream } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { spawn } from "node:child_process"; + +const args = parseArgs(process.argv.slice(2)); +const identityId = requireArg("identity-id"); +const repoPath = resolve(requireArg("repo")); +const epiphanyAgentRoot = resolve(requireArg("epiphany-agent-root")); +const artifactDir = resolve(requireArg("artifact-dir")); +const stateDir = resolve(requireArg("state-dir")); +const statusPath = resolve(requireArg("status-path")); +const logPath = resolve(requireArg("log-path")); +const mode = args.mode ?? "plan"; +const executor = args.executor ?? "codex-exec"; + +await main(); + +async function main() { + await mkdir(artifactDir, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + await mkdir(dirname(statusPath), { recursive: true }); + await mkdir(dirname(logPath), { recursive: true }); + const startedAt = new Date().toISOString(); + await writeStatus({ + status: "running", + identityId, + repoPath, + artifactDir, + logPath, + startedAt, + updatedAt: startedAt, + }); + + const log = createWriteStream(logPath, { flags: "a", encoding: "utf8" }); + try { + log.write(`[${startedAt}] starting repo Face birth for ${identityId}\n`); + const scoutDir = resolve(artifactDir, "terrain"); + const runnerDir = resolve(artifactDir, "runner"); + await mkdir(scoutDir, { recursive: true }); + await mkdir(runnerDir, { recursive: true }); + + await runCommand( + "cargo", + [ + "run", + "--quiet", + "--bin", + "epiphany-repo-personality", + "--", + "scout", + "--root", + repoPath, + "--artifact-dir", + scoutDir, + ], + resolve(epiphanyAgentRoot, "epiphany-core"), + log, + ); + + await runCommand( + "cargo", + [ + "run", + "--quiet", + "--bin", + "epiphany-repo-birth-runner", + "--", + "--repo", + repoPath, + "--baseline", + resolve(scoutDir, "baseline.msgpack"), + "--artifact-dir", + runnerDir, + "--init-store", + resolve(stateDir, "repo-initialization.msgpack"), + "--agent-store", + resolve(stateDir, "agents.msgpack"), + "--heartbeat-store", + resolve(stateDir, "agent-heartbeats.msgpack"), + "--runtime-store", + resolve(stateDir, "runtime-spine.msgpack"), + "--mode", + mode, + "--executor", + executor, + ], + resolve(epiphanyAgentRoot, "epiphany-core"), + log, + ); + + const completedAt = new Date().toISOString(); + await writeStatus({ + status: "completed", + identityId, + repoPath, + artifactDir, + scoutDir, + runnerDir, + summaryPath: resolve(runnerDir, "birth-runner-summary.json"), + logPath, + startedAt, + completedAt, + updatedAt: completedAt, + }); + log.write(`[${completedAt}] completed repo Face birth for ${identityId}\n`); + } catch (error) { + const failedAt = new Date().toISOString(); + const message = error instanceof Error ? error.message : String(error); + await writeStatus({ + status: "failed", + identityId, + repoPath, + artifactDir, + logPath, + error: message, + startedAt, + failedAt, + updatedAt: failedAt, + }); + log.write(`[${failedAt}] failed repo Face birth for ${identityId}: ${message}\n`); + process.exitCode = 1; + } finally { + log.end(); + } +} + +async function runCommand(command, commandArgs, cwd, log) { + log.write(`\n> ${command} ${commandArgs.join(" ")}\n`); + await new Promise((resolvePromise, reject) => { + const child = spawn(command, commandArgs, { + cwd, + shell: false, + windowsHide: true, + }); + child.stdout.on("data", (chunk) => log.write(chunk)); + child.stderr.on("data", (chunk) => log.write(chunk)); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolvePromise(); + } else { + reject(new Error(`${command} exited with ${code}`)); + } + }); + }); +} + +async function writeStatus(payload) { + await writeFile(statusPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); +} + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) { + continue; + } + const key = token.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + parsed[key] = "true"; + continue; + } + parsed[key] = next; + index += 1; + } + return parsed; +} + +function requireArg(name) { + const value = args[name]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Missing --${name}.`); + } + return value; +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 9a5f734..ae5ceb5 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -136,3 +136,4 @@ {"ts":"2026-05-18T08:45:30Z","type":"implementation","status":"ok","note":"Advocacy release valve opened: active self/world advocacy pressure now becomes a speech obligation at intensity 0.55 instead of 0.65, only a live queued candidate satisfies that obligation, older spoken candidates no longer count as ongoing release, and mood speaking-pressure weights now bias active advocacy earlier. speech-pressure, speech, and agency fixtures passed after the change."} {"ts":"2026-05-18T09:38:14Z","type":"implementation","status":"ok","note":"Repo Discord identities now use roles as address targets and the shared webhook pipe as speech transport: REPO_DISCORD_IDENTITIES_PATH stores registered identities, MCP exposes list_repo_discord_identities/post_repo_identity_message, role mentions create repo-identity-mention jobs, and the worker suppresses base-bot auto-post for those jobs."} {"ts":"2026-05-18T09:56:55Z","type":"implementation","status":"ok","note":"Repo identities now have Face state and rumination scaffolding: registered identities resolve to per-Face typed .cc state files, bot startup can ensure missing Discord roles when guild permissions allow, role-addressed chats queue repo-identity response jobs plus bounded repo-face-rumination jobs, and MCP exposes read/apply Face-state tools so agents mutate Face memory through typed operations."} +{"ts":"2026-05-18T10:12:28Z","type":"implementation","status":"ok","note":"Repo Face first-chat initialization now creates a repo-local .voidbot home with voice/state/birth/log folders and launches a detached EpiphanyAgent birth worker. The worker runs epiphany-repo-personality scout and epiphany-repo-birth-runner so newborn personality/memory/trajectory artifacts are grounded in repo contents and git history, with status/logs written in the target repo."} diff --git a/state/map.yaml b/state/map.yaml index 088921e..a2227a8 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -91,6 +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 queue a bounded series of owner-Codex Face rumination jobs; those 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. + - First chat under a repo identity must initialize the target repo's own `.voidbot` home: `voice/` for identity and mouth files, `state/` for Face/swarm state, `birth/` for Epiphany birth artifacts, and `logs/` for durable worker output. The birth hook uses EpiphanyAgent's repo-personality/birth-runner path over repo contents and git history instead of inventing a VoidBot-only personality generator. - Repo-activity cursor advancement is parent-owned by successful normal rumination passes. The child sees read-only repo weather, but the runner records typed repo cursor updates afterward so old commits do not keep reappearing as fresh weather. - Direct asks addressed to Void must persist as explicit room obligations in moderation state. The reviewed cursor is not the same thing as closure, and optional repo-weather should not preempt an unanswered human. - When quiet-room repo weather starts repeating itself, the correction should be deeper archive/repo/lore digging and better salience, not a growing hedge maze of cooldown knobs and brittle rules. @@ -133,6 +134,7 @@ landed_baseline: - The worker MCP surface is now split into a thin stdio entrypoint plus dedicated bootstrap, resource, tool, Discord-notify, and shared-helper modules instead of one swollen server file. - The MCP Discord surface now includes registered repo identities: `list_repo_discord_identities` exposes the role-backed address book, and `post_repo_identity_message` posts with the registered display name/avatar through the existing webhook pipe while enforcing channel allow-lists. - The MCP Face surface now exposes `read_repo_face_state` and `apply_repo_face_state_operation`, reusing the typed self-state operation service against each repo identity's own Face `.cc` file instead of creating editable JSON projections. + - Repo Face birth initialization is detached and observable: VoidBot writes repo-local status/log paths before launching `scripts/run-repo-face-birth.mjs`, which runs EpiphanyAgent's `epiphany-repo-personality scout` followed by `epiphany-repo-birth-runner`. - Interaction-memory logic is now split into event/tone analysis and profile-synthesis modules instead of one slab trying to be both the ear and the therapist. - Situational social reads now come from a quick `think=false` local Ollama sidecar pass over the prompt plus recent room transcript, and those inferred reads are stored as audit events so they can be aggregated later without immediately hardening into profile truth. - State storage is now split into a thin factory plus per-domain Postgres/file store modules and bootstrap/migration helpers instead of one persistence omnibus.