@mokuyoaxis/dsh-iris 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -49
  2. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
  3. package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
  4. package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
  5. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
  6. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
  7. package/CHANGELOG.md +57 -2
  8. package/README.en.md +43 -7
  9. package/README.md +43 -7
  10. package/bin/dsh-iris.js +23 -0
  11. package/docs/ARCHITECTURE.md +95 -0
  12. package/docs/ARTIFACT_LIBRARY.md +24 -0
  13. package/docs/FAULT_INJECTION.md +26 -0
  14. package/docs/HOST_ADAPTER_CONTRACT.md +86 -0
  15. package/docs/HOST_DOCTOR.md +47 -0
  16. package/docs/PROVIDER_ADAPTER_CONTRACT.md +97 -0
  17. package/docs/PROVIDER_HEALTH.md +39 -0
  18. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
  19. package/docs/ROADMAP.md +21 -11
  20. package/docs/SECURITY.md +57 -0
  21. package/docs/TASK_SEMANTICS.md +148 -0
  22. package/docs/assets/diagrams/iris-architecture.png +0 -0
  23. package/docs/assets/diagrams/iris-architecture.svg +5 -5
  24. package/docs/releases/0.1.4.md +59 -0
  25. package/lib/actions.js +860 -290
  26. package/lib/adapters.js +7 -7
  27. package/lib/api.js +110 -18
  28. package/lib/artifacts.js +239 -0
  29. package/lib/bundled-skills.js +5 -5
  30. package/lib/client.js +478 -68
  31. package/lib/config.js +192 -9
  32. package/lib/doctor.js +414 -0
  33. package/lib/dsh-host-adapter.js +189 -0
  34. package/lib/host-contract.js +131 -0
  35. package/lib/host-runtime.js +156 -0
  36. package/lib/index.js +142 -157
  37. package/lib/locate.js +1 -1
  38. package/lib/media.js +16 -6
  39. package/lib/prompt-optimizer.js +10 -20
  40. package/lib/provider-adapter.js +371 -0
  41. package/lib/provider-adapters.js +279 -0
  42. package/lib/provider-contract.js +234 -0
  43. package/lib/provider-health.js +59 -0
  44. package/lib/render.js +43 -12
  45. package/lib/task-semantics.js +209 -0
  46. package/lib/tasks.js +500 -32
  47. package/lib/vision.js +23 -49
  48. package/package.json +19 -8
  49. package/user_guide.md +62 -2
  50. package/docs/assets/diagrams/iris-architecture.drawio +0 -1
  51. package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
  52. package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
  53. package/docs/releases/0.1.1.md +0 -51
  54. package/docs/releases/0.1.2.md +0 -26
package/lib/adapters.js CHANGED
@@ -236,11 +236,11 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs
236
236
  const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN']);
237
237
 
238
238
  /** @returns {{done:boolean, ok:boolean, urls:string[], message?:string}} */
239
- export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL }) {
239
+ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal }) {
240
240
  const apiBase = dashscopeApiBase(baseUrl);
241
241
  const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
242
242
  headers: authHeaders(key),
243
- signal: timeoutSignal(timeoutMs)
243
+ signal: timeoutSignal(timeoutMs, signal)
244
244
  });
245
245
  const body = await readJson(res);
246
246
  if (!res.ok) throw providerError(res.status, body, '查询任务失败');
