dsh-mobile-plugin 0.5.21 → 0.5.22

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.
Files changed (38) hide show
  1. package/README.md +3 -12
  2. package/assets/pets/README.md +9 -0
  3. package/assets/pets/deepy-whale/attention.gif +0 -0
  4. package/assets/pets/deepy-whale/done.gif +0 -0
  5. package/assets/pets/deepy-whale/error.gif +0 -0
  6. package/assets/pets/deepy-whale/idle.gif +0 -0
  7. package/assets/pets/deepy-whale/pack.json +54 -0
  8. package/assets/pets/deepy-whale/sleeping.gif +0 -0
  9. package/assets/pets/deepy-whale/working-3.gif +0 -0
  10. package/assets/pets/deepy-whale/working.gif +0 -0
  11. package/extensions/README.md +64 -0
  12. package/extensions/catalog.json +1 -0
  13. package/lib/impl.js +21 -1
  14. package/lib/mobile/approvals.js +22 -2
  15. package/lib/mobile/cli/acp.js +34 -20
  16. package/lib/mobile/cli/mobile.js +8 -1
  17. package/lib/mobile/cli/service.js +151 -10
  18. package/lib/mobile/extension-catalog.js +244 -0
  19. package/lib/mobile/local-images.js +10 -5
  20. package/lib/mobile/pet-tools.js +215 -0
  21. package/lib/mobile/pets.js +559 -0
  22. package/lib/mobile/session-local-image.js +9 -1
  23. package/lib/mobile/stats-collect.cjs +15 -7
  24. package/lib/mobile/stats-ledger.cjs +44 -5
  25. package/lib/mobile/wechat-tools.js +257 -0
  26. package/lib/mobile/wechat.js +489 -0
  27. package/lib/panel/index.html +10 -10
  28. package/lib/relay/pairing-payload.cjs +13 -5
  29. package/lib/relay/snapshot-auth.js +82 -0
  30. package/lib/relay/status.js +3 -0
  31. package/lib/routes/control-plane.js +65 -3
  32. package/lib/routes/mobile-api.js +54 -1
  33. package/lib/sidecar-facade-worker.cjs +8 -0
  34. package/lib/tailcat/host-process.cjs +266 -0
  35. package/lib/tailcat/ingress.cjs +98 -0
  36. package/lib/tailcat/session.cjs +264 -0
  37. package/lib/tailcat/wiring.cjs +96 -0
  38. package/package.json +10 -1
