@mokuyoaxis/dsh-iris 0.2.6 → 0.3.0-alpha.1

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 (177) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.en.md +104 -304
  3. package/README.md +101 -302
  4. package/bin/dsh-iris.js +446 -64
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +7 -5
  7. package/docs/ARCHITECTURE.md +20 -4
  8. package/docs/AUDIO_FLOWS.md +71 -0
  9. package/docs/CLI_MANAGEMENT.md +1 -1
  10. package/docs/CONTACT_SHEET.md +62 -0
  11. package/docs/CORE_CONNECTIONS.md +99 -0
  12. package/docs/CORE_QUICKSTART.md +108 -0
  13. package/docs/CORE_RUNTIME_CONTRACT.md +41 -5
  14. package/docs/CORE_SERVICE.md +97 -0
  15. package/docs/CORE_SKILLS.md +94 -0
  16. package/docs/DSH_COMPATIBILITY.md +14 -4
  17. package/docs/DSH_CORE_MIGRATION.md +4 -2
  18. package/docs/DSH_PROVIDER_CONNECTIONS.md +1 -1
  19. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  20. package/docs/DSH_RUNTIME.md +40 -0
  21. package/docs/FUNCTION_MODEL_POOLS.md +1 -1
  22. package/docs/HEADLESS_CLI.md +12 -4
  23. package/docs/HOST_DOCTOR.md +13 -1
  24. package/docs/IMAGE_FLOW.md +75 -0
  25. package/docs/IMAGE_GENERATION.md +127 -0
  26. package/docs/LOCAL_API.md +62 -0
  27. package/docs/LOCAL_API_CONTRACT.md +140 -0
  28. package/docs/LOCAL_CREATION.md +56 -0
  29. package/docs/LOCAL_OPERATIONS.md +46 -0
  30. package/docs/LOCAL_SELECTION.md +57 -0
  31. package/docs/LOCAL_WEB.md +201 -0
  32. package/docs/MCP.md +191 -0
  33. package/docs/MUSIC.md +2 -0
  34. package/docs/PANEL_EXTENSIONS.md +64 -0
  35. package/docs/PLUGINS.md +189 -0
  36. package/docs/PLUGIN_STARTUP.md +100 -0
  37. package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
  38. package/docs/PROVIDER_EXTENSIONS.md +78 -0
  39. package/docs/PROVIDER_HEALTH.md +4 -2
  40. package/docs/PROVIDER_SETTINGS.md +85 -0
  41. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
  42. package/docs/ROADMAP.md +28 -6
  43. package/docs/SECURITY.md +9 -1
  44. package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
  45. package/docs/VISION_CLI.md +2 -2
  46. package/docs/WEB_PROTOTYPE.md +62 -0
  47. package/docs/WEB_RECOVERY.md +46 -0
  48. package/docs/WORKBENCH_LOGIN.md +63 -0
  49. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  50. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  51. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  52. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  53. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  54. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  55. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  56. package/docs/releases/0.2.7.md +62 -0
  57. package/docs/releases/0.3.0-alpha.1.md +51 -0
  58. package/docs/screenshots.md +41 -1
  59. package/lib/actions.js +132 -70
  60. package/lib/adapters.js +20 -11
  61. package/lib/api.js +38 -38
  62. package/lib/application-runtime.js +213 -0
  63. package/lib/audio-flow-input.js +37 -0
  64. package/lib/client.js +434 -55
  65. package/lib/comfyui-adapter.js +94 -0
  66. package/lib/comfyui-client.js +51 -0
  67. package/lib/comfyui-inspection.js +73 -0
  68. package/lib/comfyui-workflow.js +94 -0
  69. package/lib/config.js +36 -1
  70. package/lib/configured-image-runtime.js +50 -0
  71. package/lib/connection-skill.js +89 -0
  72. package/lib/connection-template.js +23 -0
  73. package/lib/core-artifact-store.js +21 -2
  74. package/lib/core-bridge-selection.js +31 -0
  75. package/lib/core-bridge-tools.js +83 -0
  76. package/lib/core-capability-catalog.js +182 -0
  77. package/lib/core-connection-diagnostics.js +49 -0
  78. package/lib/core-connection-store.js +277 -0
  79. package/lib/core-connections.js +225 -0
  80. package/lib/core-maintenance.js +31 -2
  81. package/lib/core-runtime.js +20 -0
  82. package/lib/core-service-client.js +170 -0
  83. package/lib/core-tasks.js +84 -13
  84. package/lib/core-writer-lease.js +16 -0
  85. package/lib/creation-request-store.js +133 -0
  86. package/lib/doctor.js +12 -1
  87. package/lib/dsh-core-adapter.js +666 -572
  88. package/lib/dsh-host-adapter.js +8 -2
  89. package/lib/dsh-service-bridge.js +66 -0
  90. package/lib/execution-ownership.js +43 -0
  91. package/lib/flow-store.js +290 -0
  92. package/lib/generation-input.js +10 -1
  93. package/lib/headless-vision.js +7 -7
  94. package/lib/image-edit-input.js +1 -1
  95. package/lib/image-flow.js +232 -0
  96. package/lib/index.js +94 -68
  97. package/lib/local-api-projection.js +32 -0
  98. package/lib/local-api-token-directory.js +31 -0
  99. package/lib/local-api.js +1083 -0
  100. package/lib/local-creation.js +150 -0
  101. package/lib/local-operations.js +36 -0
  102. package/lib/local-request-root.js +86 -0
  103. package/lib/local-web-assets.js +37 -0
  104. package/lib/mcp-bridge.js +58 -0
  105. package/lib/mcp-check.js +26 -0
  106. package/lib/mcp-config.js +42 -0
  107. package/lib/mcp-delivery.js +46 -0
  108. package/lib/mcp-http.js +101 -0
  109. package/lib/mcp-server.js +47 -0
  110. package/lib/model-directory-service.js +16 -6
  111. package/lib/model-directory.js +3 -3
  112. package/lib/models.js +2 -2
  113. package/lib/music-transport.js +2 -1
  114. package/lib/operation-runner.js +236 -0
  115. package/lib/operation-store.js +216 -0
  116. package/lib/panel-extension.js +36 -0
  117. package/lib/plugin-contract.js +194 -0
  118. package/lib/plugin-execution.js +51 -0
  119. package/lib/plugin-launch.js +86 -0
  120. package/lib/plugin-operation-store.js +101 -0
  121. package/lib/plugin-operations.js +28 -0
  122. package/lib/plugin-registry.js +109 -0
  123. package/lib/plugin-selection.js +15 -0
  124. package/lib/plugin-source-image.js +5 -0
  125. package/lib/plugin-web-assets.js +54 -0
  126. package/lib/plugins.d.ts +288 -0
  127. package/lib/plugins.js +58 -0
  128. package/lib/private-storage.js +12 -0
  129. package/lib/provider-adapter.js +5 -1
  130. package/lib/provider-adapters.js +56 -18
  131. package/lib/provider-catalog.js +40 -29
  132. package/lib/provider-config-service.js +105 -23
  133. package/lib/provider-diagnostics.js +56 -0
  134. package/lib/provider-extensions.js +29 -0
  135. package/lib/provider-health.js +8 -1
  136. package/lib/provider-monitor.js +79 -0
  137. package/lib/provider-protocol.js +70 -20
  138. package/lib/provider-registry.js +118 -0
  139. package/lib/provider-response.js +28 -0
  140. package/lib/provider-settings.js +134 -0
  141. package/lib/provider-task-runner.js +40 -7
  142. package/lib/render-address.js +35 -0
  143. package/lib/render.js +52 -13
  144. package/lib/skill-installation.js +211 -0
  145. package/lib/vision-model-routing.js +3 -0
  146. package/lib/web/app.js +642 -0
  147. package/lib/web/appearance.js +111 -0
  148. package/lib/web/comfyui.js +146 -0
  149. package/lib/web/connections.js +377 -0
  150. package/lib/web/creation.js +355 -0
  151. package/lib/web/flow-view.js +33 -0
  152. package/lib/web/gallery.js +132 -0
  153. package/lib/web/index.html +291 -0
  154. package/lib/web/media-client.js +204 -0
  155. package/lib/web/plugin-panel.js +221 -0
  156. package/lib/web/plugin-surface.css +17 -0
  157. package/lib/web/providers.js +492 -0
  158. package/lib/web/recovery.js +105 -0
  159. package/lib/web/startup.js +18 -0
  160. package/lib/web/styles.css +518 -0
  161. package/lib/web/transport.js +96 -0
  162. package/lib/web/workbench-login.js +165 -0
  163. package/lib/workbench-artifacts.js +1 -2
  164. package/lib/workbench-auth-cli.js +47 -0
  165. package/lib/workbench-auth.js +200 -0
  166. package/lib/workbench-management.js +8 -9
  167. package/package.json +22 -6
  168. package/plugins/contact-sheet/index.d.mts +4 -0
  169. package/plugins/contact-sheet/index.mjs +33 -0
  170. package/plugins/contact-sheet/web/board.css +14 -0
  171. package/plugins/contact-sheet/web/board.mjs +93 -0
  172. package/skills/iris-core/SKILL.md +44 -0
  173. package/skills/iris-core/agents/openai.yaml +4 -0
  174. package/skills/iris-core/references/cli.md +95 -0
  175. package/skills/iris-core/references/comfyui.md +69 -0
  176. package/skills/iris-core/references/tasks.md +60 -0
  177. package/user_guide.md +19 -9
