@mokuyoaxis/dsh-iris 0.2.0 → 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 (88) 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 +47 -2
  5. package/README.en.md +65 -29
  6. package/README.md +65 -29
  7. package/bin/dsh-iris.js +222 -21
  8. package/docs/AGENT_QUICKSTART.md +10 -6
  9. package/docs/ARCHITECTURE.md +25 -18
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +1 -1
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +8 -7
  15. package/docs/DSH_CORE_MIGRATION.md +48 -19
  16. package/docs/DSH_RC2_ADAPTATION.md +4 -2
  17. package/docs/FAULT_INJECTION.md +12 -2
  18. package/docs/HEADLESS_CLI.md +13 -7
  19. package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
  20. package/docs/HOST_DOCTOR.md +3 -3
  21. package/docs/MODEL_PORT_CONTRACT.md +19 -14
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +6 -4
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +29 -19
  29. package/docs/SECURITY.md +5 -5
  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/file-access-across-environments.md +8 -5
  35. package/docs/releases/0.2.1.md +56 -0
  36. package/lib/actions.js +187 -270
  37. package/lib/adapters.js +133 -22
  38. package/lib/api.js +49 -7
  39. package/lib/chromium-browser.js +117 -0
  40. package/lib/client.js +671 -101
  41. package/lib/command-service.js +121 -7
  42. package/lib/composite-vision-routing.js +42 -0
  43. package/lib/config.js +89 -10
  44. package/lib/core-artifact-store.js +45 -13
  45. package/lib/core-maintenance.js +213 -0
  46. package/lib/core-tasks.js +12 -5
  47. package/lib/core-user-projection.js +1 -0
  48. package/lib/dsh-core-adapter.js +63 -9
  49. package/lib/dsh-text-model-adapter.js +222 -0
  50. package/lib/dsh-vision-model-adapter.js +75 -0
  51. package/lib/generation-input.js +36 -5
  52. package/lib/headless-vision.js +181 -0
  53. package/lib/host-runtime.js +1 -0
  54. package/lib/http-vision-model-adapter.js +187 -0
  55. package/lib/image-edit-input.js +34 -0
  56. package/lib/index.js +205 -175
  57. package/lib/locate.js +86 -28
  58. package/lib/media-probe.js +2 -1
  59. package/lib/model-call-runtime.js +45 -0
  60. package/lib/model-invoker.js +4 -2
  61. package/lib/model-port-contract.js +13 -5
  62. package/lib/models.js +10 -5
  63. package/lib/ocr-model-routing.js +26 -0
  64. package/lib/ocr.js +110 -64
  65. package/lib/prompt-optimizer-config.js +2 -1
  66. package/lib/prompt-optimizer-core.js +121 -0
  67. package/lib/prompt-optimizer.js +37 -193
  68. package/lib/provider-adapters.js +85 -9
  69. package/lib/provider-catalog.js +19 -7
  70. package/lib/provider-config-service.js +236 -0
  71. package/lib/provider-contract.js +3 -2
  72. package/lib/provider-health.js +80 -1
  73. package/lib/provider-protocol.js +23 -0
  74. package/lib/provider-task-runner.js +58 -16
  75. package/lib/summarize.js +31 -20
  76. package/lib/summary-input.js +52 -0
  77. package/lib/task-wait.js +40 -0
  78. package/lib/video-input.js +30 -0
  79. package/lib/vision-core.js +42 -0
  80. package/lib/vision-image-input.js +83 -0
  81. package/lib/vision-input.js +31 -0
  82. package/lib/vision-model-routing.js +180 -0
  83. package/lib/vision.js +3 -2
  84. package/lib/workbench-artifacts.js +52 -0
  85. package/lib/workbench-management.js +151 -0
  86. package/lib/workbench-zip.js +39 -0
  87. package/package.json +1 -1
  88. package/user_guide.md +52 -22
package/lib/index.js CHANGED
@@ -14,19 +14,22 @@ import * as cap from './capability.js';
14
14
  import * as tasks from './tasks.js';
15
15
  import { registerMedia, mediaLinksOf, authorizeMedia, serveMedia } from './media.js';
16
16
  import { serveApi, closeAllSse, purgeStaleUploads } from './api.js';
17
- import { buildVisionBackendsFromHost, askWithBackends } from './vision.js';
17
+ import { runVisionRequest, readVisionImageFile } from './vision-model-routing.js';
18
+ import { waitForModelWork } from './model-call-runtime.js';
18
19
  import { hasHostPort, requireHostPort } from './host-contract.js';
19
20
  import { createDshHostAdapter } from './dsh-host-adapter.js';
20
- import { cropForDsh, diffForDsh, framesForDsh, inspectProviderTaskForDsh, projectCoreTaskForDsh, readCoreArtifactMediaForDsh, resumeProviderTaskWatchesForDsh, stopProviderTaskWatchesForDsh } from './dsh-core-adapter.js';
21
+ import { cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, projectCoreTaskForDsh, readCoreArtifactMediaForDsh, resumeProviderTaskWatchesForDsh, stopProviderTaskWatchesForDsh } from './dsh-core-adapter.js';
21
22
  import { imageDimensions } from './pixels.js';
22
- import { locateObject } from './locate.js';
23
+ import { runLocateRequest, runSummaryRequest, describeGeneratedImage } from './composite-vision-routing.js';
24
+ import { normalizeSummarySource, summaryFrameRecords, summaryTimelineLabel } from './summary-input.js';
25
+ import { normalizeVisionImageSource } from './vision-input.js';
23
26
  import { serveRender } from './render.js';
24
27
  import { ensurePrivateDir, hardenPrivateTree } from './private-storage.js';
