@mokuyoaxis/dsh-iris 0.2.6 → 0.2.7

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 (58) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.en.md +28 -19
  3. package/README.md +27 -18
  4. package/bin/dsh-iris.js +140 -26
  5. package/docs/AGENT_QUICKSTART.md +5 -5
  6. package/docs/ARCHITECTURE.md +9 -3
  7. package/docs/CLI_MANAGEMENT.md +1 -1
  8. package/docs/CORE_RUNTIME_CONTRACT.md +36 -5
  9. package/docs/CORE_SKILLS.md +62 -0
  10. package/docs/DSH_COMPATIBILITY.md +4 -4
  11. package/docs/DSH_CORE_MIGRATION.md +4 -2
  12. package/docs/DSH_PROVIDER_CONNECTIONS.md +1 -1
  13. package/docs/DSH_RUNTIME.md +36 -0
  14. package/docs/FUNCTION_MODEL_POOLS.md +1 -1
  15. package/docs/HEADLESS_CLI.md +11 -3
  16. package/docs/HOST_DOCTOR.md +13 -1
  17. package/docs/IMAGE_FLOW.md +71 -0
  18. package/docs/LOCAL_API.md +60 -0
  19. package/docs/LOCAL_API_CONTRACT.md +115 -0
  20. package/docs/LOCAL_CREATION.md +50 -0
  21. package/docs/PROVIDER_HEALTH.md +1 -1
  22. package/docs/ROADMAP.md +14 -2
  23. package/docs/SECURITY.md +9 -1
  24. package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
  25. package/docs/VISION_CLI.md +1 -1
  26. package/docs/WEB_PROTOTYPE.md +47 -0
  27. package/docs/releases/0.2.7.md +62 -0
  28. package/lib/actions.js +79 -68
  29. package/lib/api.js +38 -38
  30. package/lib/application-runtime.js +186 -0
  31. package/lib/client.js +89 -3
  32. package/lib/core-artifact-store.js +5 -0
  33. package/lib/core-maintenance.js +25 -2
  34. package/lib/core-runtime.js +19 -0
  35. package/lib/core-tasks.js +62 -8
  36. package/lib/creation-request-store.js +159 -0
  37. package/lib/doctor.js +12 -1
  38. package/lib/dsh-core-adapter.js +666 -572
  39. package/lib/dsh-host-adapter.js +4 -1
  40. package/lib/flow-store.js +195 -0
  41. package/lib/image-flow.js +188 -0
  42. package/lib/index.js +59 -53
  43. package/lib/local-api-projection.js +27 -0
  44. package/lib/local-api.js +381 -0
  45. package/lib/local-creation.js +85 -0
  46. package/lib/private-storage.js +12 -0
  47. package/lib/provider-task-runner.js +22 -2
  48. package/lib/render-address.js +35 -0
  49. package/lib/render.js +52 -13
  50. package/lib/skill-installation.js +199 -0
  51. package/lib/workbench-artifacts.js +1 -2
  52. package/lib/workbench-management.js +8 -9
  53. package/package.json +3 -2
  54. package/skills/iris-core/SKILL.md +40 -0
  55. package/skills/iris-core/agents/openai.yaml +4 -0
  56. package/skills/iris-core/references/cli.md +95 -0
  57. package/skills/iris-core/references/tasks.md +58 -0
  58. package/user_guide.md +14 -8
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,199 @@
1
+ /** Explicit, copy-based installation of the bundled Core skill. 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
+
8
+ const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url));
9
+ const SKILL = 'iris-core';
10
+ const RECEIPT = '.iris-install.json';
11
+ const digest = value => crypto.createHash('sha256').update(value).digest('hex');
12
+ const error = (code, message) => Object.assign(new Error(message), { code });
13
+ const isWithin = (parent, child) => {
14
+ const relative = path.relative(parent, child);
15
+ return !relative || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
16
+ };
17
+
18
+ function stat(file) {
19
+ try { return fs.lstatSync(file); }
20
+ catch (cause) { if (cause.code === 'ENOENT') return null; throw cause; }
21
+ }
22
+
23
+ // Resolve existing ancestors without creating them, including for dry-run/status.
24
+ function physicalPath(file) {
25
+ const entry = stat(file);
26
+ if (entry) return fs.realpathSync(file);
27
+ return path.join(physicalPath(path.dirname(file)), path.basename(file));
28
+ }
29
+
30
+ function readTree(root, { installed = false, expected } = {}) {
31
+ const files = Object.create(null), bytes = Object.create(null), directories = [];
32
+ const walk = (directory, prefix = '') => {
33
+ for (const name of fs.readdirSync(directory).sort()) {
34
+ if (installed && !prefix && name === RECEIPT) continue;
35
+ const relative = prefix + name, file = path.join(directory, name), entry = fs.lstatSync(file);
36
+ if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
37
+ throw error('IRIS_SKILL_UNSAFE_PATH', 'Skill 中存在软链或特殊文件:' + relative);
38
+ }
39
+ if (entry.isDirectory()) {
40
+ directories.push(relative); walk(file, relative + '/');
41
+ } else {
42
+ // Added/size-changed user files need not be read to establish a conflict.
43
+ const content = !expected || expected[relative]?.size === entry.size ? fs.readFileSync(file) : null;
44
+ files[relative] = { size: entry.size, sha256: content ? digest(content) : null };
45
+ if (!installed) bytes[relative] = content;
46
+ }
47
+ }
48
+ };
49
+ walk(root);
50
+ return { files, bytes, directories: directories.sort() };
51
+ }
52
+
53
+ function sameTree(tree, recorded) {
54
+ const names = Object.keys(tree.files).sort(), other = Object.keys(recorded.files).sort();
55
+ return JSON.stringify(names) === JSON.stringify(other)
56
+ && names.every(name => tree.files[name].size === recorded.files[name].size
57
+ && tree.files[name].sha256 === recorded.files[name].sha256)
58
+ && JSON.stringify(tree.directories) === JSON.stringify([...recorded.directories].sort());
59
+ }
60
+
61
+ function validReceipt(value, packageName) {
62
+ const relative = name => typeof name === 'string' && name.length > 0 && !name.includes('\\')
63
+ && !name.includes('\0') && !name.split('/').some(part => !part || part === '.' || part === '..');
64
+ return value?.schemaVersion === 1 && value.packageName === packageName && value.skillName === SKILL
65
+ && typeof value.packageVersion === 'string' && value.packageVersion.length > 0
66
+ && value.files && !Array.isArray(value.files) && typeof value.files === 'object'
67
+ && Object.hasOwn(value.files, 'SKILL.md') && !Object.hasOwn(value.files, RECEIPT)
68
+ && Object.entries(value.files).every(([name, item]) => relative(name)
69
+ && Number.isSafeInteger(item?.size) && item.size >= 0 && /^[a-f0-9]{64}$/.test(item.sha256))
70
+ && Array.isArray(value.directories) && value.directories.every(relative);
71
+ }
72
+
73
+ function packageResources(packageRoot) {
74
+ const metadata = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
75
+ const source = fs.realpathSync(path.join(packageRoot, 'skills', SKILL));
76
+ const tree = readTree(source);
77
+ if (!tree.files['SKILL.md'] || tree.files[RECEIPT]) {
78
+ throw error('IRIS_SKILL_PACKAGE_INVALID', '包内缺少 Skill 入口,或含有不应发布的安装记录');
79
+ }
80
+ return { source, metadata, ...tree };
81
+ }
82
+
83
+ function inspect(destination, resources) {
84
+ const entry = stat(destination);
85
+ if (!entry) return { status: 'not_installed' };
86
+ if (entry.isSymbolicLink() || !entry.isDirectory()) return { status: 'unmanaged', reason: 'not_a_plain_directory' };
87
+ const receiptFile = path.join(destination, RECEIPT), receiptStat = stat(receiptFile);
88
+ if (!receiptStat?.isFile() || receiptStat.isSymbolicLink()) return { status: 'unmanaged', reason: 'no_valid_receipt' };
89
+ let receipt;
90
+ try { receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8')); }
91
+ catch (cause) {
92
+ if (!(cause instanceof SyntaxError)) throw cause;
93
+ return { status: 'unmanaged', reason: 'invalid_receipt' };
94
+ }
95
+ if (!validReceipt(receipt, resources.metadata.name)) return { status: 'unmanaged', reason: 'invalid_receipt' };
96
+ const base = { installedVersion: receipt.packageVersion };
97
+ let tree;
98
+ try { tree = readTree(destination, { installed: true, expected: receipt.files }); }
99
+ catch (cause) {
100
+ if (cause.code !== 'IRIS_SKILL_UNSAFE_PATH') throw cause;
101
+ return { ...base, status: 'modified', reason: 'unsafe_entry' };
102
+ }
103
+ if (!sameTree(tree, receipt)) return { ...base, status: 'modified', reason: 'local_changes' };
104
+ return { ...base, status: receipt.packageVersion === resources.metadata.version && sameTree(tree, resources)
105
+ ? 'current' : 'update_available' };
106
+ }
107
+
108
+ function resolveDirectory({ target, directory, env, home }) {
109
+ if (target !== undefined && target !== 'kimi') throw error('IRIS_SKILL_OPTIONS_INVALID', '当前仅支持 --target kimi;其他宿主请用 --directory');
110
+ if (directory === undefined) {
111
+ if (target !== 'kimi') throw error('IRIS_SKILL_OPTIONS_INVALID', '请明确指定 --target kimi 或 --directory');
112
+ const kimiHome = env.KIMI_CODE_HOME || path.join(home, '.kimi-code');
113
+ if (!path.isAbsolute(kimiHome)) throw error('IRIS_SKILL_OPTIONS_INVALID', 'KIMI_CODE_HOME 必须是绝对路径');
114
+ directory = path.join(kimiHome, 'skills');
115
+ }
116
+ if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw error('IRIS_SKILL_OPTIONS_INVALID', 'directory 必须是绝对路径');
117
+ return physicalPath(path.resolve(directory));
118
+ }
119
+
120
+ function ensureDirectory(directory) {
121
+ const entry = stat(directory);
122
+ if (entry && (!entry.isDirectory() || entry.isSymbolicLink())) throw error('IRIS_SKILL_UNSAFE_PATH', '控制目录必须是普通目录:' + directory);
123
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
124
+ }
125
+
126
+ function archive(destination, control, action) {
127
+ const transaction = fs.mkdtempSync(path.join(control, 'backup-'));
128
+ const backupPath = path.join(transaction, SKILL);
129
+ fs.writeFileSync(path.join(transaction, 'move.json'), JSON.stringify({ schemaVersion: 1, action,
130
+ at: new Date().toISOString(), origin: destination, backupPath }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
131
+ fs.renameSync(destination, backupPath);
132
+ return backupPath;
133
+ }
134
+
135
+ function copyResources(destination, resources) {
136
+ fs.mkdirSync(destination, { mode: 0o700 }); // Exclusive claim; never replace even an empty user directory.
137
+ for (const relative of resources.directories) fs.mkdirSync(path.join(destination, relative), { recursive: true, mode: 0o700 });
138
+ for (const [relative, bytes] of Object.entries(resources.bytes)) fs.writeFileSync(path.join(destination, relative), bytes, { flag: 'wx', mode: 0o600 });
139
+ fs.writeFileSync(path.join(destination, RECEIPT), JSON.stringify({ schemaVersion: 1,
140
+ packageName: resources.metadata.name, packageVersion: resources.metadata.version, skillName: SKILL,
141
+ installedAt: new Date().toISOString(), files: resources.files, directories: resources.directories
142
+ }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
143
+ }
144
+
145
+ /** Internal CLI service. Explicit mutations only; no postinstall or public SDK export. */
146
+ export function manageCoreSkill({ action, target, directory, dryRun = false }, {
147
+ packageRoot = PACKAGE_ROOT, env = process.env, home = os.homedir()
148
+ } = {}) {
149
+ if (!['install', 'status', 'uninstall'].includes(action) || action === 'status' && dryRun) {
150
+ throw error('IRIS_SKILL_OPTIONS_INVALID', '使用 skills install/status/uninstall;status 不接受 --dry-run');
151
+ }
152
+ const resources = packageResources(packageRoot);
153
+ const resolved = resolveDirectory({ target, directory, env, home }), destination = path.join(resolved, SKILL);
154
+ if (isWithin(resources.source, destination) || isWithin(destination, resources.source)) {
155
+ throw error('IRIS_SKILL_OPTIONS_INVALID', '安装目标不能与包内 Skill 源目录重叠');
156
+ }
157
+ const base = { skill: SKILL, target: target || 'directory', directory: resolved, destination,
158
+ packageVersion: resources.metadata.version, discovery: 'not_checked' };
159
+ const before = inspect(destination, resources);
160
+ const result = { ...base, ...before };
161
+ if (action === 'status') return result;
162
+ const conflict = () => error('IRIS_SKILL_CONFLICT', '保留现有 Skill;目标没有有效归属记录、包含本地修改或软链。请先检查:' + destination);
163
+ if (['modified', 'unmanaged'].includes(before.status)) throw conflict();
164
+ if (action === 'install' && before.status === 'current'
165
+ || action === 'uninstall' && before.status === 'not_installed') return { ...result, action: 'unchanged', dryRun };
166
+ const operation = action === 'uninstall' ? 'uninstall' : before.status === 'not_installed' ? 'install' : 'update';
167
+ if (dryRun) return { ...result, action: operation, dryRun: true };
168
+ const controls = path.join(path.dirname(resolved), '.iris-skill-installs');
169
+ const control = path.join(controls, digest(resolved).slice(0, 16));
170
+ if (isWithin(resolved, control)) throw error('IRIS_SKILL_OPTIONS_INVALID', '安装目录必须允许在扫描目录之外保存备份');
171
+ ensureDirectory(controls); ensureDirectory(control);
172
+ const lock = path.join(control, 'lock.json');
173
+ let fd;
174
+ try { fd = fs.openSync(lock, 'wx', 0o600); }
175
+ catch (cause) {
176
+ if (cause.code !== 'EEXIST') throw cause;
177
+ throw error('IRIS_SKILL_BUSY', '已有安装操作或未核实的锁,保留现场:' + lock);
178
+ }
179
+ let backupPath;
180
+ try {
181
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, destination }) + '\n');
182
+ const current = inspect(destination, resources);
183
+ if (current.status !== before.status || current.installedVersion !== before.installedVersion) {
184
+ throw error('IRIS_SKILL_CONFLICT', '安装状态已变化,请重新检查:' + destination);
185
+ }
186
+ ensureDirectory(resolved);
187
+ if (before.status !== 'not_installed') backupPath = archive(destination, control, operation);
188
+ if (action === 'uninstall') return { ...base, status: 'not_installed', action: 'uninstalled', backupPath };
189
+ copyResources(destination, resources);
190
+ return { ...base, status: 'current', installedVersion: resources.metadata.version,
191
+ action: operation === 'update' ? 'updated' : 'installed', ...(backupPath ? { backupPath } : {}) };
192
+ } catch (cause) {
193
+ if (backupPath) cause.message += '\n原版保存在:' + backupPath;
194
+ throw cause;
195
+ } finally {
196
+ fs.closeSync(fd);
197
+ fs.unlinkSync(lock); // Only this invocation's exclusively created coordination file.
198
+ }
199
+ }
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
  /** Host 作品目录:两种既有存储保持原样,在展示边界统一过滤、排序与分页。 */
