@mokuyoaxis/dsh-iris 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.en.md +259 -0
  3. package/README.md +70 -7
  4. package/bin/dsh-iris.js +23 -0
  5. package/cordis.patch.yml +1 -1
  6. package/docs/ARCHITECTURE.md +81 -0
  7. package/docs/FAULT_INJECTION.md +26 -0
  8. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
  9. package/docs/ROADMAP.md +25 -8
  10. package/docs/SECURITY.md +53 -0
  11. package/docs/TASK_SEMANTICS.md +147 -0
  12. package/docs/assets/diagrams/iris-architecture.drawio +1 -0
  13. package/docs/assets/diagrams/iris-architecture.png +0 -0
  14. package/docs/assets/diagrams/iris-architecture.svg +63 -0
  15. package/docs/assets/diagrams/iris-task-lifecycle.drawio +1 -0
  16. package/docs/assets/diagrams/iris-task-lifecycle.png +0 -0
  17. package/docs/assets/diagrams/iris-task-lifecycle.svg +64 -0
  18. package/docs/assets/diagrams/iris-workflow-compose.drawio +1 -0
  19. package/docs/assets/diagrams/iris-workflow-compose.png +0 -0
  20. package/docs/assets/diagrams/iris-workflow-compose.svg +41 -0
  21. package/docs/assets/examples/iris-flower-generated.webp +0 -0
  22. package/docs/assets/screenshots/image-task-succeeded.webp +0 -0
  23. package/docs/assets/screenshots/prompt-general-after.webp +0 -0
  24. package/docs/assets/screenshots/prompt-general-before.webp +0 -0
  25. package/docs/assets/screenshots/prompt-general-preview.webp +0 -0
  26. package/docs/assets/screenshots/prompt-image-before.webp +0 -0
  27. package/docs/assets/screenshots/prompt-image-preview.webp +0 -0
  28. package/docs/releases/0.1.2.md +26 -0
  29. package/docs/releases/0.1.3.md +30 -0
  30. package/docs/screenshots.md +34 -0
  31. package/lib/actions.js +609 -175
  32. package/lib/api.js +122 -18
  33. package/lib/bundled-skills.js +62 -0
  34. package/lib/client.js +460 -22
  35. package/lib/doctor.js +243 -0
  36. package/lib/index.js +57 -46
  37. package/lib/prompt-optimizer-config.js +170 -0
  38. package/lib/prompt-optimizer.js +244 -0
  39. package/lib/provider-contract.js +234 -0
  40. package/lib/task-semantics.js +209 -0
  41. package/lib/tasks.js +473 -29
  42. package/package.json +13 -4
  43. package/screenshots.json +6 -0
  44. package/user_guide.md +84 -3
package/lib/api.js CHANGED
@@ -12,12 +12,21 @@
12
12
  */
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
+ import crypto from 'node:crypto';
15
16
  import { irisHome, capabilitiesOf, onChange as onConfigChange } from './config.js';
16
17
  import { mimeOf, mediaUrl } from './media.js';
17
18
  import * as tasks from './tasks.js';
18
19
  import { runAction } from './actions.js';
19
20
  import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
20
21
  import { inferMediaProtocol } from './provider-protocol.js';
22
+ import { deriveUserState } from './task-semantics.js';
23
+ import { optimizePrompt } from './prompt-optimizer.js';
24
+ import {
25
+ importPromptOptimizerConfig,
26
+ loadPromptOptimizerConfig,
27
+ resetPromptOptimizerConfig,
28
+ setPromptOptimizerEnabled
29
+ } from './prompt-optimizer-config.js';
21
30
 
22
31
  function readJsonSafe(file) {
23
32
  try {
@@ -68,6 +77,21 @@ function mediaOf(task) {
68
77
  }));
69
78
  }
70
79
 
