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/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
+ }