@mokuyoaxis/dsh-iris 0.2.2 → 0.2.4

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 (79) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -73
  2. package/.dsh/skills/iris-compose-media/agents/openai.yaml +1 -1
  3. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +23 -22
  4. package/.dsh/skills/iris-compose-media/references/workflows.md +25 -30
  5. package/.dsh/skills/iris-use/SKILL.md +42 -0
  6. package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
  7. package/.dsh/skills/iris-use/references/media-contract.md +83 -0
  8. package/.dsh/skills/iris-verify-ui/SKILL.md +9 -7
  9. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +1 -1
  10. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +3 -0
  11. package/CHANGELOG.md +41 -2
  12. package/README.en.md +50 -38
  13. package/README.md +48 -37
  14. package/bin/dsh-iris.js +35 -6
  15. package/docs/AGENT_QUICKSTART.md +9 -9
  16. package/docs/ARCHITECTURE.md +4 -2
  17. package/docs/CLI_MANAGEMENT.md +11 -3
  18. package/docs/CORE_RUNTIME_CONTRACT.md +3 -1
  19. package/docs/DSH_COMPATIBILITY.md +43 -0
  20. package/docs/DSH_CORE_MIGRATION.md +6 -2
  21. package/docs/DSH_RC2_ADAPTATION.md +4 -2
  22. package/docs/FAULT_INJECTION.md +1 -0
  23. package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +63 -0
  24. package/docs/HEADLESS_CLI.md +14 -8
  25. package/docs/HOST_ADAPTER_CONTRACT.md +10 -7
  26. package/docs/HOST_DOCTOR.md +6 -4
  27. package/docs/IMAGE_IMPORT.md +49 -0
  28. package/docs/MODEL_PORT_CONTRACT.md +6 -2
  29. package/docs/MUSIC.md +77 -0
  30. package/docs/PROMPT_OPTIMIZER.md +48 -10
  31. package/docs/PROVIDER_ADAPTER_CONTRACT.md +2 -0
  32. package/docs/ROADMAP.md +16 -25
  33. package/docs/SECURITY.md +3 -2
  34. package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
  35. package/docs/TASK_SEMANTICS.md +4 -4
  36. package/docs/VISION_CLI.md +3 -3
  37. package/docs/VISION_MODEL.md +1 -1
  38. package/docs/WORKBENCH_ARTIFACTS.md +4 -2
  39. package/docs/releases/0.2.3.md +54 -0
  40. package/docs/releases/0.2.4.md +27 -0
  41. package/lib/actions.js +71 -14
  42. package/lib/adapters.js +6 -2
  43. package/lib/api.js +14 -5
  44. package/lib/bundled-skills.js +1 -1
  45. package/lib/capability.js +2 -0
  46. package/lib/client.js +131 -47
  47. package/lib/command-service.js +28 -4
  48. package/lib/config.js +41 -23
  49. package/lib/core-artifact-store.js +44 -6
  50. package/lib/core-delivery-cache.js +145 -0
  51. package/lib/core-maintenance.js +12 -1
  52. package/lib/core-tasks.js +19 -5
  53. package/lib/core-user-projection.js +2 -2
  54. package/lib/doctor.js +4 -10
  55. package/lib/dsh-compatibility.js +10 -0
  56. package/lib/dsh-core-adapter.js +20 -4
  57. package/lib/dsh-text-model-adapter.js +15 -2
  58. package/lib/generation-input.js +10 -0
  59. package/lib/host-runtime.js +5 -2
  60. package/lib/image-edit-input.js +7 -5
  61. package/lib/image-import.js +61 -0
  62. package/lib/index.js +60 -17
  63. package/lib/models.js +12 -3
  64. package/lib/music-generation.js +21 -0
  65. package/lib/music-options.js +18 -0
  66. package/lib/music-transport.js +46 -0
  67. package/lib/ocr-model-routing.js +1 -0
  68. package/lib/prompt-optimizer-config.js +92 -21
  69. package/lib/prompt-optimizer-core.js +51 -6
  70. package/lib/prompt-optimizer.js +20 -14
  71. package/lib/provider-adapter.js +1 -0
  72. package/lib/provider-adapters.js +35 -3
  73. package/lib/provider-catalog.js +20 -11
  74. package/lib/provider-config-service.js +18 -6
  75. package/lib/provider-protocol.js +18 -0
  76. package/lib/provider-task-runner.js +80 -5
  77. package/lib/vision-model-routing.js +22 -8
  78. package/package.json +12 -9
  79. package/user_guide.md +45 -23
package/lib/config.js CHANGED
@@ -11,7 +11,7 @@ import os from 'node:os';
11
11
  import * as cap from './capability.js';
12
12
  import * as models from './models.js';
13
13
  import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
14
- import { selectMediaProtocol, selectImageProtocol, normalizeImageProtocol, isConfiguredProvider } from './provider-protocol.js';
14
+ import { selectMediaProtocol, selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol, isConfiguredProvider } from './provider-protocol.js';
15
15
  import { redactProviderMessage } from './provider-contract.js';
