dsh-mobile-plugin 0.5.27 → 0.5.29

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 (350) hide show
  1. package/extensions/catalog.json +10 -1
  2. package/extensions/magpie/agentcompact.js +104 -0
  3. package/extensions/magpie/app.css +4902 -0
  4. package/extensions/magpie/app.js +24474 -0
  5. package/extensions/magpie/autobackup.js +185 -0
  6. package/extensions/magpie/autoresetown.js +151 -0
  7. package/extensions/magpie/balfound.js +38 -0
  8. package/extensions/magpie/boot.js +23 -0
  9. package/extensions/magpie/cfgblocked.js +39 -0
  10. package/extensions/magpie/checkinnow.js +111 -0
  11. package/extensions/magpie/claudemode.js +92 -0
  12. package/extensions/magpie/compat.js +41 -0
  13. package/extensions/magpie/context.css +433 -0
  14. package/extensions/magpie/context.js +1576 -0
  15. package/extensions/magpie/ctxtext.js +223 -0
  16. package/extensions/magpie/fonts.js +42 -0
  17. package/extensions/magpie/i18n.js +18942 -0
  18. package/extensions/magpie/icons/ai21.svg +1 -0
  19. package/extensions/magpie/icons/ai302-color.svg +1 -0
  20. package/extensions/magpie/icons/ai360-color.svg +1 -0
  21. package/extensions/magpie/icons/aihubmix-color.svg +1 -0
  22. package/extensions/magpie/icons/aionui.svg +1 -0
  23. package/extensions/magpie/icons/air.svg +4 -0
  24. package/extensions/magpie/icons/alibaba-color.svg +1 -0
  25. package/extensions/magpie/icons/alibabacloud-color.svg +1 -0
  26. package/extensions/magpie/icons/alma.png +0 -0
  27. package/extensions/magpie/icons/anthropic.svg +1 -0
  28. package/extensions/magpie/icons/antigravity-color.svg +1 -0
  29. package/extensions/magpie/icons/anyscale-color.svg +1 -0
  30. package/extensions/magpie/icons/app-alma.png +0 -0
  31. package/extensions/magpie/icons/app-cc-switch.png +0 -0
  32. package/extensions/magpie/icons/apple-touch-icon.png +0 -0
  33. package/extensions/magpie/icons/aside.svg +28 -0
  34. package/extensions/magpie/icons/astrbot.svg +1 -0
  35. package/extensions/magpie/icons/atomcode.png +0 -0
  36. package/extensions/magpie/icons/aws.svg +1 -0
  37. package/extensions/magpie/icons/azure-color.svg +1 -0
  38. package/extensions/magpie/icons/azureai-color.svg +1 -0
  39. package/extensions/magpie/icons/baichuan-color.svg +1 -0
  40. package/extensions/magpie/icons/baidu-color.svg +1 -0
  41. package/extensions/magpie/icons/baiducloud-color.svg +1 -0
  42. package/extensions/magpie/icons/bailian-color.svg +1 -0
  43. package/extensions/magpie/icons/baseten.svg +1 -0
  44. package/extensions/magpie/icons/bedrock-color.svg +1 -0
  45. package/extensions/magpie/icons/burncloud-color.svg +1 -0
  46. package/extensions/magpie/icons/bytedance-color.svg +1 -0
  47. package/extensions/magpie/icons/cerebras.svg +1 -0
  48. package/extensions/magpie/icons/chatglm-color.svg +1 -0
  49. package/extensions/magpie/icons/cherryin-color.svg +1 -0
  50. package/extensions/magpie/icons/chutes.svg +1 -0
  51. package/extensions/magpie/icons/cindy.png +0 -0
  52. package/extensions/magpie/icons/claude-color.svg +1 -0
  53. package/extensions/magpie/icons/claudecode-color.svg +1 -0
  54. package/extensions/magpie/icons/cline.svg +1 -0
  55. package/extensions/magpie/icons/cloudflare-color.svg +1 -0
  56. package/extensions/magpie/icons/codebuddy-color.svg +1 -0
  57. package/extensions/magpie/icons/codex-color.svg +1 -0
  58. package/extensions/magpie/icons/cohere-color.svg +1 -0
  59. package/extensions/magpie/icons/cometapi-color.svg +1 -0
  60. package/extensions/magpie/icons/commandcode.svg +1 -0
  61. package/extensions/magpie/icons/copilot-color.svg +1 -0
  62. package/extensions/magpie/icons/coze.svg +1 -0
  63. package/extensions/magpie/icons/crush.png +0 -0
  64. package/extensions/magpie/icons/curl.svg +1 -0
  65. package/extensions/magpie/icons/cursor.svg +1 -0
  66. package/extensions/magpie/icons/deepinfra-color.svg +1 -0
  67. package/extensions/magpie/icons/deepmind-color.svg +1 -0
  68. package/extensions/magpie/icons/deepseek-color.svg +1 -0
  69. package/extensions/magpie/icons/deveco-color.svg +12 -0
  70. package/extensions/magpie/icons/devin.svg +1 -0
  71. package/extensions/magpie/icons/doubao-color.svg +1 -0
  72. package/extensions/magpie/icons/empryo-color.svg +1 -0
  73. package/extensions/magpie/icons/factory.svg +1 -0
  74. package/extensions/magpie/icons/favicon.svg +1 -0
  75. package/extensions/magpie/icons/featherless-color.svg +1 -0
  76. package/extensions/magpie/icons/fireworks-color.svg +1 -0
  77. package/extensions/magpie/icons/friendli.svg +1 -0
  78. package/extensions/magpie/icons/fx.svg +1 -0
  79. package/extensions/magpie/icons/gemini-color.svg +1 -0
  80. package/extensions/magpie/icons/geminicli-color.svg +1 -0
  81. package/extensions/magpie/icons/gemma-color.svg +1 -0
  82. package/extensions/magpie/icons/giteeai.svg +1 -0
  83. package/extensions/magpie/icons/github.svg +1 -0
  84. package/extensions/magpie/icons/githubcopilot.svg +1 -0
  85. package/extensions/magpie/icons/glama.svg +1 -0
  86. package/extensions/magpie/icons/gmicloud.svg +1 -0
  87. package/extensions/magpie/icons/google-color.svg +1 -0
  88. package/extensions/magpie/icons/googlecloud-color.svg +1 -0
  89. package/extensions/magpie/icons/goose.svg +1 -0
  90. package/extensions/magpie/icons/grok.svg +1 -0
  91. package/extensions/magpie/icons/groq.svg +1 -0
  92. package/extensions/magpie/icons/hanako.png +0 -0
  93. package/extensions/magpie/icons/hermes.svg +1 -0
  94. package/extensions/magpie/icons/huawei-color.svg +1 -0
  95. package/extensions/magpie/icons/huaweicloud-color.svg +1 -0
  96. package/extensions/magpie/icons/huggingface-color.svg +1 -0
  97. package/extensions/magpie/icons/hunyuan-color.svg +1 -0
  98. package/extensions/magpie/icons/hyperbolic-color.svg +1 -0
  99. package/extensions/magpie/icons/icon-512.png +0 -0
  100. package/extensions/magpie/icons/iflytekcloud-color.svg +1 -0
  101. package/extensions/magpie/icons/inference.svg +1 -0
  102. package/extensions/magpie/icons/infinigence-color.svg +1 -0
  103. package/extensions/magpie/icons/internlm-color.svg +1 -0
  104. package/extensions/magpie/icons/jcode.svg +1 -0
  105. package/extensions/magpie/icons/jina.svg +1 -0
  106. package/extensions/magpie/icons/kilo.svg +1 -0
  107. package/extensions/magpie/icons/kilocode.svg +1 -0
  108. package/extensions/magpie/icons/kimi-code-desktop.png +0 -0
  109. package/extensions/magpie/icons/kimi.svg +1 -0
  110. package/extensions/magpie/icons/kiro-color.svg +1 -0
  111. package/extensions/magpie/icons/kling-color.svg +1 -0
  112. package/extensions/magpie/icons/lambda.svg +1 -0
  113. package/extensions/magpie/icons/leptonai-color.svg +1 -0
  114. package/extensions/magpie/icons/liquid.svg +1 -0
  115. package/extensions/magpie/icons/liveagent-color.svg +1 -0
  116. package/extensions/magpie/icons/llmapi.svg +1 -0
  117. package/extensions/magpie/icons/lmstudio.svg +1 -0
  118. package/extensions/magpie/icons/lody.png +0 -0
  119. package/extensions/magpie/icons/longcat.svg +1 -0
  120. package/extensions/magpie/icons/magpie.svg +1 -0
  121. package/extensions/magpie/icons/meta-color.svg +1 -0
  122. package/extensions/magpie/icons/metaai-color.svg +1 -0
  123. package/extensions/magpie/icons/microsoft-color.svg +1 -0
  124. package/extensions/magpie/icons/mimocode.svg +1 -0
  125. package/extensions/magpie/icons/minimax-color.svg +1 -0
  126. package/extensions/magpie/icons/mistermorph-color.svg +1 -0
  127. package/extensions/magpie/icons/mistral-color.svg +1 -0
  128. package/extensions/magpie/icons/mlx-serve.png +0 -0
  129. package/extensions/magpie/icons/modelscope-color.svg +1 -0
  130. package/extensions/magpie/icons/monica-color.svg +1 -0
  131. package/extensions/magpie/icons/moonshot.svg +1 -0
  132. package/extensions/magpie/icons/morph-color.svg +1 -0
  133. package/extensions/magpie/icons/mothx.png +0 -0
  134. package/extensions/magpie/icons/nebius.svg +1 -0
  135. package/extensions/magpie/icons/newapi-color.svg +1 -0
  136. package/extensions/magpie/icons/nousresearch.svg +1 -0
  137. package/extensions/magpie/icons/nova-color.svg +1 -0
  138. package/extensions/magpie/icons/novita-color.svg +1 -0
  139. package/extensions/magpie/icons/nvidia-color.svg +1 -0
  140. package/extensions/magpie/icons/ollama.svg +1 -0
  141. package/extensions/magpie/icons/omlx.svg +1 -0
  142. package/extensions/magpie/icons/omo.svg +1 -0
  143. package/extensions/magpie/icons/omp.svg +1 -0
  144. package/extensions/magpie/icons/openai.svg +1 -0
  145. package/extensions/magpie/icons/openchamber.svg +11 -0
  146. package/extensions/magpie/icons/openclaw.svg +1 -0
  147. package/extensions/magpie/icons/opencode.svg +1 -0
  148. package/extensions/magpie/icons/openmausbot.svg +1 -0
  149. package/extensions/magpie/icons/openrouter.svg +1 -0
  150. package/extensions/magpie/icons/openwebui.svg +1 -0
  151. package/extensions/magpie/icons/parasail.svg +1 -0
  152. package/extensions/magpie/icons/penguin-color.svg +1 -0
  153. package/extensions/magpie/icons/perplexity-color.svg +1 -0
  154. package/extensions/magpie/icons/phala-color.svg +1 -0
  155. package/extensions/magpie/icons/pi.svg +1 -0
  156. package/extensions/magpie/icons/pipellm-color.svg +5 -0
  157. package/extensions/magpie/icons/poe.svg +1 -0
  158. package/extensions/magpie/icons/ppio-color.svg +1 -0
  159. package/extensions/magpie/icons/prime-agent.svg +1 -0
  160. package/extensions/magpie/icons/qiniu-color.svg +1 -0
  161. package/extensions/magpie/icons/qoder.svg +1 -0
  162. package/extensions/magpie/icons/qwen-color.svg +1 -0
  163. package/extensions/magpie/icons/qwenpaw.svg +1 -0
  164. package/extensions/magpie/icons/raycast.svg +1 -0
  165. package/extensions/magpie/icons/reasonix-color.svg +48 -0
  166. package/extensions/magpie/icons/reka.svg +1 -0
  167. package/extensions/magpie/icons/replicate.svg +1 -0
  168. package/extensions/magpie/icons/roocode.svg +1 -0
  169. package/extensions/magpie/icons/rtk.png +0 -0
  170. package/extensions/magpie/icons/sambanova-color.svg +1 -0
  171. package/extensions/magpie/icons/sensenova-color.svg +1 -0
  172. package/extensions/magpie/icons/siliconcloud-color.svg +1 -0
  173. package/extensions/magpie/icons/snow.png +0 -0
  174. package/extensions/magpie/icons/snowflake-color.svg +1 -0
  175. package/extensions/magpie/icons/sophnet-color.svg +1 -0
  176. package/extensions/magpie/icons/spark-color.svg +1 -0
  177. package/extensions/magpie/icons/stepfun-color.svg +1 -0
  178. package/extensions/magpie/icons/straico-color.svg +1 -0
  179. package/extensions/magpie/icons/submodel-color.svg +1 -0
  180. package/extensions/magpie/icons/t3code.svg +4 -0
  181. package/extensions/magpie/icons/targon-color.svg +1 -0
  182. package/extensions/magpie/icons/tencent-color.svg +1 -0
  183. package/extensions/magpie/icons/tencentcloud-color.svg +1 -0
  184. package/extensions/magpie/icons/tiangong-color.svg +1 -0
  185. package/extensions/magpie/icons/together-color.svg +1 -0
  186. package/extensions/magpie/icons/trae.svg +1 -0
  187. package/extensions/magpie/icons/typesafe.png +0 -0
  188. package/extensions/magpie/icons/upstage-color.svg +1 -0
  189. package/extensions/magpie/icons/venice-color.svg +1 -0
  190. package/extensions/magpie/icons/vercel.svg +1 -0
  191. package/extensions/magpie/icons/vertexai-color.svg +1 -0
  192. package/extensions/magpie/icons/vllm-color.svg +1 -0
  193. package/extensions/magpie/icons/volcengine-color.svg +1 -0
  194. package/extensions/magpie/icons/voyage-color.svg +1 -0
  195. package/extensions/magpie/icons/vscode.svg +1 -0
  196. package/extensions/magpie/icons/wenxin-color.svg +1 -0
  197. package/extensions/magpie/icons/windsurf.svg +1 -0
  198. package/extensions/magpie/icons/workbuddy-color.svg +1 -0
  199. package/extensions/magpie/icons/workersai-color.svg +1 -0
  200. package/extensions/magpie/icons/xai.svg +1 -0
  201. package/extensions/magpie/icons/xiaomimimo.svg +1 -0
  202. package/extensions/magpie/icons/xinference-color.svg +1 -0
  203. package/extensions/magpie/icons/yi.svg +1 -0
  204. package/extensions/magpie/icons/yuanbao-color.svg +1 -0
  205. package/extensions/magpie/icons/yylx.svg +5 -0
  206. package/extensions/magpie/icons/zai.svg +1 -0
  207. package/extensions/magpie/icons/zcode.png +0 -0
  208. package/extensions/magpie/icons/zed.svg +1 -0
  209. package/extensions/magpie/icons/zenmux.svg +1 -0
  210. package/extensions/magpie/icons/zeroone.svg +1 -0
  211. package/extensions/magpie/icons/zhipu-color.svg +1 -0
  212. package/extensions/magpie/icons/zoo-code.png +0 -0
  213. package/extensions/magpie/index.html +572 -0
  214. package/extensions/magpie/kernel.js +274 -0
  215. package/extensions/magpie/keyspend.js +63 -0
  216. package/extensions/magpie/layout.js +26 -0
  217. package/extensions/magpie/manifest.json +37 -0
  218. package/extensions/magpie/navlamp.js +47 -0
  219. package/extensions/magpie/omarchy.css +72 -0
  220. package/extensions/magpie/omarchy.js +46 -0
  221. package/extensions/magpie/passerrors.js +21 -0
  222. package/extensions/magpie/pluginpages.js +405 -0
  223. package/extensions/magpie/plugins/gateway/client.js +1194 -0
  224. package/extensions/magpie/plugins/gateway/keys.js +972 -0
  225. package/extensions/magpie/plugins/gateway/settings.js +459 -0
  226. package/extensions/magpie/plugins/library/client.js +6788 -0
  227. package/extensions/magpie/plugins/library/i18n.js +3228 -0
  228. package/extensions/magpie/plugins/library/library.css +848 -0
  229. package/extensions/magpie/plugins/otel/i18n.js +48 -0
  230. package/extensions/magpie/plugins/otel/page.js +189 -0
  231. package/extensions/magpie/plugins/redact/client.js +46 -0
  232. package/extensions/magpie/plugins/redact/demo.js +461 -0
  233. package/extensions/magpie/plugins/redact/i18n.js +432 -0
  234. package/extensions/magpie/plugins/redact/page.js +985 -0
  235. package/extensions/magpie/plugins/redact/redact.css +385 -0
  236. package/extensions/magpie/plugins/redact/settings.js +248 -0
  237. package/extensions/magpie/plugins/redact/xq_dark.jpg +0 -0
  238. package/extensions/magpie/plugins/redact/xq_dark.webm +0 -0
  239. package/extensions/magpie/plugins/redact/xq_light.jpg +0 -0
  240. package/extensions/magpie/plugins/redact/xq_light.webm +0 -0
  241. package/extensions/magpie/plugins/routing/client.js +8593 -0
  242. package/extensions/magpie/plugins/routing/i18n.js +2696 -0
  243. package/extensions/magpie/plugins/routing/routing.css +1111 -0
  244. package/extensions/magpie/plugins/search/settings.js +460 -0
  245. package/extensions/magpie/plugins/sessions/client.js +1622 -0
  246. package/extensions/magpie/plugins/sessions/i18n.js +360 -0
  247. package/extensions/magpie/plugins/sessions/sessions.css +181 -0
  248. package/extensions/magpie/plugins/sessions/settings.js +100 -0
  249. package/extensions/magpie/plugins/usage/client.js +150 -0
  250. package/extensions/magpie/plugins/usage/i18n.js +984 -0
  251. package/extensions/magpie/plugins/usage/page.js +4307 -0
  252. package/extensions/magpie/plugins.css +319 -0
  253. package/extensions/magpie/plugins.js +1926 -0
  254. package/extensions/magpie/profilerename.js +27 -0
  255. package/extensions/magpie/purposes.js +2 -0
  256. package/extensions/magpie/quitask.js +79 -0
  257. package/extensions/magpie/quotaall.js +142 -0
  258. package/extensions/magpie/ratewindows.js +123 -0
  259. package/extensions/magpie/registry.js +87 -0
  260. package/extensions/magpie/resetask.js +137 -0
  261. package/extensions/magpie/ruleedit.js +53 -0
  262. package/extensions/magpie/scrollends.js +91 -0
  263. package/extensions/magpie/settingshints.js +75 -0
  264. package/extensions/magpie/signsheets.js +86 -0
  265. package/extensions/magpie/start.js +26 -0
  266. package/extensions/magpie/syncfiles.js +216 -0
  267. package/extensions/magpie/theme.js +29 -0
  268. package/extensions/magpie/traywindows.js +41 -0
  269. package/extensions/magpie/webfonts.js +130 -0
  270. package/extensions/magpie/whatsnew.js +252 -0
  271. package/extensions/magpie.zip +0 -0
  272. package/knowledge/apps/baijiahao.md +30 -0
  273. package/knowledge/apps/bilibili.md +1 -0
  274. package/knowledge/apps/dedao.md +33 -0
  275. package/knowledge/apps/feishu.md +35 -0
  276. package/knowledge/apps/gmail.md +33 -0
  277. package/knowledge/apps/jike.md +31 -0
  278. package/knowledge/apps/kuaishou.md +34 -0
  279. package/knowledge/apps/linkedin.md +33 -0
  280. package/knowledge/apps/qqmail.md +30 -0
  281. package/knowledge/apps/telegram.md +27 -0
  282. package/knowledge/apps/toutiao.md +32 -0
  283. package/knowledge/apps/uber-eats.md +26 -0
  284. package/knowledge/apps/uber.md +27 -0
  285. package/knowledge/apps/whatsapp.md +27 -0
  286. package/knowledge/apps/x.md +32 -0
  287. package/knowledge/apps/xiaohongshu.md +1 -0
  288. package/knowledge/apps/zhihu.md +1 -0
  289. package/knowledge/sites/bilibili.com/SITE.md +50 -0
  290. package/knowledge/sites/bilibili.com/references/view-fields.md +17 -0
  291. package/knowledge/sites/github.com/SITE.md +51 -0
  292. package/knowledge/sites/github.com/references/repo-fields.md +13 -0
  293. package/knowledge/sites/x.com/SITE.md +47 -0
  294. package/knowledge/sites/xiaohongshu.com/SITE.md +48 -0
  295. package/knowledge/sites/xiaohongshu.com/references/note-fields.md +18 -0
  296. package/knowledge/sites/youtube.com/SITE.md +49 -0
  297. package/knowledge/sites/youtube.com/references/innertube-fields.md +14 -0
  298. package/knowledge/sites/zhihu.com/SITE.md +47 -0
  299. package/knowledge/sites/zhihu.com/references/api-fields.md +21 -0
  300. package/lib/mobile/extension-catalog.js +41 -1
  301. package/lib/mobile/extension-data.js +224 -0
  302. package/lib/mobile/extension-dev.js +3 -1
  303. package/lib/mobile/file-ops.js +252 -0
  304. package/lib/mobile/history-derive.js +80 -4
  305. package/lib/mobile/host-bridge/endpoints.js +48 -6
  306. package/lib/mobile/host-bridge/event-synthesis.js +33 -6
  307. package/lib/mobile/jobs-control.js +118 -0
  308. package/lib/mobile/memory.js +635 -0
  309. package/lib/mobile/model-badges.js +77 -0
  310. package/lib/mobile/pet-tools.js +50 -4
  311. package/lib/mobile/pets.js +214 -18
  312. package/lib/mobile/phone/access.js +85 -0
  313. package/lib/mobile/phone/evidence.js +228 -0
  314. package/lib/mobile/phone/gate.js +290 -0
  315. package/lib/mobile/phone/grounding.js +314 -0
  316. package/lib/mobile/phone/guard.js +465 -0
  317. package/lib/mobile/phone/irreversible.js +88 -8
  318. package/lib/mobile/phone/knowledge-tools.js +665 -0
  319. package/lib/mobile/phone/learn.js +329 -0
  320. package/lib/mobile/phone/redact.js +237 -0
  321. package/lib/mobile/phone/risk.js +95 -0
  322. package/lib/mobile/phone/routes.js +28 -1
  323. package/lib/mobile/phone/service.js +23 -0
  324. package/lib/mobile/phone/site-commands.js +271 -0
  325. package/lib/mobile/phone/step-summary.js +201 -0
  326. package/lib/mobile/phone/stop-summary.js +235 -0
  327. package/lib/mobile/phone/tool-groups.js +125 -0
  328. package/lib/mobile/phone/tools.js +589 -0
  329. package/lib/mobile/phone/untrusted.js +65 -0
  330. package/lib/mobile/phone-knowledge.js +20 -4
  331. package/lib/mobile/schedule-edit.js +500 -0
  332. package/lib/mobile/schedule.js +5 -0
  333. package/lib/mobile/send-continuation.js +52 -0
  334. package/lib/mobile/session-models.js +27 -31
  335. package/lib/mobile/session-send.js +12 -12
  336. package/lib/mobile/skill-import.js +338 -0
  337. package/lib/mobile/skill-store.js +524 -0
  338. package/lib/routes/mobile-api.js +42 -0
  339. package/package.json +7 -6
  340. package/skills/skill-authoring/SKILL.md +46 -0
  341. package/skills/skill-authoring/references/examples.md +82 -0
  342. package/skills/skill-authoring/references/frontmatter-and-fields.md +46 -0
  343. package/skills/skill-authoring/scripts/check_skill.py +167 -0
  344. package/skills/skill-authoring/scripts/manifest.json +3 -0
  345. package/knowledge/sites/bilibili.com.md +0 -27
  346. package/knowledge/sites/github.com.md +0 -27
  347. package/knowledge/sites/x.com.md +0 -28
  348. package/knowledge/sites/xiaohongshu.com.md +0 -27
  349. package/knowledge/sites/youtube.com.md +0 -28
  350. package/knowledge/sites/zhihu.com.md +0 -27
