diff --git a/README.md b/README.md index 7251bb1708c04dd550f0f13655af71c0166aa110..7fb7e895873f1d02ab1afea3b2eca3edc1304b29 100644 --- a/README.md +++ b/README.md @@ -68,17 +68,17 @@ pi install npm:@amolith/pi-questionnaire > word wrapping for long prompts, and custom answers are sent as a steering > user message so the user can go back and edit them later. -### `@amolith/pi-personality` +### `@amolith/pi-personas` -[![npm](https://img.shields.io/npm/v/@amolith/pi-personality)](https://www.npmjs.com/package/@amolith/pi-personality) +[![npm](https://img.shields.io/npm/v/@amolith/pi-personas)](https://www.npmjs.com/package/@amolith/pi-personas) -Drop markdown files in `$PI_CODING_AGENT_DIR/personalities/` and switch between -them with `/personality`. Lets your agent be a grumpy senior today and a patient -tutor tomorrow. The active personality gets injected into the system prompt and -persists across sessions until you change it. +Drop markdown files in `$PI_CODING_AGENT_DIR/personas/` and switch between them +with `/persona`. Lets your agent be a grumpy senior today and a patient tutor +tomorrow. The active persona gets injected into the system prompt and persists +across sessions until you change it. ```sh -pi install npm:@amolith/pi-personality +pi install npm:@amolith/pi-personas ``` > All mine :) diff --git a/bun.lock b/bun.lock index 3a324abd719a18debb6256ae8719fc23aea9b942..87f9fb5907f59bfcc81927f0cd2ae0f1cbf345ba 100644 --- a/bun.lock +++ b/bun.lock @@ -12,36 +12,36 @@ "name": "@amolith/pi-answer", "version": "0.1.0", "peerDependencies": { - "@mariozechner/pi-ai": "*", - "@mariozechner/pi-coding-agent": "*", - "@mariozechner/pi-tui": "*", - "@sinclair/typebox": "*", + "@mariozechner/pi-ai": ">=0.65.0", + "@mariozechner/pi-coding-agent": ">=0.65.0", + "@mariozechner/pi-tui": ">=0.65.0", + "@sinclair/typebox": ">=0.34.0", }, }, "packages/handoff": { "name": "@amolith/pi-handoff", "version": "0.1.0", "peerDependencies": { - "@mariozechner/pi-ai": "*", - "@mariozechner/pi-coding-agent": "*", - "@mariozechner/pi-tui": "*", - "@sinclair/typebox": "*", + "@mariozechner/pi-ai": ">=0.65.0", + "@mariozechner/pi-coding-agent": ">=0.65.0", + "@mariozechner/pi-tui": ">=0.65.0", + "@sinclair/typebox": ">=0.34.0", }, }, - "packages/personality": { - "name": "@amolith/pi-personality", + "packages/personas": { + "name": "@amolith/pi-personas", "version": "0.1.0", "peerDependencies": { - "@mariozechner/pi-coding-agent": "*", + "@mariozechner/pi-coding-agent": ">=0.65.0", }, }, "packages/questionnaire": { "name": "@amolith/pi-questionnaire", "version": "0.1.0", "peerDependencies": { - "@mariozechner/pi-coding-agent": "*", - "@mariozechner/pi-tui": "*", - "@sinclair/typebox": "*", + "@mariozechner/pi-coding-agent": ">=0.65.0", + "@mariozechner/pi-tui": ">=0.65.0", + "@sinclair/typebox": ">=0.34.0", }, }, }, @@ -50,7 +50,7 @@ "@amolith/pi-handoff": ["@amolith/pi-handoff@workspace:packages/handoff"], - "@amolith/pi-personality": ["@amolith/pi-personality@workspace:packages/personality"], + "@amolith/pi-personas": ["@amolith/pi-personas@workspace:packages/personas"], "@amolith/pi-questionnaire": ["@amolith/pi-questionnaire@workspace:packages/questionnaire"], diff --git a/packages/personality/src/index.ts b/packages/personality/src/index.ts deleted file mode 100644 index b9c18d292a1367beaacc27685f54ae2b0726ae33..0000000000000000000000000000000000000000 --- a/packages/personality/src/index.ts +++ /dev/null @@ -1,271 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: Unlicense - -/** - * Pi Personalities Extension - * - * Switch between agent personalities stored as markdown files. - * - * Personalities live in $PI_CODING_AGENT_DIR/personalities/ as .md files. - * The active personality is appended to the system prompt in a - * section, independent of SYSTEM.md and AGENTS.md. - * - * Commands: - * /personality — Interactive picker to switch personality - * /personality none|unset|clear — Remove active personality - * - * The active personality persists across sessions in a plain text file - * at $PI_CODING_AGENT_DIR/personality. - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as os from "node:os"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; - -const CLEAR_KEYWORDS = ["none", "unset", "clear"]; - -function getAgentDir(): string { - const envDir = process.env.PI_CODING_AGENT_DIR; - if (envDir) { - if (envDir === "~") return os.homedir(); - if (envDir.startsWith("~/")) return path.join(os.homedir(), envDir.slice(2)); - return envDir; - } - return path.join(os.homedir(), ".pi", "agent"); -} - -function getPersonalitiesDir(): string { - return path.join(getAgentDir(), "personalities"); -} - -function getActivePersonalityPath(): string { - return path.join(getAgentDir(), "personality"); -} - -/** List available personality names (without .md extension). */ -function listPersonalities(): string[] { - const dir = getPersonalitiesDir(); - if (!fs.existsSync(dir)) return []; - - try { - return fs - .readdirSync(dir) - .filter((f) => f.endsWith(".md")) - .map((f) => f.slice(0, -3)) - .sort(); - } catch (err) { - console.error(`Failed to list personalities: ${err}`); - return []; - } -} - -/** - * Read the persisted active personality name, or null if none. - * Validates the name against available personalities to prevent - * path traversal via a tampered persistence file. - */ -function readActivePersonality(): string | null { - const filePath = getActivePersonalityPath(); - if (!fs.existsSync(filePath)) return null; - - try { - const name = fs.readFileSync(filePath, "utf-8").trim(); - if (!name) return null; - - // Reject names that could escape the personalities directory - const available = listPersonalities(); - if (!available.includes(name)) { - console.error(`Persisted personality "${name}" not in available list, ignoring`); - return null; - } - - return name; - } catch (err) { - console.error(`Failed to read active personality: ${err}`); - return null; - } -} - -/** Persist the active personality name (or remove the file to clear). */ -function writeActivePersonality(name: string | null): void { - const filePath = getActivePersonalityPath(); - try { - if (name) { - fs.writeFileSync(filePath, `${name}\n`, "utf-8"); - } else if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - } catch (err) { - console.error(`Failed to write active personality: ${err}`); - } -} - -/** Read personality markdown content, or null if file missing. */ -function readPersonalityContent(name: string): string | null { - const filePath = path.join(getPersonalitiesDir(), `${name}.md`); - if (!fs.existsSync(filePath)) return null; - try { - return fs.readFileSync(filePath, "utf-8"); - } catch (err) { - console.error(`Failed to read personality "${name}": ${err}`); - return null; - } -} - -const PERSONALITY_TAG_OPEN = - ''; -const PERSONALITY_TAG_CLOSE = ""; - -function wrapPersonality(content: string): string { - return `\n\n${PERSONALITY_TAG_OPEN}\n${content}\n${PERSONALITY_TAG_CLOSE}\n`; -} - -export default function (pi: ExtensionAPI) { - // "active" tracks what the user last picked (persisted to disk). - // "session" is locked at session_start and never changes — it - // controls what goes into the system prompt for this session. - let activePersonality: string | null = null; - let sessionPersonalityContent: string | null = null; - let pendingReminder: string | null = null; - - /** Set the active personality name. Does NOT touch session prompt. */ - function setActive(name: string | null): void { - activePersonality = name; - writeActivePersonality(name); - } - - // Lock personality into the system prompt at session start. - // This content never changes for the lifetime of the session. - pi.on("session_start", async (_event, ctx) => { - const persisted = readActivePersonality(); - pendingReminder = null; - sessionPersonalityContent = null; - - if (persisted) { - const content = readPersonalityContent(persisted); - if (content) { - activePersonality = persisted; - sessionPersonalityContent = content; - ctx.ui.notify(`Set personality to "${persisted}"`, "info"); - } else { - // Personality file was removed since last session - setActive(null); - } - } - }); - - // Append the session personality to the system prompt (ephemeral, - // never persisted). The content is identical every turn so the - // provider cache stays valid. - // - // Mid-session switches only fire a one-shot message — they never - // alter the system prompt. - pi.on("before_agent_start", async (event) => { - if (!sessionPersonalityContent && !pendingReminder) return undefined; - - const result: { - systemPrompt?: string; - message?: { customType: string; content: string; display: boolean }; - } = {}; - - if (sessionPersonalityContent) { - result.systemPrompt = event.systemPrompt + wrapPersonality(sessionPersonalityContent); - } - - if (pendingReminder) { - result.message = { - customType: "personality-switch", - content: pendingReminder, - display: false, - }; - pendingReminder = null; - } - - return result; - }); - - // Register /personality command - pi.registerCommand("personality", { - description: "Switch agent personality or clear it", - getArgumentCompletions: (prefix: string) => { - const names = [...listPersonalities(), ...CLEAR_KEYWORDS]; - const filtered = names.filter((n) => n.startsWith(prefix)).map((n) => ({ value: n, label: n })); - return filtered.length > 0 ? filtered : null; - }, - handler: async (args, ctx) => { - const arg = args?.trim() || ""; - - // /personality none|unset|clear — remove active personality - if (CLEAR_KEYWORDS.includes(arg.toLowerCase())) { - if (!activePersonality) { - ctx.ui.notify("No personality is active", "info"); - return; - } - const was = activePersonality; - pendingReminder = `Personality "${was}" has been cleared. Revert to your default behaviour.`; - setActive(null); - ctx.ui.notify(`Personality cleared (was: ${was})`, "info"); - return; - } - - // /personality someName — direct switch (not in picker) - if (arg) { - const available = listPersonalities(); - if (!available.includes(arg)) { - ctx.ui.notify(`Personality "${arg}" not found`, "error"); - return; - } - const content = readPersonalityContent(arg); - if (!content) { - ctx.ui.notify(`Personality "${arg}" could not be read`, "error"); - return; - } - pendingReminder = wrapPersonality(content); - setActive(arg); - ctx.ui.notify(`Set personality to "${arg}"`, "info"); - return; - } - - // /personality — interactive picker - const available = listPersonalities(); - if (available.length === 0) { - ctx.ui.notify("No personalities available", "info"); - return; - } - - const options = available.map((name) => (name === activePersonality ? `${name} (active)` : name)); - const unsetLabel = activePersonality ? "unset" : "unset (active)"; - options.unshift(unsetLabel); - - const choice = await ctx.ui.select("Pick a personality:", options); - - if (!choice) return; // cancelled - - // Strip " (active)" suffix to get the real name - const picked = choice.replace(/ \(active\)$/, ""); - - if (picked === "unset") { - if (!activePersonality) { - ctx.ui.notify("No personality is active", "info"); - } else { - const was = activePersonality; - pendingReminder = `Personality "${was}" has been cleared. Revert to your default behaviour.`; - setActive(null); - ctx.ui.notify(`Personality cleared (was: ${was})`, "info"); - } - return; - } - - const content = readPersonalityContent(picked); - if (!content) { - ctx.ui.notify(`Personality "${picked}" could not be read`, "error"); - return; - } - pendingReminder = wrapPersonality(content); - setActive(picked); - ctx.ui.notify(`Set personality to "${picked}"`, "info"); - }, - }); -} diff --git a/packages/personality/package.json b/packages/personas/package.json similarity index 73% rename from packages/personality/package.json rename to packages/personas/package.json index b2f9ba83c4236853aa3f82c0f30cf78b201a396c..87340d147e9716b9c8a03358ad8f86da82fc5250 100644 --- a/packages/personality/package.json +++ b/packages/personas/package.json @@ -1,7 +1,7 @@ { - "name": "@amolith/pi-personality", + "name": "@amolith/pi-personas", "version": "0.1.0", - "description": "Switchable agent personalities for Pi", + "description": "Switchable agent personas for Pi", "keywords": [ "pi-package" ], diff --git a/packages/personality/package.json.license b/packages/personas/package.json.license similarity index 100% rename from packages/personality/package.json.license rename to packages/personas/package.json.license diff --git a/packages/personas/src/index.ts b/packages/personas/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..087d8055872d8df86f1f9a2f64082c3df565d069 --- /dev/null +++ b/packages/personas/src/index.ts @@ -0,0 +1,271 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: Unlicense + +/** + * Pi Personas Extension + * + * Switch between agent personas stored as markdown files. + * + * Personas live in $PI_CODING_AGENT_DIR/personas/ as .md files. + * The active persona is appended to the system prompt in a + * section, independent of SYSTEM.md and AGENTS.md. + * + * Commands: + * /persona — Interactive picker to switch persona + * /persona none|unset|clear — Remove active persona + * + * The active persona persists across sessions in a plain text file + * at $PI_CODING_AGENT_DIR/persona. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +const CLEAR_KEYWORDS = ["none", "unset", "clear"]; + +function getAgentDir(): string { + const envDir = process.env.PI_CODING_AGENT_DIR; + if (envDir) { + if (envDir === "~") return os.homedir(); + if (envDir.startsWith("~/")) return path.join(os.homedir(), envDir.slice(2)); + return envDir; + } + return path.join(os.homedir(), ".pi", "agent"); +} + +function getPersonasDir(): string { + return path.join(getAgentDir(), "personas"); +} + +function getActivePersonaPath(): string { + return path.join(getAgentDir(), "persona"); +} + +/** List available persona names (without .md extension). */ +function listPersonas(): string[] { + const dir = getPersonasDir(); + if (!fs.existsSync(dir)) return []; + + try { + return fs + .readdirSync(dir) + .filter((f) => f.endsWith(".md")) + .map((f) => f.slice(0, -3)) + .sort(); + } catch (err) { + console.error(`Failed to list personas: ${err}`); + return []; + } +} + +/** + * Read the persisted active persona name, or null if none. + * Validates the name against available personas to prevent + * path traversal via a tampered persistence file. + */ +function readActivePersona(): string | null { + const filePath = getActivePersonaPath(); + if (!fs.existsSync(filePath)) return null; + + try { + const name = fs.readFileSync(filePath, "utf-8").trim(); + if (!name) return null; + + // Reject names that could escape the personas directory + const available = listPersonas(); + if (!available.includes(name)) { + console.error(`Persisted persona "${name}" not in available list, ignoring`); + return null; + } + + return name; + } catch (err) { + console.error(`Failed to read active persona: ${err}`); + return null; + } +} + +/** Persist the active persona name (or remove the file to clear). */ +function writeActivePersona(name: string | null): void { + const filePath = getActivePersonaPath(); + try { + if (name) { + fs.writeFileSync(filePath, `${name}\n`, "utf-8"); + } else if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (err) { + console.error(`Failed to write active persona: ${err}`); + } +} + +/** Read persona markdown content, or null if file missing. */ +function readPersonaContent(name: string): string | null { + const filePath = path.join(getPersonasDir(), `${name}.md`); + if (!fs.existsSync(filePath)) return null; + try { + return fs.readFileSync(filePath, "utf-8"); + } catch (err) { + console.error(`Failed to read persona "${name}": ${err}`); + return null; + } +} + +const PERSONA_TAG_OPEN = + ''; +const PERSONA_TAG_CLOSE = ""; + +function wrapPersona(content: string): string { + return `\n\n${PERSONA_TAG_OPEN}\n${content}\n${PERSONA_TAG_CLOSE}\n`; +} + +export default function (pi: ExtensionAPI) { + // "active" tracks what the user last picked (persisted to disk). + // "session" is locked at session_start and never changes — it + // controls what goes into the system prompt for this session. + let activePersona: string | null = null; + let sessionPersonaContent: string | null = null; + let pendingReminder: string | null = null; + + /** Set the active persona name. Does NOT touch session prompt. */ + function setActive(name: string | null): void { + activePersona = name; + writeActivePersona(name); + } + + // Lock persona into the system prompt at session start. + // This content never changes for the lifetime of the session. + pi.on("session_start", async (_event, ctx) => { + const persisted = readActivePersona(); + pendingReminder = null; + sessionPersonaContent = null; + + if (persisted) { + const content = readPersonaContent(persisted); + if (content) { + activePersona = persisted; + sessionPersonaContent = content; + ctx.ui.notify(`Set persona to "${persisted}"`, "info"); + } else { + // Persona file was removed since last session + setActive(null); + } + } + }); + + // Append the session persona to the system prompt (ephemeral, + // never persisted). The content is identical every turn so the + // provider cache stays valid. + // + // Mid-session switches only fire a one-shot message — they never + // alter the system prompt. + pi.on("before_agent_start", async (event) => { + if (!sessionPersonaContent && !pendingReminder) return undefined; + + const result: { + systemPrompt?: string; + message?: { customType: string; content: string; display: boolean }; + } = {}; + + if (sessionPersonaContent) { + result.systemPrompt = event.systemPrompt + wrapPersona(sessionPersonaContent); + } + + if (pendingReminder) { + result.message = { + customType: "persona-switch", + content: pendingReminder, + display: false, + }; + pendingReminder = null; + } + + return result; + }); + + // Register /persona command + pi.registerCommand("persona", { + description: "Switch agent persona or clear it", + getArgumentCompletions: (prefix: string) => { + const names = [...listPersonas(), ...CLEAR_KEYWORDS]; + const filtered = names.filter((n) => n.startsWith(prefix)).map((n) => ({ value: n, label: n })); + return filtered.length > 0 ? filtered : null; + }, + handler: async (args, ctx) => { + const arg = args?.trim() || ""; + + // /persona none|unset|clear — remove active persona + if (CLEAR_KEYWORDS.includes(arg.toLowerCase())) { + if (!activePersona) { + ctx.ui.notify("No persona is active", "info"); + return; + } + const was = activePersona; + pendingReminder = `Persona "${was}" has been cleared. Revert to your default behaviour.`; + setActive(null); + ctx.ui.notify(`Persona cleared (was: ${was})`, "info"); + return; + } + + // /persona someName — direct switch (not in picker) + if (arg) { + const available = listPersonas(); + if (!available.includes(arg)) { + ctx.ui.notify(`Persona "${arg}" not found`, "error"); + return; + } + const content = readPersonaContent(arg); + if (!content) { + ctx.ui.notify(`Persona "${arg}" could not be read`, "error"); + return; + } + pendingReminder = wrapPersona(content); + setActive(arg); + ctx.ui.notify(`Set persona to "${arg}"`, "info"); + return; + } + + // /persona — interactive picker + const available = listPersonas(); + if (available.length === 0) { + ctx.ui.notify("No personas available", "info"); + return; + } + + const options = available.map((name) => (name === activePersona ? `${name} (active)` : name)); + const unsetLabel = activePersona ? "unset" : "unset (active)"; + options.unshift(unsetLabel); + + const choice = await ctx.ui.select("Pick a persona:", options); + + if (!choice) return; // cancelled + + // Strip " (active)" suffix to get the real name + const picked = choice.replace(/ \(active\)$/, ""); + + if (picked === "unset") { + if (!activePersona) { + ctx.ui.notify("No persona is active", "info"); + } else { + const was = activePersona; + pendingReminder = `Persona "${was}" has been cleared. Revert to your default behaviour.`; + setActive(null); + ctx.ui.notify(`Persona cleared (was: ${was})`, "info"); + } + return; + } + + const content = readPersonaContent(picked); + if (!content) { + ctx.ui.notify(`Persona "${picked}" could not be read`, "error"); + return; + } + pendingReminder = wrapPersona(content); + setActive(picked); + ctx.ui.notify(`Set persona to "${picked}"`, "info"); + }, + }); +} diff --git a/packages/personality/tsconfig.json b/packages/personas/tsconfig.json similarity index 100% rename from packages/personality/tsconfig.json rename to packages/personas/tsconfig.json diff --git a/packages/personality/tsconfig.json.license b/packages/personas/tsconfig.json.license similarity index 100% rename from packages/personality/tsconfig.json.license rename to packages/personas/tsconfig.json.license