made-market 0.2.0 → 0.4.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/README.md CHANGED
@@ -23,8 +23,8 @@ npx made-market publish <包目录>
23
23
 
24
24
  ## 服务地址
25
25
 
26
- - 默认索引:GitHub 上的 `registry/index.json`(离线时回落到包内自带快照)
27
- - 自建市场:`--server` 指定市场服务地址,或设 `MADE_MARKET_REGISTRY` / `MADE_MARKET_SERVER` 环境变量
26
+ - 默认走市场服务的分页 API(`--server` 或 `MADE_MARKET_SERVER`,缺省官方市场);list/search/info 按页拉取,install/info 按包直查,大市场友好
27
+ - 自建静态源:`--registry` 或 `MADE_MARKET_REGISTRY` 指向索引文件(离线时 install 回落到包内自带示例快照)
28
28
 
29
29
  ## 服务端
30
30
 
@@ -33,8 +33,8 @@ const USAGE = `码得 · 插件市场 (made-market)
33
33
  命令
34
34
  init <目录> --kind <类型> 生成插件脚手架(可直接 publish 的最小包,SDK 入口)
35
35
  validate <包目录> 本地校验一个包(与市场服务端同一套规则)
36
- list 列出索引里的全部包
37
- search <关键词> 在包名、简介和标签里搜索
36
+ list 列出市场上的包(分页 API,大市场友好)
37
+ search <关键词> 在包名、简介和标签里搜索(服务端搜索,分页)
38
38
  info <包 ID> 看一个包的详情:文件、校验和、安装位置、创建人
39
39
  install <包 ID…> 装到包目录,重启桥接器后生效(匿名,无需登录)
40
40
  publish <包目录> 把一个包目录上传到市场服务(需先登录)
@@ -46,8 +46,8 @@ const USAGE = `码得 · 插件市场 (made-market)
46
46
 
47
47
  选项
48
48
  --home <目录> 包目录根,默认 $MADE_HOME 或 ~/made
49
- --registry <路径|URL> 索引来源。默认 $MADE_MARKET_REGISTRY > 官方市场(联网拉实时索引,
50
- 失败回落本包自带索引,离线可装示例包)
49
+ --registry <路径|URL> 索引来源(install 默认走市场 API 按包直查;
50
+ 显式指定后 list/search/info 也改读这个索引文件,自建静态源用)
51
51
  --server <URL> 市场服务地址(register/login/publish/install;install 默认官方市场,
52
52
  可用 $MADE_MARKET_SERVER 覆盖,已登录时 publish 也可从凭证读取)
53
53
  --token <t> 发布 token(publish;默认 $MADE_MARKET_TOKEN 或登录凭证)