@@ -323,10 +323,10 @@ export async function openAiGenerateImage({ key, baseUrl, model, prompt, size =
323
323
  * 免费只读;返回 [{ id }] 原样(分类由 models.js 规则负责)。
324
324
  * @returns {Promise<string[]>}
325
325
  */
326
- export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT }) {
326
+ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal }) {
327
327
  const base = String(baseUrl || '').trim().replace(/\/+$/, '');
328
328
  if (!base) throw new Error('iris: 供应商缺 baseUrl,无法发现模型');
329
- const res = await fetch(`${base}/models`, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs) });
329
+ const res = await fetch(`${base}/models`, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
330
330
  const body = await readJson(res);
331
331
  if (!res.ok) throw providerError(res.status, body, '模型发现失败');
332
332
  const items = (body && Array.isArray(body.data) && body.data) || (Array.isArray(body && body.models) ? body.models : []);
@@ -476,11 +476,11 @@ export function transcriptionText(payload) {
476
476
  }
477
477
 
478
478
  /** 轮询 ASR 任务;正式结果位于 results[].transcription_url 指向的 JSON。 */
479
- export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL }) {
479
+ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal }) {
480
480
  const apiBase = dashscopeApiBase(baseUrl);
481
481
  const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
482
482
  headers: authHeaders(key),
483
- signal: timeoutSignal(timeoutMs)
483
+ signal: timeoutSignal(timeoutMs, signal)
484
484
  });
485
485
  const body = await readJson(res);
486
486
  if (!res.ok) throw providerError(res.status, body, '查询转写任务失败');
@@ -499,7 +499,7 @@ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeou
499
499
  if (typeof result.transcription_text === 'string') text += result.transcription_text;
500
500
  else if (typeof result.text === 'string') text += result.text;
501
501
  if (status === 'SUCCEEDED' && result.subtask_status !== 'FAILED' && typeof result.transcription_url === 'string') {
502
- const transcriptRes = await fetch(result.transcription_url, { signal: timeoutSignal(timeoutMs) });
502
+ const transcriptRes = await fetch(result.transcription_url, { signal: timeoutSignal(timeoutMs, signal) });
503
503
  if (!transcriptRes.ok) throw new Error(`下载转写结果失败:HTTP ${transcriptRes.status}`);
504
504
  const transcriptBody = await readJson(transcriptRes);
505
505
  if (!transcriptBody) throw new Error('下载转写结果失败:响应不是 JSON');
package/lib/api.js CHANGED
@@ -12,12 +12,17 @@
12
12
  */
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
- import { irisHome, capabilitiesOf, onChange as onConfigChange } from './config.js';
15
+ import crypto from 'node:crypto';
16
+ import { irisHome, capabilitiesOf, onChange as onConfigChange, providerHealthSnapshot } from './config.js';
16
17
  import { mimeOf, mediaUrl } from './media.js';
17
18
  import * as tasks from './tasks.js';
19
+ import * as artifacts from './artifacts.js';
18
20
  import { runAction } from './actions.js';
19
21
  import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
20
22
  import { inferMediaProtocol } from './provider-protocol.js';
23
+ import { deriveUserState } from './task-semantics.js';
24
+ import { hostDoctor } from './doctor.js';
25
+ import { hostRuntimeEvidence, recordHostClient } from './host-runtime.js';
21
26
  import { optimizePrompt } from './prompt-optimizer.js';
22
27
  import {
23
28
  importPromptOptimizerConfig,
@@ -75,6 +80,21 @@ function mediaOf(task) {
75
80
  }));
76
81
  }
77
82
 
