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.
@@ -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(cardPath);
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 markdown = decodeUtf8(buffer, cardPath).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trim();
143
- const parsed = parseCard(markdown, cardPath);
144
- let mood: MoodConfig;
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 { CharacterLoadError, discoverCharacters, loadCharacter } from "./character-loader.ts";
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 on|off";
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: "List, enable, disable, or inspect character personas",
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 = ["on", "off"]
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 { characters } = await discoverCharacters(charactersRoot);
45
- const matches = characters
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 { characters, issues } = await discoverCharacters(charactersRoot);
55
- if (characters.length === 0) {
56
- ctx.ui.notify(`No valid characters found in ${charactersRoot}`, "warning");
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 = characters
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 loadCharacter(charactersRoot, id);
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.avatarEnabled ? "on" : "off"}\nForms: ${active.forms.filter((form) => form.status === "available").length}/${active.forms.length} available\nCard: ${active.cardPath}`
95
- : `Character mode: off\nAvatar: ${state.avatarEnabled ? "on" : "off"}`,
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 (subcommand === "avatar" && (id === "on" || id === "off") && extra.length === 0) {
119
- state.setAvatarEnabled(id === "on");
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: ${id}`, "info");
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
+ }