index.ts

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