@mokuyoaxis/dsh-iris 0.2.1 → 0.2.2

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 (42) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.en.md +25 -25
  3. package/README.md +25 -25
  4. package/docs/AGENT_QUICKSTART.md +7 -7
  5. package/docs/ARCHITECTURE.md +10 -2
  6. package/docs/ARTIFACT_LIBRARY.md +1 -1
  7. package/docs/CLI_MANAGEMENT.md +5 -3
  8. package/docs/COMPOSITE_VISION.md +1 -1
  9. package/docs/CORE_RUNTIME_CONTRACT.md +9 -1
  10. package/docs/DSH_CORE_MIGRATION.md +1 -1
  11. package/docs/FAULT_INJECTION.md +10 -1
  12. package/docs/HEADLESS_CLI.md +4 -2
  13. package/docs/HOST_ADAPTER_CONTRACT.md +1 -1
  14. package/docs/MODEL_PORT_CONTRACT.md +4 -2
  15. package/docs/OCR_MODEL.md +1 -1
  16. package/docs/PROMPT_OPTIMIZER.md +33 -3
  17. package/docs/PROVIDER_ADAPTER_CONTRACT.md +10 -2
  18. package/docs/PROVIDER_HEALTH.md +1 -1
  19. package/docs/ROADMAP.md +10 -2
  20. package/docs/SECURITY.md +1 -1
  21. package/docs/TASK_SEMANTICS.md +1 -1
  22. package/docs/VISION_CLI.md +1 -1
  23. package/docs/VISION_MODEL.md +1 -1
  24. package/docs/WORKBENCH_ARTIFACTS.md +1 -1
  25. package/docs/releases/0.2.2.md +31 -0
  26. package/lib/actions.js +12 -16
  27. package/lib/adapters.js +88 -24
  28. package/lib/capability.js +23 -16
  29. package/lib/client.js +268 -22
  30. package/lib/config.js +13 -4
  31. package/lib/core-lease-recovery.js +64 -92
  32. package/lib/core-runtime.js +45 -47
  33. package/lib/core-writer-lease.js +134 -0
  34. package/lib/models.js +19 -37
  35. package/lib/prompt-optimizer-config.js +19 -9
  36. package/lib/prompt-optimizer-core.js +133 -7
  37. package/lib/provider-adapters.js +2 -1
  38. package/lib/provider-config-service.js +3 -4
  39. package/lib/provider-protocol.js +61 -0
  40. package/lib/provider-task-runner.js +6 -4
  41. package/package.json +1 -1
  42. package/user_guide.md +30 -16
@@ -3,11 +3,11 @@
3
3
  import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { CORE_CONTRACT_VERSION, CoreContractError, normalizeCoreOptions } from './core-contract.js';
7
- import { writePrivateFile } from './private-storage.js';
6
+ import { CoreContractError, normalizeCoreOptions } from './core-contract.js';
7
+ import { newWriterOwner, publishLeaseRecord, readWriterLease, recoveryClaimFile, removeWriterIntent,
8
+ retireLeaseDirectory, withdrawLeaseRecord,
9
+ writerOwnerStatus } from './core-writer-lease.js';
8
10
 
9
- const WRITER_LEASE_DIR = '.iris-runtime-writer-v0';
10
- const WRITER_OWNER_FILE = 'owner.json';
11
11
  const RECOVERY_AUDIT_DIR = '.iris-runtime-recovery-audit-v0';
12
12
 
13
13
  function fail(code, message) {
@@ -27,34 +27,6 @@ function physicalDataRoot(dataRoot) {
27
27
  }
28
28
  }
29
29
 