80
+ const V2_FACT_FIELDS = ['phase', 'acceptance', 'watchState', 'outcome', 'deliveryState', 'cancelState'];
81
+
82
+ function isTaskV2(t) {
83
+ return Boolean(t && t.schemaVersion === 2 && V2_FACT_FIELDS.every((field) => Object.prototype.hasOwnProperty.call(t, field)));
84
+ }
85
+
86
+ /** 旧记录保持原四态;只有完整 v2 记录才使用事实轴派生用户态。 */
87
+ function taskUserState(t) {
88
+ if (isTaskV2(t)) return deriveUserState(t);
89
+ if (t && t.status === 'succeeded') return 'succeeded';
90
+ if (t && t.status === 'failed') return 'failed';
91
+ if (t && t.status === 'canceled') return 'canceled';
92
+ return 'running';
93
+ }
94
+
71
95
  function taskPublic(t) {
72
96
  const elapsedMs =
73
97
  t.elapsedMs ||
@@ -76,6 +100,9 @@ function taskPublic(t) {
76
100
  id: t.id,
77
101
  cap: t.cap || '',
78
102
  status: t.status || 'running',
103
+ userState: taskUserState(t),
104
+ schemaVersion: Number(t.schemaVersion || 1),
105
+ revision: Number(t.revision || 0),
79
106
  progress: t.progress || '',
80
107
  model: t.model || '',
81
108
  providerName: t.providerName || '',
@@ -87,6 +114,8 @@ function taskPublic(t) {
87
114
  saved: !!t.saved,
88
115
  expired: !!t.expired,
89
116
  mode: t.mode || '',
117
+ retryOf: t.retryOf || '',
118
+ attentionDisposition: tasks.attentionDisposition(t),
90
119
  files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
91
120
  media: mediaOf(t)
92
121
  };
@@ -103,21 +132,32 @@ function loadProviders() {
103
132
  return Array.isArray(cache && cache.providers) ? cache.providers : [];
104
133
  }
105
134
 
106
- /** 构建完整工作台状态(纯 JSON,可序列化) */
135
+ const STATE_EPOCH = crypto.randomUUID();
136
+ let stateRevision = 0;
137
+
138
+ /** 构建完整工作台状态(纯 JSON,可序列化);序号用于客户端拒绝迟到快照。 */
107
139
  export function buildState() {
108
- const tasks = loadTasks();
140
+ const taskRecords = loadTasks();
109
141
  const providers = loadProviders();
110
142
 
111
- const all = [...tasks].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
112
- const running = all.filter((t) => t.status === 'running' && !t.expired);
113
- const terminal = all.filter((t) => t.status !== 'running');
143
+ const all = [...taskRecords].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
144
+ const stateOf = (t) => taskUserState(t);
145
+ const needsAttention = (t) => ['watching_paused', 'needs_attention', 'artifact_unavailable'].includes(stateOf(t));
146
+ const acknowledged = (t) => tasks.attentionDisposition(t).status === 'acknowledged';
147
+ const running = all.filter((t) => ['queued', 'running'].includes(stateOf(t)) && !t.expired);
148
+ const attention = all.filter((t) => needsAttention(t) && !acknowledged(t) && !t.expired);
149
+ const terminal = all.filter((t) => ['succeeded', 'failed', 'canceled'].includes(stateOf(t))
150
+ || (needsAttention(t) && acknowledged(t)) || t.expired);
114
151
  const recent = terminal.slice(0, 100).map(taskPublic);
115
152
 
116
153
  return {
154
+ stateEpoch: STATE_EPOCH,
155
+ stateRevision: ++stateRevision,
117
156
  providers: providers.map(providerPublic),
118
157
  tasks: {
119
158
  recent,
120
159
  recentTotal: terminal.length, // 泡泡「查看全部 (N)」计数用(不受 slice 截断)
160
+ attention: attention.slice(0, 20).map(taskPublic),
121
161
  running: running.slice(0, 10).map(taskPublic)
122
162
  }
123
163
  };
@@ -131,15 +171,44 @@ export function sseClientCount() {
131
171
  }
132
172
 
133
173
  const sseClients = new Set(); // 活跃 res(长连接)
174
+ const sseBlocked = new Set(); // 背压中的连接
175
+ const sseLatest = new WeakMap(); // 每个慢连接只保留最新状态
176
+ const sseDrainBound = new WeakSet();
134
177
  let sseHeartbeat = null; // 心跳定时器(15s,防代理/浏览器空闲断连)
135
178
  let sseThrottleTimer = null; // 推送节流(400ms 合并,防盯守高频 update 刷屏)
136
179
  let ssePending = false;
137
180
  let sseBound = false;
138
181
 
182
+ function bindSseDrain(res) {
183
+ sseBlocked.add(res);
184
+ if (sseDrainBound.has(res)) return;
185
+ sseDrainBound.add(res);
186
+ res.once('drain', () => {
187
+ sseDrainBound.delete(res);
188
+ sseBlocked.delete(res);
189
+ const latest = sseLatest.get(res);
190
+ sseLatest.delete(res);
191
+ if (latest !== undefined) pushSse(res, latest);
192
+ });
193
+ }
194
+
139
195
  function pushSse(res, data) {
140
196
  if (!res || res.writableEnded || res.destroyed) return;
197
+ if (sseBlocked.has(res)) {
198
+ sseLatest.set(res, data);
199
+ return;
200
+ }
201
+ try {
202
+ if (res.write(`data: ${data}\n\n`) === false) bindSseDrain(res);
203
+ } catch (_) {
204
+ /* 连接已死,close 事件会清理 */
205
+ }
206
+ }
207
+
208
+ function pushSseComment(res, comment) {
209
+ if (!res || res.writableEnded || res.destroyed || sseBlocked.has(res)) return;
141
210
  try {
142
- res.write(`data: ${data}\n\n`);
211
+ if (res.write(`: ${comment}\n\n`) === false) bindSseDrain(res);
143
212
  } catch (_) {
144
213
  /* 连接已死,close 事件会清理 */
145
214
  }
@@ -170,7 +239,7 @@ function scheduleBroadcast() {
170
239
  function ensureHeartbeat() {
171
240
  if (sseHeartbeat) return;
172
241
  sseHeartbeat = setInterval(() => {
173
- for (const res of [...sseClients]) pushSse(res, ': ping'); // 注释行保持连接活跃
242
+ for (const res of [...sseClients]) pushSseComment(res, 'ping'); // 注释帧不覆盖背压期间的最新状态
174
243
  if (!sseClients.size) stopHeartbeat();
175
244
  }, 15000);
176
245
  if (sseHeartbeat && sseHeartbeat.unref) sseHeartbeat.unref();
@@ -232,6 +301,8 @@ function serveSse(res) {
232
301
  pushSse(res, JSON.stringify(buildState())); // 立即推当前状态
233
302
  const onClose = () => {
234
303
  sseClients.delete(res);
304
+ sseBlocked.delete(res);
305
+ sseLatest.delete(res);
235
306
  if (!sseClients.size) {
236
307
  stopHeartbeat();
237
308
  if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
@@ -244,7 +315,7 @@ function serveSse(res) {
244
315
  res.on('error', onClose);
245
316
  }
246
317
 
247
- /** webServer 前缀路由 handler:GET/HEAD /iris/api/state 和 /iris/api/task/:id,POST /iris/api/actions/:name */
318
+ /** webServer 前缀路由 handler:状态、任务、动作与提示词优化器 API。 */
248
319
  export function serveApi(req, res, ctx) {
249
320
  try {
250
321
  const method = String(req.method || 'GET').toUpperCase();
@@ -256,6 +327,8 @@ export function serveApi(req, res, ctx) {
256
327
  if (urlPath === '/iris/api/upload') {
257
328
  return handleUpload(req, res);
258
329
  }
330
+ const promptMatch = typeof ctx === 'object' && urlPath.match(/^\/iris\/api\/prompt-optimizer\/(optimize|import|reset|enabled)\/?$/);
331
+ if (promptMatch) return handlePromptOptimizer(ctx, promptMatch[1], req, res);
259
332
  const actionMatch = typeof ctx === 'object' && urlPath.match(/^\/iris\/api\/actions\/([A-Za-z0-9_-]+)\/?$/);
260
333
  if (!actionMatch) { return sendJson(res, 405, { error: 'method not allowed' }); }
261
334
  return handleAction(ctx, actionMatch[1], req, res);
@@ -269,6 +342,9 @@ export function serveApi(req, res, ctx) {
269
342
  if (method === 'HEAD') return sendJson(res, 405, { error: 'method not allowed' });
270
343
  return serveSse(res);
271
344
  }
345
+ if (urlPath === '/iris/api/prompt-optimizer/config') {
346
+ return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
347
+ }
272
348
  const taskMatch = urlPath.match(/^\/iris\/api\/task\/([^/]+)$/);
273
349
  if (taskMatch) {
274
350
  return serveTaskDetail(taskMatch[1], method, res);
@@ -297,10 +373,12 @@ function sendJson(res, status, obj) {
297
373
 
298
374
  /** POST /iris/api/actions/:name:读取 JSON body 调 runAction 返回结果 */
299
375
  const MAX_BODY_BYTES = 1e6; // 1MB 上限(防滥用)
300
- function handleAction(ctx, name, req, res) {
376
+ function handleJsonPost(req, res, execute) {
301
377
  const chunks = [];
302
378
  const controller = new AbortController();
303
379
  let completed = false;
380
+ let totalBytes = 0;
381
+ let overflow = false;
304
382
  const finish = (status, body) => {
305
383
  if (completed) return;
306
384
  completed = true;
@@ -309,32 +387,49 @@ function handleAction(ctx, name, req, res) {
309
387
  const disconnect = () => { if (!completed) controller.abort(); };
310
388
  if (typeof req.on === 'function') req.on('aborted', disconnect);
311
389
  if (typeof res.on === 'function') res.on('close', disconnect);
312
- let totalBytes = 0; // 注意:必须按字节计——body 数组的 length 是块数不是字节数(旧代码的坑)
313
- let overflow = false;
314
- req.on('data', (c) => {
390
+ req.on('data', (chunk) => {
315
391
  if (overflow) return;
316
- totalBytes += c.length;
392
+ totalBytes += chunk.length;
317
393
  if (totalBytes > MAX_BODY_BYTES) {
318
394
  overflow = true;
319
- chunks.length = 0; // 丢弃已收块,不再占内存
395
+ chunks.length = 0;
320
396
  return;
321
397
  }
322
- chunks.push(c);
398
+ chunks.push(chunk);
323
399
  });
324
400
  req.on('end', async () => {
325
401
  if (overflow) return finish(413, { error: 'request body too large (limit 1MB)' });
326
402
  try {
327
403
  const raw = Buffer.concat(chunks).toString('utf8');
328
- const args = raw ? JSON.parse(raw) : {};
329
- const result = await runAction(ctx, name, args, { signal: controller.signal });
404
+ const body = raw ? JSON.parse(raw) : {};
405
+ const result = await execute(body, controller.signal);
330
406
  finish(200, result);
331
407
  } catch (err) {
332
- finish(controller.signal.aborted ? 499 : 400, { error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err) });
408
+ finish(controller.signal.aborted ? 499 : 400, {
409
+ error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err)
410
+ });
333
411
  }
334
412
  });
335
413
  req.on('error', () => finish(400, { error: 'bad request' }));
336
414
  }
337
415
 
416
+ function handleAction(ctx, name, req, res) {
417
+ return handleJsonPost(req, res, (args, signal) => runAction(ctx, name, args, { signal }));
418
+ }
419
+
420
+ function handlePromptOptimizer(ctx, operation, req, res) {
421
+ return handleJsonPost(req, res, async (body, signal) => {
422
+ if (operation === 'optimize') return optimizePrompt(ctx, body, { signal });
423
+ if (operation === 'import') {
424
+ const imported = body && body.config !== undefined ? body.config : body;
425
+ return { ok: true, ...importPromptOptimizerConfig(imported) };
426
+ }
427
+ if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig() };
428
+ if (operation === 'enabled') return { ok: true, ...setPromptOptimizerEnabled(body && body.enabled) };
429
+ throw new Error('unknown prompt optimizer operation');
430
+ });
431
+ }
432
+
338
433
  /* ---------- 文件选择器 L2:本地文件上传(阶段 10) ---------- */
339
434
  export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
340
435
  export const UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -462,6 +557,10 @@ function serveTaskDetail(taskId, method, res) {
462
557
  id: t.id,
463
558
  cap: t.cap || '',
464
559
  status: t.status || '',
560
+ userState: taskUserState(t),
561
+ schemaVersion: Number(t.schemaVersion || 1),
562
+ revision: Number(t.revision || 0),
563
+ ...(isTaskV2(t) ? Object.fromEntries(V2_FACT_FIELDS.map((field) => [field, t[field]])) : {}),
465
564
  progress: t.progress || '',
466
565
  model: t.model || '',
467
566
  providerName: t.providerName || '',
@@ -472,6 +571,11 @@ function serveTaskDetail(taskId, method, res) {
472
571
  error: t.error || '',
473
572
  mode: t.mode || '',
474
573
  remoteTaskId: t.remoteTaskId || '',
574
+ retryOf: t.retryOf || '',
575
+ manualRetries: Array.isArray(t.manualRetries) ? t.manualRetries.filter((item) => item && item.taskId).map((item) => ({
576
+ taskId: String(item.taskId), createdAt: item.createdAt || ''
577
+ })) : [],
578
+ attentionDisposition: tasks.attentionDisposition(t),
475
579
  files: Array.isArray(t.files) ? t.files.map((f) => path.basename(f)) : [],
476
580
  media: mediaOf(t),
477
581
  attachments: Array.isArray(t.attachments) ? t.attachments.map((a) => ({
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const SKILL_NAMES = ['iris-verify-ui', 'iris-compose-media'];
8
+ const SKILL_ROOT = fileURLToPath(new URL('../.dsh/skills/', import.meta.url));
9
+
10
+ function parseScalar(value, file) {
11
+ const trimmed = value.trim();
12
+ if (trimmed.startsWith('"')) {
13
+ try {
14
+ return JSON.parse(trimmed);
15
+ } catch (error) {
16
+ throw new Error(`随包 Skill frontmatter 字符串无效:${file}: ${error.message}`);
17
+ }
18
+ }
19
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
20
+ return trimmed.slice(1, -1).replaceAll("''", "'");
21
+ }
22
+ return trimmed;
23
+ }
24
+
25
+ /** 读取由 Iris 自己发布的固定 Skill;不把此解析器当作通用 YAML 解析器。 */
26
+ export function loadBundledSkills() {
27
+ return SKILL_NAMES.map((expectedName) => {
28
+ const directory = path.join(SKILL_ROOT, expectedName);
29
+ const file = path.join(directory, 'SKILL.md');
30
+ const source = fs.readFileSync(file, 'utf8');
31
+ const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
32
+ if (!frontmatter) throw new Error(`随包 Skill 缺少 frontmatter:${file}`);
33
+
34
+ const nameLine = frontmatter[1].match(/^name:\s*(.+)$/m);
35
+ const descriptionLine = frontmatter[1].match(/^description:\s*(.+)$/m);
36
+ const name = nameLine ? parseScalar(nameLine[1], file) : '';
37
+ const description = descriptionLine ? parseScalar(descriptionLine[1], file) : '';
38
+ if (name !== expectedName) throw new Error(`随包 Skill 名称不匹配:${file}`);
39
+ if (!description) throw new Error(`随包 Skill 缺少 description:${file}`);
40
+
41
+ return Object.freeze({
42
+ name,
43
+ description,
44
+ content: source.slice(frontmatter[0].length).replace(/^\r?\n/, ''),
45
+ source: 'bundled',
46
+ provider: 'iris-bundled',
47
+ path: file,
48
+ resourceBase: Object.freeze({ kind: 'directory', path: directory })
49
+ });
50
+ });
51
+ }
52
+
53
+ /** 注册为宿主级 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
+ }
59
+ const skills = loadBundledSkills();
60
+ for (const skill of skills) registry.register(skill);
61
+ return skills.map((skill) => skill.name);
62
+ }