@@ -0,0 +1,244 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 已审核扩展清单:随 dsh-mobile-plugin 的 npm 包一起发布的第三方扩展(包内 `extensions/`)。
5
+ *
6
+ * 与开发包那条路的分界:开发包读作者电脑上的 `$DSH_HOME/extension-dev`(边写边看),这条读
7
+ * 插件包自带的只读目录 —— 只有审过、登记进 `extensions/catalog.json` 的包才下发,目录里有但
8
+ * 没登记的一律当不存在。审核闸门在清单,不在目录。
9
+ *
10
+ * 能力面只多「读这一个固定目录」:根由 `__dirname` 定位,不依赖进程 cwd,也不建目录;每个
11
+ * 文件的 size 与 sha256 由宿主扫目录现算,不信清单里写的数字。取文件先挡绝对路径与 `..` 段,
12
+ * 再 resolve 之后判前缀(带分隔符),不接受任意路径。仍然不暴露 shell、系统设置、任意文件读写。
13
+ *
14
+ * 坏包一律静默跳过:没登记、目录不在、manifest 读不出、超限(20MB / 200 个文件)都不该让整张
15
+ * 列表变红;catalog.json 不在或坏了就是一份空清单,不抛。取文件那条才逐个回错误码。
16
+ */
17
+
18
+ const crypto = require('node:crypto');
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const { sendJson } = require('../shared/http-util');
23
+
24
+ /** 审核清单文件名。只有列进来的 slug 才下发。 */
25
+ const CATALOG_FILE = 'catalog.json';
26
+ /** slug 直接当包目录名,所以只认这套字符;别的名字连读都不读。 */
27
+ const SLUG_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
28
+ /** 整包上限:与开发包同一档,太重的包手机也不该整份拉。 */
29
+ const MAX_PACKAGE_BYTES = 20 * 1024 * 1024;
30
+ const MAX_PACKAGE_FILES = 200;
31
+ /** 单个文件上限:一条 base64 响应要塞进手机内存,再大就不该走这条路。 */
32
+ const MAX_FILE_BYTES = 8 * 1024 * 1024;
33
+
34
+ /** 包内固定目录:从 lib/mobile/ 往上两级就是包根。不依赖 cwd、不建目录(随 npm 包发布)。 */
35
+ function catalogRoot() {
36
+ return path.join(__dirname, '..', '..', 'extensions');
37
+ }
38
+
39
+ /** 400 信封:与其余移动端接口同形,App 按 code 翻人话。 */
40
+ function fail(res, status, code, message) {
41
+ return sendJson(res, status, { ok: false, error: { code, message } });
42
+ }
43
+
44
+ /** manifest 里取一个非空字符串,缺了 / 类型不对 / 空串都退回默认值。 */
45
+ function textField(value, fallback) {
46
+ return typeof value === 'string' && value ? value : fallback;
47
+ }
48
+
49
+ /** 清单里的可选文案:非空字符串才收,其余一律 null。 */
50
+ function textOrNull(value) {
51
+ return typeof value === 'string' && value.trim() ? value : null;
52
+ }
53
+
54
+ /** 能力声明收窄成字符串数组:表外的类型与空串丢掉,重复的不给第二份。 */
55
+ function capabilitiesOf(value) {
56
+ if (!Array.isArray(value)) return [];
57
+ const out = [];
58
+ const seen = new Set();
59
+ for (const item of value) {
60
+ if (typeof item !== 'string' || !item.trim() || seen.has(item)) continue;
61
+ seen.add(item);
62
+ out.push(item);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function sha256Hex(raw) {
68
+ return crypto.createHash('sha256').update(raw).digest('hex');
69
+ }
70
+
71
+ /**
72
+ * 读审核清单。文件不在、读坏、形状不对都当空清单 —— 空的插件包本来就没有第三方扩展。
73
+ * 只收 slug / description / author / releaseNotes 四样,其余字段(包括任何 size / sha256)
74
+ * 一概不认:清单只负责「谁审过了」,文件事实由扫目录算。
75
+ */
76
+ function readCatalog(root) {
77
+ let text;
78
+ try { text = fs.readFileSync(path.join(root, CATALOG_FILE), 'utf8'); } catch { return []; }
79
+ let parsed;
80
+ try { parsed = JSON.parse(text); } catch { return []; }
81
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Array.isArray(parsed.packages)) return [];
82
+ const rows = [];
83
+ const seen = new Set();
84
+ for (const raw of parsed.packages) {
85
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
86
+ const slug = typeof raw.slug === 'string' ? raw.slug.trim() : '';
87
+ if (!SLUG_PATTERN.test(slug) || seen.has(slug)) continue;
88
+ seen.add(slug);
89
+ rows.push({
90
+ slug,
91
+ description: textOrNull(raw.description),
92
+ author: textOrNull(raw.author),
93
+ releaseNotes: textOrNull(raw.releaseNotes),
94
+ });
95
+ }
96
+ return rows;
97
+ }
98
+
99
+ /**
100
+ * 递归收集包内的普通文件,`path` 是相对包目录的 POSIX 路径。与 extension-dev.js 同一套口径:
101
+ * 点开头的文件与目录不看,符号链接不是普通文件,超限当场收手返回 false(先收完再判等于把
102
+ * 500MB 的目录整个哈希一遍)。
103
+ */
104
+ function collectFiles(directory, acc) {
105
+ let entries;
106
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return false; }
107
+ for (const entry of entries) {
108
+ if (entry.name.startsWith('.')) continue;
109
+ const full = path.join(directory, entry.name);
110
+ if (entry.isDirectory()) {
111
+ if (!collectFiles(full, acc)) return false;
112
+ continue;
113
+ }
114
+ if (!entry.isFile()) continue;
115
+ let raw;
116
+ try { raw = fs.readFileSync(full); } catch { continue; }
117
+ acc.bytes += raw.length;
118
+ acc.files.push({
119
+ path: path.relative(acc.root, full).split(path.sep).join('/'),
120
+ size: raw.length,
121
+ sha256: sha256Hex(raw),
122
+ });
123
+ if (acc.bytes > MAX_PACKAGE_BYTES || acc.files.length > MAX_PACKAGE_FILES) return false;
124
+ }
125
+ return true;
126
+ }
127
+
128
+ /**
129
+ * 扫审核清单,把每个登记过、且目录与 manifest 都在的包读成一份 Package。
130
+ *
131
+ * 入口页固定是包目录下那层 `index.html`:App 侧 `extensionEntryPath` 找的就是这个名字,
132
+ * `manifest.json` 里写 `main` 不生效。所以这里在列包时先确认 `index.html` 真的在文件清单里,
133
+ * 缺了就把整包跳过 —— 装到手机上再报「需要重新安装」,比根本不出现在清单里难查得多。
134
+ *
135
+ * 纯函数、root 由调用方给:守卫拿假目录跑,线上 handler 给包内那个固定目录。
136
+ */
137
+ function listPackages(root) {
138
+ const packages = [];
139
+ for (const entry of readCatalog(root)) {
140
+ const directory = path.join(root, entry.slug);
141
+ let stats;
142
+ try { stats = fs.statSync(directory); } catch { continue; }
143
+ if (!stats.isDirectory()) continue;
144
+ let manifest;
145
+ try {
146
+ manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
147
+ } catch { continue; }
148
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) continue;
149
+ const acc = { root: directory, files: [], bytes: 0 };
150
+ if (!collectFiles(directory, acc) || acc.files.length === 0) continue;
151
+ if (!acc.files.some((file) => file.path === 'index.html')) continue;
152
+ packages.push({
153
+ slug: entry.slug,
154
+ name: textField(manifest.name, entry.slug),
155
+ description: entry.description,
156
+ author: entry.author,
157
+ versionName: textField(manifest.versionName, textField(manifest.version, '0.0.0')),
158
+ apiVersion: textField(manifest.apiVersion, textField(manifest.api_version, '1')),
159
+ releaseNotes: entry.releaseNotes,
160
+ capabilities: capabilitiesOf(manifest.capabilities),
161
+ size: acc.bytes,
162
+ fileCount: acc.files.length,
163
+ files: acc.files,
164
+ });
165
+ }
166
+ return packages;
167
+ }
168
+
169
+ /**
170
+ * GET /api/mobile/v1/extension/catalog(token)
171
+ *
172
+ * 回审核过的第三方扩展清单。`root` 只是给守卫传假目录的可选参数,线上由 route() 调用时
173
+ * 不传,永远走包内那个固定目录。
174
+ */
175
+ async function handleCatalog(_ctx, _req, res, root = catalogRoot()) {
176
+ sendJson(res, 200, { ok: true, packages: listPackages(root) });
177
+ }
178
+
179
+ /**
180
+ * GET /api/mobile/v1/extension/catalog/file?slug=…&path=…(token)
181
+ *
182
+ * 取一个已审核包里的一个文件,回 base64 与 sha256(与列表里那条同一份算法)。审核闸门对取文件
183
+ * 同样生效:没登记进 catalog.json 的 slug 一律 404,免得绕过清单直接把目录里的字节读走。
184
+ *
185
+ * 路径检查两道:先挡明显不该出现的(绝对路径、`..` 段),再 resolve 之后判前缀 —— 后者才是
186
+ * 判据,前缀比较必须带分隔符,否则 `/x/extensions/foobar` 会被 `/x/extensions/foo` 认成子路径。
187
+ */
188
+ async function handleCatalogFile(_ctx, req, res, root = catalogRoot()) {
189
+ const url = new URL(req.url || '/', 'http://x');
190
+ const slug = (url.searchParams.get('slug') || '').trim();
191
+ const requested = (url.searchParams.get('path') || '').trim();
192
+ if (!slug || !requested || !SLUG_PATTERN.test(slug)) {
193
+ return fail(res, 400, 'bad-request', '需要合法的 slug 与 path(path 是相对包目录的路径)');
194
+ }
195
+
196
+ if (!readCatalog(root).some((entry) => entry.slug === slug)) {
197
+ return fail(res, 404, 'package-missing', `审核清单里没有 ${slug} 这个包`);
198
+ }
199
+ const directory = path.join(root, slug);
200
+ if (!fs.existsSync(path.join(directory, 'manifest.json'))) {
201
+ return fail(res, 404, 'package-missing', `${slug} 没有可下发的包(缺 manifest.json)`);
202
+ }
203
+
204
+ if (path.isAbsolute(requested) || requested.split(/[\\/]/).includes('..')) {
205
+ return fail(res, 400, 'bad-request', 'path 必须是包目录内的相对路径');
206
+ }
207
+ const target = path.resolve(directory, requested);
208
+ if (!target.startsWith(`${directory}${path.sep}`)) {
209
+ return fail(res, 400, 'bad-request', 'path 必须是包目录内的相对路径');
210
+ }
211
+
212
+ let stats;
213
+ try { stats = fs.statSync(target); } catch { stats = null; }
214
+ if (!stats || !stats.isFile()) {
215
+ return fail(res, 404, 'file-missing', `这个包里没有 ${requested}`);
216
+ }
217
+ if (stats.size > MAX_FILE_BYTES) {
218
+ return fail(res, 413, 'file-too-large', `单个文件上限 ${MAX_FILE_BYTES} 字节,这个超了`);
219
+ }
220
+
221
+ let raw;
222
+ try { raw = fs.readFileSync(target); } catch {
223
+ return fail(res, 404, 'file-missing', `这个包里没有 ${requested}`);
224
+ }
225
+
226
+ const relative = path.relative(directory, target).split(path.sep).join('/');
227
+ sendJson(res, 200, {
228
+ ok: true,
229
+ path: relative,
230
+ size: raw.length,
231
+ sha256: sha256Hex(raw),
232
+ content: raw.toString('base64'),
233
+ });
234
+ }
235
+
236
+ module.exports = {
237
+ catalogRoot,
238
+ listPackages,
239
+ handleCatalog,
240
+ handleCatalogFile,
241
+ MAX_PACKAGE_BYTES,
242
+ MAX_PACKAGE_FILES,
243
+ MAX_FILE_BYTES,
244
+ };
@@ -44,7 +44,7 @@ const MEDIA_TYPES = {
44
44
  '.gif': 'image/gif',
45
45
  };
