@mokuyoaxis/dsh-iris 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.en.md +72 -15
  3. package/README.md +72 -15
  4. package/bin/dsh-iris.js +392 -13
  5. package/docs/AGENT_QUICKSTART.md +35 -0
  6. package/docs/ARCHITECTURE.md +80 -64
  7. package/docs/ARTIFACT_MANIFEST.md +60 -0
  8. package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
  9. package/docs/DSH_CORE_MIGRATION.md +106 -0
  10. package/docs/DSH_RC2_ADAPTATION.md +36 -0
  11. package/docs/HEADLESS_CLI.md +217 -0
  12. package/docs/HOST_ADAPTER_CONTRACT.md +31 -12
  13. package/docs/HOST_DOCTOR.md +2 -0
  14. package/docs/MODEL_PORT_CONTRACT.md +300 -0
  15. package/docs/PROVIDER_ADAPTER_CONTRACT.md +51 -10
  16. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  17. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +1 -1
  18. package/docs/ROADMAP.md +42 -86
  19. package/docs/SECURITY.md +4 -3
  20. package/docs/assets/logo/README.md +55 -0
  21. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  22. package/docs/assets/logo/iris-bubble.svg +14 -0
  23. package/docs/assets/logo/iris-interim-flower.png +0 -0
  24. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  25. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  26. package/docs/assets/logo/iris-logo.svg +78 -0
  27. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  28. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  29. package/docs/assets/logo/iris-mark.svg +42 -0
  30. package/docs/releases/0.2.0.md +48 -0
  31. package/lib/actions.js +374 -362
  32. package/lib/adapters.js +52 -15
  33. package/lib/api.js +152 -5
  34. package/lib/client.js +763 -48
  35. package/lib/command-service.js +420 -0
  36. package/lib/config.js +56 -43
  37. package/lib/core-artifact-store.js +598 -0
  38. package/lib/core-artifacts.js +4 -0
  39. package/lib/core-attention.js +103 -0
  40. package/lib/core-contract.js +105 -0
  41. package/lib/core-doctor.js +41 -0
  42. package/lib/core-lease-recovery.js +170 -0
  43. package/lib/core-runtime.js +232 -0
  44. package/lib/core-tasks.js +560 -0
  45. package/lib/core-user-projection.js +192 -0
  46. package/lib/doctor.js +92 -33
  47. package/lib/dsh-core-adapter.js +599 -0
  48. package/lib/dsh-host-adapter.js +58 -7
  49. package/lib/generation-input.js +95 -0
  50. package/lib/guard.js +3 -3
  51. package/lib/index.js +159 -54
  52. package/lib/model-invoker.js +218 -0
  53. package/lib/model-port-contract.js +333 -0
  54. package/lib/models.js +97 -11
  55. package/lib/provider-adapter.js +49 -23
  56. package/lib/provider-adapters.js +84 -21
  57. package/lib/provider-catalog.js +171 -0
  58. package/lib/provider-contract.js +72 -3
  59. package/lib/provider-protocol.js +51 -2
  60. package/lib/provider-task-runner.js +328 -0
  61. package/lib/tasks.js +16 -4
  62. package/lib/vision.js +28 -7
  63. package/package.json +8 -8
  64. package/user_guide.md +27 -12
