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