pi-incarnate 0.2.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 CHANGED
@@ -2,7 +2,14 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
- ## 0.2.0 - Unreleased
5
+ ## Unreleased
6
+
7
+ ## 0.3.0 - 2026-09-11
8
+
9
+ - Add validated `avatar.png` primary avatars with bounded dimensions and file size, while retaining one `avatar.ansi` or `avatar.txt` fallback.
10
+ - Carry PNG avatars through personal import/removal, portable v1 character packages, and the backward-compatible v1 companion UI snapshot.
11
+
12
+ ## 0.2.0 - 2026-09-09
6
13
 
7
14
  - Keep unselected keyboard-menu options readable across light/dark terminal mismatches by using the terminal foreground in bold instead of forcing a theme text color.
8
15
  - Add an eight-scenario persona evaluation plan and isolated real-Pi RPC runner with scenario filtering and repeated sampling.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  让角色进入 Pi Agent 的对话现场:通过可编辑角色卡、稳定的人格层和会话内 mood,让非 coding 对话拥有更强的在场感,同时保留 Pi 原有工具、安全边界和任务完成能力。头像与状态显示由可选的 `pi-incarnate-ui` 配套扩展提供。
4
4
 
5
- 最新 npm 版本仍为 `0.1.0`;当前工作区是尚未发布的 `0.2.0` 本地候选,包含交互菜单、角色资源管理、联合协议门禁和人格回归工具。项目已在 Pi `0.85.0` 验证,要求 Node.js `>=22.19.0`。当前版本不做世界书、自动长期记忆或隐式角色切换。
5
+ 当前版本为 `0.3.0`,新增 PNG 主头像及 ANSI/TXT 兼容回退。项目已在 Pi `0.85.0` 验证,要求 Node.js `>=22.19.0`。当前版本不做世界书、自动长期记忆或隐式角色切换。
6
6
 
7
7
  ## 安装与启动
8
8
 
@@ -49,8 +49,6 @@ pi install /path/to/pi-incarnate
49
49
  pi install npm:pi-incarnate
50
50
  ```
51
51
 
52
- 如果 npm registry 中尚未提供目标版本,请先使用上面的本地路径安装方式。
53
-
54
52
  本地包由 Pi 设置管理;需要移除时运行:
55
53
 
56
54
  ```bash
@@ -122,8 +120,9 @@ pi remove /path/to/pi-incarnate
122
120
  characters/
123
121
  └── my-character/
124
122
  ├── CHARACTER.md
123
+ ├── avatar.png # 可选,支持终端图片协议时作为主头像
125
124
  ├── avatar.txt # 可选,纯文本头像
126
- ├── avatar.ansi # 可选,受限 ANSI 真彩头像;优先于 avatar.txt
125
+ ├── avatar.ansi # 可选,受限 ANSI 真彩回退;优先于 avatar.txt
127
126
  └── forms/ # 可选
128
127
  ```
129
128
 
@@ -174,11 +173,13 @@ Default: warm
174
173
 
175
174
  如果个人角色卡已经损坏,运行 `/incarnate` → `Character library` → `Add or restore character` → `Repair invalid character card`。菜单只列出目录 ID 安全、位于个人角色根目录内,且属于“卡片格式错误”或“缺少卡片”的项目。格式错误的 UTF-8 卡片会在原内容上编辑;缺少卡片时会提供完整模板。无效编码、符号链接和越界目录不会在 TUI 中打开。
176
175
 
177
- 头像可以通过 `/incarnate` → `Character library` → `Edit character` → `Manage character avatar` 导入或移除。选择角色后输入 `.ansi` 或 `.txt` 文件路径;支持绝对路径、相对当前工作目录的路径、`~/...`、`file://...`、成对引号和终端拖放常见的转义空格。导入内置角色时会先请求创建个人覆盖副本,包内资源不会被修改。
176
+ 头像可以通过 `/incarnate` → `Character library` → `Edit character` → `Manage character avatar` 导入或移除。选择角色后输入 `.png`、`.ansi` 或 `.txt` 文件路径;支持绝对路径、相对当前工作目录的路径、`~/...`、`file://...`、成对引号和终端拖放常见的转义空格。导入内置角色时会先请求创建个人覆盖副本,包内资源不会被修改。
177
+
178
+ `avatar.png` 是主头像。支持 Kitty 或 iTerm2 图片协议时,`pi-incarnate-ui` 会按 PNG 像素尺寸和终端 cell 尺寸等比缩放;不支持图片、图片被 Pi 禁用或渲染失败时,自动回退到 `avatar.ansi`,其次是 `avatar.txt`。只有 PNG 而无法显示图片时使用紧凑角色状态,不影响人格。PNG 最大 512 KiB,宽高各不超过 2048 px,总像素不超过 4,194,304;导入时检查 PNG 签名、chunk checksum、IHDR、必要数据块和尺寸。
178
179
 
179
- `avatar.txt` 是纯文本格式,会去除 ANSI 和终端控制序列。`avatar.ansi` 用于彩色头像,存在时优先于 `avatar.txt`;它只保留标准色、256 色、24-bit 前景/背景色及 reset,光标移动、清屏、OSC、超链接和其他控制序列一律删除。两种格式都要求 UTF-8,最大 64 KiB、16 行、每行 48 个终端列。ANSI 每行会强制 reset,防止颜色泄漏到 Pi 界面。头像损坏或不可读时只降级为角色状态行,不会关闭已经启用的人格。
180
+ `avatar.txt` 是纯文本格式,会去除 ANSI 和终端控制序列。`avatar.ansi` 用于彩色回退,存在时优先于 `avatar.txt`;它只保留标准色、256 色、24-bit 前景/背景色及 reset,光标移动、清屏、OSC、超链接和其他控制序列一律删除。两种文本格式都要求 UTF-8,最大 64 KiB、16 行、每行 48 个终端列。ANSI 每行会强制 reset,防止颜色泄漏到 Pi 界面。头像损坏或不可读时只降级为角色状态行,不会关闭已经启用的人格。
180
181
 