@@ -0,0 +1,420 @@
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, 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
+
21
+ export const COMMAND_CONTRACT_VERSION = 0;
22
+ export const CORE_COMMANDS = Object.freeze([
23
+ 'crop', 'media.diff', 'media.frames', 'task.inspect', 'task.list', 'task.observe', 'task.reobserve', 'task.redeliver', 'task.cancel', 'task.retry',
24
+ 'artifact.inspect', 'artifact.list', 'artifact.export', 'artifact.rebuild'
25
+ ]);
26
+
27
+ export class CommandError extends Error {
28
+ constructor(code, message) {
29
+ super(message);
30
+ this.name = 'CommandError';
31
+ this.code = code;
32
+ }
33
+ }
34
+
35
+ function commandError(code, message) {
36
+ return new CommandError(code, message);
37
+ }
38
+
39
+ function plain(value) {
40
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
41
+ }
42
+
43
+ function exactInput(input, allowed) {
44
+ if (!plain(input)) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'Command 输入必须是 JSON 对象');
45
+ for (const key of Object.keys(input)) {
46
+ if (!allowed.includes(key)) throw commandError('IRIS_COMMAND_INPUT_INVALID', `Command 不支持输入字段:${key}`);
47
+ }
48
+ }
49
+
50
+ function localFile(value, field, label) {
51
+ const supplied = String(value || '').trim();
52
+ if (!supplied || !path.isAbsolute(supplied)) {
53
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', field + ' 必须是宿主可读的绝对路径');
54
+ }
55
+ try {
56
+ const resolved = fs.realpathSync.native(supplied);
57
+ if (!fs.statSync(resolved).isFile()) throw new Error('not file');
58
+ return resolved;
59
+ } catch (_) {
60
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', label + '不存在或不是普通文件');
61
+ }
62
+ }
63
+
64
+ function localImage(input) {
65
+ return localFile(input.image_path, 'image_path', '图片');
66
+ }
67
+
68
+ function coordinates(input) {
69
+ const values = {};
70
+ for (const name of ['left', 'top', 'width', 'height']) {
71
+ if (typeof input[name] !== 'number' || !Number.isFinite(input[name])) {
72
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', `${name} 必须是有限数字`);
73
+ }
74
+ values[name] = input[name];
75
+ }
76
+ return values;
77
+ }
78
+
79
+ function abortError() {
80
+ const error = new Error('Core Command 已取消');
81
+ error.name = 'AbortError';
82
+ return error;
83
+ }
84
+
85
+ export function createCommandService(runtime, ports = {}) {
86
+ if (!runtime || typeof runtime.run !== 'function') throw new TypeError('Command Service 需要 Core Runtime');
87
+
88
+ async function execute(name, input) {
89
+ if (!CORE_COMMANDS.includes(name)) throw commandError('IRIS_COMMAND_UNKNOWN', `未知 Command:${String(name)}`);
90
+ if (name === 'crop') {
91
+ exactInput(input, ['image_path', 'artifact_id', 'left', 'top', 'width', 'height']);
92
+ const hasPath = Boolean(String(input.image_path || '').trim());
93
+ const hasArtifact = Boolean(String(input.artifact_id || '').trim());
94
+ if (hasPath === hasArtifact) {
95
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'crop 必须且只能提供 image_path 或 artifact_id 之一');
96
+ }
97
+ const localPath = hasPath ? localImage(input) : null;
98
+ const region = coordinates(input);
99
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
100
+ if (signal.aborted) throw abortError();
101
+ const source = hasArtifact ? readCoreArtifactBytes(dataRoot, input.artifact_id) : null;
102
+ const image = source ? source.bytes : localPath;
103
+ let cropped;
104
+ try { cropped = await cropImage({ input: image, ...region }); }
105
+ catch (error) {
106
+ if (error instanceof PixelError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
107
+ throw commandError('IRIS_COMMAND_PROCESSING_FAILED', '图片无法裁剪;请确认格式受支持且文件可读取');
108
+ }
109
+ if (signal.aborted) throw abortError();
110
+ const artifact = createCoreArtifact(dataRoot, {
111
+ bytes: cropped.buffer,
112
+ mediaType: cropped.mime,
113
+ kind: 'crop',
114
+ metadata: { width: cropped.width, height: cropped.height },
115
+ relations: source ? [{ type: 'derived-from', artifactId: source.artifact.id }] : []
116
+ });
117
+ return Object.freeze({
118
+ contractVersion: COMMAND_CONTRACT_VERSION,
119
+ command: 'crop',
120
+ artifact
121
+ });
122
+ });
123
+ }
124
+ if (name === 'media.diff') {
125
+ exactInput(input, [
126
+ 'image_a_path', 'image_a_artifact_id', 'image_b_path', 'image_b_artifact_id',
127
+ 'grid', 'top_regions'
128
+ ]);
129
+ const aPathSupplied = Boolean(String(input.image_a_path || '').trim());
130
+ const aArtifactSupplied = Boolean(String(input.image_a_artifact_id || '').trim());
131
+ const bPathSupplied = Boolean(String(input.image_b_path || '').trim());
132
+ const bArtifactSupplied = Boolean(String(input.image_b_artifact_id || '').trim());
133
+ if (aPathSupplied === aArtifactSupplied || bPathSupplied === bArtifactSupplied) {
134
+ throw commandError('IRIS_COMMAND_INPUT_INVALID',
135
+ 'media.diff 的 A/B 两侧都必须且只能提供本地路径或 Artifact ID 之一');
136
+ }
137
+ const imageAPath = aPathSupplied
138
+ ? localFile(input.image_a_path, 'image_a_path', '图片 A') : null;
139
+ const imageBPath = bPathSupplied
140
+ ? localFile(input.image_b_path, 'image_b_path', '图片 B') : null;
141
+ const grid = input.grid === undefined ? 8 : Number(input.grid);
142
+ const topRegions = input.top_regions === undefined ? 3 : Number(input.top_regions);
143
+ if (!Number.isSafeInteger(grid) || grid < 1 || grid > 64
144
+ || !Number.isSafeInteger(topRegions) || topRegions < 1 || topRegions > 100) {
145
+ throw commandError('IRIS_COMMAND_INPUT_INVALID',
146
+ 'grid 必须是 1–64 的整数,top_regions 必须是 1–100 的整数');
147
+ }
148
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
149
+ if (signal.aborted) throw abortError();
150
+ const sourceA = aArtifactSupplied
151
+ ? readCoreArtifactBytes(dataRoot, input.image_a_artifact_id) : null;
152
+ const sourceB = bArtifactSupplied
153
+ ? readCoreArtifactBytes(dataRoot, input.image_b_artifact_id) : null;
154
+ for (const source of [sourceA, sourceB]) {
155
+ if (source && !source.artifact.mediaType.startsWith('image/')) {
156
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'media.diff 的 Artifact 输入必须是图片');
157
+ }
158
+ }
159
+ let diff;
160
+ try {
161
+ diff = await pixelDiff({
162
+ inputA: sourceA ? sourceA.bytes : imageAPath,
163
+ inputB: sourceB ? sourceB.bytes : imageBPath,
164
+ grid,
165
+ topRegions
166
+ });
167
+ } catch (error) {
168
+ if (signal.aborted) throw abortError();
169
+ if (error instanceof PixelError) {
170
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
171
+ }
172
+ throw commandError('IRIS_COMMAND_PROCESSING_FAILED',
173
+ '图片差异分析失败;请确认格式受支持且文件可读取');
174
+ }
175
+ if (signal.aborted) throw abortError();
176
+ const sourceIds = [...new Set([sourceA?.artifact.id, sourceB?.artifact.id].filter(Boolean))];
177
+ const metrics = Object.freeze({
178
+ ratio: diff.ratio,
179
+ diffPixels: diff.diffPixels,
180
+ totalPixels: diff.totalPixels,
181
+ width: diff.width,
182
+ height: diff.height,
183
+ worstRegions: Object.freeze(diff.worstRegions.map((region) => Object.freeze({ ...region })))
184
+ });
185
+ const artifact = createCoreArtifact(dataRoot, {
186
+ bytes: diff.heatmap,
187
+ mediaType: diff.mime,
188
+ kind: 'pixel-diff',
189
+ metadata: { ...metrics, worstRegions: diff.worstRegions },
190
+ relations: sourceIds.map((artifactId) => ({ type: 'derived-from', artifactId }))
191
+ });
192
+ return Object.freeze({
193
+ contractVersion: COMMAND_CONTRACT_VERSION,
194
+ command: name,
195
+ metrics,
196
+ artifact
197
+ });
198
+ });
199
+ }
200
+ if (name === 'media.frames') {
201
+ exactInput(input, ['video_path', 'artifact_id', 'max_frames', 'target_width', 'quality', 'format']);
202
+ const hasPath = Boolean(String(input.video_path || '').trim());
203
+ const hasArtifact = Boolean(String(input.artifact_id || '').trim());
204
+ if (hasPath === hasArtifact) {
205
+ throw commandError('IRIS_COMMAND_INPUT_INVALID',
206
+ 'media.frames 必须且只能提供 video_path 或 artifact_id 之一');
207
+ }
208
+ const videoPath = hasPath ? localFile(input.video_path, 'video_path', '视频') : null;
209
+ let frameOptions;
210
+ try {
211
+ frameOptions = normalizeFramesOptions({
212
+ ...(input.max_frames !== undefined ? { maxFrames: input.max_frames } : {}),
213
+ ...(input.target_width !== undefined ? { targetWidth: input.target_width } : {}),
214
+ ...(input.quality !== undefined ? { quality: input.quality } : {}),
215
+ ...(input.format !== undefined ? { format: input.format } : {})
216
+ });
217
+ } catch (error) {
218
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
219
+ }
220
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
221
+ if (signal.aborted) throw abortError();
222
+ const source = hasArtifact ? readCoreArtifactBytes(dataRoot, input.artifact_id) : null;
223
+ if (source && !source.artifact.mediaType.startsWith('video/')) {
224
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'media.frames 的 Artifact 输入必须是视频');
225
+ }
226
+ let temporaryDirectory;
227
+ let readablePath = videoPath;
228
+ if (source) {
229
+ try {
230
+ temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'iris-command-frames-'));
231
+ readablePath = path.join(temporaryDirectory, 'source.mp4');
232
+ fs.writeFileSync(readablePath, source.bytes, { mode: 0o600 });
233
+ } catch (_) {
234
+ if (temporaryDirectory) {
235
+ try { fs.rmSync(temporaryDirectory, { recursive: true, force: true }); } catch (_) { /* 临时副本 */ }
236
+ }
237
+ throw commandError('IRIS_COMMAND_PROCESSING_FAILED', '视频临时副本无法安全写入');
238
+ }
239
+ }
240
+ let media;
241
+ let frames;
242
+ try {
243
+ media = probeVideo(readablePath);
244
+ frames = await extractFrames({ inputPath: readablePath, ...frameOptions, signal });
245
+ } catch (error) {
246
+ if (signal.aborted) throw abortError();
247
+ throw commandError('IRIS_COMMAND_PROCESSING_FAILED',
248
+ '视频抽帧失败;请确认 ffmpeg 可用且文件格式受支持');
249
+ } finally {
250
+ if (temporaryDirectory) {
251
+ try { fs.rmSync(temporaryDirectory, { recursive: true, force: true }); } catch (_) { /* 临时副本 */ }
252
+ }
253
+ }
254
+ if (signal.aborted) throw abortError();
255
+ const relations = source ? [{ type: 'frame-of', artifactId: source.artifact.id }] : [];
256
+ const mediaType = frameOptions.format === 'png' ? 'image/png' : 'image/jpeg';
257
+ const artifacts = frames.map((frame, index) => createCoreArtifact(dataRoot, {
258
+ bytes: frame.buffer,
259
+ mediaType,
260
+ kind: 'video-frame',
261
+ metadata: {
262
+ frameIndex: index + 1,
263
+ atSec: frame.atSec,
264
+ width: frame.width,
265
+ height: frame.height
266
+ },
267
+ relations
268
+ }));
269
+ return Object.freeze({
270
+ contractVersion: COMMAND_CONTRACT_VERSION,
271
+ command: name,
272
+ media: Object.freeze({ ...media }),
273
+ artifacts: Object.freeze(artifacts)
274
+ });
275
+ });
276
+ }
277
+ // task.reobserve 是人工触发观察的对外命令名(DSH API/工作台),与 task.observe
278
+ // 共用同一份门与单步 poll 实现;语义不区分,绝不 submit。
279
+ if (name === 'task.observe' || name === 'task.reobserve') {
280
+ exactInput(input, ['task_id']);
281
+ return runtime.run('execute', async ({ dataRoot }) => {
282
+ const before = inspectCoreTask(dataRoot, input.task_id);
283
+ if (before.acceptance !== 'accepted' || !before.remoteTaskId || !['none', 'unknown'].includes(before.outcome)) throw commandError('IRIS_TASK_NOT_OBSERVABLE', 'Task 没有可观察的远端受理事实,或已终止;不会重新提交');
284
+ if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.observe 需要宿主显式提供 Task Provider resolver');
285
+ const adapter = await ports.resolveTaskAdapter(before);
286
+ if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
287
+ if (!hasProviderOperation(adapter, 'poll')) throw commandError('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', 'Task 原 Provider 不支持 poll;不会重新提交');
288
+ const task = await createProviderTaskRunner(runtime).observe(input.task_id, adapter, { resumeActive: true });
289
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
290
+ });
291
+ }
292
+ // task.redeliver:只对 outcome=succeeded / deliveryState=failed 的任务重新取回
293
+ // 已生成的远端产物(re-poll 拿产物清单,绝不 submit,也不会重新生成)。
294
+ if (name === 'task.redeliver') {
295
+ exactInput(input, ['task_id']);
296
+ return runtime.run('execute', async ({ dataRoot }) => {
297
+ const before = inspectCoreTask(dataRoot, input.task_id);
298
+ if (before.outcome !== 'succeeded' || before.deliveryState !== 'failed' || !before.remoteTaskId) throw commandError('IRIS_TASK_NOT_REDELIVERABLE', 'Task 不处于可重新交付状态;不会重新生成');
299
+ if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.redeliver 需要宿主显式提供 Task Provider resolver');
300
+ const adapter = await ports.resolveTaskAdapter(before);
301
+ if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
302
+ if (!hasProviderOperation(adapter, 'poll')) throw commandError('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', 'Task 原 Provider 不支持 poll;不会重新生成');
303
+ const task = await createProviderTaskRunner(runtime).redeliver(input.task_id, adapter);
304
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
305
+ });
306
+ }
307
+ // task.cancel:只对已受理、有远端 ID、结果未定论且非终态的任务开放。
308
+ // 只有 Provider 明确确认才写 outcome=canceled;不支持或无法确认时保持真实状态。
309
+ if (name === 'task.cancel') {
310
+ exactInput(input, ['task_id']);
311
+ return runtime.run('execute', async ({ dataRoot }) => {
312
+ const before = inspectCoreTask(dataRoot, input.task_id);
313
+ if (before.acceptance !== 'accepted' || !before.remoteTaskId
314
+ || !['none', 'unknown'].includes(before.outcome) || before.phase === 'terminal') {
315
+ throw commandError('IRIS_TASK_NOT_CANCELABLE', 'Task 缺少可取消的远端受理事实,或已终止');
316
+ }
317
+ if (before.cancelState !== 'none') {
318
+ throw commandError('IRIS_TASK_CANCEL_ALREADY_REQUESTED', '该 Task 已请求过取消;请显式重新观察以确认远端真实状态');
319
+ }
320
+ if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.cancel 需要宿主显式提供 Task Provider resolver');
321
+ const adapter = await ports.resolveTaskAdapter(before);
322
+ if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
323
+ const task = await createProviderTaskRunner(runtime).cancel(input.task_id, adapter);
324
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
325
+ });
326
+ }
327
+ // task.retry:D4 重试为新任务。产生新的真实计费,必须显式 confirm_billing;
328
+ // 只对终态且未成功交付的 Task 开放;新 Task 有全新 id/attempts/binding 并记录
329
+ // 单向 retriedFrom 关系,旧 Task 不动;Core 不持久化 Prompt,prompt 必须由
330
+ // 调用方重新提供(绝不从旧记录"恢复")。
331
+ if (name === 'task.retry') {
332
+ exactInput(input, ['task_id', 'provider_input', 'model_ref', 'confirm_billing']);
333
+ if (input.confirm_billing !== true) {
334
+ throw commandError('IRIS_COMMAND_BILLING_CONFIRM_REQUIRED', '重试会创建新任务并可能产生重复生成费用;必须显式确认计费');
335
+ }
336
+ return runtime.run('execute', async ({ dataRoot }) => {
337
+ const before = inspectCoreTask(dataRoot, input.task_id);
338
+ if (before.phase !== 'terminal' || (before.outcome === 'succeeded' && before.deliveryState === 'ready')) {
339
+ throw commandError('IRIS_TASK_NOT_RETRYABLE', '只有终态且未成功交付的 Task 可以重试为新任务');
340
+ }
341
+ let normalized;
342
+ try { normalized = normalizeGenerationInput(before.capability, input.provider_input); }
343
+ catch (error) {
344
+ if (error instanceof GenerationInputError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
345
+ throw error;
346
+ }
347
+ const providerInput = normalized.providerInput;
348
+ if (Object.hasOwn(input.provider_input, 'model_ref')) {
349
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'retry 的模型覆盖请使用外层 model_ref');
350
+ }
351
+ const modelRef = String(input.model_ref || '').trim();
352
+ if (input.model_ref !== undefined && !modelRef) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'model_ref 必须是 providerId::modelId 复合引用');
353
+ if (typeof ports.resolveTaskCandidates !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.retry 需要宿主显式提供候选链 resolver');
354
+ const candidates = await ports.resolveTaskCandidates({ capability: before.capability, modelRef });
355
+ const result = await createProviderTaskRunner(runtime).submit({
356
+ capability: before.capability,
357
+ candidates,
358
+ providerInput,
359
+ retriedFrom: before.id
360
+ });
361
+ return Object.freeze({
362
+ contractVersion: COMMAND_CONTRACT_VERSION,
363
+ command: name,
364
+ taskId: result.taskId,
365
+ retriedFrom: before.id,
366
+ task: result.task,
367
+ ...(result.localError ? { localError: result.localError } : {})
368
+ });
369
+ });
370
+ }
371
+ if (name === 'task.inspect') {
372
+ exactInput(input, ['task_id']);
373
+ return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
374
+ contractVersion: COMMAND_CONTRACT_VERSION,
375
+ command: name,
376
+ task: inspectCoreTask(dataRoot, input.task_id)
377
+ }));
378
+ }
379
+ if (name === 'task.list') {
380
+ exactInput(input, ['offset', 'limit']);
381
+ return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
382
+ contractVersion: COMMAND_CONTRACT_VERSION,
383
+ command: name,
384
+ ...listCoreTasks(dataRoot, input)
385
+ }));
386
+ }
387
+ if (name === 'artifact.inspect') {
388
+ exactInput(input, ['artifact_id']);
389
+ return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
390
+ contractVersion: COMMAND_CONTRACT_VERSION,
391
+ command: name,
392
+ artifact: inspectCoreArtifact(dataRoot, input.artifact_id)
393
+ }));
394
+ }
395
+ if (name === 'artifact.list') {
396
+ exactInput(input, ['offset', 'limit']);
397
+ return runtime.run('inspect', ({ dataRoot }) => Object.freeze({
398
+ contractVersion: COMMAND_CONTRACT_VERSION,
399
+ command: name,
400
+ ...listCoreArtifacts(dataRoot, input)
401
+ }));
402
+ }
403
+ if (name === 'artifact.rebuild') {
404
+ exactInput(input, []);
405
+ return runtime.run('recover', ({ dataRoot }) => Object.freeze({
406
+ contractVersion: COMMAND_CONTRACT_VERSION,
407
+ command: name,
408
+ ...rebuildCoreArtifactIndex(dataRoot)
409
+ }));
410
+ }
411
+ exactInput(input, ['artifact_id', 'output_path']);
412
+ return runtime.run('execute', ({ dataRoot }) => Object.freeze({
413
+ contractVersion: COMMAND_CONTRACT_VERSION,
414
+ command: name,
415
+ ...exportCoreArtifact(dataRoot, input.artifact_id, input.output_path)
416
+ }));
417
+ }
418
+
419
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, commands: CORE_COMMANDS, execute });
420
+ }
package/lib/config.js CHANGED
@@ -10,7 +10,7 @@ import os from 'node:os';
10
10
  import * as cap from './capability.js';
