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}