Rename pi-personality to pi-personas

Amolith created

Rename the package directory, npm package name, slash command, on-disk
paths, XML tags, and all internal references from "personality" /
"personalities" to "persona" / "personas".

Change summary

README.md                               |  14 
bun.lock                                |  30 +-
packages/personality/src/index.ts       | 271 ---------------------------
packages/personas/package.json          |   4 
packages/personas/package.json.license  |   0 
packages/personas/src/index.ts          | 271 +++++++++++++++++++++++++++
packages/personas/tsconfig.json         |   0 
packages/personas/tsconfig.json.license |   0 
8 files changed, 295 insertions(+), 295 deletions(-)

Detailed changes

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 :)

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"],
 

packages/personality/src/index.ts 🔗

@@ -1,271 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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 <personality>
- * 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 =
-	'<personality section_description="This section defines your personality. Fully embody the character, voice, and behaviour described within.">';
-const PERSONALITY_TAG_CLOSE = "</personality>";
-
-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");
-		},
-	});
-}

packages/personality/package.json → 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"
 	],

packages/personas/src/index.ts 🔗

@@ -0,0 +1,271 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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 <persona>
+ * 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 =
+	'<persona section_description="This section defines your personality. Fully embody the character, voice, and behaviour described within.">';
+const PERSONA_TAG_CLOSE = "</persona>";
+
+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");
+		},
+	});
+}