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
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
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
characterSource,
|
|
5
|
+
discoverCharacterCatalog,
|
|
6
|
+
loadCatalogCharacter,
|
|
7
|
+
type CharacterLocations,
|
|
8
|
+
} from "./character-catalog.ts";
|
|
9
|
+
import { CharacterLoadError } from "./character-loader.ts";
|
|
10
|
+
import { openIncarnateMenu } from "./menu.ts";
|
|
11
|
+
import type { AvatarMode } from "./session-state.ts";
|
|
4
12
|
import type { IncarnateSessionState } from "./session-state.ts";
|
|
5
13
|
|
|
6
14
|
export interface IncarnateCommandDependencies {
|
|
7
15
|
charactersRoot: string;
|
|
16
|
+
personalCharactersRoot?: string;
|
|
8
17
|
state: IncarnateSessionState;
|
|
9
18
|
onStateChange?: (ctx: ExtensionCommandContext) => Promise<void> | void;
|
|
10
19
|
}
|
|
11
20
|
|
|
12
21
|
const USAGE =
|
|
13
|
-
"Usage: /incarnate list | use <character-id> | off | status | mood <preset> | avatar
|
|
22
|
+
"Usage: /incarnate list | use <character-id> | off | status | mood <preset> | avatar auto|full|compact|off";
|
|
14
23
|
|
|
15
24
|
function notifyError(ctx: ExtensionCommandContext, message: string): void {
|
|
16
25
|
ctx.ui.notify(message, "error");
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
export function registerIncarnateCommand(pi: ExtensionAPI, dependencies: IncarnateCommandDependencies): void {
|
|
20
|
-
const { charactersRoot, state, onStateChange } = dependencies;
|
|
29
|
+
const { charactersRoot, personalCharactersRoot, state, onStateChange } = dependencies;
|
|
30
|
+
const locations: CharacterLocations = { builtInRoot: charactersRoot, personalRoot: personalCharactersRoot };
|
|
21
31
|
|
|
22
32
|
pi.registerCommand("incarnate", {
|
|
23
|
-
description: "
|
|
33
|
+
description: "Open the character menu or manage personas with subcommands",
|
|
24
34
|
getArgumentCompletions: async (prefix) => {
|
|
25
35
|
const [subcommand = "", argument = ""] = prefix.trimStart().split(/\s+/, 2);
|
|
26
36
|
if (!prefix.trimStart().includes(" ")) {
|
|
@@ -29,7 +39,7 @@ export function registerIncarnateCommand(pi: ExtensionAPI, dependencies: Incarna
|
|
|
29
39
|
.map((value) => ({ value, label: value }));
|
|
30
40
|
}
|
|
31
41
|
if (subcommand === "avatar") {
|
|
32
|
-
const matches = ["
|
|
42
|
+
const matches = ["auto", "full", "compact", "off", "on"]
|
|
33
43
|
.filter((value) => value.startsWith(argument))
|
|
34
44
|
.map((value) => ({ value: `avatar ${value}`, label: value }));
|
|
35
45
|
return matches.length > 0 ? matches : null;
|
|
@@ -41,22 +51,31 @@ export function registerIncarnateCommand(pi: ExtensionAPI, dependencies: Incarna
|
|
|
41
51
|
return matches.length > 0 ? matches : null;
|
|
42
52
|
}
|
|
43
53
|
if (subcommand !== "use") return null;
|
|
44
|
-
const {
|
|
45
|
-
const matches =
|
|
46
|
-
.filter((character) => character.id.startsWith(argument))
|
|
47
|
-
.map((character) => ({ value: `use ${character.id}`, label: character.id, description: character.name }));
|
|
54
|
+
const { entries } = await discoverCharacterCatalog(locations);
|
|
55
|
+
const matches = entries
|
|
56
|
+
.filter(({ character }) => character.id.startsWith(argument))
|
|
57
|
+
.map(({ character }) => ({ value: `use ${character.id}`, label: character.id, description: character.name }));
|
|
48
58
|
return matches.length > 0 ? matches : null;
|
|
49
59
|
},
|
|
50
60
|
handler: async (args, ctx) => {
|
|
51
61
|
const [subcommand = "", id, ...extra] = args.trim().split(/\s+/).filter(Boolean);
|
|
52
62
|
|
|
63
|
+
if (!subcommand) {
|
|
64
|
+
if (ctx.hasUI) {
|
|
65
|
+
await openIncarnateMenu(ctx, { locations, state, onStateChange });
|
|
66
|
+
} else {
|
|
67
|
+
ctx.ui.notify(USAGE, "info");
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
53
72
|
if (subcommand === "list" && !id) {
|
|
54
|
-
const {
|
|
55
|
-
if (
|
|
56
|
-
ctx.ui.notify(
|
|
73
|
+
const { entries, issues } = await discoverCharacterCatalog(locations);
|
|
74
|
+
if (entries.length === 0) {
|
|
75
|
+
ctx.ui.notify("No valid characters found", "warning");
|
|
57
76
|
} else {
|
|
58
|
-
const list =
|
|
59
|
-
.map((character) => `${character.id} — ${character.name}${state.activeCharacter?.id === character.id ? " (active)" : ""}`)
|
|
77
|
+
const list = entries
|
|
78
|
+
.map(({ character, source }) => `${character.id} — ${character.name}${state.activeCharacter?.id === character.id ? " (active)" : ""} [${source}]`)
|
|
60
79
|
.join("\n");
|
|
61
80
|
ctx.ui.notify(list, "info");
|
|
62
81
|
}
|
|
@@ -68,7 +87,7 @@ export function registerIncarnateCommand(pi: ExtensionAPI, dependencies: Incarna
|
|
|
68
87
|
|
|
69
88
|
if (subcommand === "use" && id && extra.length === 0) {
|
|
70
89
|
try {
|
|
71
|
-
const character = await
|
|
90
|
+
const { character } = await loadCatalogCharacter(locations, id);
|
|
72
91
|
state.activate(character);
|
|
73
92
|
await onStateChange?.(ctx);
|
|
74
93
|
ctx.ui.notify(`Character enabled: ${character.name} (${character.id})`, "info");
|
|
@@ -91,8 +110,8 @@ export function registerIncarnateCommand(pi: ExtensionAPI, dependencies: Incarna
|
|
|
91
110
|
const active = state.activeCharacter;
|
|
92
111
|
ctx.ui.notify(
|
|
93
112
|
active
|
|
94
|
-
? `Active character: ${active.name} (${active.id})\nMood: ${state.currentMood ?? "none"}\nAvatar: ${state.
|
|
95
|
-
: `Character mode: off\nAvatar: ${state.
|
|
113
|
+
? `Active character: ${active.name} (${active.id})\nSource: ${characterSource(active, locations)}\nMood: ${state.currentMood ?? "none"}\nAvatar: ${state.avatarMode}\nForms: ${active.forms.filter((form) => form.status === "available").length}/${active.forms.length} available\nCard: ${active.cardPath}`
|
|
114
|
+
: `Character mode: off\nAvatar: ${state.avatarMode}`,
|
|
96
115
|
"info",
|
|
97
116
|
);
|
|
98
117
|
return;
|
|
@@ -115,10 +134,16 @@ export function registerIncarnateCommand(pi: ExtensionAPI, dependencies: Incarna
|
|
|
115
134
|
return;
|
|
116
135
|
}
|
|
117
136
|
|
|
118
|
-
if (
|
|
119
|
-
|
|
137
|
+
if (
|
|
138
|
+
subcommand === "avatar" &&
|
|
139
|
+
id &&
|
|
140
|
+
["auto", "full", "compact", "off", "on"].includes(id) &&
|
|
141
|
+
extra.length === 0
|
|
142
|
+
) {
|
|
143
|
+
const mode: AvatarMode = id === "on" ? "auto" : (id as AvatarMode);
|
|
144
|
+
state.setAvatarMode(mode);
|
|
120
145
|
await onStateChange?.(ctx);
|
|
121
|
-
ctx.ui.notify(`Avatar: ${
|
|
146
|
+
ctx.ui.notify(`Avatar: ${mode}`, "info");
|
|
122
147
|
return;
|
|
123
148
|
}
|
|
124
149
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CharacterEditError, resolvePersonalDirectory } from "./character-editor.ts";
|
|
5
|
+
|
|
6
|
+
export const FORM_MAX_BYTES = 256 * 1024;
|
|
7
|
+
|
|
8
|
+
export interface FormDraft {
|
|
9
|
+
path: string;
|
|
10
|
+
content: string;
|
|
11
|
+
exists: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isWithin(parent: string, child: string): boolean {
|
|
15
|
+
const pathFromParent = relative(parent, child);
|
|
16
|
+
return pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formTarget(characterDirectory: string, declaredPath: string): string {
|
|
20
|
+
if (!declaredPath || isAbsolute(declaredPath) || extname(declaredPath).toLowerCase() !== ".md") {
|
|
21
|
+
throw new CharacterEditError("Editable form paths must be relative .md files");
|
|
22
|
+
}
|
|
23
|
+
const target = resolve(characterDirectory, declaredPath);
|
|
24
|
+
if (!isWithin(characterDirectory, target) || target === characterDirectory) {
|
|
25
|
+
throw new CharacterEditError(`Form path escapes the character directory: ${declaredPath}`);
|
|
26
|
+
}
|
|
27
|
+
return target;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formTemplate(label: string): string {
|
|
31
|
+
return `# ${label}\n\n在这里记录希望角色按需读取的偏好信息。\n`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function inspectExistingForm(characterDirectory: string, target: string): Promise<Buffer | undefined> {
|
|
35
|
+
let info;
|
|
36
|
+
try {
|
|
37
|
+
info = await lstat(target);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
40
|
+
throw new CharacterEditError(`Cannot inspect form file: ${target}`);
|
|
41
|
+
}
|
|
42
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
43
|
+
throw new CharacterEditError(`Form must be a regular file, not a symbolic link: ${target}`);
|
|
44
|
+
}
|
|
45
|
+
const canonicalTarget = await realpath(target);
|
|
46
|
+
if (!isWithin(characterDirectory, canonicalTarget)) {
|
|
47
|
+
throw new CharacterEditError(`Form path escapes the character directory: ${target}`);
|
|
48
|
+
}
|
|
49
|
+
if (info.size > FORM_MAX_BYTES) throw new CharacterEditError(`Form exceeds the ${FORM_MAX_BYTES}-byte editor limit: ${target}`);
|
|
50
|
+
const bytes = await readFile(canonicalTarget);
|
|
51
|
+
if (bytes.byteLength > FORM_MAX_BYTES) throw new CharacterEditError(`Form exceeds the ${FORM_MAX_BYTES}-byte editor limit: ${target}`);
|
|
52
|
+
return bytes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readFormDraft(
|
|
56
|
+
characterDirectory: string,
|
|
57
|
+
declaredPath: string,
|
|
58
|
+
label: string,
|
|
59
|
+
): Promise<FormDraft> {
|
|
60
|
+
const canonicalDirectory = await realpath(characterDirectory);
|
|
61
|
+
const target = formTarget(canonicalDirectory, declaredPath);
|
|
62
|
+
const bytes = await inspectExistingForm(canonicalDirectory, target);
|
|
63
|
+
if (!bytes) return { path: target, content: formTemplate(label), exists: false };
|
|
64
|
+
try {
|
|
65
|
+
return { path: target, content: new TextDecoder("utf-8", { fatal: true }).decode(bytes), exists: true };
|
|
66
|
+
} catch {
|
|
67
|
+
throw new CharacterEditError(`Form is not valid UTF-8: ${target}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function ensureSafeParent(characterDirectory: string, target: string): Promise<string> {
|
|
72
|
+
const relativeParent = relative(characterDirectory, dirname(target));
|
|
73
|
+
let current = characterDirectory;
|
|
74
|
+
for (const segment of relativeParent.split(sep).filter((value) => value && value !== ".")) {
|
|
75
|
+
current = resolve(current, segment);
|
|
76
|
+
try {
|
|
77
|
+
await mkdir(current);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
80
|
+
}
|
|
81
|
+
const info = await lstat(current);
|
|
82
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
83
|
+
throw new CharacterEditError(`Form parent must be a regular directory: ${current}`);
|
|
84
|
+
}
|
|
85
|
+
const canonicalCurrent = await realpath(current);
|
|
86
|
+
if (!isWithin(characterDirectory, canonicalCurrent)) {
|
|
87
|
+
throw new CharacterEditError(`Form parent escapes the character directory: ${current}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return current;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function savePersonalForm(
|
|
94
|
+
personalRoot: string,
|
|
95
|
+
id: string,
|
|
96
|
+
declaredPath: string,
|
|
97
|
+
content: string,
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const characterDirectory = await resolvePersonalDirectory(personalRoot, id);
|
|
100
|
+
const target = formTarget(characterDirectory, declaredPath);
|
|
101
|
+
await inspectExistingForm(characterDirectory, target);
|
|
102
|
+
const parent = await ensureSafeParent(characterDirectory, target);
|
|
103
|
+
const normalized = `${content.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trimEnd()}\n`;
|
|
104
|
+
if (Buffer.byteLength(normalized, "utf8") > FORM_MAX_BYTES) {
|
|
105
|
+
throw new CharacterEditError(`Form exceeds the ${FORM_MAX_BYTES}-byte editor limit: ${target}`);
|
|
106
|
+
}
|
|
107
|
+
const temporaryPath = resolve(parent, `.form-${crypto.randomUUID()}.tmp`);
|
|
108
|
+
try {
|
|
109
|
+
await writeFile(temporaryPath, normalized, { encoding: "utf8", flag: "wx" });
|
|
110
|
+
await rename(temporaryPath, target);
|
|
111
|
+
} finally {
|
|
112
|
+
await rm(temporaryPath, { force: true });
|
|
113
|
+
}
|
|
114
|
+
return target;
|
|
115
|
+
}
|
package/src/markdown.ts
CHANGED
|
@@ -56,3 +56,15 @@ export function extractLevelTwoSections(markdown: string): MarkdownSection[] {
|
|
|
56
56
|
export function findLevelTwoSection(markdown: string, name: string): string | undefined {
|
|
57
57
|
return extractLevelTwoSections(markdown).find((section) => section.name.toLowerCase() === name.toLowerCase())?.content;
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
export function removeLevelTwoSection(markdown: string, name: string): string {
|
|
61
|
+
const { lines, headings } = findHeadings(markdown, 2);
|
|
62
|
+
const excluded = new Set<number>();
|
|
63
|
+
headings.forEach((heading, index) => {
|
|
64
|
+
if (heading.name.toLowerCase() !== name.toLowerCase()) return;
|
|
65
|
+
const end = headings[index + 1]?.line ?? lines.length;
|
|
66
|
+
for (let line = heading.line; line < end; line += 1) excluded.add(line);
|
|
67
|
+
});
|
|
68
|
+
if (excluded.size === 0) return markdown;
|
|
69
|
+
return lines.filter((_line, index) => !excluded.has(index)).join("\n").trim();
|
|
70
|
+
}
|