@mokuyoaxis/dsh-iris 0.1.4 → 0.2.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 (110) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
  2. package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
  3. package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
  4. package/CHANGELOG.md +99 -1
  5. package/README.en.md +124 -31
  6. package/README.md +124 -31
  7. package/bin/dsh-iris.js +593 -13
  8. package/docs/AGENT_QUICKSTART.md +39 -0
  9. package/docs/ARCHITECTURE.md +87 -64
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +60 -0
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
  15. package/docs/DSH_CORE_MIGRATION.md +135 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +38 -0
  17. package/docs/FAULT_INJECTION.md +12 -2
  18. package/docs/HEADLESS_CLI.md +223 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
  20. package/docs/HOST_DOCTOR.md +4 -2
  21. package/docs/MODEL_PORT_CONTRACT.md +305 -0
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +49 -83
  29. package/docs/SECURITY.md +8 -7
  30. package/docs/TASK_SEMANTICS.md +2 -2
  31. package/docs/VISION_CLI.md +132 -0
  32. package/docs/VISION_MODEL.md +51 -0
  33. package/docs/WORKBENCH_ARTIFACTS.md +67 -0
  34. package/docs/assets/logo/README.md +55 -0
  35. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  36. package/docs/assets/logo/iris-bubble.svg +14 -0
  37. package/docs/assets/logo/iris-interim-flower.png +0 -0
  38. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  39. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  40. package/docs/assets/logo/iris-logo.svg +78 -0
  41. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  42. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  43. package/docs/assets/logo/iris-mark.svg +42 -0
  44. package/docs/file-access-across-environments.md +8 -5
  45. package/docs/releases/0.2.0.md +48 -0
  46. package/docs/releases/0.2.1.md +56 -0
  47. package/lib/actions.js +520 -591
  48. package/lib/adapters.js +184 -36
  49. package/lib/api.js +200 -11
  50. package/lib/chromium-browser.js +117 -0
  51. package/lib/client.js +1390 -105
  52. package/lib/command-service.js +534 -0
  53. package/lib/composite-vision-routing.js +42 -0
  54. package/lib/config.js +143 -51
  55. package/lib/core-artifact-store.js +630 -0
  56. package/lib/core-artifacts.js +4 -0
  57. package/lib/core-attention.js +103 -0
  58. package/lib/core-contract.js +105 -0
  59. package/lib/core-doctor.js +41 -0
  60. package/lib/core-lease-recovery.js +170 -0
  61. package/lib/core-maintenance.js +213 -0
  62. package/lib/core-runtime.js +232 -0
  63. package/lib/core-tasks.js +567 -0
  64. package/lib/core-user-projection.js +193 -0
  65. package/lib/doctor.js +92 -33
  66. package/lib/dsh-core-adapter.js +653 -0
  67. package/lib/dsh-host-adapter.js +58 -7
  68. package/lib/dsh-text-model-adapter.js +222 -0
  69. package/lib/dsh-vision-model-adapter.js +75 -0
  70. package/lib/generation-input.js +126 -0
  71. package/lib/guard.js +3 -3
  72. package/lib/headless-vision.js +181 -0
  73. package/lib/host-runtime.js +1 -0
  74. package/lib/http-vision-model-adapter.js +187 -0
  75. package/lib/image-edit-input.js +34 -0
  76. package/lib/index.js +323 -188
  77. package/lib/locate.js +86 -28
  78. package/lib/media-probe.js +2 -1
  79. package/lib/model-call-runtime.js +45 -0
  80. package/lib/model-invoker.js +220 -0
  81. package/lib/model-port-contract.js +341 -0
  82. package/lib/models.js +105 -14
  83. package/lib/ocr-model-routing.js +26 -0
  84. package/lib/ocr.js +110 -64
  85. package/lib/prompt-optimizer-config.js +2 -1
  86. package/lib/prompt-optimizer-core.js +121 -0
  87. package/lib/prompt-optimizer.js +37 -193
  88. package/lib/provider-adapter.js +49 -23
  89. package/lib/provider-adapters.js +165 -26
  90. package/lib/provider-catalog.js +183 -0
  91. package/lib/provider-config-service.js +236 -0
  92. package/lib/provider-contract.js +74 -4
  93. package/lib/provider-health.js +80 -1
  94. package/lib/provider-protocol.js +74 -2
  95. package/lib/provider-task-runner.js +370 -0
  96. package/lib/summarize.js +31 -20
  97. package/lib/summary-input.js +52 -0
  98. package/lib/task-wait.js +40 -0
  99. package/lib/tasks.js +16 -4
  100. package/lib/video-input.js +30 -0
  101. package/lib/vision-core.js +42 -0
  102. package/lib/vision-image-input.js +83 -0
  103. package/lib/vision-input.js +31 -0
  104. package/lib/vision-model-routing.js +180 -0
  105. package/lib/vision.js +31 -9
  106. package/lib/workbench-artifacts.js +52 -0
  107. package/lib/workbench-management.js +151 -0
  108. package/lib/workbench-zip.js +39 -0
  109. package/package.json +8 -8
  110. package/user_guide.md +71 -26