@@ -99,7 +99,7 @@ const semverGreater = (a, b) => {
99
99
 
100
100
  /**
101
101
  * API 模式安装:按包直查市场服务的 /api/packages/<id>,不下载全量索引。
102
- * 大市场(索引几十 MB)时用这个,归档地址改写成服务上的绝对地址。
102
+ * 大市场(十几万包,全量索引根本不存在)时用这个,归档地址改写成服务上的绝对地址。
103
103
  */
104
104
  async function resolveViaApi(ids, server) {
105
105
  const base = String(server).replace(/\/+$/, '');
@@ -115,6 +115,36 @@ async function resolveViaApi(ids, server) {
115
115
  return { packages, base };
116
116
  }
117
117
 
118
+ /**
119
+ * 分页拉目录:/api/packages 每页最多 100,循环翻页直到拉完。
120
+ * 十几万包的市场没有全量索引,list/search 只能这么吃。maxPages 是
121
+ * 防呆上限(默认 500 页 = 5 万条,超出提示加关键词/过滤)。
122
+ */
123
+ async function fetchAllPages(server, { kind = '', q = '', maxPages = 500 } = {}) {
124
+ const base = String(server).replace(/\/+$/, '');
125
+ const items = [];
126
+ let offset = 0;
127
+ let total = Infinity;
128
+ while (offset < total) {
129
+ const params = new URLSearchParams({ limit: '100', offset: String(offset) });
130
+ if (kind) params.set('kind', kind);
131
+ if (q) params.set('q', q);
132
+ const response = await fetch(`${base}/api/packages?${params}`, { signal: AbortSignal.timeout(20000) });
133
+ if (!response.ok) throw new Error(`目录拉取失败:HTTP ${response.status}`);
134
+ const data = await response.json();
135
+ total = Number(data.total) || 0;
136
+ const batch = Array.isArray(data.items) ? data.items : [];
137
+ items.push(...batch);
138
+ if (!batch.length) break;
139
+ offset += batch.length;
140
+ if (offset >= 100 * maxPages) {
141
+ console.error(`包太多(共 ${total} 个),只列出前 ${offset} 个;用 search 或 --kind 缩小范围。`);
142
+ break;
143
+ }
144
+ }
145
+ return { items, total, base };
146
+ }
147
+
118
148
  const human = (bytes) => (bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(2)} MB`);
119
149
  const homeOf = (options) => resolveHome(options.home);
120
150
 
@@ -198,12 +228,21 @@ async function main() {
198
228
  return;
199
229
  }
200
230
 
201
- // 索引来源按需加载:--registry > $MADE_MARKET_REGISTRY > 官方市场实时索引
202
- // (远端失败自动回落包内自带索引,离线可装示例包)。
203
- // rebuild-from-store 不依赖全量索引。
204
- const loadIndex = () => loadRegistry(resolveRegistry(options.registry));
231
+ // list/search/info 的读路径:显式 --registry 或 $MADE_MARKET_REGISTRY 指向
232
+ // 索引文件(自建静态源/离线复现)时读文件;否则走市场分页 API(大市场没有全量索引)。
233
+ const staticRegistry = options.registry || process.env.MADE_MARKET_REGISTRY || '';
234
+ const loadIndex = () => loadRegistry(resolveRegistry(staticRegistry));
235
+
236
+ // list/search 的服务端地址:--server > $MADE_MARKET_SERVER > 官方市场
237
+ const listServer = () => (options.server || process.env.MADE_MARKET_SERVER || DEFAULT_SERVER).replace(/\/+$/, '');
205
238
 
206
239
  if (options.command === 'list') {
240
+ if (!staticRegistry) {
241
+ const { items } = await fetchAllPages(listServer(), { kind: options.kind });
242
+ if (options.json) console.log(JSON.stringify(items.map(describe), null, 2));
243
+ else printTable(items);
244
+ return;
245
+ }
207
246
  const rows = listRows((await loadIndex()).index, options);
208
247
  if (options.json) console.log(JSON.stringify(rows.map(describe), null, 2));
209
248
  else printTable(rows);
@@ -213,6 +252,13 @@ async function main() {
213
252
  if (options.command === 'search') {
214
253
  const query = options.args.join(' ').trim().toLowerCase();
215
254
  if (!query) throw new Error('search 需要一个关键词');
255
+ // 搜索在服务端做(id/名称/简介/标签全字段),客户端只翻页聚合。
256
+ if (!staticRegistry) {
257
+ const { items } = await fetchAllPages(listServer(), { q: query, kind: options.kind });
258
+ if (options.json) console.log(JSON.stringify(items.map(describe), null, 2));
259
+ else printTable(items);
260
+ return;
261
+ }
216
262
  const rows = listRows((await loadIndex()).index, options).filter((entry) => [
217
263
  entry.id, entry.name, entry.nameEn, entry.summary, entry.summaryEn, ...(entry.tags || []), ...(entry.highlights || []),
218
264
  ].filter(Boolean).some((field) => String(field).toLowerCase().includes(query)));
@@ -224,8 +270,19 @@ async function main() {
224
270
  if (options.command === 'info') {
225
271
  const id = options.args[0];
226
272
  if (!id) throw new Error('info 需要一个包 ID');
227
- const entry = (await loadIndex()).index.packages.find((item) => item.id === id);
228
- if (!entry) throw new Error(`索引里没有包 ${id}`);
273
+ let entry;
274
+ if (!staticRegistry) {
275
+ const base = listServer();
276
+ const response = await fetch(`${base}/api/packages/${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(15000) });
277
+ if (!response.ok) throw new Error(`服务上没有包 ${id}(HTTP ${response.status})`);
278
+ const versions = await response.json();
279
+ entry = versions.reduce((best, item) => (!best || semverGreater(item.version, best.version) ? item : best), null);
280
+ if (!entry) throw new Error(`服务上没有包 ${id}`);
281
+ if (!/^https?:\/\//.test(entry.download)) entry.download = `${base}/${entry.download}`;
282
+ } else {
283
+ entry = (await loadIndex()).index.packages.find((item) => item.id === id);
284
+ if (!entry) throw new Error(`索引里没有包 ${id}`);
285
+ }
229
286
  if (options.json) {
230
287
  console.log(JSON.stringify(describe(entry), null, 2));
231
288
  return;
@@ -254,8 +311,9 @@ async function main() {
254
311
  if (options.command === 'install') {
255
312
  if (!options.args.length) throw new Error('install 需要至少一个包 ID');
256
313
  // 默认走市场 API:按包直查最新版本,不拉全量索引(大市场友好),
257
- // 新发布的包立即可装。显式 --registry 才按索引文件走(自建源/精确复现用)。
258
- if (!options.registry) {
314
+ // 新发布的包立即可装。显式 --registry 或 $MADE_MARKET_REGISTRY 才按索引文件走
315
+ // (自建源/精确复现用)。
316
+ if (!options.registry && !process.env.MADE_MARKET_REGISTRY) {
259
317
  const server = options.server || process.env.MADE_MARKET_SERVER || DEFAULT_SERVER;
260
318
  try {
261
319
  const { packages, base } = await resolveViaApi(options.args, server);
@@ -34,6 +34,9 @@ export const CAPABILITIES = {
34
34
  { name: 'voice.start', desc: '主动点亮录音' },
35
35
  { name: 'task.confirm', desc: '批准待确认任务' },
36
36
  { name: 'task.cancel', desc: '取消任务' },
37
+ { name: 'ui.swipe', desc: '语义化手势,fields.direction:left/right(切助手)、up(开抽屉)、down(收抽屉)' },
38
+ { name: 'ui.tap', desc: '点击屏幕,fields.x/fields.y 为板面像素坐标(240x320),命中最深的可点击对象' },
39
+ { name: 'debug.screenshot', desc: '截取当前画面回传桥接器转 PNG(存 stateDir/screenshots/)' },
37
40
  ],
38
41
  },
39
42
  /** onJob 收到的任务生命周期,type 字段的全部取值(按典型顺序排列)。 */
package/lib/market.mjs CHANGED
@@ -38,12 +38,17 @@ const PAYLOAD_IGNORED = new Set(['market.json']);
38
38
  export const sha256 = (buffer) => crypto.createHash('sha256').update(buffer).digest('hex');
39
39
  export const hashFile = (file) => sha256(fs.readFileSync(file));
40
40
 
41
- /** Deterministic, sorted payload listing — `market.json` never ships. */
41
+ /** Deterministic, sorted payload listing — `market.json` never ships.
42
+ * 递归收子目录(如 agent 包的 sprite/),路径带 / 前缀;点文件与 market.json 永远跳过。 */
42
43
  export function payloadFiles(dir) {
43
- return fs.readdirSync(dir)
44
- .filter((name) => !PAYLOAD_IGNORED.has(name) && !name.startsWith('.'))
45
- .filter((name) => fs.statSync(path.join(dir, name)).isFile())
46
- .sort();
44
+ const walk = (base, prefix = '') => fs.readdirSync(base, { withFileTypes: true })
45
+ .filter((entry) => !PAYLOAD_IGNORED.has(entry.name) && !entry.name.startsWith('.'))
46
+ .flatMap((entry) => {
47
+ if (entry.isDirectory()) return walk(path.join(base, entry.name), `${prefix}${entry.name}/`);
48
+ if (entry.isFile()) return [`${prefix}${entry.name}`];
49
+ return [];
50
+ });
51
+ return walk(dir).sort();
47
52
  }
48
53
 
49
54
  export function readPackages(root = ROOT) {
@@ -134,8 +139,57 @@ function checkMarketFields(pkg, fail, warn) {
134
139
  /**
135
140
  * @returns {{errors: string[], warnings: string[], installDir: string, entry: string, pluginId: string}}
136
141
  */
137
- export function validatePackage(pkg) {
138
- const errors = [];
142
+ /** agent 包可选的 sprite/ 形象素材目录(规范见 made 仓库 docs/SPRITES.md)。
143
+ * 有目录就必须有合法 manifest;引用的 PNG 必须存在;整包 ≤512KB(分片下发协议约束)。 */
144
+ function validateSpriteDir(dir, fail, warn) {
145
+ const spriteDir = path.join(dir, 'sprite');
146
+ if (!fs.existsSync(spriteDir)) {
147
+ warn('agent 包没有 sprite/ 目录,设备将显示内置兜底形象(规范见 made 仓库 docs/SPRITES.md)');
148
+ return;
149
+ }
150
+ const manifestPath = path.join(spriteDir, 'manifest.json');
151
+ if (!fs.existsSync(manifestPath)) {
152
+ fail('sprite/ 缺少 manifest.json');
153
+ return;
154
+ }
155
+ let sprite;
156
+ try {
157
+ sprite = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
158
+ } catch (error) {
159
+ fail(`sprite/manifest.json 不是合法 JSON:${error.message}`);
160
+ return;
161
+ }
162
+ if (!Number.isInteger(sprite.version) || sprite.version < 1) {
163
+ fail('sprite/manifest.json 的 version 必须是 ≥1 的整数(改素材必须递增,否则设备不更新)');
164
+ }
165
+ if (!Array.isArray(sprite.layers) || !sprite.layers.length) {
166
+ fail('sprite/manifest.json 的 layers 必须是非空数组');
167
+ }
168
+ const pngs = fs.readdirSync(spriteDir).filter((name) => name.endsWith('.png'));
169
+ const referenced = new Set();
170
+ for (const layer of sprite.layers || []) {
171
+ for (const file of layer.frames || (layer.file ? [layer.file] : [])) {
172
+ if (typeof file !== 'string') {
173
+ fail('sprite/manifest.json 的层素材字段必须是字符串');
174
+ continue;
175
+ }
176
+ referenced.add(file);
177
+ if (!/^[A-Za-z0-9_-]{1,16}\.png$/i.test(file)) {
178
+ // 固件把素材平铺缓存在 SPIFFS(对象名上限 32 字符),文件名必须短。
179
+ fail(`sprite/${file} 文件名不合规:1–16 个字母/数字/下划线/连字符 + .png(总长 ≤20)`);
180
+ }
181
+ if (!pngs.includes(file)) fail(`sprite/manifest.json 引用了不存在的 ${file}`);
182
+ }
183
+ }
184
+ let total = 0;
185
+ for (const file of pngs) {
186
+ total += fs.statSync(path.join(spriteDir, file)).size;
187
+ if (!referenced.has(file)) warn(`sprite/${file} 没有被 manifest.json 引用,不会下发到设备`);
188
+ }
189
+ if (total > 512 * 1024) fail(`sprite/ 素材共 ${total} 字节,超过 512KB 上限`);
190
+ }
191
+
192
+ export function validatePackage(pkg) { const errors = [];
139
193
  const warnings = [];
140
194
  const fail = (message) => errors.push(message);
141
195
  const warn = (message) => warnings.push(message);
@@ -235,6 +289,7 @@ export function validatePackage(pkg) {
235
289
  for (const key of ['session', 'model', 'cancel', 'progress']) {
236
290
  if (!scan.capabilityKeys.includes(key)) warn(`${source} 的 capabilities 缺少 ${key}`);
237
291
  }
292
+ validateSpriteDir(pkg.dir, fail, warn);
238
293
  }
239
294
  if (spec?.fields?.length && !pkg.market.highlights?.length) warn('这个包有可配置项,建议在 market.json 里写 highlights');
240
295
  }
package/lib/plugin.mjs CHANGED
@@ -19,11 +19,12 @@ const MAX_VALUE = 500;
19
19
  const MAX_PATTERN = 200;
20
20
  const MAX_FILE = 16 * 1024;
21
21
 
22
- /** Commands the device currently executes (made README, 板侧事件与命令). */
22
+ /** Commands the device currently executes (made 仓库 AGENTS.md,板侧事件与命令). */
23
23
  export const BOARD_COMMANDS = Object.freeze([
24
24
  'caption.show', 'audio.play', 'theme.apply', 'volume.set', 'agent.select',
25
25
  'session.create', 'session.select', 'session.delete', 'voice.start',
26
26
  'task.confirm', 'task.cancel',
27
+ 'ui.swipe', 'ui.tap', 'debug.screenshot',
27
28
  ]);
28
29
 
29
30
  /** Events the device currently publishes. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "made-market",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "码得(made)公开插件市场的命令行工具:列出、搜索、安装主题/管控/agent 包,以及登录后发布新包。市场服务端在 github.com/jobop/made-market 仓库内,不随本包分发。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -80,7 +80,7 @@
80
80
  ],
81
81
  "payloadBytes": 209717,
82
82
  "archiveBytes": 39822,
83
- "sha256": "b656724a6be3466c0ad52010bfa9f2a3b043595b6b7b6bed90a01bdfcca3ba95",
83
+ "sha256": "f99ab016bda97781fba979cf5d31ffec18edf1ea91a2339c1d7e4cf63f36a0a7",
84
84
  "download": "packages/sunset-1.0.0.tar.gz",
85
85
  "swatch": [
86
86
  "#e0834a",
@@ -153,7 +153,7 @@
153
153
  ],
154
154
  "payloadBytes": 212917,
155
155
  "archiveBytes": 44910,
156
- "sha256": "3894aadcfdba77eb351abc4381d3b76141ed0d6a15d8b75a982a64092e1300fa",
156
+ "sha256": "a48c12c45c83185041a25ea0f408f8ce15a1632d4473440a0a1fbc7b5f674cd1",
157
157
  "download": "packages/ocean-1.0.0.tar.gz",
158
158
  "swatch": [
159
159
  "#2fa3b8",
@@ -226,7 +226,7 @@
226
226
  ],
227
227
  "payloadBytes": 198517,
228
228
  "archiveBytes": 31125,
229
- "sha256": "ff66fd400882e4ce3017b32782825aca59e60e5a407c793b175d2c4500c1cb97",
229
+ "sha256": "9dd6037414f9303ecc507c4348380baeaf8dd2a97c98c58748410c18b37c014e",
230
230
  "download": "packages/graphite-1.0.0.tar.gz",
231
231
  "swatch": [
232
232
  "#8e9aa6",
@@ -299,7 +299,7 @@
299
299
  ],
300
300
  "payloadBytes": 206517,
301
301
  "archiveBytes": 34163,
302
- "sha256": "24dbf64bb96520a16efbfbb553ccff91190ae4fde5b18444f3c6a7ee97b0993f",
302
+ "sha256": "76095ce19587f8e0bc7ba5aeb7ca62f1e622bc8ef14847fdb3479dbf0ea87b29",
303
303
  "download": "packages/bamboo-1.0.0.tar.gz",
304
304
  "swatch": [
305
305
  "#5aa85e",
@@ -367,7 +367,7 @@
367
367
  ],
368
368
  "payloadBytes": 1576,
369
369
  "archiveBytes": 1067,
370
- "sha256": "c3f6afae9ef5f47b622c74c2faf7174ba2ba38a9db2d19651a992874fd6f5f9a",
370
+ "sha256": "35c4768f607bb936b2e263e972895920c66cce74a8829d944cd86c6d2e5b0030",
371
371
  "download": "packages/theme-cycler-1.0.0.tar.gz",
372
372
  "sourcePath": "packages/control/theme-cycler"
373
373
  },
@@ -430,7 +430,7 @@
430
430
  ],
431
431
  "payloadBytes": 3023,
432
432
  "archiveBytes": 1808,
433
- "sha256": "ab7acdc88895a391596182fa9425e1e81d0b7ec5de73c035b9fe0ee2a7a3a123",
433
+ "sha256": "0c7c6453b78a76c327282dfd62f6d023e90dc12a2c7458a67b1b43e5e20e15ba",
434
434
  "download": "packages/webhook-notify-1.0.0.tar.gz",
435
435
  "sourcePath": "packages/control/webhook-notify"
436
436
  },
@@ -492,7 +492,7 @@
492
492
  ],
493
493
  "payloadBytes": 2370,
494
494
  "archiveBytes": 1450,
495
- "sha256": "43512f16e9559f5984ab9cf827b9b8e0e7c5671bc1cd1cf211adc0b0344e9f37",
495
+ "sha256": "cea53eedab0232272ed6f5ebd5c6b8a7250ff08240fb2d754b16b1e7c9088d7a",
496
496
  "download": "packages/key-chime-1.0.0.tar.gz",
497
497
  "sourcePath": "packages/control/key-chime"
498
498
  },
@@ -554,7 +554,7 @@
554
554
  ],
555
555
  "payloadBytes": 3940,
556
556
  "archiveBytes": 1959,
557
- "sha256": "c131b7a0e67a9e0a1e4ab23b9dcf5ec05f7bb73c8b43621fe849c6762009c4e0",
557
+ "sha256": "bd4c7ae744be6a99dacaf663a0624e61e568f8da5e83566eb104127d39ddf11d",
558
558
  "download": "packages/alarm-clock-1.0.0.tar.gz",
559
559
  "sourcePath": "packages/control/alarm-clock"
560
560
  },
@@ -621,7 +621,7 @@
621
621
  ],
622
622
  "payloadBytes": 3793,
623
623
  "archiveBytes": 1612,
624
- "sha256": "7f3d1045be427b2d4cbe0b070cb63ca0fc1fe127c63201f9423f23970d37f736",
624
+ "sha256": "aaf99b7ab9af49712f9ff5da37695c4d41cf89e09ae6ec5168057dede7075546",
625
625
  "download": "packages/demo-echo-1.0.0.tar.gz",
626
626
  "icon": {
627
627
  "format": "indexed4",
@@ -702,7 +702,7 @@
702
702
  ],
703
703
  "payloadBytes": 6098,
704
704
  "archiveBytes": 2672,
705
- "sha256": "0a33b62f6f186a80d370012ddd146f9c504daf182503cc23cb4c13bd9c7e35d3",
705
+ "sha256": "8da05c474aa4b0d3d8913e16109b0ed887a4bab2d1d136527bb85bd798860cff",
706
706
  "download": "packages/ollama-agent-1.0.0.tar.gz",
707
707
  "icon": {
708
708
  "format": "indexed4",
@@ -786,7 +786,7 @@
786
786
  ],
787
787
  "payloadBytes": 5814,
788
788
  "archiveBytes": 2456,
789
- "sha256": "541eef0c992b636cfe5d0b8f1f03dffa15a58b414f061ed392971bd3723a6c0f",
789
+ "sha256": "12de415fa0bb0bfaa3eb119c5e0930b3d3239836d184eab781dc1b36be93d06a",
790
790
  "download": "packages/shell-agent-1.0.0.tar.gz",
791
791
  "icon": {
792
792
  "format": "indexed4",
package/src/init.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
+ import zlib from 'node:zlib';
10
11
  import { PACKAGE_DIR } from '../lib/plugin.mjs';
11
12
  import { renderIcon } from '../lib/icon.mjs';
12
13
  import { capabilitiesJson, contractsDts } from '../lib/capabilities.mjs';
@@ -182,7 +183,7 @@ function agentMarketJson(id, pluginId) {
182
183
  nameEn: 'My Agent Runtime',
183
184
  summary: '一句话简介:接的是哪个模型服务、怎么驱动码得。',
184
185
  summaryEn: 'One-line summary shown in the market listing.',
185
- description: '详细描述会话建立方式、模型接入与取消行为。',
186
+ description: '详细描述会话建立方式、模型接入与取消行为。\n\n可选 sprite/ 目录:分层 PNG + manifest.json 定义助手在码得屏幕上的形象与动效,规范见 made 仓库 docs/SPRITES.md。眨眼/说话是 eyes/mouth 层切帧,位置全由 manifest 声明,固件零写死坐标;内置绿豆形象就是这套格式的参考实例。改素材必须递增 manifest 的 version。',
186
187
  descriptionEn: 'Long description in English.',
187
188
  author: '你的名字',
188
189
  license: 'MIT',
@@ -196,25 +197,34 @@ function agentMarketJson(id, pluginId) {
196
197
 
197
198
  /** agent 模板的 capabilities:四个键写成一行字面量,静态扫描(scanPluginSource)才认得。 */
198
199
  function agentPluginMjs(pluginId) {
200
+ // 模板必须能直接通过桥接器 validatePlugin:capabilities 是元数据(session 取
201
+ // 'native' | 'external_unscoped',其余是布尔),probe 与 run 是顶层函数。
199
202
  return `/**
200
- * agent 运行时模板:把云端模型会话接到码得。capabilities 静态扫描会检查
201
- * session / model / cancel / progress 四个键是否齐全(缺了只是告警,但建议补全)。
202
- * agent 包不接收板侧事件。
203
+ * agent 运行时模板:把你的模型/CLI 接到码得。probe 决定「是否可用」的展示,
204
+ * run 执行一轮任务。capabilities 是能力声明(不是函数):
205
+ * session: 'native'(可跨轮续会话)| 'external_unscoped'(任务隔离受限)。
203
206
  */
204
- const createSession = async ({ deviceId, config }) => {
205
- // 建立(或复用)一个模型会话,返回会话句柄。
206
- return { id: \`demo-\${deviceId}\`, model: 'demo-model' };
207
- };
208
- const currentModel = async ({ config }) => 'demo-model';
209
- const cancelTask = async () => { /* 取消当前任务 */ };
210
- const reportProgress = ({ state }) => { /* 上报进度 */ };
211
-
212
207
  export default {
213
208
  apiVersion: 1,
214
209
  kind: 'coding-agent',
215
210
  id: '${pluginId}',
216
211
  label: '我的 Agent',
217
- capabilities: { session: createSession, model: currentModel, cancel: cancelTask, progress: reportProgress },
212
+ capabilities: { session: 'native', model: true, cancel: true, progress: true },
213
+ model: { default: '', required: false },
214
+
215
+ probe: (config) => {
216
+ // 返回 { available, reason };reason 会显示在电脑端助手里。
217
+ return { available: true, reason: '模板助手:把 run 换成你的模型接入即可' };
218
+ },
219
+
220
+ run: async ({ job, project, config, signal, onProgress, onSession, model }) => {
221
+ // job.instruction 是用户指令;job.sessionId 非空表示续接既有会话。
222
+ // 首轮必须上报会话 id(onSession),后续轮经 job.sessionId 传回。
223
+ if (!job.sessionId) onSession(\`demo-\${Date.now()}\`);
224
+ onProgress('模板正在假装干活……');
225
+ // 真正的接入在这里:调你的模型 API / CLI 子进程,尊重 signal 取消。
226
+ return { status: 'completed', result: \`(模板)收到指令:\${job.instruction}\` };
227
+ },
218
228
  };
219
229
  `;
220
230
  }
@@ -224,6 +234,73 @@ function agentIconJson() {
224
234
  return renderIcon({ background: '#141C29', accent: '#5B8DEF', shapes: [[12, 12, 18, 18, '5B8DEF', 5]] });
225
235
  }
226
236
 
237
+ // ── sprite/ 形象素材(规范真源:made 仓库 docs/SPRITES.md)────────────────
238
+ // 占位 PNG 是合法 RGBA 图,尺寸/透明度合法,替换成真图即可。整体 ≤512KB。
239
+
240
+ const CRC_TABLE = (() => {
241
+ const table = new Int32Array(256);
242
+ for (let n = 0; n < 256; n++) {
243
+ let c = n;
244
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
245
+ table[n] = c;
246
+ }
247
+ return table;
248
+ })();
249
+
250
+ function crc32(buffer) {
251
+ let c = 0xffffffff;
252
+ for (const byte of buffer) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
253
+ return (c ^ 0xffffffff) >>> 0;
254
+ }
255
+
256
+ function pngChunk(type, data) {
257
+ const length = Buffer.alloc(4);
258
+ length.writeUInt32BE(data.length);
259
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
260
+ const crc = Buffer.alloc(4);
261
+ crc.writeUInt32BE(crc32(body));
262
+ return Buffer.concat([length, body, crc]);
263
+ }
264
+
265
+ /** 纯色 RGBA PNG(逐行相同,deflate 后只有几十字节)。 */
266
+ function placeholderPng(width, height, [r, g, b, a]) {
267
+ const ihdr = Buffer.alloc(13);
268
+ ihdr.writeUInt32BE(width, 0);
269
+ ihdr.writeUInt32BE(height, 4);
270
+ ihdr[8] = 8; // bit depth
271
+ ihdr[9] = 6; // color type: RGBA
272
+ const row = Buffer.alloc(1 + width * 4);
273
+ for (let x = 0; x < width; x++) {
274
+ row[1 + x * 4] = r;
275
+ row[2 + x * 4] = g;
276
+ row[3 + x * 4] = b;
277
+ row[4 + x * 4] = a;
278
+ }
279
+ const raw = Buffer.concat(Array.from({ length: height }, () => row));
280
+ return Buffer.concat([
281
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
282
+ pngChunk('IHDR', ihdr),
283
+ pngChunk('IDAT', zlib.deflateSync(raw)),
284
+ pngChunk('IEND', Buffer.alloc(0)),
285
+ ]);
286
+ }
287
+
288
+ function spriteManifest() {
289
+ return {
290
+ version: 1,
291
+ canvas: { w: 360, h: 360 },
292
+ layers: [
293
+ // body 铺满画布;eyes/mouth 的 x/y 是层左上角在画布上的位置,z 小的在下。
294
+ { id: 'body', file: 'body.png', x: 100, y: 62, z: 0 },
295
+ { id: 'eyes', frames: ['eyes-open.png', 'eyes-closed.png'], x: 150, y: 150, z: 1,
296
+ blink: { period_ms: 4000, closed_ms: 120 } },
297
+ { id: 'mouth', frames: ['mouth-closed.png', 'mouth-open.png'], x: 155, y: 215, z: 2, talk: true },
298
+ ],
299
+ // 整体动效:呼吸缩放 / 左右轻摆;缺省用固件默认值。
300
+ motion: { breath: { scale_pct: 103, period_ms: 3000 }, sway_deg: 3, sway_period_ms: 1800 },
301
+ };
302
+ }
303
+
227
304
  const SCHEMAS = {
228
305
  theme: {
229
306
  label: '主题',
@@ -262,6 +339,14 @@ const SCHEMAS = {
262
339
  { name: 'plugin.mjs', data: agentPluginMjs(pluginId) },
263
340
  { name: 'plugin.json', data: `${JSON.stringify({ id: pluginId, settings: [] }, null, 2)}\n` },
264
341
  { name: 'icon.json', data: `${JSON.stringify(agentIconJson(), null, 2)}\n` },
342
+ // 形象素材:分层 PNG + manifest。规范与字段说明见 made 仓库 docs/SPRITES.md。
343
+ // 改素材后必须递增 manifest.json 的 version,已安装的设备才会重新拉取。
344
+ { name: 'sprite/manifest.json', data: `${JSON.stringify(spriteManifest(), null, 2)}\n` },
345
+ { name: 'sprite/body.png', data: placeholderPng(160, 160, [0x10, 0x26, 0x29, 0xff]) },
346
+ { name: 'sprite/eyes-open.png', data: placeholderPng(40, 16, [0x26, 0x21, 0x5c, 0xff]) },
347
+ { name: 'sprite/eyes-closed.png', data: placeholderPng(40, 16, [0x10, 0x26, 0x29, 0xff]) },
348
+ { name: 'sprite/mouth-closed.png', data: placeholderPng(32, 12, [0x26, 0x21, 0x5c, 0xff]) },
349
+ { name: 'sprite/mouth-open.png', data: placeholderPng(32, 12, [0x71, 0x2b, 0x13, 0xff]) },
265
350
  ];
266
351
  },
267
352
  },
@@ -283,6 +368,7 @@ export function scaffold({ dir, kind, force = false }) {
283
368
  fs.mkdirSync(dir, { recursive: true });
284
369
  return schema.files(id).map((file) => {
285
370
  const target = path.join(dir, file.name);
371
+ fs.mkdirSync(path.dirname(target), { recursive: true }); // sprite/ 等子目录
286
372
  fs.writeFileSync(target, file.data);
287
373
  return target;
288
374
  });
package/src/install.mjs CHANGED
@@ -141,7 +141,11 @@ export async function install(ids, { home = '', registry = BUNDLED_REGISTRY, ind
141
141
  if (!dryRun) {
142
142
  fs.rmSync(target, { recursive: true, force: true });
143
143
  fs.mkdirSync(target, { recursive: true });
144
- for (const file of entries) fs.writeFileSync(path.join(target, file.name), file.data);
144
+ for (const file of entries) {
145
+ const dest = path.join(target, file.name);
146
+ fs.mkdirSync(path.dirname(dest), { recursive: true }); // sprite/ 等子目录
147
+ fs.writeFileSync(dest, file.data);
148
+ }
145
149
  }
146
150
  done.push({ entry, target, files: entries.map((file) => file.name) });
147
151
  console.log(`${dryRun ? '将安装' : '已安装'} ${entry.name} · ${id} v${entry.version}`);