package/lib/render.js CHANGED
@@ -15,8 +15,41 @@ import crypto from 'node:crypto';
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { irisHome } from './config.js';
18
- import { webBase, mimeOf } from './media.js';
18
+ import { mimeOf } from './media.js';
19
+ import { resolveRenderAddress } from './render-address.js';
19
20
  import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
21
+ import { imageDimensions } from './pixels.js';
22
+
23
+ const PNG_DATA_PREFIX = 'data:image/png;base64,';
24
+ const MAX_SCREENSHOT_BYTES = 64 * 1024 * 1024;
25
+
26
+ async function screenshotPng(result) {
27
+ const dataUrl = result?.dataUrl;
28
+ if (typeof dataUrl !== 'string' || !dataUrl.startsWith(PNG_DATA_PREFIX)) {
29
+ throw new RenderError('iris_html_screenshot: 浏览器没有返回 PNG 图片数据');
30
+ }
31
+ if (dataUrl.length > PNG_DATA_PREFIX.length + Math.ceil(MAX_SCREENSHOT_BYTES / 3) * 4) {
32
+ throw new RenderError('iris_html_screenshot: 截图超过 64 MiB,请缩短页面后重试');
33
+ }
34
+ const encoded = dataUrl.slice(PNG_DATA_PREFIX.length);
35
+ const png = Buffer.from(encoded, 'base64');
36
+ if (!png.length || png.length > MAX_SCREENSHOT_BYTES || png.toString('base64') !== encoded
37
+ || !png.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
38
+ throw new RenderError('iris_html_screenshot: 浏览器返回的 PNG 图片数据无效');
39
+ }
40
+ let dimensions;
41
+ try { dimensions = await imageDimensions(png); }
42
+ catch (cause) {
43
+ const error = new RenderError('iris_html_screenshot: 浏览器返回的 PNG 图片无法读取');
44
+ error.cause = cause;
45
+ throw error;
46
+ }
47
+ // 与独立 Chromium 后端保持相同的截图尺寸上限,采用实际图片尺寸而非浏览器声明值。
48
+ if (dimensions.width > 16384 || dimensions.height > 16384 || dimensions.width * dimensions.height > 40000000) {
49
+ throw new RenderError('iris_html_screenshot: 截图尺寸超过限制(边长 16384,4000 万像素)');
50
+ }
51
+ return png;
52
+ }
20
53
 
