@mokuyoaxis/dsh-iris 0.2.5 → 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 (83) hide show
  1. package/.dsh/skills/iris-use/SKILL.md +2 -2
  2. package/.dsh/skills/iris-use/references/media-contract.md +2 -2
  3. package/CHANGELOG.md +49 -1
  4. package/README.en.md +39 -22
  5. package/README.md +40 -21
  6. package/bin/dsh-iris.js +166 -35
  7. package/docs/AGENT_QUICKSTART.md +7 -7
  8. package/docs/ARCHITECTURE.md +9 -3
  9. package/docs/CLI_MANAGEMENT.md +5 -1
  10. package/docs/CORE_RUNTIME_CONTRACT.md +36 -5
  11. package/docs/CORE_SKILLS.md +62 -0
  12. package/docs/DSH_COMPATIBILITY.md +8 -4
  13. package/docs/DSH_CORE_MIGRATION.md +4 -2
  14. package/docs/DSH_PROVIDER_CONNECTIONS.md +34 -0
  15. package/docs/DSH_RUNTIME.md +36 -0
  16. package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +2 -0
  17. package/docs/FUNCTION_MODEL_POOLS.md +84 -0
  18. package/docs/HEADLESS_CLI.md +16 -8
  19. package/docs/HOST_ADAPTER_CONTRACT.md +7 -2
  20. package/docs/HOST_DOCTOR.md +14 -2
  21. package/docs/IMAGE_FLOW.md +71 -0
  22. package/docs/LOCAL_API.md +60 -0
  23. package/docs/LOCAL_API_CONTRACT.md +115 -0
  24. package/docs/LOCAL_CREATION.md +50 -0
  25. package/docs/MUSIC.md +2 -2
  26. package/docs/PROVIDER_HEALTH.md +26 -2
  27. package/docs/ROADMAP.md +18 -4
  28. package/docs/SECURITY.md +20 -2
  29. package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
  30. package/docs/VISION_CLI.md +2 -2
  31. package/docs/WEB_PROTOTYPE.md +47 -0
  32. package/docs/releases/0.2.6.md +50 -0
  33. package/docs/releases/0.2.7.md +62 -0
  34. package/lib/actions.js +208 -90
  35. package/lib/adapters.js +36 -9
  36. package/lib/api.js +40 -39
  37. package/lib/application-runtime.js +186 -0
  38. package/lib/client.js +805 -182
  39. package/lib/composite-vision-routing.js +6 -6
  40. package/lib/config.js +140 -29
  41. package/lib/core-artifact-store.js +5 -0
  42. package/lib/core-maintenance.js +25 -2
  43. package/lib/core-runtime.js +19 -0
  44. package/lib/core-tasks.js +62 -8
  45. package/lib/creation-request-store.js +159 -0
  46. package/lib/doctor.js +20 -2
  47. package/lib/dsh-core-adapter.js +669 -564
  48. package/lib/dsh-host-adapter.js +14 -1
  49. package/lib/dsh-provider-config.js +35 -0
  50. package/lib/dsh-provider-source.js +102 -0
  51. package/lib/flow-store.js +195 -0
  52. package/lib/headless-vision.js +5 -5
  53. package/lib/host-contract.js +1 -0
  54. package/lib/http-vision-model-adapter.js +5 -2
  55. package/lib/image-flow.js +188 -0
  56. package/lib/index.js +103 -78
  57. package/lib/local-api-projection.js +27 -0
  58. package/lib/local-api.js +381 -0
  59. package/lib/local-creation.js +85 -0
  60. package/lib/model-directory-service.js +53 -0
  61. package/lib/model-directory.js +103 -0
  62. package/lib/models.js +71 -6
  63. package/lib/ocr-model-routing.js +2 -2
  64. package/lib/private-storage.js +12 -0
  65. package/lib/provider-adapters.js +42 -9
  66. package/lib/provider-catalog.js +14 -9
  67. package/lib/provider-config-scope.js +16 -0
  68. package/lib/provider-config-service.js +82 -13
  69. package/lib/provider-health.js +33 -0
  70. package/lib/provider-protocol.js +30 -0
  71. package/lib/provider-task-runner.js +22 -2
  72. package/lib/render-address.js +35 -0
  73. package/lib/render.js +52 -13
  74. package/lib/skill-installation.js +199 -0
  75. package/lib/vision-model-routing.js +25 -12
  76. package/lib/workbench-artifacts.js +1 -2
  77. package/lib/workbench-management.js +8 -9
  78. package/package.json +4 -3
  79. package/skills/iris-core/SKILL.md +40 -0
  80. package/skills/iris-core/agents/openai.yaml +4 -0
  81. package/skills/iris-core/references/cli.md +95 -0
  82. package/skills/iris-core/references/tasks.md +58 -0
  83. package/user_guide.md +32 -20
package/lib/index.js CHANGED
@@ -18,7 +18,9 @@ import { runVisionRequest, readVisionImageFile } from './vision-model-routing.js
18
18
  import { waitForModelWork } from './model-call-runtime.js';
19
19
  import { hasHostPort, requireHostPort } from './host-contract.js';
20
20
  import { createDshHostAdapter } from './dsh-host-adapter.js';
