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
change zkyplvkwtzqrolrpqryvznnyrlntwvwn
commit 67f3bdcdac2bcff282a7bb7fca7613cb90807a2a
author Alpha Chen <alpha@kejadlen.dev>
date
parent kmkwztou
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"),
-    };
-  });
-}