16
16
  import {
17
17
  PROVIDER_HEALTH_FRESH_MS,
@@ -183,37 +183,29 @@ export function providerById(id) {
183
183
  const FIELD_OF = models.CAP_FIELD;
184
184
 
185
185
  /**
186
- * 严格选择(阶段 6 模型池):优先用「已分配模型」(有序列表取第一个可用),
187
- * 否则从全局模型池挑第一个有能力标签的模型。
186
+ * 严格选择:仅从功能分配名单取第一个可用模型。
188
187
  * 返回该模型所属 provider 的副本,并把该能力的模型字段设为选中模型。
189
188
  * 向后兼容:调用方继续读 provider.apiKey / provider.imageModel 等。
190
189
  * 无可用模型 → null(不再兜底到任意 provider)。
191
190
  */
192
191
  export function pickFor(capability) {
193
- const pool = models.modelPool(providers());
194
- let m = null;
195
- for (const ref of assignmentOrder(capability)) {
196
- m = pool.find((x) => !x.rateLimited && x.ref === ref && x.capabilities.includes(capability));
197
- if (m) break;
198
- }
199
- if (!m) m = models.pickModel(pool, capability);
200
- if (!m) return null;
201
- const p = providerById(m.providerId);
202
- if (!p) return null;
203
- const field = FIELD_OF[capability];
204
- return field ? { ...p, [field]: m.id } : p;
192
+ return pickAllFor(capability)[0] || null;
205
193
  }
206
194
 
207
195
  /**
208
196
  * 同一能力的有序 provider 列表(供 failover 依次尝试):
209
- * 已分配顺序优先(阶段 6 条目 4),其余按池顺序补齐(去重)。
197
+ * 只使用已分配顺序,不补入全局池。
210
198
  * 一个 provider 若池里有多个该能力模型,会返回多条(各自带不同模型)。
211
199
  */
212
200
  export function pickAllFor(capability, { includeRateLimited = false } = {}) {
213
- const pool = models.modelPool(providers());
201
+ const pool = models.modelPool(providers(), { includeHostVision: true });
214
202
  const chosen = models.orderedModels(pool, capability, assignments()[capability], { includeRateLimited });
215
203
  const out = [];
216
204
  for (const m of chosen) {
205
+ if (m.ref === models.HOST_VISION_REF) {
206
+ out.push({ id: models.HOST_VISION_REF, visionModel: m.id });
207
+ continue;
208
+ }
217
209
  const p = providerById(m.providerId);
218
210
  if (!p) continue;
219
211
  const field = FIELD_OF[capability];
@@ -233,11 +225,12 @@ const resolveAssignment = models.resolvePoolModel;
233
225
  export function assignmentOrder(capability) {
234
226
  const a = assignments()[capability];
235
227
  if (!a) return [];
236
- const pool = models.modelPool(providers());
228
+ const pool = models.modelPool(allProviders(), { includeHostVision: true });
237
229
  const refs = [];
238
230
  for (const raw of (Array.isArray(a) ? a : [a])) {
239
231
  const m = resolveAssignment(pool, raw, capability);
240
232
  if (m && !refs.includes(m.ref)) refs.push(m.ref);
233
+ else if (!m && typeof raw === 'string' && models.parseModelRef(raw) && !refs.includes(raw)) refs.push(raw);
241
234
  }
242
235
  return refs;
243
236
  }
@@ -245,7 +238,7 @@ export function assignmentOrder(capability) {
245
238
  /** 设置某能力分配的模型 id(需在全局池中存在且具备该能力) */
246
239
  export function setAssignment(capability, ref) {
247
240
  const c = load();
248
- const pool = models.modelPool(providers());
241
+ const pool = models.modelPool(allProviders(), { includeHostVision: true });
249
242
  const m = resolveAssignment(pool, ref, capability);
250
243
  if (!m) return false;
251
244
  if (!c.assignments) c.assignments = {};
@@ -256,7 +249,7 @@ export function setAssignment(capability, ref) {
256
249
 
257
250
  /**
258
251
  * 设置某能力的有序 failover 列表(阶段 6 条目 4):每个模型须在全局池中存在且具备该能力。
259
- * 空数组 = 清除分配(回退池顺序自动选择)。重复 id 自动去重。
252
+ * 空数组 = 未分配,不自动选择。重复 id 自动去重。
260
253
  */
261
254
  export function setAssignmentOrder(capability, modelRefs) {
262
255
  const c = load();
@@ -268,7 +261,7 @@ export function setAssignmentOrder(capability, modelRefs) {
268
261
  }
269
262
  return true;
270
263
  }
271
- const pool = models.modelPool(providers());
264
+ const pool = models.modelPool(allProviders(), { includeHostVision: true });
272
265
  const refs = [];
273
266
  for (const raw of modelRefs) {
274
267
  const m = resolveAssignment(pool, raw, capability);
@@ -281,7 +274,7 @@ export function setAssignmentOrder(capability, modelRefs) {
281
274
  return true;
282
275
  }
283
276
 
284
- /** 清除某个能力的手动分配(回退到池自动选择) */
277
+ /** 清除功能分配;此后不再发起该功能的新调用。 */
285
278
  export function clearAssignment(capability) {
286
279
  const c = load();
287
280
  if (c.assignments && c.assignments[capability]) {
@@ -451,7 +444,8 @@ export function providerHealthSnapshot({ now = Date.now() } = {}) {
451
444
  for (const capability of Object.values(cap.CAPABILITIES)) {
452
445
  const candidates = pickAllFor(capability, { includeRateLimited: true }).map((provider) => {
453
446
  const modelId = String(provider[FIELD_OF[capability]] || '');
454
- const health = modelHealth(provider.id, modelId, capability, { now });
447
+ const health = provider.id === models.HOST_VISION_REF ? { status: 'configured', source: 'configuration' }
448
+ : modelHealth(provider.id, modelId, capability, { now });
455
449
  return { providerId: provider.id, modelId, ...health };
456
450
  });
457
451
  const status = aggregateHealthStates(candidates.map((item) => item.status));
@@ -489,6 +483,7 @@ export function upsert(provider) {
489
483
  if (rest.visionInput !== undefined) rest.visionInput = normalizeVisionInputLimits(rest.visionInput) || null;
490
484
  if (Array.isArray(rest.models)) rest.models = rest.models.map(model => typeof model === 'string' ? model : ({ ...model,
491
485
  ...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
486
+ ...(model?.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(model.musicProtocol) } : {}),
492
487
  ...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
493
488
  if (id) {
494
489
  const i = c.providers.findIndex((p) => p.id === id);
@@ -541,6 +536,7 @@ export function setProviderModels(id, modelEntries) {
541
536
  p.models = modelEntries.map((m) => {
542
537
  const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities,
543
538
  ...(m.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(m.imageProtocol) } : {}),
539
+ ...(m.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(m.musicProtocol) } : {}),
544
540
  ...(m.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(m.visionInput) || null } : {}) };
545
541
  const old = prev.get(entry.id);
546
542
  if (old) {
@@ -548,6 +544,7 @@ export function setProviderModels(id, modelEntries) {
548
544
  if (old.source) entry.source = old.source;
549
545
  if (old.source === 'manual' && Array.isArray(old.capabilities)) entry.capabilities = old.capabilities;
550
546
  if (m?.imageProtocol === undefined && old.imageProtocol !== undefined) entry.imageProtocol = old.imageProtocol;
547
+ if (m?.musicProtocol === undefined && old.musicProtocol !== undefined) entry.musicProtocol = old.musicProtocol;
551
548
  if (m?.visionInput === undefined && old.visionInput !== undefined) entry.visionInput = old.visionInput;
552
549
  }
553
550
  if (m && m.verified) entry.verified = m.verified;
@@ -590,6 +587,9 @@ function findModelEntry(p, modelId) {
590
587
 
591
588
  function resetChangedImageProtocolHealth(previous, next) {
592
589
  for (const model of models.providerModels(previous)) {
590
+ if (model.musicProtocol !== models.providerModels(next).find(item => item.id === model.id)?.musicProtocol) {
591
+ clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.MUSIC);
592
+ }
593
593
  if (selectImageProtocol(previous, model.id).mediaProtocol !== selectImageProtocol(next, model.id).mediaProtocol) {
594
594
  clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.IMAGE);
595
595
  }
@@ -615,6 +615,24 @@ export function setModelImageProtocol(id, modelId, value) {
615
615
  return true;
616
616
  }
617
617
 
618
+ /** 音乐协议独立绑定到已配置模型,不覆盖账号生图协议。 */
619
+ export function setModelMusicProtocol(id, modelId, value) {
620
+ const protocol = normalizeMusicProtocol(value);
621
+ const provider = load().providers.find(item => item.id === id);
622
+ if (!provider) return false;
623
+ const entries = Array.isArray(provider.models) ? provider.models
624
+ : models.providerModels(provider).map(({ id, capabilities }) => ({ id, capabilities }));
625
+ const index = entries.findIndex(model => (typeof model === 'string' ? model : model?.id)?.trim() === modelId);
626
+ if (index < 0) return false;
627
+ const entry = typeof entries[index] === 'string' ? { id: entries[index] } : entries[index];
628
+ entries[index] = entry;
629
+ provider.models = entries;
630
+ if (entry.musicProtocol !== protocol) clearModelCapabilityHealth(provider, modelId, cap.CAPABILITIES.MUSIC);
631
+ entry.musicProtocol = protocol;
632
+ persist();
633
+ return true;
634
+ }
635
+
618
636
  /** 看图预算变更不会伪造实测成功、解除额度停用或改写原图。 */
619
637
  export function setModelVisionInput(id, modelId, value) {
620
638
  const limits = normalizeVisionInputLimits(value);
@@ -305,20 +305,46 @@ function finishObject(dataRoot, input) {
305
305
  const relations = normalizedRelations(input.relations);
306
306
  validateRelationTargets(dataRoot, relations);
307
307
  const metadata = jsonClone(input.metadata, 'metadata');
308
- const id = 'artifact_' + crypto.randomBytes(12).toString('hex');
308
+ // 同步交付缓存预留的 ID 可重入提交,避免多产物交付或 Task 落盘中断后重复作品。
309
+ const resumable = input.artifactId !== undefined;
310
+ const id = resumable ? input.artifactId : 'artifact_' + crypto.randomBytes(12).toString('hex');
311
+ if (!ID.test(id) || (resumable && (!HASH.test(input.expectedDigest)
312
+ || !Number.isSafeInteger(input.expectedSize) || input.expectedSize < 1))) {
313
+ fail('IRIS_ARTIFACT_INPUT_INVALID', '可恢复 Artifact 身份或内容证据无效');
314
+ }
309
315
  const store = ensureStore(dataRoot);
310
316
  const objectName = id + extension;
311
317
  const object = path.join(store.objects, objectName);
318
+ const exists = (file) => {
319
+ try { fs.lstatSync(file); return true; }
320
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
321
+ };
322
+ if (resumable && exists(recordPath(dataRoot, id))) {
323
+ const previous = readArtifact(dataRoot, id).manifest;
324
+ if (previous.size !== input.expectedSize || previous.digest.value !== input.expectedDigest
325
+ || previous.kind !== kind || previous.mediaType !== mediaType
326
+ || JSON.stringify(previous.metadata) !== JSON.stringify(metadata)
327
+ || JSON.stringify(previous.relations) !== JSON.stringify(relations)) {
328
+ fail('IRIS_ARTIFACT_INPUT_INVALID', '可恢复 Artifact 与已提交内容不一致');
329
+ }
330
+ try { refreshIndex(dataRoot); } catch (_) { /* 不撤销已有提交 */ }
331
+ return publicArtifact(previous);
332
+ }
312
333
  let stat;
313
334
  try {
314
- input.writeObject(object);
335
+ if (resumable && exists(object)) {
336
+ if (regularFile(object).size !== input.expectedSize || hashFile(object) !== input.expectedDigest) {
337
+ fail('IRIS_ARTIFACT_INPUT_INVALID', '未提交 Artifact 与恢复内容不一致');
338
+ }
339
+ } else input.writeObject(object);
315
340
  stat = regularFile(object);
316
- } catch (_) {
341
+ } catch (error) {
342
+ if (resumable) throw error;
317
343
  try { fs.rmSync(object, { force: true }); } catch (_) { /* 当前随机 ID */ }
318
344
  fail('IRIS_ARTIFACT_WRITE_FAILED', 'Artifact 对象无法安全写入');
319
345
  }
320
346
  if (!stat.size) {
321
- try { fs.rmSync(object, { force: true }); } catch (_) { /* 只清理当前随机 ID */ }
347
+ if (!resumable) try { fs.rmSync(object, { force: true }); } catch (_) { /* 只清理当前随机 ID */ }
322
348
  fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact 内容不能为空');
323
349
  }
324
350
  const manifest = {
@@ -333,12 +359,23 @@ function finishObject(dataRoot, input) {
333
359
  relations,
334
360
  metadata
335
361
  };
362
+ if (resumable && (manifest.size !== input.expectedSize || manifest.digest.value !== input.expectedDigest)) {
363
+ fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact 复制结果与恢复内容证据不一致');
364
+ }
365
+ if (resumable && exists(manifestPath(dataRoot, id))) {
366
+ const previous = validateManifest(jsonFile(manifestPath(dataRoot, id)), id);
367
+ if (JSON.stringify(previous) !== JSON.stringify(manifest)) {
368
+ fail('IRIS_ARTIFACT_INPUT_INVALID', '未提交 Artifact Manifest 与恢复内容不一致');
369
+ }
370
+ }
336
371
  try {
337
372
  writeManifest(dataRoot, manifest);
338
373
  commitRecord(dataRoot, id);
339
374
  } catch (_) {
340
- try { fs.rmSync(object, { force: true }); } catch (_) { /* 当前 ID */ }
341
- try { fs.rmSync(manifestPath(dataRoot, id), { force: true }); } catch (_) { /* 当前 ID */ }
375
+ if (!resumable) {
376
+ try { fs.rmSync(object, { force: true }); } catch (_) { /* 当前 ID */ }
377
+ try { fs.rmSync(manifestPath(dataRoot, id), { force: true }); } catch (_) { /* 当前 ID */ }
378
+ }
342
379
  fail('IRIS_ARTIFACT_WRITE_FAILED', 'Artifact 无法安全提交');
343
380
  }
344
381
  try { refreshIndex(dataRoot); } catch (_) { /* index 是可重建缓存,不撤销已提交 Artifact */ }
@@ -366,6 +403,7 @@ export function adoptCoreArtifactFile(dataRoot, input = {}) {
366
403
  return finishObject(dataRoot, {
367
404
  ...input,
368
405
  mediaType,
406
+ ...(input.artifactId ? { expectedSize: stat.size, expectedDigest: hashFile(source) } : {}),
369
407
  kind: input.kind || 'adopted',
370
408
  writeObject(object) {
371
409
  const tmp = privateSibling(object, 'adopt');
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ /** 同步完成结果的私有交付证据;来源 URL/字节不进入 Task、Manifest 或日志。 */
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
8
+
9
+ const TASK_ID = /^task_[a-f0-9]{24}$/;
10
+ const ARTIFACT_ID = /^artifact_[a-f0-9]{24}$/;
11
+ const PAYLOAD_FILE = /^payload-[a-f0-9]{24}\.bin$/;
12
+ const HASH = /^[a-f0-9]{64}$/;
13
+
14
+ export class DeliveryCacheError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'DeliveryCacheError';
18
+ this.code = 'IRIS_DELIVERY_CACHE_INVALID';
19
+ }
20
+ }
21
+
22
+ function invalid() { throw new DeliveryCacheError('同步交付缓存缺失、损坏或与 Task 身份不一致;不会重新生成'); }
23
+
24
+ function directory(dataRoot, taskId, create = false) {
25
+ if (!TASK_ID.test(taskId)) invalid();
26
+ let current = dataRoot;
27
+ for (const part of ['delivery-cache', 'v0', taskId]) {
28
+ current = path.join(current, part);
29
+ if (create) {
30
+ try { fs.lstatSync(current); }
31
+ catch (error) { if (error.code !== 'ENOENT') throw error; ensurePrivateDir(current); }
32
+ }
33
+ const stat = fs.lstatSync(current);
34
+ if (!stat.isDirectory() || stat.isSymbolicLink()) invalid();
35
+ }
36
+ return current;
37
+ }
38
+
39
+ function regular(file) {
40
+ const stat = fs.lstatSync(file);
41
+ if (!stat.isFile() || stat.isSymbolicLink()) invalid();
42
+ return stat;
43
+ }
44
+
45
+ function fingerprint(file) {
46
+ const stat = regular(file);
47
+ if (!stat.size) invalid();
48
+ const hash = crypto.createHash('sha256');
49
+ const chunk = Buffer.alloc(1024 * 1024);
50
+ const fd = fs.openSync(file, 'r');
51
+ try {
52
+ let count;
53
+ while ((count = fs.readSync(fd, chunk, 0, chunk.length, null))) hash.update(chunk.subarray(0, count));
54
+ } finally { fs.closeSync(fd); }
55
+ return { size: stat.size, sha256: hash.digest('hex') };
56
+ }
57
+
58
+ function save(dataRoot, cache) {
59
+ atomicWritePrivate(path.join(directory(dataRoot, cache.taskId), 'result.json'), JSON.stringify(cache, null, 2) + '\n');
60
+ }
61
+
62
+ export function createDeliveryCache(dataRoot, task, artifacts) {
63
+ if (!Array.isArray(artifacts) || !artifacts.length) invalid();
64
+ const root = directory(dataRoot, task.id, true);
65
+ try { fs.lstatSync(path.join(root, 'result.json')); invalid(); }
66
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
67
+ const cache = {
68
+ schemaVersion: 0, resultKind: 'completed', taskId: task.id, attemptId: task.activeAttemptId,
69
+ providerId: task.providerId, modelRef: task.modelRef, capability: task.capability,
70
+ ...(task.providerBinding ? { providerBinding: task.providerBinding } : {}),
71
+ createdAt: new Date().toISOString(), items: []
72
+ };
73
+ for (const remote of artifacts) {
74
+ const item = { artifactId: 'artifact_' + crypto.randomBytes(12).toString('hex') };
75
+ if (remote.kind === 'remote-url') {
76
+ item.source = { kind: 'remote-url', url: remote.url, ...(remote.mediaType ? { mediaType: remote.mediaType } : {}) };
77
+ } else if (remote.kind === 'inline-base64') {
78
+ const bytes = Buffer.from(remote.data, 'base64');
79
+ if (!bytes.length || bytes.toString('base64') !== remote.data) invalid();
80
+ const file = 'payload-' + crypto.randomBytes(12).toString('hex') + '.bin';
81
+ atomicWritePrivate(path.join(root, file), bytes);
82
+ item.payload = { file, size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
83
+ ...(remote.mediaType ? { mediaType: remote.mediaType } : {}) };
84
+ } else invalid();
85
+ cache.items.push(item);
86
+ }
87
+ // result.json 是完整同步响应的提交证据;未发布的文件不作为可恢复结果。
88
+ save(dataRoot, cache);
89
+ return cache;
90
+ }
91
+
92
+ export function readDeliveryCache(dataRoot, task, { allowAbsent = false } = {}) {
93
+ let cache;
94
+ try {
95
+ const file = path.join(directory(dataRoot, task.id), 'result.json');
96
+ regular(file);
97
+ cache = JSON.parse(fs.readFileSync(file, 'utf8'));
98
+ } catch (error) {
99
+ if (allowAbsent && error.code === 'ENOENT') return null;
100
+ invalid();
101
+ }
102
+ if (!cache || cache.schemaVersion !== 0 || cache.resultKind !== 'completed'
103
+ || cache.taskId !== task.id || cache.attemptId !== task.activeAttemptId
104
+ || cache.providerId !== task.providerId || cache.modelRef !== task.modelRef
105
+ || cache.capability !== task.capability || cache.providerBinding !== task.providerBinding
106
+ || !Array.isArray(cache.items) || !cache.items.length
107
+ || typeof cache.createdAt !== 'string' || !Number.isFinite(Date.parse(cache.createdAt))) invalid();
108
+ const ids = new Set();
109
+ for (const item of cache.items) {
110
+ if (!item || !ARTIFACT_ID.test(item.artifactId) || ids.has(item.artifactId)) invalid();
111
+ ids.add(item.artifactId);
112
+ if (item.source && (item.source.kind !== 'remote-url' || typeof item.source.url !== 'string' || !item.source.url.trim()
113
+ || (item.source.mediaType !== undefined && typeof item.source.mediaType !== 'string'))) invalid();
114
+ if (item.payload && (!PAYLOAD_FILE.test(item.payload.file) || !HASH.test(item.payload.sha256)
115
+ || !Number.isSafeInteger(item.payload.size) || item.payload.size < 1
116
+ || (item.payload.mediaType !== undefined && typeof item.payload.mediaType !== 'string'))) invalid();
117
+ if (!item.payload && !item.source) invalid();
118
+ }
119
+ return cache;
120
+ }
121
+
122
+ export function deliveryCacheNeedsProvider(dataRoot, task) {
123
+ return readDeliveryCache(dataRoot, task).items.some(item => !item.payload);
124
+ }
125
+
126
+ export function newDeliveryPayloadPath(dataRoot, cache) {
127
+ return path.join(directory(dataRoot, cache.taskId), 'payload-' + crypto.randomBytes(12).toString('hex') + '.bin');
128
+ }
129
+
130
+ export function recordDeliveryPayload(dataRoot, cache, index, file, mediaType) {
131
+ const root = directory(dataRoot, cache.taskId);
132
+ if (path.dirname(file) !== root || !PAYLOAD_FILE.test(path.basename(file))) invalid();
133
+ cache.items[index].payload = { file: path.basename(file), ...fingerprint(file), mediaType };
134
+ delete cache.items[index].source;
135
+ save(dataRoot, cache);
136
+ }
137
+
138
+ export function readDeliveryPayload(dataRoot, cache, index) {
139
+ const payload = cache.items[index].payload;
140
+ const file = path.join(directory(dataRoot, cache.taskId), payload.file);
141
+ let actual;
142
+ try { actual = fingerprint(file); } catch (_) { invalid(); }
143
+ if (actual.size !== payload.size || actual.sha256 !== payload.sha256) invalid();
144
+ return file;
145
+ }
@@ -4,11 +4,12 @@ import crypto from 'node:crypto';
4
4
  import { inspectCoreArtifact, refreshCoreArtifactInventory } from './core-artifacts.js';
5
5
  import { inspectCoreTask, listCoreTasks } from './core-tasks.js';
6
6
  import { atomicWritePrivate } from './private-storage.js';
7
+ import { readDeliveryCache } from './core-delivery-cache.js';
7
8
 
8
9
  const TASK = /^task_[a-f0-9]{24}$/;
9
10
  const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
10
11
  const TRANSACTION = /^delete_[a-f0-9]{24}$/;
11
- const MANAGED = /^(?:task-store\/v0\/tasks\/task_[a-f0-9]{24}\.json|artifact-store\/v0\/(?:records|manifests)\/artifact_[a-f0-9]{24}\.json|artifact-store\/v0\/objects\/artifact_[a-f0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm|wav|mp3|ogg|flac|txt|json)|provider-staging\/v0\/download-[1-9][0-9]*-[a-f0-9]{16}\.part)$/;
12
+ const MANAGED = /^(?:task-store\/v0\/tasks\/task_[a-f0-9]{24}\.json|artifact-store\/v0\/(?:records|manifests)\/artifact_[a-f0-9]{24}\.json|artifact-store\/v0\/objects\/artifact_[a-f0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm|wav|mp3|ogg|flac|txt|json)|provider-staging\/v0\/download-[1-9][0-9]*-[a-f0-9]{16}\.part|delivery-cache\/v0\/task_[a-f0-9]{24}\/(?:result\.json|payload-[a-f0-9]{24}\.bin))$/;
12
13
  const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
13
14
  function fileDigest(file) {
14
15
  const hash = crypto.createHash('sha256'), chunk = Buffer.allocUnsafe(1024 * 1024), fd = fs.openSync(file, 'r');
@@ -68,6 +69,8 @@ export function planCoreDeletion(root, input) {
68
69
  const task = inspectCoreTask(root, id);
69
70
  if (task.phase !== 'terminal' || task.acceptance === 'unknown' || task.outcome === 'unknown') blockers.push({ id, reason: 'task_not_settled' });
70
71
  files.push(managedFile(root, 'task-store/v0/tasks/' + id + '.json'));
72
+ const cache = 'delivery-cache/v0/' + id;
73
+ for (const name of names(root, cache)) files.push(managedFile(root, cache + '/' + name));
71
74
  }
72
75
  for (const id of artifactIds) {
73
76
  inspectCoreArtifact(root, id);
@@ -83,6 +86,11 @@ export function planCoreDeletion(root, input) {
83
86
  for (const task of existing.tasks) {
84
87
  if (selectedTasks.has(task.id)) continue;
85
88
  for (const id of task.artifactIds) if (selectedArtifacts.has(id)) blockers.push({ id, referencedBy: task.id, reason: 'task_artifact_reference' });
89
+ if (task.deliveryCache && task.deliveryState !== 'ready') {
90
+ for (const item of readDeliveryCache(root, task).items) {
91
+ if (selectedArtifacts.has(item.artifactId)) blockers.push({ id: item.artifactId, referencedBy: task.id, reason: 'delivery_cache_reference' });
92
+ }
93
+ }
86
94
  if (selectedArtifacts.has(task.sourceArtifactId)) blockers.push({ id: task.sourceArtifactId, referencedBy: task.id, reason: 'task_input_reference' });
87
95
  if (selectedTasks.has(task.retriedFrom)) blockers.push({ id: task.retriedFrom, referencedBy: task.id, reason: 'retry_reference' });
88
96
  }
@@ -109,6 +117,9 @@ export function planCoreCleanup(root, { older_than_ms = 3600000 } = {}) {
109
117
  const referenced = new Set(existing.artifacts.map(artifact => artifact.id));
110
118
  for (const artifact of existing.artifacts) for (const edge of artifact.relations) referenced.add(edge.artifactId);
111
119
  for (const task of existing.tasks) for (const id of task.artifactIds) referenced.add(id);
120
+ for (const task of existing.tasks) if (task.deliveryCache && task.deliveryState !== 'ready') {
121
+ for (const item of readDeliveryCache(root, task).items) referenced.add(item.artifactId);
122
+ }
112
123
  for (const task of existing.tasks) if (task.sourceArtifactId) referenced.add(task.sourceArtifactId);
113
124
  for (const name of names(root, 'artifact-store/v0/manifests')) {
114
125
  if (/^artifact_[a-f0-9]{24}\.json$/.test(name)) {
package/lib/core-tasks.js CHANGED
@@ -7,6 +7,7 @@ import { atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
7
7
  import { parseModelRef } from './models.js';
8
8
  import { providerErrorRecord } from './provider-contract.js';
9
9
  import { deriveLegacyStatus, semanticViolations } from './task-semantics.js';
10
+ import { readDeliveryCache } from './core-delivery-cache.js';
10
11
 
11
12
  const STORE_DIR = 'task-store';
12
13
  const STORE_VERSION = 'v0';
@@ -14,7 +15,7 @@ const TASK_ID = /^task_[a-f0-9]{24}$/;
14
15
  const ARTIFACT_ID = /^artifact_[a-f0-9]{24}$/;
15
16
  const ATTEMPT_ID = /^attempt_[a-f0-9]{24}$/;
16
17
  const PROVIDER_BINDING = /^sha256:[a-f0-9]{64}$/;
17
- const CAPABILITIES = new Set(['image', 'video', 'tts', 'transcribe']);
18
+ const CAPABILITIES = new Set(['image', 'video', 'tts', 'music', 'transcribe']);
18
19
  const ATTEMPT_SELECTION_REASONS = new Set(['explicit', 'assignment', 'pool']);
19
20
  const ATTEMPT_STAGE_TIMESTAMPS = Object.freeze([
20
21
  'queuedAt', 'submitStartedAt', 'submittedAt', 'remoteCompletedAt',
@@ -106,6 +107,7 @@ function validateRecord(record, expectedId) {
106
107
  || !Array.isArray(record.attempts) || !Array.isArray(record.artifactIds)
107
108
  || !Number.isSafeInteger(record.revision) || record.revision < 1
108
109
  || typeof record.createdAt !== 'string' || typeof record.updatedAt !== 'string'
110
+ || (record.deliveryCache !== undefined && record.deliveryCache !== true)
109
111
  || (record.providerBinding !== undefined && !PROVIDER_BINDING.test(record.providerBinding))
110
112
  || (record.retriedFrom !== undefined && !TASK_ID.test(record.retriedFrom))
111
113
  || (record.sourceArtifactId !== undefined && (record.capability !== 'image' || !ARTIFACT_ID.test(record.sourceArtifactId)))) {
@@ -292,6 +294,7 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
292
294
  task.deliveryState = 'none';
293
295
  task.cancelState = 'none';
294
296
  delete task.remoteTaskId;
297
+ delete task.deliveryCache;
295
298
  delete task.lastError;
296
299
  });
297
300
  return Object.freeze(clone(created));
@@ -327,6 +330,7 @@ export function recordCoreAttemptResult(dataRoot, taskId, snapshot) {
327
330
  task.phase = 'running';
328
331
  task.outcome = 'succeeded';
329
332
  task.deliveryState = 'pending';
333
+ if (snapshot.deliveryCache === true) task.deliveryCache = true;
330
334
  }
331
335
  if (snapshot.remoteTaskId) task.remoteTaskId = String(snapshot.remoteTaskId);
332
336
  if (attempt.error) task.lastError = attempt.error;
@@ -436,7 +440,7 @@ export function recordCoreDownloadCompleted(dataRoot, taskId) {
436
440
 
437
441
  export function prepareCoreRedelivery(dataRoot, taskId) {
438
442
  return mutate(dataRoot, taskId, (task) => {
439
- if (task.outcome !== 'succeeded' || task.deliveryState !== 'failed' || !task.remoteTaskId) {
443
+ if (task.outcome !== 'succeeded' || task.deliveryState !== 'failed' || (!task.remoteTaskId && !task.deliveryCache)) {
440
444
  fail('IRIS_TASK_NOT_REDELIVERABLE', 'Task 不处于可重新交付状态');
441
445
  }
442
446
  task.phase = 'running';
@@ -515,11 +519,21 @@ export function recordCoreCancelResult(dataRoot, taskId, result) {
515
519
  }
516
520
 
517
521
  /**
518
- * 进程在 submit 返回落盘前退出时,没有 remoteTaskId 可以安全观察;恢复只能把
519
- * 事实收口为受理未知。已经落盘 remoteTaskId 的任务保持可观察,绝不新建 Attempt。
522
+ * 同步完整缓存可补回尚未落盘的完成事实;否则提交中断仍收口为受理未知。
523
+ * 已有 remoteTaskId 的任务保持可观察。恢复不重新提交,也不自动交付缓存。
520
524
  */
521
525
  export function recoverCoreTask(dataRoot, taskId) {
522
- const current = readRecord(dataRoot, taskId);
526
+ let current = readRecord(dataRoot, taskId);
527
+ if (current.phase === 'submitting' && current.acceptance === 'none') {
528
+ const cache = readDeliveryCache(dataRoot, current, { allowAbsent: true });
529
+ if (cache) {
530
+ recordCoreAttemptResult(dataRoot, taskId, {
531
+ id: cache.attemptId, providerId: cache.providerId, model: cache.modelRef,
532
+ acceptance: 'accepted', resultKind: 'completed', deliveryCache: true
533
+ });
534
+ current = readRecord(dataRoot, taskId);
535
+ }
536
+ }
523
537
  if (current.cancelState === 'requested') {
524
538
  return mutate(dataRoot, taskId, (task) => {
525
539
  task.phase = 'terminal';
@@ -79,7 +79,7 @@ export function coreTaskObservable(task) {
79
79
  }
80
80
 
81
81
  /**
82
- * 与 task.redeliver 同一组交付事实门:远端已成功、产物取回失败、远端 ID 仍在。
82
+ * 与 task.redeliver 同一组交付事实门:远端已成功、交付失败,有远端 ID 或同步缓存。
83
83
  * 只读持久化事实轴,不解析 Provider、不接触网络;binding 与能力校验仍在
84
84
  * Command Service。五类状态里只有 delivery_failed 会标记 redeliverable。
85
85
  */
@@ -87,7 +87,7 @@ export function coreTaskRedeliverable(task) {
87
87
  if (!task || typeof task !== 'object' || Array.isArray(task)) return false;
88
88
  return String(task.outcome || '') === 'succeeded'
89
89
  && String(task.deliveryState || '') === 'failed'
90
- && Boolean(String(task.remoteTaskId || '').trim());
90
+ && (Boolean(String(task.remoteTaskId || '').trim()) || task.deliveryCache === true);
91
91
  }
92
92
 
93
93
  /**
package/lib/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
  import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
+ import { isSupportedDshVersion, SUPPORTED_DSH_VERSIONS } from './dsh-compatibility.js';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
6
7
  import { spawnSync } from 'node:child_process';
@@ -331,13 +332,6 @@ function summarizeChecks(checks) {
331
332
  };
332
333
  }
333
334
 
334
- function dshVersionSupported(value) {
335
- /* 当前 0.2 候选只支持经真实安装服务隔离验收的准确 rc.2。
336
- Iris 0.1.4 的旧宿主窗口保留在历史发行中,不用于放行当前源码。 */
337
- const version = String(value || '').trim().replace(/\s+/g, '');
338
- return /^0\.2\.0-rc\.2(?:\+.*)?$/.test(version);
339
- }
340
-
341
335
  function names(value) {
342
336
  return [...new Set((Array.isArray(value) ? value : []).map((item) => String(item || '').trim()).filter(Boolean))];
343
337
  }
@@ -379,12 +373,12 @@ export function hostDoctor(host, evidence = {}, options = {}) {
379
373
  if (snapshot.host.id === 'deepseek-harness') {
380
374
  if (snapshot.host.version === 'unknown') {
381
375
  checks.push(result('dsh-version', 'warn', '无法从当前进程识别 DSH 版本',
382
- '使用 dsh --version 核对;当前候选仅支持 DSH 0.2.0-rc.2。'));
383
- } else if (dshVersionSupported(snapshot.host.version)) {
376
+ '使用 dsh --version 核对;已验证版本为 DSH ' + SUPPORTED_DSH_VERSIONS.join('、') + '。'));
377
+ } else if (isSupportedDshVersion(snapshot.host.version)) {
384
378
  checks.push(result('dsh-version', 'ok', 'DSH ' + snapshot.host.version + ' 位于已验证支持范围'));
385
379
  } else {
386
380
  checks.push(result('dsh-version', 'error', 'DSH ' + snapshot.host.version + ' 超出已验证支持范围',
387
- '当前候选需要 DSH 0.2.0-rc.2;旧 DSH 0.1.2 线或 0.1.5-rc.1 使用 Iris @mokuyoaxis/dsh-iris@0.1.4。其他版本须另行验收。'));
381
+ '当前支持 DSH ' + SUPPORTED_DSH_VERSIONS.join('、') + ';旧 DSH 0.1.2 线或 0.1.5-rc.1 使用 Iris @mokuyoaxis/dsh-iris@0.1.4。其他版本须另行验收。'));
388
382
  }
389
383
  }
390
384
 
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ /** 已完成宿主接口验收的版本;manifest/Doctor/验收脚本共用同一边界。 */
4
+ export const SUPPORTED_DSH_VERSIONS = Object.freeze(['0.2.0-rc.2', '0.2.1-alpha.4']);
5
+ export const DSH_SUPPORT_RANGE = SUPPORTED_DSH_VERSIONS.join(' || ');
6
+
7
+ export function isSupportedDshVersion(value) {
8
+ const version = String(value || '').trim().split('+')[0];
9
+ return SUPPORTED_DSH_VERSIONS.includes(version);
10
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { allProviders, irisHome, recordRateLimit } from './config.js';
5
+ import { allProviders, assignments, irisHome, recordRateLimit } from './config.js';
6
6
  import { createCommandService } from './command-service.js';
7
7
  import { createCoreArtifact, inspectCoreArtifact, listCoreArtifacts, listCoreArtifactMetadata, readCoreArtifactBytes } from './core-artifacts.js';
8
8
  import { createCoreRuntime } from './core-runtime.js';
@@ -15,7 +15,8 @@ import { requireHostPort } from './host-contract.js';
15
15
  import { createProviderTaskRunner } from './provider-task-runner.js';
16
16
  import { createConfiguredProviderAdapter as createProviderAdapter } from './provider-adapters.js';
17
17
  import { parseModelRef } from './models.js';
18
- import { imageCandidatesFromCatalog, providerForTaskFromCatalog, providerTaskBinding, transcribeCandidatesFromCatalog, ttsCandidatesFromCatalog, videoCandidatesFromCatalog } from './provider-catalog.js';
18
+ import { imageCandidatesFromCatalog, musicCandidatesFromCatalog, providerForTaskFromCatalog, providerTaskBinding, transcribeCandidatesFromCatalog, ttsCandidatesFromCatalog, videoCandidatesFromCatalog } from './provider-catalog.js';
19
+ import { generateMusicTask } from './music-generation.js';
19
20
 
20
21
  let writerTail = Promise.resolve();
21
22
  const providerWatchers = new Map();
@@ -67,6 +68,12 @@ function withReader(callback, signal) {
67
68
  return withRuntime('reader', callback, signal);
68
69
  }
69
70
 
71
+ /** 本地图片与会话附件都交给同一导入Command,不创建生成任务。 */
72
+ export function importImageForDsh(input = {}) {
73
+ return withWriter((_runtime, commands) => commands.execute('artifact.import', input.imageBytes !== undefined
74
+ ? { image_bytes: input.imageBytes } : { image_path: input.imagePath }), input.signal);
75
+ }
76
+
70
77
  /**
71
78
  * 将 DSH 图片输入投影到共享 crop Command,再把中性 Artifact 读成宿主可保存的字节。
72
79
  * bytes 输入先成为 host-input Artifact,输出通过 derived-from 保留关系。
@@ -179,6 +186,13 @@ export function submitProviderTaskForDsh(input = {}) {
179
186
  }), input.signal);
180
187
  }
181
188
 
189
+ export function submitMusicForDsh(input) {
190
+ return withWriter(runtime => generateMusicTask({
191
+ runner: createProviderTaskRunner(runtime), candidates: input.candidates,
192
+ providerInput: input.providerInput, options: input.musicOptions, signal: input.signal
193
+ }), input.signal);
194
+ }
195
+
182
196
  /** 单步观察已经受理的 Core Task;不会自动循环,也不会创建第二次 submit。 */
183
197
  export function observeProviderTaskForDsh(input = {}) {
184
198
  return withWriter((runtime) => createProviderTaskRunner(runtime).observe(
@@ -221,7 +235,7 @@ export function reobserveProviderTaskForDsh(input = {}) {
221
235
 
222
236
  /**
223
237
  * D2 人工 redeliver:只对 outcome=succeeded / deliveryState=failed 的 Task
224
- * 重新取回远端产物(re-poll 产物清单 → download → 新 Artifact),绝不 submit、
238
+ * 重新交付原结果(同步读缓存,异步 re-poll → download),绝不 submit、
225
239
  * 绝不重新生成。
226
240
  */
227
241
  export function redeliverProviderTaskForDsh(input = {}) {
@@ -277,12 +291,14 @@ export function retryProviderTaskForDsh(input = {}) {
277
291
  return withWriter(async (runtime) => {
278
292
  const commands = createCommandService(runtime, {
279
293
  resolveTaskCandidates: ({ capability, modelRef }) => {
280
- const catalog = { providers: allProviders() };
294
+ const catalog = { providers: allProviders(), assignments: assignments() };
281
295
  const ref = modelRef || undefined;
282
296
  const candidates = capability === 'video'
283
297
  ? videoCandidatesFromCatalog(catalog, ref)
284
298
  : capability === 'tts'
285
299
  ? ttsCandidatesFromCatalog(catalog, ref)
300
+ : capability === 'music'
301
+ ? musicCandidatesFromCatalog(catalog, ref)
286
302
  : capability === 'transcribe'
287
303
  ? transcribeCandidatesFromCatalog(catalog, ref)
288
304
  : imageCandidatesFromCatalog(catalog, ref);