@mokuyoaxis/dsh-iris 0.1.3 → 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 (51) 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 +24 -2
  8. package/README.en.md +29 -7
  9. package/README.md +29 -7
  10. package/docs/ARCHITECTURE.md +27 -13
  11. package/docs/ARTIFACT_LIBRARY.md +24 -0
  12. package/docs/HOST_ADAPTER_CONTRACT.md +86 -0
  13. package/docs/HOST_DOCTOR.md +47 -0
  14. package/docs/PROVIDER_ADAPTER_CONTRACT.md +97 -0
  15. package/docs/PROVIDER_HEALTH.md +39 -0
  16. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  17. package/docs/ROADMAP.md +18 -8
  18. package/docs/SECURITY.md +6 -2
  19. package/docs/TASK_SEMANTICS.md +3 -2
  20. package/docs/assets/diagrams/iris-architecture.png +0 -0
  21. package/docs/assets/diagrams/iris-architecture.svg +5 -5
  22. package/docs/releases/0.1.4.md +59 -0
  23. package/lib/actions.js +357 -221
  24. package/lib/adapters.js +7 -7
  25. package/lib/api.js +30 -11
  26. package/lib/artifacts.js +239 -0
  27. package/lib/bundled-skills.js +5 -5
  28. package/lib/client.js +370 -55
  29. package/lib/config.js +192 -9
  30. package/lib/doctor.js +175 -4
  31. package/lib/dsh-host-adapter.js +189 -0
  32. package/lib/host-contract.js +131 -0
  33. package/lib/host-runtime.js +156 -0
  34. package/lib/index.js +97 -113
  35. package/lib/locate.js +1 -1
  36. package/lib/media.js +16 -6
  37. package/lib/prompt-optimizer.js +10 -20
  38. package/lib/provider-adapter.js +371 -0
  39. package/lib/provider-adapters.js +279 -0
  40. package/lib/provider-health.js +59 -0
  41. package/lib/render.js +43 -12
  42. package/lib/tasks.js +29 -5
  43. package/lib/vision.js +23 -49
  44. package/package.json +14 -8
  45. package/user_guide.md +42 -2
  46. package/docs/assets/diagrams/iris-architecture.drawio +0 -1
  47. package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
  48. package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
  49. package/docs/releases/0.1.1.md +0 -51
  50. package/docs/releases/0.1.2.md +0 -26
  51. package/docs/releases/0.1.3.md +0 -30
package/lib/config.js CHANGED
@@ -11,6 +11,14 @@ import * as cap from './capability.js';
11
11
  import * as models from './models.js';
12
12
  import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
13
13
  import { inferMediaProtocol } from './provider-protocol.js';
14
+ import { redactProviderMessage } from './provider-contract.js';
15
+ import {
16
+ PROVIDER_HEALTH_FRESH_MS,
17
+ aggregateHealthStates,
18
+ healthObservationState,
19
+ isDefinitiveHealthFailure,
20
+ newestHealthEvidence
21
+ } from './provider-health.js';
14
22
 