@@ -0,0 +1,534 @@
1
+ 'use strict';
2
+
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ createCoreArtifact,
8
+ exportCoreArtifact,
9
+ inspectCoreArtifact,
10
+ listCoreArtifacts,
11
+ readCoreArtifactBytes,
12
+ rebuildCoreArtifactIndex
13
+ } from './core-artifacts.js';
14
+ import { inspectCoreTask, listCoreTasks } from './core-tasks.js';
15
+ import { cropImage, pixelDiff, imageDimensions, PixelError } from './pixels.js';
16
+ import { extractFrames, normalizeFramesOptions, probeVideo } from './media-probe.js';
17
+ import { hasProviderOperation } from './provider-adapter.js';
18
+ import { createProviderTaskRunner } from './provider-task-runner.js';
19
+ import { GenerationInputError, normalizeGenerationInput } from './generation-input.js';
20
+ import { videoTaskCandidates } from './video-input.js';
21
+ import { waitForCoreTask } from './task-wait.js';
22
+ import { planCoreDeletion, planCoreCleanup, quarantineCoreFiles, inspectCoreTransactions, restoreCoreTransaction } from './core-maintenance.js';
23
+
24
+ export const COMMAND_CONTRACT_VERSION = 0;
25
+ export const CORE_COMMANDS = Object.freeze([
26
+ 'crop', 'media.diff', 'media.frames', 'media.html', 'task.inspect', 'task.list', 'task.wait', 'task.observe', 'task.reobserve', 'task.redeliver', 'task.cancel', 'task.retry',
27
+ 'task.inspect-many', 'artifact.inspect', 'artifact.inspect-many', 'artifact.list', 'artifact.export', 'artifact.export-many', 'artifact.rebuild',
28
+ 'core.delete', 'core.cleanup', 'core.transactions', 'core.restore'
29
+ ]);
30
+
31
+ export class CommandError extends Error {
32
+ constructor(code, message) {
33
+ super(message);
34
+ this.name = 'CommandError';
35
+ this.code = code;
36
+ }
37
+ }
38
+
39
+ function commandError(code, message) {
40
+ return new CommandError(code, message);
41
+ }
42
+
43
+ function plain(value) {
44
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
45
+ }
46
+
47
+ function exactInput(input, allowed) {
48
+ if (!plain(input)) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'Command 输入必须是 JSON 对象');
49
+ for (const key of Object.keys(input)) {
50
+ if (!allowed.includes(key)) throw commandError('IRIS_COMMAND_INPUT_INVALID', `Command 不支持输入字段:${key}`);
51
+ }
52
+ }
53
+
54
+ function localFile(value, field, label) {
55
+ const supplied = String(value || '').trim();
56
+ if (!supplied || !path.isAbsolute(supplied)) {
57
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', field + ' 必须是宿主可读的绝对路径');
58
+ }
59
+ try {
60
+ const resolved = fs.realpathSync.native(supplied);
61
+ if (!fs.statSync(resolved).isFile()) throw new Error('not file');
62
+ return resolved;
63
+ } catch (_) {
64
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', label + '不存在或不是普通文件');
65
+ }
66
+ }
67
+
68
+ function localImage(input) {
69
+ return localFile(input.image_path, 'image_path', '图片');
70
+ }
71
+
72
+ function coordinates(input) {
73
+ const values = {};
74
+ for (const name of ['left', 'top', 'width', 'height']) {
75
+ if (typeof input[name] !== 'number' || !Number.isFinite(input[name])) {
76
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', `${name} 必须是有限数字`);
77
+ }
78
+ values[name] = input[name];
79
+ }
80
+ return values;
81
+ }
82
+
83
+ function abortError() {
84
+ const error = new Error('Core Command 已取消');
85
+ error.name = 'AbortError';
86
+ return error;
87
+ }
88
+
89
+ function idList(input, field) {
90
+ const ids = input[field];
91
+ if (!Array.isArray(ids) || !ids.length || ids.length > 200 || !ids.every(id => typeof id === 'string') || new Set(ids).size !== ids.length) {
92
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', field + ' 必须是 1–200 个不重复 ID 的数组');
93
+ }
94
+ return ids;
95
+ }
96
+
97
+ function queryInput(input, fields) {
98
+ exactInput(input, ['offset', 'limit', ...fields]);
99
+ for (const [key, value] of Object.entries(input)) {
100
+ if (['offset', 'limit'].includes(key)) {
101
+ if (!Number.isSafeInteger(value) || value < (key === 'offset' ? 0 : 1) || (key === 'limit' && value > 200)) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'offset 必须非负,limit 必须是 1–200 的整数');
102
+ } else if (typeof value !== 'string' || !value.trim()) throw commandError('IRIS_COMMAND_INPUT_INVALID', '过滤字段必须是非空字符串');
103
+ }
104
+ }
105
+
106
+ export function createCommandService(runtime, ports = {}) {
107
+ if (!runtime || typeof runtime.run !== 'function') throw new TypeError('Command Service 需要 Core Runtime');
108
+
109
+ async function execute(name, input) {
110
+ if (!CORE_COMMANDS.includes(name)) throw commandError('IRIS_COMMAND_UNKNOWN', `未知 Command:${String(name)}`);
111
+ if (name === 'core.delete' || name === 'core.cleanup') {
112
+ exactInput(input, name === 'core.delete' ? ['task_ids', 'artifact_ids', 'confirm_delete'] : ['paths', 'older_than_ms', 'confirm_delete']);
113
+ if (input.confirm_delete !== undefined && typeof input.confirm_delete !== 'boolean') throw commandError('IRIS_COMMAND_INPUT_INVALID', 'confirm_delete 必须是布尔值');
114
+ return runtime.run(input.confirm_delete === true ? 'execute' : 'inspect', ({ dataRoot, signal }) => {
115
+ if (signal.aborted) throw abortError();
116
+ let plan = name === 'core.delete' ? planCoreDeletion(dataRoot, input) : planCoreCleanup(dataRoot, input);
117
+ if (name === 'core.cleanup' && input.confirm_delete === true) {
118
+ const paths = input.paths;
119
+ if (!Array.isArray(paths) || !paths.length || paths.length > 200 || !paths.every(file => typeof file === 'string') || new Set(paths).size !== paths.length) throw commandError('IRIS_COMMAND_INPUT_INVALID', '执行清理必须明确指定 1–200 个预览中的 paths');
120
+ const entries = paths.map(file => plan.candidates.find(entry => entry.path === file));
121
+ if (entries.some(entry => !entry)) throw commandError('IRIS_CORE_CLEANUP_CHANGED', '指定路径不在当前可清理预览中');
122
+ plan = { operation: 'cleanup', allowed: true, entries };
123
+ }
124
+ const result = input.confirm_delete === true ? quarantineCoreFiles(dataRoot, plan) : { preview: true, ...plan };
125
+ return { contractVersion: COMMAND_CONTRACT_VERSION, command: name, ...result };
126
+ });
127
+ }
128
+ if (name === 'core.transactions') {
129
+ exactInput(input, []);
130
+ return runtime.run('inspect', ({ dataRoot }) => ({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, transactions: inspectCoreTransactions(dataRoot) }));
131
+ }
132
+ if (name === 'core.restore') {
133
+ exactInput(input, ['transaction_id']);
134
+ return runtime.run('execute', ({ dataRoot }) => ({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, ...restoreCoreTransaction(dataRoot, input.transaction_id) }));
135
+ }
136
+ if (name === 'media.html') {
137
+ exactInput(input, ['html', 'width', 'height', 'full_page']);
138
+ if (typeof input.html !== 'string' || !input.html.trim() || Buffer.byteLength(input.html) > 1024 * 1024) {
139
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'html 必须是非空字符串,最多 1 MiB');
140
+ }
141
+ const width = input.width ?? 1280, height = input.height ?? 720;
142
+ if (![width, height].every(value => Number.isSafeInteger(value) && value > 0 && value <= 4096)
143
+ || (input.full_page !== undefined && typeof input.full_page !== 'boolean')) {
144
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'width/height 必须是 1–4096 的整数,full_page 必须是布尔值');
145
+ }
146
+ if (typeof ports.browser?.renderHtml !== 'function') throw commandError('IRIS_COMMAND_BROWSER_REQUIRED', 'HTML 截图需要 Browser Port');
147
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
148
+ const rendered = await ports.browser.renderHtml({ html: input.html, width, height, fullPage: input.full_page !== false, signal });
149
+ if (signal.aborted) throw abortError();
150
+ const bytes = Buffer.from(rendered.bytes);
151
+ if (rendered.mediaType !== 'image/png' || !bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
152
+ throw commandError('IRIS_COMMAND_PROCESSING_FAILED', '浏览器没有返回 PNG 截图');
153
+ }
154
+ const dimensions = await imageDimensions(bytes);
155
+ if (signal.aborted) throw abortError();
156
+ const artifact = createCoreArtifact(dataRoot, { bytes, mediaType: 'image/png', kind: 'html-screenshot', metadata: dimensions });
157
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, artifact });
158
+ });
159
+ }
160
+ if (name === 'crop') {
161
+ exactInput(input, ['image_path', 'artifact_id', 'left', 'top', 'width', 'height']);
162
+ const hasPath = Boolean(String(input.image_path || '').trim());
163
+ const hasArtifact = Boolean(String(input.artifact_id || '').trim());
164
+ if (hasPath === hasArtifact) {
165
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'crop 必须且只能提供 image_path 或 artifact_id 之一');
166
+ }
167
+ const localPath = hasPath ? localImage(input) : null;
168
+ const region = coordinates(input);
169
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
170
+ if (signal.aborted) throw abortError();
171
+ const source = hasArtifact ? readCoreArtifactBytes(dataRoot, input.artifact_id) : null;
172
+ const image = source ? source.bytes : localPath;
173
+ let cropped;
174
+ try { cropped = await cropImage({ input: image, ...region }); }
175
+ catch (error) {
176
+ if (error instanceof PixelError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
177
+ throw commandError('IRIS_COMMAND_PROCESSING_FAILED', '图片无法裁剪;请确认格式受支持且文件可读取');
178
+ }
179
+ if (signal.aborted) throw abortError();
180
+ const artifact = createCoreArtifact(dataRoot, {
181
+ bytes: cropped.buffer,
182
+ mediaType: cropped.mime,
183
+ kind: 'crop',
184
+ metadata: { width: cropped.width, height: cropped.height },
185
+ relations: source ? [{ type: 'derived-from', artifactId: source.artifact.id }] : []
186
+ });
187
+ return Object.freeze({
188
+ contractVersion: COMMAND_CONTRACT_VERSION,
189
+ command: 'crop',
190
+ artifact
191
+ });
192
+ });
193
+ }
194
+ if (name === 'media.diff') {
195
+ exactInput(input, [
196
+ 'image_a_path', 'image_a_artifact_id', 'image_b_path', 'image_b_artifact_id',
197
+ 'grid', 'top_regions'
198
+ ]);
199
+ const aPathSupplied = Boolean(String(input.image_a_path || '').trim());
200
+ const aArtifactSupplied = Boolean(String(input.image_a_artifact_id || '').trim());
201
+ const bPathSupplied = Boolean(String(input.image_b_path || '').trim());
202
+ const bArtifactSupplied = Boolean(String(input.image_b_artifact_id || '').trim());
203
+ if (aPathSupplied === aArtifactSupplied || bPathSupplied === bArtifactSupplied) {
204
+ throw commandError('IRIS_COMMAND_INPUT_INVALID',
205
+ 'media.diff 的 A/B 两侧都必须且只能提供本地路径或 Artifact ID 之一');
206
+ }
207
+ const imageAPath = aPathSupplied
208
+ ? localFile(input.image_a_path, 'image_a_path', '图片 A') : null;
209
+ const imageBPath = bPathSupplied
210
+ ? localFile(input.image_b_path, 'image_b_path', '图片 B') : null;
211
+ const grid = input.grid === undefined ? 8 : Number(input.grid);
212
+ const topRegions = input.top_regions === undefined ? 3 : Number(input.top_regions);
213
+ if (!Number.isSafeInteger(grid) || grid < 1 || grid > 64
214
+ || !Number.isSafeInteger(topRegions) || topRegions < 1 || topRegions > 100) {
215
+ throw commandError('IRIS_COMMAND_INPUT_INVALID',
216
+ 'grid 必须是 1–64 的整数,top_regions 必须是 1–100 的整数');
217
+ }
218
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
219
+ if (signal.aborted) throw abortError();
220
+ const sourceA = aArtifactSupplied
221
+ ? readCoreArtifactBytes(dataRoot, input.image_a_artifact_id) : null;
222
+ const sourceB = bArtifactSupplied
223
+ ? readCoreArtifactBytes(dataRoot, input.image_b_artifact_id) : null;
224
+ for (const source of [sourceA, sourceB]) {
225
+ if (source && !source.artifact.mediaType.startsWith('image/')) {
226
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'media.diff 的 Artifact 输入必须是图片');
227
+ }
228
+ }
229
+ let diff;
230
+ try {
231
+ diff = await pixelDiff({
232
+ inputA: sourceA ? sourceA.bytes : imageAPath,
233
+ inputB: sourceB ? sourceB.bytes : imageBPath,
234
+ grid,
235
+ topRegions
236
+ });
237
+ } catch (error) {
238
+ if (signal.aborted) throw abortError();
239
+ if (error instanceof PixelError) {
240
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
241
+ }
242
+ throw commandError('IRIS_COMMAND_PROCESSING_FAILED',
243
+ '图片差异分析失败;请确认格式受支持且文件可读取');
244
+ }
245
+ if (signal.aborted) throw abortError();
246
+ const sourceIds = [...new Set([sourceA?.artifact.id, sourceB?.artifact.id].filter(Boolean))];
247
+ const metrics = Object.freeze({
248
+ ratio: diff.ratio,
249
+ diffPixels: diff.diffPixels,
250
+ totalPixels: diff.totalPixels,
251
+ width: diff.width,
252
+ height: diff.height,
253
+ worstRegions: Object.freeze(diff.worstRegions.map((region) => Object.freeze({ ...region })))
254
+ });
255
+ const artifact = createCoreArtifact(dataRoot, {
256
+ bytes: diff.heatmap,
257
+ mediaType: diff.mime,
258
+ kind: 'pixel-diff',
259
+ metadata: { ...metrics, worstRegions: diff.worstRegions },
260
+ relations: sourceIds.map((artifactId) => ({ type: 'derived-from', artifactId }))
261
+ });
262
+ return Object.freeze({
263
+ contractVersion: COMMAND_CONTRACT_VERSION,
264
+ command: name,
265
+ metrics,
266
+ artifact
267
+ });
268
+ });
269
+ }
270
+ if (name === 'media.frames') {
271
+ exactInput(input, ['video_path', 'artifact_id', 'max_frames', 'target_width', 'quality', 'format']);
272
+ const hasPath = Boolean(String(input.video_path || '').trim());
273
+ const hasArtifact = Boolean(String(input.artifact_id || '').trim());
274
+ if (hasPath === hasArtifact) {
275
+ throw commandError('IRIS_COMMAND_INPUT_INVALID',
276
+ 'media.frames 必须且只能提供 video_path 或 artifact_id 之一');
277
+ }
278
+ const videoPath = hasPath ? localFile(input.video_path, 'video_path', '视频') : null;
279
+ let frameOptions;
280
+ try {
281
+ frameOptions = normalizeFramesOptions({
282
+ ...(input.max_frames !== undefined ? { maxFrames: input.max_frames } : {}),
283
+ ...(input.target_width !== undefined ? { targetWidth: input.target_width } : {}),
284
+ ...(input.quality !== undefined ? { quality: input.quality } : {}),
285
+ ...(input.format !== undefined ? { format: input.format } : {})
286
+ });
287
+ } catch (error) {
288
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
289
+ }
290
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
291
+ if (signal.aborted) throw abortError();
292
+ const source = hasArtifact ? readCoreArtifactBytes(dataRoot, input.artifact_id) : null;
293
+ if (source && !source.artifact.mediaType.startsWith('video/')) {
294
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'media.frames 的 Artifact 输入必须是视频');
295
+ }
296
+ let temporaryDirectory;
297
+ let readablePath = videoPath;
298
+ if (source) {
299
+ try {
300
+ temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'iris-command-frames-'));
301
+ readablePath = path.join(temporaryDirectory, 'source.mp4');
302
+ fs.writeFileSync(readablePath, source.bytes, { mode: 0o600 });
303
+ } catch (_) {
304
+ if (temporaryDirectory) {
305
+ try { fs.rmSync(temporaryDirectory, { recursive: true, force: true }); } catch (_) { /* 临时副本 */ }
306
+ }
307
+ throw commandError('IRIS_COMMAND_PROCESSING_FAILED', '视频临时副本无法安全写入');
308
+ }
309
+ }
310
+ let media;
311
+ let frames;
312
+ try {
313
+ media = probeVideo(readablePath);
314
+ frames = await extractFrames({ inputPath: readablePath, ...frameOptions, signal });
315
+ } catch (error) {
316
+ if (signal.aborted) throw abortError();
317
+ throw commandError('IRIS_COMMAND_PROCESSING_FAILED',
318
+ '视频抽帧失败;请确认 ffmpeg 可用且文件格式受支持');
319
+ } finally {
320
+ if (temporaryDirectory) {
321
+ try { fs.rmSync(temporaryDirectory, { recursive: true, force: true }); } catch (_) { /* 临时副本 */ }
322
+ }
323
+ }
324
+ if (signal.aborted) throw abortError();
325
+ const relations = source ? [{ type: 'frame-of', artifactId: source.artifact.id }] : [];
326
+ const mediaType = frameOptions.format === 'png' ? 'image/png' : 'image/jpeg';
327
+ const artifacts = frames.map((frame, index) => createCoreArtifact(dataRoot, {
328
+ bytes: frame.buffer,
329
+ mediaType,
330
+ kind: 'video-frame',
331
+ metadata: {
332
+ frameIndex: index + 1,
333
+ atSec: frame.atSec,
334
+ width: frame.width,
335
+ height: frame.height
336
+ },
337
+ relations
338
+ }));
339
+ return Object.freeze({
340
+ contractVersion: COMMAND_CONTRACT_VERSION,
341
+ command: name,
342
+ media: Object.freeze({ ...media }),
343
+ artifacts: Object.freeze(artifacts)
344
+ });
345
+ });
346
+ }
347
+ if (name === 'task.wait') {
348
+ exactInput(input, ['task_id', 'timeout_ms', 'poll_interval_ms']);
349
+ const result = await waitForCoreTask(runtime, input.task_id, { timeoutMs: input.timeout_ms, pollIntervalMs: input.poll_interval_ms,
350
+ ...(typeof ports.resolveTaskAdapter === 'function' ? { observe: () => execute('task.observe', { task_id: input.task_id }) } : {}) });
351
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, ...result });
352
+ }
353
+ // task.reobserve 是人工触发观察的对外命令名(DSH API/工作台),与 task.observe
354
+ // 共用同一份门与单步 poll 实现;语义不区分,绝不 submit。
355
+ if (name === 'task.observe' || name === 'task.reobserve') {
356
+ exactInput(input, ['task_id']);
357
+ return runtime.run('execute', async ({ dataRoot }) => {
358
+ const before = inspectCoreTask(dataRoot, input.task_id);
359
+ if (before.acceptance !== 'accepted' || !before.remoteTaskId || !['none', 'unknown'].includes(before.outcome)) throw commandError('IRIS_TASK_NOT_OBSERVABLE', 'Task 没有可观察的远端受理事实,或已终止;不会重新提交');
360
+ if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.observe 需要宿主显式提供 Task Provider resolver');
361
+ const adapter = await ports.resolveTaskAdapter(before);
362
+ if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
363
+ if (!hasProviderOperation(adapter, 'poll')) throw commandError('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', 'Task 原 Provider 不支持 poll;不会重新提交');
364
+ const task = await createProviderTaskRunner(runtime).observe(input.task_id, adapter, { resumeActive: true });
365
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
366
+ });
367
+ }
368
+ // task.redeliver:只对 outcome=succeeded / deliveryState=failed 的任务重新取回
369
+ // 已生成的远端产物(re-poll 拿产物清单,绝不 submit,也不会重新生成)。
370
+ if (name === 'task.redeliver') {
371
+ exactInput(input, ['task_id']);
372
+ return runtime.run('execute', async ({ dataRoot }) => {
373
+ const before = inspectCoreTask(dataRoot, input.task_id);
374
+ if (before.outcome !== 'succeeded' || before.deliveryState !== 'failed' || !before.remoteTaskId) throw commandError('IRIS_TASK_NOT_REDELIVERABLE', 'Task 不处于可重新交付状态;不会重新生成');
375
+ if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.redeliver 需要宿主显式提供 Task Provider resolver');
376
+ const adapter = await ports.resolveTaskAdapter(before);
377
+ if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
378
+ if (!hasProviderOperation(adapter, 'poll')) throw commandError('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', 'Task 原 Provider 不支持 poll;不会重新生成');
379
+ const task = await createProviderTaskRunner(runtime).redeliver(input.task_id, adapter);
380
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
381
+ });
382
+ }
383
+ // task.cancel:只对已受理、有远端 ID、结果未定论且非终态的任务开放。
384
+ // 只有 Provider 明确确认才写 outcome=canceled;不支持或无法确认时保持真实状态。
385
+ if (name === 'task.cancel') {
386
+ exactInput(input, ['task_id']);
387
+ return runtime.run('execute', async ({ dataRoot }) => {
388
+ const before = inspectCoreTask(dataRoot, input.task_id);
389
+ if (before.acceptance !== 'accepted' || !before.remoteTaskId
390
+ || !['none', 'unknown'].includes(before.outcome) || before.phase === 'terminal') {
391
+ throw commandError('IRIS_TASK_NOT_CANCELABLE', 'Task 缺少可取消的远端受理事实,或已终止');
392
+ }
393
+ if (before.cancelState !== 'none') {
394
+ throw commandError('IRIS_TASK_CANCEL_ALREADY_REQUESTED', '该 Task 已请求过取消;请显式重新观察以确认远端真实状态');
395
+ }
396
+ if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.cancel 需要宿主显式提供 Task Provider resolver');
397
+ const adapter = await ports.resolveTaskAdapter(before);
398
+ if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
399
+ const task = await createProviderTaskRunner(runtime).cancel(input.task_id, adapter);
400
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
401
+ });
402
+ }
403
+ // task.retry:D4 重试为新任务。产生新的真实计费,必须显式 confirm_billing;
404
+ // 只对终态且未成功交付的 Task 开放;新 Task 有全新 id/attempts/binding 并记录
405
+ // 单向 retriedFrom 关系,旧 Task 不动;Core 不持久化 Prompt,prompt 必须由
406
+ // 调用方重新提供(绝不从旧记录"恢复")。
407
+ if (name === 'task.retry') {
408
+ exactInput(input, ['task_id', 'provider_input', 'model_ref', 'confirm_billing']);
409
+ if (input.confirm_billing !== true) {
410
+ throw commandError('IRIS_COMMAND_BILLING_CONFIRM_REQUIRED', '重试会创建新任务并可能产生重复生成费用;必须显式确认计费');
411
+ }
412
+ return runtime.run('execute', async ({ dataRoot }) => {
413
+ const before = inspectCoreTask(dataRoot, input.task_id);
414
+ if (before.phase !== 'terminal' || (before.outcome === 'succeeded' && before.deliveryState === 'ready')) {
415
+ throw commandError('IRIS_TASK_NOT_RETRYABLE', '只有终态且未成功交付的 Task 可以重试为新任务');
416
+ }
417
+ let normalized;
418
+ try { normalized = normalizeGenerationInput(before.capability, input.provider_input, { allowVideoPaths: before.capability === 'video' }); }
419
+ catch (error) {
420
+ if (error instanceof GenerationInputError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
421
+ throw error;
422
+ }
423
+ const providerInput = normalized.providerInput;
424
+ if (before.sourceArtifactId && !normalized.sourceArtifactId) {
425
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', '改图重试必须重新提供 source_artifact_id 和修改指令');
426
+ }
427
+ if (Object.hasOwn(input.provider_input, 'model_ref')) {
428
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'retry 的模型覆盖请使用外层 model_ref');
429
+ }
430
+ const modelRef = String(input.model_ref || '').trim();
431
+ if (input.model_ref !== undefined && !modelRef) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'model_ref 必须是 providerId::modelId 复合引用');
432
+ if (typeof ports.resolveTaskCandidates !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.retry 需要宿主显式提供候选链 resolver');
433
+ let candidates = await ports.resolveTaskCandidates({ capability: before.capability, modelRef });
434
+ if (before.capability === 'video') candidates = videoTaskCandidates(candidates, normalized);
435
+ const result = await createProviderTaskRunner(runtime).submit({
436
+ capability: before.capability,
437
+ candidates,
438
+ providerInput,
439
+ sourceArtifactId: normalized.sourceArtifactId,
440
+ retriedFrom: before.id
441
+ });
442
+ return Object.freeze({
443
+ contractVersion: COMMAND_CONTRACT_VERSION,
444
+ command: name,
445
+ taskId: result.taskId,
446
+ retriedFrom: before.id,
447
+ task: result.task,
448
+ ...(result.localError ? { localError: result.localError } : {})
449
+ });
450
+ });
451
+ }
452
+ if (name === 'task.inspect') {
453
+ exactInput(input, ['task_id']);
454
+ return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
455
+ contractVersion: COMMAND_CONTRACT_VERSION,
456
+ command: name,
457
+ task: inspectCoreTask(dataRoot, input.task_id)
458
+ }));
459
+ }
460
+ if (name === 'task.list') {
461
+ queryInput(input, ['status', 'capability', 'provider_id', 'model_ref', 'outcome', 'delivery_state']);
462
+ return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
463
+ contractVersion: COMMAND_CONTRACT_VERSION,
464
+ command: name,
465
+ ...listCoreTasks(dataRoot, input)
466
+ }));
467
+ }
468
+ if (name === 'artifact.inspect') {
469
+ exactInput(input, ['artifact_id']);
470
+ return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
471
+ contractVersion: COMMAND_CONTRACT_VERSION,
472
+ command: name,
473
+ artifact: inspectCoreArtifact(dataRoot, input.artifact_id)
474
+ }));
475
+ }
476
+ if (name === 'artifact.list') {
477
+ queryInput(input, ['kind', 'media_type', 'task_id']);
478
+ return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
479
+ contractVersion: COMMAND_CONTRACT_VERSION,
480
+ command: name,
481
+ ...listCoreArtifacts(dataRoot, input)
482
+ }));
483
+ }
484
+ if (name === 'artifact.rebuild') {
485
+ exactInput(input, []);
486
+ return runtime.run('recover', ({ dataRoot }) => Object.freeze({
487
+ contractVersion: COMMAND_CONTRACT_VERSION,
488
+ command: name,
489
+ ...rebuildCoreArtifactIndex(dataRoot)
490
+ }));
491
+ }
492
+ if (name === 'task.inspect-many' || name === 'artifact.inspect-many') {
493
+ const task = name.startsWith('task.'), field = task ? 'task_ids' : 'artifact_ids';
494
+ exactInput(input, [field]);
495
+ const ids = idList(input, field);
496
+ return runtime.run('inspect', ({ dataRoot }) => ({ contractVersion: COMMAND_CONTRACT_VERSION, command: name,
497
+ results: ids.map(id => {
498
+ try { return { id, [task ? 'task' : 'artifact']: task ? inspectCoreTask(dataRoot, id) : inspectCoreArtifact(dataRoot, id) }; }
499
+ catch (error) { return { id, error: { code: error.code || 'IRIS_COMMAND_FAILED' } }; }
500
+ }) }));
501
+ }
502
+ if (name === 'artifact.export-many') {
503
+ exactInput(input, ['artifact_ids', 'output_directory']);
504
+ const ids = idList(input, 'artifact_ids');
505
+ const directory = input.output_directory;
506
+ if (typeof directory !== 'string' || !path.isAbsolute(directory) || !fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
507
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'output_directory 必须是已存在的绝对目录');
508
+ }
509
+ return runtime.run('execute', ({ dataRoot, signal }) => {
510
+ const extensions = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif', 'video/mp4': '.mp4', 'video/webm': '.webm', 'audio/wav': '.wav', 'audio/mpeg': '.mp3', 'audio/ogg': '.ogg', 'audio/flac': '.flac', 'text/plain': '.txt', 'application/json': '.json' };
511
+ const outputs = ids.map(id => {
512
+ const artifact = inspectCoreArtifact(dataRoot, id);
513
+ const file = path.join(directory, id + (extensions[artifact.mediaType] || '.bin'));
514
+ if (fs.existsSync(file)) throw commandError('IRIS_ARTIFACT_EXPORT_EXISTS', '批量导出目标已存在;未导出任何文件');
515
+ return { id, file };
516
+ });
517
+ const results = outputs.map(({ id, file }) => {
518
+ if (signal.aborted) return { id, exported: false, error: { code: 'IRIS_COMMAND_ABORTED' } };
519
+ try { return { id, ...exportCoreArtifact(dataRoot, id, file) }; }
520
+ catch (error) { return { id, exported: false, error: { code: error.code || 'IRIS_ARTIFACT_EXPORT_FAILED' } }; }
521
+ });
522
+ return { contractVersion: COMMAND_CONTRACT_VERSION, command: name, results };
523
+ });
524
+ }
525
+ exactInput(input, ['artifact_id', 'output_path']);
526
+ return runtime.run('execute', ({ dataRoot }) => Object.freeze({
527
+ contractVersion: COMMAND_CONTRACT_VERSION,
528
+ command: name,
529
+ ...exportCoreArtifact(dataRoot, input.artifact_id, input.output_path)
530
+ }));
531
+ }
532
+
533
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, commands: CORE_COMMANDS, execute });
534
+ }
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+ /** Host 选型留边界,定位/拼图只消费共享模型端口;准备也属于本次预算。 */
3
+ import sharp from 'sharp';
4
+ import { runVisionOperation, runVisionRequest } from './vision-model-routing.js';
5
+ import { locateObject, normalizeLocateTarget, createLocateImageRequest } from './locate.js';
6
+ import { summarizeMedia } from './summarize.js';
7
+ import { VISION_BUDGET } from './vision-core.js';
8
+ import { normalizeModelCall } from './model-port-contract.js';
9
+ import { waitForModelWork } from './model-call-runtime.js';
10
+
11
+ export async function runLocateRequest(host, { providers = [], model, target, image, prepareImage, signal, budget = VISION_BUDGET, onRateLimit }) {
12
+ target = normalizeLocateTarget(target);
13
+ const inputs = new Map();
14
+ const result = await runVisionOperation(host, { providers, model, signal, budget, onRateLimit, prepareImages: true,
15
+ orientImages: true, prepareImageRequest: createLocateImageRequest(target, inputs) }, async (ports, operation) => {
16
+ const resolved = await waitForModelWork(() => prepareImage ? prepareImage(operation.signal) : image, operation.signal);
17
+ const source = normalizeModelCall(ports[0].describe(), { prompt: target, image: resolved }, { budget }).request.image;
18
+ const { width, height } = await waitForModelWork(() => sharp(Buffer.from(source.bytes)).metadata(), operation.signal);
19
+ const completion = await locateObject(ports, { target, image: source, width, height }, { operation, budget });
20
+ return { completion, ...completion.bbox, width, height, input: inputs.get(completion.identity.backendId) };
21
+ });
22
+ const { answer: _answer, ...location } = result;
23
+ return location;
24
+ }
25
+
26
+ export async function runSummaryRequest(host, { providers = [], model, prepareMedia, frames, question, transcript, signal, budget = VISION_BUDGET, onRateLimit }) {
27
+ return runVisionOperation(host, { providers, model, signal, budget, onRateLimit }, async (ports, operation) => {
28
+ const media = await waitForModelWork(() => prepareMedia ? prepareMedia(operation.signal) : { frames, transcript }, operation.signal);
29
+ const completion = await summarizeMedia({ ports, frames: media.frames, question, transcript: media.transcript,
30
+ operation, budget });
31
+ return { completion, sheet: completion.sheet, frames: media.frames, meta: media.meta, transcriptNote: media.transcriptNote || '' };
32
+ });
33
+ }
34
+
35
+ /** 增强描述沿用共享调用;失败不改变已经完成的媒体交付。 */
36
+ export async function describeGeneratedImage(host, { providers = [], originalPrompt, image, prepareImage, signal, onRateLimit }) {
37
+ try {
38
+ const result = await runVisionRequest(host, { providers, image, prepareImage, signal, onRateLimit,
39
+ question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」` });
40
+ return result.answer.slice(0, 300);
41
+ } catch (_) { return ''; }
42
+ }