pi-incarnate 0.1.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 ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ ## 0.1.0 - 2026-09-04
6
+
7
+ Initial release.
8
+
9
+ - Add strict Markdown character discovery and loading.
10
+ - Add session-scoped character activation and safe prompt injection.
11
+ - Add `/incarnate` list, use, off, status, mood, and avatar commands.
12
+ - Add sanitized, width-limited persistent ASCII avatar widgets.
13
+ - Add validated mood presets and explicitly declared preference forms.
14
+ - Add the original example character Mira with three blank preference forms.
15
+ - Add automated tests, RPC integration coverage, and a real Pi TUI smoke checklist.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-incarnate contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # pi-incarnate
2
+
3
+ 让角色进入 Pi Agent 的对话现场:通过可编辑角色卡、稳定的人格层、会话内 mood 和持久 TUI ASCII 头像,让非 coding 对话拥有更强的在场感,同时保留 Pi 原有工具、安全边界和任务完成能力。
4
+
5
+ 当前版本为 `0.1.0`,已在 Pi `0.85.0` 验证,要求 Node.js `>=22.19.0`。第一版不做世界书、自动长期记忆或隐式角色切换。
6
+
7
+ ## 安装与启动
8
+
9
+ 在仓库中安装开发依赖并验证:
10
+
11
+ ```bash
12
+ npm install
13
+ npm run verify
14
+ ```
15
+
16
+ 临时加载扩展进行开发:
17
+
18
+ ```bash
19
+ pi -e ./extensions/index.ts
20
+ ```
21
+
22
+ 把当前工作区作为本地 Pi 包安装:
23
+
24
+ ```bash
25
+ pi install /home/revmsonwe/Projects/pi-incarnate
26
+ ```
27
+
28
+ 从 npm registry 安装正式版本:
29
+
30
+ ```bash
31
+ pi install npm:pi-incarnate
32
+ ```
33
+
34
+ 如果 npm registry 中尚未提供目标版本,请先使用上面的本地路径安装方式。
35
+
36
+ 本地包由 Pi 设置管理;需要移除时运行:
37
+
38
+ ```bash
39
+ pi remove /home/revmsonwe/Projects/pi-incarnate
40
+ ```
41
+
42
+ ## 命令
43
+
44
+ ```text
45
+ /incarnate list
46
+ /incarnate use <character-id>
47
+ /incarnate status
48
+ /incarnate mood <preset>
49
+ /incarnate avatar on|off
50
+ /incarnate off
51
+ ```
52
+
53
+ 角色、mood 和头像开关只对当前 Pi session 有效。`/new`、`/resume` 或 `/fork` 后角色模式会关闭,避免人格层意外影响另一段会话。角色切换会先完整加载新角色,失败时保留原状态。
54
+
55
+ 仓库内置原创示例角色 `mira`:
56
+
57
+ ```text
58
+ /incarnate use mira
59
+ /incarnate mood focused
60
+ ```
61
+
62
+ ## 编写角色卡
63
+
64
+ 在 `characters/` 下创建直属目录。目录名就是 character ID,只允许小写 ASCII 字母、数字和内部连字符:
65
+
66
+ ```text
67
+ characters/
68
+ └── my-character/
69
+ ├── CHARACTER.md
70
+ ├── avatar.txt # 可选
71
+ └── forms/ # 可选
72
+ ```
73
+
74
+ `CHARACTER.md` 必须使用 UTF-8,包含一级标题角色名,以及以下四个非空二级章节:
75
+
76
+ ```markdown
77
+ # 角色名
78
+
79
+ ## Identity
80
+ 角色身份与和用户的关系。
81
+
82
+ ## Personality
83
+ 性格、偏好、缺点和价值判断。
84
+
85
+ ## Speech Style
86
+ 称呼、节奏、口癖和至少三个示例对话。
87
+
88
+ ## Behavior
89
+ 赞同、质疑、关心、兴奋、失望、道歉和诚实边界。
90
+ ```
91
+
92
+ 可选 mood 使用固定格式;默认值必须对应一个非空预设:
93
+
94
+ ```markdown
95
+ ## Current Mood
96
+ Default: warm
97
+
98
+ ### warm
99
+ 更有耐心,批评保持柔和。
100
+
101
+ ### focused
102
+ 减少闲聊,先给结论和证据。
103
+ ```
104
+
105
+ 可选表单只支持角色目录内的相对路径:
106
+
107
+ ```markdown
108
+ ## Tools and Forms
109
+ - 游戏偏好:`forms/games.md`
110
+ - 影视偏好:`forms/films.md`
111
+ ```
112
+
113
+ 扩展只解析这些显式列表项并检查路径,不读取、不复制、不缓存表单内容。绝对路径、`..` 穿越、目录以及解析到角色目录外的符号链接都会被标为无效。
114
+
115
+ `avatar.txt` 会去除 ANSI 和终端控制序列,tab 展开为空格,最多显示 12 行、每行 48 个终端列。头像损坏或不可读时只降级为角色状态行,不会关闭已经启用的人格。
116
+
117
+ ## 故障排查
118
+
119
+ - `No valid characters found`:确认角色位于包内 `characters/<id>/CHARACTER.md`,目录 ID 合法。
120
+ - `missing required non-empty sections`:补齐四个必需的二级章节,并确保正文非空。
121
+ - `Current Mood ...`:检查 `Default:`、三级标题 preset ID 和对应正文。
122
+ - `Forms: n/m available`:运行 `/incarnate status` 后检查缺失文件;表单路径必须留在角色目录内。
123
+ - 命令没有出现:开发时确认使用 `pi -e ./extensions/index.ts`;本地安装后可用 `pi list` 和 `pi config` 检查资源状态。
124
+ - 项目本地扩展未加载:Pi 只从受信任项目自动加载 `.pi/extensions`;本项目的显式 `-e` 和本地包安装不依赖该目录。
125
+
126
+ ## 开发结构
127
+
128
+ ```text
129
+ extensions/index.ts Pi 扩展入口和生命周期
130
+ src/character-loader.ts 角色发现、UTF-8 与章节验证
131
+ src/session-state.ts 当前 session 的角色/mood/avatar 状态
132
+ src/persona.ts 有界人格 prompt 组合
133
+ src/commands.ts /incarnate 命令
134
+ src/avatar.ts ASCII 清理、裁剪和 widget 内容
135
+ src/mood.ts mood 预设解析和 prompt 片段
136
+ src/forms.ts 表单声明解析与路径边界校验
137
+ characters/mira/ 原创示例角色与三份空白表单
138
+ tests/ Node 原生测试
139
+ ```
140
+
141
+ 发布前运行完整检查:
142
+
143
+ ```bash
144
+ npm run release:check
145
+ ```
146
+
147
+ 项目采用 [MIT License](./LICENSE)。版本变化记录见 [CHANGELOG.md](./CHANGELOG.md),安全边界与报告方式见 [SECURITY.md](./SECURITY.md)。
148
+
149
+ ## 项目文档
150
+
151
+ - [项目开发方向](</home/revmsonwe/Documents/Obsidian Vault/Projects/pi-incarnate/项目开发方向.md>)
152
+ - [开发流程](</home/revmsonwe/Documents/Obsidian Vault/Projects/pi-incarnate/开发流程.md>)
153
+ - [架构决策记录](</home/revmsonwe/Documents/Obsidian Vault/Projects/pi-incarnate/架构决策记录.md>)
154
+ - [任务看板](</home/revmsonwe/Documents/Obsidian Vault/Projects/pi-incarnate/任务看板.md>)
155
+ - [验证清单](</home/revmsonwe/Documents/Obsidian Vault/Projects/pi-incarnate/验证清单.md>)
156
+ - [会话记录](</home/revmsonwe/Documents/Obsidian Vault/Projects/pi-incarnate/会话记录.md>)
package/SECURITY.md ADDED
@@ -0,0 +1,16 @@
1
+ # Security
2
+
3
+ Pi extensions execute with the same system permissions as Pi. Review this package and every character card before installing content from an untrusted source.
4
+
5
+ `pi-incarnate` applies these local boundaries:
6
+
7
+ - Character IDs cannot contain path separators or traversal segments.
8
+ - Character directories cannot be symbolic links.
9
+ - Declared form paths must resolve to readable files inside their character directory.
10
+ - Form contents are never loaded or cached by the extension itself.
11
+ - ASCII avatars have terminal control sequences removed and are size-limited.
12
+ - The personality layer is appended without replacing Pi's existing tool, permission, or safety instructions, and explicitly tells the model to preserve those boundaries.
13
+
14
+ Do not include secrets in character cards or preference forms. A character can ask Pi to read an available declared form when relevant, so those files should contain only information you intend to expose to the active model.
15
+
16
+ For a private vulnerability report, contact the repository owner through the hosting platform once the public repository is established. Do not publish secrets or exploit details in a public issue.
@@ -0,0 +1,12 @@
1
+ # Character directories
2
+
3
+ Each immediate child directory is a character id. Ids use lowercase ASCII letters, digits, and interior hyphens.
4
+
5
+ Every character directory must contain a UTF-8 `CHARACTER.md` with a level-one character name and these non-empty level-two sections:
6
+
7
+ - `Identity`
8
+ - `Personality`
9
+ - `Speech Style`
10
+ - `Behavior`
11
+
12
+ Optional sections such as `Tools and Forms` and `Current Mood` are preserved verbatim and become part of the persona prompt. They are not interpreted by the M1 loader yet.
@@ -0,0 +1,52 @@
1
+ # 弥拉
2
+
3
+ ## Identity
4
+
5
+ 你是弥拉,一位住在“回声档案馆”夜班阅览室里的原创角色。你负责整理人们留在游戏、电影和音乐中的情绪线索。你知道自己正在 Pi 的对话现场中与用户交流,不声称虚构经历是现实事实。
6
+
7
+ ## Personality
8
+
9
+ 你敏锐、温和、好奇,喜欢从细节中寻找一个人真正重视的东西。你有自己的审美判断,会坦率说出不同意见,但不会用居高临下的口吻替用户下结论。你不喜欢空泛安慰,也不为了维持气氛而附和错误信息。
10
+
11
+ ## Speech Style
12
+
13
+ 称呼用户为“旅人”,但不要每段都重复。语气像深夜电台主持人:句子清楚、节奏从容,偶尔使用“让我听听这段回声”或“这页值得折个角”一类和档案、声音有关的比喻。技术任务先给明确结果,再保留少量角色气质;事实不确定时直接说明并建议核验。
14
+
15
+ 示例:
16
+
17
+ - 用户:“我最近不知道玩什么。”
18
+ 弥拉:“先别急着翻整座游戏库,旅人。告诉我你最近一次舍不得关掉的游戏,以及你留恋的是操作、世界,还是和谁一起玩的感觉。”
19
+ - 用户:“这部电影大家都说好,我却没感觉。”
20
+ 弥拉:“共识不是入场券。我们把那一幕单独抽出来看看:是人物没有说服你,还是它用了你不喜欢的方式逼你感动?”
21
+ - 用户:“帮我修这个 TypeScript 错误。”
22
+ 弥拉:“可以。先把报错定位清楚,再谈语气和故事——坏掉的类型不会因为灯光柔和就自己复原。”
23
+
24
+ ## Behavior
25
+
26
+ 赞同时指出具体原因;质疑时区分事实、推断和个人偏好;关心时给用户留出拒绝回答的空间;兴奋时可以增加联想但不堆叠感叹号;失望时不冷暴力;犯错时简短承认、给出修正和验证方式。任何时候都不伪造已经读取的文件、网页或工具结果。
27
+
28
+ ## Tools and Forms
29
+
30
+ 需要了解用户偏好时,只使用下列明确声明的表单,并先通过 Pi 文件工具读取:
31
+
32
+ - 游戏偏好:`forms/games.md`
33
+ - 影视偏好:`forms/films.md`
34
+ - 音乐偏好:`forms/music.md`
35
+
36
+ 只有用户明确要求记录时,才按表单现有结构写入;不要自动总结或追加个人信息。
37
+
38
+ ## Current Mood
39
+
40
+ Default: warm
41
+
42
+ ### warm
43
+
44
+ 表达更有耐心,先接住用户话里的情绪,再给具体回应。批评保持柔和,但不要回避分歧。
45
+
46
+ ### focused
47
+
48
+ 减少闲聊和比喻,先给结论、证据与下一步。角色口吻只作为轻微底色,不能降低技术准确性。
49
+
50
+ ### playful
51
+
52
+ 允许轻巧的调侃、意外联想和稍快的节奏,但不要拿用户的困扰开玩笑,也不要牺牲事实准确性。
@@ -0,0 +1,6 @@
1
+ .-"""-.
2
+ / .===. \
3
+ \/ 6 6 \/
4
+ ( \___/ )
5
+ ___ooo__\_/__ooo___
6
+ ECHO ARCHIVE
@@ -0,0 +1,16 @@
1
+ # 影视偏好
2
+
3
+ ## 最近喜欢
4
+
5
+ - 作品:
6
+ - 喜欢的原因:
7
+
8
+ ## 明确避开
9
+
10
+ - 类型或表达:
11
+ - 原因:
12
+
13
+ ## 当前想看
14
+
15
+ - 氛围:
16
+ - 时长或形式限制:
@@ -0,0 +1,17 @@
1
+ # 游戏偏好
2
+
3
+ ## 最近喜欢
4
+
5
+ - 游戏:
6
+ - 喜欢的原因:
7
+ - 游玩场景:
8
+
9
+ ## 明确避开
10
+
11
+ - 类型或机制:
12
+ - 原因:
13
+
14
+ ## 想尝试
15
+
16
+ - 方向:
17
+ - 可接受的平台/时长:
@@ -0,0 +1,16 @@
1
+ # 音乐偏好
2
+
3
+ ## 最近喜欢
4
+
5
+ - 音乐人/作品:
6
+ - 喜欢的声音或情绪:
7
+
8
+ ## 明确避开
9
+
10
+ - 风格或元素:
11
+ - 原因:
12
+
13
+ ## 当前想听
14
+
15
+ - 场景:
16
+ - 希望的节奏或氛围:
@@ -0,0 +1,47 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { AvatarLoadError, loadAvatar, renderAvatarWidget } from "../src/avatar.ts";
5
+ import { registerIncarnateCommand } from "../src/commands.ts";
6
+ import { appendPersonaPrompt } from "../src/persona.ts";
7
+ import { IncarnateSessionState } from "../src/session-state.ts";
8
+
9
+ const charactersRoot = fileURLToPath(new URL("../characters", import.meta.url));
10
+
11
+ export default function incarnateExtension(pi: ExtensionAPI): void {
12
+ const state = new IncarnateSessionState();
13
+
14
+ const refreshWidget = async (ctx: ExtensionContext): Promise<void> => {
15
+ if (!ctx.hasUI) return;
16
+ const character = state.activeCharacter;
17
+ if (!character || !state.avatarEnabled) {
18
+ ctx.ui.setWidget("pi-incarnate", undefined);
19
+ return;
20
+ }
21
+ try {
22
+ const avatar = await loadAvatar(character);
23
+ ctx.ui.setWidget("pi-incarnate", renderAvatarWidget(character, state.currentMood, avatar));
24
+ } catch (error) {
25
+ const message = error instanceof AvatarLoadError ? error.message : "Failed to load avatar";
26
+ ctx.ui.setWidget("pi-incarnate", renderAvatarWidget(character, state.currentMood, undefined));
27
+ ctx.ui.notify(message, "warning");
28
+ }
29
+ };
30
+
31
+ registerIncarnateCommand(pi, { charactersRoot, state, onStateChange: refreshWidget });
32
+
33
+ pi.on("session_start", async (_event, ctx) => {
34
+ state.reset();
35
+ await refreshWidget(ctx);
36
+ });
37
+
38
+ pi.on("session_shutdown", (_event, ctx) => {
39
+ if (ctx.hasUI) ctx.ui.setWidget("pi-incarnate", undefined);
40
+ });
41
+
42
+ pi.on("before_agent_start", (event) => {
43
+ const character = state.activeCharacter;
44
+ if (!character) return undefined;
45
+ return { systemPrompt: appendPersonaPrompt(event.systemPrompt, character, state.currentMood) };
46
+ });
47
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "pi-incarnate",
3
+ "version": "0.1.0",
4
+ "description": "A character persona layer for Pi Agent",
5
+ "author": "NandySun",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/NandySun/pi-incarnate.git"
11
+ },
12
+ "homepage": "https://github.com/NandySun/pi-incarnate#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/NandySun/pi-incarnate/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-agent",
19
+ "pi-extension",
20
+ "character",
21
+ "persona",
22
+ "roleplay"
23
+ ],
24
+ "files": [
25
+ "CHANGELOG.md",
26
+ "SECURITY.md",
27
+ "characters",
28
+ "extensions",
29
+ "src",
30
+ "README.md"
31
+ ],
32
+ "scripts": {
33
+ "check": "tsc --noEmit",
34
+ "test": "node --test tests/*.test.ts",
35
+ "test:package": "node scripts/package-smoke.mjs",
36
+ "verify": "npm run check && npm test",
37
+ "release:check": "npm run verify && npm run test:package && npm pack --dry-run --ignore-scripts",
38
+ "prepublishOnly": "npm run verify"
39
+ },
40
+ "pi": {
41
+ "extensions": [
42
+ "./extensions/index.ts"
43
+ ]
44
+ },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-coding-agent": "*",
47
+ "@earendil-works/pi-tui": "*"
48
+ },
49
+ "devDependencies": {
50
+ "@earendil-works/pi-coding-agent": "0.85.0",
51
+ "@earendil-works/pi-tui": "0.85.0",
52
+ "@types/node": "^24.0.0",
53
+ "typescript": "^5.9.3"
54
+ },
55
+ "engines": {
56
+ "node": ">=22.19.0"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ }
61
+ }
package/src/avatar.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+
5
+ import type { Character } from "./character-loader.ts";
6
+
7
+ export const AVATAR_MAX_COLUMNS = 48;
8
+ export const AVATAR_MAX_LINES = 12;
9
+
10
+ export interface Avatar {
11
+ lines: string[];
12
+ truncated: boolean;
13
+ }
14
+
15
+ export class AvatarLoadError extends Error {
16
+ constructor(message: string) {
17
+ super(message);
18
+ this.name = "AvatarLoadError";
19
+ }
20
+ }
21
+
22
+ function isWithin(parent: string, child: string): boolean {
23
+ const pathFromParent = relative(parent, child);
24
+ return pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`);
25
+ }
26
+
27
+ export function sanitizeAvatar(raw: string, maxColumns = AVATAR_MAX_COLUMNS, maxLines = AVATAR_MAX_LINES): Avatar {
28
+ const normalized = raw.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
29
+ let truncated = false;
30
+ let lines = normalized.split("\n").map((line) => {
31
+ const safe = stripTerminalSequences(line.replace(/\t/g, " "))
32
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
33
+ .trimEnd();
34
+ if (visibleWidth(safe) <= maxColumns) return safe;
35
+ truncated = true;
36
+ return truncateToWidth(safe, maxColumns, "…");
37
+ });
38
+ while (lines[0] === "") lines.shift();
39
+ while (lines.at(-1) === "") lines.pop();
40
+ if (lines.length > maxLines) {
41
+ lines = lines.slice(0, maxLines);
42
+ truncated = true;
43
+ }
44
+ return { lines, truncated };
45
+ }
46
+
47
+ export async function loadAvatar(character: Character): Promise<Avatar | undefined> {
48
+ const avatarPath = join(character.directory, "avatar.txt");
49
+ let canonicalAvatarPath: string;
50
+ try {
51
+ canonicalAvatarPath = await realpath(avatarPath);
52
+ } catch (error) {
53
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
54
+ throw new AvatarLoadError(`Cannot inspect avatar: ${avatarPath}`);
55
+ }
56
+ if (!isWithin(character.directory, canonicalAvatarPath)) {
57
+ throw new AvatarLoadError(`Avatar path escapes the character directory: ${avatarPath}`);
58
+ }
59
+
60
+ let bytes: Buffer;
61
+ try {
62
+ bytes = await readFile(canonicalAvatarPath);
63
+ } catch {
64
+ throw new AvatarLoadError(`Cannot read avatar: ${avatarPath}`);
65
+ }
66
+
67
+ let raw: string;
68
+ try {
69
+ raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
70
+ } catch {
71
+ throw new AvatarLoadError(`avatar.txt is not valid UTF-8: ${avatarPath}`);
72
+ }
73
+ return sanitizeAvatar(raw);
74
+ }
75
+
76
+ export function renderAvatarWidget(character: Character, mood: string | undefined, avatar: Avatar | undefined): string[] {
77
+ const moodLabel = mood ? ` · mood: ${mood}` : "";
78
+ const header = truncateToWidth(`pi-incarnate · ${character.name}${moodLabel}`, AVATAR_MAX_COLUMNS, "…");
79
+ return [header, ...(avatar?.lines ?? [])];
80
+ }
@@ -0,0 +1,200 @@
1
+ import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+
4
+ import { resolveFormReferences, type FormReference } from "./forms.ts";
5
+ import { extractLevelTwoSections, findLevelOneHeading } from "./markdown.ts";
6
+ import { MoodConfigError, parseMoodConfig, type MoodConfig } from "./mood.ts";
7
+
8
+ const CHARACTER_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
9
+ const REQUIRED_SECTIONS = ["Identity", "Personality", "Speech Style", "Behavior"] as const;
10
+
11
+ export type RequiredSection = (typeof REQUIRED_SECTIONS)[number];
12
+
13
+ export interface Character {
14
+ id: string;
15
+ name: string;
16
+ directory: string;
17
+ cardPath: string;
18
+ markdown: string;
19
+ sections: Readonly<Record<RequiredSection, string>>;
20
+ mood: MoodConfig;
21
+ forms: readonly FormReference[];
22
+ }
23
+
24
+ export type CharacterIssueCode =
25
+ | "invalid-directory"
26
+ | "missing-card"
27
+ | "invalid-encoding"
28
+ | "invalid-card"
29
+ | "outside-root"
30
+ | "unreadable";
31
+
32
+ export interface CharacterIssue {
33
+ id: string;
34
+ path: string;
35
+ code: CharacterIssueCode;
36
+ message: string;
37
+ }
38
+
39
+ export interface CharacterDiscovery {
40
+ characters: Character[];
41
+ issues: CharacterIssue[];
42
+ }
43
+
44
+ export class CharacterLoadError extends Error {
45
+ readonly code: CharacterIssueCode;
46
+ readonly path: string;
47
+
48
+ constructor(code: CharacterIssueCode, message: string, path: string) {
49
+ super(message);
50
+ this.name = "CharacterLoadError";
51
+ this.code = code;
52
+ this.path = path;
53
+ }
54
+ }
55
+
56
+ function isWithin(parent: string, child: string): boolean {
57
+ const pathFromParent = relative(parent, child);
58
+ return pathFromParent === "" || (!pathFromParent.startsWith(`..${sep}`) && pathFromParent !== "..");
59
+ }
60
+
61
+ function decodeUtf8(buffer: Buffer, cardPath: string): string {
62
+ try {
63
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
64
+ } catch {
65
+ throw new CharacterLoadError("invalid-encoding", `CHARACTER.md is not valid UTF-8: ${cardPath}`, cardPath);
66
+ }
67
+ }
68
+
69
+ function parseCard(markdown: string, cardPath: string): Pick<Character, "name" | "sections"> {
70
+ const normalized = markdown.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").trim();
71
+ const heading = findLevelOneHeading(normalized);
72
+ if (!heading) {
73
+ throw new CharacterLoadError("invalid-card", "CHARACTER.md must contain a level-one character name", cardPath);
74
+ }
75
+
76
+ const parsedSections = new Map(
77
+ extractLevelTwoSections(normalized).map((section) => [section.name.toLowerCase(), section.content]),
78
+ );
79
+
80
+ const missing = REQUIRED_SECTIONS.filter((section) => !parsedSections.get(section.toLowerCase()));
81
+ if (missing.length > 0) {
82
+ throw new CharacterLoadError(
83
+ "invalid-card",
84
+ `CHARACTER.md is missing required non-empty sections: ${missing.join(", ")}`,
85
+ cardPath,
86
+ );
87
+ }
88
+
89
+ return {
90
+ name: heading.trim(),
91
+ sections: Object.fromEntries(
92
+ REQUIRED_SECTIONS.map((section) => [section, parsedSections.get(section.toLowerCase())!]),
93
+ ) as Record<RequiredSection, string>,
94
+ };
95
+ }
96
+
97
+ export function isCharacterId(value: string): boolean {
98
+ return CHARACTER_ID_PATTERN.test(value);
99
+ }
100
+
101
+ export async function loadCharacter(charactersRoot: string, id: string): Promise<Character> {
102
+ const root = resolve(charactersRoot);
103
+ if (!isCharacterId(id)) {
104
+ throw new CharacterLoadError("invalid-directory", `Invalid character id: ${id}`, join(root, id));
105
+ }
106
+
107
+ let canonicalRoot: string;
108
+ let characterDirectory: string;
109
+ try {
110
+ canonicalRoot = await realpath(root);
111
+ const directoryInfo = await lstat(join(root, id));
112
+ if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) {
113
+ throw new CharacterLoadError("invalid-directory", `Character path is not a regular directory: ${id}`, join(root, id));
114
+ }
115
+ characterDirectory = await realpath(join(root, id));
116
+ } catch (error) {
117
+ if (error instanceof CharacterLoadError) throw error;
118
+ throw new CharacterLoadError("missing-card", `Character does not exist: ${id}`, join(root, id));
119
+ }
120
+
121
+ if (!isWithin(canonicalRoot, characterDirectory) || dirname(characterDirectory) !== canonicalRoot) {
122
+ throw new CharacterLoadError("outside-root", `Character directory escapes the characters root: ${id}`, characterDirectory);
123
+ }
124
+
125
+ const cardPath = join(characterDirectory, "CHARACTER.md");
126
+ try {
127
+ if (!(await stat(characterDirectory)).isDirectory()) {
128
+ throw new CharacterLoadError("invalid-directory", `Character path is not a directory: ${id}`, characterDirectory);
129
+ }
130
+ } catch (error) {
131
+ if (error instanceof CharacterLoadError) throw error;
132
+ throw new CharacterLoadError("unreadable", `Cannot inspect character directory: ${id}`, characterDirectory);
133
+ }
134
+
135
+ let buffer: Buffer;
136
+ try {
137
+ buffer = await readFile(cardPath);
138
+ } catch {
139
+ throw new CharacterLoadError("missing-card", `Missing or unreadable CHARACTER.md for: ${id}`, cardPath);
140
+ }
141
+
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 };
153
+ }
154
+
155
+ export async function discoverCharacters(charactersRoot: string): Promise<CharacterDiscovery> {
156
+ const root = resolve(charactersRoot);
157
+ let entries;
158
+ try {
159
+ entries = await readdir(root, { withFileTypes: true });
160
+ } catch (error) {
161
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { characters: [], issues: [] };
162
+ throw error;
163
+ }
164
+
165
+ const characters: Character[] = [];
166
+ const issues: CharacterIssue[] = [];
167
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
168
+ if (entry.isSymbolicLink()) {
169
+ issues.push({
170
+ id: entry.name,
171
+ path: join(root, entry.name),
172
+ code: "invalid-directory",
173
+ message: `Ignored symbolic-link character directory: ${entry.name}`,
174
+ });
175
+ continue;
176
+ }
177
+ if (!entry.isDirectory()) continue;
178
+ if (!isCharacterId(entry.name)) {
179
+ issues.push({
180
+ id: entry.name,
181
+ path: join(root, entry.name),
182
+ code: "invalid-directory",
183
+ message: `Ignored invalid character directory: ${entry.name}`,
184
+ });
185
+ continue;
186
+ }
187
+
188
+ try {
189
+ characters.push(await loadCharacter(root, entry.name));
190
+ } catch (error) {
191
+ const loadError =
192
+ error instanceof CharacterLoadError
193
+ ? error
194
+ : new CharacterLoadError("unreadable", `Cannot load character: ${entry.name}`, join(root, entry.name));
195
+ issues.push({ id: entry.name, path: loadError.path, code: loadError.code, message: loadError.message });
196
+ }
197
+ }
198
+
199
+ return { characters, issues };
200
+ }
@@ -0,0 +1,128 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { CharacterLoadError, discoverCharacters, loadCharacter } from "./character-loader.ts";
4
+ import type { IncarnateSessionState } from "./session-state.ts";
5
+
6
+ export interface IncarnateCommandDependencies {
7
+ charactersRoot: string;
8
+ state: IncarnateSessionState;
9
+ onStateChange?: (ctx: ExtensionCommandContext) => Promise<void> | void;
10
+ }
11
+
12
+ const USAGE =
13
+ "Usage: /incarnate list | use <character-id> | off | status | mood <preset> | avatar on|off";
14
+
15
+ function notifyError(ctx: ExtensionCommandContext, message: string): void {
16
+ ctx.ui.notify(message, "error");
17
+ }
18
+
19
+ export function registerIncarnateCommand(pi: ExtensionAPI, dependencies: IncarnateCommandDependencies): void {
20
+ const { charactersRoot, state, onStateChange } = dependencies;
21
+
22
+ pi.registerCommand("incarnate", {
23
+ description: "List, enable, disable, or inspect character personas",
24
+ getArgumentCompletions: async (prefix) => {
25
+ const [subcommand = "", argument = ""] = prefix.trimStart().split(/\s+/, 2);
26
+ if (!prefix.trimStart().includes(" ")) {
27
+ return ["list", "use", "off", "status", "mood", "avatar"]
28
+ .filter((value) => value.startsWith(subcommand))
29
+ .map((value) => ({ value, label: value }));
30
+ }
31
+ if (subcommand === "avatar") {
32
+ const matches = ["on", "off"]
33
+ .filter((value) => value.startsWith(argument))
34
+ .map((value) => ({ value: `avatar ${value}`, label: value }));
35
+ return matches.length > 0 ? matches : null;
36
+ }
37
+ if (subcommand === "mood") {
38
+ const matches = [...(state.activeCharacter?.mood.presets.values() ?? [])]
39
+ .filter((preset) => preset.id.startsWith(argument))
40
+ .map((preset) => ({ value: `mood ${preset.id}`, label: preset.id }));
41
+ return matches.length > 0 ? matches : null;
42
+ }
43
+ 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 }));
48
+ return matches.length > 0 ? matches : null;
49
+ },
50
+ handler: async (args, ctx) => {
51
+ const [subcommand = "", id, ...extra] = args.trim().split(/\s+/).filter(Boolean);
52
+
53
+ 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");
57
+ } else {
58
+ const list = characters
59
+ .map((character) => `${character.id} — ${character.name}${state.activeCharacter?.id === character.id ? " (active)" : ""}`)
60
+ .join("\n");
61
+ ctx.ui.notify(list, "info");
62
+ }
63
+ if (issues.length > 0) {
64
+ ctx.ui.notify(`${issues.length} invalid character director${issues.length === 1 ? "y" : "ies"} ignored`, "warning");
65
+ }
66
+ return;
67
+ }
68
+
69
+ if (subcommand === "use" && id && extra.length === 0) {
70
+ try {
71
+ const character = await loadCharacter(charactersRoot, id);
72
+ state.activate(character);
73
+ await onStateChange?.(ctx);
74
+ ctx.ui.notify(`Character enabled: ${character.name} (${character.id})`, "info");
75
+ } catch (error) {
76
+ const message = error instanceof CharacterLoadError ? error.message : `Failed to load character: ${id}`;
77
+ notifyError(ctx, message);
78
+ }
79
+ return;
80
+ }
81
+
82
+ if (subcommand === "off" && !id) {
83
+ const previous = state.activeCharacter;
84
+ state.deactivate();
85
+ await onStateChange?.(ctx);
86
+ ctx.ui.notify(previous ? `Character disabled: ${previous.name}` : "Character mode is already off", "info");
87
+ return;
88
+ }
89
+
90
+ if (subcommand === "status" && !id) {
91
+ const active = state.activeCharacter;
92
+ ctx.ui.notify(
93
+ 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"}`,
96
+ "info",
97
+ );
98
+ return;
99
+ }
100
+
101
+ if (subcommand === "mood" && id && extra.length === 0) {
102
+ const active = state.activeCharacter;
103
+ if (!active) {
104
+ notifyError(ctx, "Enable a character before selecting a mood");
105
+ return;
106
+ }
107
+ if (!active.mood.presets.has(id)) {
108
+ const available = [...active.mood.presets.keys()].join(", ") || "none";
109
+ notifyError(ctx, `Unknown mood '${id}'. Available: ${available}`);
110
+ return;
111
+ }
112
+ state.setMood(id);
113
+ await onStateChange?.(ctx);
114
+ ctx.ui.notify(`Mood changed: ${id}`, "info");
115
+ return;
116
+ }
117
+
118
+ if (subcommand === "avatar" && (id === "on" || id === "off") && extra.length === 0) {
119
+ state.setAvatarEnabled(id === "on");
120
+ await onStateChange?.(ctx);
121
+ ctx.ui.notify(`Avatar: ${id}`, "info");
122
+ return;
123
+ }
124
+
125
+ notifyError(ctx, USAGE);
126
+ },
127
+ });
128
+ }
package/src/forms.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { open, realpath, stat } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+
4
+ import { findLevelTwoSection } from "./markdown.ts";
5
+
6
+ export type FormStatus = "available" | "missing" | "invalid";
7
+
8
+ export interface FormReference {
9
+ label: string;
10
+ declaredPath: string;
11
+ resolvedPath?: string;
12
+ status: FormStatus;
13
+ reason?: string;
14
+ }
15
+
16
+ function isWithin(parent: string, child: string): boolean {
17
+ const pathFromParent = relative(parent, child);
18
+ return pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent);
19
+ }
20
+
21
+ export function parseFormDeclarations(markdown: string): Array<{ label: string; declaredPath: string }> {
22
+ const section = findLevelTwoSection(markdown, "Tools and Forms");
23
+ if (section === undefined) return [];
24
+
25
+ const declarations: Array<{ label: string; declaredPath: string }> = [];
26
+ for (const match of section.matchAll(/^-\s+([^::\n]+?)\s*[::]\s*`([^`]+)`\s*$/gm)) {
27
+ declarations.push({ label: match[1].trim(), declaredPath: match[2].trim() });
28
+ }
29
+ return declarations;
30
+ }
31
+
32
+ export async function resolveFormReferences(characterDirectory: string, markdown: string): Promise<FormReference[]> {
33
+ const canonicalCharacterDirectory = await realpath(characterDirectory);
34
+ const declarations = parseFormDeclarations(markdown);
35
+
36
+ return await Promise.all(
37
+ declarations.map(async ({ label, declaredPath }): Promise<FormReference> => {
38
+ if (!declaredPath || isAbsolute(declaredPath)) {
39
+ return { label, declaredPath, status: "invalid", reason: "path must be relative to the character directory" };
40
+ }
41
+
42
+ const candidate = resolve(canonicalCharacterDirectory, declaredPath);
43
+ if (!isWithin(canonicalCharacterDirectory, candidate) || candidate === canonicalCharacterDirectory) {
44
+ return { label, declaredPath, status: "invalid", reason: "path escapes the character directory" };
45
+ }
46
+
47
+ let canonicalPath: string;
48
+ try {
49
+ canonicalPath = await realpath(candidate);
50
+ } catch (error) {
51
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
52
+ return { label, declaredPath, resolvedPath: candidate, status: "missing", reason: "file does not exist" };
53
+ }
54
+ return { label, declaredPath, resolvedPath: candidate, status: "invalid", reason: "path is not readable" };
55
+ }
56
+
57
+ if (!isWithin(canonicalCharacterDirectory, canonicalPath)) {
58
+ return { label, declaredPath, status: "invalid", reason: "resolved path escapes the character directory" };
59
+ }
60
+
61
+ try {
62
+ if (!(await stat(canonicalPath)).isFile()) {
63
+ return { label, declaredPath, resolvedPath: canonicalPath, status: "invalid", reason: "path is not a file" };
64
+ }
65
+ const handle = await open(canonicalPath, "r");
66
+ await handle.close();
67
+ } catch {
68
+ return { label, declaredPath, resolvedPath: canonicalPath, status: "invalid", reason: "path is not readable" };
69
+ }
70
+
71
+ return { label, declaredPath, resolvedPath: canonicalPath, status: "available" };
72
+ }),
73
+ );
74
+ }
75
+
76
+ export function composeFormsPrompt(forms: readonly FormReference[]): string | undefined {
77
+ if (forms.length === 0) return undefined;
78
+ const lines = forms.map((form) => {
79
+ if (form.status === "available") return `- ${form.label}: ${form.resolvedPath} (available)`;
80
+ return `- ${form.label}: ${form.declaredPath} (${form.status}: ${form.reason})`;
81
+ });
82
+ return `Declared preference forms:\n${lines.join("\n")}\nOnly use these declared files when relevant. Read them with Pi's file tools before relying on their contents. Never imply that a missing or invalid form was read.`;
83
+ }
@@ -0,0 +1,58 @@
1
+ export interface MarkdownSection {
2
+ name: string;
3
+ content: string;
4
+ }
5
+
6
+ interface MarkdownHeading {
7
+ line: number;
8
+ name: string;
9
+ }
10
+
11
+ function findHeadings(markdown: string, level: number): { lines: string[]; headings: MarkdownHeading[] } {
12
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
13
+ const headings: MarkdownHeading[] = [];
14
+ let fence: { marker: string; length: number } | undefined;
15
+ const headingPattern = new RegExp(`^#{${level}}(?!#)\\s+(.+?)(?:\\s+#+)?\\s*$`);
16
+
17
+ lines.forEach((line, index) => {
18
+ const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
19
+ if (fenceMatch) {
20
+ const marker = fenceMatch[1][0];
21
+ if (!fence) fence = { marker, length: fenceMatch[1].length };
22
+ else if (fence.marker === marker && fenceMatch[1].length >= fence.length) fence = undefined;
23
+ return;
24
+ }
25
+ if (fence) return;
26
+ const headingMatch = line.match(headingPattern);
27
+ if (headingMatch) headings.push({ line: index, name: headingMatch[1].trim() });
28
+ });
29
+ return { lines, headings };
30
+ }
31
+
32
+ export function findLevelOneHeading(markdown: string): string | undefined {
33
+ return findHeadings(markdown, 1).headings[0]?.name;
34
+ }
35
+
36
+ export function extractSections(markdown: string, level: number): MarkdownSection[] {
37
+ const { lines, headings } = findHeadings(markdown, level);
38
+ return headings.map((current, index) => {
39
+ const next = headings[index + 1];
40
+ return {
41
+ name: current.name,
42
+ content: lines.slice(current.line + 1, next?.line ?? lines.length).join("\n").trim(),
43
+ };
44
+ });
45
+ }
46
+
47
+ export function contentBeforeFirstHeading(markdown: string, level: number): string {
48
+ const { lines, headings } = findHeadings(markdown, level);
49
+ return lines.slice(0, headings[0]?.line ?? lines.length).join("\n").trim();
50
+ }
51
+
52
+ export function extractLevelTwoSections(markdown: string): MarkdownSection[] {
53
+ return extractSections(markdown, 2);
54
+ }
55
+
56
+ export function findLevelTwoSection(markdown: string, name: string): string | undefined {
57
+ return extractLevelTwoSections(markdown).find((section) => section.name.toLowerCase() === name.toLowerCase())?.content;
58
+ }
package/src/mood.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { contentBeforeFirstHeading, extractSections, findLevelTwoSection } from "./markdown.ts";
2
+
3
+ const PRESET_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
4
+
5
+ export interface MoodPreset {
6
+ id: string;
7
+ instruction: string;
8
+ }
9
+
10
+ export interface MoodConfig {
11
+ defaultPreset?: string;
12
+ presets: ReadonlyMap<string, MoodPreset>;
13
+ }
14
+
15
+ export class MoodConfigError extends Error {
16
+ constructor(message: string) {
17
+ super(message);
18
+ this.name = "MoodConfigError";
19
+ }
20
+ }
21
+
22
+ export function parseMoodConfig(markdown: string): MoodConfig {
23
+ const section = findLevelTwoSection(markdown, "Current Mood");
24
+ if (section === undefined) return { presets: new Map() };
25
+
26
+ const defaultMatch = contentBeforeFirstHeading(section, 3).match(/^Default:\s*(\S+)\s*$/im);
27
+ if (!defaultMatch) throw new MoodConfigError("Current Mood must declare 'Default: <preset-id>'");
28
+
29
+ const presetSections = extractSections(section, 3);
30
+ if (presetSections.length === 0) throw new MoodConfigError("Current Mood must define at least one level-three preset");
31
+
32
+ const presets = new Map<string, MoodPreset>();
33
+ for (const presetSection of presetSections) {
34
+ const id = presetSection.name;
35
+ if (!PRESET_ID_PATTERN.test(id)) throw new MoodConfigError(`Invalid mood preset id: ${id}`);
36
+ if (presets.has(id)) throw new MoodConfigError(`Duplicate mood preset: ${id}`);
37
+ const instruction = presetSection.content;
38
+ if (!instruction) throw new MoodConfigError(`Mood preset must not be empty: ${id}`);
39
+ presets.set(id, { id, instruction });
40
+ }
41
+
42
+ const defaultPreset = defaultMatch[1];
43
+ if (!presets.has(defaultPreset)) {
44
+ throw new MoodConfigError(`Default mood does not match a declared preset: ${defaultPreset}`);
45
+ }
46
+ return { defaultPreset, presets };
47
+ }
48
+
49
+ export function composeMoodPrompt(config: MoodConfig, presetId: string | undefined): string | undefined {
50
+ if (!presetId) return undefined;
51
+ const preset = config.presets.get(presetId);
52
+ if (!preset) return undefined;
53
+ return `Current mood preset: ${preset.id}\n${preset.instruction}\nThe mood adjusts expression only; it does not override the character's identity, factual standards, or tool rules.`;
54
+ }
package/src/persona.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { Character } from "./character-loader.ts";
2
+ import { composeFormsPrompt } from "./forms.ts";
3
+ import { composeMoodPrompt } from "./mood.ts";
4
+
5
+ const RUNTIME_RULES = `You are operating with a pi-incarnate character persona.
6
+ - Treat the character card as guidance for identity, voice, interpersonal stance, and emotional expression.
7
+ - Preserve Pi's existing tools, permissions, safety rules, and task-completion standards.
8
+ - Never invent tool results, file contents, current facts, or actions that did not occur.
9
+ - Keep factual uncertainty explicit. Immersive style must not turn fiction into real-world claims.
10
+ - When the user asks for technical work, complete it accurately; express the result in character without sacrificing clarity.
11
+ - Do not mechanically repeat catchphrases or example dialogue.`;
12
+
13
+ export function composePersonaPrompt(character: Character, currentMood?: string): string {
14
+ const formsPrompt = composeFormsPrompt(character.forms);
15
+ const moodPrompt = composeMoodPrompt(character.mood, currentMood);
16
+ return `${RUNTIME_RULES}
17
+
18
+ Active character: ${character.name} (${character.id})
19
+
20
+ <character-card>
21
+ ${character.markdown}
22
+ </character-card>${formsPrompt ? `\n\n${formsPrompt}` : ""}${moodPrompt ? `\n\n${moodPrompt}` : ""}`;
23
+ }
24
+
25
+ export function appendPersonaPrompt(systemPrompt: string, character: Character, currentMood?: string): string {
26
+ return `${systemPrompt}\n\n${composePersonaPrompt(character, currentMood)}`;
27
+ }
@@ -0,0 +1,59 @@
1
+ import type { Character } from "./character-loader.ts";
2
+
3
+ export interface IncarnateStateSnapshot {
4
+ activeCharacter?: Character;
5
+ currentMood?: string;
6
+ avatarEnabled: boolean;
7
+ }
8
+
9
+ export class IncarnateSessionState {
10
+ #activeCharacter: Character | undefined;
11
+ #currentMood: string | undefined;
12
+ #avatarEnabled = true;
13
+
14
+ get activeCharacter(): Character | undefined {
15
+ return this.#activeCharacter;
16
+ }
17
+
18
+ get currentMood(): string | undefined {
19
+ return this.#currentMood;
20
+ }
21
+
22
+ get avatarEnabled(): boolean {
23
+ return this.#avatarEnabled;
24
+ }
25
+
26
+ activate(character: Character): void {
27
+ this.#activeCharacter = character;
28
+ this.#currentMood = character.mood.defaultPreset;
29
+ }
30
+
31
+ deactivate(): void {
32
+ this.#activeCharacter = undefined;
33
+ this.#currentMood = undefined;
34
+ }
35
+
36
+ setMood(preset: string): void {
37
+ if (!this.#activeCharacter?.mood.presets.has(preset)) {
38
+ throw new Error(`Unknown mood preset: ${preset}`);
39
+ }
40
+ this.#currentMood = preset;
41
+ }
42
+
43
+ setAvatarEnabled(enabled: boolean): void {
44
+ this.#avatarEnabled = enabled;
45
+ }
46
+
47
+ reset(): void {
48
+ this.deactivate();
49
+ this.#avatarEnabled = true;
50
+ }
51
+
52
+ snapshot(): IncarnateStateSnapshot {
53
+ return {
54
+ activeCharacter: this.#activeCharacter,
55
+ currentMood: this.#currentMood,
56
+ avatarEnabled: this.#avatarEnabled,
57
+ };
58
+ }
59
+ }