15
23
  function isPlainObject(value) {
16
24
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -293,6 +301,163 @@ export function capabilitiesOf(p) {
293
301
  return [...caps];
294
302
  }
295
303
 
304
+ const HEALTH_SENSITIVE_FIELDS = new Set([
305
+ 'type', 'baseUrl', 'apiKey', 'mediaProtocol',
306
+ 'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
307
+ ]);
308
+
309
+ function healthContainer(provider) {
310
+ const current = provider.health;
311
+ if (current && current.version === 1 && Number.isSafeInteger(current.revision)
312
+ && Array.isArray(current.observations)) return current;
313
+ const revision = Number.isSafeInteger(current && current.revision) ? current.revision : 0;
314
+ provider.health = { version: 1, revision, observations: [] };
315
+ return provider.health;
316
+ }
317
+
318
+ function healthObservation(provider, modelId, capability) {
319
+ const health = provider && provider.health;
320
+ if (!health || health.version !== 1 || !Array.isArray(health.observations)) return null;
321
+ return health.observations.find((item) => item && item.modelId === modelId
322
+ && item.capability === capability) || null;
323
+ }
324
+
325
+ function safeHealthNote(provider, value) {
326
+ const raw = String(value ?? '').trim();
327
+ if (!raw) return '';
328
+ let note = redactProviderMessage(raw, 200);
329
+ const key = String(provider && provider.apiKey || '');
330
+ if (key) note = note.split(key).join('[REDACTED]');
331
+ return note;
332
+ }
333
+
334
+ function observationEvidence(provider, result, fallbackSource) {
335
+ const atMs = Date.parse(String(result && result.at || ''));
336
+ const status = Number(result && (result.httpStatus ?? result.status));
337
+ const note = safeHealthNote(provider, result && (result.note || result.safeMessage || result.message));
338
+ return {
339
+ at: new Date(Number.isFinite(atMs) ? atMs : Date.now()).toISOString(),
340
+ source: ['probe', 'task'].includes(result && result.source) ? result.source : fallbackSource,
341
+ category: String(result && result.category || (result && result.ok ? 'success' : 'unknown')).slice(0, 40),
342
+ ...(Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}),
343
+ ...(note ? { note } : {})
344
+ };
345
+ }
346
+
347
+ function applyProviderHealth(provider, modelId, capability, result, fallbackSource = 'task') {
348
+ const model = String(modelId || '').trim();
349
+ if (!provider || !model || !cap.isKnownCapability(capability)) return false;
350
+ const health = healthContainer(provider);
351
+ let observation = health.observations.find((item) => item && item.modelId === model
352
+ && item.capability === capability);
353
+ if (!observation) {
354
+ observation = { modelId: model, capability };
355
+ health.observations.push(observation);
356
+ }
357
+ const evidence = observationEvidence(provider, result || {}, fallbackSource);
358
+ if (result && result.ok === true) {
359
+ observation.lastSuccess = evidence;
360
+ } else if (isDefinitiveHealthFailure({ ...result, ...evidence })) {
361
+ observation.lastFailure = evidence;
362
+ } else {
363
+ observation.lastTransient = evidence;
364
+ }
365
+ return true;
366
+ }
367
+
368
+ function pruneHealthToEffectiveModels(provider) {
369
+ if (!provider) return;
370
+ const allowedByModel = new Map();
371
+ for (const entry of models.providerModels(provider)) {
372
+ allowedByModel.set(entry.id, new Set(entry.capabilities));
373
+ }
374
+ if (provider.health && Array.isArray(provider.health.observations)) {
375
+ provider.health.observations = provider.health.observations.filter((item) =>
376
+ item && allowedByModel.get(item.modelId)?.has(item.capability));
377
+ }
378
+ for (const entry of provider.models || []) {
379
+ if (!entry || typeof entry !== 'object' || !entry.verified || typeof entry.verified !== 'object') continue;
380
+ const allowed = allowedByModel.get(entry.id) || new Set();
381
+ entry.verified = Object.fromEntries(Object.entries(entry.verified).filter(([capability]) => allowed.has(capability)));
382
+ if (!Object.keys(entry.verified).length) delete entry.verified;
383
+ }
384
+ }
385
+
386
+ function pruneModelHealth(provider, modelId) {
387
+ if (!provider || !provider.health || !Array.isArray(provider.health.observations)) return;
388
+ provider.health.observations = provider.health.observations.filter((item) => item.modelId !== modelId);
389
+ }
390
+
391
+ function resetHealthOnCriticalChange(previous, next, patch) {
392
+ const changed = [...HEALTH_SENSITIVE_FIELDS].some((key) =>
393
+ Object.prototype.hasOwnProperty.call(patch, key) && previous[key] !== next[key]);
394
+ if (!changed) return;
395
+ const current = previous.health;
396
+ const revision = Number.isSafeInteger(current && current.revision) ? current.revision + 1 : 1;
397
+ next.health = { version: 1, revision, observations: [] };
398
+ }
399
+
400
+ /** 记录一次显式实测或真实任务观察;临时错误不会覆盖近期成功。 */
401
+ export function recordProviderHealth(id, modelId, capability, result) {
402
+ const c = load();
403
+ const provider = c.providers.find((item) => item.id === id);
404
+ if (!provider || !applyProviderHealth(provider, modelId, capability, result, result && result.source || 'task')) {
405
+ return false;
406
+ }
407
+ persist();
408
+ return true;
409
+ }
410
+
411
+ /** 返回单个 Provider × Model × Capability 的安全状态。 */
412
+ export function modelHealth(id, modelId, capability, { now = Date.now() } = {}) {
413
+ const provider = load().providers.find((item) => item.id === id);
414
+ if (!provider || provider.enabled === false || !provider.apiKey) return { status: 'unconfigured' };
415
+ const observation = healthObservation(provider, modelId, capability);
416
+ const status = healthObservationState(observation, { now });
417
+ const evidence = status === 'verified'
418
+ ? observation && observation.lastSuccess
419
+ : (status === 'failed' ? observation && observation.lastFailure : newestHealthEvidence(observation));
420
+ return {
421
+ status,
422
+ ...(evidence ? {
423
+ observedAt: evidence.at,
424
+ source: evidence.source,
425
+ category: evidence.category,
426
+ ...(evidence.httpStatus ? { httpStatus: evidence.httpStatus } : {}),
427
+ ...(evidence.note ? { note: evidence.note } : {})
428
+ } : {})
429
+ };
430
+ }
431
+
432
+ /** 当前有序 failover 路径的健康快照;只含脱敏标量,不触发网络。 */
433
+ export function providerHealthSnapshot({ now = Date.now() } = {}) {
434
+ const capabilities = {};
435
+ for (const capability of Object.values(cap.CAPABILITIES)) {
436
+ const candidates = pickAllFor(capability).map((provider) => {
437
+ const modelId = String(provider[FIELD_OF[capability]] || '');
438
+ const health = modelHealth(provider.id, modelId, capability, { now });
439
+ return { providerId: provider.id, modelId, ...health };
440
+ });
441
+ const status = aggregateHealthStates(candidates.map((item) => item.status));
442
+ const newest = [...candidates]
443
+ .filter((item) => item.status === status && item.observedAt)
444
+ .sort((a, b) => String(b.observedAt).localeCompare(String(a.observedAt)))[0];
445
+ capabilities[capability] = {
446
+ status,
447
+ candidateCount: candidates.length,
448
+ ...(newest ? { observedAt: newest.observedAt } : {}),
449
+ candidates
450
+ };
451
+ }
452
+ return {
453
+ schemaVersion: 1,
454
+ freshForMs: PROVIDER_HEALTH_FRESH_MS,
455
+ generatedAt: new Date(Number(now)).toISOString(),
456
+ overall: aggregateHealthStates(Object.values(capabilities).map((item) => item.status)),
457
+ capabilities
458
+ };
459
+ }
460
+
296
461
  export function upsert(provider) {
297
462
  const c = load();
298
463
  const { id, ...input } = provider;
@@ -301,11 +466,15 @@ export function upsert(provider) {
301
466
  if (id) {
302
467
  const i = c.providers.findIndex((p) => p.id === id);
303
468
  if (i >= 0) {
304
- if (rest.mediaProtocol === 'auto') rest.mediaProtocol = inferMediaProtocol(rest.baseUrl ?? c.providers[i].baseUrl);
305
- c.providers[i] = { ...c.providers[i], ...rest };
306
- normalizeStoredProvider(c.providers[i]);
469
+ const previous = c.providers[i];
470
+ if (rest.mediaProtocol === 'auto') rest.mediaProtocol = inferMediaProtocol(rest.baseUrl ?? previous.baseUrl);
471
+ const next = { ...previous, ...rest };
472
+ normalizeStoredProvider(next);
473
+ resetHealthOnCriticalChange(previous, next, rest);
474
+ if (Object.prototype.hasOwnProperty.call(rest, 'models')) pruneHealthToEffectiveModels(next);
475
+ c.providers[i] = next;
307
476
  persist();
308
- return c.providers[i];
477
+ return next;
309
478
  }
310
479
  }
311
480
  const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
@@ -352,6 +521,7 @@ export function setProviderModels(id, modelEntries) {
352
521
  return entry;
353
522
  });
354
523
  }
