import { useKeyboard } from "@opentui/solid"; import { onMount, onCleanup } from "solid-js"; import * as fs from "fs"; import { join } from "path"; import { homedir } from "os"; import type { Command, CommandContext } from "./registry"; import type { PersonaEntity } from "../../../src/core/types.js"; import { ConfirmOverlay } from "../components/ConfirmOverlay.js"; import { personaPreviewToYAML, personaPreviewFromYAML, } from "../util/yaml-serializers.js"; import { useKeyboardNav } from "../context/keyboard.js"; import { resolveDataPath } from "../util/resolve-data-path.js"; function wrapComment(text: string, width = 78): string { const words = text.split(/\s+/); const lines: string[] = []; let current = ""; for (const word of words) { if (current && (current + " " + word).length > width) { lines.push(`# ${current}`); current = word; } else { current = current ? `${current} ${word}` : word; } } if (current) lines.push(`# ${current}`); return lines.join("\n"); } function getReflectFolder(persona: PersonaEntity): string { const datePrefix = persona.pending_update!.created_at.slice(0, 10); const safeName = persona.display_name.replace(/\s+/g, "_"); return join(resolveDataPath(), "reflect", `${datePrefix}_${safeName}`); } async function resolveReflectionPersona( args: string[], ctx: CommandContext ): Promise<{ personaId: string; persona: PersonaEntity } | null> { const SUBCOMMANDS = new Set(["generate", "update", "apply", "dismiss"]); const nameArgs = SUBCOMMANDS.has(args[0]?.toLowerCase() ?? "") ? args.slice(1) : args; const name = nameArgs.join(" ").trim(); let personaId: string | null; if (name) { personaId = await ctx.ei.resolvePersonaName(name); if (!personaId) { ctx.showNotification(`No persona named "${name}"`, "error"); return null; } } else { personaId = ctx.ei.activePersonaId(); if (!personaId) { ctx.showNotification("No active persona", "error"); return null; } } const persona = await ctx.ei.getPersona(personaId); if (!persona) { ctx.showNotification("Could not load persona", "error"); return null; } if (!persona.pending_update) { ctx.showNotification(`No pending reflection for ${persona.display_name}`, "error"); return null; } return { personaId, persona }; } export const reflectCommand: Command = { name: "reflect", aliases: ["rf"], description: "Review a persona's pending reflection — compare current vs proposed identity while chatting", usage: "/reflect [generate|update|apply|dismiss]", async execute(args, ctx) { const subcommand = args[0]?.toLowerCase(); if (!subcommand || !["generate", "update", "apply", "dismiss"].includes(subcommand)) { const activeId = ctx.ei.activePersonaId(); const activePersona = activeId ? ctx.ei.personas().find(p => p.id === activeId) : null; const hasPending = activePersona?.has_pending_update ?? false; const pendingPersonas = hasPending ? [] : ctx.ei.personas().filter(p => p.has_pending_update); if (!hasPending && pendingPersonas.length === 0) { ctx.showNotification("No pending reflections.", "info"); return; } const headerName = hasPending ? activePersona!.display_name : undefined; const pendingNames = pendingPersonas.map(p => p.display_name); const dataPath = resolveDataPath(); ctx.showOverlay((hideOverlay, _hideForEditor) => { const { setOverlayActive } = useKeyboardNav(); onMount(() => setOverlayActive(true)); onCleanup(() => setOverlayActive(false)); useKeyboard((event) => { event.preventDefault(); hideOverlay(); }); const header = headerName ? `✦ Persona Reflection: ${headerName}` : `✦ Persona Reflection — pending: ${pendingNames.join(", ")}`; return ( {header} {"Personas grow through conversation. Reflection is a chance"} {"to review proposed changes to their Identity with them."} {"Open the generated files in your editor while you chat."} {" /reflect generate Write current + proposed YAML files"} {" /reflect update Read proposed.yaml back into Ei"} {" (Persona sees updated data)"} {" /reflect apply Write proposed.yaml to your Persona"} {" /reflect dismiss Discard without changing anything"} {dataPath.replace(homedir(), "~") + "/reflect/YYYY-MM-DD_Name/"} {" ├── current.yaml # Read-Only Reference"} {" └── proposed.yaml # Edit while Chatting"} {"[Press any key to close]"} ); }, ctx.renderer); return; } if (subcommand === "generate") { const result = await resolveReflectionPersona(args, ctx); if (!result) return; const { persona } = result; const folderPath = getReflectFolder(persona); if (fs.existsSync(folderPath)) { const date = persona.pending_update!.created_at.slice(0, 10); ctx.showNotification(`Found existing reflection from ${date}, regenerating...`, "info"); fs.rmSync(folderPath, { recursive: true, force: true }); } fs.mkdirSync(folderPath, { recursive: true }); const now = new Date().toISOString(); const critique = wrapComment(persona.pending_update!.critique); const currentHeader = [ `# Current Identity: ${persona.display_name}`, `# This is the CURRENT identity — for reference only. Do not edit this file.`, `# Generated: ${now}`, `#`, `# Critique from reflection analysis:`, critique, ``, ].join("\n"); const currentYAML = personaPreviewToYAML( { long_description: persona.long_description ?? "", short_description: persona.short_description ?? "", traits: (persona.traits ?? []).map(t => ({ name: t.name, description: t.description, strength: t.strength ?? 0.5, sentiment: t.sentiment ?? 0, })), topics: (persona.topics ?? []).map(t => ({ name: t.name, perspective: t.perspective, approach: t.approach, personal_stake: t.personal_stake, sentiment: t.sentiment ?? 0, exposure_current: t.exposure_current ?? 0.5, exposure_desired: t.exposure_desired ?? 0.5, })), }, persona.display_name ); fs.writeFileSync(join(folderPath, "current.yaml"), currentHeader + currentYAML, "utf-8"); const proposedHeader = [ `# Proposed Identity: ${persona.display_name}`, `# Edit this file freely. Use /reflect update to sync changes into the system.`, `# Use /reflect apply when done, or /reflect dismiss to discard.`, ``, ].join("\n"); const proposedYAML = personaPreviewToYAML( { long_description: persona.pending_update!.long_description, short_description: persona.pending_update!.short_description ?? "", traits: (persona.pending_update!.traits ?? []).map(t => ({ name: t.name, description: t.description, strength: t.strength ?? 0.5, sentiment: t.sentiment ?? 0, })), topics: (persona.pending_update!.topics ?? []).map(t => ({ name: t.name, perspective: t.perspective, approach: t.approach, personal_stake: t.personal_stake, sentiment: t.sentiment ?? 0, exposure_current: t.exposure_current ?? 0.5, exposure_desired: t.exposure_desired ?? 0.5, })), }, persona.display_name ); fs.writeFileSync(join(folderPath, "proposed.yaml"), proposedHeader + proposedYAML, "utf-8"); ctx.showNotification( `Reflection files written to ${folderPath} — open proposed.yaml in your editor alongside this chat`, "info" ); return; } if (subcommand === "update") { const result = await resolveReflectionPersona(args, ctx); if (!result) return; const { personaId, persona } = result; const folderPath = getReflectFolder(persona); if (!fs.existsSync(folderPath)) { ctx.showNotification( `No reflection folder found — run /reflect generate first`, "error" ); return; } const proposedContent = fs.readFileSync(join(folderPath, "proposed.yaml"), "utf-8"); let parsed: ReturnType; try { parsed = personaPreviewFromYAML(proposedContent); } catch (e) { ctx.showNotification( `Parse error: ${e instanceof Error ? e.message : String(e)}`, "error" ); return; } await ctx.ei.updatePersona(personaId, { pending_update: { critique: persona.pending_update!.critique, created_at: persona.pending_update!.created_at, long_description: parsed.long_description, short_description: parsed.short_description ?? persona.pending_update!.short_description, traits: parsed.traits, topics: parsed.topics, }, }); ctx.showNotification( `Updated — ${persona.display_name} will see your changes in the next message`, "info" ); return; } if (subcommand === "apply") { const result = await resolveReflectionPersona(args, ctx); if (!result) return; const { personaId, persona } = result; const folderPath = getReflectFolder(persona); let finalParsed: ReturnType | null = null; if (fs.existsSync(folderPath)) { const proposedPath = join(folderPath, "proposed.yaml"); if (fs.existsSync(proposedPath)) { const proposedContent = fs.readFileSync(proposedPath, "utf-8"); try { finalParsed = personaPreviewFromYAML(proposedContent); } catch (e) { ctx.showNotification( `Parse error in proposed.yaml: ${e instanceof Error ? e.message : String(e)}`, "error" ); return; } } } const source = finalParsed ?? { long_description: persona.pending_update!.long_description, short_description: persona.pending_update!.short_description as string | undefined, traits: persona.pending_update!.traits, topics: persona.pending_update!.topics, }; await ctx.ei.finalizeReflection(personaId, "apply", source); if (fs.existsSync(folderPath)) { fs.rmSync(folderPath, { recursive: true, force: true }); } ctx.showNotification(`Applied reflection for ${persona.display_name}`, "info"); return; } if (subcommand === "dismiss") { const result = await resolveReflectionPersona(args, ctx); if (!result) return; const { personaId, persona } = result; const confirmed = await new Promise((resolve) => { ctx.showOverlay((hideOverlay, _hideForEditor) => ( { hideOverlay(); resolve(true); }} onCancel={() => { hideOverlay(); resolve(false); }} /> ), ctx.renderer); }); if (!confirmed) { ctx.showNotification("Cancelled", "info"); return; } await ctx.ei.finalizeReflection(personaId, "dismiss"); const folderPath = getReflectFolder(persona); if (fs.existsSync(folderPath)) { fs.rmSync(folderPath, { recursive: true, force: true }); } ctx.showNotification(`Dismissed reflection for ${persona.display_name}`, "info"); return; } }, };