25
28
  import { guarded } from './guard.js';
26
- import { longOcr } from './ocr.js';
29
+ import { formatOcrResult } from './ocr.js';
30
+ import { runOcrRequest } from './ocr-model-routing.js';
27
31
  import { runAction, listActions, taskPollDeps } from './actions.js';
28
32
  import { ffmpegAvailable, extractFrames, probeVideo, extractAudioTrack } from './media-probe.js';
29
- import { buildContactSheet, summarizeMedia } from './summarize.js';
30
33
  import { registerBundledSkills } from './bundled-skills.js';
31
34
  import {
32
35
  EXPECTED_IRIS_ROUTES, IRIS_PLUGIN_ID, beginHostRuntime, recordHostRoutes, recordHostSkills, recordHostTool
@@ -187,13 +190,6 @@ async function awaitCoreTerminal(taskId, { timeoutMs, signal }) {
187
190
  }
188
191
  }
189
192
 
190
- /* ---------------- 图片 data URL(视觉工具共用) ---------------- */
191
-
192
- function toDataUrl(absPath, mediaType) {
193
- const buf = fs.readFileSync(absPath);
194
- return `data:${mediaType};base64,${buf.toString('base64')}`;
195
- }
196
-
197
193
  export const name = 'mokuyoaxis-dsh-iris';
198
194
  export const inject = ['tools'];
199
195
 
@@ -283,11 +279,41 @@ export async function apply(ctx) {
283
279
  return;
284
280
  }
285
281
 
282
+ async function coreImageToolResult(submitted, prompt, signal) {
283
+ let final = await inspectProviderTaskForDsh(submitted.taskId, { signal });
284
+ if (!coreTaskWaitFinished(final)) final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal });
285
+ if (!final) return { blocks: [{ type: 'text', text: `[iris] Core 图片任务仍在后台观察(task: ${submitted.taskId})。稍后可用 iris_task_status 查询进度与 Artifact。` }] };
286
+ const terminalProblem = coreTaskTerminalProblem(final, '图像生成');
287
+ if (terminalProblem) throw new Error(terminalProblem);
288
+ const projected = await projectCoreTaskForDsh(dshHost(ctx), submitted.taskId, { signal });
289
+ const refs = projected.artifacts.map((item) => item.attachment);
290
+ const blocks = projected.artifacts.map((item) => ({ type: 'image', attachment: item.attachment }));
291
+ let note = '';
292
+ try {
293
+ const described = await describeGeneratedImage(dshHost(ctx), {
294
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, originalPrompt: prompt, signal,
295
+ async prepareImage(inputSignal) {
296
+ const first = await readCoreArtifactMediaForDsh(projected.artifacts[0].artifact.id, { signal: inputSignal });
297
+ return { bytes: new Uint8Array(first.bytes), mediaType: first.artifact.mediaType };
298
+ }
299
+ });
300
+ if (described) note = `画面内容:${described}`;
301
+ } catch (_) { /* 自述失败不影响 Core 交付 */ }
302
+ blocks.unshift({ type: 'text', text:
303
+ `[iris] 图像已生成(${submitted.model},Core task: ${submitted.taskId})。` +
304
+ (submitted.sourceArtifactId ? `\n原图 artifact: ${submitted.sourceArtifactId}` : '') +
305
+ (note ? `\n${note}` : '') +
306
+ `\nartifact: ${projected.artifacts.map((item) => item.artifact.id).join(', ')}` +
307
+ `\nattachment: ${refs.map((ref) => ref.attachmentId).join(', ')}`
308
+ });
309
+ return { blocks };
310
+ }
311
+
286
312
  /* ---------- 🖼️ 画图 ---------- */
