index.ts

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: Unlicense
  4
  5/**
  6 * Pi Personalities Extension
  7 *
  8 * Switch between agent personalities stored as markdown files.
  9 *
 10 * Personalities live in $PI_CODING_AGENT_DIR/personalities/ as .md files.
 11 * The active personality is appended to the system prompt in a <personality>
 12 * section, independent of SYSTEM.md and AGENTS.md.
 13 *
 14 * Commands:
 15 *   /personality              — Interactive picker to switch personality
 16 *   /personality none|unset|clear — Remove active personality
 17 *
 18 * The active personality persists across sessions in a plain text file
 19 * at $PI_CODING_AGENT_DIR/personality.
 20 */
 21
 22import * as fs from "node:fs";
 23import * as path from "node:path";
 24import * as os from "node:os";
 25import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 26
 27const CLEAR_KEYWORDS = ["none", "unset", "clear"];
 28
 29function getAgentDir(): string {
 30	const envDir = process.env.PI_CODING_AGENT_DIR;
 31	if (envDir) {
 32		if (envDir === "~") return os.homedir();
 33		if (envDir.startsWith("~/")) return path.join(os.homedir(), envDir.slice(2));
 34		return envDir;
 35	}
 36	return path.join(os.homedir(), ".pi", "agent");
 37}
 38
 39function getPersonalitiesDir(): string {
 40	return path.join(getAgentDir(), "personalities");
 41}
 42
 43function getActivePersonalityPath(): string {
 44	return path.join(getAgentDir(), "personality");
 45}
 46
 47/** List available personality names (without .md extension). */
 48function listPersonalities(): string[] {
 49	const dir = getPersonalitiesDir();
 50	if (!fs.existsSync(dir)) return [];
 51
 52	return fs
 53		.readdirSync(dir)
 54		.filter((f) => f.endsWith(".md"))
 55		.map((f) => f.slice(0, -3))
 56		.sort();
 57}
 58
 59/** Read the persisted active personality name, or null if none. */
 60function readActivePersonality(): string | null {
 61	const filePath = getActivePersonalityPath();
 62	if (!fs.existsSync(filePath)) return null;
 63
 64	const name = fs.readFileSync(filePath, "utf-8").trim();
 65	return name || null;
 66}
 67
 68/** Persist the active personality name (or remove the file to clear). */
 69function writeActivePersonality(name: string | null): void {
 70	const filePath = getActivePersonalityPath();
 71	if (name) {
 72		fs.writeFileSync(filePath, `${name}\n`, "utf-8");
 73	} else if (fs.existsSync(filePath)) {
 74		fs.unlinkSync(filePath);
 75	}
 76}
 77
 78/** Read personality markdown content, or null if file missing. */
 79function readPersonalityContent(name: string): string | null {
 80	const filePath = path.join(getPersonalitiesDir(), `${name}.md`);
 81	if (!fs.existsSync(filePath)) return null;
 82	return fs.readFileSync(filePath, "utf-8");
 83}
 84
 85const PERSONALITY_TAG_OPEN =
 86	'<personality section_description="This section defines your personality. Fully embody the character, voice, and behaviour described within.">';
 87const PERSONALITY_TAG_CLOSE = "</personality>";
 88
 89function wrapPersonality(content: string): string {
 90	return `\n\n${PERSONALITY_TAG_OPEN}\n${content}\n${PERSONALITY_TAG_CLOSE}\n`;
 91}
 92
 93export default function (pi: ExtensionAPI) {
 94	// "active" tracks what the user last picked (persisted to disk).
 95	// "session" is locked at session_start and never changes — it
 96	// controls what goes into the system prompt for this session.
 97	let activePersonality: string | null = null;
 98	let sessionPersonalityContent: string | null = null;
 99	let pendingReminder: string | null = null;
100
101	/** Set the active personality name. Does NOT touch session prompt. */
102	function setActive(name: string | null): void {
103		activePersonality = name;
104		writeActivePersonality(name);
105	}
106
107	// Lock personality into the system prompt at session start.
108	// This content never changes for the lifetime of the session.
109	pi.on("session_start", async (_event, ctx) => {
110		const persisted = readActivePersonality();
111		pendingReminder = null;
112		sessionPersonalityContent = null;
113
114		if (persisted) {
115			const content = readPersonalityContent(persisted);
116			if (content) {
117				activePersonality = persisted;
118				sessionPersonalityContent = content;
119				ctx.ui.notify(`Set personality to "${persisted}"`, "info");
120			} else {
121				// Personality file was removed since last session
122				setActive(null);
123			}
124		}
125	});
126
127	// Append the session personality to the system prompt (ephemeral,
128	// never persisted). The content is identical every turn so the
129	// provider cache stays valid.
130	//
131	// Mid-session switches only fire a one-shot message — they never
132	// alter the system prompt.
133	pi.on("before_agent_start", async (event) => {
134		if (!sessionPersonalityContent && !pendingReminder) return undefined;
135
136		const result: {
137			systemPrompt?: string;
138			message?: { customType: string; content: string; display: boolean };
139		} = {};
140
141		if (sessionPersonalityContent) {
142			result.systemPrompt = event.systemPrompt + wrapPersonality(sessionPersonalityContent);
143		}
144
145		if (pendingReminder) {
146			result.message = {
147				customType: "personality-switch",
148				content: pendingReminder,
149				display: false,
150			};
151			pendingReminder = null;
152		}
153
154		return result;
155	});
156
157	// Register /personality command
158	pi.registerCommand("personality", {
159		description: "Switch agent personality or clear it",
160		getArgumentCompletions: (prefix: string) => {
161			const names = [...listPersonalities(), ...CLEAR_KEYWORDS];
162			const filtered = names.filter((n) => n.startsWith(prefix)).map((n) => ({ value: n, label: n }));
163			return filtered.length > 0 ? filtered : null;
164		},
165		handler: async (args, ctx) => {
166			const arg = args?.trim() || "";
167
168			// /personality none|unset|clear — remove active personality
169			if (CLEAR_KEYWORDS.includes(arg.toLowerCase())) {
170				if (!activePersonality) {
171					ctx.ui.notify("No personality is active", "info");
172					return;
173				}
174				const was = activePersonality;
175				pendingReminder = `Personality "${was}" has been cleared. Revert to your default behaviour.`;
176				setActive(null);
177				ctx.ui.notify(`Personality cleared (was: ${was})`, "info");
178				return;
179			}
180
181			// /personality someName — direct switch (not in picker)
182			if (arg) {
183				const available = listPersonalities();
184				if (!available.includes(arg)) {
185					ctx.ui.notify(`Personality "${arg}" not found`, "error");
186					return;
187				}
188				const content = readPersonalityContent(arg);
189				if (!content) {
190					ctx.ui.notify(`Personality "${arg}" could not be read`, "error");
191					return;
192				}
193				pendingReminder = wrapPersonality(content);
194				setActive(arg);
195				ctx.ui.notify(`Set personality to "${arg}"`, "info");
196				return;
197			}
198
199			// /personality — interactive picker
200			const available = listPersonalities();
201			if (available.length === 0) {
202				ctx.ui.notify("No personalities available", "info");
203				return;
204			}
205
206			const options = available.map((name) => (name === activePersonality ? `${name} (active)` : name));
207			const unsetLabel = activePersonality ? "unset" : "unset (active)";
208			options.unshift(unsetLabel);
209
210			const choice = await ctx.ui.select("Pick a personality:", options);
211
212			if (!choice) return; // cancelled
213
214			// Strip " (active)" suffix to get the real name
215			const picked = choice.replace(/ \(active\)$/, "");
216
217			if (picked === "unset") {
218				if (!activePersonality) {
219					ctx.ui.notify("No personality is active", "info");
220				} else {
221					const was = activePersonality;
222					pendingReminder = `Personality "${was}" has been cleared. Revert to your default behaviour.`;
223					setActive(null);
224					ctx.ui.notify(`Personality cleared (was: ${was})`, "info");
225				}
226				return;
227			}
228
229			const content = readPersonalityContent(picked);
230			if (!content) {
231				ctx.ui.notify(`Personality "${picked}" could not be read`, "error");
232				return;
233			}
234			pendingReminder = wrapPersonality(content);
235			setActive(picked);
236			ctx.ui.notify(`Set personality to "${picked}"`, "info");
237		},
238	});
239}