3
3
  import * as legacy from './artifacts.js';
4
- import { galleryArtifactsForDsh } from './dsh-core-adapter.js';
5
4
  import { mimeOf } from './media.js';
6
5
 
7
6
  export function normalizeGalleryQuery(input = {}) {
@@ -22,7 +21,7 @@ export async function workbenchArtifacts(input, options = {}) {
22
21
  let droppedCore = 0, coreError = '';
23
22
  if (query.source !== 'legacy') {
24
23
  try {
25
- const core = await galleryArtifactsForDsh(options);
24
+ const core = await options.core.galleryArtifactsForDsh(options);
26
25
  droppedCore = core.dropped;
27
26
  for (const artifact of core.artifacts) {
28
27
  if (artifact.kind === 'host-input') continue;
@@ -5,7 +5,6 @@ import crypto from 'node:crypto';
5
5
  import { setImmediate } from 'node:timers/promises';
6
6
  import * as legacy from './artifacts.js';
7
7
  import { mimeOf } from './media.js';
8
- import { inspectWorkbenchArtifactForDsh, inspectProviderTaskForDsh, maintainWorkbenchForDsh, readCoreArtifactMediaForDsh } from './dsh-core-adapter.js';
9
8
  import { workbenchZip } from './workbench-zip.js';
10
9
 
11
10
  export const MAX_WORKBENCH_SELECTION = 200;
@@ -56,7 +55,7 @@ function safeMetadata(metadata) {
56
55
  export async function workbenchDetail(source, id, options = {}) {
57
56
  validItem({ source, id }); active(options.signal);
58
57
  if (source === 'core') {
59
- const artifact = await inspectWorkbenchArtifactForDsh(id, options);
58
+ const artifact = await options.core.inspectWorkbenchArtifactForDsh(id, options);
60
59
  return { item: { id, source, file: id + EXTENSIONS[artifact.mediaType], mime: artifact.mediaType,
61
60
  kind: artifact.kind, size: artifact.size, createdAt: artifact.createdAt, digest: artifact.digest.value,
62
61
  relations: artifact.relations, metadata: safeMetadata(artifact.metadata), integrity: 'verified',
@@ -82,7 +81,7 @@ export async function downloadWorkbenchSelection(input, options = {}) {
82
81
  bytes = 0;
83
82
  for (const detail of details) {
84
83
  active(options.signal);
85
- const data = detail.source === 'core' ? (await readCoreArtifactMediaForDsh(detail.id, options)).bytes : fs.readFileSync(legacyFile(detail.id).abs);
84
+ const data = detail.source === 'core' ? (await options.core.readCoreArtifactMediaForDsh(detail.id, options)).bytes : fs.readFileSync(legacyFile(detail.id).abs);
86
85
  const digest = crypto.createHash('sha256').update(data).digest('hex');
87
86
  if (detail.source === 'core' && digest !== detail.digest) fail('IRIS_ARTIFACT_DIGEST_MISMATCH', '选中作品内容已改变', 409);
88
87
  bytes += data.length;
@@ -106,15 +105,15 @@ export async function deleteWorkbenchArtifacts(input, options = {}) {
106
105
  || new Set(taskIds).size !== taskIds.length) fail('IRIS_COMMAND_INPUT_INVALID', '关联任务 ID 无效');
107
106
  active(options.signal);
108
107
  const artifactIds = chosen.map(item => item.id);
109
- const base = await maintainWorkbenchForDsh('core.delete', { artifact_ids: artifactIds }, options);
108
+ const base = await options.core.maintainWorkbenchForDsh('core.delete', { artifact_ids: artifactIds }, options);
110
109
  const referencing = [...new Set(base.blockers.filter(blocker => ['task_artifact_reference', 'task_input_reference'].includes(blocker.reason)).map(blocker => blocker.referencedBy))];
111
110
  if (taskIds.some(id => !referencing.includes(id))) fail('IRIS_COMMAND_INPUT_INVALID', '只能显式选择这些作品直接关联的任务');
112
- if (input.confirm_delete === true) return { ok: true, ...await maintainWorkbenchForDsh('core.delete', {
111
+ if (input.confirm_delete === true) return { ok: true, ...await options.core.maintainWorkbenchForDsh('core.delete', {
113
112
  artifact_ids: artifactIds, task_ids: taskIds, confirm_delete: true }, options) };
114
- const plan = taskIds.length ? await maintainWorkbenchForDsh('core.delete', { artifact_ids: artifactIds, task_ids: taskIds }, options) : base;
113
+ const plan = taskIds.length ? await options.core.maintainWorkbenchForDsh('core.delete', { artifact_ids: artifactIds, task_ids: taskIds }, options) : base;
115
114
  const tasks = [];
116
115
  for (const id of referencing) {
117
- const task = await inspectProviderTaskForDsh(id, options);
116
+ const task = await options.core.inspectProviderTaskForDsh(id, options);
118
117
  tasks.push({ id, settled: task.phase === 'terminal' && task.acceptance !== 'unknown' && task.outcome !== 'unknown' });
119
118
  }
120
119
  return { ok: true, preview: true, allowed: plan.allowed, artifactIds, taskIds, files: plan.entries.length,
@@ -122,7 +121,7 @@ export async function deleteWorkbenchArtifacts(input, options = {}) {
122
121
  }
123
122
 
124
123
  export async function workbenchTransactions(options = {}) {
125
- return { ok: true, ...await maintainWorkbenchForDsh('core.transactions', {}, options) };
124
+ return { ok: true, ...await options.core.maintainWorkbenchForDsh('core.transactions', {}, options) };
126
125
  }
127
126
 
128
127
  export async function restoreWorkbenchTransaction(input, options = {}) {
@@ -130,7 +129,7 @@ export async function restoreWorkbenchTransaction(input, options = {}) {
130
129
  if (input.confirm_restore !== true) fail('IRIS_WORK_RESTORE_CONFIRM_REQUIRED', '请明确确认恢复此隔离事务');
131
130
  if (typeof input.transaction_id !== 'string' || !/^delete_[a-f0-9]{24}$/.test(input.transaction_id)) fail('IRIS_COMMAND_INPUT_INVALID', '隔离事务 ID 无效');
132
131
  active(options.signal);
133
- return { ok: true, ...await maintainWorkbenchForDsh('core.restore', { transaction_id: input.transaction_id }, options) };
132
+ return { ok: true, ...await options.core.maintainWorkbenchForDsh('core.restore', { transaction_id: input.transaction_id }, options) };
134
133
  }
135
134
 
136
135
  /** API 边界只返回固定提示和稳定码,避免底层 ENOENT/JSON 错误带出文件路径。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mokuyoaxis/dsh-iris",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Multi-provider image, video, music, and speech generation, transcription, visual understanding, and prompt optimization with an integrated Iris workbench for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -30,7 +30,8 @@
30
30
  "!docs/releases/0.1.3.md",
31
31
  ".dsh/skills/iris-use",
32
32
  ".dsh/skills/iris-verify-ui",
33
- ".dsh/skills/iris-compose-media"
33
+ ".dsh/skills/iris-compose-media",
34
+ "skills/iris-core"
34
35
  ],
35
36
  "dsh": {
36
37
  "engines": {
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: iris-core
3
+ description: "Create, edit, and retrieve persistent Iris images through the standalone CLI without a running DSH host. Use for local image imports, revisions, existing task inspection, and artifact export."
4
+ ---
5
+
6
+ # Iris Core · 核心图片工作流
7
+
8
+ Organize the work around the image the user wants to receive. Reuse existing assets and tasks, preserve originals, and report actual results. Use this skill for a single image operation or a sequence of image revisions and delivery steps.
9
+
10
+ ## Choose the execution path · 确认执行入口
11
+
12
+ Use the [standalone CLI reference](references/cli.md) to confirm the installed command, explicit data root, and private provider configuration needed for generation. Reuse information already established in the conversation. Do not scan another application's configuration or assume a personal directory, provider, or model.
13
+
14
+ A visible skill does not prove that its execution tools are available. Report a missing command, connection, or capability precisely. This edition binds to the existing CLI; future MCP and independent workbench integrations need their own verified invocation instructions.
15
+
16
+ ## Plan the image work · 按作品组织步骤
17
+
18
+ - Import a local image when it needs to become a persistent source. Reuse an existing Artifact in the current root without importing it again.
19
+ - Generate for a new image request. For a revision, pass the source Artifact and requested changes to create a related version while retaining the original. Retrieve existing results instead of regenerating them.
20
+ - Omit the model override unless the user specifies one, allowing the configured capability policy to select candidates. An explicit model must be allowed for that capability. Names, catalog entries, and past success do not establish present availability.
21
+ - Add only steps needed for the requested result. An explicit edit can proceed directly; a generation request does not require extra paid vision checks or another generation.
22
+ - Wait for actual delivery before starting a dependent operation. When content verification is required, use an image-reading capability available in the current host. Metadata and file existence alone do not establish visual correctness.
23
+
24
+ ## Preserve asset identities · 保持作品与任务身份
25
+
26
+ An Artifact ID identifies a persistent asset in a particular Core root; a Task ID identifies an actual operation. Host attachment IDs, file paths, and playback URLs have different meanings. Do not interchange them or construct storage paths from IDs.
27
+
28
+ Keep the returned Task and Artifact IDs at each handoff. Export to a new, explicit path when a consumer needs a file, preserving the original media format. Use the host's actual capabilities to present that file as an attachment.
29
+
30
+ ## Continue from recorded facts · 根据事实继续
31
+
32
+ Check the Task's `acceptance`, `outcome`, `deliveryState`, and available Artifacts. Exit code 0 alone does not prove that an image was delivered. Inspect an existing task first; read [task continuation and recovery](references/tasks.md) when waiting, redelivery, cancellation, or an informed retry is needed.
33
+
34
+ Preserve the original Task after interruption or unknown acceptance; do not probe by submitting another generation. If no Task ID was returned, list tasks read-only in the original root and identify the relevant record. Report uncertainty when that association cannot be established. A busy root does not authorize deleting a lease, taking over a writer, or hiding the original task in a new root.
35
+
36
+ ## Deliver the result · 交付实际结果
37
+
38
+ Return the actual image file or a host-accessible asset, with the Task/Artifact IDs needed to continue. Distinguish availability, export, display in the conversation, and visual verification. Report partial results and unmet conditions accurately.
39
+
40
+ This edition covers images and their tasks. It does not imply support for video, speech, UI operations, or a production MCP service in this entry point. Existing DSH skills continue to guide DSH-native tools and workbench workflows.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Iris Core · 图片创作"
3
+ short_description: "Create, edit and retrieve persistent Iris images"
4
+ default_prompt: "Use $iris-core to create or revise my image, reuse existing tasks and artifacts, and return the actual result."
@@ -0,0 +1,95 @@
1
+ # Standalone CLI · 图片与作品
2
+
3
+ The current executable binding is `dsh-iris`, which uses shared Application/Core operations without a running DSH host. Check the installed command with `dsh-iris --help`. In a confirmed source checkout, `node /absolute/path/to/iris/bin/dsh-iris.js` is also available. A missing command does not authorize installing software or changing package versions.
4
+
5
+ These image, artifact, and task commands exist in 0.2.6. This skill is a new, unreleased package resource. Development-only Flow commands, local HTTP writes, and a future UI are not prerequisites.
6
+
7
+ ## Establish the context · 明确本次上下文
8
+
9
+ Obtain these values from the user or already established context:
10
+
11
+ - `IRIS_DATA_ROOT`: an absolute Core root. Continue each task in its original root.
12
+ - `IRIS_PROVIDER_FILE`: a private provider JSON file for generation/editing or remote observation. Import, export, and read-only inspection do not need it.
13
+ - `IRIS_INPUT_FILE`: the operation's JSON input file, containing the actual request and necessary parameters.
14
+ - `IRIS_TASK_ID` / `IRIS_ARTIFACT_ID`: IDs actually returned by Iris.
15
+ - `IRIS_OUTPUT_FILE`: a new absolute output path requested by the user or chosen in the task workspace, with an extension matching the real media type.
16
+
17
+ Quote these variables in the shell; they are example shell variables, not configuration Iris discovers automatically. Pass request content through a JSON file or stdin instead of inserting user prompts into shell code. Keep private creative input in appropriately protected files without overwriting existing user files.
18
+
19
+ Do not assume a personal DSH root. Choose a task-specific root for new work when the context permits; changing roots cannot recover an existing task. Use an explicitly supplied provider configuration without printing its credentials.
20
+
21
+ ## Inspect configuration · 查询配置
22
+
23
+ Read the configured candidate summary when the allowed scope is unclear; do not test configuration by generating:
24
+
25
+ ```bash
26
+ dsh-iris capabilities list --provider-config "$IRIS_PROVIDER_FILE"
27
+ ```
28
+
29
+ This reports configuration and historical evidence, not remote availability. Report an empty candidate list without adding vendor defaults or expanding the capability pool.
30
+
31
+ ## Import a local image · 导入本地图片
32
+
33
+ Set `IRIS_INPUT_FILE` to a file containing `{"image_path":"/absolute/path/source.png"}`, then run:
34
+
35
+ ```bash
36
+ dsh-iris artifact import --data-root "$IRIS_DATA_ROOT" --input-file "$IRIS_INPUT_FILE"
37
+ ```
38
+
39
+ The source must be a static PNG/JPEG/WebP of at most 20 MiB. Keep the returned `artifact.id`, source file, and original bytes. Import does not call a provider or create a generation Task. A host upload must first become a readable file; this CLI does not accept a host attachment ID.
40
+
41
+ ## Generate an image · 生成新图
42
+
43
+ Write the user's original or authorized revised request into the input file, for example:
44
+
45
+ ```json
46
+ {"prompt":"An iris flower after the rain","n":1}
47
+ ```
48
+
49
+ ```bash
50
+ dsh-iris run image --data-root "$IRIS_DATA_ROOT" --provider-config "$IRIS_PROVIDER_FILE" --input-file "$IRIS_INPUT_FILE"
51
+ ```
52
+
53
+ Set `n` to the requested count (1–4); do not increase it for testing. Supply `size` only when requested and supported. Omit `model_ref` unless a model was specified; use an allowed `providerId::modelId` for an explicit choice. An explicit choice has no candidate fallback. Default candidate switching requires evidence of non-acceptance.
54
+
55
+ ## Edit an existing image · 编辑已有图片
56
+
57
+ Use the same `run image` command with the actual source Artifact ID in the input:
58
+
59
+ ```json
60
+ {"source_artifact_id":"artifact_0123456789abcdef01234567","prompt":"Make the background green while preserving the subject","n":1}
61
+ ```
62
+
63
+ Replace the example ID with a real Artifact in the original root. The source is retained; the new Task's `sourceArtifactId` and new Artifact's `derived-from` relation record its origin. The model must be allowed for images and support an implemented edit protocol. Report an unsupported protocol without substituting a new text-to-image request.
64
+
65
+ ## Inspect tasks and artifacts · 查询原任务和作品
66
+
67
+ ```bash
68
+ dsh-iris task inspect "$IRIS_TASK_ID" --data-root "$IRIS_DATA_ROOT"
69
+ ```
70
+
71
+ ```bash
72
+ dsh-iris artifact inspect "$IRIS_ARTIFACT_ID" --data-root "$IRIS_DATA_ROOT"
73
+ ```
74
+
75
+ Read the Task's `id`, `acceptance`, `outcome`, `deliveryState`, `artifactIds`, and Attempt facts. Read the Artifact's `id`, `mediaType`, size, digest, and provenance. `outcome=succeeded` with `deliveryState=ready` establishes completed local delivery. A failed operation can still have partial artifacts; preserve them.
76
+
77
+ These inspections do not call providers, initialize missing roots, or require provider configuration. They do not advance a queued remote task.
78
+
79
+ If a Task ID was not returned, list the original root's tasks read-only and identify the relevant record:
80
+
81
+ ```bash
82
+ dsh-iris task list --data-root "$IRIS_DATA_ROOT" --capability image --limit 20
83
+ ```
84
+
85
+ Retain uncertainty when the association is not reliable; the most recent task is not necessarily this request.
86
+
87
+ ## Export an artifact · 导出已有作品
88
+
89
+ ```bash
90
+ dsh-iris artifact export "$IRIS_ARTIFACT_ID" --data-root "$IRIS_DATA_ROOT" --output "$IRIS_OUTPUT_FILE"
91
+ ```
92
+
93
+ Export copies the original bytes without generation, conversion, or overwriting an existing file. Choose the extension from `mediaType`. Successful export does not establish display as a conversation attachment; use the current host to read or present the file as requested.
94
+
95
+ For observation, redelivery, cancellation, or informed retries, read [task continuation and recovery](tasks.md) instead of using another generation command as recovery.
@@ -0,0 +1,58 @@
1
+ # Image task recovery · 任务继续与恢复
2
+
3
+ Inspect the Task in its original data root first. Tasks do not persist prompts; do not reconstruct the user's input from a task summary. Inspection has no provider side effects. Observe or recover only as needed for the current request.
4
+
5
+ | Recorded fact | Action |
6
+ |---|---|
7
+ | Accepted and still queued/running | Observe or wait for the original Task; do not create another generation |
8
+ | `outcome=succeeded`, `deliveryState=ready` | Retrieve existing Artifacts and export as needed |
9
+ | `outcome=succeeded`, `deliveryState=failed` | Redeliver the original result, preferring a complete local cache |
10
+ | Unknown acceptance/outcome, or an Attempt without a reliable response | Preserve the original Task and uncertainty; do not infer that no work or billing occurred |
11
+ | User requests stopping | Stop subsequent generation; remote cancellation succeeds only with protocol support and provider confirmation |
12
+ | User knowingly requests retrying a terminal task without successful delivery | Create a linked new Task with possible additional billing, preserving the original |
13
+
14
+ An unfinished Attempt with `acceptance=none` does not prove that nothing was sent. Local timeout, CLI interruption, and stopping a local wait do not establish successful remote cancellation.
15
+
16
+ ## Observe and wait · 观察与等待
17
+
18
+ Use the original provider binding. A single observation advances at most one poll:
19
+
20
+ ```bash
21
+ dsh-iris task observe "$IRIS_TASK_ID" --data-root "$IRIS_DATA_ROOT" --provider-config "$IRIS_PROVIDER_FILE"
22
+ ```
23
+
24
+ Use bounded waiting when needed, adjusting the limit to the user's task:
25
+
26
+ ```bash
27
+ dsh-iris task wait "$IRIS_TASK_ID" --data-root "$IRIS_DATA_ROOT" --provider-config "$IRIS_PROVIDER_FILE" --timeout-ms 120000 --poll-interval-ms 2500
28
+ ```
29
+
30
+ Observation and waiting do not resubmit or create a background loop. Terminal inspection needs no provider configuration. Report protocols without remote observation support. `task wait` exits 0 for ready, 1 for unsuccessful delivery, and 3 for timeout; timeout preserves the original task facts.
31
+
32
+ ## Redeliver · 补交付
33
+
34
+ ```bash
35
+ dsh-iris task redeliver "$IRIS_TASK_ID" --data-root "$IRIS_DATA_ROOT" --provider-config "$IRIS_PROVIDER_FILE"
36
+ ```
37
+
38
+ Recover the original result only when the Task state permits it. A complete cache can deliver offline; missing bytes may require retrieving the original remote result. Verify the original provider/model binding without switching to the current candidate pool or regenerating.
39
+
40
+ ## Cancel · 取消
41
+
42
+ ```bash
43
+ dsh-iris task cancel "$IRIS_TASK_ID" --data-root "$IRIS_DATA_ROOT" --provider-config "$IRIS_PROVIDER_FILE"
44
+ ```
45
+
46
+ Execute only when the task state and protocol permit it. Report unsupported or unconfirmed cancellation accurately instead of claiming that remote work was revoked.
47
+
48
+ ## Retry with informed authorization · 知情重试
49
+
50
+ Use this operation after the user has authorized a retry with possible duplicate billing; do not ask again for authorization already given. Supply the prompt again in the input file, and the original `source_artifact_id` for an edit:
51
+
52
+ ```bash
53
+ dsh-iris task retry "$IRIS_TASK_ID" --data-root "$IRIS_DATA_ROOT" --provider-config "$IRIS_PROVIDER_FILE" --confirm-billing --input-file "$IRIS_INPUT_FILE"
54
+ ```
55
+
56
+ This creates a new Task with `retriedFrom`, leaving the original intact. `--confirm-billing` is a standalone switch with no following value. Specify a retry model with the outer `--model-ref` flag, not the `model_ref` field used by `run image` input.
57
+
58
+ A busy root, stale lease, or mismatched binding is an execution gap. This skill does not delete leases, take over writers, migrate data, or reset configuration.