46
46
 
47
- /** 单张图上限:再大的东西不该整包回给手机(超出如实保持字面 markdown)。 */
47
+ /** 单张图上限:再大的东西不该整包回给手机。 */
48
48
  const MAX_IMAGE_BYTES = 2.5 * 1024 * 1024;
49
49
  /** 读宽高只需要文件头:JPEG 的 SOF 段可能在几百字节之后,64KB 足够覆盖绝大多实拍图。 */
50
50
  const HEAD_BYTES = 64 * 1024;
@@ -246,6 +246,10 @@ async function readHead(filePath, size) {
246
246
  *
247
247
  * 编号取「路径 + 大小 + 修改时间」的短哈希:同一张图在同一轮历史里会被反复解析,
248
248
  * 这个组合稳定,且改过之后自然换号(图片内容变了本来就该换)。
249
+ *
250
+ * **超过单张上限的图也登记,只是标一句 `tooLarge`**:不登记的话正文里那一段就退回字面
251
+ * markdown,用户在手机上看到的是一串 `![](路径)` 源码 —— 既不知道发生过什么,也没有任何
252
+ * 入口。标了之后取图口如实回 413,App 画成「图片太大」的占位块,这才是用户能读懂的一句话。
249
253
  */
250
254
  async function registerLocalImage(filePath) {
251
255
  let stat;
@@ -254,7 +258,7 @@ async function registerLocalImage(filePath) {
254
258
  } catch {
255
259
  return null;
256
260
  }
257
- if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_IMAGE_BYTES) return null;
261
+ if (!stat.isFile() || stat.size <= 0) return null;
258
262
  const id = crypto.createHash('sha1').update(`${filePath}:${stat.size}:${stat.mtimeMs}`).digest('hex').slice(0, 16);
259
263
  const known = registry.get(id);
260
264
  if (known) return { id, ...known };
@@ -262,11 +266,12 @@ async function registerLocalImage(filePath) {
262
266
  if (!head) return null;
263
267
  const mediaType = sniffMediaType(head);
264
268
  if (!mediaType) return null;
265
- const parsed = imageSizeOf(head);
269
+ const over = stat.size > MAX_IMAGE_BYTES;
270
+ const parsed = over ? null : imageSizeOf(head);
266
271
  const sane = parsed && parsed.width > 0 && parsed.height > 0
267
272
  && parsed.width <= 30000 && parsed.height <= 30000;
268
273
  const size = sane ? parsed : {};
269
- const entry = { filePath, mediaType, ...size };
274
+ const entry = { filePath, mediaType, ...size, ...(over ? { tooLarge: true } : {}) };
270
275
  registry.set(id, entry);
271
276
  while (registry.size > MAX_REGISTRY_ENTRIES) registry.delete(registry.keys().next().value);
272
277
  return { id, ...entry };
@@ -467,4 +472,4 @@ function inRanges(index, ranges) {
467
472
  return false;
468
473
  }
469
474
 
470
- module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages };
475
+ module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages, MAX_IMAGE_BYTES };
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 给模型的两个工具:看一眼这台电脑上有哪些宠物包、做一只新的(或者改现有的)。
5
+ *
6
+ * ## 为什么要有工具
7
+ *
8
+ * 宠物包是「一个目录 + 一份 pack.json + 几张图」,而素材基本都已经是电脑上的图片文件了。
9
+ * 让模型自己往目录里写,它记不住放哪儿、id 什么形状、状态键叫什么;写完还得自己回一句
10
+ * 「好了」,用户那边刷半天也不一定看得到。工具负责的正是这几件它记不住的事。
11
+ *
12
+ * ## 为什么不收 base64 为主
13
+ *
14
+ * 与公众号那条同一个理由:让模型把几 MB 的图抄进工具参数,抄不动也抄不对。它手里真有的是
15
+ * 「那张图在这台电脑上的路径」,所以 `filePath` 是主路,`base64` 只在它手上真有字节时兜底。
16
+ *
17
+ * ## 能力面的边界
18
+ *
19
+ * 只碰这个库:id 形状卡死、图片只认单层 `.gif` / `.png`、单张与整包都有上限。**没有删除** ——
20
+ * 删是人的动作(设置页那边有个按钮),不该由模型顺手做掉。
21
+ */
22
+
23
+ const { defineTool } = require('@deepseek-ai/dsh-tools');
24
+
25
+ const {
26
+ listPacks,
27
+ writePack,
28
+ isImageName,
29
+ ID_RE,
30
+ KNOWN_STATES,
31
+ } = require('./pets.js');
32
+
33
+ /** 列表里一行一只:名字、id、内置还是自己做的、状态齐不齐。 */
34
+ function packLine(pack) {
35
+ const tags = [pack.builtin ? '内置' : '自己做的'];
36
+ tags.push(`id ${pack.id}`);
37
+ // `pack.states` 到这一层已经是状态名数组(execute 那步折过),按名字查键永远是 undefined。
38
+ const missing = KNOWN_STATES.filter((state) => state !== 'sleeping' && !pack.states.includes(state));
39
+ tags.push(missing.length === 0 ? '五个状态都有' : `缺 ${missing.join(' / ')}(将回退到 idle)`);
40
+ if (pack.author) tags.push(pack.author);
41
+ return `· 《${pack.name}》${tags.join(',')}`;
42
+ }
43
+
44
+ /**
45
+ * 注册这两个工具。
46
+ *
47
+ * 必须走 `ctx.inject(['tools'], …)`:宿主里 `tools` 是子代理作用域提供的服务,插件这一层
48
+ * 直接读 `ctx.tools` 拿到的常常是 undefined(见 `phone/tools.js` 那一整段教训)。缺失时
49
+ * 静默跳过,别把整块装配掀掉。
50
+ */
51
+ function installPetTools(ctx) {
52
+ if (typeof ctx.inject !== 'function') return;
53
+ try {
54
+ ctx.inject(['tools'], mountTools);
55
+ } catch (error) {
56
+ // 没有 tools 注入的 profile(cordis 抛 "… without inject"):只少这两个工具,包目录与
57
+ // 三条路由照常可用。其余错误照抛 —— 别把真的注册失败也当成「这个 profile 没注入」。
58
+ const message = error && error.message ? error.message : String(error);
59
+ if (!message.includes('without inject')) throw error;
60
+ }
61
+ }
62
+
63
+ /** `ctx.inject` 的回调体。单独拎出来只为让上面那层 try/catch 短一点。 */
64
+ function mountTools(scoped) {
65
+ if (!scoped || !scoped.tools || typeof scoped.tools.register !== 'function') return;
66
+
67
+ scoped.tools.register(defineTool({
68
+ name: 'pet_pack_list',
69
+ description: 'List the desktop-pet packages on this computer: the built-in ones that ship with the app, plus any the user made. Each line gives the id, name, whether it is built-in, and which animation states it has. Use this when the user asks what pets they have or what their pet looks like, and before writing a pack so you pick an id that is free.',
70
+ parameters: {},
71
+ output: {
72
+ schema: {
73
+ type: 'object',
74
+ additionalProperties: false,
75
+ properties: {
76
+ packs: {
77
+ type: 'array',
78
+ required: true,
79
+ items: {
80
+ type: 'object',
81
+ additionalProperties: false,
82
+ properties: {
83
+ id: { type: 'string' },
84
+ name: { type: 'string' },
85
+ builtin: { type: 'boolean' },
86
+ states: { type: 'array', items: { type: 'string' } },
87
+ },
88
+
89
+ },
90
+ },
91
+ },
92
+ },
93
+ render: (_args, value) => {
94
+ const packs = Array.isArray(value.packs) ? value.packs : [];
95
+ return [{
96
+ type: 'text',
97
+ text: packs.length === 0
98
+ ? '这台电脑上一个宠物包都还没有。用户想做一只的话,用 pet_pack_write 建一个。'
99
+ : `这台电脑上有 ${packs.length} 只:\n${packs.map(packLine).join('\n')}`,
100
+ }];
101
+ },
102
+ },
103
+ execute: async () => ({
104
+ packs: listPacks().map((pack) => ({
105
+ id: pack.id,
106
+ name: pack.name,
107
+ builtin: pack.builtin,
108
+ states: Object.keys(pack.states),
109
+ })),
110
+ }),
111
+ }));
112
+
113
+ scoped.tools.register(defineTool({
114
+ name: 'pet_pack_write',
115
+ description: 'Create or replace a desktop-pet package on this computer, so it shows up on the user\'s phone under Settings → floating pet. Use this whenever the user wants to change their pet, make a new one, or drop in their own artwork. A pack is one directory with a pack.json plus the images it references. States are idle / working / attention / done / error / sleeping, and idle is required; a state you leave out falls back to idle. Pictures are GIF (animated) or PNG (static). Give each picture the PATH of the file on this computer — the host reads it — rather than its bytes; base64 is only for when you already hold the bytes. Existing packs with the same id are replaced wholesale, including a built-in one (warn the user before doing that). You cannot delete packs; that is the user\'s job in Settings.',
116
+ parameters: {
117
+ id: {
118
+ type: 'string',
119
+ description: 'Pack id, also the folder name: 1-64 lowercase letters, digits and dashes, not starting with a dash, e.g. deepy-whale.',
120
+ },
121
+ pack: {
122
+ type: 'object',
123
+ additionalProperties: true,
124
+ description: 'The whole pack.json as an object. Required keys: name and states (with at least idle). Optional: author, license, canvas {w,h}, anchor {x,baseline}, tiers.working [{min,file}] (largest min wins), loop [], sleepAfterMs. Every file named here must be one you pass in files[].',
125
+ properties: {
126
+ name: { type: 'string', description: 'Shown in the picker on the phone.' },
127
+ author: { type: 'string', description: 'Credited in the picker footer. Fill it in whenever the artwork is not yours.' },
128
+ license: { type: 'string', description: 'License note, shown next to the author.' },
129
+ states: {
130
+ type: 'object',
131
+ additionalProperties: true,
132
+ description: 'State name → { file }. Recognised: idle, working, attention, done, error, sleeping. At least idle is required.',
133
+ },
134
+ tiers: {
135
+ type: 'object',
136
+ additionalProperties: true,
137
+ description: 'Optional. tiers.working is [{min, file}]: pick the image by how many conversations are running.',
138
+ },
139
+ loop: { type: 'array', items: { type: 'string' }, description: 'States that loop. Defaults to idle, working, attention.' },
140
+ sleepAfterMs: { type: 'number', description: 'Idle time before switching to sleeping. Only meaningful when the sleeping state exists.' },
141
+ },
142
+ },
143
+ files: {
144
+ type: 'array',
145
+ description: 'The images this pack uses. Each one is written into the pack folder under that name.',
146
+ items: {
147
+ type: 'object',
148
+ additionalProperties: false,
149
+ properties: {
150
+ path: { type: 'string', required: true, description: 'File name inside the pack, e.g. idle.gif. Single level, .gif or .png, no slashes.' },
151
+ filePath: { type: 'string', description: 'Absolute path of the picture on this computer. This is the normal way.' },
152
+ base64: { type: 'string', description: 'The image bytes, base64 encoded. Only when you already hold the bytes and have no file to point at.' },
153
+ },
154
+ },
155
+ },
156
+ },
157
+ output: {
158
+ schema: {
159
+ type: 'object',
160
+ additionalProperties: false,
161
+ properties: {
162
+ id: { type: 'string', required: true },
163
+ name: { type: 'string', required: true },
164
+ states: { type: 'array', items: { type: 'string' } },
165
+ written: { type: 'array', items: { type: 'string' } },
166
+ note: { type: 'string' },
167
+ },
168
+ },
169
+ render: (_args, value) => {
170
+ const lines = [`《${value.name}》存好了,id ${value.id},状态:${(value.states || []).join(' / ') || 'idle'}。`];
171
+ if (Array.isArray(value.written) && value.written.length > 0) lines.push(`图片已存:${value.written.join('、')}。`);
172
+ if (value.note) lines.push(value.note);
173
+ return [{ type: 'text', text: lines.join('\n') }];
174
+ },
175
+ },
176
+ execute: async (args) => {
177
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
178
+ if (!ID_RE.test(id)) {
179
+ throw new Error(`"${id}" 不是合法的宠物包 id:只认 1-64 位小写字母、数字与连字符,且不以连字符开头。`);
180
+ }
181
+ // 文件名先收窄一遍再交给库:这样「哪几个名字不合规矩」能在写之前就一趟说清。
182
+ const files = Array.isArray(args.files) ? args.files : [];
183
+ const badNames = files
184
+ .map((item) => (item && typeof item.path === 'string' ? item.path.trim() : ''))
185
+ .filter((name) => !isImageName(name));
186
+ if (badNames.length > 0) {
187
+ throw new Error(`这几个文件名不合规矩:${badNames.map((name) => `"${name}"`).join('、')}。只认单层 .gif / .png(不含斜杠),例如 idle.gif。`);
188
+ }
189
+
190
+ let result;
191
+ try {
192
+ result = await writePack({ id, pack: args.pack, files });
193
+ } catch (error) {
194
+ throw new Error(error && error.message ? error.message : '没能写进去。');
195
+ }
196
+ const { pack, rejected, written, overriddenBuiltin } = result;
197
+ const notes = [];
198
+ if (overriddenBuiltin) {
199
+ notes.push(`这个 id 原本有一只内置的宠物,现在被这份盖住了(手机上显示的是新的这只)。内置那份没被改动。`);
200
+ }
201
+ if (rejected.length > 0) {
202
+ notes.push(`这几张没能收进来:${rejected.join('、')}(读不到,或者不是 .gif / .png)。`);
203
+ }
204
+ return {
205
+ id: pack.id,
206
+ name: pack.name,
207
+ states: Object.keys(pack.states),
208
+ written,
209
+ ...(notes.length > 0 ? { note: notes.join('\n') } : {}),
210
+ };
211
+ },
212
+ }));
213
+ }
214
+
215
+ module.exports = { installPetTools };