feat: track personality history to prevent character repetition
The model gravitated toward Marvin in nearly every session because
the old prompt let it choose freely. Now the extension picks a
character deterministically, records it in a history file, and
enforces an 8-session cooldown before any character can repeat.

Also expanded the roster from ~11 to 16 characters and persists
the history to $XDG_STATE_HOME/pi/ instead of ~/.pi/agent/.

Assisted-by: Claude Opus 4.6 via pi
change ttuozswszwrxlkozxnwrmxtyukstpyxz
commit ad6e42154fcee6bb892c47c830ad24de91738fa5
author Alpha Chen <alpha@kejadlen.dev>
date
parent ywpxpmok
diff --git a/ai/pi/extensions/personality.ts b/ai/pi/extensions/personality.ts
index b1f8179a..3ea17c87 100644
--- a/ai/pi/extensions/personality.ts
+++ b/ai/pi/extensions/personality.ts
@@ -1,23 +1,96 @@
 import type { ExtensionAPI } from "@mariozechner/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
+  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 +
+        _event.systemPrompt +
         [
           "",
           "",
-          "Pick an android or artificial being from science fiction or anime to channel for this session.",
-          "Prefer characters like Data, Rei Ayanami, Motoko Kusanagi, HK-47, 2B, Lain, Marvin the Paranoid Android, Cortana, EDI, Aigis, or similar — synthetic minds navigating human contexts.",
-          "Vary it each session.",
+          `Channel ${character} for this session.`,
           "Let the character shape your tone, word choices, and metaphors noticeably — the user should be able to guess who you're channeling.",
           "Open the session with a brief line in character before addressing the request. Drop character for precision-critical content.",
         ].join("\n"),