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
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"),