21
54
  export class RenderError extends Error {
22
55
  constructor(message) {
@@ -60,22 +93,27 @@ export function renderRoot() {
60
93
 
61
94
  /**
62
95
  * 基于 DSH Adapter 持有的原生 browser 服务完成 HTML → PNG。
63
- * @param {{browser?: object, waitMs?: number}} opts browser 只在 DSH Adapter 内构造
96
+ * @param {{browser?: object, waitMs?: number, getAddress?: function}} opts browser 只在 DSH Adapter 内构造
64
97
  */
65
98
  export class BrowserHtmlRenderer {
66
- constructor({ browser, waitMs = 1200 }) {
99
+ constructor({ browser, waitMs = 1200, getAddress = resolveRenderAddress }) {
67
100
  this.browser = browser;
68
101
  this.waitMs = waitMs;
102
+ this.getAddress = getAddress;
69
103
  }
70
104
 
71
105
  async render({ html, width, height, fullPage, signal }) {
72
106
  const b = this.browser;
73
- if (!b || typeof b.open !== 'function' || typeof b.openUrl !== 'function' || typeof b.screenshot !== 'function') {
107
+ if (!b || !['open', 'openUrl', 'screenshot', 'close'].every((name) => typeof b[name] === 'function')) {
74
108
  throw new RenderError('iris: 浏览器服务不可用——需要启用兼容的 dsh-builtin-browser 插件');
75
109
  }
76
110
  const text = String(html || '').trim();
77
111
  if (!text) throw new RenderError('iris_html_screenshot: html 不能为空');
78
112
  throwIfAborted(signal);
113
+ const address = this.getAddress();
114
+ if (address.status === 'invalid') {
115
+ throw Object.assign(new RenderError(address.message), { code: address.code });
116
+ }
79
117
 
80
118
  // 每次调用独立子目录(随机名),写 index.html
81
119
  const dir = path.join(renderRoot(), crypto.randomBytes(6).toString('hex'));
@@ -88,24 +126,25 @@ export class BrowserHtmlRenderer {
88
126
  }
89
127
  const htmlPath = path.join(dir, 'index.html');
90
128
  writePrivateFile(htmlPath, content);
91
- const url = `${webBase()}/iris/render/${path.basename(dir)}/index.html`;
92
- const pngPath = path.join(dir, 'render.png');
93
-
129
+ const url = `${address.baseUrl}/iris/render/${path.basename(dir)}/index.html`;
94
130
  try {
95
- const session = await b.open({ signal });
96
- throwIfAborted(signal);
131
+ // open 只接受可选的会话名称;取消发生在打开期间时,取得会话后仍须关闭。
132
+ const session = await b.open();
97
133
  try {
98
- await b.openUrl(session, { url, newTab: true, signal });
134
+ throwIfAborted(signal);
135
+ await b.openUrl(session, { url, newTab: true }, signal);
99
136
  throwIfAborted(signal);
100
137
  await waitForRender(this.waitMs, signal); // 等页面加载(无加载完成事件可用的折中)
101
138
  throwIfAborted(signal);
102
- await b.screenshot(session, { fullPage: fullPage !== false, savePath: pngPath, signal });
139
+ // 不要求浏览器写入 Iris 私有目录,兼容 downloadDir 内的保存路径限制。
140
+ const result = await b.screenshot(session, { fullPage: fullPage !== false, format: 'png' }, signal);
141
+ throwIfAborted(signal);
142
+ const png = await screenshotPng(result);
103
143
  throwIfAborted(signal);
144
+ return { png };
104
145
  } finally {
105
146
  try { await b.close(session); } catch (_) { /* 幂等 */ }
106
147
  }
107
- if (!fs.existsSync(pngPath)) throw new RenderError('iris_html_screenshot: 浏览器未产出截图文件');
108
- return { png: fs.readFileSync(pngPath) };
109
148
  } finally {
110
149
  fs.rmSync(dir, { recursive: true, force: true }); // 用完即清
111
150
  }
@@ -0,0 +1,211 @@
1
+ /** Explicit installation of the bundled Core skill or a rendered connection snapshot. No host/model I/O. */
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { connectionSkillResources } from './connection-skill.js';
8
+
9
+ const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url));
10
+ const SKILL = 'iris-core';
11
+ const RECEIPT = '.iris-install.json';
12
+ const digest = value => crypto.createHash('sha256').update(value).digest('hex');
13
+ const error = (code, message) => Object.assign(new Error(message), { code });
14
+ const isWithin = (parent, child) => {
15
+ const relative = path.relative(parent, child);
16
+ return !relative || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
17
+ };
18
+
19
+ function stat(file) {
20
+ try { return fs.lstatSync(file); }
21
+ catch (cause) { if (cause.code === 'ENOENT') return null; throw cause; }
22
+ }
23
+
24
+ // Resolve existing ancestors without creating them, including for dry-run/status.
25
+ function physicalPath(file) {
26
+ const entry = stat(file);
27
+ if (entry) return fs.realpathSync(file);
28
+ return path.join(physicalPath(path.dirname(file)), path.basename(file));
29
+ }
30
+
31
+ function readTree(root, { installed = false, expected } = {}) {
32
+ const files = Object.create(null), bytes = Object.create(null), directories = [];
33
+ const walk = (directory, prefix = '') => {
34
+ for (const name of fs.readdirSync(directory).sort()) {
35
+ if (installed && !prefix && name === RECEIPT) continue;
36
+ const relative = prefix + name, file = path.join(directory, name), entry = fs.lstatSync(file);
37
+ if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
38
+ throw error('IRIS_SKILL_UNSAFE_PATH', 'Skill 中存在软链或特殊文件:' + relative);
39
+ }
40
+ if (entry.isDirectory()) {
41
+ directories.push(relative); walk(file, relative + '/');
42
+ } else {
43
+ // Added/size-changed user files need not be read to establish a conflict.
44
+ const content = !expected || expected[relative]?.size === entry.size ? fs.readFileSync(file) : null;
45
+ files[relative] = { size: entry.size, sha256: content ? digest(content) : null };
46
+ if (!installed) bytes[relative] = content;
47
+ }
48
+ }
49
+ };
50
+ walk(root);
51
+ return { files, bytes, directories: directories.sort() };
52
+ }
53
+
54
+ function sameTree(tree, recorded) {
55
+ const names = Object.keys(tree.files).sort(), other = Object.keys(recorded.files).sort();
56
+ return JSON.stringify(names) === JSON.stringify(other)
57
+ && names.every(name => tree.files[name].size === recorded.files[name].size
58
+ && tree.files[name].sha256 === recorded.files[name].sha256)
59
+ && JSON.stringify(tree.directories) === JSON.stringify([...recorded.directories].sort());
60
+ }
61
+
62
+ function validReceipt(value, packageName, skillName) {
63
+ const relative = name => typeof name === 'string' && name.length > 0 && !name.includes('\\')
64
+ && !name.includes('\0') && !name.split('/').some(part => !part || part === '.' || part === '..');
65
+ return value?.schemaVersion === 1 && value.packageName === packageName && value.skillName === skillName
66
+ && typeof value.packageVersion === 'string' && value.packageVersion.length > 0
67
+ && value.files && !Array.isArray(value.files) && typeof value.files === 'object'
68
+ && Object.hasOwn(value.files, 'SKILL.md') && !Object.hasOwn(value.files, RECEIPT)
69
+ && Object.entries(value.files).every(([name, item]) => relative(name)
70
+ && Number.isSafeInteger(item?.size) && item.size >= 0 && /^[a-f0-9]{64}$/.test(item.sha256))
71
+ && Array.isArray(value.directories) && value.directories.every(relative);
72
+ }
73
+
74
+ function packageResources(packageRoot) {
75
+ const metadata = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
76
+ const source = fs.realpathSync(path.join(packageRoot, 'skills', SKILL));
77
+ const tree = readTree(source);
78
+ if (!tree.files['SKILL.md'] || tree.files[RECEIPT]) {
79
+ throw error('IRIS_SKILL_PACKAGE_INVALID', '包内缺少 Skill 入口,或含有不应发布的安装记录');
80
+ }
81
+ return { skillName: SKILL, source, metadata, ...tree };
82
+ }
83
+
84
+ function bundleResources(file, packageRoot) {
85
+ if (typeof file !== 'string' || !path.isAbsolute(file)) throw error('IRIS_SKILL_OPTIONS_INVALID', '--bundle 必须是绝对路径');
86
+ const entry = fs.lstatSync(file);
87
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > 16384) throw error('IRIS_SKILL_BUNDLE_INVALID', 'Skill 交付文件必须是最多 16 KiB 的普通 JSON 文件');
88
+ let value;
89
+ try { value = JSON.parse(fs.readFileSync(file, 'utf8')); }
90
+ catch (cause) { if (!(cause instanceof SyntaxError)) throw cause; throw error('IRIS_SKILL_BUNDLE_INVALID', 'Skill 交付文件不是有效 JSON'); }
91
+ const metadata = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
92
+ return { ...connectionSkillResources(value, metadata), source: fs.realpathSync(file) };
93
+ }
94
+
95
+ function inspect(destination, resources) {
96
+ const entry = stat(destination);
97
+ if (!entry) return { status: 'not_installed' };
98
+ if (entry.isSymbolicLink() || !entry.isDirectory()) return { status: 'unmanaged', reason: 'not_a_plain_directory' };
99
+ const receiptFile = path.join(destination, RECEIPT), receiptStat = stat(receiptFile);
100
+ if (!receiptStat?.isFile() || receiptStat.isSymbolicLink()) return { status: 'unmanaged', reason: 'no_valid_receipt' };
101
+ let receipt;
102
+ try { receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8')); }
103
+ catch (cause) {
104
+ if (!(cause instanceof SyntaxError)) throw cause;
105
+ return { status: 'unmanaged', reason: 'invalid_receipt' };
106
+ }
107
+ if (!validReceipt(receipt, resources.metadata.name, resources.skillName)) return { status: 'unmanaged', reason: 'invalid_receipt' };
108
+ const base = { installedVersion: receipt.packageVersion };
109
+ let tree;
110
+ try { tree = readTree(destination, { installed: true, expected: receipt.files }); }
111
+ catch (cause) {
112
+ if (cause.code !== 'IRIS_SKILL_UNSAFE_PATH') throw cause;
113
+ return { ...base, status: 'modified', reason: 'unsafe_entry' };
114
+ }
115
+ if (!sameTree(tree, receipt)) return { ...base, status: 'modified', reason: 'local_changes' };
116
+ return { ...base, status: receipt.packageVersion === resources.metadata.version && sameTree(tree, resources)
117
+ ? 'current' : 'update_available' };
118
+ }
119
+
120
+ function resolveDirectory({ target, directory, env, home }) {
121
+ if (target !== undefined && target !== 'kimi') throw error('IRIS_SKILL_OPTIONS_INVALID', '当前仅支持 --target kimi;其他宿主请用 --directory');
122
+ if (directory === undefined) {
123
+ if (target !== 'kimi') throw error('IRIS_SKILL_OPTIONS_INVALID', '请明确指定 --target kimi 或 --directory');
124
+ const kimiHome = env.KIMI_CODE_HOME || path.join(home, '.kimi-code');
125
+ if (!path.isAbsolute(kimiHome)) throw error('IRIS_SKILL_OPTIONS_INVALID', 'KIMI_CODE_HOME 必须是绝对路径');
126
+ directory = path.join(kimiHome, 'skills');
127
+ }
128
+ if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw error('IRIS_SKILL_OPTIONS_INVALID', 'directory 必须是绝对路径');
129
+ return physicalPath(path.resolve(directory));
130
+ }
131
+
132
+ function ensureDirectory(directory) {
133
+ const entry = stat(directory);
134
+ if (entry && (!entry.isDirectory() || entry.isSymbolicLink())) throw error('IRIS_SKILL_UNSAFE_PATH', '控制目录必须是普通目录:' + directory);
135
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
136
+ }
137
+
138
+ function archive(destination, control, action) {
139
+ const transaction = fs.mkdtempSync(path.join(control, 'backup-'));
140
+ const backupPath = path.join(transaction, path.basename(destination));
141
+ fs.writeFileSync(path.join(transaction, 'move.json'), JSON.stringify({ schemaVersion: 1, action,
142
+ at: new Date().toISOString(), origin: destination, backupPath }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
143
+ fs.renameSync(destination, backupPath);
144
+ return backupPath;
145
+ }
146
+
147
+ function copyResources(destination, resources) {
148
+ fs.mkdirSync(destination, { mode: 0o700 }); // Exclusive claim; never replace even an empty user directory.
149
+ for (const relative of resources.directories) fs.mkdirSync(path.join(destination, relative), { recursive: true, mode: 0o700 });
150
+ for (const [relative, bytes] of Object.entries(resources.bytes)) fs.writeFileSync(path.join(destination, relative), bytes, { flag: 'wx', mode: 0o600 });
151
+ fs.writeFileSync(path.join(destination, RECEIPT), JSON.stringify({ schemaVersion: 1,
152
+ packageName: resources.metadata.name, packageVersion: resources.metadata.version, skillName: resources.skillName,
153
+ installedAt: new Date().toISOString(), files: resources.files, directories: resources.directories
154
+ }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
155
+ }
156
+
157
+ /** Internal CLI service. Explicit mutations only; no postinstall or public SDK export. */
158
+ export function manageCoreSkill({ action, target, directory, bundle, dryRun = false }, {
159
+ packageRoot = PACKAGE_ROOT, env = process.env, home = os.homedir()
160
+ } = {}) {
161
+ if (!['install', 'status', 'uninstall'].includes(action) || action === 'status' && dryRun) {
162
+ throw error('IRIS_SKILL_OPTIONS_INVALID', '使用 skills install/status/uninstall;status 不接受 --dry-run');
163
+ }
164
+ const resources = bundle === undefined ? packageResources(packageRoot) : bundleResources(bundle, packageRoot);
165
+ const resolved = resolveDirectory({ target, directory, env, home }), destination = path.join(resolved, resources.skillName);
166
+ if (isWithin(resources.source, destination) || isWithin(destination, resources.source)) {
167
+ throw error('IRIS_SKILL_OPTIONS_INVALID', '安装目标不能与包内 Skill 源目录重叠');
168
+ }
169
+ const base = { skill: resources.skillName, target: target || 'directory', directory: resolved, destination,
170
+ packageVersion: resources.metadata.version, discovery: 'not_checked' };
171
+ const before = inspect(destination, resources);
172
+ const result = { ...base, ...before };
173
+ if (action === 'status') return result;
174
+ const conflict = () => error('IRIS_SKILL_CONFLICT', '保留现有 Skill;目标没有有效归属记录、包含本地修改或软链。请先检查:' + destination);
175
+ if (['modified', 'unmanaged'].includes(before.status)) throw conflict();
176
+ if (action === 'install' && before.status === 'current'
177
+ || action === 'uninstall' && before.status === 'not_installed') return { ...result, action: 'unchanged', dryRun };
178
+ const operation = action === 'uninstall' ? 'uninstall' : before.status === 'not_installed' ? 'install' : 'update';
179
+ if (dryRun) return { ...result, action: operation, dryRun: true };
180
+ const controls = path.join(path.dirname(resolved), '.iris-skill-installs');
181
+ const control = path.join(controls, digest(resolved).slice(0, 16));
182
+ if (isWithin(resolved, control)) throw error('IRIS_SKILL_OPTIONS_INVALID', '安装目录必须允许在扫描目录之外保存备份');
183
+ ensureDirectory(controls); ensureDirectory(control);
184
+ const lock = path.join(control, 'lock.json');
185
+ let fd;
186
+ try { fd = fs.openSync(lock, 'wx', 0o600); }
187
+ catch (cause) {
188
+ if (cause.code !== 'EEXIST') throw cause;
189
+ throw error('IRIS_SKILL_BUSY', '已有安装操作或未核实的锁,保留现场:' + lock);
190
+ }
191
+ let backupPath;
192
+ try {
193
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, destination }) + '\n');
194
+ const current = inspect(destination, resources);
195
+ if (current.status !== before.status || current.installedVersion !== before.installedVersion) {
196
+ throw error('IRIS_SKILL_CONFLICT', '安装状态已变化,请重新检查:' + destination);
197
+ }
198
+ ensureDirectory(resolved);
199
+ if (before.status !== 'not_installed') backupPath = archive(destination, control, operation);
200
+ if (action === 'uninstall') return { ...base, status: 'not_installed', action: 'uninstalled', backupPath };
201
+ copyResources(destination, resources);
202
+ return { ...base, status: 'current', installedVersion: resources.metadata.version,
203
+ action: operation === 'update' ? 'updated' : 'installed', ...(backupPath ? { backupPath } : {}) };
204
+ } catch (cause) {
205
+ if (backupPath) cause.message += '\n原版保存在:' + backupPath;
206
+ throw cause;
207
+ } finally {
208
+ fs.closeSync(fd);
209
+ fs.unlinkSync(lock); // Only this invocation's exclusively created coordination file.
210
+ }
211
+ }
@@ -61,6 +61,9 @@ export function createConfiguredVisionModelPort(provider, model, { allowRateLimi
61
61
  orientImages, prepareImageRequest,
62
62
  ...(prepareImages ? { imageInput: visionInputLimits(provider, parsed.modelId) } : {}) });
63
63
  const availability = result => {
64
+ // Ordinary vision/OCR is read-only for provider configuration. Explicit probes
65
+ // and definitive failures still update availability through the existing hook.
66
+ if (result.ok === true && !allowRateLimited) return;
64
67
  const event = { ...result, stage: 'submit', source: allowRateLimited ? 'probe' : 'task',
65
68
  binding: modelAvailabilityBinding(provider, parsed.modelId, 'vision') };
66
69
  if (!recordModelAvailability(provider, parsed.modelId, 'vision', event)) return;