From 8af865ff9e5c2c821725c2722fdbf35d64100007 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 5 Apr 2026 16:01:27 -0600 Subject: [PATCH] Add personality extension Switchable agent personalities stored as markdown files, injected into the system prompt. Includes a /personality command for interactive switching and persistence across sessions. Licensed under the Unlicense. --- packages/personality/package.json | 20 ++ packages/personality/package.json.license | 3 + packages/personality/src/index.ts | 233 +++++++++++++++++++++ packages/personality/tsconfig.json | 4 + packages/personality/tsconfig.json.license | 3 + 5 files changed, 263 insertions(+) create mode 100644 packages/personality/package.json create mode 100644 packages/personality/package.json.license create mode 100644 packages/personality/src/index.ts create mode 100644 packages/personality/tsconfig.json create mode 100644 packages/personality/tsconfig.json.license diff --git a/packages/personality/package.json b/packages/personality/package.json new file mode 100644 index 0000000000000000000000000000000000000000..5b8dd0c2268d13f1816c3e6ad404084a580024e2 --- /dev/null +++ b/packages/personality/package.json @@ -0,0 +1,20 @@ +{ + "name": "@amolith/pi-personality", + "version": "0.1.0", + "description": "Switchable agent personalities for Pi", + "keywords": [ + "pi-package" + ], + "pi": { + "extensions": [ + "./src/index.ts" + ] + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*" + }, + "type": "module" +} diff --git a/packages/personality/package.json.license b/packages/personality/package.json.license new file mode 100644 index 0000000000000000000000000000000000000000..3dbb1e29808ff6ce1e89aa3211dbfa6c8aa5ef0e --- /dev/null +++ b/packages/personality/package.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Amolith + +SPDX-License-Identifier: CC0-1.0 diff --git a/packages/personality/src/index.ts b/packages/personality/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a87b52a9855a6fd4cd8b6601f1f426ab3de9414 --- /dev/null +++ b/packages/personality/src/index.ts @@ -0,0 +1,233 @@ +// 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 []; + + return fs + .readdirSync(dir) + .filter((f) => f.endsWith(".md")) + .map((f) => f.slice(0, -3)) + .sort(); +} + +/** Read the persisted active personality name, or null if none. */ +function readActivePersonality(): string | null { + const filePath = getActivePersonalityPath(); + if (!fs.existsSync(filePath)) return null; + + const name = fs.readFileSync(filePath, "utf-8").trim(); + return name || null; +} + +/** Persist the active personality name (or remove the file to clear). */ +function writeActivePersonality(name: string | null): void { + const filePath = getActivePersonalityPath(); + if (name) { + fs.writeFileSync(filePath, name + "\n", "utf-8"); + } else if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +} + +/** 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; + return fs.readFileSync(filePath, "utf-8"); +} + +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) { + let activePersonality: string | null = null; + let personalityContent: string | null = null; + let isFirstTurn = true; + + /** Load personality by name. Returns true if found and loaded. */ + function loadPersonality(name: string): boolean { + const content = readPersonalityContent(name); + if (!content) return false; + + activePersonality = name; + personalityContent = content; + return true; + } + + /** Clear active personality (both in-memory and on disk). */ + function clearPersonality(): void { + activePersonality = null; + personalityContent = null; + writeActivePersonality(null); + } + + /** Switch to a personality: load into memory and persist to disk. */ + function switchPersonality(name: string): boolean { + if (!loadPersonality(name)) return false; + writeActivePersonality(name); + return true; + } + + // Restore persisted personality on session start + pi.on("session_start", async (_event, ctx) => { + const persisted = readActivePersonality(); + + // Determine whether this is a fresh session or a resumed one + const entries = ctx.sessionManager.getEntries(); + const hasMessages = entries.some((e) => e.type === "message" && e.message.role === "assistant"); + isFirstTurn = !hasMessages; + + if (persisted) { + if (loadPersonality(persisted)) { + ctx.ui.notify(`Set personality to "${persisted}"`, "info"); + } else { + // Personality file was removed since last session + clearPersonality(); + } + } + }); + + // Inject personality into the system prompt or as a reminder message. + // On the first turn (no prior messages), we modify the system prompt + // directly so the personality is baked into the initial cache. + // On subsequent turns, we inject a reminder message instead to avoid + // invalidating the inference cache. + pi.on("before_agent_start", async (event) => { + if (!personalityContent) return undefined; + + if (isFirstTurn) { + isFirstTurn = false; + return { + systemPrompt: event.systemPrompt + wrapPersonality(personalityContent), + }; + } + + return { + message: { + customType: "system-reminder", + content: wrapPersonality(personalityContent), + display: false, + }, + }; + }); + + // 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; + clearPersonality(); + 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; + } + switchPersonality(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; + clearPersonality(); + ctx.ui.notify(`Personality cleared (was: ${was})`, "info"); + } + return; + } + + switchPersonality(picked); + ctx.ui.notify(`Set personality to "${picked}"`, "info"); + }, + }); +} diff --git a/packages/personality/tsconfig.json b/packages/personality/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..0c91d6284869132f30fd7f951e89ce4b9c7d9d54 --- /dev/null +++ b/packages/personality/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/packages/personality/tsconfig.json.license b/packages/personality/tsconfig.json.license new file mode 100644 index 0000000000000000000000000000000000000000..3dbb1e29808ff6ce1e89aa3211dbfa6c8aa5ef0e --- /dev/null +++ b/packages/personality/tsconfig.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Amolith + +SPDX-License-Identifier: CC0-1.0