pi-incarnate 0.1.0 → 0.3.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.
- package/CHANGELOG.md +34 -0
- package/README.md +117 -23
- package/SECURITY.md +11 -3
- package/characters/README.md +5 -1
- package/characters/mira/CHARACTER.md +1 -1
- package/evals/persona-scenarios.json +104 -0
- package/extensions/index.ts +34 -17
- package/package.json +5 -1
- package/scripts/persona-eval.mjs +211 -0
- package/src/avatar-manager.ts +198 -0
- package/src/avatar.ts +230 -22
- package/src/character-bundle.ts +383 -0
- package/src/character-catalog.ts +69 -0
- package/src/character-editor.ts +199 -0
- package/src/character-lifecycle.ts +94 -0
- package/src/character-loader.ts +36 -12
- package/src/character-section-editor.ts +120 -0
- package/src/commands.ts +45 -20
- package/src/form-editor.ts +115 -0
- package/src/markdown.ts +12 -0
- package/src/menu.ts +753 -0
- package/src/mood.ts +1 -1
- package/src/persona.ts +3 -1
- package/src/select-menu.ts +70 -0
- package/src/session-state.ts +17 -5
- package/src/ui-protocol.ts +74 -0
|
@@ -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,198 @@
|
|
|
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
|
+
AVATAR_PNG_MAX_BYTES,
|
|
12
|
+
sanitizeAnsiAvatar,
|
|
13
|
+
sanitizeAvatar,
|
|
14
|
+
validatePngAvatar,
|
|
15
|
+
AvatarLoadError,
|
|
16
|
+
} from "./avatar.ts";
|
|
17
|
+
import { CharacterEditError, resolvePersonalDirectory } from "./character-editor.ts";
|
|
18
|
+
|
|
19
|
+
export type TextAvatarFileName = "avatar.ansi" | "avatar.txt";
|
|
20
|
+
export type AvatarFileName = "avatar.png" | TextAvatarFileName;
|
|
21
|
+
|
|
22
|
+
export interface PreparedTextAvatarImport {
|
|
23
|
+
kind: "text";
|
|
24
|
+
filename: TextAvatarFileName;
|
|
25
|
+
content: string;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PreparedPngAvatarImport {
|
|
31
|
+
kind: "png";
|
|
32
|
+
filename: "avatar.png";
|
|
33
|
+
content: Buffer;
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type PreparedAvatarImport = PreparedTextAvatarImport | PreparedPngAvatarImport;
|
|
39
|
+
|
|
40
|
+
function stripWrappingQuotes(value: string): string {
|
|
41
|
+
if (value.length < 2) return value;
|
|
42
|
+
const first = value[0];
|
|
43
|
+
return (first === "'" || first === '"') && value.at(-1) === first ? value.slice(1, -1) : value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveUserPath(input: string, cwd: string, userHome = homedir()): string {
|
|
47
|
+
let value = stripWrappingQuotes(input.trim()).replace(/\\([\\ ])/g, "$1");
|
|
48
|
+
if (value.startsWith("file://")) return fileURLToPath(value);
|
|
49
|
+
if (value === "~") value = userHome;
|
|
50
|
+
else if (value.startsWith("~/")) value = join(userHome, value.slice(2));
|
|
51
|
+
return resolve(cwd, value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveAvatarSourcePath(input: string, cwd: string, userHome = homedir()): string {
|
|
55
|
+
return resolveUserPath(input, cwd, userHome);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function prepareAvatarContent(filename: TextAvatarFileName, raw: string): PreparedTextAvatarImport {
|
|
59
|
+
if (Buffer.byteLength(raw, "utf8") > AVATAR_MAX_BYTES) {
|
|
60
|
+
throw new CharacterEditError(`Avatar exceeds the ${AVATAR_MAX_BYTES}-byte limit`);
|
|
61
|
+
}
|
|
62
|
+
const avatar = filename === "avatar.ansi" ? sanitizeAnsiAvatar(raw) : sanitizeAvatar(raw);
|
|
63
|
+
if (avatar.lines.length === 0) throw new CharacterEditError("Avatar contains no visible content");
|
|
64
|
+
if (avatar.truncated) {
|
|
65
|
+
throw new CharacterEditError(`Avatar must fit within ${AVATAR_MAX_COLUMNS} columns and ${AVATAR_MAX_LINES} lines`);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
kind: "text",
|
|
69
|
+
filename,
|
|
70
|
+
content: `${avatar.lines.join("\n")}\n`,
|
|
71
|
+
width: avatar.lines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0),
|
|
72
|
+
height: avatar.lines.length,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function preparePngAvatarContent(bytes: Uint8Array): PreparedPngAvatarImport {
|
|
77
|
+
try {
|
|
78
|
+
const image = validatePngAvatar(bytes);
|
|
79
|
+
return {
|
|
80
|
+
kind: "png",
|
|
81
|
+
filename: "avatar.png",
|
|
82
|
+
content: Buffer.from(bytes),
|
|
83
|
+
width: image.widthPx,
|
|
84
|
+
height: image.heightPx,
|
|
85
|
+
};
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof AvatarLoadError) throw new CharacterEditError(error.message);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function prepareAvatarImport(sourcePath: string): Promise<PreparedAvatarImport> {
|
|
93
|
+
const extension = extname(sourcePath).toLowerCase();
|
|
94
|
+
let filename: AvatarFileName;
|
|
95
|
+
let maximumBytes: number;
|
|
96
|
+
if (extension === ".ansi") filename = "avatar.ansi";
|
|
97
|
+
else if (extension === ".txt") filename = "avatar.txt";
|
|
98
|
+
else if (extension === ".png") filename = "avatar.png";
|
|
99
|
+
else throw new CharacterEditError("Avatar file must end in .png, .ansi, or .txt");
|
|
100
|
+
maximumBytes = filename === "avatar.png" ? AVATAR_PNG_MAX_BYTES : AVATAR_MAX_BYTES;
|
|
101
|
+
|
|
102
|
+
let bytes: Buffer;
|
|
103
|
+
try {
|
|
104
|
+
const info = await lstat(sourcePath);
|
|
105
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
106
|
+
throw new CharacterEditError(`Avatar source must be a regular file: ${sourcePath}`);
|
|
107
|
+
}
|
|
108
|
+
if (info.size > maximumBytes) {
|
|
109
|
+
throw new CharacterEditError(`Avatar exceeds the ${maximumBytes}-byte limit: ${sourcePath}`);
|
|
110
|
+
}
|
|
111
|
+
bytes = await readFile(sourcePath);
|
|
112
|
+
if (bytes.byteLength > maximumBytes) {
|
|
113
|
+
throw new CharacterEditError(`Avatar exceeds the ${maximumBytes}-byte limit: ${sourcePath}`);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof CharacterEditError) throw error;
|
|
117
|
+
throw new CharacterEditError(`Cannot read avatar source: ${sourcePath}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (filename === "avatar.png") return preparePngAvatarContent(bytes);
|
|
121
|
+
|
|
122
|
+
let raw: string;
|
|
123
|
+
try {
|
|
124
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new CharacterEditError(`Avatar source is not valid UTF-8: ${sourcePath}`);
|
|
127
|
+
}
|
|
128
|
+
return prepareAvatarContent(filename, raw);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function assertReplaceable(path: string): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
const info = await lstat(path);
|
|
134
|
+
if (info.isDirectory()) throw new CharacterEditError(`Avatar target is a directory: ${path}`);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof CharacterEditError) throw error;
|
|
137
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function installPersonalAvatar(
|
|
142
|
+
personalRoot: string,
|
|
143
|
+
id: string,
|
|
144
|
+
prepared: PreparedAvatarImport,
|
|
145
|
+
): Promise<string> {
|
|
146
|
+
const directory = await resolvePersonalDirectory(personalRoot, id);
|
|
147
|
+
const targetPath = join(directory, prepared.filename);
|
|
148
|
+
const alternatePath = prepared.kind === "text"
|
|
149
|
+
? join(directory, prepared.filename === "avatar.ansi" ? "avatar.txt" : "avatar.ansi")
|
|
150
|
+
: undefined;
|
|
151
|
+
await assertReplaceable(targetPath);
|
|
152
|
+
if (alternatePath) await assertReplaceable(alternatePath);
|
|
153
|
+
|
|
154
|
+
const nonce = crypto.randomUUID();
|
|
155
|
+
const temporaryPath = join(directory, `.avatar-import-${nonce}.tmp`);
|
|
156
|
+
const alternateBackup = join(directory, `.avatar-replaced-${nonce}.tmp`);
|
|
157
|
+
let movedAlternate = false;
|
|
158
|
+
try {
|
|
159
|
+
if (prepared.kind === "png") await writeFile(temporaryPath, prepared.content, { flag: "wx" });
|
|
160
|
+
else await writeFile(temporaryPath, prepared.content, { encoding: "utf8", flag: "wx" });
|
|
161
|
+
if (alternatePath) {
|
|
162
|
+
try {
|
|
163
|
+
await rename(alternatePath, alternateBackup);
|
|
164
|
+
movedAlternate = true;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
await rename(temporaryPath, targetPath);
|
|
170
|
+
if (movedAlternate) await rm(alternateBackup, { force: true });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (movedAlternate && alternatePath) await rename(alternateBackup, alternatePath).catch(() => undefined);
|
|
173
|
+
throw new CharacterEditError(`Cannot install avatar for ${id}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
174
|
+
} finally {
|
|
175
|
+
await rm(temporaryPath, { force: true });
|
|
176
|
+
}
|
|
177
|
+
return targetPath;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function removePersonalAvatars(personalRoot: string, id: string): Promise<number> {
|
|
181
|
+
const directory = await resolvePersonalDirectory(personalRoot, id);
|
|
182
|
+
let removed = 0;
|
|
183
|
+
for (const filename of ["avatar.png", "avatar.ansi", "avatar.txt"] as const) {
|
|
184
|
+
const path = join(directory, filename);
|
|
185
|
+
try {
|
|
186
|
+
const info = await lstat(path);
|
|
187
|
+
if (info.isDirectory()) throw new CharacterEditError(`Avatar target is a directory: ${path}`);
|
|
188
|
+
await rm(path);
|
|
189
|
+
removed += 1;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error instanceof CharacterEditError) throw error;
|
|
192
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
193
|
+
throw new CharacterEditError(`Cannot remove avatar: ${path}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return removed;
|
|
198
|
+
}
|