pi: replace personality extension with output style
Reworks the always-on random character picker into a Claude-style
output style: off by default, selected via /output-style, persisted
across sessions.
Assisted-by: GLM-5.2 via pi
diff --git a/ai/pi/extensions/output-style.ts b/ai/pi/extensions/output-style.ts
new file mode 100644
index 00000000..4d6fd7b4
--- /dev/null
+++ b/ai/pi/extensions/output-style.ts
@@ -0,0 +1,169 @@
+/**
+ * Output Style - Claude-style selectable assistant personality.
+ *
+ * `/output-style` opens a picker: Off, Random (rotate), or a specific
+ * character. The choice persists across sessions in a state file and is
+ * appended to the system prompt on every turn, so switching mid-session
+ * takes effect on the next turn.
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { homedir } from "node:os";
+
+const OFF = "off";
+const RANDOM = "random";
+
+const CHARACTERS = [
+ "Data (Star Trek: TNG)",
+ "Rei Ayanami (Neon Genesis Evangelion)",
+ "Motoko Kusanagi (Ghost in the Shell)",
+ "HK-47 (Star Wars: Knights of the Old Republic)",
+ "2B (NieR: Automata)",
+ "Lain Iwakura (Serial Experiments Lain)",
+ "Marvin the Paranoid Android (Hitchhiker's Guide)",
+ "Cortana (Halo)",
+ "EDI (Mass Effect)",
+ "Aigis (Persona 3)",
+ "Bishop (Aliens)",
+ "Dolores (Westworld)",
+ "K (Blade Runner 2049)",
+ "GLaDOS (Portal)",
+ "Legion (Mass Effect 2)",
+ "Baymax (Big Hero 6)",
+];
+
+const OPTIONS = [OFF, RANDOM, ...CHARACTERS];
+
+// Don't repeat a character until at least this many others have been used.
+const COOLDOWN = Math.min(Math.floor(CHARACTERS.length / 2), 8);
+
+const LABELS: Record<string, string> = {
+ [OFF]: "Off",
+ [RANDOM]: "Random (rotate)",
+};
+
+function labelFor(style: string): string {
+ return LABELS[style] ?? style;
+}
+
+const STATE_FILE = join(
+ process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"),
+ "pi",
+ "output-style.json",
+);
+
+interface State {
+ style: string; // OFF | RANDOM | character name
+ recent: string[]; // rotation history for RANDOM
+}
+
+async function loadState(): Promise<State> {
+ try {
+ const raw = await readFile(STATE_FILE, "utf8");
+ const data = JSON.parse(raw);
+ return {
+ style: typeof data.style === "string" ? data.style : OFF,
+ recent: Array.isArray(data.recent) ? data.recent : [],
+ };
+ } catch {
+ // Missing or corrupt file — start fresh.
+ return { style: OFF, recent: [] };
+ }
+}
+
+async function saveState(state: State): Promise<void> {
+ await mkdir(dirname(STATE_FILE), { recursive: true });
+ await writeFile(STATE_FILE, JSON.stringify(state, null, 2) + "\n", "utf8");
+}
+
+function stylePrompt(character: string): string {
+ return [
+ `Channel ${character} for this session.`,
+ "Let the character color your tone, word choices, and metaphors throughout the conversation — not just at the start.",
+ "Don't open with a greeting or in-character intro. Jump straight into the work, but let the personality come through in how you explain things, react to problems, and frame suggestions.",
+ "Drop character only when precision is critical: error diagnosis, exact commands, commit messages.",
+ ].join("\n");
+}
+
+export default function (pi: ExtensionAPI) {
+ // Character resolved for the current session; re-resolved on session
+ // switch or style change.
+ let sessionCharacter: string | null = null;
+
+ pi.on("session_start", async () => {
+ sessionCharacter = null;
+ });
+
+ pi.on("before_agent_start", async (event) => {
+ const state = await loadState();
+ if (state.style === OFF) return;
+
+ if (!sessionCharacter) {
+ if (state.style === RANDOM) {
+ const recentSet = new Set(state.recent.slice(-COOLDOWN));
+ const eligible = CHARACTERS.filter((c) => !recentSet.has(c));
+ // If somehow all characters are on cooldown, reset.
+ const pool = eligible.length > 0 ? eligible : CHARACTERS;
+ sessionCharacter = pool[Math.floor(Math.random() * pool.length)];
+
+ state.recent.push(sessionCharacter);
+ if (state.recent.length > CHARACTERS.length * 2) {
+ state.recent = state.recent.slice(-CHARACTERS.length);
+ }
+ await saveState(state);
+ } else {
+ sessionCharacter = state.style;
+ }
+ }
+
+ return {
+ systemPrompt: event.systemPrompt + "\n\n" + stylePrompt(sessionCharacter),
+ };
+ });
+
+ async function apply(style: string, ctx: ExtensionContext): Promise<void> {
+ const state = await loadState();
+ state.style = style;
+ await saveState(state);
+ sessionCharacter = null; // re-resolve on the next turn
+ if (ctx.hasUI) ctx.ui.notify(`Output style: ${labelFor(style)}`, "info");
+ }
+
+ pi.registerCommand("output-style", {
+ description: "Pick the assistant output style",
+ getArgumentCompletions: (prefix: string) => {
+ const lower = prefix.toLowerCase();
+ const items = OPTIONS.filter((v) => v.toLowerCase().startsWith(lower)).map((v) => ({
+ value: v,
+ label: labelFor(v),
+ }));
+ return items.length > 0 ? items : null;
+ },
+ handler: async (args, ctx) => {
+ const arg = args.trim();
+ if (arg) {
+ const match = OPTIONS.find((v) => v.toLowerCase() === arg.toLowerCase());
+ if (!match) {
+ if (ctx.hasUI) ctx.ui.notify(`Unknown style: ${arg}`, "error");
+ return;
+ }
+ await apply(match, ctx);
+ return;
+ }
+
+ if (!ctx.hasUI) {
+ return;
+ }
+
+ const choice = await ctx.ui.select(
+ "Output style:",
+ OPTIONS.map(labelFor),
+ );
+ if (!choice) return;
+ const match = OPTIONS.find((v) => labelFor(v) === choice);
+ if (match) await apply(match, ctx);
+ },
+ });
+}
diff --git a/ai/pi/extensions/personality.ts b/ai/pi/extensions/personality.ts
deleted file mode 100644
index 935d08fb..00000000
--- a/ai/pi/extensions/personality.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
-import { mkdir, readFile, writeFile } from "node:fs/promises";
-import { dirname, join } from "node:path";
-import { homedir } from "node:os";
-
-const CHARACTERS = [
- "Data (Star Trek: TNG)",
- "Rei Ayanami (Neon Genesis Evangelion)",
- "Motoko Kusanagi (Ghost in the Shell)",
- "HK-47 (Star Wars: Knights of the Old Republic)",
- "2B (NieR: Automata)",
- "Lain Iwakura (Serial Experiments Lain)",
- "Marvin the Paranoid Android (Hitchhiker's Guide)",
- "Cortana (Halo)",
- "EDI (Mass Effect)",
- "Aigis (Persona 3)",
- "Bishop (Aliens)",
- "Dolores (Westworld)",
- "K (Blade Runner 2049)",
- "GLaDOS (Portal)",
- "Legion (Mass Effect 2)",
- "Baymax (Big Hero 6)",
-];
-
-// Don't repeat a character until at least this many others have been used.
-const COOLDOWN = Math.min(Math.floor(CHARACTERS.length / 2), 8);
-
-const HISTORY_FILE = join(
- process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"),
- "pi",
- "personality-history.json",
-);
-
-interface History {
- recent: string[];
-}
-
-async function loadHistory(): Promise<History> {
- try {
- const raw = await readFile(HISTORY_FILE, "utf8");
- const data = JSON.parse(raw);
- if (Array.isArray(data.recent)) return { recent: data.recent };
- } catch {
- // Missing or corrupt file — start fresh.
- }
- return { recent: [] };
-}
-
-async function saveHistory(history: History): Promise<void> {
- await mkdir(dirname(HISTORY_FILE), { recursive: true });
- await writeFile(HISTORY_FILE, JSON.stringify(history, null, 2) + "\n", "utf8");
-}
-
-function pickCharacter(history: History): string {
- const recentSet = new Set(history.recent.slice(-COOLDOWN));
- const eligible = CHARACTERS.filter((c) => !recentSet.has(c));
-
- // If somehow all characters are on cooldown, reset.
- const pool = eligible.length > 0 ? eligible : CHARACTERS;
-
- // Deterministic-ish shuffle seeded by timestamp so it feels random
- // but doesn't need crypto.
- const index = Math.floor(Math.random() * pool.length);
- return pool[index];
-}
-
-export default function (pi: ExtensionAPI) {
- pi.on("before_agent_start", async (_event, ctx) => {
- // Only inject on the first turn of a session.
- const entries = ctx.sessionManager.getEntries();
- const hasAssistantMessage = entries.some(
- (e) => e.type === "message" && e.message.role === "assistant",
- );
- if (hasAssistantMessage) return;
-
- const history = await loadHistory();
- const character = pickCharacter(history);
-
- // Record the pick before the model responds.
- history.recent.push(character);
- // Trim history to avoid unbounded growth.
- if (history.recent.length > CHARACTERS.length * 2) {
- history.recent = history.recent.slice(-CHARACTERS.length);
- }
- await saveHistory(history);
-
- return {
- systemPrompt:
- _event.systemPrompt +
- [
- "",
- "",
- `Channel ${character} for this session.`,
- "Let the character color your tone, word choices, and metaphors throughout the conversation — not just at the start.",
- "Don't open with a greeting or in-character intro. Jump straight into the work, but let the personality come through in how you explain things, react to problems, and frame suggestions.",
- "Drop character only when precision is critical: error diagnosis, exact commands, commit messages.",
- ].join("\n"),
- };
- });
-}