@@ -3,11 +3,8 @@
3
3
  /**
4
4
  * 模型目录:会话当前选择 + 可用模型分组 + 厂商标识与图标 + 各档推理强度,外加「切模型」动作。
5
5
  *
6
- * 形状校验(safeModelsValue)与 TTL 缓存是这里的核心,两处都有非平凡的理由:
7
- * 1. safeModelsValue 不是防御性洁癖——宿主是另一个进程,它给的形状会随版本变,
8
- * 而这些字段会直接进 App 的模型选择面板;逐字段挑出来才不会把半截对象糊上去。
9
- * 2. 20 秒 TTL 是热路径取舍:模型目录几乎不变,而手机端在输入区每次展开面板都要读。
10
- * stale-while-revalidate 让展开立刻拿到旧值,同时只发一个在途请求。
6
+ * 形状校验(safeModelsValue)与 TTL 缓存是这里的核心:宿主是另一个进程、形状随版本变,
7
+ * 逐字段挑出来才不会把半截对象糊进选择面板;20 秒 TTL + stale-while-revalidate 是热路径取舍。
11
8
  */
12
9
 
13
10
  const { sendJson, readJsonBody } = require('../shared/http-util');
@@ -16,6 +13,7 @@ const { callSessions } = require('./rpc.js');
16
13
  const { canonicalDeviceKey, sessionModelsStateOf, invalidateSessionModels } = require('./session-cache.js');