524
+ pruneHealthToEffectiveModels(p);
355
525
  persist();
356
526
  return p;
357
527
  }
@@ -376,6 +546,7 @@ export function addProviderModel(id, modelId, capabilities) {
376
546
  } else if (Array.isArray(capabilities)) {
377
547
  entry.capabilities = capabilities;
378
548
  }
549
+ pruneHealthToEffectiveModels(p);
379
550
  persist();
380
551
  return entry;
381
552
  }
@@ -388,6 +559,7 @@ export function removeProviderModel(id, modelId) {
388
559
  const i = p.models.findIndex((m) => m && m.id === modelId);
389
560
  if (i < 0) return false;
390
561
  p.models.splice(i, 1);
562
+ pruneModelHealth(p, modelId);
391
563
  persist();
392
564
  return true;
393
565
  }
@@ -399,20 +571,31 @@ export function setModelCapabilities(id, modelId, capabilities) {
399
571
  if (!p) return false;
400
572
  const entry = findModelEntry(p, modelId);
401
573
  if (!entry) return false;
402
- entry.capabilities = (Array.isArray(capabilities) ? capabilities : []).filter((x) => cap.isKnownCapability(x));
574
+ const nextCapabilities = (Array.isArray(capabilities) ? capabilities : []).filter((x) => cap.isKnownCapability(x));
575
+ entry.capabilities = nextCapabilities;
576
+ pruneHealthToEffectiveModels(p);
403
577
  persist();
404
578
  return true;
405
579
  }
406
580
 
407
- /** 记录某模型的实测结果(verified[capability] = {ok, at, note}) */
581
+ /** 记录某模型的显式实测结果,并同步到新健康事实。 */
408
582
  export function setModelVerified(id, modelId, capability, result) {
409
583
  const c = load();
410
584
  const p = c.providers.find((x) => x.id === id);
411
585
  if (!p) return false;
412
586
  const entry = findModelEntry(p, modelId);
413
- if (!entry) return false;
414
- if (!entry.verified) entry.verified = {};
415
- entry.verified[capability] = { ok: !!result.ok, at: new Date().toISOString(), note: String(result.note || '').slice(0, 200) };
587
+ const at = new Date().toISOString();
588
+ if (entry) {
589
+ if (!entry.verified) entry.verified = {};
590
+ entry.verified[capability] = {
591
+ ok: !!result.ok,
592
+ at,
593
+ note: safeHealthNote(p, result.note || ''),
594
+ source: 'probe',
595
+ category: String(result.category || (result.ok ? 'success' : 'unknown')).slice(0, 40)
596
+ };
597
+ }
598
+ applyProviderHealth(p, modelId, capability, { ...result, at, source: 'probe' }, 'probe');
416
599
  persist();
417
600
  return true;
418
601
  }
