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