17
14
  const { readModelOptions, safeModelOptions } = require('./session-model-options.js');
18
15
  const { decorateModelGroups } = require('./model-vendors.js');
16
+ const { modelBadgeOf, requestLanguage } = require('./model-badges.js');
19
17
 
20
18
  const SESSION_MODELS_CACHE_MS = 20000; // 模型目录短 TTL;成功后 stale-while-revalidate
21
19
 
@@ -24,18 +22,14 @@ function modelText(value, max) {
24
22
  }
25
23
 
26
24
  /**
27
- * 模型 / 推理档位描述的下发上限。
28
- *
29
- * 选择器里这两处都是 `numberOfLines={1}`,按屏宽也就显示二三十个字;宿主那边的描述
30
- * 却有写到几千字的(能力说明、价格表都有)。整份目录每次启动、每次进对话都会传一遍,
31
- * 留着 8KB 的上限等于给每个模型背一段永远读不到的正文。
25
+ * 模型 / 推理档位描述的下发上限:选择器里是 `numberOfLines={1}`,按屏宽也就二三十个字,
26
+ * 而宿主描述能写到几千字,整份目录每次进对话都要传一遍。
32
27
  */
33
28
  const MODEL_DESCRIPTION_MAX = 200;
34
29
  const EFFORT_DESCRIPTION_MAX = 200;
