@mokuyoaxis/dsh-iris 0.2.2 → 0.2.3

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 (63) 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 +41 -0
  6. package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
  7. package/.dsh/skills/iris-use/references/media-contract.md +73 -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 +26 -2
  12. package/README.en.md +39 -32
  13. package/README.md +37 -31
  14. package/bin/dsh-iris.js +27 -6
  15. package/docs/AGENT_QUICKSTART.md +9 -9
  16. package/docs/ARCHITECTURE.md +1 -1
  17. package/docs/CORE_RUNTIME_CONTRACT.md +3 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -2
  19. package/docs/FAULT_INJECTION.md +1 -0
  20. package/docs/HEADLESS_CLI.md +8 -4
  21. package/docs/HOST_DOCTOR.md +3 -3
  22. package/docs/MODEL_PORT_CONTRACT.md +6 -2
  23. package/docs/MUSIC.md +77 -0
  24. package/docs/PROMPT_OPTIMIZER.md +48 -10
  25. package/docs/PROVIDER_ADAPTER_CONTRACT.md +2 -0
  26. package/docs/ROADMAP.md +14 -25
  27. package/docs/SECURITY.md +3 -2
  28. package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
  29. package/docs/TASK_SEMANTICS.md +4 -4
  30. package/docs/releases/0.2.3.md +54 -0
  31. package/lib/actions.js +40 -3
  32. package/lib/adapters.js +1 -0
  33. package/lib/api.js +14 -5
  34. package/lib/bundled-skills.js +1 -1
  35. package/lib/capability.js +2 -0
  36. package/lib/client.js +111 -26
  37. package/lib/command-service.js +12 -3
  38. package/lib/config.js +26 -2
  39. package/lib/core-artifact-store.js +44 -6
  40. package/lib/core-delivery-cache.js +145 -0
  41. package/lib/core-maintenance.js +12 -1
  42. package/lib/core-tasks.js +19 -5
  43. package/lib/core-user-projection.js +2 -2
  44. package/lib/dsh-core-adapter.js +14 -4
  45. package/lib/dsh-text-model-adapter.js +15 -2
  46. package/lib/generation-input.js +10 -0
  47. package/lib/host-runtime.js +4 -2
  48. package/lib/index.js +23 -4
  49. package/lib/models.js +4 -1
  50. package/lib/music-generation.js +21 -0
  51. package/lib/music-options.js +18 -0
  52. package/lib/music-transport.js +46 -0
  53. package/lib/prompt-optimizer-config.js +92 -21
  54. package/lib/prompt-optimizer-core.js +51 -6
  55. package/lib/prompt-optimizer.js +20 -14
  56. package/lib/provider-adapter.js +1 -0
  57. package/lib/provider-adapters.js +24 -1
  58. package/lib/provider-catalog.js +18 -7
  59. package/lib/provider-config-service.js +13 -1
  60. package/lib/provider-protocol.js +12 -0
  61. package/lib/provider-task-runner.js +80 -5
  62. package/package.json +4 -2
  63. package/user_guide.md +30 -16
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,
@@ -196,7 +196,7 @@ export function pickFor(capability) {
196
196
  m = pool.find((x) => !x.rateLimited && x.ref === ref && x.capabilities.includes(capability));
197
197
  if (m) break;
198
198
  }
199
- if (!m) m = models.pickModel(pool, capability);
199
+ if (!m && capability !== cap.CAPABILITIES.MUSIC) m = models.pickModel(pool, capability);
200
200
  if (!m) return null;
201
201
  const p = providerById(m.providerId);
202
202
  if (!p) return null;