287
313
  safeRegister(host, {
288
314
  name: 'iris_draw_image',
289
315
  description:
290
- 'Generate an image from a detailed prompt using the user-configured image model (DashScope wan* or OpenAI-images-compatible). ' +
316
+ 'Generate an image from a detailed prompt using the user-configured image model and its configured image protocol. ' +
291
317
  'Returns a durable DSH attachment rendered in the conversation plus a vision-model description so you know what was drawn.',
292
318
  parameters: {
293
319
  type: 'object',
@@ -308,32 +334,7 @@ export async function apply(ctx) {
308
334
  const prompt = String(args.prompt || '').trim();
309
335
  const submitted = await runAction(dshHost(ctx), 'image', args, { signal: exec.signal });
310
336
  if (submitted.storage === 'core') {
311
- let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
312
- if (!coreTaskWaitFinished(final)) {
313
- final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal: exec.signal });
314
- }
315
- if (!final) {
316
- return { blocks: [{ type: 'text', text: `[iris] Core 图片任务仍在后台观察(task: ${submitted.taskId})。稍后可用 iris_task_status 查询进度与 Artifact。` }] };
317
- }
318
- const terminalProblem = coreTaskTerminalProblem(final, '图像生成');
319
- if (terminalProblem) throw new Error(terminalProblem);
320
- const projected = await projectCoreTaskForDsh(dshHost(ctx), submitted.taskId, { signal: exec.signal });
321
- const refs = projected.artifacts.map((item) => item.attachment);
322
- const blocks = projected.artifacts.map((item) => ({ type: 'image', attachment: item.attachment }));
323
- let note = '';
324
- try {
325
- const first = await readCoreArtifactMediaForDsh(projected.artifacts[0].artifact.id, { signal: exec.signal });
326
- const described = await describeImageData(dshHost(ctx), refs[0],
327
- `data:${first.artifact.mediaType};base64,${first.bytes.toString('base64')}`, prompt);
328
- if (described) note = `画面内容:${described}`;
329
- } catch (_) { /* 自述是增强项,失败不影响 Core 交付 */ }
330
- blocks.unshift({ type: 'text', text:
331
- `[iris] 图像已生成(${submitted.model},Core task: ${submitted.taskId})。` +
332
- (note ? `\n${note}` : '') +
333
- `\nartifact: ${projected.artifacts.map((item) => item.artifact.id).join(', ')}` +
334
- `\nattachment: ${refs.map((ref) => ref.attachmentId).join(', ')}`
335
- });
336
- return { blocks };
337
+ return coreImageToolResult(submitted, prompt, exec.signal);
337
338
  }
338
339
  let final = tasks.get(submitted.taskId);
339
340
  if (final && !taskWaitFinished(final)) {
@@ -364,7 +365,10 @@ export async function apply(ctx) {
364
365
 
365
366
  let note = '';
366
367
  try {
367
- const described = await describeFirstImage(dshHost(ctx), refs[0], files[0], prompt);
368
+ const described = await describeGeneratedImage(dshHost(ctx), {
369
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, originalPrompt: prompt, signal: exec.signal,
370
+ prepareImage: signal => readVisionImageFile(files[0], MEDIA_TYPES[path.extname(files[0]).toLowerCase()] || 'image/png', signal)
371
+ });
368
372
  if (described) note = `画面内容:${described}`;
369
373
  } catch (_) { /* 增强失败不影响生成结果 */ }
370
374
  blocks.unshift({
@@ -377,6 +381,25 @@ export async function apply(ctx) {
377
381
  }
378
382
  });
379
383
 
384
+ safeRegister(host, {
385
+ name: 'iris_edit_image',
386
+ description: 'Edit an existing Core image by Artifact ID and an instruction using a configured openai-chat-images model. Returns new Core Artifacts and DSH image attachments; preserves the source image and records its provenance.',
387
+ parameters: {
388
+ type: 'object', properties: {
389
+ source_artifact_id: { type: 'string', description: 'Core Artifact ID of the source PNG, JPEG or WebP image (up to 20 MiB)' },
390
+ prompt: { type: 'string', description: 'Describe the changes and what should be preserved in the source image' },
391
+ model: { type: 'string', description: 'Optional providerId::modelId; image protocol must be openai-chat-images' },
392
+ n: { type: 'string', description: "Number of images, default '1'" }
393
+ }, required: ['source_artifact_id', 'prompt']
394
+ },
395
+ output: { schema: { type: 'object' }, render: (_args, value) => value.blocks },
396
+ isConcurrencySafe: () => false,
397
+ async execute(args, exec) {
398
+ const submitted = await runAction(dshHost(ctx), 'image_edit', args, { signal: exec.signal });
399
+ return coreImageToolResult(submitted, String(args.prompt || '').trim(), exec.signal);
400
+ }
401
+ });
402
+
380
403
  /* ---------- 🎬 视频生成 ---------- */
381
404
  safeRegister(host, {
382
405
  name: 'iris_generate_video',
@@ -389,7 +412,7 @@ export async function apply(ctx) {
389
412
  parameters: {
390
413
  type: 'object',
391
414
  properties: {
392
- prompt: { type: 'string', description: 'Detailed description of the motion/scene for the video' },
415
+ prompt: { type: 'string', description: 'Motion/scene description, required for t2v/i2v; optional for s2v' },
393
416
  first_frame_attachment_id: { type: 'string', description: 'Attachment id of an image generated by iris_draw_image, used as the first frame' },
394
417
  first_frame_path: { type: 'string', description: 'Absolute path to a local image used as the first frame (alternative to attachment id)' },
395
418
  audio_path: { type: 'string', description: 'Absolute path to a wav/mp3 file (<15MB, <20s, clear human voice). Required for s2v models like wan2.2-s2v' },
@@ -398,12 +421,12 @@ export async function apply(ctx) {
398
421
  duration: { type: 'number', description: 't2v/i2v only: duration in seconds, if the model supports it' },
399
422
  model: { type: 'string', description: 'Override with a providerId::modelId reference (legacy bare model id accepted)' }
400
423
  },
401
- required: ['prompt']
424
+ required: []
402
425
  },
403
426
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
404
427
  isConcurrencySafe: () => false,
405
428
  async execute(args, exec) {
406
- const submitted = await runAction(dshHost(ctx), 'video', args, { signal: exec.signal });
429
+ const submitted = await runAction(dshHost(ctx), 'video', { ...args, session_id: exec.agent?.session?.id }, { signal: exec.signal });
407
430
  if (submitted.storage === 'core') {
408
431
  let final = await inspectProviderTaskForDsh(submitted.taskId, { signal: exec.signal });
409
432
  if (!coreTaskWaitFinished(final)) {
@@ -572,6 +595,7 @@ export async function apply(ctx) {
572
595
  `[iris] 视频抽帧完成:${frames.length} 帧(${meta.durationSec.toFixed(1)}s 视频,` +
573
596
  `${frames[0].width}x${frames[0].height},${mediaType === 'image/png' ? 'PNG' : 'JPEG'})。\n` +
574
597
  `时间戳:${ats}\n` +
598
+ `frame_artifact_ids: ${JSON.stringify(frames.map(f => f.artifact.id))}\n` +
575
599
  `attachments: ${refs.map((r) => r.attachmentId).join(', ')}`
576
600
  });
577
601
  return { blocks, artifactIds: frames.map((frame) => frame.artifact.id) };
@@ -582,83 +606,91 @@ export async function apply(ctx) {
582
606
  safeRegister(host, {
583
607
  name: 'iris_media_summarize',
584
608
  description:
585
- 'Summarize a local video by compressing it into a contact sheet of sampled frames (with timestamps) plus an optional ' +
609
+ 'Summarize a local video or existing Core video-frame Artifacts as one contact sheet (with timestamps) plus an optional ' +
586
610
  'auto-transcribed audio track, then asking the vision model to describe content. Returns the summary text plus the contact sheet as a DSH image attachment. ' +
587
- 'Requires ffmpeg + ffprobe on PATH. Transcription reuses the configured provider (qwen-audio-3.0-asr-flash-filetrans) when the video has an audio track.',
611
+ 'Provide exactly one of video_path or frame_artifact_ids. Existing frames are read in timestamp order without ffmpeg or the original video. ' +
612
+ 'The video_path mode requires ffmpeg + ffprobe and can auto-transcribe the audio track with the configured provider.',
588
613
  parameters: {
589
614
  type: 'object',
590
615
  properties: {
591
616
  video_path: { type: 'string', description: 'Absolute path to the local video file (mp4/webm/mov etc.)' },
617
+ frame_artifact_ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 20, uniqueItems: true,
618
+ description: 'Existing Core video-frame IDs from iris_video_frames; sorted by timestamp. Use instead of video_path, without sampling options or auto-transcription.' },
592
619
  question: { type: 'string', description: 'Custom question about the video content (default: summarize scenes/theme)' },
593
620
  max_frames: { type: 'integer', description: 'Frames to sample for the contact sheet, clamped to 1..12 (default 8)' },
594
621
  target_width: { type: 'integer', description: 'Frame width for sampling (default 640)' },
595
- transcribe: { type: 'boolean', description: 'Auto-transcribe the audio track if present (default true)' },
622
+ 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.' },
623
+ transcribe_text: { type: 'string', description: 'Optional existing transcript text; does not create a transcription task' },
596
624
  model: { type: 'string', description: 'Override the configured vision model id' }
597
625
  },
598
- required: ['video_path']
626
+ required: []
599
627
  },
600
628
  output: { schema: { type: 'object' }, render: (_args, value) => value.blocks },
601
629
  isConcurrencySafe: () => false,
602
630
  async execute(args, exec) {
603
631
  if (exec.signal && exec.signal.aborted) throw new Error('已取消');
632
+ const source = normalizeSummarySource(args);
604
633
  const p = String(args.video_path || '').trim();
605
- if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
606
- if (!ffmpegAvailable()) throw new Error('iris: ffmpeg 不可用,无法分析视频(可选安装 ffmpeg/ffprobe 后启用)');
607
- const meta = probeVideo(p);
608
- const frames = await extractFrames({
609
- inputPath: p,
610
- maxFrames: Math.min(Number(args.max_frames) || 8, 12),
611
- targetWidth: args.target_width,
612
- format: 'jpeg',
613
- quality: 70,
614
- signal: exec.signal
615
- });
616
-
617
- // 音轨转写(可选;与独立 transcribe capability 共用同一动作与 failover)
618
- let transcript = '';
619
- let transcriptNote = '';
620
- const wantTranscribe = args.transcribe !== false && meta.hasAudio;
621
- if (wantTranscribe) {
622
- if (store.pickAllFor(cap.CAPABILITIES.TRANSCRIBE).length) {
623
- try {
624
- const audio = await extractAudioTrack({ inputPath: p, signal: exec.signal });
625
- try {
626
- const submitted = await runAction(dshHost(ctx), 'transcribe', { audio_path: audio.filePath }, { signal: exec.signal });
627
- const final = await awaitTerminal(submitted.taskId, { timeoutMs: 120000, signal: exec.signal });
628
- if (final && final.status === 'succeeded') transcript = final.transcribeText || '';
629
- else if (!final) transcriptNote = `\n(音轨转写仍在后台:task ${submitted.taskId},本次摘要未含语音内容)`;
630
- } finally {
631
- fs.rmSync(audio.outDir, { recursive: true, force: true });
632
- }
633
- } catch (err) {
634
- transcriptNote = '\n(音轨转写失败,本次摘要仅画面:' + String(err.message || err).slice(0, 160) + ')';
634
+ if (source === 'video') {
635
+ if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
636
+ if (!ffmpegAvailable()) throw new Error('iris: ffmpeg 不可用,无法分析视频(可选安装 ffmpeg/ffprobe 后启用)');
637
+ }
638
+ const { answer, via, model, sheet, meta, frames, transcriptNote } = await runSummaryRequest(dshHost(ctx), {
639
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, model: args.model, question: args.question, signal: exec.signal,
640
+ async prepareMedia(signal) {
641
+ if (source === 'core-artifacts') {
642
+ return { ...await summaryFramesForDsh({ frameArtifactIds: args.frame_artifact_ids, signal }), transcript: args.transcribe_text };
635
643
  }
636
- } else {
637
- transcriptNote = '\n(视频含音轨但未配置转写供应商,本次摘要仅画面)';
644
+ const meta = probeVideo(p);
645
+ const frames = await extractFrames({
646
+ inputPath: p, maxFrames: Math.min(Number(args.max_frames) || 8, 12),
647
+ targetWidth: args.target_width, format: 'jpeg', quality: 70, signal
648
+ });
649
+ // 显式选项保留自动转写;消费已完成的 Core Artifact,不重提远端任务。
650
+ let transcript = args.transcribe_text || '', transcriptNote = '';
651
+ if (args.transcribe !== false && !transcript.trim() && meta.hasAudio) {
652
+ if (store.pickAllFor(cap.CAPABILITIES.TRANSCRIBE).length) {
653
+ try {
654
+ const audio = await extractAudioTrack({ inputPath: p, signal });
655
+ try {
656
+ const submitted = await runAction(dshHost(ctx), 'transcribe', { audio_path: audio.filePath }, { signal });
657
+ const final = await awaitCoreTerminal(submitted.taskId, { timeoutMs: 120000, signal });
658
+ if (final?.outcome === 'succeeded' && final.deliveryState === 'ready') {
659
+ const texts = [];
660
+ for (const artifactId of final.artifactIds) {
661
+ const media = await readCoreArtifactMediaForDsh(artifactId, { signal });
662
+ texts.push(Buffer.from(media.bytes).toString('utf8'));
663
+ }
664
+ transcript = texts.join('\n');
665
+ } else transcriptNote = '\n(音轨转写未完成,本次摘要仅画面;可用 iris_task_status 查询 Core task)';
666
+ } finally {
667
+ fs.rmSync(audio.outDir, { recursive: true, force: true });
668
+ }
669
+ } catch (err) {
670
+ if (signal.aborted) throw signal.reason;
671
+ transcriptNote = '\n(音轨转写失败,本次摘要仅画面;可用 iris_task_status 查询转写任务)';
672
+ }
673
+ } else transcriptNote = '\n(视频含音轨但未配置转写供应商,本次摘要仅画面)';
674
+ }
675
+ return { meta, frames, transcript, transcriptNote };
638
676
  }
639
- }
640
-
641
- // contact sheet → attachment → 视觉摘要
642
- const sheet = await buildContactSheet({ frames, cols: 3 });
677
+ });
678
+ // 成功摘要的同一张拼图作为显式工具产物发布。
679
+ if (exec.signal?.aborted) throw exec.signal.reason;
643
680
  const ref = await attachmentService(host).saveImage({
644
681
  data: new Uint8Array(sheet.buffer),
645
682
  mediaType: 'image/png',
646
683
  name: `iris-sheet-${Date.now()}.png`
647
684
  });
648
- const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION);
649
- const backends = buildVisionBackendsFromHost(dshHost(ctx), { providers: visionProviders, model: args.model });
650
- const { answer, via, model } = await summarizeMedia({
651
- backends, question: args.question, transcript,
652
- imageDataUrl: `data:image/png;base64,${sheet.buffer.toString('base64')}`,
653
- ref, signal: exec.signal
654
- });
685
+ if (exec.signal?.aborted) throw exec.signal.reason;
655
686
  const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
656
687
  return {
688
+ frames: summaryFrameRecords(frames),
657
689
  blocks: [
658
690
  {
659
691
  type: 'text',
660
692
  text:
661
- `[iris] 视频摘要完成(${model} · ${viaLabel},${meta.durationSec.toFixed(1)}s / ${frames.length} 帧联系表):\n` +
693
+ `[iris] 视频摘要完成(${model} · ${viaLabel},${summaryTimelineLabel(meta)} / ${frames.length} 帧联系表):\n` +
662
694
  `${answer}${transcriptNote || ''}\n` +
663
695
  `contact sheet attachment: ${ref.attachmentId}`
664
696
  },
@@ -673,30 +705,39 @@ export async function apply(ctx) {
673
705
  safeRegister(host, {
674
706
  name: 'iris_look_at_image',
675
707
  description:
676
- 'Look at a local image file and answer one question about it with the configured vision model (qwen-vl via the self-hosted provider stack; falls back to the global DSH vision model). The image is saved as a durable attachment so follow-ups can reference it.',
708
+ '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.',
677
709
  parameters: {
678
710
  type: 'object',
679
711
  properties: {
680
712
  image_path: { type: 'string', description: 'Absolute path to the image file (png/jpg/jpeg/webp)' },
713
+ artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path)' },
681
714
  question: { type: 'string', description: 'Question or extraction request about the image; defaults to a detailed description' },
682
715
  model: { type: 'string', description: 'Override the configured vision model id (default qwen-vl-plus)' }
683
716
  },
684
- required: ['image_path']
717
+ required: []
685
718
  },
686
719
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
687
720
  isConcurrencySafe: () => true,
688
721
  async execute(args, exec) {
722
+ const source = normalizeVisionImageSource(args);
689
723
  const p = String(args.image_path || '').trim();
690
- if (!path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
691
724
  const mt = MEDIA_TYPES[path.extname(p).toLowerCase()];
692
- if (!mt || mt === 'image/gif') throw new Error('iris: 不支持的图片格式(png/jpg/jpeg/webp):' + p);
693
- if (!fs.existsSync(p)) throw new Error('iris: 图片不存在:' + p);
725
+ if (source === 'image_path') {
726
+ if (!path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
727
+ if (!mt || mt === 'image/gif') throw new Error('iris: 不支持的图片格式(png/jpg/jpeg/webp):' + p);
728
+ if (!fs.existsSync(p)) throw new Error('iris: 图片不存在:' + p);
729
+ }
694
730
  const question = String(args.question || '').trim() || '详细描述这张图片。';
695
731
  if (exec.signal && exec.signal.aborted) throw new Error('已取消');
696
732
 
697
- const dataUrl = toDataUrl(p, mt);
698
- const ref = await attachmentService(host).saveImage({ data: new Uint8Array(fs.readFileSync(p)), mediaType: mt, name: path.basename(p) });
699
- return runVisionTool(dshHost(ctx), exec, { origin: 'tool', model: args.model, question, ref, dataUrl });
733
+ return runVisionTool(dshHost(ctx), exec, { origin: 'tool', model: args.model, question, artifactId: args.artifact_id,
734
+ async prepareImage(signal) {
735
+ if (source === 'artifact_id') return visionImageForDsh({ artifactId: args.artifact_id, signal });
736
+ const image = await readVisionImageFile(p, mt, signal);
737
+ await waitForModelWork(() => attachmentService(host).saveImage({ data: image.bytes, mediaType: mt, name: path.basename(p) }), signal);
738
+ return image;
739
+ }
740
+ });
700
741
  }
701
742
  });
702
743
 
@@ -722,18 +763,16 @@ export async function apply(ctx) {
722
763
  if (!question) throw new Error('iris: question 不能为空');
723
764
  if (exec.signal && exec.signal.aborted) throw new Error('已取消');
724
765
 
725
- // 定位:本会话出现过的图片优先,其次本插件产物记录
726
- const hit = (await sessionAttachmentRef(host, exec, id)) || findOwnAttachment(id);
727
- if (!hit) throw new Error('iris: 该 attachment 不在本会话中、也不是 iris 生成的图片:' + id);
728
-
729
- let dataUrl;
730
- if (hit.absPath) {
731
- dataUrl = toDataUrl(hit.absPath, hit.ref.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png');
732
- } else {
733
- const stored = await attachmentService(host).readImage(hit.ref, exec.signal);
734
- dataUrl = `data:${stored.mediaType || hit.ref.mediaType};base64,${Buffer.from(stored.data).toString('base64')}`;
735
- }
736
- return runVisionTool(dshHost(ctx), exec, { origin: 'relook', model: args.model, question, ref: hit.ref, dataUrl });
766
+ return runVisionTool(dshHost(ctx), exec, { origin: 'relook', model: args.model, question,
767
+ async prepareImage(signal) {
768
+ // 会话扫描、解引用与读图都属于这次操作的总预算。
769
+ const hit = (await waitForModelWork(() => sessionAttachmentRef(host, exec, id), signal)) || findOwnAttachment(id);
770
+ if (!hit) throw new Error('iris: 图片引用不可用');
771
+ if (hit.absPath) return readVisionImageFile(hit.absPath, hit.ref.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png', signal);
772
+ const stored = await waitForModelWork(() => attachmentService(host).readImage(hit.ref, signal), signal);
773
+ return { bytes: new Uint8Array(stored.data), mediaType: stored.mediaType || hit.ref.mediaType };
774
+ }
775
+ });
737
776
  }
738
777
  });
739
778
 
@@ -866,13 +905,14 @@ export async function apply(ctx) {
866
905
  safeRegister(host, {
867
906
  name: 'iris_locate',
868
907
  description:
869
- 'Locate an object/region described by `target` in an image (absolute path or session attachment_id), ' +
908
+ 'Locate an object/region described by `target` in an image (exactly one of absolute image_path, Core artifact_id, or session attachment_id), ' +
870
909
  'returning the pixel bounding box (x1,y1,x2,y2) in original image coordinates. ' +
871
- 'Use the returned bbox with iris_crop to extract the region.',
910
+ 'Use the bbox with iris_crop for path/session images or CLI run crop with artifact_id for Core images; do not treat a Core ID as a file path.',
872
911
  parameters: {
873
912
  type: 'object',
874
913
  properties: {
875
914
  image_path: { type: 'string', description: 'Absolute path to the source image (png/jpg/jpeg/webp)' },
915
+ artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path or attachment_id)' },
876
916
  attachment_id: { type: 'string', description: 'Session attachment id of an image (alternative to image_path)' },
877
917
  target: { type: 'string', description: 'The object or UI element to locate, e.g. "send button"' },
878
918
  model: { type: 'string', description: 'Override the vision model for this locate call (e.g. qwen3-vl-235b-a22b-thinking for higher grounding precision; default is the provider visionModel, typically qwen-vl-plus)' }
@@ -882,23 +922,15 @@ export async function apply(ctx) {
882
922
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
883
923
  isConcurrencySafe: () => true,
884
924
  async execute(args, exec) {
885
- if (exec.signal && exec.signal.aborted) throw new Error('已取消');
886
- const input = await resolveImageInput(dshHost(ctx), exec, {
887
- image_path: args.image_path,
888
- attachment_id: args.attachment_id
925
+ const r = await runLocateRequest(dshHost(ctx), {
926
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, model: args.model, target: args.target,
927
+ prepareImage: signal => resolveVisionImageInput(dshHost(ctx), exec, args, signal), signal: exec.signal
889
928
  });
890
- const { width, height } = await imageDimensions(input.buffer);
891
- const dataUrl = `data:${input.mediaType};base64,${input.buffer.toString('base64')}`;
892
- const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION);
893
- const backends = buildVisionBackendsFromHost(dshHost(ctx), { providers: visionProviders, model: args.model });
894
- const r = await locateObject(backends, {
895
- target: args.target,
896
- imageDataUrl: dataUrl,
897
- width,
898
- height,
899
- signal: exec.signal
900
- });
901
- if (!r.found) return `[iris] 在图片中未找到「${args.target}」(${r.via} · ${r.model})`;
929
+ const { width, height } = r;
930
+ const sourceNote = args.artifact_id ? `\nCore Artifact: ${args.artifact_id}` : '';
931
+ if (!r.found) return `[iris] 在图片中未找到「${args.target}」(${r.via} · ${r.model})${sourceNote}`;
932
+ if (args.artifact_id) return `[iris] 定位「${args.target}」:bbox (${r.x1},${r.y1},${r.x2},${r.y2}) / ${width}x${height}(${r.via} · ${r.model})${sourceNote}\n` +
933
+ `裁剪区域:left=${r.x1}, top=${r.y1}, width=${r.x2 - r.x1}, height=${r.y2 - r.y1}`;
902
934
  return `[iris] 定位「${args.target}」:bbox (${r.x1},${r.y1},${r.x2},${r.y2}) / ${width}x${height}(${r.via} · ${r.model})\n` +
903
935
  `裁剪指令:iris_crop(image_path="${args.image_path || args.attachment_id}", left=${r.x1}, top=${r.y1}, width=${r.x2 - r.x1}, height=${r.y2 - r.y1})`;
904
936
  }
@@ -928,7 +960,8 @@ export async function apply(ctx) {
928
960
  async execute(args, exec) {
929
961
  if (exec.signal && exec.signal.aborted) throw new Error('已取消');
930
962
  const browser = requireHostPort(dshHost(ctx), 'browser', 'HTML 截图');
931
- const rendered = await browser.renderHtml({
963
+ const rendered = await htmlForDsh({
964
+ browser,
932
965
  html: args.html,
933
966
  width: args.width,
934
967
  height: args.height,
@@ -947,7 +980,7 @@ export async function apply(ctx) {
947
980
  {
948
981
  type: 'text',
949
982
  text: `[iris] HTML 截图完成(${outW}x${outH},fullPage: ${args.fullPage !== false})\n` +
950
- `attachment: ${ref.attachmentId}` +
983
+ `artifact: ${rendered.artifact.id}\nattachment: ${ref.attachmentId}` +
951
984
  `\n注意:渲染在共享浏览器临时标签页中完成,页面短暂可见属正常行为。`
952
985
  },
953
986
  { type: 'image', attachment: ref }
@@ -960,13 +993,14 @@ export async function apply(ctx) {
960
993
  safeRegister(host, {
961
994
  name: 'iris_long_ocr',
962
995
  description:
963
- 'OCR a (possibly long) image: slice it into chunks (1200px tall with 120px overlap by default) ' +
996
+ '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) ' +
964
997
  'and read the text chunk by chunk with the vision model, then join the results in top-to-bottom order. ' +
965
- 'Returns the full recognized text plus per-chunk details. Good for long screenshots / documents.',
998
+ 'Marks partial results when chunks fail. Cancellation or timeout stops the whole operation. Good for long screenshots / documents.',
966
999
  parameters: {
967
1000
  type: 'object',
968
1001
  properties: {
969
1002
  image_path: { type: 'string', description: 'Absolute path to the image (png/jpg/jpeg/webp)' },
1003
+ artifact_id: { type: 'string', description: 'Existing Core image Artifact ID in the current profile (alternative to image_path or attachment_id)' },
970
1004
  attachment_id: { type: 'string', description: 'Session attachment id of the image (alternative to image_path)' },
971
1005
  chunk_height: { type: 'integer', description: 'Chunk height in pixels, default 1200' },
972
1006
  overlap: { type: 'integer', description: 'Overlap between chunks in pixels to avoid cutting text lines, default 120' }
@@ -976,28 +1010,19 @@ export async function apply(ctx) {
976
1010
  output: { schema: { type: 'string' }, render: (_args, v) => [{ type: 'text', text: v }] },
977
1011
  isConcurrencySafe: () => true,
978
1012
  async execute(args, exec) {
979
- if (exec.signal && exec.signal.aborted) throw new Error('已取消');
980
- const input = await resolveImageInput(dshHost(ctx), exec, {
981
- image_path: args.image_path,
982
- attachment_id: args.attachment_id
983
- });
984
- const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION);
985
- const backends = buildVisionBackendsFromHost(dshHost(ctx), { providers: visionProviders });
986
- const result = await longOcr({
987
- input: input.buffer,
988
- backends,
989
- chunkHeight: args.chunk_height || 1200,
990
- overlap: args.overlap || 120,
1013
+ const host = dshHost(ctx);
1014
+ const result = await runOcrRequest(host, {
1015
+ providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit,
1016
+ prepareImage: operationSignal => resolveVisionImageInput(host, exec, args, operationSignal),
1017
+ chunkHeight: args.chunk_height,
1018
+ overlap: args.overlap,
991
1019
  signal: exec.signal
992
1020
  });
993
- const errCount = result.chunks.filter((c) => c.error).length;
994
- const head = `[iris] 长截图 OCR 完成:${result.width}x${result.height}px,${result.totalChunks} 块${errCount ? '(' + errCount + ' 块失败)' : ''}\n`;
995
- if (!result.fullText) return head + '(未识别到文字)';
996
- return head + result.fullText;
1021
+ return formatOcrResult(result) + (args.artifact_id ? `\nCore Artifact: ${args.artifact_id}` : '');
997
1022
  }
998
1023
  });
999
1024
 
1000
- console.log('[iris] tools registered: iris_draw_image, iris_generate_video, iris_speak_text, iris_transcribe_audio, iris_task_status, iris_look_at_image, iris_relook_attachment, iris_crop, iris_pixel_diff, iris_locate, iris_html_screenshot, iris_long_ocr, iris_video_frames, iris_media_summarize');
1025
+ console.log('[iris] tools registered: iris_draw_image, iris_edit_image, iris_generate_video, iris_speak_text, iris_transcribe_audio, iris_task_status, iris_look_at_image, iris_relook_attachment, iris_crop, iris_pixel_diff, iris_locate, iris_html_screenshot, iris_long_ocr, iris_video_frames, iris_media_summarize');
1001
1026
  };
1002
1027
 
1003
1028
  /* ---------------- 视觉路由(M3 补全):look/relook 共用执行器 + 降级链 ---------------- */
@@ -1046,12 +1071,35 @@ async function resolveImageInput(host, exec, { image_path, attachment_id }) {
1046
1071
  throw new Error('iris: 需要 image_path 或 attachment_id');
1047
1072
  }
1048
1073
 
1074
+ /** 视觉解引用在整体预算内;路径限额读,Host 会话/附件等待可取消。 */
1075
+ async function resolveVisionImageInput(host, exec, input, signal) {
1076
+ const source = normalizeVisionImageSource(input, { allowAttachment: true });
1077
+ const { image_path, artifact_id, attachment_id } = input;
1078
+ if (source === 'artifact_id') return visionImageForDsh({ artifactId: artifact_id, signal });
1079
+ if (image_path) {
1080
+ const file = String(image_path).trim();
1081
+ const mediaType = MEDIA_TYPES[path.extname(file).toLowerCase()];
1082
+ if (!path.isAbsolute(file) || !mediaType || mediaType === 'image/gif') throw new Error('invalid vision image');
1083
+ return readVisionImageFile(file, mediaType, signal);
1084
+ }
1085
+ if (attachment_id) {
1086
+ const id = String(attachment_id).trim();
1087
+ const hit = await waitForModelWork(() => sessionAttachmentRef(host, exec, id), signal) || findOwnAttachment(id);
1088
+ if (!hit) throw new Error('unavailable vision attachment');
1089
+ if (hit.absPath) return readVisionImageFile(hit.absPath,
1090
+ hit.ref.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png', signal);
1091
+ const stored = await waitForModelWork(() => attachmentService(host).readImage(hit.ref, signal), signal);
1092
+ return { bytes: stored.data, mediaType: stored.mediaType || hit.ref.mediaType || 'image/png' };
1093
+ }
1094
+ throw new Error('missing vision image');
1095
+ }
1096
+
1049
1097
  /** 在本插件的任务产物里按 attachmentId 找 iris 自生成的图片(relook 的兜底) */
1050
1098
  export function findOwnAttachment(attachmentId) {
1051
1099
  for (const t of tasks.all()) {
1052
1100
  for (const a of t.attachments || []) {
1053
1101
  if (a.attachmentId === attachmentId) {
1054
- const absPath = path.join(outputDir(), a.file);
1102
+ const absPath = path.join(tasks.outputsDir(), a.file);
1055
1103
  if (fs.existsSync(absPath)) {
1056
1104
  return { ref: { attachmentId: a.attachmentId, mediaType: a.mediaType }, absPath };
1057
1105
  }
@@ -1087,44 +1135,26 @@ export async function sessionAttachmentRef(host, exec, attachmentId) {
1087
1135
  * 全局视觉模型兜底;每个后端的失败现场保留在 errors 里。
1088
1136
  * @returns {{answer:string,via:'selfstack'|'global',model?:string,backendId:string}}
1089
1137
  */
1090
- export async function askVision(host, { question, ref, dataUrl, signal, model }) {
1138
+ export async function askVision(host, { question, dataUrl, image, prepareImage, signal, model, budget }) {
1091
1139
  const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION);
1092
- const backends = buildVisionBackendsFromHost(host, { providers: visionProviders, model });
1093
- return askWithBackends(backends, { question, ref, imageDataUrl: dataUrl, signal });
1140
+ return runVisionRequest(host, { providers: visionProviders, model, question, dataUrl, image, prepareImage, signal, budget, onRateLimit: store.recordRateLimit });
1094
1141
  }
1095
1142
 
1096
1143
  /**
1097
1144
  * look / relook 共用执行器:看图问答 → 人话结果(标注模型归属,方便排查走哪条链)。
1098
1145
  * @returns {Promise<string>} 直接作为工具输出
1099
1146
  */
1100
- export async function runVisionTool(host, exec, { origin, model, question, ref, dataUrl }) {
1147
+ export async function runVisionTool(host, exec, { origin, model, question, dataUrl, image, prepareImage, artifactId }) {
1101
1148
  if (exec && exec.signal && exec.signal.aborted) throw new Error('已取消');
1102
1149
  const { answer, via, model: usedModel } = await askVision(host, {
1103
1150
  question,
1104
- ref,
1105
1151
  dataUrl,
1152
+ image,
1153
+ prepareImage,
1106
1154
  signal: exec && exec.signal,
1107
1155
  model
1108
1156
  });
1109
1157
  const label = origin === 'relook' ? '重看回答' : '看图回答';
1110
1158
  const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
1111
- return `[iris] ${label}(${usedModel || '默认'} · ${viaLabel}):\n${answer}`;
1112
- }
1113
-
1114
- /* ---------------- 视觉自述(画图后增强项):走 askVision 降级链,失败静默 ---------------- */
1115
- async function describeFirstImage(host, ref, absPath, originalPrompt) {
1116
- try {
1117
- const mt = MEDIA_TYPES[path.extname(absPath).toLowerCase()] || 'image/png';
1118
- return await describeImageData(host, ref, toDataUrl(absPath, mt), originalPrompt);
1119
- } catch (_) {
1120
- return ''; // 自述是增强项,失败不影响交付
1121
- }
1122
- }
1123
-
1124
- async function describeImageData(host, ref, dataUrl, originalPrompt) {
1125
- const { answer } = await askVision(host, {
1126
- question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
1127
- ref, dataUrl, signal: undefined
1128
- });
1129
- return answer.slice(0, 300);
1159
+ return `[iris] ${label}(${usedModel || '默认'} · ${viaLabel}):\n${answer}` + (artifactId ? `\nCore Artifact: ${artifactId}` : '');
1130
1160
  }