@mokuyoaxis/dsh-iris 0.2.1 → 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 (76) 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 +48 -2
  12. package/README.en.md +47 -40
  13. package/README.md +45 -39
  14. package/bin/dsh-iris.js +27 -6
  15. package/docs/AGENT_QUICKSTART.md +9 -9
  16. package/docs/ARCHITECTURE.md +11 -3
  17. package/docs/ARTIFACT_LIBRARY.md +1 -1
  18. package/docs/CLI_MANAGEMENT.md +5 -3
  19. package/docs/COMPOSITE_VISION.md +1 -1
  20. package/docs/CORE_RUNTIME_CONTRACT.md +12 -2
  21. package/docs/DSH_CORE_MIGRATION.md +7 -3
  22. package/docs/FAULT_INJECTION.md +11 -1
  23. package/docs/HEADLESS_CLI.md +10 -4
  24. package/docs/HOST_ADAPTER_CONTRACT.md +1 -1
  25. package/docs/HOST_DOCTOR.md +3 -3
  26. package/docs/MODEL_PORT_CONTRACT.md +9 -3
  27. package/docs/MUSIC.md +77 -0
  28. package/docs/OCR_MODEL.md +1 -1
  29. package/docs/PROMPT_OPTIMIZER.md +74 -6
  30. package/docs/PROVIDER_ADAPTER_CONTRACT.md +12 -2
  31. package/docs/PROVIDER_HEALTH.md +1 -1
  32. package/docs/ROADMAP.md +15 -18
  33. package/docs/SECURITY.md +4 -3
  34. package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
  35. package/docs/TASK_SEMANTICS.md +5 -5
  36. package/docs/VISION_CLI.md +1 -1
  37. package/docs/VISION_MODEL.md +1 -1
  38. package/docs/WORKBENCH_ARTIFACTS.md +1 -1
  39. package/docs/releases/0.2.2.md +31 -0
  40. package/docs/releases/0.2.3.md +54 -0
  41. package/lib/actions.js +52 -19
  42. package/lib/adapters.js +89 -24
  43. package/lib/api.js +14 -5
  44. package/lib/bundled-skills.js +1 -1
  45. package/lib/capability.js +25 -16
  46. package/lib/client.js +374 -43
  47. package/lib/command-service.js +12 -3
  48. package/lib/config.js +39 -6
  49. package/lib/core-artifact-store.js +44 -6
  50. package/lib/core-delivery-cache.js +145 -0
  51. package/lib/core-lease-recovery.js +64 -92
  52. package/lib/core-maintenance.js +12 -1
  53. package/lib/core-runtime.js +45 -47
  54. package/lib/core-tasks.js +19 -5
  55. package/lib/core-user-projection.js +2 -2
  56. package/lib/core-writer-lease.js +134 -0
  57. package/lib/dsh-core-adapter.js +14 -4
  58. package/lib/dsh-text-model-adapter.js +15 -2
  59. package/lib/generation-input.js +10 -0
  60. package/lib/host-runtime.js +4 -2
  61. package/lib/index.js +23 -4
  62. package/lib/models.js +23 -38
  63. package/lib/music-generation.js +21 -0
  64. package/lib/music-options.js +18 -0
  65. package/lib/music-transport.js +46 -0
  66. package/lib/prompt-optimizer-config.js +111 -30
  67. package/lib/prompt-optimizer-core.js +183 -12
  68. package/lib/prompt-optimizer.js +20 -14
  69. package/lib/provider-adapter.js +1 -0
  70. package/lib/provider-adapters.js +26 -2
  71. package/lib/provider-catalog.js +18 -7
  72. package/lib/provider-config-service.js +16 -5
  73. package/lib/provider-protocol.js +73 -0
  74. package/lib/provider-task-runner.js +86 -9
  75. package/package.json +4 -2
  76. package/user_guide.md +52 -24
