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}