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,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
|
+
}
|
package/src/character-loader.ts
CHANGED
|
@@ -41,6 +41,13 @@ export interface CharacterDiscovery {
|
|
|
41
41
|
issues: CharacterIssue[];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export interface ParsedCharacterCard {
|
|
45
|
+
markdown: string;
|
|
46
|
+
name: string;
|
|
47
|
+
sections: Readonly<Record<RequiredSection, string>>;
|
|
48
|
+
mood: MoodConfig;
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
export class CharacterLoadError extends Error {
|
|
45
52
|
readonly code: CharacterIssueCode;
|
|
46
53
|
readonly path: string;
|
|
@@ -94,6 +101,19 @@ function parseCard(markdown: string, cardPath: string): Pick<Character, "name" |
|
|
|
94
101
|
};
|
|
95
102
|
}
|
|
96
103
|
|
|
104
|
+
export function parseCharacterCard(markdown: string, cardPath = "CHARACTER.md"): ParsedCharacterCard {
|
|
105
|
+
const normalized = markdown.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trim();
|
|
106
|
+
const parsed = parseCard(normalized, cardPath);
|
|
107
|
+
let mood: MoodConfig;
|
|
108
|
+
try {
|
|
109
|
+
mood = parseMoodConfig(normalized);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const message = error instanceof MoodConfigError ? error.message : "Invalid Current Mood section";
|
|
112
|
+
throw new CharacterLoadError("invalid-card", message, cardPath);
|
|
113
|
+
}
|
|
114
|
+
return { markdown: normalized, mood, ...parsed };
|
|
115
|
+
}
|
|
116
|
+
|
|
97
117
|
export function isCharacterId(value: string): boolean {
|
|
98
118
|
return CHARACTER_ID_PATTERN.test(value);
|
|
99
119
|
}
|
|
@@ -123,33 +143,37 @@ export async function loadCharacter(charactersRoot: string, id: string): Promise
|
|
|
123
143
|
}
|
|
124
144
|
|
|
125
145
|
const cardPath = join(characterDirectory, "CHARACTER.md");
|
|
146
|
+
let canonicalCardPath: string;
|
|
126
147
|
try {
|
|
127
148
|
if (!(await stat(characterDirectory)).isDirectory()) {
|
|
128
149
|
throw new CharacterLoadError("invalid-directory", `Character path is not a directory: ${id}`, characterDirectory);
|
|
129
150
|
}
|
|
151
|
+
const cardInfo = await lstat(cardPath);
|
|
152
|
+
if (!cardInfo.isFile() || cardInfo.isSymbolicLink()) {
|
|
153
|
+
throw new CharacterLoadError("outside-root", `CHARACTER.md must be a regular file: ${id}`, cardPath);
|
|
154
|
+
}
|
|
155
|
+
canonicalCardPath = await realpath(cardPath);
|
|
156
|
+
if (!isWithin(characterDirectory, canonicalCardPath) || dirname(canonicalCardPath) !== characterDirectory) {
|
|
157
|
+
throw new CharacterLoadError("outside-root", `CHARACTER.md escapes the character directory: ${id}`, cardPath);
|
|
158
|
+
}
|
|
130
159
|
} catch (error) {
|
|
131
160
|
if (error instanceof CharacterLoadError) throw error;
|
|
161
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
162
|
+
throw new CharacterLoadError("missing-card", `Missing or unreadable CHARACTER.md for: ${id}`, cardPath);
|
|
163
|
+
}
|
|
132
164
|
throw new CharacterLoadError("unreadable", `Cannot inspect character directory: ${id}`, characterDirectory);
|
|
133
165
|
}
|
|
134
166
|
|
|
135
167
|
let buffer: Buffer;
|
|
136
168
|
try {
|
|
137
|
-
buffer = await readFile(
|
|
169
|
+
buffer = await readFile(canonicalCardPath);
|
|
138
170
|
} catch {
|
|
139
171
|
throw new CharacterLoadError("missing-card", `Missing or unreadable CHARACTER.md for: ${id}`, cardPath);
|
|
140
172
|
}
|
|
141
173
|
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
mood = parseMoodConfig(markdown);
|
|
147
|
-
} catch (error) {
|
|
148
|
-
const message = error instanceof MoodConfigError ? error.message : "Invalid Current Mood section";
|
|
149
|
-
throw new CharacterLoadError("invalid-card", message, cardPath);
|
|
150
|
-
}
|
|
151
|
-
const forms = await resolveFormReferences(characterDirectory, markdown);
|
|
152
|
-
return { id, directory: characterDirectory, cardPath, markdown, mood, forms, ...parsed };
|
|
174
|
+
const parsed = parseCharacterCard(decodeUtf8(buffer, cardPath), cardPath);
|
|
175
|
+
const forms = await resolveFormReferences(characterDirectory, parsed.markdown);
|
|
176
|
+
return { id, directory: characterDirectory, cardPath, forms, ...parsed };
|
|
153
177
|
}
|
|
154
178
|
|
|
155
179
|
export async function discoverCharacters(charactersRoot: string): Promise<CharacterDiscovery> {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { CharacterEditError } from "./character-editor.ts";
|
|
2
|
+
|
|
3
|
+
export const GUIDED_CHARACTER_SECTIONS = [
|
|
4
|
+
"Identity",
|
|
5
|
+
"Personality",
|
|
6
|
+
"Speech Style",
|
|
7
|
+
"Behavior",
|
|
8
|
+
"Current Mood",
|
|
9
|
+
"Tools and Forms",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export type GuidedCharacterSection = (typeof GUIDED_CHARACTER_SECTIONS)[number];
|
|
13
|
+
|
|
14
|
+
export interface CharacterSectionDraft {
|
|
15
|
+
content: string;
|
|
16
|
+
exists: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface HeadingLocation {
|
|
20
|
+
level: number;
|
|
21
|
+
line: number;
|
|
22
|
+
name: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizedLines(markdown: string): string[] {
|
|
26
|
+
return markdown.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function headings(lines: readonly string[]): HeadingLocation[] {
|
|
30
|
+
const locations: HeadingLocation[] = [];
|
|
31
|
+
let fence: { marker: string; length: number } | undefined;
|
|
32
|
+
lines.forEach((line, index) => {
|
|
33
|
+
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
|
34
|
+
if (fenceMatch) {
|
|
35
|
+
const marker = fenceMatch[1]![0]!;
|
|
36
|
+
if (!fence) fence = { marker, length: fenceMatch[1]!.length };
|
|
37
|
+
else if (fence.marker === marker && fenceMatch[1]!.length >= fence.length) fence = undefined;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (fence) return;
|
|
41
|
+
const match = line.match(/^(#{1,6})(?!#)\s+(.+?)(?:\s+#+)?\s*$/);
|
|
42
|
+
if (match) locations.push({ level: match[1]!.length, line: index, name: match[2]!.trim() });
|
|
43
|
+
});
|
|
44
|
+
return locations;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeBody(content: string): string {
|
|
48
|
+
return content.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function finish(lines: readonly string[]): string {
|
|
52
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sectionLocation(lines: readonly string[], name: string): { heading: HeadingLocation; endLine: number } | undefined {
|
|
56
|
+
const levelTwo = headings(lines).filter((heading) => heading.level === 2);
|
|
57
|
+
const matches = levelTwo.filter((heading) => heading.name.toLowerCase() === name.toLowerCase());
|
|
58
|
+
if (matches.length > 1) throw new CharacterEditError(`Character card contains duplicate sections: ${name}`);
|
|
59
|
+
const heading = matches[0];
|
|
60
|
+
if (!heading) return undefined;
|
|
61
|
+
const next = levelTwo.find((candidate) => candidate.line > heading.line);
|
|
62
|
+
return { heading, endLine: next?.line ?? lines.length };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readCharacterSection(markdown: string, name: GuidedCharacterSection): CharacterSectionDraft {
|
|
66
|
+
const lines = normalizedLines(markdown);
|
|
67
|
+
const location = sectionLocation(lines, name);
|
|
68
|
+
if (!location) return { content: "", exists: false };
|
|
69
|
+
return {
|
|
70
|
+
content: lines.slice(location.heading.line + 1, location.endLine).join("\n").trim(),
|
|
71
|
+
exists: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function updateCharacterSection(
|
|
76
|
+
markdown: string,
|
|
77
|
+
name: GuidedCharacterSection,
|
|
78
|
+
content: string,
|
|
79
|
+
): string {
|
|
80
|
+
const lines = normalizedLines(markdown);
|
|
81
|
+
const location = sectionLocation(lines, name);
|
|
82
|
+
const body = normalizeBody(content);
|
|
83
|
+
if (headings(body.split("\n")).some((heading) => heading.level <= 2)) {
|
|
84
|
+
throw new CharacterEditError("Section content cannot contain level-one or level-two headings; use the complete card editor");
|
|
85
|
+
}
|
|
86
|
+
if (!location) {
|
|
87
|
+
const existing = lines.join("\n").trimEnd();
|
|
88
|
+
return `${existing}\n\n## ${name}\n\n${body}${body ? "\n" : ""}`;
|
|
89
|
+
}
|
|
90
|
+
const replacement = body ? ["", ...body.split("\n"), ""] : [""];
|
|
91
|
+
return finish([
|
|
92
|
+
...lines.slice(0, location.heading.line + 1),
|
|
93
|
+
...replacement,
|
|
94
|
+
...lines.slice(location.endLine),
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function updateCharacterName(markdown: string, name: string): string {
|
|
99
|
+
const normalizedName = name.trim().replace(/\s*[\r\n]+\s*/g, " ");
|
|
100
|
+
if (!normalizedName) throw new CharacterEditError("Character name must not be empty");
|
|
101
|
+
if (normalizedName.length > 120) throw new CharacterEditError("Character name must not exceed 120 characters");
|
|
102
|
+
if (/\s#+$/.test(normalizedName)) {
|
|
103
|
+
throw new CharacterEditError("Character name must not end with Markdown closing hashes");
|
|
104
|
+
}
|
|
105
|
+
const lines = normalizedLines(markdown);
|
|
106
|
+
const firstHeading = headings(lines).find((heading) => heading.level === 1);
|
|
107
|
+
if (!firstHeading) throw new CharacterEditError("CHARACTER.md must contain a level-one character name");
|
|
108
|
+
lines[firstHeading.line] = `# ${normalizedName}`;
|
|
109
|
+
return finish(lines);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function sectionStarter(name: GuidedCharacterSection): string {
|
|
113
|
+
if (name === "Current Mood") {
|
|
114
|
+
return "Default: normal\n\n### normal\n\n保持角色的常态表达方式。";
|
|
115
|
+
}
|
|
116
|
+
if (name === "Tools and Forms") {
|
|
117
|
+
return "- 偏好表名称:`forms/preferences.md`";
|
|
118
|
+
}
|
|
119
|
+
return "";
|
|
120
|
+
}
|