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}