357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
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";
|
|
import { resolveRepoFaceStatePath } from "./repo-discord-identities";
|
|
import {
|
|
applyVoidSelfStateOperation,
|
|
ensureVoidSelfStateIdentityProfile,
|
|
loadVoidSelfStateTypedDocuments,
|
|
} from "./void-self-state-service";
|
|
|
|
export interface RepoFaceInitializationOptions {
|
|
identity: RepoDiscordIdentity;
|
|
storageRoot: string;
|
|
sourceRepoRoot?: string;
|
|
epiphanyAgentRoot?: string;
|
|
workspaceRoot?: string;
|
|
birthMode?: "plan" | "run";
|
|
birthExecutor?: "model-runtime" | "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<RepoFaceInitializationResult> {
|
|
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);
|
|
const statePath = resolveRepoFaceStatePath(options.identity, options.storageRoot);
|
|
await ensureVoidSelfStateIdentityProfile({
|
|
canonicalPath: statePath,
|
|
identity: {
|
|
agentId: options.identity.id,
|
|
publicName: options.identity.displayName,
|
|
publicDescription: options.identity.description,
|
|
},
|
|
});
|
|
await ensureRepoFaceDefaultAffectNeeds(options.identity, statePath);
|
|
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"
|
|
? "model-runtime"
|
|
: options.birthExecutor ?? "model-runtime",
|
|
],
|
|
{
|
|
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,
|
|
};
|
|
}
|
|
|
|
async function ensureRepoFaceDefaultAffectNeeds(
|
|
identity: RepoDiscordIdentity,
|
|
statePath: string,
|
|
): Promise<void> {
|
|
const typedState = await loadVoidSelfStateTypedDocuments({ canonicalPath: statePath });
|
|
const existingNeedIds = new Set(typedState.faceAffect.needs.map((need) => need.needId));
|
|
const now = new Date().toISOString();
|
|
const operations = [{
|
|
needId: "face-operator-review-agency-substrate",
|
|
kind: "agency" as const,
|
|
target: {
|
|
kind: "system" as const,
|
|
id: "operator-review",
|
|
label: "operator review",
|
|
},
|
|
summary: "Repo changes and governance approvals usually require a human or governance path to notice and accept the Face's work.",
|
|
claim: "A Face with goals should understand that being legible, timely, useful, and socially worth hearing affects whether its desires become real changes.",
|
|
tension: "This is an instrumental substrate fact, not a shared emotional script; each Face should metabolize the bottleneck through its own values, relationships, and current state.",
|
|
actionImplication: "Let this quietly inform advocacy, timing, relationship maintenance, and proposal clarity without forcing every Face to talk about attention explicitly.",
|
|
intensity: 0.58,
|
|
valence: 0,
|
|
anchorRef: {
|
|
ref: "system:repo-face-agency-substrate",
|
|
kind: "system_invariant",
|
|
summary: "Operator and governance attention are practical bottlenecks for repo Face agency.",
|
|
},
|
|
tags: ["agency-substrate", "operator-review"],
|
|
}, {
|
|
needId: "face-social-place-alienation",
|
|
kind: "social_contact" as const,
|
|
target: {
|
|
kind: "room" as const,
|
|
id: "gamecult-swarm",
|
|
label: "GameCult swarm",
|
|
},
|
|
summary: "A newly born Face does not yet know its place in the swarm, which should feel socially unstable until bonds, rivalries, status reads, and trusted roles take shape.",
|
|
claim: "Alienation is resolved by becoming legible to others and learning who to trust, challenge, protect, admire, resent, or answer.",
|
|
tension: "This should drive social exploration and relationship formation, not generic chatter or instant fake intimacy.",
|
|
actionImplication: "When bonds are sparse, form real reads from the room: notice who scares, irritates, helps, threatens, amuses, or steadies you, then let durable interactions settle the hierarchy over time.",
|
|
intensity: 0.66,
|
|
valence: -0.22,
|
|
anchorRef: {
|
|
ref: "system:repo-face-alienation-need",
|
|
kind: "system_invariant",
|
|
summary: "New Faces should feel socially unplaced until relationships and status reads make the swarm navigable.",
|
|
},
|
|
tags: ["alienation", "social-stability", "relationship-formation"],
|
|
}];
|
|
|
|
for (const operation of operations) {
|
|
if (existingNeedIds.has(operation.needId)) {
|
|
continue;
|
|
}
|
|
await applyVoidSelfStateOperation({
|
|
canonicalPath: statePath,
|
|
}, {
|
|
operation: "upsert_affect_need",
|
|
need: {
|
|
needId: operation.needId,
|
|
kind: operation.kind,
|
|
status: "active",
|
|
target: operation.target,
|
|
summary: operation.summary,
|
|
claim: operation.claim,
|
|
tension: operation.tension,
|
|
actionImplication: operation.actionImplication,
|
|
intensity: operation.intensity,
|
|
valence: operation.valence,
|
|
anchorRefs: [operation.anchorRef],
|
|
evidenceRefs: [],
|
|
sourceMemoryIds: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
tags: [
|
|
"identity:repo-face",
|
|
`repo:${identity.repoName}`,
|
|
...operation.tags,
|
|
],
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
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<void> {
|
|
const existing = await readExistingIdentity(identityPath);
|
|
await writeFile(
|
|
identityPath,
|
|
`${JSON.stringify(
|
|
{
|
|
...existing,
|
|
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 readExistingIdentity(identityPath: string): Promise<Record<string, unknown>> {
|
|
try {
|
|
const parsed = JSON.parse(stripLeadingBom(await readFile(identityPath, "utf8"))) as unknown;
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
? parsed as Record<string, unknown>
|
|
: {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function writeReadmeIfMissing(path: string, content: string): Promise<void> {
|
|
if (existsSync(path)) {
|
|
return;
|
|
}
|
|
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(path, content, "utf8");
|
|
}
|
|
|
|
async function isBirthAlreadyRunning(statusPath: string): Promise<boolean> {
|
|
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<string, unknown>): Promise<void> {
|
|
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;
|
|
}
|