181
- 菜单导入会把清理后的安全版本写入个人角色目录,并拒绝需要裁剪的资源,避免静默损失图像。导入一种格式会移除另一种格式,确保新头像立即生效;写入使用同目录临时文件替换。移除操作需要确认,只删除个人副本中的 `avatar.ansi` 和 `avatar.txt`。
182
+ 菜单导入会把验证或清理后的安全版本写入个人角色目录,并拒绝超出边界或需要裁剪的资源。导入 PNG 会保留已有文本回退;导入 ANSI/TXT 会互相替换但保留 PNG 主图。写入使用同目录临时文件替换。移除操作需要确认,并删除个人副本中的 `avatar.png`、`avatar.ansi` 和 `avatar.txt`。
182
183
 
183
184
  个人角色可以通过 `/incarnate` → `Character library` → `Manage or export character` 重命名或归档;恢复入口位于 `Add or restore character`。重命名只修改安全目录 ID,不改角色卡中的显示名;如果角色正在使用,会同步更新当前会话。归档会在确认后把完整角色目录移到:
184
185
 
@@ -196,11 +197,11 @@ Default: warm
196
197
  <character-id>.pi-character.json
197
198
  ```
198
199
 
199
- 导出只收集 `CHARACTER.md`、运行时优先使用的一个头像,以及角色卡中已经声明且当前可用的 Markdown 表单。未知文件、未声明文件、缺失或无效表单和被另一格式遮蔽的头像不会进入角色包。确认界面会显示实际包含的头像和表单数量;偏好表单可能包含私人信息,分享前应直接打开 JSON 检查。
200
+ 导出只收集 `CHARACTER.md`、可用的 PNG 主头像、一个运行时优先的 ANSI/TXT 回退,以及角色卡中已经声明且当前可用的 Markdown 表单。PNG JSON 中使用显式 base64 编码;旧的纯文本 v1 角色包仍可导入。未知文件、未声明文件、缺失或无效表单和被另一文本格式遮蔽的头像不会进入角色包。确认界面会显示实际包含的头像和表单数量;偏好表单可能包含私人信息,分享前应直接打开 JSON 检查。
200
201
 
201
- 导入会先检查格式版本、安全 ID、UTF-8、文件数量与总大小、角色卡结构、头像安全边界,以及每份表单是否由卡片明确声明。路径穿越、重复路径、未知文件、符号链接来源和同时包含两个头像的包都会被拒绝。整个角色先在个人目录内的临时位置完成构建和加载,再整体移动到正式位置;不会覆盖已有个人角色。同 ID 只有内置角色时,导入结果会成为个人覆盖副本。导入成功后可选择立即启用。
202
+ 导入会先检查格式版本、安全 ID、UTF-8/base64、文件数量与总大小、角色卡结构、头像安全边界,以及每份表单是否由卡片明确声明。路径穿越、重复路径、未知文件、符号链接来源、多个 PNG 或多个文本回退都会被拒绝。整个角色先在个人目录内的临时位置完成构建和加载,再整体移动到正式位置;不会覆盖已有个人角色。同 ID 只有内置角色时,导入结果会成为个人覆盖副本。导入成功后可选择立即启用。
202
203
 
203
- 角色包最大 1 MiB、最多 66 个文件;其中角色卡最大 512 KiB,表单和头像继续沿用各自的 256 KiB 64 KiB 限制。导出目标和导入来源支持与头像导入相同的绝对路径、相对路径、`~/...`、`file://...`、引号和转义空格输入。导出不会覆盖已有文件。
204
+ 角色包最大 1 MiB、最多 66 个文件;其中角色卡最大 512 KiB,表单、文本头像和 PNG 分别沿用 256 KiB、64 KiB 512 KiB 限制。导出目标和导入来源支持与头像导入相同的绝对路径、相对路径、`~/...`、`file://...`、引号和转义空格输入。导出不会覆盖已有文件。
204
205
 
205
206
  ## 故障排查
206
207
 
@@ -210,6 +211,7 @@ Default: warm
210
211
  - `Current Mood ...`:检查 `Default:`、三级标题 preset ID 和对应正文。
211
212
  - `Forms: n/m available`:运行 `/incarnate status` 后检查缺失文件;表单路径必须留在角色目录内。
212
213
  - 彩色头像不显示:文件名应为 `avatar.ansi` 并位于对应角色目录;任意 ANSI 动画、光标控制或终端命令不会被支持。
214
+ - PNG 头像不显示:确认同时安装 `pi-incarnate-ui`,并使用 Kitty、Ghostty、WezTerm 或 iTerm2 等已启用图片协议的终端;不支持时会自动使用 `avatar.ansi`/`avatar.txt` 回退。
213
215
  - 命令没有出现:开发时确认使用 `pi -e ./extensions/index.ts`;本地安装后可用 `pi list` 和 `pi config` 检查资源状态。
214
216
  - 项目本地扩展未加载:Pi 只从受信任项目自动加载 `.pi/extensions`;本项目的显式 `-e` 和本地包安装不依赖该目录。
215
217
 
@@ -228,7 +230,7 @@ src/persona.ts 有界人格 prompt 组合
228
230
  src/markdown.ts 识别 fenced code 的 Markdown 章节操作
229
231
  src/commands.ts /incarnate 命令
230
232
  src/menu.ts 键盘导航菜单与角色卡编辑流程