83
+ const V2_FACT_FIELDS = ['phase', 'acceptance', 'watchState', 'outcome', 'deliveryState', 'cancelState'];
84
+
85
+ function isTaskV2(t) {
86
+ return Boolean(t && t.schemaVersion === 2 && V2_FACT_FIELDS.every((field) => Object.prototype.hasOwnProperty.call(t, field)));
87
+ }
88
+
89
+ /** 旧记录保持原四态;只有完整 v2 记录才使用事实轴派生用户态。 */
90
+ function taskUserState(t) {
91
+ if (isTaskV2(t)) return deriveUserState(t);
92
+ if (t && t.status === 'succeeded') return 'succeeded';
93
+ if (t && t.status === 'failed') return 'failed';
94
+ if (t && t.status === 'canceled') return 'canceled';
95
+ return 'running';
96
+ }
97
+
78
98
  function taskPublic(t) {
79
99
  const elapsedMs =
80
100
  t.elapsedMs ||
@@ -83,6 +103,9 @@ function taskPublic(t) {
83
103
  id: t.id,
84
104
  cap: t.cap || '',
85
105
  status: t.status || 'running',
106
+ userState: taskUserState(t),
107
+ schemaVersion: Number(t.schemaVersion || 1),
108
+ revision: Number(t.revision || 0),
86
109
  progress: t.progress || '',
87
110
  model: t.model || '',
88
111
  providerName: t.providerName || '',
@@ -94,6 +117,8 @@ function taskPublic(t) {
94
117
  saved: !!t.saved,
95
118
  expired: !!t.expired,
96
119
  mode: t.mode || '',
120
+ retryOf: t.retryOf || '',
121
+ attentionDisposition: tasks.attentionDisposition(t),
97
122
  files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
98
123
  media: mediaOf(t)
99
124
  };
@@ -110,21 +135,35 @@ function loadProviders() {
110
135
  return Array.isArray(cache && cache.providers) ? cache.providers : [];
111
136
  }
112
137
 
113
- /** 构建完整工作台状态(纯 JSON,可序列化) */
138
+ const STATE_EPOCH = crypto.randomUUID();
139
+ let stateRevision = 0;
140
+
141
+ /** 构建完整工作台状态(纯 JSON,可序列化);序号用于客户端拒绝迟到快照。 */
114
142
  export function buildState() {
115
- const tasks = loadTasks();
143
+ const taskRecords = loadTasks();
116
144
  const providers = loadProviders();
117
145
 
118
- const all = [...tasks].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
119
- const running = all.filter((t) => t.status === 'running' && !t.expired);
120
- const terminal = all.filter((t) => t.status !== 'running');
146
+ const all = [...taskRecords].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
147
+ const stateOf = (t) => taskUserState(t);
148
+ const needsAttention = (t) => ['watching_paused', 'needs_attention', 'artifact_unavailable'].includes(stateOf(t));
149
+ const acknowledged = (t) => tasks.attentionDisposition(t).status === 'acknowledged';
150
+ const running = all.filter((t) => ['queued', 'running'].includes(stateOf(t)) && !t.expired);
151
+ const attention = all.filter((t) => needsAttention(t) && !acknowledged(t) && !t.expired);
152
+ const terminal = all.filter((t) => ['succeeded', 'failed', 'canceled'].includes(stateOf(t))
153
+ || (needsAttention(t) && acknowledged(t)) || t.expired);
121
154
  const recent = terminal.slice(0, 100).map(taskPublic);
155
+ const artifactSummary = artifacts.page({ offset: 0, limit: 6 });
122
156
 
123
157
  return {
158
+ stateEpoch: STATE_EPOCH,
159
+ stateRevision: ++stateRevision,
124
160
  providers: providers.map(providerPublic),
161
+ health: providerHealthSnapshot(),
162
+ artifacts: { total: artifactSummary.total, recent: artifactSummary.items },
125
163
  tasks: {
126
164
  recent,
127
165
  recentTotal: terminal.length, // 泡泡「查看全部 (N)」计数用(不受 slice 截断)
166
+ attention: attention.slice(0, 20).map(taskPublic),
128
167
  running: running.slice(0, 10).map(taskPublic)
129
168
  }
130
169
  };
@@ -138,15 +177,44 @@ export function sseClientCount() {
138
177
  }
139
178
 
140
179
  const sseClients = new Set(); // 活跃 res(长连接)
180
+ const sseBlocked = new Set(); // 背压中的连接
181
+ const sseLatest = new WeakMap(); // 每个慢连接只保留最新状态
182
+ const sseDrainBound = new WeakSet();
141
183
  let sseHeartbeat = null; // 心跳定时器(15s,防代理/浏览器空闲断连)
142
184
  let sseThrottleTimer = null; // 推送节流(400ms 合并,防盯守高频 update 刷屏)
143
185
  let ssePending = false;
144
186
  let sseBound = false;
145
187
 
188
+ function bindSseDrain(res) {
189
+ sseBlocked.add(res);
190
+ if (sseDrainBound.has(res)) return;
191
+ sseDrainBound.add(res);
192
+ res.once('drain', () => {
193
+ sseDrainBound.delete(res);
194
+ sseBlocked.delete(res);
195
+ const latest = sseLatest.get(res);
196
+ sseLatest.delete(res);
197
+ if (latest !== undefined) pushSse(res, latest);
198
+ });
199
+ }
200
+
146
201
  function pushSse(res, data) {
147
202
  if (!res || res.writableEnded || res.destroyed) return;
203
+ if (sseBlocked.has(res)) {
204
+ sseLatest.set(res, data);
205
+ return;
206
+ }
207
+ try {
208
+ if (res.write(`data: ${data}\n\n`) === false) bindSseDrain(res);
209
+ } catch (_) {
210
+ /* 连接已死,close 事件会清理 */
211
+ }
212
+ }
213
+
214
+ function pushSseComment(res, comment) {
215
+ if (!res || res.writableEnded || res.destroyed || sseBlocked.has(res)) return;
148
216
  try {
149
- res.write(`data: ${data}\n\n`);
217
+ if (res.write(`: ${comment}\n\n`) === false) bindSseDrain(res);
150
218
  } catch (_) {
151
219
  /* 连接已死,close 事件会清理 */
152
220
  }
@@ -177,7 +245,7 @@ function scheduleBroadcast() {
177
245
  function ensureHeartbeat() {
178
246
  if (sseHeartbeat) return;
179
247
  sseHeartbeat = setInterval(() => {
180
- for (const res of [...sseClients]) pushSse(res, ': ping'); // 注释行保持连接活跃
248
+ for (const res of [...sseClients]) pushSseComment(res, 'ping'); // 注释帧不覆盖背压期间的最新状态
181
249
  if (!sseClients.size) stopHeartbeat();
182
250
  }, 15000);
183
251
  if (sseHeartbeat && sseHeartbeat.unref) sseHeartbeat.unref();
@@ -214,7 +282,7 @@ let sseBusDisposers = [];
214
282
  function bindChangeBus() {
215
283
  if (sseBound) return;
216
284
  sseBound = true;
217
- sseBusDisposers = [tasks.onChange(scheduleBroadcast), onConfigChange(scheduleBroadcast)];
285
+ sseBusDisposers = [tasks.onChange(scheduleBroadcast), artifacts.onChange(scheduleBroadcast), onConfigChange(scheduleBroadcast)];
218
286
  }
219
287
  function unbindChangeBus() {
220
288
  for (const d of sseBusDisposers) {
@@ -239,6 +307,8 @@ function serveSse(res) {
239
307
  pushSse(res, JSON.stringify(buildState())); // 立即推当前状态
240
308
  const onClose = () => {
241
309
  sseClients.delete(res);
310
+ sseBlocked.delete(res);
311
+ sseLatest.delete(res);
242
312
  if (!sseClients.size) {
243
313
  stopHeartbeat();
244
314
  if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
@@ -252,22 +322,26 @@ function serveSse(res) {
252
322
  }
253
323
 
254
324
  /** webServer 前缀路由 handler:状态、任务、动作与提示词优化器 API。 */
255
- export function serveApi(req, res, ctx) {
325
+ export function serveApi(req, res, host) {
256
326
  try {
257
327
  const method = String(req.method || 'GET').toUpperCase();
258
328
  const urlPath = String(req.url || '').split('?')[0];
259
329
 
260
330
  // POST /iris/api/actions/:name —— GUI 直连触发工具执行
261
331
  if (method === 'POST') {
332
+ if (urlPath === '/iris/api/host-client') {
333
+ if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
334
+ return handleJsonPost(req, res, (body) => recordHostClient(body));
335
+ }
262
336
  // 文件选择器 L2:上传本地文件字节 → 存 uploads/ → 返回路径(独立大 body 上限)
263
337
  if (urlPath === '/iris/api/upload') {
264
338
  return handleUpload(req, res);
265
339
  }
266
- const promptMatch = typeof ctx === 'object' && urlPath.match(/^\/iris\/api\/prompt-optimizer\/(optimize|import|reset|enabled)\/?$/);
267
- if (promptMatch) return handlePromptOptimizer(ctx, promptMatch[1], req, res);
268
- const actionMatch = typeof ctx === 'object' && urlPath.match(/^\/iris\/api\/actions\/([A-Za-z0-9_-]+)\/?$/);
340
+ const promptMatch = typeof host === 'object' && urlPath.match(/^\/iris\/api\/prompt-optimizer\/(optimize|import|reset|enabled)\/?$/);
341
+ if (promptMatch) return handlePromptOptimizer(host, promptMatch[1], req, res);
342
+ const actionMatch = typeof host === 'object' && urlPath.match(/^\/iris\/api\/actions\/([A-Za-z0-9_-]+)\/?$/);
269
343
  if (!actionMatch) { return sendJson(res, 405, { error: 'method not allowed' }); }
270
- return handleAction(ctx, actionMatch[1], req, res);
344
+ return handleAction(host, actionMatch[1], req, res);
271
345
  }
272
346
  // GET/HEAD 原逻辑
273
347
  if (method !== 'GET' && method !== 'HEAD') {
@@ -281,6 +355,15 @@ export function serveApi(req, res, ctx) {
281
355
  if (urlPath === '/iris/api/prompt-optimizer/config') {
282
356
  return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
283
357
  }
358
+ if (urlPath === '/iris/api/doctor') {
359
+ if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
360
+ return sendJson(res, 200, method === 'HEAD' ? {} : hostDoctor(host, hostRuntimeEvidence()));
361
+ }
362
+ if (urlPath === '/iris/api/artifacts') {
363
+ const query = new URL(req.url || '', 'http://iris.local').searchParams;
364
+ const page = artifacts.page({ offset: Number(query.get('offset') || 0), limit: Number(query.get('limit') || 24) });
365
+ return sendJson(res, 200, method === 'HEAD' ? {} : page);
366
+ }
284
367
  const taskMatch = urlPath.match(/^\/iris\/api\/task\/([^/]+)$/);
285
368
  if (taskMatch) {
286
369
  return serveTaskDetail(taskMatch[1], method, res);
@@ -349,13 +432,13 @@ function handleJsonPost(req, res, execute) {
349
432
  req.on('error', () => finish(400, { error: 'bad request' }));
350
433
  }
351
434
 
352
- function handleAction(ctx, name, req, res) {
353
- return handleJsonPost(req, res, (args, signal) => runAction(ctx, name, args, { signal }));
435
+ function handleAction(host, name, req, res) {
436
+ return handleJsonPost(req, res, (args, signal) => runAction(host, name, args, { signal }));
354
437
  }
355
438
 
356
- function handlePromptOptimizer(ctx, operation, req, res) {
439
+ function handlePromptOptimizer(host, operation, req, res) {
357
440
  return handleJsonPost(req, res, async (body, signal) => {
358
- if (operation === 'optimize') return optimizePrompt(ctx, body, { signal });
441
+ if (operation === 'optimize') return optimizePrompt(host, body, { signal });
359
442
  if (operation === 'import') {
360
443
  const imported = body && body.config !== undefined ? body.config : body;
361
444
  return { ok: true, ...importPromptOptimizerConfig(imported) };
@@ -493,6 +576,10 @@ function serveTaskDetail(taskId, method, res) {
493
576
  id: t.id,
494
577
  cap: t.cap || '',
495
578
  status: t.status || '',
579
+ userState: taskUserState(t),
580
+ schemaVersion: Number(t.schemaVersion || 1),
581
+ revision: Number(t.revision || 0),
582
+ ...(isTaskV2(t) ? Object.fromEntries(V2_FACT_FIELDS.map((field) => [field, t[field]])) : {}),
496
583
  progress: t.progress || '',
497
584
  model: t.model || '',
498
585
  providerName: t.providerName || '',
@@ -503,6 +590,11 @@ function serveTaskDetail(taskId, method, res) {
503
590
  error: t.error || '',
504
591
  mode: t.mode || '',
505
592
  remoteTaskId: t.remoteTaskId || '',
593
+ retryOf: t.retryOf || '',
594
+ manualRetries: Array.isArray(t.manualRetries) ? t.manualRetries.filter((item) => item && item.taskId).map((item) => ({
595
+ taskId: String(item.taskId), createdAt: item.createdAt || ''
596
+ })) : [],
597
+ attentionDisposition: tasks.attentionDisposition(t),
506
598
  files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
507
599
  media: mediaOf(t),
508
600
  attachments: Array.isArray(t.attachments) ? t.attachments.map((a) => ({
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 作品库 v0。
4
+ *
5
+ * 任务历史与作品生命周期相互独立:清除 tasks.json 不会让 outputs/ 中的作品
6
+ * 从 UI 消失。这里只记录展示、分页和随机令牌授权所需的最小字段,不保留
7
+ * prompt、Provider、Model 或任务关系;完整 Artifact Manifest 留给 0.2.0。
8
+ */
9
+ import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { irisHome } from './config.js';
13
+ import { atomicWritePrivate, chmodPrivateFile, ensurePrivateDir, privateSibling } from './private-storage.js';
14
+
15
+ const FILE = () => path.join(irisHome(), 'artifacts.json');
16
+ const OUTPUTS = () => path.join(irisHome(), 'outputs');
17
+ const MIME = {
18
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
19
+ '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mpeg': 'video/mpeg', '.mpg': 'video/mpeg',
20
+ '.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4',
21
+ '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.flac': 'audio/flac',
22
+ '.avif': 'image/avif', '.bmp': 'image/bmp',
23
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
24
+ '.webp': 'image/webp', '.gif': 'image/gif'
25
+ };
26
+
27
+ let cache = null;
28
+ const changeListeners = new Set();
29
+
30
+ export function onChange(fn) {
31
+ changeListeners.add(fn);
32
+ return () => changeListeners.delete(fn);
33
+ }
34
+
35
+ function emitChange() {
36
+ for (const fn of [...changeListeners]) {
37
+ try { fn(); } catch (_) { /* 一个监听者不能阻断作品落盘 */ }
38
+ }
39
+ }
40
+
41
+ function persist() {
42
+ atomicWritePrivate(FILE(), JSON.stringify({ version: 1, artifacts: cache.artifacts }, null, 2));
43
+ emitChange();
44
+ }
45
+
46
+ function mimeFor(name) {
47
+ return MIME[path.extname(String(name)).toLowerCase()] || '';
48
+ }
49
+
50
+ function safeOutputFile(name) {
51
+ const value = String(name || '');
52
+ return value && path.basename(value) === value && value !== '.' && value !== '..' && mimeFor(value);
53
+ }
54
+
55
+ function outputStat(name) {
56
+ if (!safeOutputFile(name)) return null;
57
+ const abs = path.join(OUTPUTS(), name);
58
+ try {
59
+ const stat = fs.lstatSync(abs);
60
+ return stat.isFile() && !stat.isSymbolicLink() ? { abs, stat } : null;
61
+ } catch (_) {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function newEntry(name, stat) {
67
+ return {
68
+ id: 'a_' + crypto.randomBytes(12).toString('hex'),
69
+ file: name,
70
+ token: crypto.randomBytes(16).toString('hex'),
71
+ mime: mimeFor(name),
72
+ size: stat.size,
73
+ createdAt: new Date(stat.mtimeMs || Date.now()).toISOString()
74
+ };
75
+ }
76
+
77
+ function validateStored(value) {
78
+ if (!value || typeof value !== 'object' || !Array.isArray(value.artifacts)) throw new Error('artifacts 必须是数组');
79
+ const ids = new Set();
80
+ const files = new Set();
81
+ for (const item of value.artifacts) {
82
+ if (!item || typeof item !== 'object' || !String(item.id || '').startsWith('a_')
83
+ || !/^[a-f0-9]{32}$/.test(String(item.token || '')) || !safeOutputFile(item.file)) {
84
+ throw new Error('作品条目格式无效');
85
+ }
86
+ if (ids.has(item.id) || files.has(item.file)) throw new Error('作品条目重复');
87
+ ids.add(item.id);
88
+ files.add(item.file);
89
+ }
90
+ }
91
+
92
+ function adoptOutputs() {
93
+ ensurePrivateDir(OUTPUTS());
94
+ const known = new Set(cache.artifacts.map((item) => item.file));
95
+ let added = 0;
96
+ for (const name of fs.readdirSync(OUTPUTS())) {
97
+ if (known.has(name)) continue;
98
+ const hit = outputStat(name);
99
+ if (!hit) continue;
100
+ cache.artifacts.push(newEntry(name, hit.stat));
101
+ known.add(name);
102
+ added++;
103
+ }
104
+ return added;
105
+ }
106
+
107
+ function load() {
108
+ if (cache) return cache;
109
+ const file = FILE();
110
+ if (!fs.existsSync(file)) {
111
+ cache = { version: 1, artifacts: [] };
112
+ adoptOutputs(); // 升级时一次性接回现有 outputs;不读取任务历史。
113
+ persist();
114
+ return cache;
115
+ }
116
+ try {
117
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
118
+ validateStored(raw);
119
+ cache = { version: 1, artifacts: raw.artifacts.map((item) => ({ ...item })) };
120
+ } catch (error) {
121
+ const backup = privateSibling(file, 'corrupted');
122
+ try {
123
+ fs.renameSync(file, backup);
124
+ chmodPrivateFile(backup);
125
+ } catch (_) { /* 隔离失败仍继续用文件重建最小索引 */ }
126
+ console.error('[iris] artifacts.json 已损坏,已隔离为 ' + backup + ':', error && error.message);
127
+ cache = { version: 1, artifacts: [] };
128
+ adoptOutputs();
129
+ persist();
130
+ }
131
+ return cache;
132
+ }
133
+
134
+ export function resetCache() {
135
+ cache = null;
136
+ }
137
+
138
+ export function all() {
139
+ return [...load().artifacts].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
140
+ }
141
+
142
+ export function artifactUrl(entry) {
143
+ const base = String(process.env.DSH_WEB_BASE || 'http://127.0.0.1:3080').replace(/\/+$/, '');
144
+ return `${base}/iris/media/artifact/${entry.id}/${entry.token}/${encodeURIComponent(entry.file)}`;
145
+ }
146
+
147
+ function publicEntry(item) {
148
+ return {
149
+ id: item.id,
150
+ file: item.file,
151
+ mime: item.mime || mimeFor(item.file),
152
+ size: Number(item.size || 0),
153
+ createdAt: item.createdAt || '',
154
+ url: artifactUrl(item)
155
+ };
156
+ }
157
+
158
+ export function page({ offset = 0, limit = 24 } = {}) {
159
+ const sorted = all();
160
+ const start = Math.max(0, Number.isFinite(Number(offset)) ? Math.trunc(Number(offset)) : 0);
161
+ const size = Math.max(1, Math.min(60, Number.isFinite(Number(limit)) ? Math.trunc(Number(limit)) : 24));
162
+ return { total: sorted.length, offset: start, limit: size, items: sorted.slice(start, start + size).map(publicEntry) };
163
+ }
164
+
165
+ export function register(absPath) {
166
+ const resolved = path.resolve(String(absPath || ''));
167
+ if (path.dirname(resolved) !== path.resolve(OUTPUTS())) throw new Error('作品必须位于 Iris outputs/');
168
+ const name = path.basename(resolved);
169
+ const hit = outputStat(name);
170
+ if (!hit) throw new Error('作品文件不存在、类型不受支持或不是普通文件');
171
+ const data = load();
172
+ const existing = data.artifacts.find((item) => item.file === name);
173
+ if (existing) {
174
+ if (existing.size !== hit.stat.size || existing.mime !== mimeFor(name)) {
175
+ existing.size = hit.stat.size;
176
+ existing.mime = mimeFor(name);
177
+ persist();
178
+ }
179
+ return publicEntry(existing);
180
+ }
181
+ const entry = newEntry(name, hit.stat);
182
+ data.artifacts.push(entry);
183
+ persist();
184
+ return publicEntry(entry);
185
+ }
186
+
187
+ export function reindex() {
188
+ const data = load();
189
+ const before = data.artifacts.length;
190
+ data.artifacts = data.artifacts.filter((item) => outputStat(item.file));
191
+ const missing = before - data.artifacts.length;
192
+ const added = adoptOutputs();
193
+ if (missing || added) persist();
194
+ return { added, missing, total: data.artifacts.length };
195
+ }
196
+
197
+ export function authorize(id, token, name) {
198
+ const item = load().artifacts.find((entry) => entry.id === id && entry.token === token && entry.file === name);
199
+ if (!item) return null;
200
+ const hit = outputStat(item.file);
201
+ if (!hit) return null;
202
+ return { entry: item, abs: hit.abs, size: hit.stat.size };
203
+ }
204
+
205
+ export function remove(id) {
206
+ const data = load();
207
+ const index = data.artifacts.findIndex((item) => item.id === id);
208
+ if (index < 0) return { ok: false, reason: '作品不存在' };
209
+ const item = data.artifacts[index];
210
+ const hit = outputStat(item.file);
211
+ if (hit) fs.rmSync(hit.abs);
212
+ data.artifacts.splice(index, 1);
213
+ persist();
214
+ return { ok: true, file: item.file };
215
+ }
216
+
217
+ export function clear() {
218
+ const data = load();
219
+ const kept = [];
220
+ let deleted = 0;
221
+ let bytes = 0;
222
+ for (const item of data.artifacts) {
223
+ const hit = outputStat(item.file);
224
+ try {
225
+ if (hit) {
226
+ fs.rmSync(hit.abs);
227
+ bytes += hit.stat.size;
228
+ }
229
+ deleted++;
230
+ } catch (_) {
231
+ kept.push(item);
232
+ }
233
+ }
234
+ if (deleted) {
235
+ data.artifacts = kept;
236
+ persist();
237
+ }
238
+ return { deleted, failed: kept.length, bytes };
239
+ }
@@ -3,6 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { requireHostPort } from './host-contract.js';
6
7
 
7
8
  const SKILL_NAMES = ['iris-verify-ui', 'iris-compose-media'];
8
9
  const SKILL_ROOT = fileURLToPath(new URL('../.dsh/skills/', import.meta.url));
@@ -44,6 +45,7 @@ export function loadBundledSkills() {
44
45
  content: source.slice(frontmatter[0].length).replace(/^\r?\n/, ''),
45
46
  source: 'bundled',
46
47
  provider: 'iris-bundled',
48
+ invocation: Object.freeze({ modelInvocable: true, userInvocable: true }),
47
49
  path: file,
48
50
  resourceBase: Object.freeze({ kind: 'directory', path: directory })
49
51
  });
@@ -51,11 +53,9 @@ export function loadBundledSkills() {
51
53
  }
52
54
 
53
55
  /** 注册为宿主级 runtime Skill;DSH 项目目录中的同名 Skill 仍按原生优先级胜出。 */
54
- export function registerBundledSkills(ctx) {
55
- const registry = ctx && (ctx.skills || (typeof ctx.get === 'function' && ctx.get('skills')));
56
- if (!registry || typeof registry.register !== 'function') {
57
- throw new Error('DSH Skill registry 不可用');
58
- }
56
+ export function registerBundledSkills(host) {
57
+ const registry = requireHostPort(host, 'skills', '注册随包 Skill');
58
+ if (typeof registry.register !== 'function') throw new Error('Host skills 端口缺少 register');
59
59
  const skills = loadBundledSkills();
60
60
  for (const skill of skills) registry.register(skill);
61
61
  return skills.map((skill) => skill.name);