@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.
- package/.dsh/skills/iris-compose-media/SKILL.md +23 -73
- package/.dsh/skills/iris-compose-media/agents/openai.yaml +1 -1
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +23 -22
- package/.dsh/skills/iris-compose-media/references/workflows.md +25 -30
- package/.dsh/skills/iris-use/SKILL.md +41 -0
- package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-use/references/media-contract.md +73 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +9 -7
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +1 -1
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +3 -0
- package/CHANGELOG.md +48 -2
- package/README.en.md +47 -40
- package/README.md +45 -39
- package/bin/dsh-iris.js +27 -6
- package/docs/AGENT_QUICKSTART.md +9 -9
- package/docs/ARCHITECTURE.md +11 -3
- package/docs/ARTIFACT_LIBRARY.md +1 -1
- package/docs/CLI_MANAGEMENT.md +5 -3
- package/docs/COMPOSITE_VISION.md +1 -1
- package/docs/CORE_RUNTIME_CONTRACT.md +12 -2
- package/docs/DSH_CORE_MIGRATION.md +7 -3
- package/docs/FAULT_INJECTION.md +11 -1
- package/docs/HEADLESS_CLI.md +10 -4
- package/docs/HOST_ADAPTER_CONTRACT.md +1 -1
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MODEL_PORT_CONTRACT.md +9 -3
- package/docs/MUSIC.md +77 -0
- package/docs/OCR_MODEL.md +1 -1
- package/docs/PROMPT_OPTIMIZER.md +74 -6
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +12 -2
- package/docs/PROVIDER_HEALTH.md +1 -1
- package/docs/ROADMAP.md +15 -18
- package/docs/SECURITY.md +4 -3
- package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
- package/docs/TASK_SEMANTICS.md +5 -5
- package/docs/VISION_CLI.md +1 -1
- package/docs/VISION_MODEL.md +1 -1
- package/docs/WORKBENCH_ARTIFACTS.md +1 -1
- package/docs/releases/0.2.2.md +31 -0
- package/docs/releases/0.2.3.md +54 -0
- package/lib/actions.js +52 -19
- package/lib/adapters.js +89 -24
- package/lib/api.js +14 -5
- package/lib/bundled-skills.js +1 -1
- package/lib/capability.js +25 -16
- package/lib/client.js +374 -43
- package/lib/command-service.js +12 -3
- package/lib/config.js +39 -6
- package/lib/core-artifact-store.js +44 -6
- package/lib/core-delivery-cache.js +145 -0
- package/lib/core-lease-recovery.js +64 -92
- package/lib/core-maintenance.js +12 -1
- package/lib/core-runtime.js +45 -47
- package/lib/core-tasks.js +19 -5
- package/lib/core-user-projection.js +2 -2
- package/lib/core-writer-lease.js +134 -0
- package/lib/dsh-core-adapter.js +14 -4
- package/lib/dsh-text-model-adapter.js +15 -2
- package/lib/generation-input.js +10 -0
- package/lib/host-runtime.js +4 -2
- package/lib/index.js +23 -4
- package/lib/models.js +23 -38
- package/lib/music-generation.js +21 -0
- package/lib/music-options.js +18 -0
- package/lib/music-transport.js +46 -0
- package/lib/prompt-optimizer-config.js +111 -30
- package/lib/prompt-optimizer-core.js +183 -12
- package/lib/prompt-optimizer.js +20 -14
- package/lib/provider-adapter.js +1 -0
- package/lib/provider-adapters.js +26 -2
- package/lib/provider-catalog.js +18 -7
- package/lib/provider-config-service.js +16 -5
- package/lib/provider-protocol.js +73 -0
- package/lib/provider-task-runner.js +86 -9
- package/package.json +4 -2
- package/user_guide.md +52 -24
package/lib/core-runtime.js
CHANGED
|
@@ -12,36 +12,20 @@ import {
|
|
|
12
12
|
normalizeCoreOptions,
|
|
13
13
|
transitionCoreLifecycle
|
|
14
14
|
} from './core-contract.js';
|
|
15
|
-
import { ensurePrivateDir
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
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,
|
|
64
|
+
const ownerFile = path.join(directory, 'owner.json');
|
|
65
|
+
const intentFile = path.join(root, WRITER_INTENT_FILE);
|
|
81
66
|
try {
|
|
82
|
-
fs.
|
|
67
|
+
fs.lstatSync(directory);
|
|
68
|
+
throw coreDataRootBusyError();
|
|
83
69
|
} catch (error) {
|
|
84
|
-
if (error
|
|
85
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
if (!
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
*
|
|
519
|
-
*
|
|
522
|
+
* 同步完整缓存可补回尚未落盘的完成事实;否则提交中断仍收口为受理未知。
|
|
523
|
+
* 已有 remoteTaskId 的任务保持可观察。恢复不重新提交,也不自动交付缓存。
|
|
520
524
|
*/
|
|
521
525
|
export function recoverCoreTask(dataRoot, taskId) {
|
|
522
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/dsh-core-adapter.js
CHANGED
|
@@ -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
|
-
*
|
|
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 (_) {
|
|
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
|
-
|
|
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 事件读取器;图片桥接仅由视觉适配器注入。 */
|
package/lib/generation-input.js
CHANGED
|
@@ -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 } : {})
|
package/lib/host-runtime.js
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
|
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
|
|
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 共用执行器 + 降级链 ---------------- */
|