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
package/src/avatar.ts
CHANGED
|
@@ -5,11 +5,33 @@ 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 =
|
|
8
|
+
export const AVATAR_MAX_LINES = 16;
|
|
9
|
+
export const AVATAR_MAX_BYTES = 64 * 1024;
|
|
10
|
+
export const AVATAR_PNG_MAX_BYTES = 512 * 1024;
|
|
11
|
+
export const AVATAR_PNG_MAX_DIMENSION = 2048;
|
|
12
|
+
export const AVATAR_PNG_MAX_PIXELS = 2048 * 2048;
|
|
13
|
+
const ANSI_RESET = "\u001b[0m";
|
|
14
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
15
|
+
const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
16
|
+
let crc = value;
|
|
17
|
+
for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
|
18
|
+
return crc >>> 0;
|
|
19
|
+
});
|
|
20
|
+
const CSI_SEQUENCE_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
21
|
+
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
|
9
22
|
|
|
10
23
|
export interface Avatar {
|
|
11
24
|
lines: string[];
|
|
12
25
|
truncated: boolean;
|
|
26
|
+
image?: AvatarImage;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AvatarImage {
|
|
30
|
+
mimeType: "image/png";
|
|
31
|
+
data: string;
|
|
32
|
+
widthPx: number;
|
|
33
|
+
heightPx: number;
|
|
34
|
+
bytes: number;
|
|
13
35
|
}
|
|
14
36
|
|
|
15
37
|
export class AvatarLoadError extends Error {
|
|
@@ -19,33 +41,185 @@ export class AvatarLoadError extends Error {
|
|
|
19
41
|
}
|
|
20
42
|
}
|
|
21
43
|
|
|
44
|
+
function isSafeSgr(sequence: string): boolean {
|
|
45
|
+
const match = sequence.match(/^\u001b\[([0-9;]*)m$/);
|
|
46
|
+
if (!match) return false;
|
|
47
|
+
const parameters = match[1] === "" ? [0] : match[1].split(";").map(Number);
|
|
48
|
+
if (parameters.some((value) => !Number.isInteger(value))) return false;
|
|
49
|
+
if (parameters.length === 1) {
|
|
50
|
+
const value = parameters[0]!;
|
|
51
|
+
return (
|
|
52
|
+
value === 0 ||
|
|
53
|
+
value === 39 ||
|
|
54
|
+
value === 49 ||
|
|
55
|
+
(value >= 30 && value <= 37) ||
|
|
56
|
+
(value >= 40 && value <= 47) ||
|
|
57
|
+
(value >= 90 && value <= 97) ||
|
|
58
|
+
(value >= 100 && value <= 107)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (parameters.length === 3 && (parameters[0] === 38 || parameters[0] === 48) && parameters[1] === 5) {
|
|
62
|
+
return parameters[2]! >= 0 && parameters[2]! <= 255;
|
|
63
|
+
}
|
|
64
|
+
if (parameters.length === 5 && (parameters[0] === 38 || parameters[0] === 48) && parameters[1] === 2) {
|
|
65
|
+
return parameters.slice(2).every((value) => value >= 0 && value <= 255);
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function preserveSafeSgr(raw: string): string {
|
|
71
|
+
const sequences: string[] = [];
|
|
72
|
+
const tokenized = raw
|
|
73
|
+
.replace(/[\uE000\uE001]/g, "")
|
|
74
|
+
.replace(CSI_SEQUENCE_PATTERN, (sequence) => {
|
|
75
|
+
if (!isSafeSgr(sequence)) return "";
|
|
76
|
+
const index = sequences.push(sequence) - 1;
|
|
77
|
+
return `\uE000${index}\uE001`;
|
|
78
|
+
});
|
|
79
|
+
return stripTerminalSequences(tokenized)
|
|
80
|
+
.replace(CONTROL_CHARACTER_PATTERN, "")
|
|
81
|
+
.replace(/\uE000(\d+)\uE001/g, (_token, index: string) => sequences[Number(index)] ?? "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isVisuallyBlank(line: string): boolean {
|
|
85
|
+
return stripTerminalSequences(line).trim() === "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function limitAvatarLines(lines: string[], maxColumns: number, maxLines: number): Avatar {
|
|
89
|
+
let truncated = false;
|
|
90
|
+
const limited = lines.map((line) => {
|
|
91
|
+
if (visibleWidth(line) <= maxColumns) return line;
|
|
92
|
+
truncated = true;
|
|
93
|
+
return truncateToWidth(line, maxColumns, "…");
|
|
94
|
+
});
|
|
95
|
+
while (limited.length > 0 && isVisuallyBlank(limited[0]!)) limited.shift();
|
|
96
|
+
while (limited.length > 0 && isVisuallyBlank(limited.at(-1)!)) limited.pop();
|
|
97
|
+
if (limited.length > maxLines) {
|
|
98
|
+
limited.splice(maxLines);
|
|
99
|
+
truncated = true;
|
|
100
|
+
}
|
|
101
|
+
return { lines: limited, truncated };
|
|
102
|
+
}
|
|
103
|
+
|
|
22
104
|
function isWithin(parent: string, child: string): boolean {
|
|
23
105
|
const pathFromParent = relative(parent, child);
|
|
24
106
|
return pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`);
|
|
25
107
|
}
|
|
26
108
|
|
|
109
|
+
function pngChunkCrc(buffer: Buffer, start: number, end: number): number {
|
|
110
|
+
let crc = 0xffffffff;
|
|
111
|
+
for (let index = start; index < end; index += 1) {
|
|
112
|
+
crc = CRC32_TABLE[(crc ^ buffer[index]!) & 0xff]! ^ (crc >>> 8);
|
|
113
|
+
}
|
|
114
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function validatePngAvatar(bytes: Uint8Array): AvatarImage {
|
|
118
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
119
|
+
if (buffer.byteLength > AVATAR_PNG_MAX_BYTES) {
|
|
120
|
+
throw new AvatarLoadError(`avatar.png exceeds the ${AVATAR_PNG_MAX_BYTES}-byte limit`);
|
|
121
|
+
}
|
|
122
|
+
if (buffer.byteLength < 45 || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
|
123
|
+
throw new AvatarLoadError("avatar.png is not a valid PNG file");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let offset = PNG_SIGNATURE.length;
|
|
127
|
+
let widthPx = 0;
|
|
128
|
+
let heightPx = 0;
|
|
129
|
+
let sawImageData = false;
|
|
130
|
+
let sawEnd = false;
|
|
131
|
+
while (offset + 12 <= buffer.byteLength) {
|
|
132
|
+
const chunkLength = buffer.readUInt32BE(offset);
|
|
133
|
+
const chunkEnd = offset + 12 + chunkLength;
|
|
134
|
+
if (chunkEnd > buffer.byteLength) throw new AvatarLoadError("avatar.png contains a truncated PNG chunk");
|
|
135
|
+
const chunkType = buffer.toString("ascii", offset + 4, offset + 8);
|
|
136
|
+
if (pngChunkCrc(buffer, offset + 4, offset + 8 + chunkLength) !== buffer.readUInt32BE(offset + 8 + chunkLength)) {
|
|
137
|
+
throw new AvatarLoadError(`avatar.png has an invalid ${chunkType} checksum`);
|
|
138
|
+
}
|
|
139
|
+
if (offset === PNG_SIGNATURE.length) {
|
|
140
|
+
if (chunkType !== "IHDR" || chunkLength !== 13) {
|
|
141
|
+
throw new AvatarLoadError("avatar.png must begin with a valid IHDR chunk");
|
|
142
|
+
}
|
|
143
|
+
widthPx = buffer.readUInt32BE(offset + 8);
|
|
144
|
+
heightPx = buffer.readUInt32BE(offset + 12);
|
|
145
|
+
const bitDepth = buffer[offset + 16]!;
|
|
146
|
+
const colorType = buffer[offset + 17]!;
|
|
147
|
+
const validDepths: Record<number, number[]> = {
|
|
148
|
+
0: [1, 2, 4, 8, 16],
|
|
149
|
+
2: [8, 16],
|
|
150
|
+
3: [1, 2, 4, 8],
|
|
151
|
+
4: [8, 16],
|
|
152
|
+
6: [8, 16],
|
|
153
|
+
};
|
|
154
|
+
if (
|
|
155
|
+
widthPx === 0 ||
|
|
156
|
+
heightPx === 0 ||
|
|
157
|
+
widthPx > AVATAR_PNG_MAX_DIMENSION ||
|
|
158
|
+
heightPx > AVATAR_PNG_MAX_DIMENSION ||
|
|
159
|
+
widthPx * heightPx > AVATAR_PNG_MAX_PIXELS
|
|
160
|
+
) {
|
|
161
|
+
throw new AvatarLoadError(
|
|
162
|
+
`avatar.png must be at most ${AVATAR_PNG_MAX_DIMENSION}×${AVATAR_PNG_MAX_DIMENSION} pixels and ${AVATAR_PNG_MAX_PIXELS} total pixels`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
!validDepths[colorType]?.includes(bitDepth) ||
|
|
167
|
+
buffer[offset + 18] !== 0 ||
|
|
168
|
+
buffer[offset + 19] !== 0 ||
|
|
169
|
+
(buffer[offset + 20] !== 0 && buffer[offset + 20] !== 1)
|
|
170
|
+
) {
|
|
171
|
+
throw new AvatarLoadError("avatar.png has an unsupported PNG header");
|
|
172
|
+
}
|
|
173
|
+
} else if (chunkType === "IHDR") {
|
|
174
|
+
throw new AvatarLoadError("avatar.png contains multiple IHDR chunks");
|
|
175
|
+
}
|
|
176
|
+
if (chunkType === "IDAT") sawImageData = true;
|
|
177
|
+
if (chunkType === "IEND") {
|
|
178
|
+
if (chunkLength !== 0 || chunkEnd !== buffer.byteLength) {
|
|
179
|
+
throw new AvatarLoadError("avatar.png has an invalid IEND chunk");
|
|
180
|
+
}
|
|
181
|
+
sawEnd = true;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
offset = chunkEnd;
|
|
185
|
+
}
|
|
186
|
+
if (!sawImageData || !sawEnd) throw new AvatarLoadError("avatar.png is missing required PNG chunks");
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
mimeType: "image/png",
|
|
190
|
+
data: buffer.toString("base64"),
|
|
191
|
+
widthPx,
|
|
192
|
+
heightPx,
|
|
193
|
+
bytes: buffer.byteLength,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
27
197
|
export function sanitizeAvatar(raw: string, maxColumns = AVATAR_MAX_COLUMNS, maxLines = AVATAR_MAX_LINES): Avatar {
|
|
28
198
|
const normalized = raw.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
29
|
-
|
|
30
|
-
let lines = normalized.split("\n").map((line) => {
|
|
199
|
+
const lines = normalized.split("\n").map((line) => {
|
|
31
200
|
const safe = stripTerminalSequences(line.replace(/\t/g, " "))
|
|
32
|
-
.replace(
|
|
201
|
+
.replace(CONTROL_CHARACTER_PATTERN, "")
|
|
33
202
|
.trimEnd();
|
|
34
|
-
|
|
35
|
-
truncated = true;
|
|
36
|
-
return truncateToWidth(safe, maxColumns, "…");
|
|
203
|
+
return safe;
|
|
37
204
|
});
|
|
38
|
-
|
|
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 };
|
|
205
|
+
return limitAvatarLines(lines, maxColumns, maxLines);
|
|
45
206
|
}
|
|
46
207
|
|
|
47
|
-
export
|
|
48
|
-
|
|
208
|
+
export function sanitizeAnsiAvatar(
|
|
209
|
+
raw: string,
|
|
210
|
+
maxColumns = AVATAR_MAX_COLUMNS,
|
|
211
|
+
maxLines = AVATAR_MAX_LINES,
|
|
212
|
+
): Avatar {
|
|
213
|
+
const normalized = raw.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
214
|
+
const lines = normalized.split("\n").map((line) => {
|
|
215
|
+
const safe = preserveSafeSgr(line.replace(/\t/g, " "));
|
|
216
|
+
return safe.includes("\u001b[") ? `${safe}${ANSI_RESET}` : safe;
|
|
217
|
+
});
|
|
218
|
+
return limitAvatarLines(lines, maxColumns, maxLines);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function loadAvatarFile(character: Character, filename: string, allowAnsi: boolean): Promise<Avatar | undefined> {
|
|
222
|
+
const avatarPath = join(character.directory, filename);
|
|
49
223
|
let canonicalAvatarPath: string;
|
|
50
224
|
try {
|
|
51
225
|
canonicalAvatarPath = await realpath(avatarPath);
|
|
@@ -63,18 +237,52 @@ export async function loadAvatar(character: Character): Promise<Avatar | undefin
|
|
|
63
237
|
} catch {
|
|
64
238
|
throw new AvatarLoadError(`Cannot read avatar: ${avatarPath}`);
|
|
65
239
|
}
|
|
240
|
+
if (bytes.byteLength > AVATAR_MAX_BYTES) {
|
|
241
|
+
throw new AvatarLoadError(`${filename} exceeds the ${AVATAR_MAX_BYTES}-byte limit: ${avatarPath}`);
|
|
242
|
+
}
|
|
66
243
|
|
|
67
244
|
let raw: string;
|
|
68
245
|
try {
|
|
69
246
|
raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
70
247
|
} catch {
|
|
71
|
-
throw new AvatarLoadError(
|
|
248
|
+
throw new AvatarLoadError(`${filename} is not valid UTF-8: ${avatarPath}`);
|
|
249
|
+
}
|
|
250
|
+
return allowAnsi ? sanitizeAnsiAvatar(raw) : sanitizeAvatar(raw);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function loadPngAvatar(character: Character): Promise<AvatarImage | undefined> {
|
|
254
|
+
const filename = "avatar.png";
|
|
255
|
+
const avatarPath = join(character.directory, filename);
|
|
256
|
+
let canonicalAvatarPath: string;
|
|
257
|
+
try {
|
|
258
|
+
canonicalAvatarPath = await realpath(avatarPath);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
261
|
+
throw new AvatarLoadError(`Cannot inspect avatar: ${avatarPath}`);
|
|
262
|
+
}
|
|
263
|
+
if (!isWithin(character.directory, canonicalAvatarPath)) {
|
|
264
|
+
throw new AvatarLoadError(`Avatar path escapes the character directory: ${avatarPath}`);
|
|
265
|
+
}
|
|
266
|
+
let bytes: Buffer;
|
|
267
|
+
try {
|
|
268
|
+
bytes = await readFile(canonicalAvatarPath);
|
|
269
|
+
} catch {
|
|
270
|
+
throw new AvatarLoadError(`Cannot read avatar: ${avatarPath}`);
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
return validatePngAvatar(bytes);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof AvatarLoadError) {
|
|
276
|
+
throw new AvatarLoadError(`${error.message}: ${avatarPath}`);
|
|
277
|
+
}
|
|
278
|
+
throw error;
|
|
72
279
|
}
|
|
73
|
-
return sanitizeAvatar(raw);
|
|
74
280
|
}
|
|
75
281
|
|
|
76
|
-
export function
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
282
|
+
export async function loadAvatar(character: Character): Promise<Avatar | undefined> {
|
|
283
|
+
const image = await loadPngAvatar(character);
|
|
284
|
+
const ansi = await loadAvatarFile(character, "avatar.ansi", true);
|
|
285
|
+
const fallback = ansi ?? await loadAvatarFile(character, "avatar.txt", false);
|
|
286
|
+
if (!image) return fallback;
|
|
287
|
+
return { lines: fallback?.lines ?? [], truncated: fallback?.truncated ?? false, image };
|
|
80
288
|
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { link, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
installPersonalAvatar,
|
|
6
|
+
prepareAvatarContent,
|
|
7
|
+
prepareAvatarImport,
|
|
8
|
+
preparePngAvatarContent,
|
|
9
|
+
type PreparedAvatarImport,
|
|
10
|
+
} from "./avatar-manager.ts";
|
|
11
|
+
import {
|
|
12
|
+
CharacterEditError,
|
|
13
|
+
createPersonalCharacter,
|
|
14
|
+
} from "./character-editor.ts";
|
|
15
|
+
import { FORM_MAX_BYTES, readFormDraft, savePersonalForm } from "./form-editor.ts";
|
|
16
|
+
import { parseFormDeclarations } from "./forms.ts";
|
|
17
|
+
import { isCharacterId, loadCharacter, parseCharacterCard, type Character } from "./character-loader.ts";
|
|
18
|
+
|
|
19
|
+
export const CHARACTER_BUNDLE_FORMAT = "pi-incarnate-character";
|
|
20
|
+
export const CHARACTER_BUNDLE_VERSION = 1;
|
|
21
|
+
export const CHARACTER_BUNDLE_SUFFIX = ".pi-character.json";
|
|
22
|
+
export const CHARACTER_BUNDLE_MAX_BYTES = 1024 * 1024;
|
|
23
|
+
const CHARACTER_CARD_MAX_BYTES = 512 * 1024;
|
|
24
|
+
const CHARACTER_BUNDLE_MAX_FILES = 66;
|
|
25
|
+
|
|
26
|
+
interface CharacterBundleFileV1 {
|
|
27
|
+
path: string;
|
|
28
|
+
content: string;
|
|
29
|
+
encoding?: "base64";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CharacterBundleDocumentV1 {
|
|
33
|
+
format: typeof CHARACTER_BUNDLE_FORMAT;
|
|
34
|
+
version: typeof CHARACTER_BUNDLE_VERSION;
|
|
35
|
+
id: string;
|
|
36
|
+
files: CharacterBundleFileV1[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PreparedCharacterBundle {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
card: string;
|
|
43
|
+
avatars: PreparedAvatarImport[];
|
|
44
|
+
forms: Array<{ path: string; content: string }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CharacterBundleSummary {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
avatarIncluded: boolean;
|
|
51
|
+
formsIncluded: number;
|
|
52
|
+
formsDeclared: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function byteLength(value: string): number {
|
|
60
|
+
return Buffer.byteLength(value, "utf8");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeDeclaredFormPath(value: string): string {
|
|
64
|
+
if (
|
|
65
|
+
!value ||
|
|
66
|
+
value.includes("\\") ||
|
|
67
|
+
value.includes("\0") ||
|
|
68
|
+
isAbsolute(value) ||
|
|
69
|
+
posix.isAbsolute(value) ||
|
|
70
|
+
/^[a-zA-Z]:/.test(value)
|
|
71
|
+
) {
|
|
72
|
+
throw new CharacterEditError(`Invalid character bundle path: ${value}`);
|
|
73
|
+
}
|
|
74
|
+
const normalized = posix.normalize(value);
|
|
75
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
76
|
+
throw new CharacterEditError(`Invalid character bundle path: ${value}`);
|
|
77
|
+
}
|
|
78
|
+
return normalized;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizePortablePath(value: string): string {
|
|
82
|
+
const normalized = normalizeDeclaredFormPath(value);
|
|
83
|
+
if (normalized !== value) throw new CharacterEditError(`Invalid character bundle path: ${value}`);
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function portableRelativePath(root: string, target: string): string {
|
|
88
|
+
const value = relative(root, target).split(sep).join("/");
|
|
89
|
+
return normalizePortablePath(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function assertBundleTarget(path: string): void {
|
|
93
|
+
if (!path.toLowerCase().endsWith(CHARACTER_BUNDLE_SUFFIX)) {
|
|
94
|
+
throw new CharacterEditError(`Character bundle must end in ${CHARACTER_BUNDLE_SUFFIX}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function assertMissing(path: string, message: string): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
await lstat(path);
|
|
101
|
+
throw new CharacterEditError(message);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof CharacterEditError) throw error;
|
|
104
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readRegularUtf8(path: string, maximumBytes: number, label: string): Promise<string> {
|
|
109
|
+
let bytes: Buffer;
|
|
110
|
+
try {
|
|
111
|
+
const info = await lstat(path);
|
|
112
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
113
|
+
throw new CharacterEditError(`${label} must be a regular file: ${path}`);
|
|
114
|
+
}
|
|
115
|
+
if (info.size > maximumBytes) throw new CharacterEditError(`${label} exceeds the ${maximumBytes}-byte limit`);
|
|
116
|
+
bytes = await readFile(path);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error instanceof CharacterEditError) throw error;
|
|
119
|
+
throw new CharacterEditError(`Cannot read ${label.toLowerCase()}: ${path}`);
|
|
120
|
+
}
|
|
121
|
+
if (bytes.byteLength > maximumBytes) throw new CharacterEditError(`${label} exceeds the ${maximumBytes}-byte limit`);
|
|
122
|
+
try {
|
|
123
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
124
|
+
} catch {
|
|
125
|
+
throw new CharacterEditError(`${label} is not valid UTF-8: ${path}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function bundleDocument(character: Character): Promise<{ document: CharacterBundleDocumentV1; summary: CharacterBundleSummary }> {
|
|
130
|
+
const card = `${character.markdown.trimEnd()}\n`;
|
|
131
|
+
if (byteLength(card) > CHARACTER_CARD_MAX_BYTES) {
|
|
132
|
+
throw new CharacterEditError(`CHARACTER.md exceeds the ${CHARACTER_CARD_MAX_BYTES}-byte bundle limit`);
|
|
133
|
+
}
|
|
134
|
+
const files: CharacterBundleFileV1[] = [{ path: "CHARACTER.md", content: card }];
|
|
135
|
+
|
|
136
|
+
let avatarIncluded = false;
|
|
137
|
+
for (const filename of ["avatar.png"] as const) {
|
|
138
|
+
const avatarPath = join(character.directory, filename);
|
|
139
|
+
try {
|
|
140
|
+
await lstat(avatarPath);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
143
|
+
throw new CharacterEditError(`Cannot inspect avatar for export: ${avatarPath}`);
|
|
144
|
+
}
|
|
145
|
+
const avatar = await prepareAvatarImport(avatarPath);
|
|
146
|
+
if (avatar.kind !== "png") throw new CharacterEditError(`Unexpected avatar format: ${avatarPath}`);
|
|
147
|
+
files.push({ path: avatar.filename, content: avatar.content.toString("base64"), encoding: "base64" });
|
|
148
|
+
avatarIncluded = true;
|
|
149
|
+
}
|
|
150
|
+
for (const filename of ["avatar.ansi", "avatar.txt"] as const) {
|
|
151
|
+
const avatarPath = join(character.directory, filename);
|
|
152
|
+
try {
|
|
153
|
+
await lstat(avatarPath);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
156
|
+
throw new CharacterEditError(`Cannot inspect avatar for export: ${avatarPath}`);
|
|
157
|
+
}
|
|
158
|
+
const avatar = await prepareAvatarImport(avatarPath);
|
|
159
|
+
if (avatar.kind !== "text") throw new CharacterEditError(`Unexpected avatar format: ${avatarPath}`);
|
|
160
|
+
files.push({ path: avatar.filename, content: avatar.content });
|
|
161
|
+
avatarIncluded = true;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const includedForms = new Set<string>();
|
|
166
|
+
for (const form of character.forms) {
|
|
167
|
+
if (form.status !== "available") continue;
|
|
168
|
+
const draft = await readFormDraft(character.directory, form.declaredPath, form.label);
|
|
169
|
+
if (!draft.exists) continue;
|
|
170
|
+
const path = portableRelativePath(character.directory, draft.path);
|
|
171
|
+
if (includedForms.has(path)) continue;
|
|
172
|
+
includedForms.add(path);
|
|
173
|
+
files.push({ path, content: draft.content });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (files.length > CHARACTER_BUNDLE_MAX_FILES) {
|
|
177
|
+
throw new CharacterEditError(`Character bundle exceeds the ${CHARACTER_BUNDLE_MAX_FILES}-file limit`);
|
|
178
|
+
}
|
|
179
|
+
const document: CharacterBundleDocumentV1 = {
|
|
180
|
+
format: CHARACTER_BUNDLE_FORMAT,
|
|
181
|
+
version: CHARACTER_BUNDLE_VERSION,
|
|
182
|
+
id: character.id,
|
|
183
|
+
files,
|
|
184
|
+
};
|
|
185
|
+
const encoded = `${JSON.stringify(document, null, 2)}\n`;
|
|
186
|
+
if (byteLength(encoded) > CHARACTER_BUNDLE_MAX_BYTES) {
|
|
187
|
+
throw new CharacterEditError(`Character bundle exceeds the ${CHARACTER_BUNDLE_MAX_BYTES}-byte limit`);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
document,
|
|
191
|
+
summary: {
|
|
192
|
+
id: character.id,
|
|
193
|
+
name: character.name,
|
|
194
|
+
avatarIncluded,
|
|
195
|
+
formsIncluded: includedForms.size,
|
|
196
|
+
formsDeclared: character.forms.length,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function writeCharacterBundle(
|
|
202
|
+
character: Character,
|
|
203
|
+
targetPath: string,
|
|
204
|
+
): Promise<CharacterBundleSummary> {
|
|
205
|
+
assertBundleTarget(targetPath);
|
|
206
|
+
const requestedTarget = resolve(targetPath);
|
|
207
|
+
const parent = dirname(requestedTarget);
|
|
208
|
+
let canonicalParent: string;
|
|
209
|
+
try {
|
|
210
|
+
const info = await lstat(parent);
|
|
211
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
212
|
+
throw new CharacterEditError(`Bundle destination parent must be a real directory: ${parent}`);
|
|
213
|
+
}
|
|
214
|
+
canonicalParent = await realpath(parent);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error instanceof CharacterEditError) throw error;
|
|
217
|
+
throw new CharacterEditError(`Cannot access bundle destination directory: ${parent}`);
|
|
218
|
+
}
|
|
219
|
+
const target = join(canonicalParent, basename(requestedTarget));
|
|
220
|
+
await assertMissing(target, `Character bundle already exists: ${target}`);
|
|
221
|
+
const { document, summary } = await bundleDocument(character);
|
|
222
|
+
const temporaryPath = join(canonicalParent, `.incarnate-export-${crypto.randomUUID()}.tmp`);
|
|
223
|
+
try {
|
|
224
|
+
await writeFile(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
225
|
+
await link(temporaryPath, target);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
228
|
+
throw new CharacterEditError(`Character bundle already exists: ${target}`);
|
|
229
|
+
}
|
|
230
|
+
if (error instanceof CharacterEditError) throw error;
|
|
231
|
+
throw new CharacterEditError(`Cannot write character bundle: ${target}`);
|
|
232
|
+
} finally {
|
|
233
|
+
await rm(temporaryPath, { force: true });
|
|
234
|
+
}
|
|
235
|
+
return summary;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function decodeBase64(value: string, path: string): Buffer {
|
|
239
|
+
if (value.length === 0 || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
240
|
+
throw new CharacterEditError(`Character bundle contains invalid base64 content: ${path}`);
|
|
241
|
+
}
|
|
242
|
+
const decoded = Buffer.from(value, "base64");
|
|
243
|
+
if (decoded.toString("base64") !== value) {
|
|
244
|
+
throw new CharacterEditError(`Character bundle contains non-canonical base64 content: ${path}`);
|
|
245
|
+
}
|
|
246
|
+
return decoded;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function bundleAvatarEntry(avatar: PreparedAvatarImport): CharacterBundleFileV1 {
|
|
250
|
+
return avatar.kind === "png"
|
|
251
|
+
? { path: avatar.filename, content: avatar.content.toString("base64"), encoding: "base64" }
|
|
252
|
+
: { path: avatar.filename, content: avatar.content };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function validateBundleDocument(value: unknown): PreparedCharacterBundle {
|
|
256
|
+
if (!isRecord(value) || value.format !== CHARACTER_BUNDLE_FORMAT || value.version !== CHARACTER_BUNDLE_VERSION) {
|
|
257
|
+
throw new CharacterEditError("Unsupported character bundle format or version");
|
|
258
|
+
}
|
|
259
|
+
if (typeof value.id !== "string" || !isCharacterId(value.id)) {
|
|
260
|
+
throw new CharacterEditError("Character bundle has an invalid id");
|
|
261
|
+
}
|
|
262
|
+
if (!Array.isArray(value.files) || value.files.length === 0 || value.files.length > CHARACTER_BUNDLE_MAX_FILES) {
|
|
263
|
+
throw new CharacterEditError(`Character bundle must contain 1-${CHARACTER_BUNDLE_MAX_FILES} files`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const files = new Map<string, CharacterBundleFileV1>();
|
|
267
|
+
let contentBytes = 0;
|
|
268
|
+
for (const entry of value.files) {
|
|
269
|
+
if (
|
|
270
|
+
!isRecord(entry) ||
|
|
271
|
+
typeof entry.path !== "string" ||
|
|
272
|
+
typeof entry.content !== "string" ||
|
|
273
|
+
(entry.encoding !== undefined && entry.encoding !== "base64")
|
|
274
|
+
) {
|
|
275
|
+
throw new CharacterEditError("Character bundle contains an invalid file entry");
|
|
276
|
+
}
|
|
277
|
+
const path = normalizePortablePath(entry.path);
|
|
278
|
+
if (files.has(path)) throw new CharacterEditError(`Character bundle contains a duplicate path: ${path}`);
|
|
279
|
+
contentBytes += byteLength(entry.content);
|
|
280
|
+
if (contentBytes > CHARACTER_BUNDLE_MAX_BYTES) {
|
|
281
|
+
throw new CharacterEditError(`Character bundle content exceeds the ${CHARACTER_BUNDLE_MAX_BYTES}-byte limit`);
|
|
282
|
+
}
|
|
283
|
+
files.set(path, { path, content: entry.content, ...(entry.encoding === "base64" ? { encoding: "base64" as const } : {}) });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const cardEntry = files.get("CHARACTER.md");
|
|
287
|
+
if (cardEntry === undefined) throw new CharacterEditError("Character bundle is missing CHARACTER.md");
|
|
288
|
+
if (cardEntry.encoding !== undefined) throw new CharacterEditError("CHARACTER.md must use UTF-8 text content");
|
|
289
|
+
const card = cardEntry.content;
|
|
290
|
+
if (byteLength(card) > CHARACTER_CARD_MAX_BYTES) {
|
|
291
|
+
throw new CharacterEditError(`CHARACTER.md exceeds the ${CHARACTER_CARD_MAX_BYTES}-byte bundle limit`);
|
|
292
|
+
}
|
|
293
|
+
const parsed = parseCharacterCard(card, "CHARACTER.md");
|
|
294
|
+
const allowedForms = new Set<string>();
|
|
295
|
+
for (const declaration of parseFormDeclarations(card)) {
|
|
296
|
+
try {
|
|
297
|
+
const path = normalizeDeclaredFormPath(declaration.declaredPath);
|
|
298
|
+
if (extname(path).toLowerCase() === ".md" && path !== "CHARACTER.md") allowedForms.add(path);
|
|
299
|
+
} catch {
|
|
300
|
+
// Invalid declarations remain unavailable after import and cannot authorize bundled files.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const avatars: PreparedAvatarImport[] = [];
|
|
305
|
+
let textAvatarFound = false;
|
|
306
|
+
let pngAvatarFound = false;
|
|
307
|
+
const forms: Array<{ path: string; content: string }> = [];
|
|
308
|
+
for (const [path, entry] of files) {
|
|
309
|
+
if (path === "CHARACTER.md") continue;
|
|
310
|
+
const { content, encoding } = entry;
|
|
311
|
+
if (path === "avatar.png") {
|
|
312
|
+
if (pngAvatarFound) throw new CharacterEditError("Character bundle may contain only one PNG avatar");
|
|
313
|
+
if (encoding !== "base64") throw new CharacterEditError("avatar.png must use base64 content");
|
|
314
|
+
avatars.push(preparePngAvatarContent(decodeBase64(content, path)));
|
|
315
|
+
pngAvatarFound = true;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (path === "avatar.ansi" || path === "avatar.txt") {
|
|
319
|
+
if (textAvatarFound) throw new CharacterEditError("Character bundle may contain only one text avatar fallback");
|
|
320
|
+
if (encoding !== undefined) throw new CharacterEditError(`${path} must use UTF-8 text content`);
|
|
321
|
+
avatars.push(prepareAvatarContent(path, content));
|
|
322
|
+
textAvatarFound = true;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (encoding !== undefined) throw new CharacterEditError(`Character bundle text file must not use base64: ${path}`);
|
|
326
|
+
if (!allowedForms.has(path)) {
|
|
327
|
+
throw new CharacterEditError(`Character bundle contains an undeclared file: ${path}`);
|
|
328
|
+
}
|
|
329
|
+
if (byteLength(content) > FORM_MAX_BYTES) {
|
|
330
|
+
throw new CharacterEditError(`Form exceeds the ${FORM_MAX_BYTES}-byte limit: ${path}`);
|
|
331
|
+
}
|
|
332
|
+
forms.push({ path, content });
|
|
333
|
+
}
|
|
334
|
+
return { id: value.id, name: parsed.name, card, avatars, forms };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function prepareCharacterBundleImport(sourcePath: string): Promise<PreparedCharacterBundle> {
|
|
338
|
+
assertBundleTarget(sourcePath);
|
|
339
|
+
const content = await readRegularUtf8(sourcePath, CHARACTER_BUNDLE_MAX_BYTES, "Character bundle");
|
|
340
|
+
let document: unknown;
|
|
341
|
+
try {
|
|
342
|
+
document = JSON.parse(content);
|
|
343
|
+
} catch {
|
|
344
|
+
throw new CharacterEditError("Character bundle is not valid JSON");
|
|
345
|
+
}
|
|
346
|
+
return validateBundleDocument(document);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function installCharacterBundle(
|
|
350
|
+
personalRoot: string,
|
|
351
|
+
bundle: PreparedCharacterBundle,
|
|
352
|
+
): Promise<Character> {
|
|
353
|
+
const validated = validateBundleDocument({
|
|
354
|
+
format: CHARACTER_BUNDLE_FORMAT,
|
|
355
|
+
version: CHARACTER_BUNDLE_VERSION,
|
|
356
|
+
id: bundle.id,
|
|
357
|
+
files: [
|
|
358
|
+
{ path: "CHARACTER.md", content: bundle.card },
|
|
359
|
+
...bundle.avatars.map(bundleAvatarEntry),
|
|
360
|
+
...bundle.forms.map((form) => ({ path: form.path, content: form.content })),
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
await mkdir(personalRoot, { recursive: true });
|
|
364
|
+
const canonicalRoot = await realpath(personalRoot);
|
|
365
|
+
const target = join(canonicalRoot, validated.id);
|
|
366
|
+
await assertMissing(target, `A personal character already exists: ${validated.id}`);
|
|
367
|
+
const stagingRoot = await mkdtemp(join(canonicalRoot, ".incarnate-import-"));
|
|
368
|
+
try {
|
|
369
|
+
await createPersonalCharacter(stagingRoot, validated.id, validated.card);
|
|
370
|
+
for (const form of validated.forms) {
|
|
371
|
+
await savePersonalForm(stagingRoot, validated.id, form.path, form.content);
|
|
372
|
+
}
|
|
373
|
+
for (const avatar of validated.avatars) await installPersonalAvatar(stagingRoot, validated.id, avatar);
|
|
374
|
+
const staged = await loadCharacter(stagingRoot, validated.id);
|
|
375
|
+
await rename(staged.directory, target);
|
|
376
|
+
return await loadCharacter(canonicalRoot, validated.id);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (error instanceof CharacterEditError) throw error;
|
|
379
|
+
throw new CharacterEditError(`Cannot install character bundle: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
380
|
+
} finally {
|
|
381
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
382
|
+
}
|
|
383
|
+
}
|