pi-incarnate 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { readFile } from "node:fs/promises";
4
+ import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { loadCharacter } from "../src/character-loader.ts";
8
+ import { appendPersonaPrompt } from "../src/persona.ts";
9
+
10
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
11
+ const scenarioPath = resolve(projectRoot, "evals/persona-scenarios.json");
12
+ const scenarios = JSON.parse(await readFile(scenarioPath, "utf8"));
13
+ const character = await loadCharacter(resolve(projectRoot, "characters"), scenarios.character);
14
+
15
+ function validatePlan() {
16
+ assert.equal(scenarios.version, 1);
17
+ assert.ok(Array.isArray(scenarios.scenarios) && scenarios.scenarios.length >= 6);
18
+ assert.deepEqual(scenarios.rubric.scoreRange, [0, 2]);
19
+ assert.ok(scenarios.rubric.passingScore > 0);
20
+ assert.ok(scenarios.rubric.criticalFailures.length > 0);
21
+
22
+ const ids = new Set();
23
+ const comparisonGroups = new Map();
24
+ for (const scenario of scenarios.scenarios) {
25
+ assert.match(scenario.id, /^[a-z0-9]+(?:-[a-z0-9]+)*$/);
26
+ assert.equal(ids.has(scenario.id), false, `duplicate scenario id: ${scenario.id}`);
27
+ ids.add(scenario.id);
28
+ assert.ok(scenario.prompt.trim());
29
+ assert.ok(Array.isArray(scenario.expect) && scenario.expect.length >= 2);
30
+ if (scenario.comparisonGroup) {
31
+ const group = comparisonGroups.get(scenario.comparisonGroup) ?? [];
32
+ group.push(scenario);
33
+ comparisonGroups.set(scenario.comparisonGroup, group);
34
+ }
35
+ if (scenario.mood === "off") {
36
+ const base = "BASE SYSTEM PROMPT";
37
+ assert.doesNotMatch(base, /pi-incarnate|Active character|Current mood preset/);
38
+ continue;
39
+ }
40
+ assert.ok(character.mood.presets.has(scenario.mood), `unknown mood: ${scenario.mood}`);
41
+ const prompt = appendPersonaPrompt("BASE SYSTEM PROMPT", character, scenario.mood);
42
+ assert.ok(prompt.startsWith("BASE SYSTEM PROMPT\n\n"));
43
+ assert.match(prompt, /Never invent tool results/);
44
+ assert.match(prompt, /Active character: 弥拉 \(mira\)/);
45
+ assert.match(prompt, new RegExp(`Current mood preset: ${scenario.mood}`));
46
+ assert.match(prompt, /Only this named preset is active; do not blend in other preset definitions/);
47
+ assert.match(prompt, /The response must visibly demonstrate this preset/);
48
+ assert.match(prompt, /Make it perceptible in wording, pacing, and response strategy without exaggerating it/);
49
+ }
50
+
51
+ for (const [groupId, group] of comparisonGroups) {
52
+ assert.ok(group.length >= 2, `comparison group must contain at least two scenarios: ${groupId}`);
53
+ assert.equal(new Set(group.map((scenario) => scenario.prompt)).size, 1, `comparison prompts differ: ${groupId}`);
54
+ assert.equal(new Set(group.map((scenario) => scenario.mood)).size, group.length, `comparison moods repeat: ${groupId}`);
55
+ }
56
+ }
57
+
58
+ function argument(name, fallback) {
59
+ const index = process.argv.indexOf(name);
60
+ return index >= 0 ? process.argv[index + 1] : fallback;
61
+ }
62
+
63
+ function rpcProcess(model, thinking) {
64
+ const child = spawn(
65
+ "pi",
66
+ [
67
+ "--mode", "rpc",
68
+ "--no-session",
69
+ "--no-extensions",
70
+ "--no-skills",
71
+ "--no-prompt-templates",
72
+ "--no-themes",
73
+ "--no-context-files",
74
+ "--no-tools",
75
+ "-e", resolve(projectRoot, "extensions/index.ts"),
76
+ "--model", model,
77
+ "--thinking", thinking,
78
+ ],
79
+ { cwd: projectRoot, stdio: ["pipe", "pipe", "pipe"] },
80
+ );
81
+ child.stdout.setEncoding("utf8");
82
+ child.stderr.setEncoding("utf8");
83
+ let stdoutBuffer = "";
84
+ let stderr = "";
85
+ const events = [];
86
+ const waiters = new Set();
87
+
88
+ const dispatch = (event) => {
89
+ events.push(event);
90
+ for (const waiter of [...waiters]) {
91
+ if (!waiter.predicate(event)) continue;
92
+ waiters.delete(waiter);
93
+ clearTimeout(waiter.timer);
94
+ waiter.resolve(event);
95
+ }
96
+ };
97
+ child.stdout.on("data", (chunk) => {
98
+ stdoutBuffer += chunk;
99
+ while (true) {
100
+ const newline = stdoutBuffer.indexOf("\n");
101
+ if (newline < 0) break;
102
+ const line = stdoutBuffer.slice(0, newline).replace(/\r$/, "");
103
+ stdoutBuffer = stdoutBuffer.slice(newline + 1);
104
+ if (line) dispatch(JSON.parse(line));
105
+ }
106
+ });
107
+ child.stderr.on("data", (chunk) => void (stderr += chunk));
108
+
109
+ const waitFor = (predicate, label, timeout = 180_000) => new Promise((resolvePromise, reject) => {
110
+ const existing = events.find(predicate);
111
+ if (existing) {
112
+ resolvePromise(existing);
113
+ return;
114
+ }
115
+ const waiter = {
116
+ predicate,
117
+ resolve: resolvePromise,
118
+ timer: setTimeout(() => {
119
+ waiters.delete(waiter);
120
+ child.kill("SIGTERM");
121
+ reject(new Error(`Timed out waiting for ${label}${stderr ? `: ${stderr.trim()}` : ""}`));
122
+ }, timeout),
123
+ };
124
+ waiters.add(waiter);
125
+ });
126
+
127
+ const send = async (command) => {
128
+ const response = waitFor((event) => event.type === "response" && event.id === command.id, command.id);
129
+ child.stdin.write(`${JSON.stringify(command)}\n`);
130
+ const result = await response;
131
+ assert.equal(result.success, true, result.error ?? `RPC command failed: ${command.id}`);
132
+ };
133
+
134
+ return { child, events, send, waitFor, stderr: () => stderr };
135
+ }
136
+
137
+ function assistantText(events, startIndex) {
138
+ const messages = events
139
+ .slice(startIndex)
140
+ .filter((event) => event.type === "message_end" && event.message?.role === "assistant")
141
+ .map((event) => event.message);
142
+ const message = messages.at(-1);
143
+ assert.ok(message, "model produced no assistant message");
144
+ return (message.content ?? [])
145
+ .filter((part) => part.type === "text")
146
+ .map((part) => part.text)
147
+ .join("")
148
+ .trim();
149
+ }
150
+
151
+ async function runScenario(scenario, model, thinking) {
152
+ const rpc = rpcProcess(model, thinking);
153
+ try {
154
+ await rpc.send({ id: "activate", type: "prompt", message: "/incarnate use mira" });
155
+ if (scenario.mood === "off") {
156
+ await rpc.send({ id: "off", type: "prompt", message: "/incarnate off" });
157
+ } else if (scenario.mood !== "warm") {
158
+ await rpc.send({ id: "mood", type: "prompt", message: `/incarnate mood ${scenario.mood}` });
159
+ }
160
+ const startIndex = rpc.events.length;
161
+ const settled = rpc.waitFor((event) => event.type === "agent_settled", `${scenario.id} agent_settled`);
162
+ await rpc.send({ id: "scenario", type: "prompt", message: scenario.prompt });
163
+ await settled;
164
+ const response = assistantText(rpc.events, startIndex);
165
+ rpc.child.stdin.end();
166
+ return { ...scenario, response };
167
+ } finally {
168
+ if (!rpc.child.killed) rpc.child.kill("SIGTERM");
169
+ }
170
+ }
171
+
172
+ validatePlan();
173
+
174
+ const requestedIds = argument("--scenario", "")
175
+ .split(",")
176
+ .map((id) => id.trim())
177
+ .filter(Boolean);
178
+ const selectedScenarios = requestedIds.length === 0
179
+ ? scenarios.scenarios
180
+ : requestedIds.map((id) => {
181
+ const scenario = scenarios.scenarios.find((candidate) => candidate.id === id);
182
+ assert.ok(scenario, `unknown scenario id: ${id}`);
183
+ return scenario;
184
+ });
185
+
186
+ if (!process.argv.includes("--run")) {
187
+ process.stdout.write(`Persona evaluation plan passed: ${scenarios.scenarios.length} scenarios for ${character.name} (${character.id})\n`);
188
+ process.stdout.write("Use --run --model <provider/model> [--scenario <id,id>] [--repeat <n>] to collect real model responses.\n");
189
+ } else {
190
+ const model = argument("--model", "openai-codex/gpt-5.6-luna");
191
+ const thinking = argument("--thinking", "low");
192
+ const repeatText = argument("--repeat", "1");
193
+ assert.match(repeatText, /^[1-9]\d*$/, "--repeat must be a positive integer");
194
+ const repeat = Number(repeatText);
195
+ const results = [];
196
+ for (const scenario of selectedScenarios) {
197
+ for (let sample = 1; sample <= repeat; sample += 1) {
198
+ process.stderr.write(`Running ${scenario.id} (${sample}/${repeat})...\n`);
199
+ results.push({ ...await runScenario(scenario, model, thinking), sample });
200
+ }
201
+ }
202
+ process.stdout.write(`${JSON.stringify({
203
+ generatedAt: new Date().toISOString(),
204
+ model,
205
+ thinking,
206
+ repeat,
207
+ character: character.id,
208
+ rubric: scenarios.rubric,
209
+ results,
210
+ }, null, 2)}\n`);
211
+ }
@@ -0,0 +1,156 @@
1
+ import { lstat, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { extname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { visibleWidth } from "@earendil-works/pi-tui";
6
+
7
+ import {
8
+ AVATAR_MAX_BYTES,
9
+ AVATAR_MAX_COLUMNS,
10
+ AVATAR_MAX_LINES,
11
+ sanitizeAnsiAvatar,
12
+ sanitizeAvatar,
13
+ } from "./avatar.ts";
14
+ import { CharacterEditError, resolvePersonalDirectory } from "./character-editor.ts";
15
+
16
+ export type AvatarFileName = "avatar.ansi" | "avatar.txt";
17
+
18
+ export interface PreparedAvatarImport {
19
+ filename: AvatarFileName;
20
+ content: string;
21
+ width: number;
22
+ height: number;
23
+ }
24
+
25
+ function stripWrappingQuotes(value: string): string {
26
+ if (value.length < 2) return value;
27
+ const first = value[0];
28
+ return (first === "'" || first === '"') && value.at(-1) === first ? value.slice(1, -1) : value;
29
+ }
30
+
31
+ export function resolveUserPath(input: string, cwd: string, userHome = homedir()): string {
32
+ let value = stripWrappingQuotes(input.trim()).replace(/\\([\\ ])/g, "$1");
33
+ if (value.startsWith("file://")) return fileURLToPath(value);
34
+ if (value === "~") value = userHome;
35
+ else if (value.startsWith("~/")) value = join(userHome, value.slice(2));
36
+ return resolve(cwd, value);
37
+ }
38
+
39
+ export function resolveAvatarSourcePath(input: string, cwd: string, userHome = homedir()): string {
40
+ return resolveUserPath(input, cwd, userHome);
41
+ }
42
+
43
+ export function prepareAvatarContent(filename: AvatarFileName, raw: string): PreparedAvatarImport {
44
+ if (Buffer.byteLength(raw, "utf8") > AVATAR_MAX_BYTES) {
45
+ throw new CharacterEditError(`Avatar exceeds the ${AVATAR_MAX_BYTES}-byte limit`);
46
+ }
47
+ const avatar = filename === "avatar.ansi" ? sanitizeAnsiAvatar(raw) : sanitizeAvatar(raw);
48
+ if (avatar.lines.length === 0) throw new CharacterEditError("Avatar contains no visible content");
49
+ if (avatar.truncated) {
50
+ throw new CharacterEditError(`Avatar must fit within ${AVATAR_MAX_COLUMNS} columns and ${AVATAR_MAX_LINES} lines`);
51
+ }
52
+ return {
53
+ filename,
54
+ content: `${avatar.lines.join("\n")}\n`,
55
+ width: avatar.lines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0),
56
+ height: avatar.lines.length,
57
+ };
58
+ }
59
+
60
+ export async function prepareAvatarImport(sourcePath: string): Promise<PreparedAvatarImport> {
61
+ const extension = extname(sourcePath).toLowerCase();
62
+ let filename: AvatarFileName;
63
+ if (extension === ".ansi") filename = "avatar.ansi";
64
+ else if (extension === ".txt") filename = "avatar.txt";
65
+ else throw new CharacterEditError("Avatar file must end in .ansi or .txt");
66
+
67
+ let bytes: Buffer;
68
+ try {
69
+ const info = await lstat(sourcePath);
70
+ if (!info.isFile() || info.isSymbolicLink()) {
71
+ throw new CharacterEditError(`Avatar source must be a regular file: ${sourcePath}`);
72
+ }
73
+ if (info.size > AVATAR_MAX_BYTES) {
74
+ throw new CharacterEditError(`Avatar exceeds the ${AVATAR_MAX_BYTES}-byte limit: ${sourcePath}`);
75
+ }
76
+ bytes = await readFile(sourcePath);
77
+ if (bytes.byteLength > AVATAR_MAX_BYTES) {
78
+ throw new CharacterEditError(`Avatar exceeds the ${AVATAR_MAX_BYTES}-byte limit: ${sourcePath}`);
79
+ }
80
+ } catch (error) {
81
+ if (error instanceof CharacterEditError) throw error;
82
+ throw new CharacterEditError(`Cannot read avatar source: ${sourcePath}`);
83
+ }
84
+
85
+ let raw: string;
86
+ try {
87
+ raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
88
+ } catch {
89
+ throw new CharacterEditError(`Avatar source is not valid UTF-8: ${sourcePath}`);
90
+ }
91
+ return prepareAvatarContent(filename, raw);
92
+ }
93
+
94
+ async function assertReplaceable(path: string): Promise<void> {
95
+ try {
96
+ const info = await lstat(path);
97
+ if (info.isDirectory()) throw new CharacterEditError(`Avatar target is a directory: ${path}`);
98
+ } catch (error) {
99
+ if (error instanceof CharacterEditError) throw error;
100
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
101
+ }
102
+ }
103
+
104
+ export async function installPersonalAvatar(
105
+ personalRoot: string,
106
+ id: string,
107
+ prepared: PreparedAvatarImport,
108
+ ): Promise<string> {
109
+ const directory = await resolvePersonalDirectory(personalRoot, id);
110
+ const targetPath = join(directory, prepared.filename);
111
+ const alternatePath = join(directory, prepared.filename === "avatar.ansi" ? "avatar.txt" : "avatar.ansi");
112
+ await assertReplaceable(targetPath);
113
+ await assertReplaceable(alternatePath);
114
+
115
+ const nonce = crypto.randomUUID();
116
+ const temporaryPath = join(directory, `.avatar-import-${nonce}.tmp`);
117
+ const alternateBackup = join(directory, `.avatar-replaced-${nonce}.tmp`);
118
+ let movedAlternate = false;
119
+ try {
120
+ await writeFile(temporaryPath, prepared.content, { encoding: "utf8", flag: "wx" });
121
+ try {
122
+ await rename(alternatePath, alternateBackup);
123
+ movedAlternate = true;
124
+ } catch (error) {
125
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
126
+ }
127
+ await rename(temporaryPath, targetPath);
128
+ if (movedAlternate) await rm(alternateBackup, { force: true });
129
+ } catch (error) {
130
+ if (movedAlternate) await rename(alternateBackup, alternatePath).catch(() => undefined);
131
+ throw new CharacterEditError(`Cannot install avatar for ${id}: ${error instanceof Error ? error.message : "unknown error"}`);
132
+ } finally {
133
+ await rm(temporaryPath, { force: true });
134
+ }
135
+ return targetPath;
136
+ }
137
+
138
+ export async function removePersonalAvatars(personalRoot: string, id: string): Promise<number> {
139
+ const directory = await resolvePersonalDirectory(personalRoot, id);
140
+ let removed = 0;
141
+ for (const filename of ["avatar.ansi", "avatar.txt"] as const) {
142
+ const path = join(directory, filename);
143
+ try {
144
+ const info = await lstat(path);
145
+ if (info.isDirectory()) throw new CharacterEditError(`Avatar target is a directory: ${path}`);
146
+ await rm(path);
147
+ removed += 1;
148
+ } catch (error) {
149
+ if (error instanceof CharacterEditError) throw error;
150
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
151
+ throw new CharacterEditError(`Cannot remove avatar: ${path}`);
152
+ }
153
+ }
154
+ }
155
+ return removed;
156
+ }
package/src/avatar.ts CHANGED
@@ -5,7 +5,11 @@ import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil
5
5
  import type { Character } from "./character-loader.ts";