11
11
  import * as models from './models.js';
12
12
  import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
13
- import { inferMediaProtocol } from './provider-protocol.js';
13
+ import { selectMediaProtocol, isConfiguredProvider } from './provider-protocol.js';
14
14
  import { redactProviderMessage } from './provider-contract.js';
15
15
  import {
16
16
  PROVIDER_HEALTH_FRESH_MS,
@@ -32,6 +32,7 @@ function validateStoredConfig(value) {
32
32
  if (!isPlainObject(provider)) throw new Error('provider 条目必须是对象');
33
33
  if (provider.id !== undefined && typeof provider.id !== 'string') throw new Error('provider.id 必须是字符串');
34
34
  if (provider.baseUrl !== undefined && typeof provider.baseUrl !== 'string') throw new Error('provider.baseUrl 必须是字符串');
35
+ if (provider.mediaBaseUrl !== undefined && typeof provider.mediaBaseUrl !== 'string') throw new Error('provider.mediaBaseUrl 必须是字符串');
35
36
  if (provider.apiKey !== undefined && typeof provider.apiKey !== 'string') throw new Error('provider.apiKey 必须是字符串');
36
37
  if (provider.models !== undefined && !Array.isArray(provider.models)) throw new Error('provider.models 必须是数组');
37
38
  for (const model of provider.models || []) {
@@ -43,10 +44,27 @@ function validateStoredConfig(value) {
43
44
  }
44
45
  }
45
46
 
47
+ /**
48
+ * 供应商认证方式:'bearer'(默认,需要 apiKey)或 'none'(本地/内网无鉴权端点)。
49
+ * T-01:解除 ComfyUI、Ollama、本地 TTS 等本地部署的硬编码阻塞(审计 C-1)。
50
+ * 无 auth 字段时一律视为 'bearer'——现有用户行为完全不变。
51
+ */
52
+ function authModeOf(provider) {
53
+ return provider && provider.auth === 'none' ? 'none' : 'bearer';
54
+ }
55
+
56
+ /** 该供应商是否缺少必需凭据('none' 认证永不缺)。 */
57
+ function missingCredentials(provider) {
58
+ if (!provider || authModeOf(provider) === 'none') return false;
59
+ return typeof provider.apiKey !== 'string' || !provider.apiKey.trim();
60
+ }
61
+
46
62
  function normalizeStoredProvider(provider) {
47
- if (provider.mediaProtocol === 'dashscope' || provider.mediaProtocol === 'openai-images') return false;
48
- provider.mediaProtocol = inferMediaProtocol(provider.baseUrl);
49
- return true;
63
+ const selected = selectMediaProtocol(provider);
64
+ const changed = provider.mediaProtocol !== selected.mediaProtocol
65
+ || (provider.protocolInferred === true) !== selected.protocolInferred;
66
+ Object.assign(provider, selected);
67
+ return models.migrateProviderModels(provider) || changed;
50
68
  }
51
69
 
52
70
  export function irisHome() {
@@ -134,15 +152,17 @@ export function importFromWorkbench(workbenchConfigPath) {
134
152
  for (const p of list) {
135
153
  if (!p || typeof p.apiKey !== 'string' || !p.apiKey.trim() ||
136
154
  typeof p.baseUrl !== 'string' || !p.baseUrl.trim()) continue;
137
- c.providers.push({
155
+ const imported = {
138
156
  id: 'iris_' + Math.random().toString(36).slice(2, 8),
139
157
  name: p.name || p.id,
140
- type: p.type === 'anthropic' ? 'anthropic' : 'openai',
158
+ type: typeof p.type === 'string' && p.type.trim() ? p.type.trim() : 'openai',
141
159
  baseUrl: p.baseUrl,
142
160
  apiKey: p.apiKey,
143
161
  enabled: p.enabled !== false,
144
- mediaProtocol: inferMediaProtocol(p.baseUrl)
145
- });
162
+ mediaProtocol: p.mediaProtocol
163
+ };
164
+ normalizeStoredProvider(imported);
165
+ c.providers.push(imported);
146
166
  n++;
147
167
  }
148
168
  if (n) persist();
@@ -150,7 +170,7 @@ export function importFromWorkbench(workbenchConfigPath) {
150
170
  }
151
171
 
152
172
  export function providers() {
153
- return load().providers.filter((p) => p.enabled && p.apiKey);
173
+ return load().providers.filter(isConfiguredProvider);
154
174
  }
155
175
 
156
176
  export function providerById(id) {
@@ -189,16 +209,7 @@ export function pickFor(capability) {
189
209
  */
190
210
  export function pickAllFor(capability) {
191
211
  const pool = models.modelPool(providers());
192
- const chosen = [];
193
- const seen = new Set();
194
- const add = (m) => {
195
- const k = m.providerId + '\u0000' + m.id;
196
- if (!seen.has(k)) { seen.add(k); chosen.push(m); }
197
- };
198
- for (const ref of assignmentOrder(capability)) {
199
- for (const m of pool) if (m.ref === ref && m.capabilities.includes(capability)) add(m);
200
- }
201
- for (const m of pool) if (m.capabilities.includes(capability)) add(m);
212
+ const chosen = models.orderedModels(pool, capability, assignments()[capability]);
202
213
  const out = [];
203
214
  for (const m of chosen) {
204
215
  const p = providerById(m.providerId);
@@ -214,21 +225,7 @@ export function assignments() {
214
225
  return load().assignments || {};
215
226
  }
216
227
 
217
- function resolveAssignment(pool, raw, capability) {
218
- let providerId = '';
219
- let modelId = '';
220
- if (raw && typeof raw === 'object') {
221
- providerId = String(raw.providerId || '');
222
- modelId = String(raw.modelId || raw.id || '');
223
- } else if (typeof raw === 'string') {
224
- const parsed = models.parseModelRef(raw);
225
- if (parsed) ({ providerId, modelId } = parsed);
226
- else modelId = raw; // v1 旧格式:纯 model id,按池顺序取第一个
227
- }
228
- return pool.find((m) => m.id === modelId
229
- && (!providerId || m.providerId === providerId)
230
- && m.capabilities.includes(capability)) || null;
231
- }
228
+ const resolveAssignment = models.resolvePoolModel;
232
229
 
233
230
  /** 归一化某能力的有序分配列表 → [providerId::modelId, ...]。 */
234
231
  export function assignmentOrder(capability) {
@@ -302,7 +299,7 @@ export function capabilitiesOf(p) {
302
299
  }
303
300
 
304
301
  const HEALTH_SENSITIVE_FIELDS = new Set([
305
- 'type', 'baseUrl', 'apiKey', 'mediaProtocol',
302
+ 'auth', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'mediaProtocol',
306
303
  'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
307
304
  ]);
308
305
 
@@ -411,7 +408,7 @@ export function recordProviderHealth(id, modelId, capability, result) {
411
408
  /** 返回单个 Provider × Model × Capability 的安全状态。 */
412
409
  export function modelHealth(id, modelId, capability, { now = Date.now() } = {}) {
413
410
  const provider = load().providers.find((item) => item.id === id);
414
- if (!provider || provider.enabled === false || !provider.apiKey) return { status: 'unconfigured' };
411
+ if (!provider || provider.enabled === false || missingCredentials(provider)) return { status: 'unconfigured' };
415
412
  const observation = healthObservation(provider, modelId, capability);
416
413
  const status = healthObservationState(observation, { now });
417
414
  const evidence = status === 'verified'
@@ -467,8 +464,8 @@ export function upsert(provider) {
467
464
  const i = c.providers.findIndex((p) => p.id === id);
468
465
  if (i >= 0) {
469
466
  const previous = c.providers[i];
470
- if (rest.mediaProtocol === 'auto') rest.mediaProtocol = inferMediaProtocol(rest.baseUrl ?? previous.baseUrl);
471
467
  const next = { ...previous, ...rest };
468
+ if (Object.prototype.hasOwnProperty.call(rest, 'mediaProtocol')) next.protocolInferred = false;
472
469
  normalizeStoredProvider(next);
473
470
  resetHealthOnCriticalChange(previous, next, rest);
474
471
  if (Object.prototype.hasOwnProperty.call(rest, 'models')) pruneHealthToEffectiveModels(next);
@@ -478,7 +475,7 @@ export function upsert(provider) {
478
475
  }
479
476
  }
480
477
  const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
481
- if (fresh.mediaProtocol === 'auto') delete fresh.mediaProtocol;
478
+ fresh.protocolInferred = false;
482
479
  normalizeStoredProvider(fresh);
483
480
  c.providers.push(fresh);
484
481
  persist();
@@ -500,21 +497,22 @@ export function removeProvider(id) {
500
497
  return true;
501
498
  }
502
499
 
503
- /** 设置一个供应商的模型池(覆盖显式 models 数组;空数组 = 回退到自动发现/旧字段) */
500
+ /** 显式替换模型池;空数组表示没有可用模型。发现使用合并入口。 */
504
501
  export function setProviderModels(id, modelEntries) {
505
502
  const c = load();
506
503
  const p = c.providers.find((x) => x.id === id);
507
504
  if (!p) return null;
508
505
  if (Array.isArray(modelEntries)) {
509
- // 保留同名模型已有的 verified/source(重新发现不丢实测结果)
510
- const prev = {};
511
- for (const m of p.models || []) if (m && m.id) prev[m.id] = m;
506
+ // 保留同名模型的实测结果;手工能力标注优先于后续自动发现。
507
+ const prev = new Map();
508
+ for (const m of p.models || []) if (m && m.id) prev.set(m.id, m);
512
509
  p.models = modelEntries.map((m) => {
513
510
  const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities };
514
- const old = prev[entry.id];
511
+ const old = prev.get(entry.id);
515
512
  if (old) {
516
513
  if (old.verified) entry.verified = old.verified;
517
514
  if (old.source) entry.source = old.source;
515
+ if (old.source === 'manual' && Array.isArray(old.capabilities)) entry.capabilities = old.capabilities;
518
516
  }
519
517
  if (m && m.verified) entry.verified = m.verified;
520
518
  if (m && m.source) entry.source = m.source;
@@ -526,6 +524,19 @@ export function setProviderModels(id, modelEntries) {
526
524
  return p;
527
525
  }
528
526
 
527
+ /** 发现只追加或更新;用户手工项和未再返回的旧项始终保留。 */
528
+ export function mergeDiscoveredModels(id, modelEntries) {
529
+ const provider = load().providers.find((p) => p.id === id);
530
+ if (!provider) return null;
531
+ const merged = new Map(models.providerModels(provider).map((m) => [m.id, m]));
532
+ for (const model of modelEntries) {
533
+ const previous = merged.get(model.id);
534
+ if (previous?.source === 'manual') continue;
535
+ merged.set(model.id, { ...previous, ...model, source: 'discovered' });
536
+ }
537
+ return setProviderModels(id, [...merged.values()]);
538
+ }
539
+
529
540
  /** 找 provider 里的某个模型条目(可变引用) */
530
541
  function findModelEntry(p, modelId) {
531
542
  return (p.models || []).find((m) => m && m.id === modelId);
@@ -546,6 +557,7 @@ export function addProviderModel(id, modelId, capabilities) {
546
557
  } else if (Array.isArray(capabilities)) {
547
558
  entry.capabilities = capabilities;
548
559
  }
560
+ entry.source = 'manual';
549
561
  pruneHealthToEffectiveModels(p);
550
562
  persist();
551
563
  return entry;
@@ -573,6 +585,7 @@ export function setModelCapabilities(id, modelId, capabilities) {
573
585
  if (!entry) return false;
574
586
  const nextCapabilities = (Array.isArray(capabilities) ? capabilities : []).filter((x) => cap.isKnownCapability(x));
575
587
  entry.capabilities = nextCapabilities;
588
+ entry.source = 'manual';
576
589
  pruneHealthToEffectiveModels(p);
577
590
  persist();
578
591
  return true;