21
- import { cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, projectCoreTaskForDsh, readCoreArtifactMediaForDsh, resumeProviderTaskWatchesForDsh, stopProviderTaskWatchesForDsh } from './dsh-core-adapter.js';
21
+ import { withDshProviderConfig } from './dsh-provider-config.js';
22
+ import { createDshRuntime, dshCoreDataRoot } from './dsh-core-adapter.js';
23
+ import { ModelPortError } from './model-port-contract.js';
22
24
  import { imageDimensions } from './pixels.js';
23
25
  import { runLocateRequest, runSummaryRequest, describeGeneratedImage } from './composite-vision-routing.js';
24
26
  import { normalizeSummarySource, summaryFrameRecords, summaryTimelineLabel } from './summary-input.js';
@@ -64,12 +66,11 @@ function outputDir() {
64
66
 
65
67
  /* ---------------- 媒体路由:/iris/media/:taskId/:token/:name ---------------- */
66
68
 
67
- let mediaRouteMounted = false;
68
- function mountIrisRoutes(routeCtx) {
69
+ function mountIrisRoutes(routeCtx, core, routes) {
69
70
  // Cordis 服务只在 DSH 宿主边界探测;Command 与通用工具辅助函数不接收原始 ctx
70
71
  const host = dshHost(routeCtx);
71
72
  const reg = hasHostPort(host, 'routes') ? host.ports.routes : undefined;
72
- if (!reg || typeof reg.register !== 'function' || mediaRouteMounted) return;
73
+ if (!reg || typeof reg.register !== 'function' || routes.mounted) return;
73
74
  try {
74
75
  const disposeMedia = reg.register({
75
76
  kind: 'prefix',
@@ -79,21 +80,21 @@ function mountIrisRoutes(routeCtx) {
79
80
  const disposeApi = reg.register({
80
81
  kind: 'prefix',
81
82
  path: '/iris/api',
82
- handler: guarded((req, res) => serveApi(req, res, dshHost(routeCtx)))
83
+ handler: guarded((req, res) => serveApi(req, res, dshHost(routeCtx), core))
83
84
  });
84
85
  const disposeRender = reg.register({
85
86
  kind: 'prefix',
86
87
  path: '/iris/render',
87
88
  handler: guarded(serveRender)
88
89
  });
89
- mediaRouteMounted = true;
90
+ routes.mounted = true;
90
91
  recordHostRoutes(EXPECTED_IRIS_ROUTES);
91
92
  routeCtx.effect(() => () => {
92
93
  if (typeof disposeMedia === 'function') disposeMedia();
93
94
  if (typeof disposeApi === 'function') disposeApi();
94
95
  if (typeof disposeRender === 'function') disposeRender();
95
96
  closeAllSse(); // SSE 长连接随插件停用一起关闭,不留悬挂连接
96
- mediaRouteMounted = false;
97
+ routes.mounted = false;
97
98
  }, 'iris: media+api+actions+render routes');
98
99
  console.log('[iris] 媒体+工作台+操作路由已挂载:/iris/media · /iris/api · /iris/api/actions · /iris/render');
99
100
  } catch (err) {
@@ -179,11 +180,11 @@ function coreTaskTerminalProblem(task, label = '任务') {
179
180
  return label + '需要人工处理';
180
181
  }
181
182
 
182
- async function awaitCoreTerminal(taskId, { timeoutMs, signal }) {
183
+ async function awaitCoreTerminal(core, taskId, { timeoutMs, signal }) {
183
184
  const deadline = Date.now() + timeoutMs;
184
185
  for (;;) {
185
186
  if (signal?.aborted) throw new Error('已取消;Core 任务仍保留远端受理事实,可稍后查询');
186
- const task = await inspectProviderTaskForDsh(taskId, { signal });
187
+ const task = await core.inspectProviderTaskForDsh(taskId, { signal });
187
188
  if (coreTaskWaitFinished(task)) return task;
188
189
  if (Date.now() > deadline) return null;
189
190
  await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -219,6 +220,14 @@ function safeRegister(host, definition) {
219
220
 
220
221
  export async function apply(ctx) {
221
222
  beginHostRuntime({ pluginId: IRIS_PLUGIN_ID, version: PACKAGE_VERSION });
223
+ const core = createDshRuntime({ dataRoot: dshCoreDataRoot(), getHost: () => dshHost(ctx) });
224
+ ctx.provide('irisRuntime', core);
225
+ const routes = { mounted: false };
226
+ const { cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, projectCoreTaskForDsh, readCoreArtifactMediaForDsh, resumeProviderTaskWatchesForDsh } = core;
227
+ ctx.effect(() => () => {
228
+ tasks.stopWatchAll();
229
+ return core.dispose();
230
+ }, 'iris: DSH runtime');
222
231
  guard('Iris 私有存储权限收紧', () => hardenPrivateTree(store.irisHome()));
223
232
 
224
233
  guard('上传临时文件清理', () => purgeStaleUploads());
@@ -242,26 +251,18 @@ export async function apply(ctx) {
242
251
  if (resumed.length) console.log(`[iris] 已恢复接管 ${resumed.length} 个后台任务`);
243
252
  });
244
253
 
245
- try {
246
- const resumed = await resumeProviderTaskWatchesForDsh();
247
- if (resumed.length) console.log(`[iris] 已恢复接管 ${resumed.length} 个 Core 图片任务`);
248
- } catch (error) {
249
- console.error('[iris] 恢复 Core 图片后台任务失败(已隔离,不影响宿主):', error?.message || error);
250
- }
251
-
252
- // Fiber 清理:插件停用/更新时停掉 legacy 与 Core 的全部 Host 盯守句柄
253
- guard('盯守句柄注册', () => {
254
- ctx.effect(() => () => {
255
- tasks.stopWatchAll();
256
- stopProviderTaskWatchesForDsh();
257
- });
254
+ // Owned background setup lets unload cancel a slow connection resolver during takeover.
255
+ void resumeProviderTaskWatchesForDsh().then(resumed => {
256
+ if (resumed.length) console.log('[iris] 已恢复接管 ' + resumed.length + ' 个 Core 媒体任务');
257
+ }, error => {
258
+ if (core.snapshot().state === 'open') console.error('[iris] 恢复 Core 后台任务失败:', error?.code || 'unknown');
258
259
  });
259
260
 
260
261
  // 媒体路由(视频/音频在对话流里的可播通路;图片仍走原生附件)
261
- ctx.inject(['webServer'], mountIrisRoutes);
262
- ctx.inject(['httpServer'], mountIrisRoutes);
262
+ ctx.inject(['webServer'], routeCtx => mountIrisRoutes(routeCtx, core, routes));
263
+ ctx.inject(['httpServer'], routeCtx => mountIrisRoutes(routeCtx, core, routes));
263
264
  // 若 webServer 已在运行,inject 回调也会触发;这里兜底立即挂载一次
264
- guard('媒体路由兜底挂载', () => mountIrisRoutes(ctx));
265
+ guard('媒体路由兜底挂载', () => mountIrisRoutes(ctx, core, routes));
265
266
 
266
267
  // DSH 的 Skill registry 是可选宿主能力。存在时把 npm 包内三项 Skill 注册到
267
268
  // 全局层;项目目录中的同名 Skill 仍由 DSH 原生优先级覆盖。
@@ -274,6 +275,29 @@ export async function apply(ctx) {
274
275
  });
275
276
 
276
277
  const host = dshHost(ctx);
278
+ const providerTools = new Set(['iris_draw_image', 'iris_edit_image', 'iris_generate_video', 'iris_generate_music',
279
+ 'iris_speak_text', 'iris_transcribe_audio', 'iris_look_at_image', 'iris_relook_attachment',
280
+ 'iris_media_summarize', 'iris_locate', 'iris_long_ocr']);
281
+ const visionTools = new Set(['iris_look_at_image', 'iris_relook_attachment', 'iris_media_summarize', 'iris_locate', 'iris_long_ocr']);
282
+ function registerIrisTool(definition) {
283
+ safeRegister(host, { ...definition, execute(args, exec = {}) {
284
+ const owned = structuredClone(args || {});
285
+ return core.runHostOperation(signal => {
286
+ const execute = async () => {
287
+ if (visionTools.has(definition.name) && !owned.model) await store.refreshAutomaticDirectories({ capability: 'vision', signal });
288
+ signal.throwIfAborted();
289
+ return definition.execute(owned, { ...exec, signal });
290
+ };
291
+ return providerTools.has(definition.name)
292
+ ? withDshProviderConfig(dshHost(ctx), execute, { signal }) : execute();
293
+ }, { signal: exec.signal }).catch(error => {
294
+ if (visionTools.has(definition.name) && exec.signal?.aborted && error === exec.signal.reason) {
295
+ throw new ModelPortError('IRIS_MODEL_ABORTED');
296
+ }
297
+ throw error;
298
+ });
299
+ } });
300
+ }
277
301
  if (!hasHostPort(host, 'tools')) {
278
302
  console.error('[iris] tools 服务不可用,跳过全部工具注册(宿主不受影响)');
279
303
  return;
@@ -281,7 +305,7 @@ export async function apply(ctx) {
281
305
 
282
306
  async function coreImageToolResult(submitted, prompt, signal) {
283
307
  let final = await inspectProviderTaskForDsh(submitted.taskId, { signal });
284
- if (!coreTaskWaitFinished(final)) final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal });
308
+ if (!coreTaskWaitFinished(final)) final = await awaitCoreTerminal(core, submitted.taskId, { timeoutMs: AWAIT_MS.image, signal });
285
309
  if (!final) return { blocks: [{ type: 'text', text: `[iris] Core 图片任务仍在后台观察(task: ${submitted.taskId})。稍后可用 iris_task_status 查询进度与 Artifact。` }] };
286
310
  const terminalProblem = coreTaskTerminalProblem(final, '图像生成');
287
311
  if (terminalProblem) throw new Error(terminalProblem);
@@ -291,7 +315,7 @@ export async function apply(ctx) {
291
315
  let note = '';
292
316
  try {
293
317
  const described = await describeGeneratedImage(dshHost(ctx), {
294
- providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, originalPrompt: prompt, signal,
318
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability, originalPrompt: prompt, signal,
295
319
  async prepareImage(inputSignal) {
296
320
  const first = await readCoreArtifactMediaForDsh(projected.artifacts[0].artifact.id, { signal: inputSignal });
297
321
  return { bytes: new Uint8Array(first.bytes), mediaType: first.artifact.mediaType };
@@ -309,7 +333,7 @@ export async function apply(ctx) {
309
333
  return { blocks };
310
334
  }
311
335
 
312
- safeRegister(host, {
336
+ registerIrisTool({
313
337
  name: 'iris_import_image',
314
338
  description: 'Save a local image or a current-session image attachment as a persistent Iris image Artifact for later viewing, editing and export. Provide exactly one of image_path or attachment_id. Supports static PNG/JPEG/WebP up to 20 MiB, preserves the source, and never calls a model.',
315
339
  parameters: {
@@ -327,14 +351,14 @@ export async function apply(ctx) {
327
351
  const attachmentId = String(args.attachment_id || '').trim();
328
352
  if (Boolean(imagePath) === Boolean(attachmentId)) throw new Error('图片导入须选择image_path或attachment_id之一');
329
353
  const input = imagePath ? { image_path: imagePath } : {
330
- image_bytes: (await resolveVisionImageInput(dshHost(ctx), exec, { attachment_id: attachmentId }, exec.signal)).bytes
354
+ image_bytes: (await resolveVisionImageInput(core, dshHost(ctx), exec, { attachment_id: attachmentId }, exec.signal)).bytes
331
355
  };
332
- return runAction(dshHost(ctx), 'image_import', input, { signal: exec.signal });
356
+ return runAction(dshHost(ctx), 'image_import', input, { signal: exec.signal, core });
333
357
  }
334
358
  });
335
359
 
336
360
  /* ---------- 🖼️ 画图 ---------- */
337
- safeRegister(host, {
361
+ registerIrisTool({
338
362
  name: 'iris_draw_image',
339
363
  description:
340
364
  'Generate an image from a detailed prompt using the user-configured image model and its configured image protocol. ' +
@@ -345,7 +369,7 @@ export async function apply(ctx) {
345
369
  prompt: { type: 'string', description: 'Detailed visual description of the image to create' },
346
370
  size: { type: 'string', description: "Output size; DashScope uses 'W*H' (e.g. '1024*1024'), OpenAI uses 'WxH'. Default provider default." },
347
371
  n: { type: 'string', description: "Number of images, default '1'" },
348
- model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item (legacy bare model id also accepted)' }
372
+ model: { type: 'string', description: 'Optional providerId::modelId in the enabled function pool; selects only this item (legacy bare model id also accepted)' }
349
373
  },
350
374
  required: ['prompt']
351
375
  },
@@ -356,7 +380,7 @@ export async function apply(ctx) {
356
380
  isConcurrencySafe: () => false,
357
381
  async execute(args, exec) {
358
382
  const prompt = String(args.prompt || '').trim();
359
- const submitted = await runAction(dshHost(ctx), 'image', args, { signal: exec.signal });
383
+ const submitted = await runAction(dshHost(ctx), 'image', args, { signal: exec.signal, core });
360
384
  if (submitted.storage === 'core') {
361
385
  return coreImageToolResult(submitted, prompt, exec.signal);
362
386
  }
@@ -390,7 +414,7 @@ export async function apply(ctx) {
390
414
  let note = '';
391
415
  try {
392
416
  const described = await describeGeneratedImage(dshHost(ctx), {
393
- providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, originalPrompt: prompt, signal: exec.signal,
417
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability, originalPrompt: prompt, signal: exec.signal,
394
418
  prepareImage: signal => readVisionImageFile(files[0], MEDIA_TYPES[path.extname(files[0]).toLowerCase()] || 'image/png', signal)
395
419
  });
396
420
  if (described) note = `画面内容:${described}`;
@@ -405,27 +429,27 @@ export async function apply(ctx) {
405
429
  }
406
430
  });
407
431
 
408
- safeRegister(host, {
432
+ registerIrisTool({
409
433
  name: 'iris_edit_image',
410
- description: 'Edit an existing Core image by Artifact ID and an instruction using an assigned chat-image model or native DashScope Qwen edit model. Returns new Core Artifacts and DSH image attachments; preserves the source image and records its provenance.',
434
+ description: 'Edit an existing Core image by Artifact ID and an instruction using an allowed chat-image model or native DashScope Qwen edit model. Returns new Core Artifacts and DSH image attachments; preserves the source image and records its provenance.',
411
435
  parameters: {
412
436
  type: 'object', properties: {
413
437
  source_artifact_id: { type: 'string', description: 'Core Artifact ID of the source PNG, JPEG or WebP image (up to 20 MiB)' },
414
438
  prompt: { type: 'string', description: 'Describe the changes and what should be preserved in the source image' },
415
- model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item; supports openai-chat-images or DashScope Qwen edit/max/plus and 2.0/pro models' },
439
+ model: { type: 'string', description: 'Optional providerId::modelId in the enabled image pool; selects only this item; supports openai-chat-images or DashScope Qwen edit/max/plus and 2.0/pro models' },
416
440
  n: { type: 'string', description: "Number of images, default '1'" }
417
441
  }, required: ['source_artifact_id', 'prompt']
418
442
  },
419
443
  output: { schema: { type: 'object' }, render: (_args, value) => value.blocks },
420
444
  isConcurrencySafe: () => false,
421
445
  async execute(args, exec) {
422
- const submitted = await runAction(dshHost(ctx), 'image_edit', args, { signal: exec.signal });
446
+ const submitted = await runAction(dshHost(ctx), 'image_edit', args, { signal: exec.signal, core });
423
447
  return coreImageToolResult(submitted, String(args.prompt || '').trim(), exec.signal);
424
448
  }
425
449
  });
426
450
 
427
451
  /* ---------- 🎬 视频生成 ---------- */
428
- safeRegister(host, {
452
+ registerIrisTool({
429
453
  name: 'iris_generate_video',
430
454
  description:
431
455
  'Generate a video with the configured DashScope video model. Three modes: ' +
@@ -443,18 +467,18 @@ export async function apply(ctx) {
443
467
  resolution: { type: 'string', description: "s2v only: output tier '480P' (default) or '720P'" },
444
468
  size: { type: 'string', description: "t2v/i2v only: output size 'W*H', e.g. '1280*720' (default)" },
445
469
  duration: { type: 'number', description: 't2v/i2v only: duration in seconds, if the model supports it' },
446
- model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item (legacy bare model id accepted)' }
470
+ model: { type: 'string', description: 'Optional providerId::modelId in the enabled function pool; selects only this item (legacy bare model id accepted)' }
447
471
  },
448
472
  required: []
449
473
  },
450
474
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
451
475
  isConcurrencySafe: () => false,
452
476
  async execute(args, exec) {
453
- const submitted = await runAction(dshHost(ctx), 'video', { ...args, session_id: exec.agent?.session?.id }, { signal: exec.signal });
477
+ const submitted = await runAction(dshHost(ctx), 'video', { ...args, session_id: exec.agent?.session?.id }, { signal: exec.signal, core });
454
478
  if (submitted.storage === 'core') {
455
479
  let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
456
480
  if (!coreTaskWaitFinished(final)) {
457
- final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.video, signal: exec.signal });
481
+ final = await awaitCoreTerminal(core, submitted.taskId, { timeoutMs: AWAIT_MS.video, signal: exec.signal });
458
482
  }
459
483
  if (!final) {
460
484
  return `[iris] Core 视频任务已提交并转入后台观察(task: ${submitted.taskId},remoteTask: ${submitted.remoteTaskId})。\n` +
@@ -487,12 +511,12 @@ export async function apply(ctx) {
487
511
  }
488
512
  });
489
513
 
490
- safeRegister(host, {
514
+ registerIrisTool({
491
515
  name: 'iris_generate_music',
492
- description: 'Generate music using a model explicitly assigned to Music. Returns audio artifact IDs and playback links. Lyrics, vocals and duration are prompt intentions. Configured 502 retries may generate again.',
516
+ description: 'Generate music using a model in the enabled Music pool. Returns audio artifact IDs and playback links. Lyrics, vocals and duration are prompt intentions. Configured 502 retries may generate again.',
493
517
  parameters: { type: 'object', properties: {
494
518
  prompt: { type: 'string', description: 'Describe the music, including any lyrics, vocals, mood or intended duration' },
495
- model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item' },
519
+ model: { type: 'string', description: 'Optional providerId::modelId in the enabled Music pool; selects only this item' },
496
520
  timeout_ms: { type: 'integer', description: 'Per-attempt timeout; default 300000 ms' },
497
521
  retry_502: { type: 'integer', description: '502 regeneration retries, 0–5; default 2; each retry consumes another request' },
498
522
  retry_delay_ms: { type: 'integer', description: 'Delay between 502 retries; default 5000 ms' }
@@ -500,14 +524,14 @@ export async function apply(ctx) {
500
524
  output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
501
525
  isConcurrencySafe: () => true,
502
526
  async execute(args, exec) {
503
- const submitted = await runAction(dshHost(ctx), 'music', args, { signal: exec.signal });
527
+ const submitted = await runAction(dshHost(ctx), 'music', args, { signal: exec.signal, core });
504
528
  return submitted.text + '\nartifact: ' + submitted.artifactIds.join(', ') + '\n' +
505
529
  submitted.artifactIds.map(id => '[♪ 音乐播放](/iris/api/core/artifact/' + encodeURIComponent(id) + '/media)').join('\n');
506
530
  }
507
531
  });
508
532
 
509
533
  /* ---------- 🔊 语音合成 ---------- */
510
- safeRegister(host, {
534
+ registerIrisTool({
511
535
  name: 'iris_speak_text',
512
536
  description: 'Synthesize spoken audio from text using the configured TTS model. Returns Core audio Artifact IDs and playback links; export an Artifact to a host file before passing it as audio_path to another tool. Use iris_generate_music for songs or background music.',
513
537
  parameters: {
@@ -515,14 +539,14 @@ export async function apply(ctx) {
515
539
  properties: {
516
540
  text: { type: 'string', description: 'Text to speak' },
517
541
  voice: { type: 'string', description: "Voice name, e.g. 'Cherry' (DashScope qwen-tts voices)" },
518
- model: { type: 'string', description: 'Optional assigned providerId::modelId; selects only this item (legacy bare model id accepted)' }
542
+ model: { type: 'string', description: 'Optional providerId::modelId in the enabled function pool; selects only this item (legacy bare model id accepted)' }
519
543
  },
520
544
  required: ['text']
521
545
  },
522
546
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
523
547
  isConcurrencySafe: () => true,
524
548
  async execute(args, exec) {
525
- const submitted = await runAction(dshHost(ctx), 'tts', args, { signal: exec.signal });
549
+ const submitted = await runAction(dshHost(ctx), 'tts', args, { signal: exec.signal, core });
526
550
  if (submitted.storage === 'core') {
527
551
  const final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
528
552
  const terminalProblem = coreTaskTerminalProblem(final, '语音合成');
@@ -545,25 +569,25 @@ export async function apply(ctx) {
545
569
  });
546
570
 
547
571
  /* ---------- 🎙️ 音频转写(阶段 7.2) ---------- */
548
- safeRegister(host, {
572
+ registerIrisTool({
549
573
  name: 'iris_transcribe_audio',
550
574
  description: 'Transcribe an audio file (wav/mp3) to text using the configured DashScope provider (qwen-audio-3.0-asr-flash-filetrans). The audio is uploaded to Bailian temp storage, then an async task is submitted. Returns the full recognized text.',
551
575
  parameters: {
552
576
  type: 'object',
553
577
  properties: {
554
578
  audio_path: { type: 'string', description: 'Absolute path to the audio file (wav/mp3)' },
555
- model: { type: 'string', description: 'Optional assigned transcription providerId::modelId; selects only this item (legacy bare model id accepted)' }
579
+ model: { type: 'string', description: 'Optional providerId::modelId in the enabled transcription pool; selects only this item (legacy bare model id accepted)' }
556
580
  },
557
581
  required: ['audio_path']
558
582
  },
559
583
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
560
584
  isConcurrencySafe: () => false,
561
585
  async execute(args, exec) {
562
- const submitted = await runAction(dshHost(ctx), 'transcribe', args, { signal: exec.signal });
586
+ const submitted = await runAction(dshHost(ctx), 'transcribe', args, { signal: exec.signal, core });
563
587
  if (submitted.storage === 'core') {
564
588
  let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
565
589
  if (!coreTaskWaitFinished(final)) {
566
- final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.transcribe, signal: exec.signal });
590
+ final = await awaitCoreTerminal(core, submitted.taskId, { timeoutMs: AWAIT_MS.transcribe, signal: exec.signal });
567
591
  }
568
592
  if (!final) {
569
593
  return `[iris] Core 转写任务已提交并转入后台观察(task: ${submitted.taskId})。完成后用 iris_task_status 查询进度与 Artifact。`;
@@ -588,7 +612,7 @@ export async function apply(ctx) {
588
612
  });
589
613
 
590
614
  /* ---------- 🎞️ 视频抽帧(阶段 7.1,ffmpeg 可选) ---------- */
591
- safeRegister(host, {
615
+ registerIrisTool({
592
616
  name: 'iris_video_frames',
593
617
  description:
594
618
  'Extract N frames (uniformly sampled across the video) from a local video file using the system ffmpeg. ' +
@@ -646,7 +670,7 @@ export async function apply(ctx) {
646
670
  });
647
671
 
648
672
  /* ---------- 📝 多模态视频摘要(阶段 7.3,ffmpeg 可选) ---------- */
649
- safeRegister(host, {
673
+ registerIrisTool({
650
674
  name: 'iris_media_summarize',
651
675
  description:
652
676
  'Summarize a local video or existing Core video-frame Artifacts as one contact sheet (with timestamps) plus an optional ' +
@@ -664,7 +688,7 @@ export async function apply(ctx) {
664
688
  target_width: { type: 'integer', description: 'Frame width for sampling (default 640)' },
665
689
  transcribe: { type: 'boolean', description: 'Auto-transcribe the audio track for video_path (default true unless transcribe_text is provided). Existing frames only consume explicit text.' },
666
690
  transcribe_text: { type: 'string', description: 'Optional existing transcript text; does not create a transcription task' },
667
- model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
691
+ model: { type: 'string', description: 'Optional item from the enabled vision pool (providerId::modelId or host:current-vision); selects only this item' }
668
692
  },
669
693
  required: []
670
694
  },
@@ -679,7 +703,7 @@ export async function apply(ctx) {
679
703
  if (!ffmpegAvailable()) throw new Error('iris: ffmpeg 不可用,无法分析视频(可选安装 ffmpeg/ffprobe 后启用)');
680
704
  }
681
705
  const { answer, via, model, sheet, meta, frames, transcriptNote } = await runSummaryRequest(dshHost(ctx), {
682
- providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, model: args.model, question: args.question, signal: exec.signal,
706
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability, model: args.model, question: args.question, signal: exec.signal,
683
707
  async prepareMedia(signal) {
684
708
  if (source === 'core-artifacts') {
685
709
  return { ...await summaryFramesForDsh({ frameArtifactIds: args.frame_artifact_ids, signal }), transcript: args.transcribe_text };
@@ -696,8 +720,8 @@ export async function apply(ctx) {
696
720
  try {
697
721
  const audio = await extractAudioTrack({ inputPath: p, signal });
698
722
  try {
699
- const submitted = await runAction(dshHost(ctx), 'transcribe', { audio_path: audio.filePath }, { signal });
700
- const final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: 120000, signal });
723
+ const submitted = await runAction(dshHost(ctx), 'transcribe', { audio_path: audio.filePath }, { signal, core });
724
+ const final = await awaitCoreTerminal(core, submitted.taskId, { timeoutMs: 120000, signal });
701
725
  if (final?.outcome === 'succeeded' && final.deliveryState === 'ready') {
702
726
  const texts = [];
703
727
  for (const artifactId of final.artifactIds) {
@@ -745,7 +769,7 @@ export async function apply(ctx) {
745
769
 
746
770
  /* ---------- 👁 视觉路由(M3):显式工具,自持栈为主 ---------- */
747
771
 
748
- safeRegister(host, {
772
+ registerIrisTool({
749
773
  name: 'iris_look_at_image',
750
774
  description:
751
775
  'Look at a local image file or an existing Core image Artifact and answer one question with the configured vision model (self-hosted provider stack, then DSH default vision). Provide exactly one of image_path or artifact_id. Local files are saved as durable DSH attachments; Core images are read directly without export or Core writes.',
@@ -755,7 +779,7 @@ export async function apply(ctx) {
755
779
  image_path: { type: 'string', description: 'Absolute path to the image file (png/jpg/jpeg/webp)' },
756
780
  artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path)' },
757
781
  question: { type: 'string', description: 'Question or extraction request about the image; defaults to a detailed description' },
758
- model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
782
+ model: { type: 'string', description: 'Optional item from the enabled vision pool (providerId::modelId or host:current-vision); selects only this item' }
759
783
  },
760
784
  required: []
761
785
  },
@@ -784,7 +808,7 @@ export async function apply(ctx) {
784
808
  }
785
809
  });
786
810
 
787
- safeRegister(host, {
811
+ registerIrisTool({
788
812
  name: 'iris_relook_attachment',
789
813
  description:
790
814
  'Ask a NEW question about an image that was already seen in this session: pass its attachment_id (from user uploads, tool results, or any iris-generated image). Pixels are re-read via the vision model.',
@@ -793,7 +817,7 @@ export async function apply(ctx) {
793
817
  properties: {
794
818
  attachment_id: { type: 'string', description: 'The attachment_id of an image that appeared in this session or was generated by iris' },
795
819
  question: { type: 'string', description: 'New question about the image pixels' },
796
- model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
820
+ model: { type: 'string', description: 'Optional item from the enabled vision pool (providerId::modelId or host:current-vision); selects only this item' }
797
821
  },
798
822
  required: ['attachment_id', 'question']
799
823
  },
@@ -820,7 +844,7 @@ export async function apply(ctx) {
820
844
  });
821
845
 
822
846
  /* ---------- 📋 任务查询 ---------- */
823
- safeRegister(host, {
847
+ registerIrisTool({
824
848
  name: 'iris_task_status',
825
849
  description:
826
850
  'Query Iris tasks (image/video/tts/music/transcription). Pass a task_id for one task, or omit it to list recent tasks. Core results provide Artifact IDs and playback links; legacy tasks may provide file paths. Querying does not re-observe or redeliver a task.',
@@ -834,13 +858,13 @@ export async function apply(ctx) {
834
858
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
835
859
  isConcurrencySafe: () => true,
836
860
  async execute(args) {
837
- const result = await runAction(dshHost(ctx), 'status', args || {});
861
+ const result = await runAction(dshHost(ctx), 'status', args || {}, { core });
838
862
  return result.text;
839
863
  }
840
864
  });
841
865
 
842
866
  /* ---------- ✂️ 确定性像素工具(阶段 2) ---------- */
843
- safeRegister(host, {
867
+ registerIrisTool({
844
868
  name: 'iris_crop',
845
869
  description:
846
870
  'Crop a rectangular region from an image (absolute local path or a session attachment_id) and save the result as a durable DSH attachment. ' +
@@ -890,7 +914,7 @@ export async function apply(ctx) {
890
914
  }
891
915
  });
892
916
 
893
- safeRegister(host, {
917
+ registerIrisTool({
894
918
  name: 'iris_pixel_diff',
895
919
  description:
896
920
  'Compute a pixel-level difference between two images (absolute local paths or session attachment_ids). ' +
@@ -945,7 +969,7 @@ export async function apply(ctx) {
945
969
  });
946
970
 
947
971
  /* ---------- 📍 模型驱动定位(阶段 3A) ---------- */
948
- safeRegister(host, {
972
+ registerIrisTool({
949
973
  name: 'iris_locate',
950
974
  description:
951
975
  'Locate an object/region described by `target` in an image (exactly one of absolute image_path, Core artifact_id, or session attachment_id), ' +
@@ -958,7 +982,7 @@ export async function apply(ctx) {
958
982
  artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path or attachment_id)' },
959
983
  attachment_id: { type: 'string', description: 'Session attachment id of an image (alternative to image_path)' },
960
984
  target: { type: 'string', description: 'The object or UI element to locate, e.g. "send button"' },
961
- model: { type: 'string', description: 'Optional item from the vision assignment list (providerId::modelId or host:current-vision); selects only this item' }
985
+ model: { type: 'string', description: 'Optional item from the enabled vision pool (providerId::modelId or host:current-vision); selects only this item' }
962
986
  },
963
987
  required: ['target']
964
988
  },
@@ -966,8 +990,8 @@ export async function apply(ctx) {
966
990
  isConcurrencySafe: () => true,
967
991
  async execute(args, exec) {
968
992
  const r = await runLocateRequest(dshHost(ctx), {
969
- providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, model: args.model, target: args.target,
970
- prepareImage: signal => resolveVisionImageInput(dshHost(ctx), exec, args, signal), signal: exec.signal
993
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability, model: args.model, target: args.target,
994
+ prepareImage: signal => resolveVisionImageInput(core, dshHost(ctx), exec, args, signal), signal: exec.signal
971
995
  });
972
996
  const { width, height } = r;
973
997
  const sourceNote = args.artifact_id ? `\nCore Artifact: ${args.artifact_id}` : '';
@@ -980,7 +1004,7 @@ export async function apply(ctx) {
980
1004
  });
981
1005
 
982
1006
  /* ---------- 🖼️ HTML 截图(阶段 3C,基于 dsh-builtin-browser) ---------- */
983
- safeRegister(host, {
1007
+ registerIrisTool({
984
1008
  name: 'iris_html_screenshot',
985
1009
  description:
986
1010
  'Render an HTML string to a page screenshot using the shared browser. ' +
@@ -1033,7 +1057,7 @@ export async function apply(ctx) {
1033
1057
  });
1034
1058
 
1035
1059
  /* ---------- 📄 长截图 OCR(阶段 3B,视觉模型分块) ---------- */
1036
- safeRegister(host, {
1060
+ registerIrisTool({
1037
1061
  name: 'iris_long_ocr',
1038
1062
  description:
1039
1063
  'OCR a (possibly long) image from exactly one of image_path, Core artifact_id, or session attachment_id: slice it into chunks (1200px tall with 120px overlap by default) ' +
@@ -1055,8 +1079,8 @@ export async function apply(ctx) {
1055
1079
  async execute(args, exec) {
1056
1080
  const host = dshHost(ctx);
1057
1081
  const result = await runOcrRequest(host, {
1058
- providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit,
1059
- prepareImage: operationSignal => resolveVisionImageInput(host, exec, args, operationSignal),
1082
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability,
1083
+ prepareImage: operationSignal => resolveVisionImageInput(core, host, exec, args, operationSignal),
1060
1084
  chunkHeight: args.chunk_height,
1061
1085
  overlap: args.overlap,
1062
1086
  signal: exec.signal
@@ -1115,10 +1139,10 @@ async function resolveImageInput(host, exec, { image_path, attachment_id }) {
1115
1139
  }
1116
1140
 
1117
1141
  /** 视觉解引用在整体预算内;路径限额读,Host 会话/附件等待可取消。 */
1118
- async function resolveVisionImageInput(host, exec, input, signal) {
1142
+ async function resolveVisionImageInput(core, host, exec, input, signal) {
1119
1143
  const source = normalizeVisionImageSource(input, { allowAttachment: true });
1120
1144
  const { image_path, artifact_id, attachment_id } = input;
1121
- if (source === 'artifact_id') return visionImageForDsh({ artifactId: artifact_id, signal });
1145
+ if (source === 'artifact_id') return core.visionImageForDsh({ artifactId: artifact_id, signal });
1122
1146
  if (image_path) {
1123
1147
  const file = String(image_path).trim();
1124
1148
  const mediaType = MEDIA_TYPES[path.extname(file).toLowerCase()];
@@ -1180,7 +1204,8 @@ export async function sessionAttachmentRef(host, exec, attachmentId) {
1180
1204
  */
1181
1205
  export async function askVision(host, { question, dataUrl, image, prepareImage, signal, model, budget }) {
1182
1206
  const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION);
1183
- return runVisionRequest(host, { providers: visionProviders, model, question, dataUrl, image, prepareImage, signal, budget, onRateLimit: store.recordRateLimit });
1207
+ return runVisionRequest(host, { providers: visionProviders, model, question, dataUrl, image, prepareImage, signal, budget,
1208
+ onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability });
1184
1209
  }
1185
1210
 
1186
1211
  /**
@@ -0,0 +1,27 @@
1
+ // HTTP DTOs whitelist existing Core facts; never expose records or configuration wholesale.
2
+ const STATUSES = new Set(['not_started', 'running', 'needs_attention', 'delivery_failed',
3
+ 'needs_comparison', 'awaiting_selection', 'selected']);
4
+
5
+ export function projectLocalApiFlow(facts) {
6
+ if (facts.contractVersion !== 0 || facts.run.recipe !== 'image-edit/v0' || !STATUSES.has(facts.status)) {
7
+ throw new TypeError('不支持的流程契约或状态');
8
+ }
9
+ const { run, task } = facts;
10
+ const ref = value => value ? { artifactId: value.artifactId } : null;
11
+ return {
12
+ contractVersion: 0, id: run.id, recipe: run.recipe, revision: run.revision,
13
+ createdAt: run.createdAt, updatedAt: run.updatedAt, status: facts.status,
14
+ input: { prompt: run.input.prompt, modelRef: run.input.model_ref,
15
+ ...(run.input.size !== undefined ? { size: run.input.size } : {}) },
16
+ source: ref(run.source), output: ref(run.output), comparison: ref(run.comparison), selection: ref(run.selection),
17
+ task: task ? { id: task.id, acceptance: task.acceptance, outcome: task.outcome,
18
+ deliveryState: task.deliveryState, attemptCount: task.attempts.length } : null,
19
+ metrics: facts.metrics ? Object.fromEntries(['ratio', 'diffPixels', 'totalPixels', 'width', 'height']
20
+ .map(key => [key, facts.metrics[key]])) : null
21
+ };
22
+ }
23
+
24
+ export function projectLocalApiRunSummary(run) {
25
+ return { id: run.id, recipe: run.recipe, createdAt: run.createdAt,
26
+ modelRef: run.modelRef, source: { artifactId: run.source.artifactId } };
27
+ }