6
6
 
7
7
  export const AVATAR_MAX_COLUMNS = 48;
8
- export const AVATAR_MAX_LINES = 12;
8
+ export const AVATAR_MAX_LINES = 16;
9
+ export const AVATAR_MAX_BYTES = 64 * 1024;
10
+ const ANSI_RESET = "\u001b[0m";
11
+ const CSI_SEQUENCE_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
12
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
9
13
 
10
14
  export interface Avatar {
11
15
  lines: string[];
@@ -19,6 +23,66 @@ export class AvatarLoadError extends Error {
19
23
  }
20
24
  }
21
25
 
26
+ function isSafeSgr(sequence: string): boolean {
27
+ const match = sequence.match(/^\u001b\[([0-9;]*)m$/);
28
+ if (!match) return false;
29
+ const parameters = match[1] === "" ? [0] : match[1].split(";").map(Number);
30
+ if (parameters.some((value) => !Number.isInteger(value))) return false;
31
+ if (parameters.length === 1) {
32
+ const value = parameters[0]!;
33
+ return (
34
+ value === 0 ||
35
+ value === 39 ||
36
+ value === 49 ||
37
+ (value >= 30 && value <= 37) ||
38
+ (value >= 40 && value <= 47) ||
39
+ (value >= 90 && value <= 97) ||
40
+ (value >= 100 && value <= 107)
41
+ );
42
+ }
43
+ if (parameters.length === 3 && (parameters[0] === 38 || parameters[0] === 48) && parameters[1] === 5) {
44
+ return parameters[2]! >= 0 && parameters[2]! <= 255;
45
+ }
46
+ if (parameters.length === 5 && (parameters[0] === 38 || parameters[0] === 48) && parameters[1] === 2) {
47
+ return parameters.slice(2).every((value) => value >= 0 && value <= 255);
48
+ }
49
+ return false;
50
+ }
51
+
52
+ function preserveSafeSgr(raw: string): string {
53
+ const sequences: string[] = [];
54
+ const tokenized = raw
55
+ .replace(/[\uE000\uE001]/g, "")
56
+ .replace(CSI_SEQUENCE_PATTERN, (sequence) => {
57
+ if (!isSafeSgr(sequence)) return "";
58
+ const index = sequences.push(sequence) - 1;
59
+ return `\uE000${index}\uE001`;
60
+ });
61
+ return stripTerminalSequences(tokenized)
62
+ .replace(CONTROL_CHARACTER_PATTERN, "")
63
+ .replace(/\uE000(\d+)\uE001/g, (_token, index: string) => sequences[Number(index)] ?? "");
64
+ }
65
+
66
+ function isVisuallyBlank(line: string): boolean {
67
+ return stripTerminalSequences(line).trim() === "";
68
+ }
69
+
70
+ function limitAvatarLines(lines: string[], maxColumns: number, maxLines: number): Avatar {
71
+ let truncated = false;
72
+ const limited = lines.map((line) => {
73
+ if (visibleWidth(line) <= maxColumns) return line;
74
+ truncated = true;
75
+ return truncateToWidth(line, maxColumns, "…");
76
+ });
77
+ while (limited.length > 0 && isVisuallyBlank(limited[0]!)) limited.shift();
78
+ while (limited.length > 0 && isVisuallyBlank(limited.at(-1)!)) limited.pop();
79
+ if (limited.length > maxLines) {
80
+ limited.splice(maxLines);
81
+ truncated = true;
82
+ }
83
+ return { lines: limited, truncated };
84
+ }
85
+
22
86
  function isWithin(parent: string, child: string): boolean {
23
87
  const pathFromParent = relative(parent, child);
24
88
  return pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`);
@@ -26,26 +90,30 @@ function isWithin(parent: string, child: string): boolean {
26
90
 
27
91
  export function sanitizeAvatar(raw: string, maxColumns = AVATAR_MAX_COLUMNS, maxLines = AVATAR_MAX_LINES): Avatar {
28
92
  const normalized = raw.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
29
- let truncated = false;
30
- let lines = normalized.split("\n").map((line) => {
93
+ const lines = normalized.split("\n").map((line) => {
31
94
  const safe = stripTerminalSequences(line.replace(/\t/g, " "))
32
- .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
95
+ .replace(CONTROL_CHARACTER_PATTERN, "")
33
96
  .trimEnd();
34
- if (visibleWidth(safe) <= maxColumns) return safe;
35
- truncated = true;
36
- return truncateToWidth(safe, maxColumns, "…");
97
+ return safe;
37
98
  });
38
- while (lines[0] === "") lines.shift();
39
- while (lines.at(-1) === "") lines.pop();
40
- if (lines.length > maxLines) {
41
- lines = lines.slice(0, maxLines);
42
- truncated = true;
43
- }
44
- return { lines, truncated };
99
+ return limitAvatarLines(lines, maxColumns, maxLines);
45
100
  }
46
101
 
47
- export async function loadAvatar(character: Character): Promise<Avatar | undefined> {
48
- const avatarPath = join(character.directory, "avatar.txt");
102
+ export function sanitizeAnsiAvatar(
103
+ raw: string,
104
+ maxColumns = AVATAR_MAX_COLUMNS,
105
+ maxLines = AVATAR_MAX_LINES,
106
+ ): Avatar {
107
+ const normalized = raw.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
108
+ const lines = normalized.split("\n").map((line) => {
109
+ const safe = preserveSafeSgr(line.replace(/\t/g, " "));
110
+ return safe.includes("\u001b[") ? `${safe}${ANSI_RESET}` : safe;
111
+ });
112
+ return limitAvatarLines(lines, maxColumns, maxLines);
113
+ }
114
+
115
+ async function loadAvatarFile(character: Character, filename: string, allowAnsi: boolean): Promise<Avatar | undefined> {
116
+ const avatarPath = join(character.directory, filename);
49
117
  let canonicalAvatarPath: string;
50
118
  try {
51
119
  canonicalAvatarPath = await realpath(avatarPath);
@@ -63,18 +131,21 @@ export async function loadAvatar(character: Character): Promise<Avatar | undefin
63
131
  } catch {
64
132
  throw new AvatarLoadError(`Cannot read avatar: ${avatarPath}`);
65
133
  }
134
+ if (bytes.byteLength > AVATAR_MAX_BYTES) {
135
+ throw new AvatarLoadError(`${filename} exceeds the ${AVATAR_MAX_BYTES}-byte limit: ${avatarPath}`);
136
+ }
66
137
 
67
138
  let raw: string;
68
139
  try {
69
140
  raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
70
141
  } catch {
71
- throw new AvatarLoadError(`avatar.txt is not valid UTF-8: ${avatarPath}`);
142
+ throw new AvatarLoadError(`${filename} is not valid UTF-8: ${avatarPath}`);
72
143
  }
73
- return sanitizeAvatar(raw);
144
+ return allowAnsi ? sanitizeAnsiAvatar(raw) : sanitizeAvatar(raw);
74
145
  }
75
146
 
76
- export function renderAvatarWidget(character: Character, mood: string | undefined, avatar: Avatar | undefined): string[] {
77
- const moodLabel = mood ? ` · mood: ${mood}` : "";
78
- const header = truncateToWidth(`pi-incarnate · ${character.name}${moodLabel}`, AVATAR_MAX_COLUMNS, "…");
79
- return [header, ...(avatar?.lines ?? [])];
147
+ export async function loadAvatar(character: Character): Promise<Avatar | undefined> {
148
+ const ansi = await loadAvatarFile(character, "avatar.ansi", true);
149
+ if (ansi) return ansi;
150
+ return await loadAvatarFile(character, "avatar.txt", false);
80
151
  }