package/lib/doctor.js CHANGED
@@ -5,6 +5,11 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { attentionDisposition, deriveUserState, semanticViolations } from './task-semantics.js';
8
+ import { HOST_CONTRACT_VERSION, hostCapabilitySnapshot } from './host-contract.js';
9
+ import {
10
+ EXPECTED_IRIS_CLIENT_SEATS, EXPECTED_IRIS_ROUTES, EXPECTED_IRIS_SKILLS, EXPECTED_IRIS_TOOLS,
11
+ HOST_CLIENT_PROTOCOL_VERSION, IRIS_PLUGIN_ID
12
+ } from './host-runtime.js';
8
13
 
9
14
  const PACKAGE_FILE = new URL('../package.json', import.meta.url);
10
15
 
@@ -45,7 +50,7 @@ function isGroupOrWorldAccessible(target) {
45
50
 
46
51
  function privateFileModeProblems(dataDir) {
47
52
  if (process.platform === 'win32') return [];
48
- return ['providers.json', 'tasks.json', 'prompt-optimizer.json']
53
+ return ['providers.json', 'tasks.json', 'artifacts.json', 'prompt-optimizer.json']
49
54
  .filter((name) => isGroupOrWorldAccessible(path.join(dataDir, name)));
50
55
  }
51
56
 
@@ -151,6 +156,30 @@ function inspectArtifacts(dataDir, tasks, checks) {
151
156
  for (const item of task.media || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
152
157
  for (const item of task.attachments || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
153
158
  }
159
+
160
+ const library = readJson(path.join(dataDir, 'artifacts.json'));
161
+ if (library.exists && (library.error || !plain(library.value) || !Array.isArray(library.value.artifacts))) {
162
+ checks.push(result('artifact-index', 'warn', 'artifacts.json 结构无效或无法解析', '保留损坏索引作为证据;重新启动 Iris 后会从 outputs/ 重建最小作品索引。'));
163
+ } else if (library.exists) {
164
+ let invalid = 0;
165
+ for (const item of library.value.artifacts) {
166
+ if (!plain(item) || !String(item.id || '').startsWith('a_') || !String(item.token || '').match(/^[a-f0-9]{32}$/)
167
+ || !String(item.file || '') || path.basename(String(item.file)) !== String(item.file)) {
168
+ invalid++;
169
+ continue;
170
+ }
171
+ const safe = String(item.file);
172
+ referenced.add(safe);
173
+ try {
174
+ const stat = fs.lstatSync(path.join(outputs, safe));
175
+ if (!stat.isFile() || stat.isSymbolicLink()) missing++;
176
+ } catch (_) { missing++; }
177
+ }
178
+ checks.push(invalid
179
+ ? result('artifact-index', 'warn', `${invalid} 个作品索引条目无效`, '在工作台运行“找回本地作品”重建最小索引。')
180
+ : result('artifact-index', 'ok', `${library.value.artifacts.length} 个作品索引条目结构有效`));
181
+ }
182
+
154
183
  let orphan = 0;
155
184
  if (fs.existsSync(outputs)) {
156
185
  for (const name of fs.readdirSync(outputs)) {
@@ -158,9 +187,9 @@ function inspectArtifacts(dataDir, tasks, checks) {
158
187
  try { if (fs.lstatSync(target).isFile() && !referenced.has(name)) orphan++; } catch (_) {}
159
188
  }
160
189
  }
161
- if (missing) checks.push(result('artifacts', 'warn', `${missing} 个任务产物不可访问;另有 ${orphan} 个孤儿文件`, '在工作台检查任务详情并运行孤儿产物扫描。'));
162
- else if (orphan) checks.push(result('artifacts', 'warn', `${orphan} 个产物文件没有任务引用`, '先在工作台运行孤儿扫描,确认后再删除。'));
163
- else checks.push(result('artifacts', 'ok', `${referenced.size} 个已引用产物可核对,未发现孤儿文件`));
190
+ if (missing) checks.push(result('artifacts', 'warn', `${missing} 个任务或作品索引引用的文件不可访问;另有 ${orphan} 个孤儿文件`, '在工作台检查任务详情并运行“找回本地作品”。'));
191
+ else if (orphan) checks.push(result('artifacts', 'warn', `${orphan} 个输出文件既没有任务引用也未进入作品库`, '先运行“找回本地作品”;仍无法入库的文件再作为孤儿处理。'));
192
+ else checks.push(result('artifacts', 'ok', `${referenced.size} 个任务/作品引用可核对,未发现孤儿文件`));
164
193
 
165
194
  const uploads = path.join(dataDir, 'uploads');
166
195
  let temporary = 0;
@@ -231,6 +260,148 @@ export async function doctor(options = {}) {
231
260
  };
232
261
  }
233
262
 
263
+
264
+ function summarizeChecks(checks) {
265
+ const errors = checks.filter((item) => item.status === 'error').length;
266
+ const warnings = checks.filter((item) => item.status === 'warn').length;
267
+ return {
268
+ summary: { ok: checks.length - errors - warnings, warnings, errors },
269
+ exitCode: errors ? 2 : (warnings ? 1 : 0)
270
+ };
271
+ }
272
+
273
+ function dshVersionSupported(value) {
274
+ /* 已实测的宿主窗口:>=0.1.2-rc.1 <0.1.3-0,以及 0.1.5-rc.1。
275
+ 其他预览版没有宿主 canary 证据,不放行。 */
276
+ const version = String(value || '').trim().replace(/\s+/g, '');
277
+ if (/^0\.1\.5-rc\.1(?:\+.*)?$/.test(version)) return true;
278
+ const match = version.match(/^0\.1\.2(?:-rc\.(\d+))?(?:\+.*)?$/);
279
+ return Boolean(match && (match[1] === undefined || Number(match[1]) >= 1));
280
+ }
281
+
282
+ function names(value) {
283
+ return [...new Set((Array.isArray(value) ? value : []).map((item) => String(item || '').trim()).filter(Boolean))];
284
+ }
285
+
286
+ function missing(expected, actual) {
287
+ const found = new Set(names(actual));
288
+ return expected.filter((item) => !found.has(item));
289
+ }
290
+
291
+ function capabilityCheck(checks, snapshot, id, port, label, severity = 'warn') {
292
+ const capability = snapshot.capabilities[port];
293
+ if (capability && capability.status === 'available') {
294
+ checks.push(result(id, 'ok', label + ' Host Port 可用'));
295
+ return true;
296
+ }
297
+ const status = severity === 'error' ? 'error' : 'warn';
298
+ const kind = capability?.status === 'incompatible' ? '接口不兼容' : '能力缺失';
299
+ checks.push(result(id, status, label + '不可用(' + kind + ')' + (capability?.reason ? ':' + capability.reason : ''),
300
+ capability?.status === 'incompatible'
301
+ ? '核对当前 DSH 版本与 Iris 支持范围,必要时回退到已验证版本。'
302
+ : '启用对应 DSH 服务;不需要该能力时可忽略此降级。'));
303
+ return false;
304
+ }
305
+
306
+ /**
307
+ * 运行中宿主诊断。只消费安全快照和注册账本,不调用任何端口,因此默认零网络、
308
+ * 零计费,也不会打开 Browser、读取附件/会话或调用模型。
309
+ */
310
+ export function hostDoctor(host, evidence = {}, options = {}) {
311
+ const snapshot = hostCapabilitySnapshot(host);
312
+ const packageVersion = String(options.packageVersion || evidence?.server?.version || readPackageVersion());
313
+ const checks = [];
314
+
315
+ checks.push(snapshot.contractVersion === HOST_CONTRACT_VERSION
316
+ ? result('host-contract', 'ok', 'Host Adapter v' + snapshot.contractVersion + ' · ' + snapshot.host.id)
317
+ : result('host-contract', 'error', 'Host Adapter 契约版本不兼容',
318
+ '使用与当前 Iris 包匹配的 Host Adapter。'));
319
+
320
+ if (snapshot.host.id === 'deepseek-harness') {
321
+ if (snapshot.host.version === 'unknown') {
322
+ checks.push(result('dsh-version', 'warn', '无法从当前进程识别 DSH 版本',
323
+ '使用 dsh --version 核对;0.1.4 已验证 >=0.1.2-rc.1 <0.1.3-0 与 0.1.5-rc.1。'));
324
+ } else if (dshVersionSupported(snapshot.host.version)) {
325
+ checks.push(result('dsh-version', 'ok', 'DSH ' + snapshot.host.version + ' 位于已验证支持范围'));
326
+ } else {
327
+ checks.push(result('dsh-version', 'error', 'DSH ' + snapshot.host.version + ' 超出已验证支持范围',
328
+ '使用 >=0.1.2-rc.1 <0.1.3-0 或 0.1.5-rc.1,或先运行新的隔离 canary 再扩大支持范围。'));
329
+ }
330
+ }
331
+
332
+ const server = evidence && typeof evidence.server === 'object' ? evidence.server : {};
333
+ if (server.loaded === true && server.pluginId === IRIS_PLUGIN_ID) {
334
+ checks.push(result('plugin', 'ok', IRIS_PLUGIN_ID + ' 服务端已装载 · ' + packageVersion));
335
+ } else {
336
+ checks.push(result('plugin', 'error', '无法确认 Iris 服务端插件身份或装载事实',
337
+ '检查 profile 中是否启用完整 scoped 包名 @mokuyoaxis/dsh-iris,并查看 DSH 启动日志。'));
338
+ }
339
+
340
+ const toolsAvailable = capabilityCheck(checks, snapshot, 'tools-port', 'tools', 'Agent 工具注册', 'error');
341
+ const missingTools = missing(EXPECTED_IRIS_TOOLS, evidence.tools);
342
+ checks.push(toolsAvailable && missingTools.length === 0
343
+ ? result('tools', 'ok', EXPECTED_IRIS_TOOLS.length + ' 个 Iris 工具均已登记')
344
+ : result('tools', 'error', '缺少 ' + missingTools.length + '/' + EXPECTED_IRIS_TOOLS.length + ' 个 Iris 工具'
345
+ + (missingTools.length ? ':' + missingTools.join(', ') : ''),
346
+ '检查单工具注册隔离日志;不要因一项失败重启或重复提交媒体任务。'));
347
+
348
+ const routesAvailable = capabilityCheck(checks, snapshot, 'routes-port', 'routes', 'Web 路由注册', 'error');
349
+ const missingRoutes = missing(EXPECTED_IRIS_ROUTES, evidence.routes);
350
+ checks.push(routesAvailable && missingRoutes.length === 0
351
+ ? result('routes', 'ok', EXPECTED_IRIS_ROUTES.length + ' 组 Iris 路由均已登记')
352
+ : result('routes', 'error', '缺少 ' + missingRoutes.length + '/' + EXPECTED_IRIS_ROUTES.length + ' 组 Iris 路由'
353
+ + (missingRoutes.length ? ':' + missingRoutes.join(', ') : ''),
354
+ '检查 webServer/httpServer 与路由挂载日志。'));
355
+
356
+ const skillsAvailable = capabilityCheck(checks, snapshot, 'skills-port', 'skills', 'Skill 注册');
357
+ const missingSkills = missing(EXPECTED_IRIS_SKILLS, evidence.skills);
358
+ checks.push(skillsAvailable && missingSkills.length === 0
359
+ ? result('skills', 'ok', EXPECTED_IRIS_SKILLS.length + ' 项随包 Skill 均已登记')
360
+ : result('skills', 'warn', '缺少 ' + missingSkills.length + '/' + EXPECTED_IRIS_SKILLS.length + ' 项随包 Skill'
361
+ + (missingSkills.length ? ':' + missingSkills.join(', ') : ''),
362
+ '工具仍可直接调用;检查 DSH Skill registry 和插件启动日志。'));
363
+
364
+ capabilityCheck(checks, snapshot, 'browser', 'browser', 'HTML Browser');
365
+ capabilityCheck(checks, snapshot, 'attachments', 'attachments', '附件读写');
366
+ capabilityCheck(checks, snapshot, 'sessions', 'sessions', '会话查询');
367
+ capabilityCheck(checks, snapshot, 'text-model', 'textModel', '宿主文本模型');
368
+ capabilityCheck(checks, snapshot, 'vision-model', 'visionModel', '宿主视觉模型');
369
+
370
+ const client = evidence && typeof evidence.client === 'object' ? evidence.client : null;
371
+ if (!client || client.loaded !== true) {
372
+ checks.push(result('client', 'warn', '尚未收到 Iris 浏览器客户端握手',
373
+ '在同一 DSH 实例打开或刷新 Web 页面,再重新访问 /iris/api/doctor。'));
374
+ } else if (client.pluginId !== IRIS_PLUGIN_ID
375
+ || client.protocolVersion !== HOST_CLIENT_PROTOCOL_VERSION
376
+ || client.version !== packageVersion) {
377
+ checks.push(result('client', 'error',
378
+ 'Iris 服务端/客户端版本不一致:server ' + packageVersion + ' · client ' + String(client.version || 'unknown')
379
+ + ' · protocol ' + String(client.protocolVersion),
380
+ '重新打开 DSH 输出的认证 URL并强制刷新;若仍不一致,清理该 profile 的旧插件链接后重装。'));
381
+ } else {
382
+ const missingSeats = missing(EXPECTED_IRIS_CLIENT_SEATS, client.seats);
383
+ checks.push(missingSeats.length
384
+ ? result('client', 'error', '浏览器客户端缺少 ' + missingSeats.length + '/' + EXPECTED_IRIS_CLIENT_SEATS.length
385
+ + ' 个 UI Slot:' + missingSeats.join(', '),
386
+ '检查客户端模块表和 DSH Slot API;服务端任务不受影响。')
387
+ : result('client', 'ok', '客户端 ' + client.version + ' 已装载,4 个 UI Slot 均已登记'));
388
+ }
389
+
390
+ checks.push(result('scope', 'ok', 'Host Doctor 只读取能力快照和 Iris 注册账本;未调用端口、Browser、模型或供应商'));
391
+
392
+ const totals = summarizeChecks(checks);
393
+ return {
394
+ schemaVersion: 1,
395
+ package: { name: IRIS_PLUGIN_ID, version: packageVersion },
396
+ mode: 'host',
397
+ environment: { node: process.versions.node, platform: process.platform, arch: process.arch },
398
+ host: snapshot.host,
399
+ capabilities: snapshot.capabilities,
400
+ checks,
401
+ ...totals
402
+ };
403
+ }
404
+
234
405
  export function formatDoctorReport(report) {
235
406
  const mark = { ok: '✓', warn: '!', error: '✗' };
236
407
  const lines = [`Iris Doctor ${report.package.version} · ${report.mode}`];
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ /** DSH/Cordis runtime objects are normalized here and nowhere in Iris commands. */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { defineHostAdapter } from './host-contract.js';
7
+ import { BrowserHtmlRenderer } from './render.js';
8
+
9
+ export function detectDshVersion({ entry = process.argv[1] } = {}) {
10
+ if (!entry) return 'unknown';
11
+ let resolved = path.resolve(String(entry));
12
+ try { resolved = fs.realpathSync(resolved); } catch (_) { /* 入口可能尚未物化 */ }
13
+ let current = path.dirname(resolved);
14
+ for (let depth = 0; depth < 6; depth++) {
15
+ try {
16
+ const manifest = JSON.parse(fs.readFileSync(path.join(current, 'package.json'), 'utf8'));
17
+ if (manifest.name === '@deepseek-ai/dsh') return String(manifest.version || 'unknown');
18
+ } catch (_) { /* 当前层不是 DSH 包根目录 */ }
19
+ const parent = path.dirname(current);
20
+ if (parent === current) break;
21
+ current = parent;
22
+ }
23
+ return 'unknown';
24
+ }
25
+
26
+ function property(ctx, name) {
27
+ try { return ctx && ctx[name]; } catch (_) { return undefined; }
28
+ }
29
+
30
+ function service(ctx, name) {
31
+ if (ctx && typeof ctx.get === 'function') {
32
+ try {
33
+ const value = ctx.get(name);
34
+ if (value) return value;
35
+ } catch (_) { /* optional DSH service was not injected */ }
36
+ }
37
+ return property(ctx, name);
38
+ }
39
+
40
+ function unavailableFor(value, reason) {
41
+ return { kind: value ? 'incompatible' : 'unavailable', reason };
42
+ }
43
+
44
+ function collectImageAttachments(nodes) {
45
+ const out = [];
46
+ const ids = new Set();
47
+ const seen = new Set();
48
+ const stack = Array.isArray(nodes) ? [...nodes] : [nodes];
49
+ let scanned = 0;
50
+ while (stack.length && scanned < 8000) {
51
+ const node = stack.pop();
52
+ scanned++;
53
+ if (!node || typeof node !== 'object' || seen.has(node)) continue;
54
+ seen.add(node);
55
+ const ref = node.attachment && typeof node.attachment === 'object'
56
+ ? node.attachment
57
+ : (node.attachmentId && node.mediaType ? node : null);
58
+ if (ref && typeof ref.attachmentId === 'string'
59
+ && typeof ref.mediaType === 'string' && ref.mediaType.startsWith('image/')
60
+ && !ids.has(ref.attachmentId)) {
61
+ ids.add(ref.attachmentId);
62
+ out.push(Object.freeze({
63
+ attachmentId: ref.attachmentId,
64
+ mediaType: ref.mediaType,
65
+ name: typeof ref.name === 'string' ? ref.name : ''
66
+ }));
67
+ }
68
+ for (const value of Object.values(node)) if (value && typeof value === 'object') stack.push(value);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function registerWithEffect(ctx, registry, definition) {
74
+ if (typeof ctx.effect === 'function') return ctx.effect(() => registry.register(definition));
75
+ return registry.register(definition);
76
+ }
77
+
78
+ function dshVisionPort(llm) {
79
+ return Object.freeze({
80
+ id: 'dsh-global',
81
+ model: '全局视觉模型',
82
+ async analyze({ question, ref, signal }) {
83
+ const chunks = [];
84
+ for await (const chunk of llm.stream({
85
+ sessionId: undefined,
86
+ provider: undefined,
87
+ model: undefined,
88
+ signal,
89
+ messages: [{
90
+ role: 'user',
91
+ content: [
92
+ { type: 'text', text: question },
93
+ { type: 'image', attachment: ref }
94
+ ]
95
+ }]
96
+ })) {
97
+ const text = typeof chunk?.delta === 'string'
98
+ ? chunk.delta
99
+ : (chunk?.type === 'text-delta' ? String(chunk.text || '') : '');
100
+ if (text) chunks.push(text);
101
+ if (chunks.reduce((sum, item) => sum + item.length, 0) > 6000) break;
102
+ }
103
+ return chunks.join('').trim();
104
+ }
105
+ });
106
+ }
107
+
108
+ /** Build a fresh capability snapshot so optional DSH services can arrive after plugin apply(). */
109
+ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {}) {
110
+ if (!ctx || typeof ctx !== 'object') throw new TypeError('DSH Host Adapter 需要宿主 ctx');
111
+ const ports = {};
112
+ const unavailable = {};
113
+
114
+ const attachments = service(ctx, 'attachments');
115
+ if (attachments && typeof attachments.saveImage === 'function' && typeof attachments.readImage === 'function') {
116
+ ports.attachments = Object.freeze({
117
+ saveImage: (input) => attachments.saveImage(input),
118
+ readImage: (ref, signal) => attachments.readImage(ref, signal)
119
+ });
120
+ } else unavailable.attachments = unavailableFor(attachments, 'DSH attachments 需要 saveImage/readImage');
121
+
122
+ const browser = service(ctx, 'browser');
123
+ if (browser && ['open', 'openUrl', 'screenshot', 'close'].every((name) => typeof browser[name] === 'function')) {
124
+ const renderer = new BrowserHtmlRenderer({ browser });
125
+ ports.browser = Object.freeze({
126
+ async renderHtml(input) {
127
+ const { png } = await renderer.render(input);
128
+ return { bytes: new Uint8Array(png), mediaType: 'image/png' };
129
+ }
130
+ });
131
+ } else unavailable.browser = unavailableFor(browser, 'DSH browser 需要 open/openUrl/screenshot/close');
132
+
133
+ const slots = service(ctx, 'slots');
134
+ if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
135
+ ports.clientSlots = Object.freeze({
136
+ inject: (seat, callback) => slots.inject(seat, callback),
137
+ register: (definition, component) => slots.register(definition, component)
138
+ });
139
+ } else unavailable.clientSlots = unavailableFor(slots, 'DSH client slots 需要 inject/register');
140
+
141
+ const routes = service(ctx, 'webServer') || service(ctx, 'httpServer');
142
+ if (routes && typeof routes.register === 'function') {
143
+ ports.routes = Object.freeze({ register: (definition) => routes.register(definition) });
144
+ } else unavailable.routes = unavailableFor(routes, 'DSH webServer/httpServer 需要 register');
145
+
146
+ const sessions = service(ctx, 'sessionQuery');
147
+ if (sessions && typeof sessions.readSession === 'function') {
148
+ ports.sessions = Object.freeze({
149
+ readSession: (id) => sessions.readSession(id),
150
+ async listImageAttachments(id) {
151
+ const record = await sessions.readSession(id);
152
+ return collectImageAttachments(record && record.events);
153
+ },
154
+ async findImageAttachment(id, attachmentId) {
155
+ return (await this.listImageAttachments(id)).find((item) => item.attachmentId === attachmentId) || null;
156
+ }
157
+ });
158
+ } else unavailable.sessions = unavailableFor(sessions, 'DSH sessionQuery 需要 readSession');
159
+
160
+ const skills = service(ctx, 'skills');
161
+ if (skills && typeof skills.register === 'function') {
162
+ ports.skills = Object.freeze({ register: (definition) => registerWithEffect(ctx, skills, definition) });
163
+ } else unavailable.skills = unavailableFor(skills, 'DSH Skill registry 需要 register');
164
+
165
+ const llm = service(ctx, 'llm');
166
+ const defaults = service(ctx, 'agentDefaultModel');
167
+ if (llm && typeof llm.stream === 'function') {
168
+ ports.textModel = Object.freeze({
169
+ stream: (request) => llm.stream(request),
170
+ resolveModelInfo: typeof llm.resolveModelInfo === 'function'
171
+ ? (provider, model, signal) => llm.resolveModelInfo(provider, model, signal)
172
+ : undefined,
173
+ currentSelection: defaults && typeof defaults.currentSelection === 'function'
174
+ ? () => defaults.currentSelection()
175
+ : () => null
176
+ });
177
+ ports.visionModel = dshVisionPort(llm);
178
+ } else {
179
+ unavailable.textModel = unavailableFor(llm, 'DSH llm 需要 stream');
180
+ unavailable.visionModel = unavailableFor(llm, 'DSH llm 需要 stream');
181
+ }
182
+
183
+ const tools = service(ctx, 'tools');
184
+ if (tools && typeof tools.register === 'function') {
185
+ ports.tools = Object.freeze({ register: (definition) => registerWithEffect(ctx, tools, definition) });
186
+ } else unavailable.tools = unavailableFor(tools, 'DSH tools 需要 register');
187
+
188
+ return defineHostAdapter({ id: 'deepseek-harness', version, ports, unavailable });
189
+ }