231
- src/avatar.ts ASCII/ANSI 头像读取与安全清理
233
+ src/avatar.ts PNG 校验、ASCII/ANSI 头像读取与安全清理
232
234
  src/avatar-manager.ts 头像路径解析、安全导入与移除
233
235
  src/form-editor.ts 偏好表单有界读取与原子编辑
234
236
  src/mood.ts mood 预设解析和 prompt 片段
package/SECURITY.md CHANGED
@@ -10,12 +10,13 @@ Pi extensions execute with the same system permissions as Pi. Review this packag
10
10
  - Character-card writes are validated before an atomic replacement, and copied character resources reject symbolic links and non-regular files.
11
11
  - Guided card edits replace only the selected top-level name or level-two section, reject ambiguous duplicates and structural heading injection, and pass through the same complete-card validation and atomic replacement as full edits.
12
12
  - Character lifecycle actions operate only on direct personal-character directories. Archive storage must be a real directory, moves refuse existing targets, and the menu exposes recovery instead of permanent deletion.
13
- - Portable character packages include only the card, one validated avatar, and available forms explicitly declared by that card. Imports reject unknown, duplicate, escaping, oversized, non-UTF-8, and symbolic-link inputs, build in a temporary personal directory, and never overwrite an existing personal character.
13
+ - Portable character packages include only the card, one validated PNG primary avatar, one validated ANSI/TXT fallback, and available forms explicitly declared by that card. Binary PNG data uses explicit base64 encoding. Imports reject unknown, duplicate, escaping, oversized, malformed-encoding, non-UTF-8, and symbolic-link inputs, build in a temporary personal directory, and never overwrite an existing personal character.
14
14
  - Declared form paths must resolve to readable files inside their character directory.
15
15
  - Form contents are not loaded or cached during character activation or prompt composition. They are read only after an explicit `Manage preference forms` action, with UTF-8, regular-file, path-containment, and 256 KiB editor bounds.
16
16
  - Plain avatars have terminal control sequences removed. ANSI avatars use a strict SGR color-only allowlist; cursor movement, screen control, OSC, hyperlinks, and other escape sequences are removed. Both formats are byte-, line-, and width-limited, with a forced reset on every ANSI line.
17
- - Avatar imports reject symbolic-link and non-regular sources, invalid UTF-8, oversized files, and resources that would require display truncation. Only the sanitized result is written to a personal character directory.
18
- - The optional UI protocol broadcasts only bounded character metadata and already-sanitized avatar lines. It does not expose the complete character-card body, and unknown protocol claims are ignored.
17
+ - PNG avatars are limited to 512 KiB, 2048 px per dimension, and 4,194,304 total pixels. Imports validate the PNG signature, IHDR fields, required chunks, and declared dimensions before preserving the binary bytes.
18
+ - Avatar imports reject symbolic-link and non-regular sources, invalid UTF-8, malformed PNG data, oversized files, and text resources that would require display truncation. Only the validated or sanitized result is written to a personal character directory.
19
+ - The optional UI protocol broadcasts only bounded character metadata, already-sanitized avatar lines, and validated PNG bytes encoded as base64. It does not expose the complete character-card body, and unknown protocol claims are ignored.
19
20
  - The personality layer is appended without replacing Pi's existing tool, permission, or safety instructions, and explicitly tells the model to preserve those boundaries.
20
21
 
21
22
  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.
@@ -13,4 +13,4 @@ Every character directory must contain a UTF-8 `CHARACTER.md` with a level-one c
13
13
 
14
14
  Optional sections such as `Tools and Forms` and `Current Mood` are preserved verbatim and become part of the persona prompt. Declared forms and mood presets are validated by their dedicated loaders.
15
15
 
16
- An optional `avatar.txt` provides a plain UTF-8 avatar. An optional `avatar.ansi` takes precedence and may use only safe SGR foreground/background colors and resets. Both formats are limited to 64 KiB, 16 lines, and 48 visible columns.
16
+ An optional `avatar.png` is the primary avatar on terminals with supported inline-image protocols. It is limited to 512 KiB, 2048 px per dimension, and 4,194,304 total pixels, with PNG structure and chunk checksums validated by the core extension. An optional `avatar.ansi` or `avatar.txt` provides the terminal-compatible fallback; ANSI takes precedence over plain text and may use only safe SGR foreground/background colors and resets. Both text formats are limited to 64 KiB, 16 lines, and 48 visible columns.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-incarnate",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A character persona layer for Pi Agent",
5
5
  "author": "NandySun",
6
6
  "type": "module",
@@ -8,20 +8,35 @@ import {
8
8
  AVATAR_MAX_BYTES,
9
9
  AVATAR_MAX_COLUMNS,
10
10
  AVATAR_MAX_LINES,
11
+ AVATAR_PNG_MAX_BYTES,
11
12
  sanitizeAnsiAvatar,
12
13
  sanitizeAvatar,
14
+ validatePngAvatar,
15
+ AvatarLoadError,
13
16
  } from "./avatar.ts";
14
17
  import { CharacterEditError, resolvePersonalDirectory } from "./character-editor.ts";
15
18
 
16
- export type AvatarFileName = "avatar.ansi" | "avatar.txt";
19
+ export type TextAvatarFileName = "avatar.ansi" | "avatar.txt";
20
+ export type AvatarFileName = "avatar.png" | TextAvatarFileName;
17
21
 