30
- function readLease(root) {
31
- const directory = path.join(root, WRITER_LEASE_DIR);
32
- const stat = fs.lstatSync(directory);
33
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid lease directory');
34
- const entries = fs.readdirSync(directory);
35
- if (entries.length !== 1 || entries[0] !== WRITER_OWNER_FILE) throw new Error('invalid lease contents');
36
- const ownerFile = path.join(directory, WRITER_OWNER_FILE);
37
- const ownerStat = fs.lstatSync(ownerFile);
38
- if (!ownerStat.isFile() || ownerStat.isSymbolicLink() || ownerStat.size > 16384) throw new Error('invalid owner');
39
- const serialized = fs.readFileSync(ownerFile, 'utf8');
40
- const owner = JSON.parse(serialized);
41
- const created = Date.parse(owner?.createdAt);
42
- if (owner?.contractVersion !== CORE_CONTRACT_VERSION || !Number.isSafeInteger(owner.pid) || owner.pid <= 0
43
- || !/^[a-f0-9-]{36}$/.test(String(owner.instanceId || '')) || !Number.isFinite(created)) {
44
- throw new Error('incomplete owner');
45
- }
46
- return { directory, ownerFile, owner, serialized, created };
47
- }
48
-
49
- function ownerStatus(pid) {
50
- try {
51
- process.kill(pid, 0);
52
- return 'alive';
53
- } catch (error) {
54
- return error?.code === 'ESRCH' ? 'missing' : 'unknown';
55
- }
56
- }
57
-
58
30
  function auditDirectory(root) {
59
31
  const directory = path.join(root, RECOVERY_AUDIT_DIR);
60
32
  try {
@@ -68,15 +40,12 @@ function auditDirectory(root) {
68
40
  return directory;
69
41
  }
70
42
 
71
- function releaseRecoveryLease(lease) {
72
- const stat = fs.lstatSync(lease.directory);
73
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid recovery lease');
74
- const entries = fs.readdirSync(lease.directory);
75
- if (entries.length !== 1 || entries[0] !== WRITER_OWNER_FILE) throw new Error('unexpected recovery lease contents');
76
- const actual = JSON.parse(fs.readFileSync(lease.ownerFile, 'utf8'));
77
- if (actual.instanceId !== lease.owner.instanceId) throw new Error('recovery owner changed');
78
- fs.unlinkSync(lease.ownerFile);
79
- fs.rmdirSync(lease.directory);
43
+ function requireMissingOwners(lease) {
44
+ for (const owner of lease.owners) {
45
+ const status = writerOwnerStatus(owner.pid);
46
+ if (status === 'alive') fail('IRIS_CORE_LEASE_OWNER_ALIVE', '租约 owner PID 仍存活;拒绝恢复');
47
+ if (status !== 'missing') fail('IRIS_CORE_LEASE_OWNER_UNVERIFIED', '无法确认租约 owner PID 已退出;拒绝恢复');
48
+ }
80
49
  }
81
50
 
82
51
  /**
@@ -85,41 +54,45 @@ function releaseRecoveryLease(lease) {
85
54
  */
86
55
  export function recoverCoreWriterLease(dataRoot, { confirmStalePid } = {}) {
87
56
  const root = physicalDataRoot(dataRoot);
88
- const leaseDirectory = path.join(root, WRITER_LEASE_DIR);
89
- try { fs.lstatSync(leaseDirectory); }
90
- catch (error) {
91
- if (error?.code === 'ENOENT') fail('IRIS_CORE_LEASE_NOT_FOUND', '未发现可恢复的 Iris 写者租约');
92
- fail('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约证据无法读取;已保留现场');
93
- }
94
57
  let lease;
95
- try { lease = readLease(root); }
58
+ try { lease = readWriterLease(root); }
96
59
  catch (_) { fail('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约证据不完整;已保留现场'); }
60
+ if (!lease) fail('IRIS_CORE_LEASE_NOT_FOUND', '未发现可恢复的 Iris 写者租约');
97
61
  if (!Number.isSafeInteger(confirmStalePid) || confirmStalePid <= 0 || confirmStalePid !== lease.owner.pid) {
98
62
  fail('IRIS_CORE_LEASE_CONFIRMATION_MISMATCH', '确认的 owner PID 与租约证据不一致;未执行恢复');
99
63
  }
100
- const processStatus = ownerStatus(lease.owner.pid);
101
- if (processStatus === 'alive') fail('IRIS_CORE_LEASE_OWNER_ALIVE', '租约 owner PID 仍存活;拒绝恢复');
102
- if (processStatus !== 'missing') fail('IRIS_CORE_LEASE_OWNER_UNVERIFIED', '无法确认租约 owner PID 已退出;拒绝恢复');
64
+ requireMissingOwners(lease);
103
65
 
104
66
  const auditId = 'lease_recovery_' + crypto.randomBytes(12).toString('hex');
105
- const instanceId = crypto.randomUUID();
106
67
  const recoveredAt = new Date().toISOString();
107
- const recoveryOwner = Object.freeze({
108
- contractVersion: CORE_CONTRACT_VERSION,
109
- instanceId,
110
- pid: process.pid,
111
- createdAt: recoveredAt
112
- });
113
- const recoverySerialized = JSON.stringify(recoveryOwner, null, 2) + '\n';
114
- const staleOwnerFile = path.join(lease.directory, `.owner.stale-${instanceId}.json`);
115
- let tookLease = false;
68
+ const recoveryOwner = newWriterOwner();
69
+ const claimFile = recoveryClaimFile(root, lease.owner);
70
+ const claim = { schemaVersion: 1, previousInstanceId: lease.owner.instanceId, owner: recoveryOwner };
71
+ let published = false;
72
+ let retired = false;
116
73
  let auditFile = null;
117
74
  try {
118
- // 租约目录始终存在;普通 writer 在接管窗口内仍会 fail-fast。
119
- fs.renameSync(lease.ownerFile, staleOwnerFile);
120
- if (fs.readFileSync(staleOwnerFile, 'utf8') !== lease.serialized) throw new Error('lease changed');
121
- writePrivateFile(lease.ownerFile, recoverySerialized, { flag: 'wx' });
122
- tookLease = true;
75
+ if (readWriterLease(root)?.fingerprint !== lease.fingerprint) throw new Error('lease changed');
76
+ if (!lease.intent) {
77
+ // 兼容旧 owner 目录;固定意图屏障使目录退役后的中断也可诊断和续办。
78
+ fs.linkSync(path.join(lease.directory, 'owner.json'), lease.intentFile);
79
+ lease = readWriterLease(root);
80
+ requireMissingOwners(lease);
81
+ }
82
+ if (!lease.stat) {
83
+ // 已原子发布意图,但在 mkdir 前被杀;建立占位后仍只允许显式恢复。
84
+ fs.mkdirSync(lease.directory, { mode: 0o700 });
85
+ lease = readWriterLease(root);
86
+ }
87
+ // 每一步以父 owner UUID 命名且独占发布;并发恢复只有一个能取得下一步。
88
+ // 前驱不删除,中断后可确认最近恢复 PID 并继续,证据不会消失。
89
+ publishLeaseRecord(root, claimFile, claim);
90
+ published = true;
91
+ const claimed = readWriterLease(root);
92
+ if (claimed?.owner.instanceId !== recoveryOwner.instanceId
93
+ || claimed.stat?.dev !== lease.stat.dev || claimed.stat?.ino !== lease.stat.ino
94
+ || claimed.intent?.serialized !== lease.intent.serialized) throw new Error('lease changed');
95
+ requireMissingOwners(lease);
123
96
 
124
97
  const directory = auditDirectory(root);
125
98
  auditFile = path.join(directory, auditId + '.json');
@@ -129,38 +102,37 @@ export function recoverCoreWriterLease(dataRoot, { confirmStalePid } = {}) {
129
102
  action: 'writer-lease-recovery',
130
103
  recoveredAt,
131
104
  confirmation: { ownerPid: confirmStalePid },
132
- staleOwner: {
133
- contractVersion: lease.owner.contractVersion,
134
- instanceId: lease.owner.instanceId,
135
- pid: lease.owner.pid,
136
- createdAt: new Date(lease.created).toISOString()
137
- },
138
- recoveryOwner
105
+ staleOwner: lease.originalOwner,
106
+ recoveryOwner,
107
+ leasePhase: lease.phase,
108
+ evidenceDirectory: auditId + '.lease',
109
+ evidenceFingerprint: claimed.fingerprint,
110
+ recoveryClaims: claimed.claims.map(claim => claim.value),
111
+ authorization: 'retire-lease-directory'
139
112
  };
140
- writePrivateFile(auditFile, JSON.stringify(audit, null, 2) + '\n', { flag: 'wx' });
141
- fs.unlinkSync(staleOwnerFile);
142
- releaseRecoveryLease({
143
- directory: lease.directory,
144
- ownerFile: lease.ownerFile,
145
- owner: recoveryOwner
146
- });
113
+ publishLeaseRecord(root, auditFile, audit);
114
+ if (readWriterLease(root)?.fingerprint !== claimed.fingerprint) throw new Error('lease changed');
115
+ // 审计先发布,完整目录再原子退役。只在最终步骤撤下意图屏障。
116
+ retireLeaseDirectory(lease.directory, path.join(directory, audit.evidenceDirectory));
117
+ retired = true;
118
+ removeWriterIntent(lease);
119
+ // 意图屏障解除后只整理 UUID 专属旧证据,不再触碰当前租约路径。
120
+ for (const claim of claimed.claims) {
121
+ try { fs.renameSync(claim.file, path.join(directory, audit.evidenceDirectory, path.basename(claim.file))); }
122
+ catch (_) { /* 未整理的记录仍在根中,不阻止新 UUID 的 writer */ }
123
+ }
147
124
  return Object.freeze({
148
125
  status: 'recovered',
149
126
  auditId,
150
- staleOwner: Object.freeze({ pid: lease.owner.pid, createdAt: new Date(lease.created).toISOString() })
127
+ staleOwner: Object.freeze({ pid: lease.originalOwner.pid,
128
+ createdAt: new Date(Date.parse(lease.originalOwner.createdAt)).toISOString() })
151
129
  });
152
130
  } catch (error) {
153
- // 恢复失败时尽力还原原 owner;绝不把不确定现场宣布为可写。
154
- try {
155
- if (tookLease && fs.existsSync(lease.ownerFile)) {
156
- const actual = JSON.parse(fs.readFileSync(lease.ownerFile, 'utf8'));
157
- if (actual.instanceId === instanceId) fs.unlinkSync(lease.ownerFile);
158
- }
159
- if (fs.existsSync(lease.directory) && !fs.existsSync(lease.ownerFile)) {
160
- if (fs.existsSync(staleOwnerFile)) fs.renameSync(staleOwnerFile, lease.ownerFile);
161
- else writePrivateFile(lease.ownerFile, lease.serialized, { flag: 'wx' });
162
- }
163
- } catch (_) { /* 保留现场,由 Doctor 报告 invalid */ }
131
+ // 可捕获的错误只撤回本次最后一条 claim;原 owner 与旧恢复证据保持。
132
+ if (published && !retired) {
133
+ try { withdrawLeaseRecord(claimFile, claim); }
134
+ catch (_) { /* 保留可识别的恢复链供进程退出后显式重试 */ }
135
+ }
164
136
  if (error instanceof CoreContractError) throw error;
165
137
  fail('IRIS_CORE_LEASE_RECOVERY_FAILED',
166
138
  auditFile && fs.existsSync(auditFile)
@@ -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) {
@@ -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/models.js CHANGED
@@ -3,16 +3,15 @@
3
3
  * Iris 模型发现规则(阶段 6)。
4
4
  *
5
5
  * 目标:把「账号(key)」与「能力」解耦——一个 key 提供模型池,能力从池里选模型。
6
- * 本模块是纯静态规则(零 API 调用):模型名 → 能力标签。
6
+ * 名称规则仅为建议;实际模型能力来自明确标签或可信发现元数据。
7
7
  *
8
8
  * 池的来源:
9
9
  * ① provider 显式 `models` 数组(未来配置,可带 capabilities 覆盖)
10
10
  * ② 旧模型字段迁移:imageModel→image-gen、videoModel→video-gen、ttsModel→tts、
11
11
  * transcribeModel→transcribe、visionModel→vision
12
- * 旧裸账号的已知目录仅由配置写入边界物化,不参与运行时回退。
12
+ * 裸账号不物化厂商默认模型,也不参与运行时回退。
13
13
  */
14
- import { CAPABILITIES } from './capability.js';
15
- import { isDashScopeBaseUrl, selectMediaProtocol } from './provider-protocol.js';
14
+ import { CAPABILITIES, configuredModelCapabilities } from './capability.js';
16
15
  import { modelRateLimit } from './provider-health.js';
17
16
 
18
17
  /** 能力 → provider 上的模型字段(保持向后兼容的返回形状) */
@@ -64,29 +63,7 @@ const MODEL_CAP_RULES = [
64
63
  [/^gemini/i, [CAPABILITIES.VISION]] // Gemini 默认按视觉能力纳入(可被显式配置覆盖)
65
64
  ];
66
65
 
67
- /** 仅供配置写入时接回旧裸账号,不参与运行时选型。 */
68
- const KNOWN_MODELS_BY_PROTOCOL = { dashscope: [
69
- { id: 'wan2.2-t2i-flash', caps: [CAPABILITIES.IMAGE] },
70
- { id: 'wan2.2-t2v-flash', caps: [CAPABILITIES.VIDEO] },
71
- { id: 'wan2.2-s2v-flash', caps: [CAPABILITIES.VIDEO] },
72
- { id: 'qwen-vl-plus', caps: [CAPABILITIES.VISION] },
73
- // VERIFY 2026-09-03 实证:locate grounding 零偏差(qwen-vl-plus 仅 25x25 框选)
74
- { id: 'qwen3-vl-235b-a22b-thinking', caps: [CAPABILITIES.VISION] },
75
- { id: 'qwen-tts-latest', caps: [CAPABILITIES.TTS] },
76
- { id: 'qwen-audio-3.0-asr-flash-filetrans', caps: [CAPABILITIES.TRANSCRIBE] }
77
- ] };
78
-
79
- export function migrateProviderModels(provider) {
80
- if (Array.isArray(provider.models) || Object.values(CAP_FIELD).some((field) => provider[field])) return false;
81
- const protocol = selectMediaProtocol(provider).mediaProtocol;
82
- if (!isDashScopeBaseUrl(provider.baseUrl) || !Object.hasOwn(KNOWN_MODELS_BY_PROTOCOL, protocol)) return false;
83
- provider.models = KNOWN_MODELS_BY_PROTOCOL[protocol].map((m) => ({
84
- id: m.id, capabilities: [...m.caps], source: 'migration'
85
- }));
86
- return true;
87
- }
88
-
89
- /** 按模型名规则推断能力 */
66
+ /** 模型名建议,仅用于提示;不能直接授权选型或落盘能力。 */
90
67
  export function capabilitiesOfModel(modelName) {
91
68
  const name = String(modelName || '');
92
69
  for (const [re, caps] of MODEL_CAP_RULES) {
@@ -103,14 +80,18 @@ const DISCOVERY_CAPABILITIES = Object.freeze({
103
80
  TRANSCRIBE: CAPABILITIES.TRANSCRIBE, VU: CAPABILITIES.VISION, VISION: CAPABILITIES.VISION
104
81
  });
105
82
 
106
- /** 发现元数据优先,名称规则兜底;用户保存后的显式 capabilities 仍是最终覆盖。 */
83
+ /** 只映射明确发现元数据;名字、描述和未知代码不证明能力。 */
107
84
  export function capabilitiesOfDiscoveredModel(model) {
108
- const id = String(typeof model === 'string' ? model : model?.id || '').trim();
109
- const declared = Array.isArray(model?.capabilities)
85
+ return Array.isArray(model?.capabilities)
110
86
  ? [...new Set(model.capabilities.map((value) => DISCOVERY_CAPABILITIES[String(value).toUpperCase()]).filter(Boolean))]
111
87
  : [];
112
- if (declared.length) return declared;
113
- return capabilitiesOfModel(id);
88
+ }
89
+
90
+ /** 已存的明确配置优先于再发现;未标注名称仍可接收可信元数据。 */
91
+ export function hasConfiguredModelCapabilities(provider, modelId) {
92
+ const entry = provider.models?.find((model) => String(typeof model === 'string' ? model : model?.id || '').trim() === modelId);
93
+ if (Array.isArray(entry?.capabilities)) return entry.source !== 'discovered';
94
+ return configuredModelCapabilities(provider, { id: modelId }).length > 0;
114
95
  }
115
96
 
116
97
  /** 一个 provider 的模型池条目列表:[{ id, providerId, capabilities:[], verified?, source? }] */
@@ -125,13 +106,13 @@ export function providerModels(p) {
125
106
  };
126
107
  // ① 显式 models 数组(带可选 capabilities 覆盖 + verified/source 透传)
127
108
  // 该字段一旦存在(含空数组)即为权威声明:用户可明确标记「此供应商没有可用模型」,
128
- // 不得再落入 ② 旧字段迁移或 ③ 裸账号已知池。空数组曾因 `.length` 判定被穿透,
109
+ // 不得再落入 ② 旧字段绑定。空数组曾因 `.length` 判定被穿透,
129
110
  // 导致用户声明无模型却仍被注入 7 个厂商默认模型(见审计 B-5)。
130
111
  if (Array.isArray(p.models)) {
131
112
  for (const m of p.models) {
132
113
  const id = typeof m === 'string' ? m : m && m.id;
133
- // 元素级空能力数组同样是权威覆盖:用户可明确标记“此模型无能力”。
134
- const caps = Array.isArray(m && m.capabilities) ? m.capabilities : capabilitiesOfModel(id);
114
+ // 没有明确能力的名称保留为未标注模型,不进入候选链。
115
+ const caps = configuredModelCapabilities(p, m);
135
116
  const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source,
136
117
  ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
137
118
  ...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}) } : {};
@@ -140,8 +121,9 @@ export function providerModels(p) {
140
121
  return out;
141
122
  }
142
123
  // ② 旧模型字段迁移
143
- for (const [cap, field] of Object.entries(CAP_FIELD)) {
144
- if (p[field]) push(p[field], [cap]);
124
+ for (const field of Object.values(CAP_FIELD)) {
125
+ const caps = configuredModelCapabilities(p, p[field]);
126
+ if (caps.length) push(p[field], caps);
145
127
  }
146
128
  return out;
147
129
  }
@@ -17,16 +17,26 @@ const DEFAULT_VALUE = {
17
17
  version: 1,
18
18
  enabled: true,
19
19
  systemPrompt: [
20
- '你是 Iris 提示词编辑器。把用户草稿改写成一段可直接交给目标 AI 的高质量提示词。',
21
- '必须保留原始意图、事实、专有名词、引号内原文、硬性约束和否定条件;不得臆造用户没有提供的事实。',
22
- '只补充能提高可执行性、结构清晰度、风格一致性和验收明确度的具体信息。信息不足时保持克制,不替用户作关键决定。',
23
- '默认沿用草稿的语言。只输出优化后的提示词正文,不解释、不加标题、不使用 Markdown 代码块。'
24
- ].join('\n'),
20
+ '# Iris 提示词编辑器',
21
+ '## 职责',
22
+ '按用户选择的功能,把草稿整理为可直接交给目标 AI 的提示词。只编辑文字,不执行工具或媒体生成。',
23
+ '## 保留用户意图',
24
+ '保留原始目标、事实、专有名词、引号内原文、硬性约束和否定条件。尤其保留主体身份与数量、左右方位、颜色、数值与单位、构图、时序和明确指定的风格,不擅自替换或扩展。',
25
+ '逐项保留用户已写出的具体描述,不把这些细节当成冗余删去;明确否定条件不得用更宽泛的词概括、替换或扩大。没有依据时,不增加可被观察到的新属性、位置、声音、动作或光照。',
26
+ '用户明示的事实和硬性约束优先于通用模板建议;只有用户本次改写规则明确授权的具体内容可以改变,其余细节与约束继续保留。信息不足或存在冲突时保持未指定或待确认,不替用户作关键决定。',
27
+ '素材引用只是引用。没有看到图片、听到音频或读到文件时,不声称已分析其内容;不编造 Artifact ID、附件 ID、路径、模型、工具参数或执行结果。',
28
+ '## 组织方式',
29
+ '参照 Iris Skills 的任务组织:明确目标与必要输入,写清具体操作、必须保持和允许改变的部分,以及用户要求的输出和验收条件。只使用与本次功能有关的维度,不堆砌质量词或无依据的细节。',
30
+ '默认沿用草稿语言;只允许分段、标点和明确等义的措辞。缺失维度无需补齐;不把几个改为三五位,不增加低声聊天、背景模糊或清晰、书本位置等内容,不把人物左右推为画面左右。已有内容完整时允许不改写。条件、否定、指代与动作顺序不能拆散或擅自重排。',
31
+ '## 输出',
32
+ '遵守本次编辑策略指定的输出协议;普通改写只输出完整提示词正文,不解释、不加额外标题、不使用 Markdown 代码块或工具调用 JSON。补充建议不混入正文,未经用户采纳不视为用户要求。'
33
+ ].join('\n\n'),
25
34
  targets: {
26
- general: '适用于一般对话任务:明确目标、必要背景、约束、期望输出和验收标准;避免无意义地拉长文本。',
27
- image: '适用于图像生成:在原意允许的范围内明确主体、环境、构图、视角、光线、色彩、材质、风格和负面约束。',
28
- video: '适用于视频生成:在原意允许的范围内明确主体动作、场景变化、镜头运动、景别、节奏、时长感、连续性和负面约束。',
29
- s2v: '适用于首尾帧视频:明确首帧到尾帧之间的动作、镜头、空间关系和连续性,避免引入与两端画面冲突的新主体。'
35
+ general: '# 通用任务\n\n## 目标\n把用户任务整理为明确、可执行的指令。\n\n## 组织\n按需要写清目标、必要背景与输入、约束、期望输出和验收标准;不添加用户未要求的工作步骤或背景事实。',
36
+ image: '# 画图 · iris_draw_image\n\n## 目标\n整理已有的画图提示词文字,不执行画图。\n\n## 组织\n按已提供的主体与动作、环境、构图与视角、光影、色彩、材质和风格分组。分类维度不是待补全的要求,未指定内容继续未指定。不添加人物、物体、画风、画幅或新关系。\n\n## 保留\n准确保留数量、约数、年龄、左右方位、服装、颜色、程度、姿态及否定范围;仅用户明确授权改变的项目可以改变。文字里的尺寸要求仍是用户要求,不冒充工具参数已设置。',
37
+ 'image-edit': '# 改图 · iris_edit_image\n\n## 目标\n以已有原图为基础,只修改用户明确指定的部分。\n\n## 组织\n写清需要改变的对象、属性或区域,以及必须保持的主体身份、构图、背景、风格和其余细节。修改范围越小,指令越聚焦。\n\n## 输入与约束\n原图未提供可见内容时,不猜测原图颜色、位置或人物细节;用户提供的来源 ID 保持为引用。不要把改图改写成重新画一张,也不擅自增加重绘、遮罩或多参考图工作流。',
38
+ video: '# 视频 · iris_generate_video\n\n## 目标\n描述用户要生成的文生或图生视频。\n\n## 组织\n围绕主体动作、动作先后、场景变化、镜头运动与景别、节奏和连续性组织可拍摄的过程。保留用户给出的时长、构图与负面约束,不擅自增加分镜、声音或时长参数。\n\n## 输入与约束\n已有首帧或参考图时,只依据用户明确提供的内容保持主体与空间关系;不虚构已经查看素材。不要用静态画质词替代动作和时间描述。',
39
+ s2v: '# 数字人 · iris_generate_video(S2V)\n\n## 输入\n此功能依据首帧图像与音频生成视频;S2V 不是首尾帧插值。\n\n## 目标与组织\n在既定人物和已有音频约束下,描述与用户要求一致的表情、口型、姿态、动作和镜头。保持身份、服装、背景及动作连续性,不擅自改写音频台词、添加声音或替换人物。\n\n## 约束\n音频内容或图像不可见时,不猜测台词、时长、情绪或外观细节;已有素材无需再次生成。'
30
40
  },
31
41
  route: {
32
42
  mode: 'session'