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.
- package/CHANGELOG.md +27 -0
- package/README.md +113 -21
- package/SECURITY.md +10 -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 +156 -0
- package/src/avatar.ts +93 -22
- package/src/character-bundle.ts +330 -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 +752 -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 +66 -0
|
@@ -0,0 +1,330 @@
|
|
|
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
|
+
type PreparedAvatarImport,
|
|
9
|
+
} from "./avatar-manager.ts";
|
|
10
|
+
import {
|
|
11
|
+
CharacterEditError,
|
|
12
|
+
createPersonalCharacter,
|
|
13
|
+
} from "./character-editor.ts";
|
|
14
|
+
import { FORM_MAX_BYTES, readFormDraft, savePersonalForm } from "./form-editor.ts";
|
|
15
|
+
import { parseFormDeclarations } from "./forms.ts";
|
|
16
|
+
import { isCharacterId, loadCharacter, parseCharacterCard, type Character } from "./character-loader.ts";
|
|
17
|
+
|
|
18
|
+
export const CHARACTER_BUNDLE_FORMAT = "pi-incarnate-character";
|
|
19
|
+
export const CHARACTER_BUNDLE_VERSION = 1;
|
|
20
|
+
export const CHARACTER_BUNDLE_SUFFIX = ".pi-character.json";
|
|
21
|
+
export const CHARACTER_BUNDLE_MAX_BYTES = 1024 * 1024;
|
|
22
|
+
const CHARACTER_CARD_MAX_BYTES = 512 * 1024;
|
|
23
|
+
const CHARACTER_BUNDLE_MAX_FILES = 66;
|
|
24
|
+
|
|
25
|
+
interface CharacterBundleFileV1 {
|
|
26
|
+
path: string;
|
|
27
|
+
content: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CharacterBundleDocumentV1 {
|
|
31
|
+
format: typeof CHARACTER_BUNDLE_FORMAT;
|
|
32
|
+
version: typeof CHARACTER_BUNDLE_VERSION;
|
|
33
|
+
id: string;
|
|
34
|
+
files: CharacterBundleFileV1[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PreparedCharacterBundle {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
card: string;
|
|
41
|
+
avatar?: PreparedAvatarImport;
|
|
42
|
+
forms: Array<{ path: string; content: string }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CharacterBundleSummary {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
avatarIncluded: boolean;
|
|
49
|
+
formsIncluded: number;
|
|
50
|
+
formsDeclared: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function byteLength(value: string): number {
|
|
58
|
+
return Buffer.byteLength(value, "utf8");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeDeclaredFormPath(value: string): string {
|
|
62
|
+
if (
|
|
63
|
+
!value ||
|
|
64
|
+
value.includes("\\") ||
|
|
65
|
+
value.includes("\0") ||
|
|
66
|
+
isAbsolute(value) ||
|
|
67
|
+
posix.isAbsolute(value) ||
|
|
68
|
+
/^[a-zA-Z]:/.test(value)
|
|
69
|
+
) {
|
|
70
|
+
throw new CharacterEditError(`Invalid character bundle path: ${value}`);
|
|
71
|
+
}
|
|
72
|
+
const normalized = posix.normalize(value);
|
|
73
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
74
|
+
throw new CharacterEditError(`Invalid character bundle path: ${value}`);
|
|
75
|
+
}
|
|
76
|
+
return normalized;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizePortablePath(value: string): string {
|
|
80
|
+
const normalized = normalizeDeclaredFormPath(value);
|
|
81
|
+
if (normalized !== value) throw new CharacterEditError(`Invalid character bundle path: ${value}`);
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function portableRelativePath(root: string, target: string): string {
|
|
86
|
+
const value = relative(root, target).split(sep).join("/");
|
|
87
|
+
return normalizePortablePath(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertBundleTarget(path: string): void {
|
|
91
|
+
if (!path.toLowerCase().endsWith(CHARACTER_BUNDLE_SUFFIX)) {
|
|
92
|
+
throw new CharacterEditError(`Character bundle must end in ${CHARACTER_BUNDLE_SUFFIX}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function assertMissing(path: string, message: string): Promise<void> {
|
|
97
|
+
try {
|
|
98
|
+
await lstat(path);
|
|
99
|
+
throw new CharacterEditError(message);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof CharacterEditError) throw error;
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readRegularUtf8(path: string, maximumBytes: number, label: string): Promise<string> {
|
|
107
|
+
let bytes: Buffer;
|
|
108
|
+
try {
|
|
109
|
+
const info = await lstat(path);
|
|
110
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
111
|
+
throw new CharacterEditError(`${label} must be a regular file: ${path}`);
|
|
112
|
+
}
|
|
113
|
+
if (info.size > maximumBytes) throw new CharacterEditError(`${label} exceeds the ${maximumBytes}-byte limit`);
|
|
114
|
+
bytes = await readFile(path);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof CharacterEditError) throw error;
|
|
117
|
+
throw new CharacterEditError(`Cannot read ${label.toLowerCase()}: ${path}`);
|
|
118
|
+
}
|
|
119
|
+
if (bytes.byteLength > maximumBytes) throw new CharacterEditError(`${label} exceeds the ${maximumBytes}-byte limit`);
|
|
120
|
+
try {
|
|
121
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new CharacterEditError(`${label} is not valid UTF-8: ${path}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function bundleDocument(character: Character): Promise<{ document: CharacterBundleDocumentV1; summary: CharacterBundleSummary }> {
|
|
128
|
+
const card = `${character.markdown.trimEnd()}\n`;
|
|
129
|
+
if (byteLength(card) > CHARACTER_CARD_MAX_BYTES) {
|
|
130
|
+
throw new CharacterEditError(`CHARACTER.md exceeds the ${CHARACTER_CARD_MAX_BYTES}-byte bundle limit`);
|
|
131
|
+
}
|
|
132
|
+
const files: CharacterBundleFileV1[] = [{ path: "CHARACTER.md", content: card }];
|
|
133
|
+
|
|
134
|
+
let avatarIncluded = false;
|
|
135
|
+
for (const filename of ["avatar.ansi", "avatar.txt"] as const) {
|
|
136
|
+
const avatarPath = join(character.directory, filename);
|
|
137
|
+
try {
|
|
138
|
+
await lstat(avatarPath);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
141
|
+
throw new CharacterEditError(`Cannot inspect avatar for export: ${avatarPath}`);
|
|
142
|
+
}
|
|
143
|
+
const avatar = await prepareAvatarImport(avatarPath);
|
|
144
|
+
files.push({ path: avatar.filename, content: avatar.content });
|
|
145
|
+
avatarIncluded = true;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const includedForms = new Set<string>();
|
|
150
|
+
for (const form of character.forms) {
|
|
151
|
+
if (form.status !== "available") continue;
|
|
152
|
+
const draft = await readFormDraft(character.directory, form.declaredPath, form.label);
|
|
153
|
+
if (!draft.exists) continue;
|
|
154
|
+
const path = portableRelativePath(character.directory, draft.path);
|
|
155
|
+
if (includedForms.has(path)) continue;
|
|
156
|
+
includedForms.add(path);
|
|
157
|
+
files.push({ path, content: draft.content });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (files.length > CHARACTER_BUNDLE_MAX_FILES) {
|
|
161
|
+
throw new CharacterEditError(`Character bundle exceeds the ${CHARACTER_BUNDLE_MAX_FILES}-file limit`);
|
|
162
|
+
}
|
|
163
|
+
const document: CharacterBundleDocumentV1 = {
|
|
164
|
+
format: CHARACTER_BUNDLE_FORMAT,
|
|
165
|
+
version: CHARACTER_BUNDLE_VERSION,
|
|
166
|
+
id: character.id,
|
|
167
|
+
files,
|
|
168
|
+
};
|
|
169
|
+
const encoded = `${JSON.stringify(document, null, 2)}\n`;
|
|
170
|
+
if (byteLength(encoded) > CHARACTER_BUNDLE_MAX_BYTES) {
|
|
171
|
+
throw new CharacterEditError(`Character bundle exceeds the ${CHARACTER_BUNDLE_MAX_BYTES}-byte limit`);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
document,
|
|
175
|
+
summary: {
|
|
176
|
+
id: character.id,
|
|
177
|
+
name: character.name,
|
|
178
|
+
avatarIncluded,
|
|
179
|
+
formsIncluded: includedForms.size,
|
|
180
|
+
formsDeclared: character.forms.length,
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function writeCharacterBundle(
|
|
186
|
+
character: Character,
|
|
187
|
+
targetPath: string,
|
|
188
|
+
): Promise<CharacterBundleSummary> {
|
|
189
|
+
assertBundleTarget(targetPath);
|
|
190
|
+
const requestedTarget = resolve(targetPath);
|
|
191
|
+
const parent = dirname(requestedTarget);
|
|
192
|
+
let canonicalParent: string;
|
|
193
|
+
try {
|
|
194
|
+
const info = await lstat(parent);
|
|
195
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
196
|
+
throw new CharacterEditError(`Bundle destination parent must be a real directory: ${parent}`);
|
|
197
|
+
}
|
|
198
|
+
canonicalParent = await realpath(parent);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error instanceof CharacterEditError) throw error;
|
|
201
|
+
throw new CharacterEditError(`Cannot access bundle destination directory: ${parent}`);
|
|
202
|
+
}
|
|
203
|
+
const target = join(canonicalParent, basename(requestedTarget));
|
|
204
|
+
await assertMissing(target, `Character bundle already exists: ${target}`);
|
|
205
|
+
const { document, summary } = await bundleDocument(character);
|
|
206
|
+
const temporaryPath = join(canonicalParent, `.incarnate-export-${crypto.randomUUID()}.tmp`);
|
|
207
|
+
try {
|
|
208
|
+
await writeFile(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
209
|
+
await link(temporaryPath, target);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
212
|
+
throw new CharacterEditError(`Character bundle already exists: ${target}`);
|
|
213
|
+
}
|
|
214
|
+
if (error instanceof CharacterEditError) throw error;
|
|
215
|
+
throw new CharacterEditError(`Cannot write character bundle: ${target}`);
|
|
216
|
+
} finally {
|
|
217
|
+
await rm(temporaryPath, { force: true });
|
|
218
|
+
}
|
|
219
|
+
return summary;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function validateBundleDocument(value: unknown): PreparedCharacterBundle {
|
|
223
|
+
if (!isRecord(value) || value.format !== CHARACTER_BUNDLE_FORMAT || value.version !== CHARACTER_BUNDLE_VERSION) {
|
|
224
|
+
throw new CharacterEditError("Unsupported character bundle format or version");
|
|
225
|
+
}
|
|
226
|
+
if (typeof value.id !== "string" || !isCharacterId(value.id)) {
|
|
227
|
+
throw new CharacterEditError("Character bundle has an invalid id");
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(value.files) || value.files.length === 0 || value.files.length > CHARACTER_BUNDLE_MAX_FILES) {
|
|
230
|
+
throw new CharacterEditError(`Character bundle must contain 1-${CHARACTER_BUNDLE_MAX_FILES} files`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const files = new Map<string, string>();
|
|
234
|
+
let contentBytes = 0;
|
|
235
|
+
for (const entry of value.files) {
|
|
236
|
+
if (!isRecord(entry) || typeof entry.path !== "string" || typeof entry.content !== "string") {
|
|
237
|
+
throw new CharacterEditError("Character bundle contains an invalid file entry");
|
|
238
|
+
}
|
|
239
|
+
const path = normalizePortablePath(entry.path);
|
|
240
|
+
if (files.has(path)) throw new CharacterEditError(`Character bundle contains a duplicate path: ${path}`);
|
|
241
|
+
contentBytes += byteLength(entry.content);
|
|
242
|
+
if (contentBytes > CHARACTER_BUNDLE_MAX_BYTES) {
|
|
243
|
+
throw new CharacterEditError(`Character bundle content exceeds the ${CHARACTER_BUNDLE_MAX_BYTES}-byte limit`);
|
|
244
|
+
}
|
|
245
|
+
files.set(path, entry.content);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const card = files.get("CHARACTER.md");
|
|
249
|
+
if (card === undefined) throw new CharacterEditError("Character bundle is missing CHARACTER.md");
|
|
250
|
+
if (byteLength(card) > CHARACTER_CARD_MAX_BYTES) {
|
|
251
|
+
throw new CharacterEditError(`CHARACTER.md exceeds the ${CHARACTER_CARD_MAX_BYTES}-byte bundle limit`);
|
|
252
|
+
}
|
|
253
|
+
const parsed = parseCharacterCard(card, "CHARACTER.md");
|
|
254
|
+
const allowedForms = new Set<string>();
|
|
255
|
+
for (const declaration of parseFormDeclarations(card)) {
|
|
256
|
+
try {
|
|
257
|
+
const path = normalizeDeclaredFormPath(declaration.declaredPath);
|
|
258
|
+
if (extname(path).toLowerCase() === ".md" && path !== "CHARACTER.md") allowedForms.add(path);
|
|
259
|
+
} catch {
|
|
260
|
+
// Invalid declarations remain unavailable after import and cannot authorize bundled files.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let avatar: PreparedAvatarImport | undefined;
|
|
265
|
+
const forms: Array<{ path: string; content: string }> = [];
|
|
266
|
+
for (const [path, content] of files) {
|
|
267
|
+
if (path === "CHARACTER.md") continue;
|
|
268
|
+
if (path === "avatar.ansi" || path === "avatar.txt") {
|
|
269
|
+
if (avatar) throw new CharacterEditError("Character bundle may contain only one avatar file");
|
|
270
|
+
avatar = prepareAvatarContent(path, content);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!allowedForms.has(path)) {
|
|
274
|
+
throw new CharacterEditError(`Character bundle contains an undeclared file: ${path}`);
|
|
275
|
+
}
|
|
276
|
+
if (byteLength(content) > FORM_MAX_BYTES) {
|
|
277
|
+
throw new CharacterEditError(`Form exceeds the ${FORM_MAX_BYTES}-byte limit: ${path}`);
|
|
278
|
+
}
|
|
279
|
+
forms.push({ path, content });
|
|
280
|
+
}
|
|
281
|
+
return { id: value.id, name: parsed.name, card, avatar, forms };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function prepareCharacterBundleImport(sourcePath: string): Promise<PreparedCharacterBundle> {
|
|
285
|
+
assertBundleTarget(sourcePath);
|
|
286
|
+
const content = await readRegularUtf8(sourcePath, CHARACTER_BUNDLE_MAX_BYTES, "Character bundle");
|
|
287
|
+
let document: unknown;
|
|
288
|
+
try {
|
|
289
|
+
document = JSON.parse(content);
|
|
290
|
+
} catch {
|
|
291
|
+
throw new CharacterEditError("Character bundle is not valid JSON");
|
|
292
|
+
}
|
|
293
|
+
return validateBundleDocument(document);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function installCharacterBundle(
|
|
297
|
+
personalRoot: string,
|
|
298
|
+
bundle: PreparedCharacterBundle,
|
|
299
|
+
): Promise<Character> {
|
|
300
|
+
const validated = validateBundleDocument({
|
|
301
|
+
format: CHARACTER_BUNDLE_FORMAT,
|
|
302
|
+
version: CHARACTER_BUNDLE_VERSION,
|
|
303
|
+
id: bundle.id,
|
|
304
|
+
files: [
|
|
305
|
+
{ path: "CHARACTER.md", content: bundle.card },
|
|
306
|
+
...(bundle.avatar ? [{ path: bundle.avatar.filename, content: bundle.avatar.content }] : []),
|
|
307
|
+
...bundle.forms.map((form) => ({ path: form.path, content: form.content })),
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
await mkdir(personalRoot, { recursive: true });
|
|
311
|
+
const canonicalRoot = await realpath(personalRoot);
|
|
312
|
+
const target = join(canonicalRoot, validated.id);
|
|
313
|
+
await assertMissing(target, `A personal character already exists: ${validated.id}`);
|
|
314
|
+
const stagingRoot = await mkdtemp(join(canonicalRoot, ".incarnate-import-"));
|
|
315
|
+
try {
|
|
316
|
+
await createPersonalCharacter(stagingRoot, validated.id, validated.card);
|
|
317
|
+
for (const form of validated.forms) {
|
|
318
|
+
await savePersonalForm(stagingRoot, validated.id, form.path, form.content);
|
|
319
|
+
}
|
|
320
|
+
if (validated.avatar) await installPersonalAvatar(stagingRoot, validated.id, validated.avatar);
|
|
321
|
+
const staged = await loadCharacter(stagingRoot, validated.id);
|
|
322
|
+
await rename(staged.directory, target);
|
|
323
|
+
return await loadCharacter(canonicalRoot, validated.id);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error instanceof CharacterEditError) throw error;
|
|
326
|
+
throw new CharacterEditError(`Cannot install character bundle: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
327
|
+
} finally {
|
|
328
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
discoverCharacters,
|
|
6
|
+
loadCharacter,
|
|
7
|
+
type Character,
|
|
8
|
+
type CharacterIssue,
|
|
9
|
+
} from "./character-loader.ts";
|
|
10
|
+
|
|
11
|
+
export type CharacterSource = "personal" | "built-in";
|
|
12
|
+
|
|
13
|
+
export interface CharacterLocations {
|
|
14
|
+
builtInRoot: string;
|
|
15
|
+
personalRoot?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CharacterCatalogEntry {
|
|
19
|
+
character: Character;
|
|
20
|
+
source: CharacterSource;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CharacterCatalog {
|
|
24
|
+
entries: CharacterCatalogEntry[];
|
|
25
|
+
issues: CharacterIssue[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
await lstat(path);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function discoverCharacterCatalog(locations: CharacterLocations): Promise<CharacterCatalog> {
|
|
39
|
+
const roots: Array<{ root: string; source: CharacterSource }> = [];
|
|
40
|
+
if (locations.personalRoot) roots.push({ root: locations.personalRoot, source: "personal" });
|
|
41
|
+
roots.push({ root: locations.builtInRoot, source: "built-in" });
|
|
42
|
+
|
|
43
|
+
const entries: CharacterCatalogEntry[] = [];
|
|
44
|
+
const issues: CharacterIssue[] = [];
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
for (const { root, source } of roots) {
|
|
47
|
+
const discovery = await discoverCharacters(root);
|
|
48
|
+
issues.push(...discovery.issues);
|
|
49
|
+
for (const character of discovery.characters) {
|
|
50
|
+
if (seen.has(character.id)) continue;
|
|
51
|
+
seen.add(character.id);
|
|
52
|
+
entries.push({ character, source });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
entries.sort((left, right) => left.character.id.localeCompare(right.character.id));
|
|
56
|
+
return { entries, issues };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function loadCatalogCharacter(locations: CharacterLocations, id: string): Promise<CharacterCatalogEntry> {
|
|
60
|
+
if (locations.personalRoot && (await pathExists(join(locations.personalRoot, id)))) {
|
|
61
|
+
return { character: await loadCharacter(locations.personalRoot, id), source: "personal" };
|
|
62
|
+
}
|
|
63
|
+
return { character: await loadCharacter(locations.builtInRoot, id), source: "built-in" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function characterSource(character: Character, locations: CharacterLocations): CharacterSource {
|
|
67
|
+
if (locations.personalRoot && dirname(character.directory) === resolve(locations.personalRoot)) return "personal";
|
|
68
|
+
return "built-in";
|
|
69
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CharacterLoadError,
|
|
7
|
+
isCharacterId,
|
|
8
|
+
loadCharacter,
|
|
9
|
+
parseCharacterCard,
|
|
10
|
+
type Character,
|
|
11
|
+
} from "./character-loader.ts";
|
|
12
|
+
|
|
13
|
+
export class CharacterEditError extends Error {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "CharacterEditError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isWithin(parent: string, child: string): boolean {
|
|
21
|
+
const pathFromParent = relative(parent, child);
|
|
22
|
+
return pathFromParent === "" || (!pathFromParent.startsWith(`..${sep}`) && pathFromParent !== "..");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeForSave(markdown: string): string {
|
|
26
|
+
return `${markdown.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trim()}\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertValidCard(markdown: string, cardPath: string): string {
|
|
30
|
+
try {
|
|
31
|
+
parseCharacterCard(markdown, cardPath);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof CharacterLoadError) throw new CharacterEditError(error.message);
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
return normalizeForSave(markdown);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createCharacterTemplate(name: string): string {
|
|
40
|
+
return `# ${name}
|
|
41
|
+
|
|
42
|
+
## Identity
|
|
43
|
+
|
|
44
|
+
描述角色是谁、来自何处、与用户的关系,以及对自身世界观的理解。
|
|
45
|
+
|
|
46
|
+
## Personality
|
|
47
|
+
|
|
48
|
+
描述核心性格、价值偏好、优点、缺点和敏感点。
|
|
49
|
+
|
|
50
|
+
## Speech Style
|
|
51
|
+
|
|
52
|
+
描述称呼、口癖、句式、语气、常用词、禁用词,并可加入示例对话。
|
|
53
|
+
|
|
54
|
+
## Behavior
|
|
55
|
+
|
|
56
|
+
描述角色如何表达赞同、质疑、关心、兴奋、失望和道歉,以及必须遵守的边界。
|
|
57
|
+
|
|
58
|
+
## Current Mood
|
|
59
|
+
|
|
60
|
+
Default: normal
|
|
61
|
+
|
|
62
|
+
### normal
|
|
63
|
+
|
|
64
|
+
保持角色的常态表达方式。
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function ensureNewTarget(personalRoot: string, id: string): Promise<string> {
|
|
69
|
+
if (!isCharacterId(id)) throw new CharacterEditError(`Invalid character id: ${id}`);
|
|
70
|
+
await mkdir(personalRoot, { recursive: true });
|
|
71
|
+
const canonicalRoot = await realpath(personalRoot);
|
|
72
|
+
const target = join(canonicalRoot, id);
|
|
73
|
+
try {
|
|
74
|
+
await lstat(target);
|
|
75
|
+
throw new CharacterEditError(`A personal character already exists: ${id}`);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof CharacterEditError) throw error;
|
|
78
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
79
|
+
}
|
|
80
|
+
return target;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function copyRegularTree(source: string, target: string): Promise<void> {
|
|
84
|
+
await mkdir(target);
|
|
85
|
+
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
86
|
+
const sourcePath = join(source, entry.name);
|
|
87
|
+
const targetPath = join(target, entry.name);
|
|
88
|
+
if (entry.isSymbolicLink()) throw new CharacterEditError(`Cannot copy symbolic link: ${sourcePath}`);
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
await copyRegularTree(sourcePath, targetPath);
|
|
91
|
+
} else if (entry.isFile()) {
|
|
92
|
+
await copyFile(sourcePath, targetPath, constants.COPYFILE_EXCL);
|
|
93
|
+
} else {
|
|
94
|
+
throw new CharacterEditError(`Cannot copy non-regular file: ${sourcePath}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function createPersonalCharacter(
|
|
100
|
+
personalRoot: string,
|
|
101
|
+
id: string,
|
|
102
|
+
markdown: string,
|
|
103
|
+
): Promise<Character> {
|
|
104
|
+
const target = await ensureNewTarget(personalRoot, id);
|
|
105
|
+
const normalized = assertValidCard(markdown, join(target, "CHARACTER.md"));
|
|
106
|
+
const stagingRoot = await mkdtemp(join(await realpath(personalRoot), ".incarnate-create-"));
|
|
107
|
+
try {
|
|
108
|
+
const stagingCharacter = join(stagingRoot, id);
|
|
109
|
+
await mkdir(stagingCharacter);
|
|
110
|
+
await writeFile(join(stagingCharacter, "CHARACTER.md"), normalized, { encoding: "utf8", flag: "wx" });
|
|
111
|
+
await loadCharacter(stagingRoot, id);
|
|
112
|
+
await rename(stagingCharacter, target);
|
|
113
|
+
} finally {
|
|
114
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
return await loadCharacter(personalRoot, id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function resolvePersonalDirectory(personalRoot: string, id: string): Promise<string> {
|
|
120
|
+
if (!isCharacterId(id)) throw new CharacterEditError(`Invalid character id: ${id}`);
|
|
121
|
+
const canonicalRoot = await realpath(personalRoot);
|
|
122
|
+
const candidate = join(canonicalRoot, id);
|
|
123
|
+
const info = await lstat(candidate).catch(() => undefined);
|
|
124
|
+
if (!info?.isDirectory() || info.isSymbolicLink()) {
|
|
125
|
+
throw new CharacterEditError(`Personal character directory is missing or invalid: ${id}`);
|
|
126
|
+
}
|
|
127
|
+
const canonicalDirectory = await realpath(candidate);
|
|
128
|
+
if (!isWithin(canonicalRoot, canonicalDirectory) || dirname(canonicalDirectory) !== canonicalRoot) {
|
|
129
|
+
throw new CharacterEditError(`Personal character directory escapes its root: ${id}`);
|
|
130
|
+
}
|
|
131
|
+
return canonicalDirectory;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function updatePersonalCharacter(
|
|
135
|
+
personalRoot: string,
|
|
136
|
+
id: string,
|
|
137
|
+
markdown: string,
|
|
138
|
+
): Promise<Character> {
|
|
139
|
+
const directory = await resolvePersonalDirectory(personalRoot, id);
|
|
140
|
+
const cardPath = join(directory, "CHARACTER.md");
|
|
141
|
+
const normalized = assertValidCard(markdown, cardPath);
|
|
142
|
+
const temporaryPath = join(directory, `.CHARACTER.${crypto.randomUUID()}.tmp`);
|
|
143
|
+
try {
|
|
144
|
+
await writeFile(temporaryPath, normalized, { encoding: "utf8", flag: "wx" });
|
|
145
|
+
await rename(temporaryPath, cardPath);
|
|
146
|
+
} finally {
|
|
147
|
+
await rm(temporaryPath, { force: true });
|
|
148
|
+
}
|
|
149
|
+
return await loadCharacter(personalRoot, id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function readPersonalCharacterDraft(personalRoot: string, id: string): Promise<string> {
|
|
153
|
+
const directory = await resolvePersonalDirectory(personalRoot, id);
|
|
154
|
+
const cardPath = join(directory, "CHARACTER.md");
|
|
155
|
+
let buffer: Buffer;
|
|
156
|
+
try {
|
|
157
|
+
const cardInfo = await lstat(cardPath);
|
|
158
|
+
if (!cardInfo.isFile() || cardInfo.isSymbolicLink()) {
|
|
159
|
+
throw new CharacterEditError(`Personal CHARACTER.md must be a regular file: ${id}`);
|
|
160
|
+
}
|
|
161
|
+
buffer = await readFile(cardPath);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error instanceof CharacterEditError) throw error;
|
|
164
|
+
throw new CharacterEditError(`Cannot read personal character card: ${id}`);
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new CharacterEditError(`CHARACTER.md is not valid UTF-8: ${cardPath}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function createPersonalOverride(
|
|
174
|
+
personalRoot: string,
|
|
175
|
+
source: Character,
|
|
176
|
+
markdown: string,
|
|
177
|
+
): Promise<Character> {
|
|
178
|
+
const target = await ensureNewTarget(personalRoot, source.id);
|
|
179
|
+
const normalized = assertValidCard(markdown, join(target, "CHARACTER.md"));
|
|
180
|
+
const stagingRoot = await mkdtemp(join(await realpath(personalRoot), ".incarnate-copy-"));
|
|
181
|
+
try {
|
|
182
|
+
const stagingCharacter = join(stagingRoot, source.id);
|
|
183
|
+
await copyRegularTree(source.directory, stagingCharacter);
|
|
184
|
+
await writeFile(join(stagingCharacter, "CHARACTER.md"), normalized, "utf8");
|
|
185
|
+
await loadCharacter(stagingRoot, source.id);
|
|
186
|
+
await rename(stagingCharacter, target);
|
|
187
|
+
} finally {
|
|
188
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
return await loadCharacter(personalRoot, source.id);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function readCharacterCard(character: Character): Promise<string> {
|
|
194
|
+
return await readFile(character.cardPath, "utf8");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function isPersonalCharacter(character: Character, personalRoot: string): boolean {
|
|
198
|
+
return dirname(character.directory) === resolve(personalRoot);
|
|
199
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { lstat, mkdir, realpath, rename } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CharacterEditError, resolvePersonalDirectory } from "./character-editor.ts";
|
|
5
|
+
import { discoverCharacters, isCharacterId, loadCharacter, type CharacterDiscovery, type Character } from "./character-loader.ts";
|
|
6
|
+
|
|
7
|
+
export function characterArchiveRoot(personalRoot: string): string {
|
|
8
|
+
return join(dirname(resolve(personalRoot)), "archive");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function ensureRealRoot(root: string): Promise<string> {
|
|
12
|
+
await mkdir(root, { recursive: true });
|
|
13
|
+
const canonicalRoot = await resolveExistingRoot(root);
|
|
14
|
+
if (!canonicalRoot) throw new CharacterEditError(`Character root disappeared: ${root}`);
|
|
15
|
+
return canonicalRoot;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function resolveExistingRoot(root: string): Promise<string | undefined> {
|
|
19
|
+
let info;
|
|
20
|
+
try {
|
|
21
|
+
info = await lstat(root);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
27
|
+
throw new CharacterEditError(`Character root must be a real directory: ${root}`);
|
|
28
|
+
}
|
|
29
|
+
return await realpath(root);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function assertMissing(path: string, message: string): Promise<void> {
|
|
33
|
+
try {
|
|
34
|
+
await lstat(path);
|
|
35
|
+
throw new CharacterEditError(message);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof CharacterEditError) throw error;
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function renamePersonalCharacter(
|
|
43
|
+
personalRoot: string,
|
|
44
|
+
currentId: string,
|
|
45
|
+
nextId: string,
|
|
46
|
+
): Promise<Character> {
|
|
47
|
+
if (!isCharacterId(nextId)) throw new CharacterEditError(`Invalid character id: ${nextId}`);
|
|
48
|
+
if (currentId === nextId) throw new CharacterEditError("The new character id is unchanged");
|
|
49
|
+
const source = await resolvePersonalDirectory(personalRoot, currentId);
|
|
50
|
+
const canonicalRoot = await realpath(personalRoot);
|
|
51
|
+
const target = join(canonicalRoot, nextId);
|
|
52
|
+
await assertMissing(target, `A personal character already exists: ${nextId}`);
|
|
53
|
+
|
|
54
|
+
await rename(source, target);
|
|
55
|
+
try {
|
|
56
|
+
return await loadCharacter(canonicalRoot, nextId);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
await rename(target, source).catch(() => undefined);
|
|
59
|
+
throw new CharacterEditError(`Cannot rename character: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function archivePersonalCharacter(personalRoot: string, id: string): Promise<string> {
|
|
64
|
+
const source = await resolvePersonalDirectory(personalRoot, id);
|
|
65
|
+
await loadCharacter(personalRoot, id);
|
|
66
|
+
const archiveRoot = await ensureRealRoot(characterArchiveRoot(personalRoot));
|
|
67
|
+
const target = join(archiveRoot, id);
|
|
68
|
+
await assertMissing(target, `An archived character already exists: ${id}`);
|
|
69
|
+
await rename(source, target);
|
|
70
|
+
return target;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function discoverArchivedCharacters(personalRoot: string): Promise<CharacterDiscovery> {
|
|
74
|
+
const archiveRoot = await resolveExistingRoot(characterArchiveRoot(personalRoot));
|
|
75
|
+
return archiveRoot ? await discoverCharacters(archiveRoot) : { characters: [], issues: [] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function restoreArchivedCharacter(personalRoot: string, id: string): Promise<Character> {
|
|
79
|
+
if (!isCharacterId(id)) throw new CharacterEditError(`Invalid character id: ${id}`);
|
|
80
|
+
const archiveRoot = await ensureRealRoot(characterArchiveRoot(personalRoot));
|
|
81
|
+
const archived = await loadCharacter(archiveRoot, id);
|
|
82
|
+
await mkdir(personalRoot, { recursive: true });
|
|
83
|
+
const canonicalPersonalRoot = await realpath(personalRoot);
|
|
84
|
+
const target = join(canonicalPersonalRoot, id);
|
|
85
|
+
await assertMissing(target, `A personal character already exists: ${id}`);
|
|
86
|
+
|
|
87
|
+
await rename(archived.directory, target);
|
|
88
|
+
try {
|
|
89
|
+
return await loadCharacter(canonicalPersonalRoot, id);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
await rename(target, archived.directory).catch(() => undefined);
|
|
92
|
+
throw new CharacterEditError(`Cannot restore character: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
93
|
+
}
|
|
94
|
+
}
|