@@ -12,36 +12,20 @@ import {
12
12
  normalizeCoreOptions,
13
13
  transitionCoreLifecycle
14
14
  } from './core-contract.js';
15
- import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
16
-
17
- const WRITER_LEASE_DIR = '.iris-runtime-writer-v0';
18
- const WRITER_OWNER_FILE = 'owner.json';
15
+ import { ensurePrivateDir } from './private-storage.js';
16
+ import { WRITER_LEASE_DIR, WRITER_INTENT_FILE, publishLeaseRecord, readWriterLease,
17
+ removeWriterIntent, retireLeaseDirectory, writerOwnerStatus } from './core-writer-lease.js';
19
18
 
20
19
  /** 只读租约证据;PID 缺失不等于已经获准恢复,绝不自动夺取租约。 */
21
20
  export function inspectCoreWriterLease(dataRoot) {
22
21
  const options = normalizeCoreOptions({ dataRoot, mode: 'reader' });
23
- const directory = path.join(options.dataRoot, WRITER_LEASE_DIR);
24
- let stat;
25
- try { stat = fs.lstatSync(directory); }
26
- catch (error) { return { status: error?.code === 'ENOENT' ? 'absent' : 'invalid' }; }
27
22
  try {
28
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid lease');
29
- const entries = fs.readdirSync(directory);
30
- if (entries.length !== 1 || entries[0] !== WRITER_OWNER_FILE) throw new Error('invalid contents');
31
- const ownerFile = path.join(directory, WRITER_OWNER_FILE);
32
- const ownerStat = fs.lstatSync(ownerFile);
33
- if (!ownerStat.isFile() || ownerStat.isSymbolicLink() || ownerStat.size > 16384) throw new Error('invalid owner');
34
- const owner = JSON.parse(fs.readFileSync(ownerFile, 'utf8'));
35
- const created = Date.parse(owner?.createdAt);
36
- if (owner?.contractVersion !== CORE_CONTRACT_VERSION || !Number.isSafeInteger(owner.pid) || owner.pid <= 0
37
- || !/^[a-f0-9-]{36}$/.test(String(owner.instanceId || '')) || !Number.isFinite(created)) {
38
- throw new Error('incomplete owner');
39
- }
40
- let ownerStatus = 'alive';
41
- try { process.kill(owner.pid, 0); }
42
- catch (error) { ownerStatus = error?.code === 'ESRCH' ? 'missing' : 'unknown'; }
23
+ const lease = readWriterLease(options.dataRoot);
24
+ if (!lease) return { status: 'absent' };
25
+ const owner = lease.owner;
26
+ const created = Date.parse(owner.createdAt);
43
27
  return {
44
- status: 'present', ownerStatus, pid: owner.pid,
28
+ status: 'present', ownerStatus: writerOwnerStatus(owner.pid), pid: owner.pid, phase: lease.phase,
45
29
  createdAt: new Date(created).toISOString(), ageMs: Math.max(0, Date.now() - created)
46
30
  };
47
31
  } catch (_) { return { status: 'invalid' }; }
@@ -77,56 +61,70 @@ function physicalDataRoot(options) {
77
61
 
78
62
  function acquireWriterLease(root, instanceId) {
79
63
  const directory = path.join(root, WRITER_LEASE_DIR);
80
- const ownerFile = path.join(directory, WRITER_OWNER_FILE);
64
+ const ownerFile = path.join(directory, 'owner.json');
65
+ const intentFile = path.join(root, WRITER_INTENT_FILE);
81
66
  try {
82
- fs.mkdirSync(directory, { mode: 0o700 });
67
+ fs.lstatSync(directory);
68
+ throw coreDataRootBusyError();
83
69
  } catch (error) {
84
- if (error && error.code === 'EEXIST') throw coreDataRootBusyError();
85
- throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法取得 Iris 数据根写者租约');
70
+ if (error?.code !== 'ENOENT') {
71
+ if (error instanceof CoreContractError) throw error;
72
+ throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法检查 Iris 数据根写者租约');
73
+ }
86
74
  }
87
-
88
75
  const owner = Object.freeze({
89
76
  contractVersion: CORE_CONTRACT_VERSION,
90
77
  instanceId,
91
78
  pid: process.pid,
92
79
  createdAt: new Date().toISOString()
93
80
  });
94
- const serialized = JSON.stringify(owner, null, 2) + '\n';
95
81
  try {
96
- writePrivateFile(ownerFile, serialized, { flag: 'wx' });
97
- } catch (_) {
98
- try { fs.unlinkSync(ownerFile); } catch (_) { /* owner 文件可能尚未建立 */ }
99
- try { fs.rmdirSync(directory); } catch (_) { /* 保留不确定租约比误删安全 */ }
82
+ // 创建目录之前已有完整 owner;SIGKILL 不会留下无法确认 PID 的新空锁。
83
+ publishLeaseRecord(root, intentFile, owner);
84
+ } catch (error) {
85
+ if (error?.code === 'EEXIST') throw coreDataRootBusyError();
100
86
  throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法记录 Iris 数据根写者租约');
101
87
  }
102
- return Object.freeze({ directory, ownerFile, owner, serialized });
88
+ let madeDirectory = false;
89
+ try {
90
+ fs.mkdirSync(directory, { mode: 0o700 });
91
+ madeDirectory = true;
92
+ fs.linkSync(intentFile, ownerFile);
93
+ return Object.freeze({ root, directory, ownerFile, owner, evidence: readWriterLease(root) });
94
+ } catch (error) {
95
+ // 仅撤回本次创建的空目录和意图;不碰其他 writer 或未知目录内容。
96
+ let withdrawn = !madeDirectory;
97
+ if (madeDirectory) {
98
+ try { fs.rmdirSync(directory); withdrawn = true; } catch (_) { /* owner/目录证据保留 */ }
99
+ }
100
+ if (withdrawn) { try { fs.unlinkSync(intentFile); } catch (_) { /* 保留 owner 供显式恢复 */ } }
101
+ if (error?.code === 'EEXIST') throw coreDataRootBusyError();
102
+ throw coreError('IRIS_CORE_DATA_ROOT_UNAVAILABLE', '无法取得 Iris 数据根写者租约');
103
+ }
103
104
  }
104
105
 
105
106
  function releaseWriterLease(lease) {
106
107
  let actual;
107
108
  try {
108
- const stat = fs.lstatSync(lease.directory);
109
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('lease is not a directory');
110
- const entries = fs.readdirSync(lease.directory);
111
- if (entries.length !== 1 || entries[0] !== WRITER_OWNER_FILE) throw new Error('unexpected lease contents');
112
- actual = JSON.parse(fs.readFileSync(lease.ownerFile, 'utf8'));
109
+ actual = readWriterLease(lease.root);
110
+ if (!actual || actual.fingerprint !== lease.evidence.fingerprint || actual.phase !== 'owned') throw new Error('lease changed');
113
111
  } catch (_) {
114
112
  throw coreError('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约无法安全释放;已保留现场');
115
113
  }
116
- if (actual.instanceId !== lease.owner.instanceId) {
114
+ if (actual.owner.instanceId !== lease.owner.instanceId) {
117
115
  throw coreError('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约所有者不匹配;已保留现场');
118
116
  }
119
117
 
118
+ const retired = path.join(lease.root, WRITER_LEASE_DIR + '.released-' + lease.owner.instanceId);
120
119
  try {
121
- fs.unlinkSync(lease.ownerFile);
122
- fs.rmdirSync(lease.directory);
120
+ // 整体移开非空目录;owner 从不在占位目录中先被删除。
121
+ retireLeaseDirectory(lease.directory, retired);
122
+ removeWriterIntent(actual);
123
123
  } catch (_) {
124
- // 若目录删除失败,尽力恢复 owner 证据;绝不把仍不确定的根宣布为可写。
125
- if (fs.existsSync(lease.directory) && !fs.existsSync(lease.ownerFile)) {
126
- try { writePrivateFile(lease.ownerFile, lease.serialized, { flag: 'wx' }); } catch (_) { /* 保持失败 */ }
127
- }
128
124
  throw coreError('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约释放失败;已保留现场');
129
125
  }
126
+ // 退役目录已不占有租约。中断或清理失败只留下可读证据,不阻止后续 writer。
127
+ try { fs.unlinkSync(path.join(retired, 'owner.json')); fs.rmdirSync(retired); } catch (_) { /* 保留退役现场 */ }
130
128
  }
131
129
 
132
130
  function runtimeSnapshot(options, lifecycle, hasLease, inFlight) {
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
  /**
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { CORE_CONTRACT_VERSION } from './core-contract.js';
7
+ import { privateSibling } from './private-storage.js';
8
+
9
+ export const WRITER_LEASE_DIR = '.iris-runtime-writer-v0';
10
+ export const WRITER_INTENT_FILE = '.iris-runtime-writer-v0.owner.json';
11
+ export function recoveryClaimFile(root, owner) {
12
+ return path.join(root, WRITER_LEASE_DIR + '.recovery-' + owner.instanceId + '.json');
13
+ }
14
+ const UUID = /^[a-f0-9-]{36}$/;
15
+ const STALE = /^\.owner\.stale-([a-f0-9-]{36})\.json$/;
16
+
17
+ export function writerOwnerStatus(pid) {
18
+ try { process.kill(pid, 0); return 'alive'; }
19
+ catch (error) { return error?.code === 'ESRCH' ? 'missing' : 'unknown'; }
20
+ }
21
+
22
+ export function newWriterOwner() {
23
+ return { contractVersion: CORE_CONTRACT_VERSION, instanceId: crypto.randomUUID(),
24
+ pid: process.pid, createdAt: new Date().toISOString() };
25
+ }
26
+
27
+ function checkedOwner(owner) {
28
+ if (owner?.contractVersion !== CORE_CONTRACT_VERSION || !Number.isSafeInteger(owner.pid) || owner.pid <= 0
29
+ || !UUID.test(String(owner.instanceId || '')) || !Number.isFinite(Date.parse(owner.createdAt))) {
30
+ throw new Error('incomplete owner');
31
+ }
32
+ return owner;
33
+ }
34
+
35
+ function readRecord(file) {
36
+ const stat = fs.lstatSync(file);
37
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16384) throw new Error('invalid lease record');
38
+ const serialized = fs.readFileSync(file, 'utf8');
39
+ return { serialized, value: JSON.parse(serialized) };
40
+ }
41
+
42
+ function optionalRecord(file) {
43
+ try { return readRecord(file); }
44
+ catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
45
+ }
46
+
47
+ /** 完整私有记录先在根中准备,再以 hard link 原子发布;已有目标绝不覆盖。 */
48
+ export function publishLeaseRecord(root, target, value) {
49
+ const prepared = privateSibling(path.join(root, WRITER_INTENT_FILE), 'prepare');
50
+ let created = false;
51
+ try {
52
+ fs.writeFileSync(prepared, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
53
+ created = true;
54
+ fs.linkSync(prepared, target);
55
+ } finally {
56
+ if (created) { try { fs.unlinkSync(prepared); } catch (_) { /* 未发布的准备文件不占有租约 */ } }
57
+ }
58
+ }
59
+
60
+ export function withdrawLeaseRecord(target, value) {
61
+ if (readRecord(target).serialized !== JSON.stringify(value, null, 2) + '\n') throw new Error('published record changed');
62
+ fs.unlinkSync(target);
63
+ }
64
+
65
+ export function retireLeaseDirectory(source, target) {
66
+ try { fs.lstatSync(target); throw new Error('retirement destination already exists'); }
67
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
68
+ fs.renameSync(source, target);
69
+ }
70
+
71
+ /** 只读识别完整 owner、创建/释放意图及恢复链;未知内容和缺失证据拒绝。 */
72
+ export function readWriterLease(root) {
73
+ const directory = path.join(root, WRITER_LEASE_DIR);
74
+ const intentFile = path.join(root, WRITER_INTENT_FILE);
75
+ const intent = optionalRecord(intentFile);
76
+ if (intent) checkedOwner(intent.value);
77
+ let stat;
78
+ try { stat = fs.lstatSync(directory); }
79
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
80
+ if (!stat && !intent) return null;
81
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error('invalid lease directory');
82
+ const entries = stat ? fs.readdirSync(directory).sort() : [];
83
+ const records = new Map();
84
+ for (const name of entries) {
85
+ if (name !== 'owner.json' && !STALE.test(name)) throw new Error('unexpected lease contents');
86
+ records.set(name, readRecord(path.join(directory, name)));
87
+ }
88
+ const ownerRecord = records.get('owner.json');
89
+ if (ownerRecord) checkedOwner(ownerRecord.value);
90
+ const staleRecords = entries.filter(name => STALE.test(name));
91
+ if (staleRecords.length > 1) throw new Error('ambiguous legacy recovery');
92
+ const staleRecord = staleRecords.length ? records.get(staleRecords[0]) : null;
93
+ if (staleRecord) checkedOwner(staleRecord.value);
94
+ if (staleRecord && !ownerRecord) throw new Error('legacy recovery PID unknown');
95
+ if (intent && ownerRecord && ownerRecord.serialized !== intent.serialized) {
96
+ throw new Error('intent owner mismatch');
97
+ }
98
+ if (staleRecord && ownerRecord && STALE.exec(staleRecords[0])[1] !== ownerRecord.value.instanceId) {
99
+ throw new Error('legacy recovery owner mismatch');
100
+ }
101
+ const original = staleRecord || ownerRecord || intent;
102
+ if (!original) throw new Error('owner evidence missing');
103
+ let owner = (ownerRecord || original).value;
104
+ const owners = [original.value];
105
+ if (owner.instanceId !== original.value.instanceId) owners.push(owner);
106
+ const claims = [];
107
+ for (;;) {
108
+ const file = recoveryClaimFile(root, owner);
109
+ const record = optionalRecord(file);
110
+ if (!record) break;
111
+ const claim = record.value;
112
+ if (claim?.schemaVersion !== 1 || claim.previousInstanceId !== owner.instanceId) throw new Error('invalid recovery claim');
113
+ claims.push({ file, ...record });
114
+ owner = checkedOwner(claim.owner);
115
+ if (owners.some(previous => previous.instanceId === owner.instanceId)) throw new Error('cyclic recovery chain');
116
+ owners.push(owner);
117
+ if (owners.length > 64) throw new Error('recovery chain too long');
118
+ }
119
+ const phase = owners.length > 1 || staleRecord ? 'recovering' : ownerRecord ? 'owned' : 'pending';
120
+ const fingerprint = crypto.createHash('sha256').update(JSON.stringify({
121
+ identity: stat ? [stat.dev, stat.ino] : null, intent: intent?.serialized,
122
+ records: [...records].map(([name, record]) => [name, record.serialized]),
123
+ claims: claims.map(record => [path.basename(record.file), record.serialized])
124
+ })).digest('hex');
125
+ return { directory, intentFile, intent, stat, entries, records, claims, owner, owners,
126
+ originalOwner: original.value, phase, fingerprint };
127
+ }
128
+
129
+ export function removeWriterIntent(lease) {
130
+ if (!lease.intent) return;
131
+ const actual = readRecord(lease.intentFile);
132
+ if (actual.serialized !== lease.intent.serialized) throw new Error('intent changed');
133
+ fs.unlinkSync(lease.intentFile);
134
+ }
@@ -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([
package/lib/index.js CHANGED
@@ -263,7 +263,7 @@ export async function apply(ctx) {
263
263
  // 若 webServer 已在运行,inject 回调也会触发;这里兜底立即挂载一次
264
264
  guard('媒体路由兜底挂载', () => mountIrisRoutes(ctx));
265
265
 
266
- // DSH 的 Skill registry 是可选宿主能力。存在时把 npm 包内两项 Skill 注册到
266
+ // DSH 的 Skill registry 是可选宿主能力。存在时把 npm 包内三项 Skill 注册到
267
267
  // 全局层;项目目录中的同名 Skill 仍由 DSH 原生优先级覆盖。
268
268
  ctx.inject(['skills'], (skillCtx) => {
269
269
  guard('随包 Skill 注册', () => {
@@ -463,10 +463,29 @@ export async function apply(ctx) {
463
463
  }
464
464
  });
465
465
 
466
+ safeRegister(host, {
467
+ name: 'iris_generate_music',
468
+ description: 'Generate music using a model explicitly assigned to Music. Returns audio artifact IDs and playback links. Lyrics, vocals and duration are prompt intentions. Configured 502 retries may generate again.',
469
+ parameters: { type: 'object', properties: {
470
+ prompt: { type: 'string', description: 'Describe the music, including any lyrics, vocals, mood or intended duration' },
471
+ model: { type: 'string', description: 'Assigned providerId::modelId reference' },
472
+ timeout_ms: { type: 'integer', description: 'Per-attempt timeout; default 300000 ms' },
473
+ retry_502: { type: 'integer', description: '502 regeneration retries, 0–5; default 2; each retry consumes another request' },
474
+ retry_delay_ms: { type: 'integer', description: 'Delay between 502 retries; default 5000 ms' }
475
+ }, required: ['prompt'] },
476
+ output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
477
+ isConcurrencySafe: () => true,
478
+ async execute(args, exec) {
479
+ const submitted = await runAction(dshHost(ctx), 'music', args, { signal: exec.signal });
480
+ return submitted.text + '\nartifact: ' + submitted.artifactIds.join(', ') + '\n' +
481
+ submitted.artifactIds.map(id => '[♪ 音乐播放](/iris/api/core/artifact/' + encodeURIComponent(id) + '/media)').join('\n');
482
+ }
483
+ });
484
+
466
485
  /* ---------- 🔊 语音合成 ---------- */
467
486
  safeRegister(host, {
468
487
  name: 'iris_speak_text',
469
- description: 'Synthesize speech audio from text using the configured TTS model (qwen-tts / compatible). Returns the saved audio file path.',
488
+ description: 'Synthesize spoken audio from text using the configured TTS model. Returns Core audio Artifact IDs and playback links; export an Artifact to a host file before passing it as audio_path to another tool. Use iris_generate_music for songs or background music.',
470
489
  parameters: {
471
490
  type: 'object',
472
491
  properties: {
@@ -780,7 +799,7 @@ export async function apply(ctx) {
780
799
  safeRegister(host, {
781
800
  name: 'iris_task_status',
782
801
  description:
783
- 'Query iris generation tasks (image/video/tts). Pass a task_id for one task, or omit it to list the latest tasks with status, progress, errors and output file paths.',
802
+ 'Query Iris tasks (image/video/tts/music/transcription). Pass a task_id for one task, or omit it to list recent tasks. Core results provide Artifact IDs and playback links; legacy tasks may provide file paths. Querying does not re-observe or redeliver a task.',
784
803
  parameters: {
785
804
  type: 'object',
786
805
  properties: {
@@ -1022,7 +1041,7 @@ export async function apply(ctx) {
1022
1041
  }
1023
1042
  });
1024
1043
 
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');
1044
+ console.log('[iris] tools registered: iris_draw_image, iris_edit_image, iris_generate_video, iris_generate_music, 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');
1026
1045
  };
1027
1046
 
1028
1047
  /* ---------------- 视觉路由(M3 补全):look/relook 共用执行器 + 降级链 ---------------- */