@@ -489,6 +489,7 @@ export function upsert(provider) {
489
489
  if (rest.visionInput !== undefined) rest.visionInput = normalizeVisionInputLimits(rest.visionInput) || null;
490
490
  if (Array.isArray(rest.models)) rest.models = rest.models.map(model => typeof model === 'string' ? model : ({ ...model,
491
491
  ...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
492
+ ...(model?.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(model.musicProtocol) } : {}),
492
493
  ...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
493
494
  if (id) {
494
495
  const i = c.providers.findIndex((p) => p.id === id);
@@ -541,6 +542,7 @@ export function setProviderModels(id, modelEntries) {
541
542
  p.models = modelEntries.map((m) => {
542
543
  const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities,
543
544
  ...(m.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(m.imageProtocol) } : {}),
545
+ ...(m.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(m.musicProtocol) } : {}),
544
546
  ...(m.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(m.visionInput) || null } : {}) };
545
547
  const old = prev.get(entry.id);
546
548
  if (old) {
@@ -548,6 +550,7 @@ export function setProviderModels(id, modelEntries) {
548
550
  if (old.source) entry.source = old.source;
549
551
  if (old.source === 'manual' && Array.isArray(old.capabilities)) entry.capabilities = old.capabilities;
550
552
  if (m?.imageProtocol === undefined && old.imageProtocol !== undefined) entry.imageProtocol = old.imageProtocol;
553
+ if (m?.musicProtocol === undefined && old.musicProtocol !== undefined) entry.musicProtocol = old.musicProtocol;
551
554
  if (m?.visionInput === undefined && old.visionInput !== undefined) entry.visionInput = old.visionInput;
552
555
  }
553
556
  if (m && m.verified) entry.verified = m.verified;
@@ -590,6 +593,9 @@ function findModelEntry(p, modelId) {
590
593
 
591
594
  function resetChangedImageProtocolHealth(previous, next) {
592
595
  for (const model of models.providerModels(previous)) {
596
+ if (model.musicProtocol !== models.providerModels(next).find(item => item.id === model.id)?.musicProtocol) {
597
+ clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.MUSIC);
598
+ }
593
599
  if (selectImageProtocol(previous, model.id).mediaProtocol !== selectImageProtocol(next, model.id).mediaProtocol) {
594
600
  clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.IMAGE);
595
601
  }
@@ -615,6 +621,24 @@ export function setModelImageProtocol(id, modelId, value) {
615
621
  return true;
616
622
  }
617
623
 
624
+ /** 音乐协议独立绑定到已配置模型,不覆盖账号生图协议。 */
625
+ export function setModelMusicProtocol(id, modelId, value) {
626
+ const protocol = normalizeMusicProtocol(value);
627
+ const provider = load().providers.find(item => item.id === id);
628
+ if (!provider) return false;
629
+ const entries = Array.isArray(provider.models) ? provider.models
630
+ : models.providerModels(provider).map(({ id, capabilities }) => ({ id, capabilities }));
631
+ const index = entries.findIndex(model => (typeof model === 'string' ? model : model?.id)?.trim() === modelId);
632
+ if (index < 0) return false;
633
+ const entry = typeof entries[index] === 'string' ? { id: entries[index] } : entries[index];
634
+ entries[index] = entry;
635
+ provider.models = entries;
636
+ if (entry.musicProtocol !== protocol) clearModelCapabilityHealth(provider, modelId, cap.CAPABILITIES.MUSIC);
637
+ entry.musicProtocol = protocol;
638
+ persist();
639
+ return true;
640
+ }
641
+
618
642
  /** 看图预算变更不会伪造实测成功、解除额度停用或改写原图。 */
619
643
  export function setModelVisionInput(id, modelId, value) {
620
644
  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
  /**
@@ -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();
@@ -179,6 +180,13 @@ export function submitProviderTaskForDsh(input = {}) {
179
180
  }), input.signal);
180
181
  }
181
182
 
183
+ export function submitMusicForDsh(input) {
184
+ return withWriter(runtime => generateMusicTask({
185
+ runner: createProviderTaskRunner(runtime), candidates: input.candidates,
186
+ providerInput: input.providerInput, options: input.musicOptions, signal: input.signal
187
+ }), input.signal);
188
+ }
189
+
182
190
  /** 单步观察已经受理的 Core Task;不会自动循环,也不会创建第二次 submit。 */
183
191
  export function observeProviderTaskForDsh(input = {}) {
184
192
  return withWriter((runtime) => createProviderTaskRunner(runtime).observe(
@@ -221,7 +229,7 @@ export function reobserveProviderTaskForDsh(input = {}) {
221
229
 
222
230
  /**
223
231
  * D2 人工 redeliver:只对 outcome=succeeded / deliveryState=failed 的 Task
224
- * 重新取回远端产物(re-poll 产物清单 → download → 新 Artifact),绝不 submit、
232
+ * 重新交付原结果(同步读缓存,异步 re-poll → download),绝不 submit、
225
233
  * 绝不重新生成。
226
234
  */
227
235
  export function redeliverProviderTaskForDsh(input = {}) {
@@ -277,12 +285,14 @@ export function retryProviderTaskForDsh(input = {}) {
277
285
  return withWriter(async (runtime) => {
278
286
  const commands = createCommandService(runtime, {
279
287
  resolveTaskCandidates: ({ capability, modelRef }) => {
280
- const catalog = { providers: allProviders() };
288
+ const catalog = { providers: allProviders(), ...(capability === 'music' ? { assignments: assignments() } : {}) };
281
289
  const ref = modelRef || undefined;
282
290
  const candidates = capability === 'video'
283
291
  ? videoCandidatesFromCatalog(catalog, ref)
284
292
  : capability === 'tts'
285
293
  ? ttsCandidatesFromCatalog(catalog, ref)
294
+ : capability === 'music'
295
+ ? musicCandidatesFromCatalog(catalog, ref)
286
296
  : capability === 'transcribe'
287
297
  ? transcribeCandidatesFromCatalog(catalog, ref)
288
298
  : imageCandidatesFromCatalog(catalog, ref);
@@ -65,7 +65,11 @@ export async function prepareDshTextModelPort(textModel, binding, { signal } = {
65
65
  if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
66
66
  return textModel.resolveModelInfo(route.provider, route.model, signal);
67
67
  }), signal, 'prepare', 'not_invoked'); }
68
- catch (_) { if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked'); }
68
+ catch (_) {
69
+ if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
70
+ // 查询失败不同于成功查询但能力缺失;不要吞掉错误后进入付费生成。
71
+ throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare', invocation: 'not_invoked' });
72
+ }
69
73
  }
70
74
  if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
71
75
  return createDshTextModelPort(textModel, { ...route, sessionId }, { metadata });
@@ -73,7 +77,16 @@ export async function prepareDshTextModelPort(textModel, binding, { signal } = {
73
77
 
74
78
  /** 已绑定事实的纯构造;用于缓存元数据/协议 fixture,不执行元数据查询。 */
75
79
  export function createDshTextModelPort(textModel, binding, { metadata } = {}) {
76
- return createDshBoundModelPort(textModel, binding, { metadata });
80
+ const bound = createDshBoundModelPort(textModel, binding, { metadata });
81
+ const modalities = metadata?.inputModalities;
82
+ if (modalities !== undefined) {
83
+ if (!Array.isArray(modalities) || modalities.some(value => typeof value !== 'string' || !value.trim())) {
84
+ throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'prepare', invocation: 'not_invoked' });
85
+ }
86
+ if (!modalities.includes('text')) throw new ModelPortError('IRIS_MODEL_UNSUPPORTED', { stage: 'prepare', invocation: 'not_invoked' });
87
+ }
88
+ // 缺失能力只表示未知;宿主接受文本输入也不证明远端必定只返回文字。
89
+ return Object.freeze({ ...bound, textInput: modalities === undefined ? 'unknown' : 'supported' });
77
90
  }
78
91
 
79
92
  /** 文本与视觉共用经过验证的 DSH 事件读取器;图片桥接仅由视觉适配器注入。 */
@@ -2,11 +2,13 @@
2
2
 
3
3
  import path from 'node:path';
4
4
  import fs from 'node:fs';
5
+ import { normalizeMusicOptions } from './music-options.js';
5
6
 
6
7
  const FIELDS = Object.freeze({
7
8
  image: ['prompt', 'size', 'n', 'source_artifact_id'],
8
9
  video: ['prompt', 'size', 'duration', 'img_data_url', 'first_frame_path', 'audio_path', 'resolution'],
9
10
  tts: ['text', 'voice'],
11
+ music: ['prompt', 'timeout_ms', 'retry_502', 'retry_delay_ms'],
10
12
  transcribe: ['audio_url', 'audio_path']
11
13
  });
12
14
 
@@ -47,6 +49,7 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
47
49
  const providerInput = {};
48
50
  let audioPath;
49
51
  let sourceArtifactId;
52
+ let musicOptions;
50
53
  let firstFramePath, videoMode;
51
54
  if (capability === 'video') {
52
55
  audioPath = String(input.audio_path || '').trim();
@@ -101,6 +104,12 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
101
104
  const voice = text(input.voice, 'voice', 64);
102
105
  if (voice !== undefined) providerInput.voice = voice;
103
106
  }
107
+ if (capability === 'music') {
108
+ providerInput.prompt = text(input.prompt, 'prompt', 20000, true);
109
+ try { musicOptions = normalizeMusicOptions(input); }
110
+ catch (error) { fail(error.message); }
111
+ providerInput.timeoutMs = musicOptions.timeoutMs;
112
+ }
104
113
  if (capability === 'transcribe') {
105
114
  const audioUrl = String(input.audio_url || '').trim();
106
115
  audioPath = String(input.audio_path || '').trim();
@@ -119,6 +128,7 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
119
128
  return Object.freeze({
120
129
  modelRef,
121
130
  providerInput: Object.freeze(providerInput),
131
+ ...(musicOptions ? { musicOptions } : {}),
122
132
  ...(sourceArtifactId ? { sourceArtifactId } : {}),
123
133
  ...(audioPath ? { audioPath } : {}),
124
134
  ...(capability === 'video' ? { firstFramePath, videoMode } : {})
@@ -13,6 +13,7 @@ export const EXPECTED_IRIS_TOOLS = Object.freeze([
13
13
  'iris_draw_image',
14
14
  'iris_edit_image',
15
15
  'iris_generate_video',
16
+ 'iris_generate_music',
16
17
  'iris_speak_text',
17
18
  'iris_transcribe_audio',
18
19
  'iris_task_status',
@@ -28,8 +29,9 @@ export const EXPECTED_IRIS_TOOLS = Object.freeze([
28
29
  ]);
29
30
 
30
31
  export const EXPECTED_IRIS_SKILLS = Object.freeze([
31
- 'iris-verify-ui',
32
- 'iris-compose-media'
32
+ 'iris-use',
33
+ 'iris-compose-media',
34
+ 'iris-verify-ui'
33
35
  ]);
34
36
 
35
37
  export const EXPECTED_IRIS_ROUTES = Object.freeze([