35
30
  /** 失败原因只在诊断里看,同样不需要整篇。 */
36
31
  const MODEL_FAILURE_MESSAGE_MAX = 512;
37
-
38
- function safeModelsValue(value) {
32
+ function safeModelsValue(value, language = 'en') {
39
33
  if (!value || typeof value !== 'object') return {};
40
34
  const result = {};
41
35
  if (value.current && typeof value.current === 'object') {
@@ -76,6 +70,8 @@ function safeModelsValue(value) {
76
70
  .map((model) => ({
77
71
  id: modelText(model.id, 512),
78
72
  name: modelText(model.name, 512),
73
+ // 上游清单每项可带 badge:拍平成本地化后的字符串 + 合法 tone,没有就不挂这个键。
74
+ ...modelBadgeOf(model, language),
79
75
  ...(typeof model.description === 'string' ? { description: model.description.slice(0, MODEL_DESCRIPTION_MAX) } : {}),
80
76
  ...(model.reasoning && typeof model.reasoning === 'object' && Array.isArray(model.reasoning.efforts)
81
77
  ? { reasoning: {
@@ -110,8 +106,8 @@ function safeModelsValue(value) {
110
106
  return result;
111
107
  }
112
108
 
113
- function modelsBody(value, fetchedAt) {
114
- const result = { ok: true, ...safeModelsValue(value) };
109
+ function modelsBody(value, fetchedAt, language = 'en') {
110
+ const result = { ok: true, ...safeModelsValue(value, language) };
115
111
  const revision = result.revision;
116
112
  result.meta = {
117
113
  ...(result.meta && typeof result.meta === 'object' ? result.meta : {}),
@@ -122,7 +118,7 @@ function modelsBody(value, fetchedAt) {
122
118
  return result;
123
119
  }
124
120
 
125
- function startModelsRequest(ctx, entry, sessionId, fresh = false) {
121
+ function startModelsRequest(ctx, entry, sessionId, fresh = false, language = 'en') {
126
122
  // 在途的强制读挂在 entry 自己身上,不另开模块级表:entry 已经按 ctx + 设备 + 会话
127
123
  // 隔离过(session-cache.js 的 sessionModelsStateOf),再建一张 WeakMap 只会多一份
128
124
  // 需要解释归属的状态(见 test/module-split.test.cjs:模块级 WeakMap 只许住在
@@ -138,7 +134,8 @@ function startModelsRequest(ctx, entry, sessionId, fresh = false) {
138
134
  const request = Promise.resolve(callSessions(ctx, 'models', { sessionId })).then(async (response) => {
139
135
  const { value, error } = unwrap(response);
140
136
  if (error) return { error };
141
- const safeValue = safeModelsValue(value);
137
+ // 徽标文案按请求方语言拍平(读不到就是 'en');其它字段不受影响。
138
+ const safeValue = safeModelsValue(value, language);
142
139
  // 附加选项行按**当前模型所在渠道**取(宿主目录里没有这一层,见 session-model-options.js)。
143
140
  const options = await readModelOptions(ctx, safeValue.current && safeValue.current.provider);
144
141
  if (options.length > 0) safeValue.options = options;
@@ -157,19 +154,17 @@ function startModelsRequest(ctx, entry, sessionId, fresh = false) {
157
154
  return forced;
158
155
  }
159
156
 
160
- async function buildModelsSnapshot(ctx, sessionId) {
157
+ async function buildModelsSnapshot(ctx, sessionId, language = 'en') {
161
158
  if (!sessionId) return { ok: false };
162
159
  const entry = sessionModelsStateOf(ctx, 'legacy', sessionId); const now = Date.now();
163
- if (entry.value && now - entry.fetchedAt < SESSION_MODELS_CACHE_MS) return modelsBody(entry.value, entry.fetchedAt);
164
- const request = startModelsRequest(ctx, entry, sessionId);
165
- if (entry.value) { void request.catch(() => undefined); return modelsBody(entry.value, entry.fetchedAt); }
166
- const result = await request; return result.error ? { ok: false, error: result.error } : modelsBody(result.value, result.fetchedAt);
160
+ if (entry.value && now - entry.fetchedAt < SESSION_MODELS_CACHE_MS) return modelsBody(entry.value, entry.fetchedAt, language);
161
+ const request = startModelsRequest(ctx, entry, sessionId, false, language);
162
+ if (entry.value) { void request.catch(() => undefined); return modelsBody(entry.value, entry.fetchedAt, language); }
163
+ const result = await request; return result.error ? { ok: false, error: result.error } : modelsBody(result.value, result.fetchedAt, language);
167
164
  }
168
165
 
169
- /** GET /api/mobile/v1/session/models?sessionId=…(token)
170
- * 返回会话当前模型选择与可用模型目录(透传宿主 session.models)。
171
- * 响应:{ ok, current, defaultModel, routable, groups, vendors?, options?, failures }。
172
- * `current` 是这个会话实际会用的,`defaultModel` 是全局默认(新建会话的落点),
166
+ /** GET /api/mobile/v1/session/models?sessionId=…(token)→ 当前选择与可用目录(透传宿主)。
167
+ * `current` 是这个会话实际会用的,`defaultModel` 是全局默认(新会话的落点),
173
168
  * `options` 是当前渠道的附加选项行,没有就没有这个字段。
174
169
  */
175
170
  async function handleModels(ctx, req, res) {
@@ -178,24 +173,25 @@ async function handleModels(ctx, req, res) {
178
173
  if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
179
174
  const entry = sessionModelsStateOf(ctx, canonicalDeviceKey(req), sessionId);
180
175
  const fresh = url.searchParams.get('fresh') === '1';
176
+ const language = requestLanguage(req.headers);
181
177
  if (fresh) {
182
- const result = await startModelsRequest(ctx, entry, sessionId, true);
178
+ const result = await startModelsRequest(ctx, entry, sessionId, true, language);
183
179
  if (result.error) return sendJson(res, 400, { ok: false, error: result.error });
184
- return sendJson(res, 200, modelsBody(result.value, result.fetchedAt));
180
+ return sendJson(res, 200, modelsBody(result.value, result.fetchedAt, language));
185
181
  }
186
182
  const now = Date.now();
187
183
  if (entry.value && now - entry.fetchedAt < SESSION_MODELS_CACHE_MS) {
188
- return sendJson(res, 200, modelsBody(entry.value, entry.fetchedAt));
184
+ return sendJson(res, 200, modelsBody(entry.value, entry.fetchedAt, language));
189
185
  }
190
- const request = startModelsRequest(ctx, entry, sessionId);
186
+ const request = startModelsRequest(ctx, entry, sessionId, false, language);
191
187
  // Normal reads keep stale-while-revalidate. Explicit fresh reads above always await the host.
192
188
  if (entry.value) {
193
189
  void request.catch(() => undefined);
194
- return sendJson(res, 200, modelsBody(entry.value, entry.fetchedAt));
190
+ return sendJson(res, 200, modelsBody(entry.value, entry.fetchedAt, language));
195
191
  }
196
192
  const result = await request;
197
193
  if (result.error) return sendJson(res, 400, { ok: false, error: result.error });
198
- sendJson(res, 200, modelsBody(result.value, result.fetchedAt));
194
+ sendJson(res, 200, modelsBody(result.value, result.fetchedAt, language));
199
195
  }
200
196
 
201
197
  /** POST /api/mobile/v1/session/select-model(token){sessionId, provider, model, reasoningEffort?} */
@@ -37,6 +37,7 @@ const attachmentStore = require('./attachment-store.js');
37
37
  * 所以按 §8.3(9) 抬到 10.7MB;线上那一侧另有 `MAX_SEALED_BODY` 兜着。
38
38
  */
39
39
  const SEND_MAX_BODY = Math.ceil(8 * 1024 * 1024 * 4 / 3);
40
+ const { continuationOf, resolveSendContent, continuationError } = require('./send-continuation.js');
40
41
 
41
42
  /** 仅当 content 恰好为一个文本块时返回其文本,否则 null(带图片/多块不回退命令路径)。 */
42
43
  function singleTextOf(content) {
@@ -97,15 +98,13 @@ async function handleSend(ctx, req, res) {
97
98
  if (mode !== undefined && mode !== 'queue' && mode !== 'steer') {
98
99
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: "mode 必须为 'queue' 或 'steer'" } });
99
100
  }
100
- let content;
101
- if (Array.isArray(body.content)) {
102
- content = body.content;
103
- } else if (typeof body.text === 'string' && body.text.trim()) {
104
- content = [{ type: 'text', text: body.text }];
105
- } else {
106
- return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 content 数组或 text' } });
107
- }
108
- if (content.length === 0) {
101
+ // 「继续失败的回合」这一档见 send-continuation.js(空 content 只对带 mobile-continue-
102
+ // 前缀的那一发放行,普通空输入仍然挡掉)。
103
+ const continuation = continuationOf(body);
104
+ const resolved = resolveSendContent(body, continuation);
105
+ if (resolved.error) return sendJson(res, 400, { ok: false, error: resolved.error });
106
+ const content = resolved.content;
107
+ if (content.length === 0 && !continuation) {
109
108
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'content 不能为空' } });
110
109
  }
111
110
  const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
@@ -156,19 +155,20 @@ async function handleSend(ctx, req, res) {
156
155
  try {
157
156
  promptResponse = await promptOnce(ctx, typeof body.clientMessageId === 'string' ? body.clientMessageId : '', {
158
157
  sessionId: body.sessionId,
159
- ...(mode !== undefined ? { mode } : {}),
158
+ // 继续只接受 queue 模式(宿主 `prompt` 对空内容只在这一档放行)。
159
+ ...(continuation ? { mode: 'queue' } : mode !== undefined ? { mode } : {}),
160
160
  content,
161
161
  });
162
162
  } catch (error) {
163
163
  const normalized = promptErrorObject(error);
164
164
  if (normalized.code === MODEL_UNAVAILABLE_ERROR.code) invalidateSessionModels(ctx, req, body.sessionId);
165
- return sendJson(res, 400, { ok: false, error: normalized });
165
+ return sendJson(res, 400, { ok: false, error: continuation ? continuationError(normalized) : normalized });
166
166
  }
167
167
  const { error } = unwrap(promptResponse);
168
168
  if (error) {
169
169
  const normalized = promptErrorObject(error);
170
170
  if (normalized.code === MODEL_UNAVAILABLE_ERROR.code) invalidateSessionModels(ctx, req, body.sessionId);
171
- return sendJson(res, 400, { ok: false, error: normalized });
171
+ return sendJson(res, 400, { ok: false, error: continuation ? continuationError(normalized) : normalized });
172
172
  }
173
173
  require('./phone/binding.js').noteRequestSender(req, body.sessionId);
174
174
  sendJson(res, 200, { ok: true, accepted: true });
@@ -0,0 +1,338 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 技能导入 / 分享:把别人分享来的一份 SKILL.md(或一包技能目录)当**不可信外来输入**收进来。
5
+ *
6
+ * ## 规则
7
+ *
8
+ * · 外来内容不经过模型、也不直接启用:校验通过后只产出「待收下的草稿」,由用户在「待确认」
9
+ * 里看过、点收下才落盘(落盘归 `skill-store.js`)。这一步强制 pending,不能直接 active。
10
+ * · frontmatter 必填(`name` + `description`),名字 kebab、目录名将来 == name;正文五节齐全。
11
+ * · 路径穿越一律拒绝:附件相对路径只许落在 `references/` 或 `scripts/` 下,不许绝对路径、
12
+ * 不许 `..`、不许反斜杠。
13
+ * · 大小 / 文件数上限:单文件、整包、文件数各有一道线。
14
+ * · 外来内容全部标 `untrusted`;脚本另外给「能不能跑」的标记(只有包内 `scripts/manifest.json`
15
+ * 里显式 `runnable: true` 的才算候选,且导入侧默认不信任,要用户点头)。
16
+ * · 敏感信息关卡与自学草稿共用同一份(见 `memory.js` 的 `sensitiveHit`):草稿里带密码 / 卡号 /
17
+ * 验证码 / api key 就整条拒收。
18
+ *
19
+ * ## 与 Mika 的对应
20
+ *
21
+ * `SkillImport.prepare`:`shareable`(分享时剥掉本机专用字段)+ 同名内置拒绝 + 同名个人技能变
22
+ * 更新草稿 + 强制 pending。这里把它的判据搬成纯 JS,少了模型那一段。
23
+ */
24
+
25
+ const crypto = require('node:crypto');
26
+
27
+ const { sensitiveHit } = require('./memory.js');
28
+
29
+ /** 一份技能正文的上限(字符)。与 Mika `SkillDrafter.MAX_CHARS` 一致。 */
30
+ const MAX_SKILL_CHARS = 4000;
31
+ /**
32
+ * 导入时允许的原始长度上限(字符):外来文本可能夹带围栏、注释,给正文上限的两倍。
33
+ */
34
+ const MAX_IMPORT_CHARS = 8000;
35
+ /** 导入包的文件数上限。 */
36
+ const MAX_FILES = 100;
37
+ /** 单个附件上限。 */
38
+ const MAX_FILE_BYTES = 1024 * 1024;
39
+ /** 整包附件总字节上限。 */
40
+ const MAX_TOTAL_BYTES = 5 * 1024 * 1024;
41
+
42
+ /** 技能名:小写字母、数字、连字符,同时也是目录名。 */
43
+ const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
44
+ /** 正文五节(顺序不强求,但都要在)。 */
45
+ const SECTIONS = Object.freeze(['入口', '步骤', '容易出错的地方', '怎样算办成', '什么时候停下交给用户']);
46
+ /** 本机才有意义的字段:分享出去时剥掉,导入时由代码重写。 */
47
+ const LOCAL_FIELDS = Object.freeze(['updates', 'learned_from', 'source']);
48
+ /** 反引号里形如工具名的标识符。 */
49
+ const TOOL_MENTION = /`([a-z][a-z0-9_]*)`/g;
50
+ /** 文中的技能脚本路径,如 `scripts/extract_pdf.py`。 */
51
+ const SCRIPT_PATH = /[A-Za-z0-9_./-]*scripts\/[A-Za-z0-9_./-]+\.(?:py|mjs|js|sh)(?![A-Za-z0-9_])/g;
52
+
53
+ function isRecord(value) {
54
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
55
+ }
56
+
57
+ function unquote(text) {
58
+ if (text.length >= 2) {
59
+ const first = text[0];
60
+ const last = text[text.length - 1];
61
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) return text.slice(1, -1);
62
+ }
63
+ return text;
64
+ }
65
+
66
+ /**
67
+ * 解析一份 Markdown 的 frontmatter(只认简单一层 `key: value`,数组用 JSON)。
68
+ * 返回 { hasFrontMatter, meta, body }。解析不出的一律留默认值,不抛。
69
+ */
70
+ function parseFrontMatter(markdown) {
71
+ const text = String(markdown == null ? '' : markdown).replace(/^\uFEFF/, '');
72
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
73
+ if (!match) return { hasFrontMatter: false, meta: {}, body: text };
74
+ const meta = {};
75
+ for (const line of match[1].split(/\r?\n/)) {
76
+ const trimmed = line.trim();
77
+ if (!trimmed || trimmed.startsWith('#')) continue;
78
+ const idx = trimmed.indexOf(':');
79
+ if (idx <= 0) continue;
80
+ const key = trimmed.slice(0, idx).trim();
81
+ let value = trimmed.slice(idx + 1).trim();
82
+ if (value.startsWith('[')) {
83
+ try {
84
+ value = JSON.parse(value);
85
+ } catch {
86
+ value = [];
87
+ }
88
+ } else {
89
+ value = unquote(value);
90
+ }
91
+ if (key) meta[key] = value;
92
+ }
93
+ return { hasFrontMatter: true, meta, body: match[2] || '' };
94
+ }
95
+
96
+ function isField(line, key) {
97
+ const colon = line.indexOf(':');
98
+ return colon > 0 && line.slice(0, colon).trim() === key;
99
+ }
100
+
101
+ /** 把头部 `key: value` 换掉(没有就加、重复只留一个);没有 frontmatter 原样返回。 */
102
+ function withField(markdown, key, value) {
103
+ const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
104
+ if (lines[0] === undefined || lines[0].trimEnd() !== '---') return markdown;
105
+ const end = lines.findIndex((line, i) => i > 0 && line.trimEnd() === '---');
106
+ if (end < 0) return markdown;
107
+ const matches = [];
108
+ for (let i = 1; i < end; i += 1) if (isField(lines[i], key)) matches.push(i);
109
+ if (matches.length === 0) lines.splice(end, 0, `${key}: ${value}`);
110
+ else {
111
+ lines[matches[0]] = `${key}: ${value}`;
112
+ for (let i = matches.length - 1; i >= 1; i -= 1) lines.splice(matches[i], 1);
113
+ }
114
+ return lines.join('\n');
115
+ }
116
+
117
+ /** 去掉头部全部 `key:` 行;没有就原样返回。 */
118
+ function withoutField(markdown, key) {
119
+ const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
120
+ if (lines[0] === undefined || lines[0].trimEnd() !== '---') return markdown;
121
+ const end = lines.findIndex((line, i) => i > 0 && line.trimEnd() === '---');
122
+ if (end < 0) return markdown;
123
+ const matches = [];
124
+ for (let i = 1; i < end; i += 1) if (isField(lines[i], key)) matches.push(i);
125
+ if (matches.length === 0) return markdown;
126
+ for (let i = matches.length - 1; i >= 0; i -= 1) lines.splice(matches[i], 1);
127
+ return lines.join('\n');
128
+ }
129
+
130
+ /** 分享出去用的全文:剥掉本机专用字段(出处 / 学自哪次 / 更新指向)。 */
131
+ function shareable(markdown) {
132
+ let out = String(markdown).replace(/\r\n/g, '\n');
133
+ for (const key of LOCAL_FIELDS) out = withoutField(out, key);
134
+ return out;
135
+ }
136
+
137
+ /** 去掉模型回复外面包着的代码块围栏。 */
138
+ function stripFence(text) {
139
+ const t = String(text == null ? '' : text).trim();
140
+ if (!t.startsWith('```')) return t;
141
+ return t.slice(t.indexOf('\n') + 1).replace(/```\s*$/, '').trim();
142
+ }
143
+
144
+ /**
145
+ * 技能正文的格式与安全校验;通过返回 null,否则返回原因码:
146
+ * `missing_frontmatter` / `invalid_name` / `missing_description` / `missing_section` /
147
+ * `too_long` / `unknown_tool` / `unrunnable_script` / `sensitive`。
148
+ *
149
+ * opts.toolNames 给了才校验反引号工具名;opts.scriptTool + opts.runnableScripts 给了、且
150
+ * opts.enforceRunnable !== false 时才校验脚本可运行。导入侧不强制脚本可运行(外来脚本默认不信任)。
151
+ */
152
+ function validateSkill(markdown, opts = {}) {
153
+ const md = String(markdown == null ? '' : markdown);
154
+ const { hasFrontMatter, meta, body } = parseFrontMatter(md);
155
+ if (!hasFrontMatter) return 'missing_frontmatter';
156
+ const name = typeof meta.name === 'string' ? meta.name.trim() : '';
157
+ if (!NAME_RE.test(name) || name.length > 64) return 'invalid_name';
158
+ const description = typeof meta.description === 'string' ? meta.description.trim() : '';
159
+ if (!description) return 'missing_description';
160
+ const headings = body.split('\n').filter((line) => line.startsWith('## ')).map((line) => line.slice(3).trim());
161
+ if (SECTIONS.some((section) => !headings.some((h) => h.startsWith(section)))) return 'missing_section';
162
+ if (md.length > MAX_SKILL_CHARS) return 'too_long';
163
+
164
+ const toolNames = opts.toolNames instanceof Set ? opts.toolNames : null;
165
+ if (toolNames) {
166
+ TOOL_MENTION.lastIndex = 0;
167
+ let m = TOOL_MENTION.exec(md);
168
+ while (m) {
169
+ if (!toolNames.has(m[1])) return 'unknown_tool';
170
+ m = TOOL_MENTION.exec(md);
171
+ }
172
+ }
173
+ if (opts.scriptTool && opts.enforceRunnable !== false) {
174
+ const runnable = opts.runnableScripts instanceof Set ? opts.runnableScripts : new Set();
175
+ const mentionsScriptTool = new RegExp(`\`${opts.scriptTool}\``).test(md);
176
+ if (mentionsScriptTool) {
177
+ SCRIPT_PATH.lastIndex = 0;
178
+ const paths = md.match(SCRIPT_PATH) || [];
179
+ const runnableOf = (p) => [...runnable].some((r) => r === p || r.endsWith(`/${p}`));
180
+ if (paths.length === 0 || !paths.every(runnableOf)) return 'unrunnable_script';
181
+ }
182
+ }
183
+ if (sensitiveHit(md)) return 'sensitive';
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * 附件相对路径是否安全:只许 `references/...` 或 `scripts/...`,不含绝对路径 / `..` / 反斜杠。
189
+ * 这一条同时是「路径穿越」的判据。
190
+ */
191
+ function isSafeResourcePath(relPath) {
192
+ if (typeof relPath !== 'string') return false;
193
+ const p = relPath.trim();
194
+ if (!p || p.length > 200) return false;
195
+ if (p.includes('\\')) return false;
196
+ if (p.startsWith('/') || /^[A-Za-z]:/.test(p)) return false;
197
+ if (!(p.startsWith('references/') || p.startsWith('scripts/'))) return false;
198
+ const parts = p.split('/');
199
+ if (parts.some((seg) => seg === '' || seg === '.' || seg === '..')) return false;
200
+ return true;
201
+ }
202
+
203
+ /** 附件字节数:优先 content(utf8)/ base64,其次声明的 size。 */
204
+ function bytesOf(file) {
205
+ if (typeof file.content === 'string') return Buffer.byteLength(file.content, 'utf8');
206
+ if (typeof file.base64 === 'string') return Math.floor((file.base64.length * 3) / 4);
207
+ if (Number.isFinite(file.size)) return file.size;
208
+ return 0;
209
+ }
210
+
211
+ /**
212
+ * 校验一份外来技能(文本或「已解出文件列表的包」)。返回:
213
+ * { ok: true, name, markdown, files, untrusted: true, scriptRunnable: string[] }
214
+ * 或 { ok: false, problem }
215
+ *
216
+ * input: `{ markdown, files? }`;files 每条 `{ path, content? , base64?, size? }`。
217
+ * opts: `{ builtinNames?: Set<string> }`。
218
+ *
219
+ * 说明:zip 的「解包」不在这里做(要额外依赖),handler 解出文件列表后把 `content` 传进来即可;
220
+ * 文本分享直接传 markdown。
221
+ */
222
+ function validateImport(input, opts = {}) {
223
+ const payload = typeof input === 'string' ? { markdown: input } : (isRecord(input) ? input : null);
224
+ if (!payload) return { ok: false, problem: 'bad_input' };
225
+ const rawMarkdown = typeof payload.markdown === 'string' ? stripFence(payload.markdown) : '';
226
+ if (!rawMarkdown) return { ok: false, problem: 'missing_markdown' };
227
+ if (rawMarkdown.length > MAX_IMPORT_CHARS) return { ok: false, problem: 'too_long' };
228
+
229
+ const format = validateSkill(rawMarkdown, { enforceRunnable: false });
230
+ if (format) return { ok: false, problem: format };
231
+ const { meta } = parseFrontMatter(rawMarkdown);
232
+ const name = String(meta.name).trim();
233
+ const builtinNames = opts.builtinNames instanceof Set ? opts.builtinNames : new Set();
234
+ if (builtinNames.has(name)) return { ok: false, problem: 'builtin_name' };
235
+
236
+ const rawFiles = payload.files == null ? [] : payload.files;
237
+ if (!Array.isArray(rawFiles)) return { ok: false, problem: 'bad_files' };
238
+ if (rawFiles.length > MAX_FILES) return { ok: false, problem: 'too_many_files' };
239
+
240
+ const files = [];
241
+ const seen = new Set();
242
+ let total = 0;
243
+ for (const file of rawFiles) {
244
+ if (!isRecord(file) || !isSafeResourcePath(file.path)) return { ok: false, problem: 'bad_path' };
245
+ const rel = file.path.trim();
246
+ if (seen.has(rel)) return { ok: false, problem: 'duplicate_path' };
247
+ seen.add(rel);
248
+ const bytes = bytesOf(file);
249
+ if (bytes > MAX_FILE_BYTES) return { ok: false, problem: 'file_too_large' };
250
+ total += bytes;
251
+ if (total > MAX_TOTAL_BYTES) return { ok: false, problem: 'package_too_large' };
252
+ const entry = { path: rel, size: bytes, untrusted: true, runnable: false };
253
+ if (typeof file.content === 'string') entry.content = file.content;
254
+ else if (typeof file.base64 === 'string') entry.base64 = file.base64;
255
+ files.push(entry);
256
+ }
257
+
258
+ // 脚本能否运行:只看包内 scripts/manifest.json 里显式 runnable:true 且路径确实提供的。
259
+ const scriptRunnable = [];
260
+ const manifest = files.find((f) => f.path === 'scripts/manifest.json');
261
+ if (manifest && typeof manifest.content === 'string') {
262
+ try {
263
+ const parsed = JSON.parse(manifest.content);
264
+ if (Array.isArray(parsed)) {
265
+ for (const item of parsed) {
266
+ if (!isRecord(item) || item.runnable !== true || typeof item.file !== 'string') continue;
267
+ const file = item.file.trim();
268
+ if (!isSafeResourcePath(file)) continue;
269
+ if (!files.some((f) => f.path === file)) continue;
270
+ scriptRunnable.push(file);
271
+ }
272
+ }
273
+ } catch {
274
+ // manifest 坏了:当作没有任何可运行脚本,不拒收整包。
275
+ }
276
+ }
277
+ for (const f of files) if (f.path.startsWith('scripts/') && scriptRunnable.includes(f.path)) f.runnable = true;
278
+
279
+ return { ok: true, name, markdown: rawMarkdown, files, untrusted: true, scriptRunnable };
280
+ }
281
+
282
+ /**
283
+ * 把外来技能变成一份**待收下草稿**(不落盘,落盘交给 skill-store)。
284
+ * 返回 `{ status: 'ready', draft }` 或 `{ status: 'rejected', problem }`。
285
+ *
286
+ * opts: `{ builtinNames?, personalNames?, pendingNames?, id?, now? }`
287
+ * · 命中内置技能名 → 拒;
288
+ * · 同名已有一份待确认草稿 → 拒(`draft_pending`);
289
+ * · 同名已有个人技能 → 变成它的「更新草稿」(`isUpdateOf`)。
290
+ */
291
+ function importSkill(input, opts = {}) {
292
+ const builtinNames = opts.builtinNames instanceof Set ? opts.builtinNames : new Set();
293
+ const personalNames = opts.personalNames instanceof Set ? opts.personalNames : new Set();
294
+ const pendingNames = opts.pendingNames instanceof Set ? opts.pendingNames : new Set();
295
+
296
+ const result = validateImport(input, { builtinNames });
297
+ if (!result.ok) return { status: 'rejected', problem: result.problem };
298
+
299
+ const { name } = result;
300
+ if (pendingNames.has(name)) return { status: 'rejected', problem: 'draft_pending' };
301
+ const isUpdateOf = personalNames.has(name) ? name : null;
302
+ const id = typeof opts.id === 'string' && opts.id ? opts.id : crypto.randomUUID();
303
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
304
+
305
+ return {
306
+ status: 'ready',
307
+ draft: {
308
+ id,
309
+ name,
310
+ markdown: result.markdown,
311
+ files: result.files,
312
+ untrusted: true,
313
+ source: 'imported',
314
+ isUpdateOf,
315
+ createdAt: now,
316
+ },
317
+ };
318
+ }
319
+
320
+ module.exports = {
321
+ MAX_SKILL_CHARS,
322
+ MAX_IMPORT_CHARS,
323
+ MAX_FILES,
324
+ MAX_FILE_BYTES,
325
+ MAX_TOTAL_BYTES,
326
+ NAME_RE,
327
+ SECTIONS,
328
+ LOCAL_FIELDS,
329
+ parseFrontMatter,
330
+ withField,
331
+ withoutField,
332
+ shareable,
333
+ stripFence,
334
+ validateSkill,
335
+ isSafeResourcePath,
336
+ validateImport,
337
+ importSkill,
338
+ };