18
- export interface PreparedAvatarImport {
19
- filename: AvatarFileName;
22
+ export interface PreparedTextAvatarImport {
23
+ kind: "text";
24
+ filename: TextAvatarFileName;
20
25
  content: string;
21
26
  width: number;
22
27
  height: number;
23
28
  }
24
29
 
30
+ export interface PreparedPngAvatarImport {
31
+ kind: "png";
32
+ filename: "avatar.png";
33
+ content: Buffer;
34
+ width: number;
35
+ height: number;
36
+ }
37
+
38
+ export type PreparedAvatarImport = PreparedTextAvatarImport | PreparedPngAvatarImport;
39
+
25
40
  function stripWrappingQuotes(value: string): string {
26
41
  if (value.length < 2) return value;
27
42
  const first = value[0];
@@ -40,7 +55,7 @@ export function resolveAvatarSourcePath(input: string, cwd: string, userHome = h
40
55
  return resolveUserPath(input, cwd, userHome);
41
56
  }
42
57
 
43
- export function prepareAvatarContent(filename: AvatarFileName, raw: string): PreparedAvatarImport {
58
+ export function prepareAvatarContent(filename: TextAvatarFileName, raw: string): PreparedTextAvatarImport {
44
59
  if (Buffer.byteLength(raw, "utf8") > AVATAR_MAX_BYTES) {
45
60
  throw new CharacterEditError(`Avatar exceeds the ${AVATAR_MAX_BYTES}-byte limit`);
46
61
  }
@@ -50,6 +65,7 @@ export function prepareAvatarContent(filename: AvatarFileName, raw: string): Pre
50
65
  throw new CharacterEditError(`Avatar must fit within ${AVATAR_MAX_COLUMNS} columns and ${AVATAR_MAX_LINES} lines`);
51
66
  }
52
67
  return {
68
+ kind: "text",
53
69
  filename,
54
70
  content: `${avatar.lines.join("\n")}\n`,
55
71
  width: avatar.lines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0),
@@ -57,12 +73,31 @@ export function prepareAvatarContent(filename: AvatarFileName, raw: string): Pre
57
73
  };
58
74
  }
59
75
 
76
+ export function preparePngAvatarContent(bytes: Uint8Array): PreparedPngAvatarImport {
77
+ try {
78
+ const image = validatePngAvatar(bytes);
79
+ return {
80
+ kind: "png",
81
+ filename: "avatar.png",
82
+ content: Buffer.from(bytes),
83
+ width: image.widthPx,
84
+ height: image.heightPx,
85
+ };
86
+ } catch (error) {
87
+ if (error instanceof AvatarLoadError) throw new CharacterEditError(error.message);
88
+ throw error;
89
+ }
90
+ }
91
+
60
92
  export async function prepareAvatarImport(sourcePath: string): Promise<PreparedAvatarImport> {
61
93
  const extension = extname(sourcePath).toLowerCase();
62
94
  let filename: AvatarFileName;
95
+ let maximumBytes: number;
63
96
  if (extension === ".ansi") filename = "avatar.ansi";
64
97
  else if (extension === ".txt") filename = "avatar.txt";
65
- else throw new CharacterEditError("Avatar file must end in .ansi or .txt");
98
+ else if (extension === ".png") filename = "avatar.png";
99
+ else throw new CharacterEditError("Avatar file must end in .png, .ansi, or .txt");
100
+ maximumBytes = filename === "avatar.png" ? AVATAR_PNG_MAX_BYTES : AVATAR_MAX_BYTES;
66
101
 
67
102
  let bytes: Buffer;
68
103
  try {
@@ -70,18 +105,20 @@ export async function prepareAvatarImport(sourcePath: string): Promise<PreparedA
70
105
  if (!info.isFile() || info.isSymbolicLink()) {
71
106
  throw new CharacterEditError(`Avatar source must be a regular file: ${sourcePath}`);
72
107
  }
73
- if (info.size > AVATAR_MAX_BYTES) {
74
- throw new CharacterEditError(`Avatar exceeds the ${AVATAR_MAX_BYTES}-byte limit: ${sourcePath}`);
108
+ if (info.size > maximumBytes) {
109
+ throw new CharacterEditError(`Avatar exceeds the ${maximumBytes}-byte limit: ${sourcePath}`);
75
110
  }
76
111
  bytes = await readFile(sourcePath);
77
- if (bytes.byteLength > AVATAR_MAX_BYTES) {
78
- throw new CharacterEditError(`Avatar exceeds the ${AVATAR_MAX_BYTES}-byte limit: ${sourcePath}`);
112
+ if (bytes.byteLength > maximumBytes) {
113
+ throw new CharacterEditError(`Avatar exceeds the ${maximumBytes}-byte limit: ${sourcePath}`);
79
114
  }
80
115
  } catch (error) {
81
116
  if (error instanceof CharacterEditError) throw error;
82
117
  throw new CharacterEditError(`Cannot read avatar source: ${sourcePath}`);
83
118
  }
84
119
 
120
+ if (filename === "avatar.png") return preparePngAvatarContent(bytes);
121
+
85
122
  let raw: string;
86
123
  try {
87
124
  raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
@@ -108,26 +145,31 @@ export async function installPersonalAvatar(
108
145
  ): Promise<string> {
109
146
  const directory = await resolvePersonalDirectory(personalRoot, id);
110
147
  const targetPath = join(directory, prepared.filename);
111
- const alternatePath = join(directory, prepared.filename === "avatar.ansi" ? "avatar.txt" : "avatar.ansi");
148
+ const alternatePath = prepared.kind === "text"
149
+ ? join(directory, prepared.filename === "avatar.ansi" ? "avatar.txt" : "avatar.ansi")
150
+ : undefined;
112
151
  await assertReplaceable(targetPath);
113
- await assertReplaceable(alternatePath);
152
+ if (alternatePath) await assertReplaceable(alternatePath);
114
153
 
115
154
  const nonce = crypto.randomUUID();
116
155
  const temporaryPath = join(directory, `.avatar-import-${nonce}.tmp`);
117
156
  const alternateBackup = join(directory, `.avatar-replaced-${nonce}.tmp`);
118
157
  let movedAlternate = false;
119
158
  try {
120
- await writeFile(temporaryPath, prepared.content, { encoding: "utf8", flag: "wx" });
121
- try {
122
- await rename(alternatePath, alternateBackup);
123
- movedAlternate = true;
124
- } catch (error) {
125
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
159
+ if (prepared.kind === "png") await writeFile(temporaryPath, prepared.content, { flag: "wx" });
160
+ else await writeFile(temporaryPath, prepared.content, { encoding: "utf8", flag: "wx" });
161
+ if (alternatePath) {
162
+ try {
163
+ await rename(alternatePath, alternateBackup);
164
+ movedAlternate = true;
165
+ } catch (error) {
166
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
167
+ }
126
168
  }
127
169
  await rename(temporaryPath, targetPath);
128
170
  if (movedAlternate) await rm(alternateBackup, { force: true });
129
171
  } catch (error) {
130
- if (movedAlternate) await rename(alternateBackup, alternatePath).catch(() => undefined);
172
+ if (movedAlternate && alternatePath) await rename(alternateBackup, alternatePath).catch(() => undefined);
131
173
  throw new CharacterEditError(`Cannot install avatar for ${id}: ${error instanceof Error ? error.message : "unknown error"}`);
132
174
  } finally {
133
175
  await rm(temporaryPath, { force: true });
@@ -138,7 +180,7 @@ export async function installPersonalAvatar(
138
180
  export async function removePersonalAvatars(personalRoot: string, id: string): Promise<number> {
139
181
  const directory = await resolvePersonalDirectory(personalRoot, id);
140
182
  let removed = 0;
141
- for (const filename of ["avatar.ansi", "avatar.txt"] as const) {
183
+ for (const filename of ["avatar.png", "avatar.ansi", "avatar.txt"] as const) {
142
184
  const path = join(directory, filename);
143
185
  try {
144
186
  const info = await lstat(path);
package/src/avatar.ts CHANGED
@@ -7,13 +7,31 @@ import type { Character } from "./character-loader.ts";
7
7
  export const AVATAR_MAX_COLUMNS = 48;
8
8
  export const AVATAR_MAX_LINES = 16;
9
9
  export const AVATAR_MAX_BYTES = 64 * 1024;
10
+ export const AVATAR_PNG_MAX_BYTES = 512 * 1024;
11
+ export const AVATAR_PNG_MAX_DIMENSION = 2048;
12
+ export const AVATAR_PNG_MAX_PIXELS = 2048 * 2048;
10
13
  const ANSI_RESET = "\u001b[0m";
14
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
15
+ const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
16
+ let crc = value;
17
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
18
+ return crc >>> 0;
19
+ });
11
20
  const CSI_SEQUENCE_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
12
21
  const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
13
22
 
14
23
  export interface Avatar {
15
24
  lines: string[];
16
25
  truncated: boolean;
26
+ image?: AvatarImage;
27
+ }
28
+
29
+ export interface AvatarImage {
30
+ mimeType: "image/png";
31
+ data: string;
32
+ widthPx: number;
33
+ heightPx: number;
34
+ bytes: number;
17
35
  }
18
36
 
19
37
  export class AvatarLoadError extends Error {
@@ -88,6 +106,94 @@ function isWithin(parent: string, child: string): boolean {
88
106
  return pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`);
89
107
  }
90
108
 
109
+ function pngChunkCrc(buffer: Buffer, start: number, end: number): number {
110
+ let crc = 0xffffffff;
111
+ for (let index = start; index < end; index += 1) {
112
+ crc = CRC32_TABLE[(crc ^ buffer[index]!) & 0xff]! ^ (crc >>> 8);
113
+ }
114
+ return (crc ^ 0xffffffff) >>> 0;
115
+ }
116
+
117
+ export function validatePngAvatar(bytes: Uint8Array): AvatarImage {
118
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
119
+ if (buffer.byteLength > AVATAR_PNG_MAX_BYTES) {
120
+ throw new AvatarLoadError(`avatar.png exceeds the ${AVATAR_PNG_MAX_BYTES}-byte limit`);
121
+ }
122
+ if (buffer.byteLength < 45 || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
123
+ throw new AvatarLoadError("avatar.png is not a valid PNG file");
124
+ }
125
+
126
+ let offset = PNG_SIGNATURE.length;
127
+ let widthPx = 0;
128
+ let heightPx = 0;
129
+ let sawImageData = false;
130
+ let sawEnd = false;
131
+ while (offset + 12 <= buffer.byteLength) {
132
+ const chunkLength = buffer.readUInt32BE(offset);
133
+ const chunkEnd = offset + 12 + chunkLength;
134
+ if (chunkEnd > buffer.byteLength) throw new AvatarLoadError("avatar.png contains a truncated PNG chunk");
135
+ const chunkType = buffer.toString("ascii", offset + 4, offset + 8);
136
+ if (pngChunkCrc(buffer, offset + 4, offset + 8 + chunkLength) !== buffer.readUInt32BE(offset + 8 + chunkLength)) {
137
+ throw new AvatarLoadError(`avatar.png has an invalid ${chunkType} checksum`);
138
+ }
139
+ if (offset === PNG_SIGNATURE.length) {
140
+ if (chunkType !== "IHDR" || chunkLength !== 13) {
141
+ throw new AvatarLoadError("avatar.png must begin with a valid IHDR chunk");
142
+ }
143
+ widthPx = buffer.readUInt32BE(offset + 8);
144
+ heightPx = buffer.readUInt32BE(offset + 12);
145
+ const bitDepth = buffer[offset + 16]!;
146
+ const colorType = buffer[offset + 17]!;
147
+ const validDepths: Record<number, number[]> = {
148
+ 0: [1, 2, 4, 8, 16],
149
+ 2: [8, 16],
150
+ 3: [1, 2, 4, 8],
151
+ 4: [8, 16],
152
+ 6: [8, 16],
153
+ };
154
+ if (
155
+ widthPx === 0 ||
156
+ heightPx === 0 ||
157
+ widthPx > AVATAR_PNG_MAX_DIMENSION ||
158
+ heightPx > AVATAR_PNG_MAX_DIMENSION ||
159
+ widthPx * heightPx > AVATAR_PNG_MAX_PIXELS
160
+ ) {
161
+ throw new AvatarLoadError(
162
+ `avatar.png must be at most ${AVATAR_PNG_MAX_DIMENSION}×${AVATAR_PNG_MAX_DIMENSION} pixels and ${AVATAR_PNG_MAX_PIXELS} total pixels`,
163
+ );
164
+ }
165
+ if (
166
+ !validDepths[colorType]?.includes(bitDepth) ||
167
+ buffer[offset + 18] !== 0 ||
168
+ buffer[offset + 19] !== 0 ||
169
+ (buffer[offset + 20] !== 0 && buffer[offset + 20] !== 1)
170
+ ) {
171
+ throw new AvatarLoadError("avatar.png has an unsupported PNG header");
172
+ }
173
+ } else if (chunkType === "IHDR") {
174
+ throw new AvatarLoadError("avatar.png contains multiple IHDR chunks");
175
+ }
176
+ if (chunkType === "IDAT") sawImageData = true;
177
+ if (chunkType === "IEND") {
178
+ if (chunkLength !== 0 || chunkEnd !== buffer.byteLength) {
179
+ throw new AvatarLoadError("avatar.png has an invalid IEND chunk");
180
+ }
181
+ sawEnd = true;
182
+ break;
183
+ }
184
+ offset = chunkEnd;
185
+ }
186
+ if (!sawImageData || !sawEnd) throw new AvatarLoadError("avatar.png is missing required PNG chunks");
187
+
188
+ return {
189
+ mimeType: "image/png",
190
+ data: buffer.toString("base64"),
191
+ widthPx,
192
+ heightPx,
193
+ bytes: buffer.byteLength,
194
+ };
195
+ }
196
+
91
197
  export function sanitizeAvatar(raw: string, maxColumns = AVATAR_MAX_COLUMNS, maxLines = AVATAR_MAX_LINES): Avatar {
92
198
  const normalized = raw.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
93
199
  const lines = normalized.split("\n").map((line) => {
@@ -144,8 +250,39 @@ async function loadAvatarFile(character: Character, filename: string, allowAnsi:
144
250
  return allowAnsi ? sanitizeAnsiAvatar(raw) : sanitizeAvatar(raw);
145
251
  }
146
252
 
253
+ async function loadPngAvatar(character: Character): Promise<AvatarImage | undefined> {
254
+ const filename = "avatar.png";
255
+ const avatarPath = join(character.directory, filename);
256
+ let canonicalAvatarPath: string;
257
+ try {
258
+ canonicalAvatarPath = await realpath(avatarPath);
259
+ } catch (error) {
260
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
261
+ throw new AvatarLoadError(`Cannot inspect avatar: ${avatarPath}`);
262
+ }
263
+ if (!isWithin(character.directory, canonicalAvatarPath)) {
264
+ throw new AvatarLoadError(`Avatar path escapes the character directory: ${avatarPath}`);
265
+ }
266
+ let bytes: Buffer;
267
+ try {
268
+ bytes = await readFile(canonicalAvatarPath);
269
+ } catch {
270
+ throw new AvatarLoadError(`Cannot read avatar: ${avatarPath}`);
271
+ }
272
+ try {
273
+ return validatePngAvatar(bytes);
274
+ } catch (error) {
275
+ if (error instanceof AvatarLoadError) {
276
+ throw new AvatarLoadError(`${error.message}: ${avatarPath}`);
277
+ }
278
+ throw error;
279
+ }
280
+ }
281
+
147
282
  export async function loadAvatar(character: Character): Promise<Avatar | undefined> {
283
+ const image = await loadPngAvatar(character);
148
284
  const ansi = await loadAvatarFile(character, "avatar.ansi", true);
149
- if (ansi) return ansi;
150
- return await loadAvatarFile(character, "avatar.txt", false);
285
+ const fallback = ansi ?? await loadAvatarFile(character, "avatar.txt", false);
286
+ if (!image) return fallback;
287
+ return { lines: fallback?.lines ?? [], truncated: fallback?.truncated ?? false, image };
151
288
  }
@@ -5,6 +5,7 @@ import {
5
5
  installPersonalAvatar,
6
6
  prepareAvatarContent,
7
7
  prepareAvatarImport,
8
+ preparePngAvatarContent,
8
9
  type PreparedAvatarImport,
9
10
  } from "./avatar-manager.ts";
10
11
  import {
@@ -25,6 +26,7 @@ const CHARACTER_BUNDLE_MAX_FILES = 66;
25
26
  interface CharacterBundleFileV1 {
26
27
  path: string;
27
28
  content: string;
29
+ encoding?: "base64";
28
30
  }
29
31
 
30
32
  interface CharacterBundleDocumentV1 {
@@ -38,7 +40,7 @@ export interface PreparedCharacterBundle {
38
40
  id: string;
39
41
  name: string;
40
42
  card: string;
41
- avatar?: PreparedAvatarImport;
43
+ avatars: PreparedAvatarImport[];
42
44
  forms: Array<{ path: string; content: string }>;
43
45
  }
44
46
 
@@ -132,6 +134,19 @@ async function bundleDocument(character: Character): Promise<{ document: Charact
132
134
  const files: CharacterBundleFileV1[] = [{ path: "CHARACTER.md", content: card }];
133
135
 
134
136
  let avatarIncluded = false;
137
+ for (const filename of ["avatar.png"] as const) {
138
+ const avatarPath = join(character.directory, filename);
139
+ try {
140
+ await lstat(avatarPath);
141
+ } catch (error) {
142
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
143
+ throw new CharacterEditError(`Cannot inspect avatar for export: ${avatarPath}`);
144
+ }
145
+ const avatar = await prepareAvatarImport(avatarPath);
146
+ if (avatar.kind !== "png") throw new CharacterEditError(`Unexpected avatar format: ${avatarPath}`);
147
+ files.push({ path: avatar.filename, content: avatar.content.toString("base64"), encoding: "base64" });
148
+ avatarIncluded = true;
149
+ }
135
150
  for (const filename of ["avatar.ansi", "avatar.txt"] as const) {
136
151
  const avatarPath = join(character.directory, filename);
137
152
  try {
@@ -141,6 +156,7 @@ async function bundleDocument(character: Character): Promise<{ document: Charact
141
156
  throw new CharacterEditError(`Cannot inspect avatar for export: ${avatarPath}`);
142
157
  }
143
158
  const avatar = await prepareAvatarImport(avatarPath);
159
+ if (avatar.kind !== "text") throw new CharacterEditError(`Unexpected avatar format: ${avatarPath}`);
144
160
  files.push({ path: avatar.filename, content: avatar.content });
145
161
  avatarIncluded = true;
146
162
  break;
@@ -219,6 +235,23 @@ export async function writeCharacterBundle(
219
235
  return summary;
220
236
  }
221
237
 
238
+ function decodeBase64(value: string, path: string): Buffer {
239
+ if (value.length === 0 || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
240
+ throw new CharacterEditError(`Character bundle contains invalid base64 content: ${path}`);
241
+ }
242
+ const decoded = Buffer.from(value, "base64");
243
+ if (decoded.toString("base64") !== value) {
244
+ throw new CharacterEditError(`Character bundle contains non-canonical base64 content: ${path}`);
245
+ }
246
+ return decoded;
247
+ }
248
+
249
+ function bundleAvatarEntry(avatar: PreparedAvatarImport): CharacterBundleFileV1 {
250
+ return avatar.kind === "png"
251
+ ? { path: avatar.filename, content: avatar.content.toString("base64"), encoding: "base64" }
252
+ : { path: avatar.filename, content: avatar.content };
253
+ }
254
+
222
255
  function validateBundleDocument(value: unknown): PreparedCharacterBundle {
223
256
  if (!isRecord(value) || value.format !== CHARACTER_BUNDLE_FORMAT || value.version !== CHARACTER_BUNDLE_VERSION) {
224
257
  throw new CharacterEditError("Unsupported character bundle format or version");
@@ -230,10 +263,15 @@ function validateBundleDocument(value: unknown): PreparedCharacterBundle {
230
263
  throw new CharacterEditError(`Character bundle must contain 1-${CHARACTER_BUNDLE_MAX_FILES} files`);
231
264
  }
232
265
 
233
- const files = new Map<string, string>();
266
+ const files = new Map<string, CharacterBundleFileV1>();
234
267
  let contentBytes = 0;
235
268
  for (const entry of value.files) {
236
- if (!isRecord(entry) || typeof entry.path !== "string" || typeof entry.content !== "string") {
269
+ if (
270
+ !isRecord(entry) ||
271
+ typeof entry.path !== "string" ||
272
+ typeof entry.content !== "string" ||
273
+ (entry.encoding !== undefined && entry.encoding !== "base64")
274
+ ) {
237
275
  throw new CharacterEditError("Character bundle contains an invalid file entry");
238
276
  }
239
277
  const path = normalizePortablePath(entry.path);
@@ -242,11 +280,13 @@ function validateBundleDocument(value: unknown): PreparedCharacterBundle {
242
280
  if (contentBytes > CHARACTER_BUNDLE_MAX_BYTES) {
243
281
  throw new CharacterEditError(`Character bundle content exceeds the ${CHARACTER_BUNDLE_MAX_BYTES}-byte limit`);
244
282
  }
245
- files.set(path, entry.content);
283
+ files.set(path, { path, content: entry.content, ...(entry.encoding === "base64" ? { encoding: "base64" as const } : {}) });
246
284
  }
247
285
 
248
- const card = files.get("CHARACTER.md");
249
- if (card === undefined) throw new CharacterEditError("Character bundle is missing CHARACTER.md");
286
+ const cardEntry = files.get("CHARACTER.md");
287
+ if (cardEntry === undefined) throw new CharacterEditError("Character bundle is missing CHARACTER.md");
288
+ if (cardEntry.encoding !== undefined) throw new CharacterEditError("CHARACTER.md must use UTF-8 text content");
289
+ const card = cardEntry.content;
250
290
  if (byteLength(card) > CHARACTER_CARD_MAX_BYTES) {
251
291
  throw new CharacterEditError(`CHARACTER.md exceeds the ${CHARACTER_CARD_MAX_BYTES}-byte bundle limit`);
252
292
  }
@@ -261,15 +301,28 @@ function validateBundleDocument(value: unknown): PreparedCharacterBundle {
261
301
  }
262
302
  }
263
303
 
264
- let avatar: PreparedAvatarImport | undefined;
304
+ const avatars: PreparedAvatarImport[] = [];
305
+ let textAvatarFound = false;
306
+ let pngAvatarFound = false;
265
307
  const forms: Array<{ path: string; content: string }> = [];
266
- for (const [path, content] of files) {
308
+ for (const [path, entry] of files) {
267
309
  if (path === "CHARACTER.md") continue;
310
+ const { content, encoding } = entry;
311
+ if (path === "avatar.png") {
312
+ if (pngAvatarFound) throw new CharacterEditError("Character bundle may contain only one PNG avatar");
313
+ if (encoding !== "base64") throw new CharacterEditError("avatar.png must use base64 content");
314
+ avatars.push(preparePngAvatarContent(decodeBase64(content, path)));
315
+ pngAvatarFound = true;
316
+ continue;
317
+ }
268
318
  if (path === "avatar.ansi" || path === "avatar.txt") {
269
- if (avatar) throw new CharacterEditError("Character bundle may contain only one avatar file");
270
- avatar = prepareAvatarContent(path, content);
319
+ if (textAvatarFound) throw new CharacterEditError("Character bundle may contain only one text avatar fallback");
320
+ if (encoding !== undefined) throw new CharacterEditError(`${path} must use UTF-8 text content`);
321
+ avatars.push(prepareAvatarContent(path, content));
322
+ textAvatarFound = true;
271
323
  continue;
272
324
  }
325
+ if (encoding !== undefined) throw new CharacterEditError(`Character bundle text file must not use base64: ${path}`);
273
326
  if (!allowedForms.has(path)) {
274
327
  throw new CharacterEditError(`Character bundle contains an undeclared file: ${path}`);
275
328
  }
@@ -278,7 +331,7 @@ function validateBundleDocument(value: unknown): PreparedCharacterBundle {
278
331
  }
279
332
  forms.push({ path, content });
280
333
  }
281
- return { id: value.id, name: parsed.name, card, avatar, forms };
334
+ return { id: value.id, name: parsed.name, card, avatars, forms };
282
335
  }
283
336
 
284
337
  export async function prepareCharacterBundleImport(sourcePath: string): Promise<PreparedCharacterBundle> {
@@ -303,7 +356,7 @@ export async function installCharacterBundle(
303
356
  id: bundle.id,
304
357
  files: [
305
358
  { path: "CHARACTER.md", content: bundle.card },
306
- ...(bundle.avatar ? [{ path: bundle.avatar.filename, content: bundle.avatar.content }] : []),
359
+ ...bundle.avatars.map(bundleAvatarEntry),
307
360
  ...bundle.forms.map((form) => ({ path: form.path, content: form.content })),
308
361
  ],
309
362
  });
@@ -317,7 +370,7 @@ export async function installCharacterBundle(
317
370
  for (const form of validated.forms) {
318
371
  await savePersonalForm(stagingRoot, validated.id, form.path, form.content);
319
372
  }
320
- if (validated.avatar) await installPersonalAvatar(stagingRoot, validated.id, validated.avatar);
373
+ for (const avatar of validated.avatars) await installPersonalAvatar(stagingRoot, validated.id, avatar);
321
374
  const staged = await loadCharacter(stagingRoot, validated.id);
322
375
  await rename(staged.directory, target);
323
376
  return await loadCharacter(canonicalRoot, validated.id);
package/src/menu.ts CHANGED
@@ -391,7 +391,7 @@ async function manageAvatarFile(ctx: ExtensionCommandContext, dependencies: Inca
391
391
 
392
392
  try {
393
393
  if (action === "Import avatar file") {
394
- const input = await ctx.ui.input("Avatar file", "path to a .ansi or .txt file; drag and drop is supported");
394
+ const input = await ctx.ui.input("Avatar file", "path to a .png, .ansi, or .txt file; drag and drop is supported");
395
395
  if (!input?.trim()) return;
396
396
  const sourcePath = resolveAvatarSourcePath(input, ctx.cwd);
397
397
  const prepared = await prepareAvatarImport(sourcePath);
@@ -399,7 +399,8 @@ async function manageAvatarFile(ctx: ExtensionCommandContext, dependencies: Inca
399
399
  if (!character) return;
400
400
  const targetPath = await installPersonalAvatar(personalRoot, character.id, prepared);
401
401
  await refreshEditedCharacter(ctx, dependencies, character);
402
- ctx.ui.notify(`Avatar imported: ${prepared.width}×${prepared.height}\n${targetPath}`, "info");
402
+ const unit = prepared.kind === "png" ? "px" : "cells";
403
+ ctx.ui.notify(`Avatar imported: ${prepared.width}×${prepared.height} ${unit}\n${targetPath}`, "info");
403
404
  return;
404
405
  }
405
406
 
@@ -610,7 +611,7 @@ async function importCharacterBundle(
610
611
  const confirmed = await confirmMenu(
611
612
  ctx,
612
613
  "Import character package?",
613
- `${bundle.name} (${bundle.id}) · Avatar: ${bundle.avatar ? "included" : "none"} · Forms: ${bundle.forms.length}. Existing personal characters are never overwritten.`,
614
+ `${bundle.name} (${bundle.id}) · Avatar: ${bundle.avatars.length > 0 ? "included" : "none"} · Forms: ${bundle.forms.length}. Existing personal characters are never overwritten.`,
614
615
  );
615
616
  if (!confirmed) return;
616
617
  const character = await installCharacterBundle(personalRoot, bundle);
@@ -26,6 +26,13 @@ export interface IncarnateUiStateV1 {
26
26
  width: number;
27
27
  height: number;
28
28
  truncated: boolean;
29
+ image?: {
30
+ mimeType: "image/png";
31
+ data: string;
32
+ widthPx: number;
33
+ heightPx: number;
34
+ bytes: number;
35
+ };
29
36
  };
30
37
  }
31
38
 
@@ -60,6 +67,7 @@ export function createUiStateSnapshot(
60
67
  width: avatar.lines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0),
61
68
  height: avatar.lines.length,
62
69
  truncated: avatar.truncated,
70
+ ...(avatar.image ? { image: { ...avatar.image } } : {}),
63
71
  };
64
72
  }
65
73
  return snapshot;