VoidBot/scripts/export-persona-friendly-input.ts
2026-06-11 22:22:10 +01:00

619 lines
20 KiB
TypeScript

import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import {
loadVoidSelfStateTypedDocuments,
type VoidSelfStateTypedProjection,
} from "@voidbot/core";
interface CliOptions {
identityPath?: string;
identityId?: string;
displayName?: string;
repoName?: string;
avatarUri?: string;
repoPath?: string;
statePath: string;
outPath: string;
}
interface RepoFaceIdentity {
identityId?: string;
displayName?: string;
repoName?: string;
avatarUrl?: string;
description?: string;
repoPath?: string;
createdOrRefreshedAt?: string;
}
type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue | undefined };
const DEFAULT_IDENTITY_PATH = "E:/Projects/Sai/.voidbot/voice/identity.json";
const DEFAULT_STATE_PATH = "E:/Projects/Sai/.voidbot/state/sai.cc";
const DEFAULT_OUT_PATH = "docs/persona-intake/sai.persona-intake.yaml";
async function main(args: string[]): Promise<void> {
const options = parseArgs(args);
const identity = await resolveIdentity(options);
const identityDefaults = identity.displayName
? {
agentId: identity.identityId ?? identity.displayName,
publicName: identity.displayName,
publicDescription: identity.description,
}
: undefined;
const typedState = await loadVoidSelfStateTypedDocuments({
canonicalPath: options.statePath,
identity: identityDefaults,
});
const projection = buildFriendlyPersonaInput({
identity,
identityPath: options.identityPath,
statePath: options.statePath,
typedState,
});
const yaml = [
"# Generated by scripts/export-persona-friendly-input.ts.",
"# This is a human-friendly projection of typed Persona state, not the canonical state owner.",
toYaml(projection),
].join("\n");
const outPath = resolve(options.outPath);
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, `${yaml.trimEnd()}\n`, "utf8");
console.log(`Wrote ${outPath}`);
}
function buildFriendlyPersonaInput(input: {
identity: RepoFaceIdentity;
identityPath?: string;
statePath: string;
typedState: VoidSelfStateTypedProjection;
}): JsonValue {
const { identity, typedState } = input;
const profile = typedState.selfProfile;
const updatedAt = latestTimestamp([
profile.updatedAt,
typedState.thoughtMemory.updatedAt,
typedState.faceAffect.updatedAt,
typedState.agencyPressure.updatedAt,
typedState.candidateInterventions.updatedAt,
typedState.scheduledRuntime.updatedAt,
typedState.speechReceipts.updatedAt,
typedState.moderationCursor.updatedAt,
]);
return stripUndefined({
schemaVersion: "gamecult.persona_intake.v0",
source: {
authority: "projection",
sourceSystem: identity.repoPath ? "voidbot.repo_face_state" : "voidbot.native_persona_state",
sourceStatePath: resolve(input.statePath),
sourceIdentityPath: input.identityPath ? resolve(input.identityPath) : undefined,
projectedAt: new Date().toISOString(),
sourceUpdatedAt: updatedAt,
},
persona: {
id: profile.agentId,
publicName: profile.publicName,
publicDescription: profile.publicDescription ?? "",
pronouns: "",
avatarUri: identity.avatarUrl ?? "",
jurisdiction: identity.repoName
? identity.repoPath
? `Repo Face for ${identity.repoName}.`
: `Native VoidBot Persona for ${identity.repoName}.`
: identity.repoPath
? "Repo Face."
: "Native VoidBot Persona.",
sourceRepo: identity.repoPath
? {
kind: "repo",
id: identity.repoName ?? profile.agentId,
label: identity.repoName ?? profile.publicName,
path: identity.repoPath,
}
: undefined,
},
presentation: {
voiceSummary: inferVoiceSummary(profile.publicDescription),
defaultRenderer: "chat",
homeContext: {
kind: identity.repoPath ? "repo" : "self",
id: identity.repoName ?? profile.agentId,
label: identity.repoName ?? profile.publicName,
},
publicHandles: [],
},
personality: mapActivationProfile(profile.activationProfile),
coreValues: profile.values.map((value) => ({
id: value.id,
label: value.label,
priority: scaleWord(value.priority),
rawPriority: round(value.priority),
summary: value.summary ?? "",
behavioralImplication: "",
refusalLine: "",
})),
privateNotes: profile.privateNotes,
memories: {
shortTerm: typedState.thoughtMemory.shortTerm.map(mapMemory),
longTerm: typedState.thoughtMemory.memories.map(mapMemory),
incubation: typedState.thoughtMemory.incubation.map((thread) => stripUndefined({
id: thread.threadId,
status: thread.status,
topic: thread.topic,
summary: thread.summary,
target: thread.target,
maturation: scaleWord(thread.maturation),
rawMaturation: round(thread.maturation),
noveltyToRoom: maybeScale(thread.noveltyToRoom),
noveltyToSelf: maybeScale(thread.noveltyToSelf),
desireToSpeak: maybeScale(thread.desireToSpeak),
saturation: maybeScale(thread.saturationScore),
supportMemoryIds: thread.supportMemoryIds,
anchorRefs: thread.anchorRefs,
evidenceRefs: thread.evidenceRefs,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
})),
},
affect: {
needs: typedState.faceAffect.needs.map((need) => stripUndefined({
id: need.needId,
kind: need.kind,
status: need.status,
target: need.target,
summary: need.summary,
claim: need.claim,
question: need.question,
tension: need.tension,
actionImplication: need.actionImplication,
intensity: scaleWord(need.intensity),
rawIntensity: round(need.intensity),
valence: valenceWord(need.valence),
rawValence: round(need.valence),
anchorRefs: need.anchorRefs,
evidenceRefs: need.evidenceRefs,
sourceMemoryIds: need.sourceMemoryIds,
lastSatisfiedAt: need.lastSatisfiedAt,
createdAt: need.createdAt,
updatedAt: need.updatedAt,
retiredAt: need.retiredAt,
tags: need.tags,
})),
bonds: typedState.faceAffect.socialBonds.map((bond) => stripUndefined({
id: bond.bondId,
target: bond.target,
relationship: bond.stance,
status: bond.status,
summary: bond.summary,
claim: bond.claim,
tension: bond.tension,
actionImplication: bond.actionImplication,
trust: bond.stance === "trust" ? scaleWord(bond.intensity) : undefined,
closeness: ["fondness", "attachment", "protectiveness"].includes(bond.stance)
? scaleWord(bond.intensity)
: undefined,
intensity: scaleWord(bond.intensity),
rawIntensity: round(bond.intensity),
anchorRefs: bond.anchorRefs,
evidenceRefs: bond.evidenceRefs,
createdAt: bond.createdAt,
updatedAt: bond.updatedAt,
retiredAt: bond.retiredAt,
tags: bond.tags,
})),
statusReads: typedState.faceAffect.statusReads.map((read) => stripUndefined({
id: read.readId,
status: read.status,
target: read.target,
summary: read.summary,
claim: read.claim,
tension: read.tension,
actionImplication: read.actionImplication,
intensity: scaleWord(read.intensity),
rawIntensity: round(read.intensity),
anchorRefs: read.anchorRefs,
evidenceRefs: read.evidenceRefs,
createdAt: read.createdAt,
updatedAt: read.updatedAt,
retiredAt: read.retiredAt,
tags: read.tags,
})),
mood: Object.fromEntries(
typedState.faceAffect.moodDimensions.map((dimension) => [
dimension.name,
stripUndefined({
value: scaleWord(dimension.value),
rawValue: round(dimension.value),
source: dimension.source,
updatedAt: dimension.updatedAt,
}),
]),
),
socialBiases: typedState.faceAffect.socialBiases.map((bias) => ({
name: bias.name,
value: scaleWord(bias.value),
rawValue: round(bias.value),
summary: bias.summary,
behavioralPull: bias.behavioralPull,
updatedAt: bias.updatedAt,
})),
doctrineStances: typedState.faceAffect.doctrineStances.map((stance) => stripUndefined({
id: stance.stanceId,
principle: stance.doctrine,
stance: stance.status,
target: stance.target,
summary: stance.summary,
claim: stance.claim,
question: stance.question,
tension: stance.tension,
actionImplication: stance.actionImplication,
intensity: scaleWord(stance.intensity),
rawIntensity: round(stance.intensity),
valence: valenceWord(stance.valence),
rawValence: round(stance.valence),
anchorRefs: stance.anchorRefs,
evidenceRefs: stance.evidenceRefs,
sourceMemoryIds: stance.sourceMemoryIds,
createdAt: stance.createdAt,
updatedAt: stance.updatedAt,
retiredAt: stance.retiredAt,
tags: stance.tags,
})),
},
agencyPressure: typedState.agencyPressure.pressures.map((pressure) => stripUndefined({
id: pressure.pressureId,
kind: pressure.kind,
status: pressure.status,
target: pressure.target,
summary: pressure.summary,
claim: pressure.claim,
question: pressure.question,
tension: pressure.tension,
actionImplication: pressure.actionImplication,
intensity: scaleWord(pressure.intensity),
rawIntensity: round(pressure.intensity),
anchorRefs: pressure.anchorRefs,
evidenceRefs: pressure.evidenceRefs,
sourceMemoryIds: pressure.sourceMemoryIds,
createdAt: pressure.createdAt,
updatedAt: pressure.updatedAt,
resolvedAt: pressure.resolvedAt,
retiredAt: pressure.retiredAt,
tags: pressure.tags,
})),
candidateActions: typedState.candidateInterventions.interventions.map((intervention) => stripUndefined({
id: intervention.interventionId,
kind: intervention.kind,
status: intervention.status,
target: intervention.target,
summary: intervention.summary,
draft: intervention.draft,
deliveryTarget: intervention.deliveryTarget,
priority: scaleWord(intervention.priority),
rawPriority: round(intervention.priority),
mustEventuallyShare: intervention.mustEventuallyShare,
createdAt: intervention.createdAt,
updatedAt: intervention.updatedAt,
spokenAt: intervention.spokenAt,
retiredAt: intervention.retiredAt,
tags: intervention.tags,
})),
operationalState: {
moderation: {
lastReviewedMessageId: typedState.moderationCursor.lastReviewedMessageId,
lastReviewedTimestamp: typedState.moderationCursor.lastReviewedTimestamp,
openCases: typedState.moderationCursor.openCases,
repoActivityCursor: typedState.moderationCursor.repoActivityCursor,
updatedAt: typedState.moderationCursor.updatedAt,
},
runtime: {
sleepCycle: typedState.scheduledRuntime.sleepCycle,
speakingPressure: mapSpeakingPressure(typedState.scheduledRuntime.speakingPressure),
lastRuns: typedState.scheduledRuntime.lastRuns,
updatedAt: typedState.scheduledRuntime.updatedAt,
},
speechReceipts: typedState.speechReceipts.recentReceipts.map((receipt) => receipt),
},
});
}
function mapActivationProfile(
profile: VoidSelfStateTypedProjection["selfProfile"]["activationProfile"],
): JsonValue {
return {
underlyingOrganization: mapActivationCategory(profile.underlyingOrganization),
stableDispositions: mapActivationCategory(profile.stableDispositions),
behavioralDimensions: mapActivationCategory(profile.behavioralDimensions),
presentationStrategy: mapActivationCategory(profile.presentationStrategy),
voiceStyle: mapActivationCategory(profile.voiceStyle),
situationalState: mapActivationCategory(profile.situationalState),
};
}
function mapActivationCategory(
category: Record<string, { mean: number; plasticity: number; current_activation: number }>,
): JsonValue {
return Object.fromEntries(
Object.entries(category).map(([key, value]) => [
key,
{
level: scaleWord(value.mean),
activation: scaleWord(value.current_activation),
plasticity: scaleWord(value.plasticity),
raw: {
mean: round(value.mean),
currentActivation: round(value.current_activation),
plasticity: round(value.plasticity),
},
},
]),
);
}
function mapMemory(memory: VoidSelfStateTypedProjection["thoughtMemory"]["memories"][number]): JsonValue {
return stripUndefined({
id: memory.memoryId,
kind: memory.kind,
status: memory.retiredAt ? "retired" : "active",
summary: memory.summary,
claim: memory.claim,
question: memory.question,
tension: memory.tension,
actionImplication: memory.actionImplication,
importance: inferImportance(memory.tags),
target: memory.target,
anchorRefs: memory.anchorRefs,
evidenceRefs: memory.evidenceRefs,
createdAt: memory.createdAt,
updatedAt: memory.updatedAt,
retiredAt: memory.retiredAt,
tags: memory.tags,
});
}
function mapSpeakingPressure(
pressure: VoidSelfStateTypedProjection["scheduledRuntime"]["speakingPressure"],
): JsonValue {
return stripUndefined({
needToSpeak: scaleWord(pressure.needToSpeak),
rawNeedToSpeak: round(pressure.needToSpeak),
confessionPressure: maybeScale(pressure.confessionPressure),
noveltyPressure: maybeScale(pressure.noveltyPressure),
recentSpeechDamping: maybeScale(pressure.recentSpeechDamping),
lastSpokeAt: pressure.lastSpokeAt,
lastHeraldAt: pressure.lastHeraldAt,
});
}
function inferVoiceSummary(description: string | undefined): string {
if (!description) {
return "";
}
const sentence = description
.split(/(?<=[.!?])\s+/)
.find((line) => /should|voice|speak|herald|pride|tone/i.test(line));
return sentence?.trim() ?? "";
}
function inferImportance(tags: string[]): string {
if (tags.some((tag) => /identity|value|doctrine/i.test(tag))) {
return "high";
}
return "medium";
}
function maybeScale(value: number | undefined): JsonValue | undefined {
return value === undefined
? undefined
: {
value: scaleWord(value),
rawValue: round(value),
};
}
function scaleWord(value: number): "very_low" | "low" | "medium" | "high" | "very_high" {
if (value < 0.2) {
return "very_low";
}
if (value < 0.4) {
return "low";
}
if (value < 0.65) {
return "medium";
}
if (value < 0.85) {
return "high";
}
return "very_high";
}
function valenceWord(value: number): "negative" | "mixed_negative" | "neutral" | "mixed_positive" | "positive" {
if (value <= -0.6) {
return "negative";
}
if (value < -0.15) {
return "mixed_negative";
}
if (value <= 0.15) {
return "neutral";
}
if (value < 0.6) {
return "mixed_positive";
}
return "positive";
}
function latestTimestamp(values: Array<string | undefined>): string {
return values
.filter((value): value is string => Boolean(value))
.sort((left, right) => left.localeCompare(right))
.at(-1) ?? new Date(0).toISOString();
}
function round(value: number): number {
return Number(value.toFixed(3));
}
function stripUndefined<T extends JsonValue | undefined>(value: T): T {
if (Array.isArray(value)) {
return value.map((entry) => stripUndefined(entry)) as T;
}
if (!value || typeof value !== "object") {
return value;
}
const output: Record<string, JsonValue> = {};
for (const [key, child] of Object.entries(value)) {
if (child !== undefined) {
output[key] = stripUndefined(child as JsonValue);
}
}
return output as T;
}
function toYaml(value: JsonValue, indent = 0): string {
const pad = " ".repeat(indent);
if (Array.isArray(value)) {
if (value.length === 0) {
return "[]";
}
return value
.map((entry) => {
if (isScalar(entry)) {
return `${pad}- ${formatScalar(entry)}`;
}
const rendered = toYaml(entry, indent + 2);
return `${pad}- ${rendered.trimStart()}`;
})
.join("\n");
}
if (value && typeof value === "object") {
const entries = Object.entries(value).filter(([, child]) => child !== undefined);
if (entries.length === 0) {
return "{}";
}
return entries
.map(([key, child]) => {
if (isScalar(child)) {
return `${pad}${key}: ${formatScalar(child)}`;
}
const rendered = toYaml(child as JsonValue, indent + 2);
if (rendered === "[]" || rendered === "{}") {
return `${pad}${key}: ${rendered}`;
}
return `${pad}${key}:\n${rendered}`;
})
.join("\n");
}
return formatScalar(value);
}
function isScalar(value: unknown): value is null | boolean | number | string {
return value === null || ["boolean", "number", "string"].includes(typeof value);
}
function formatScalar(value: null | boolean | number | string): string {
if (value === null) {
return "null";
}
if (typeof value === "boolean" || typeof value === "number") {
return String(value);
}
return JSON.stringify(value);
}
async function readIdentity(path: string): Promise<RepoFaceIdentity> {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as RepoFaceIdentity;
}
async function resolveIdentity(options: CliOptions): Promise<RepoFaceIdentity> {
const fileIdentity = options.identityPath
? await readIdentity(options.identityPath)
: {};
return {
...fileIdentity,
identityId: options.identityId ?? fileIdentity.identityId,
displayName: options.displayName ?? fileIdentity.displayName,
repoName: options.repoName ?? fileIdentity.repoName,
avatarUrl: options.avatarUri ?? fileIdentity.avatarUrl,
repoPath: options.repoPath ?? fileIdentity.repoPath,
};
}
function parseArgs(args: string[]): CliOptions {
const options: CliOptions = {
identityPath: DEFAULT_IDENTITY_PATH,
statePath: DEFAULT_STATE_PATH,
outPath: DEFAULT_OUT_PATH,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
const value = args[index + 1];
if (arg === "--identity" && value) {
options.identityPath = value;
index += 1;
} else if (arg === "--no-identity-file") {
options.identityPath = undefined;
} else if (arg === "--id" && value) {
options.identityId = value;
index += 1;
} else if (arg === "--name" && value) {
options.displayName = value;
index += 1;
} else if (arg === "--repo-name" && value) {
options.repoName = value;
index += 1;
} else if (arg === "--avatar-uri" && value) {
options.avatarUri = value;
index += 1;
} else if (arg === "--repo-path" && value) {
options.repoPath = value;
index += 1;
} else if (arg === "--state" && value) {
options.statePath = value;
index += 1;
} else if (arg === "--out" && value) {
options.outPath = value;
index += 1;
} else if (arg === "--help" || arg === "-h") {
printUsage();
process.exit(0);
} else {
throw new Error(`Unknown or incomplete argument: ${arg}`);
}
}
return options;
}
function printUsage(): void {
console.log([
"Usage: npx tsx scripts/export-persona-friendly-input.ts [--identity path | --no-identity-file] [--state path] [--out path]",
"",
`Defaults:`,
` --identity ${DEFAULT_IDENTITY_PATH}`,
` --state ${DEFAULT_STATE_PATH}`,
` --out ${DEFAULT_OUT_PATH}`,
"",
"Native Persona options:",
" --no-identity-file --state .voidbot/private/personas/metame/metame.cc --out docs/persona-intake/metame.persona-intake.yaml",
].join("\n"));
}
main(process.argv.slice(2)).catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
process.exitCode = 1;
});