@mokuyoaxis/dsh-iris 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -73
  2. package/.dsh/skills/iris-compose-media/agents/openai.yaml +1 -1
  3. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +23 -22
  4. package/.dsh/skills/iris-compose-media/references/workflows.md +25 -30
  5. package/.dsh/skills/iris-use/SKILL.md +41 -0
  6. package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
  7. package/.dsh/skills/iris-use/references/media-contract.md +73 -0
  8. package/.dsh/skills/iris-verify-ui/SKILL.md +9 -7
  9. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +1 -1
  10. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +3 -0
  11. package/CHANGELOG.md +48 -2
  12. package/README.en.md +47 -40
  13. package/README.md +45 -39
  14. package/bin/dsh-iris.js +27 -6
  15. package/docs/AGENT_QUICKSTART.md +9 -9
  16. package/docs/ARCHITECTURE.md +11 -3
  17. package/docs/ARTIFACT_LIBRARY.md +1 -1
  18. package/docs/CLI_MANAGEMENT.md +5 -3
  19. package/docs/COMPOSITE_VISION.md +1 -1
  20. package/docs/CORE_RUNTIME_CONTRACT.md +12 -2
  21. package/docs/DSH_CORE_MIGRATION.md +7 -3
  22. package/docs/FAULT_INJECTION.md +11 -1
  23. package/docs/HEADLESS_CLI.md +10 -4
  24. package/docs/HOST_ADAPTER_CONTRACT.md +1 -1
  25. package/docs/HOST_DOCTOR.md +3 -3
  26. package/docs/MODEL_PORT_CONTRACT.md +9 -3
  27. package/docs/MUSIC.md +77 -0
  28. package/docs/OCR_MODEL.md +1 -1
  29. package/docs/PROMPT_OPTIMIZER.md +74 -6
  30. package/docs/PROVIDER_ADAPTER_CONTRACT.md +12 -2
  31. package/docs/PROVIDER_HEALTH.md +1 -1
  32. package/docs/ROADMAP.md +15 -18
  33. package/docs/SECURITY.md +4 -3
  34. package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
  35. package/docs/TASK_SEMANTICS.md +5 -5
  36. package/docs/VISION_CLI.md +1 -1
  37. package/docs/VISION_MODEL.md +1 -1
  38. package/docs/WORKBENCH_ARTIFACTS.md +1 -1
  39. package/docs/releases/0.2.2.md +31 -0
  40. package/docs/releases/0.2.3.md +54 -0
  41. package/lib/actions.js +52 -19
  42. package/lib/adapters.js +89 -24
  43. package/lib/api.js +14 -5
  44. package/lib/bundled-skills.js +1 -1
  45. package/lib/capability.js +25 -16
  46. package/lib/client.js +374 -43
  47. package/lib/command-service.js +12 -3
  48. package/lib/config.js +39 -6
  49. package/lib/core-artifact-store.js +44 -6
  50. package/lib/core-delivery-cache.js +145 -0
  51. package/lib/core-lease-recovery.js +64 -92
  52. package/lib/core-maintenance.js +12 -1
  53. package/lib/core-runtime.js +45 -47
  54. package/lib/core-tasks.js +19 -5
  55. package/lib/core-user-projection.js +2 -2
  56. package/lib/core-writer-lease.js +134 -0
  57. package/lib/dsh-core-adapter.js +14 -4
  58. package/lib/dsh-text-model-adapter.js +15 -2
  59. package/lib/generation-input.js +10 -0
  60. package/lib/host-runtime.js +4 -2
  61. package/lib/index.js +23 -4
  62. package/lib/models.js +23 -38
  63. package/lib/music-generation.js +21 -0
  64. package/lib/music-options.js +18 -0
  65. package/lib/music-transport.js +46 -0
  66. package/lib/prompt-optimizer-config.js +111 -30
  67. package/lib/prompt-optimizer-core.js +183 -12
  68. package/lib/prompt-optimizer.js +20 -14
  69. package/lib/provider-adapter.js +1 -0
  70. package/lib/provider-adapters.js +26 -2
  71. package/lib/provider-catalog.js +18 -7
  72. package/lib/provider-config-service.js +16 -5
  73. package/lib/provider-protocol.js +73 -0
  74. package/lib/provider-task-runner.js +86 -9
  75. package/package.json +4 -2
  76. package/user_guide.md +52 -24
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 上的模型字段(保持向后兼容的返回形状) */
@@ -20,6 +19,7 @@ export const CAP_FIELD = {
20
19
  [CAPABILITIES.IMAGE]: 'imageModel',
21
20
  [CAPABILITIES.VIDEO]: 'videoModel',
22
21
  [CAPABILITIES.TTS]: 'ttsModel',
22
+ [CAPABILITIES.MUSIC]: 'musicModel',
23
23
  [CAPABILITIES.TRANSCRIBE]: 'transcribeModel',
24
24
  [CAPABILITIES.VISION]: 'visionModel'
25
25
  };
@@ -64,29 +64,7 @@ const MODEL_CAP_RULES = [
64
64
  [/^gemini/i, [CAPABILITIES.VISION]] // Gemini 默认按视觉能力纳入(可被显式配置覆盖)
65
65
  ];
66
66
 
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
- /** 按模型名规则推断能力 */
67
+ /** 模型名建议,仅用于提示;不能直接授权选型或落盘能力。 */
90
68
  export function capabilitiesOfModel(modelName) {
91
69
  const name = String(modelName || '');
92
70
  for (const [re, caps] of MODEL_CAP_RULES) {
@@ -100,17 +78,22 @@ const DISCOVERY_CAPABILITIES = Object.freeze({
100
78
  IG: CAPABILITIES.IMAGE, IMAGE: CAPABILITIES.IMAGE, 'IMAGE-GEN': CAPABILITIES.IMAGE,
101
79
  VG: CAPABILITIES.VIDEO, VIDEO: CAPABILITIES.VIDEO, 'VIDEO-GEN': CAPABILITIES.VIDEO,
102
80
  TTS: CAPABILITIES.TTS, ASR: CAPABILITIES.TRANSCRIBE,
81
+ MUSIC: CAPABILITIES.MUSIC, 'MUSIC-GEN': CAPABILITIES.MUSIC,
103
82
  TRANSCRIBE: CAPABILITIES.TRANSCRIBE, VU: CAPABILITIES.VISION, VISION: CAPABILITIES.VISION
104
83
  });
105
84
 
106
- /** 发现元数据优先,名称规则兜底;用户保存后的显式 capabilities 仍是最终覆盖。 */
85
+ /** 只映射明确发现元数据;名字、描述和未知代码不证明能力。 */
107
86
  export function capabilitiesOfDiscoveredModel(model) {
108
- const id = String(typeof model === 'string' ? model : model?.id || '').trim();
109
- const declared = Array.isArray(model?.capabilities)
87
+ return Array.isArray(model?.capabilities)
110
88
  ? [...new Set(model.capabilities.map((value) => DISCOVERY_CAPABILITIES[String(value).toUpperCase()]).filter(Boolean))]
111
89
  : [];
112
- if (declared.length) return declared;
113
- return capabilitiesOfModel(id);
90
+ }
91
+
92
+ /** 已存的明确配置优先于再发现;未标注名称仍可接收可信元数据。 */
93
+ export function hasConfiguredModelCapabilities(provider, modelId) {
94
+ const entry = provider.models?.find((model) => String(typeof model === 'string' ? model : model?.id || '').trim() === modelId);
95
+ if (Array.isArray(entry?.capabilities)) return entry.source !== 'discovered';
96
+ return configuredModelCapabilities(provider, { id: modelId }).length > 0;
114
97
  }
115
98
 
116
99
  /** 一个 provider 的模型池条目列表:[{ id, providerId, capabilities:[], verified?, source? }] */
@@ -125,23 +108,25 @@ export function providerModels(p) {
125
108
  };
126
109
  // ① 显式 models 数组(带可选 capabilities 覆盖 + verified/source 透传)
127
110
  // 该字段一旦存在(含空数组)即为权威声明:用户可明确标记「此供应商没有可用模型」,
128
- // 不得再落入 ② 旧字段迁移或 ③ 裸账号已知池。空数组曾因 `.length` 判定被穿透,
111
+ // 不得再落入 ② 旧字段绑定。空数组曾因 `.length` 判定被穿透,
129
112
  // 导致用户声明无模型却仍被注入 7 个厂商默认模型(见审计 B-5)。
130
113
  if (Array.isArray(p.models)) {
131
114
  for (const m of p.models) {
132
115
  const id = typeof m === 'string' ? m : m && m.id;
133
- // 元素级空能力数组同样是权威覆盖:用户可明确标记“此模型无能力”。
134
- const caps = Array.isArray(m && m.capabilities) ? m.capabilities : capabilitiesOfModel(id);
116
+ // 没有明确能力的名称保留为未标注模型,不进入候选链。
117
+ const caps = configuredModelCapabilities(p, m);
135
118
  const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source,
136
119
  ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
120
+ ...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
137
121
  ...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}) } : {};
138
122
  push(id, caps, extra);
139
123
  }
140
124
  return out;
141
125
  }
142
126
  // ② 旧模型字段迁移
143
- for (const [cap, field] of Object.entries(CAP_FIELD)) {
144
- if (p[field]) push(p[field], [cap]);
127
+ for (const field of Object.values(CAP_FIELD)) {
128
+ const caps = configuredModelCapabilities(p, p[field]);
129
+ if (caps.length) push(p[field], caps);
145
130
  }
146
131
  return out;
147
132
  }
@@ -185,7 +170,7 @@ export function orderedModels(pool, capability, assignments, { includeRateLimite
185
170
  for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
186
171
  add(resolvePoolModel(pool, raw, capability));
187
172
  }
188
- for (const model of pool) add(model);
173
+ if (capability !== CAPABILITIES.MUSIC) for (const model of pool) add(model);
189
174
  return ordered;
190
175
  }
191
176
 
@@ -0,0 +1,21 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+
3
+ /** Explicit Music 502 regeneration policy; each resubmission is a new Core Task. */
4
+ export async function generateMusicTask({ runner, candidates, providerInput, options, signal }) {
5
+ const taskIds = [];
6
+ let result, retriedFrom;
7
+ for (let index = 0; index <= options.retry502; index++) {
8
+ signal?.throwIfAborted();
9
+ result = await runner.submit({ capability: 'music', candidates, providerInput, ...(retriedFrom ? { retriedFrom } : {}) });
10
+ taskIds.push(result.task.id);
11
+ const task = result.task;
12
+ if (result.localError || task.phase !== 'terminal' || task.lastError?.httpStatus !== 502
13
+ || task.outcome === 'succeeded' || index === options.retry502) break;
14
+ // Retry the actual model, without switching to another (potentially paid) source.
15
+ candidates = candidates.filter(candidate => candidate.model === task.modelRef && candidate.adapter.id === task.providerId);
16
+ if (!candidates.length) throw new Error('Music 重试无法找到原模型绑定');
17
+ retriedFrom = task.id;
18
+ await delay(options.retryDelayMs, undefined, { signal });
19
+ }
20
+ return Object.freeze({ ...result, taskIds: Object.freeze(taskIds), retries: taskIds.length - 1 });
21
+ }
@@ -0,0 +1,18 @@
1
+ /** Music caller controls; generation parameters remain in the prompt. */
2
+ export function normalizeMusicOptions(input = {}) {
3
+ const integer = (field, fallback, min, max) => {
4
+ if (input[field] !== undefined && !['number', 'string'].includes(typeof input[field])) {
5
+ throw new TypeError(`${field} 必须是整数`);
6
+ }
7
+ const value = input[field] === undefined ? fallback : Number(input[field]);
8
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
9
+ throw new TypeError(`${field} 必须是 ${min}–${max} 的整数`);
10
+ }
11
+ return value;
12
+ };
13
+ return Object.freeze({
14
+ timeoutMs: integer('timeout_ms', 300000, 1, 3600000),
15
+ retry502: integer('retry_502', 2, 0, 5),
16
+ retryDelayMs: integer('retry_delay_ms', 5000, 0, 60000)
17
+ });
18
+ }
@@ -0,0 +1,46 @@
1
+ import { ProviderContractError } from './provider-contract.js';
2
+
3
+ function responseError(message) {
4
+ return new ProviderContractError(message, { stage: 'response', category: 'protocol', acceptance: 'unknown' });
5
+ }
6
+
7
+ /** Gemini web gateway's documented non-streaming Markdown audio response. */
8
+ export function musicArtifactsFromResponse(body) {
9
+ if (!Array.isArray(body?.choices)) throw responseError('Music 响应缺少 choices');
10
+ const artifacts = [], seen = new Set();
11
+ for (const choice of body.choices) {
12
+ const content = choice?.message?.content;
13
+ if (typeof content !== 'string') continue;
14
+ for (const match of content.matchAll(/data:(audio\/(?:mpeg|wav|x-wav));base64,([A-Za-z0-9+/=]+)/gi)) {
15
+ const data = match[2];
16
+ const bytes = Buffer.from(data, 'base64');
17
+ if (!bytes.length || bytes.toString('base64') !== data) throw responseError('Music 音频 base64 无效');
18
+ if (seen.has(data)) continue;
19
+ seen.add(data);
20
+ artifacts.push({ kind: 'inline-base64', data, mediaType: match[1].toLowerCase() === 'audio/mpeg' ? 'audio/mpeg' : 'audio/wav' });
21
+ }
22
+ }
23
+ if (!artifacts.length) throw responseError('Music 响应没有可交付的 MP3/WAV 音频');
24
+ return artifacts;
25
+ }
26
+
27
+ export async function generateMusic({ key, baseUrl, model, prompt, timeoutMs = 300000, signal }) {
28
+ const timeout = AbortSignal.timeout(timeoutMs);
29
+ const response = await fetch(String(baseUrl).replace(/\/+$/, '') + '/chat/completions', {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) },
32
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
33
+ body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt }] })
34
+ });
35
+ const text = await response.text();
36
+ let body;
37
+ try { body = JSON.parse(text); }
38
+ catch (error) { if (response.ok) throw responseError('Music 响应不是有效 JSON'); }
39
+ if (!response.ok) {
40
+ const message = String(body?.error?.message || body?.message || `Music HTTP ${response.status}`);
41
+ throw new ProviderContractError(key ? message.replaceAll(key, '[REDACTED]') : message, {
42
+ stage: 'submit', httpStatus: response.status, category: 'provider', acceptance: 'unknown', retryable: response.status === 502
43
+ });
44
+ }
45
+ return musicArtifactsFromResponse(body);
46
+ }
@@ -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'
@@ -108,62 +118,133 @@ export function normalizePromptOptimizerConfig(input) {
108
118
  };
109
119
  }
110
120
 
121
+ /** 只反馈字段名,不携带被忽略的字段值;持久配置仍使用原 v1 规范化。 */
122
+ function importFeedback(input) {
123
+ const ignored = [];
124
+ const collect = (value, prefix, allowed) => {
125
+ if (!value) return;
126
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) ignored.push(prefix + key);
127
+ };
128
+ collect(input, '', ['version', 'enabled', 'systemPrompt', 'targets', 'route', 'generation']);
129
+ collect(input.targets, 'targets.', PROMPT_TARGETS);
130
+ collect(input.route, 'route.', input.route?.mode === 'fixed' ? ['mode', 'provider', 'model'] : ['mode']);
131
+ collect(input.generation, 'generation.', ['temperature', 'reasoningEffort', 'maxOutputTokens', 'timeoutMs']);
132
+ return { warnings: ignored.length ? [{ code: 'ignored-fields', count: ignored.length,
133
+ fields: ignored.slice(0, 32).map(field => field.length > 256 ? field.slice(0, 253) + '...' : field) }] : [] };
134
+ }
135
+
111
136
  let cache = null;
112
137
  let cacheSource = 'default';
138
+ let cacheFeedback = { warnings: [] };
113
139
 
114
- export function loadPromptOptimizerConfig() {
115
- if (cache) return { source: cacheSource, config: structuredClone(cache) };
116
- const file = configFile();
117
- if (!fs.existsSync(file)) {
118
- cache = structuredClone(DEFAULT_VALUE);
119
- cacheSource = 'default';
120
- return { source: cacheSource, config: structuredClone(cache) };
140
+ function snapshot() {
141
+ return { source: cacheSource, config: structuredClone(cache), feedback: structuredClone(cacheFeedback) };
142
+ }
143
+
144
+ function persistConfig(next) {
145
+ const encoded = JSON.stringify(next, null, 2);
146
+ try { atomicWritePrivate(configFile(), encoded); }
147
+ catch (error) {
148
+ // 已知文件系统异常可能带私有路径;编程异常仍原样暴露。
149
+ if (typeof error.code !== 'string' || typeof error.syscall !== 'string') throw error;
150
+ const display = new Error('提示词配置写入失败,请检查存储权限和空间后重新载入配置');
151
+ display.code = error.code;
152
+ throw display;
121
153
  }
122
- try {
123
- cache = normalizePromptOptimizerConfig(JSON.parse(fs.readFileSync(file, 'utf8')));
124
- cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
125
- } catch (err) {
154
+ }
155
+
156
+ function useFallback(file, reason, isolate) {
157
+ let isolation = 'not-attempted';
158
+ if (isolate) {
126
159
  const backup = privateSibling(file, 'corrupted');
127
160
  try {
128
161
  fs.renameSync(file, backup);
162
+ isolation = 'saved';
129
163
  chmodPrivateFile(backup);
130
- } catch (_) { /* 隔离失败也必须能使用内置默认值 */ }
131
- console.error('[iris] prompt-optimizer.json 已损坏,已回退内置默认值:', err && err.message);
164
+ } catch (_) { if (isolation !== 'saved') isolation = 'failed'; }
165
+ }
166
+ cache = structuredClone(DEFAULT_VALUE);
167
+ cacheSource = 'default';
168
+ cacheFeedback = { warnings: [{ code: 'config-fallback', reason, isolation }] };
169
+ // JSON.parse/文件系统异常可能带原稿或绝对路径;只记录受控原因与隔离事实。
170
+ console.error('[iris] prompt-optimizer.json 已回退内置默认值:', reason, isolation);
171
+ return snapshot();
172
+ }
173
+
174
+ export function loadPromptOptimizerConfig() {
175
+ if (cache) return snapshot();
176
+ const file = configFile();
177
+ let raw;
178
+ try { raw = fs.readFileSync(file, 'utf8'); }
179
+ catch (error) {
180
+ if (error.code !== 'ENOENT') return useFallback(file, 'read-failed', false);
132
181
  cache = structuredClone(DEFAULT_VALUE);
133
182
  cacheSource = 'default';
183
+ cacheFeedback = { warnings: [] };
184
+ return snapshot();
134
185
  }
135
- return { source: cacheSource, config: structuredClone(cache) };
186
+ let input;
187
+ try { input = JSON.parse(raw); }
188
+ catch (_) { return useFallback(file, 'json-invalid', true); }
189
+ try {
190
+ cache = normalizePromptOptimizerConfig(input);
191
+ cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
192
+ } catch (_) { return useFallback(file, 'validation-invalid', true); }
193
+ cacheFeedback = importFeedback(input);
194
+ return snapshot();
136
195
  }
137
196
 
138
197
  export function importPromptOptimizerConfig(input) {
139
198
  const next = normalizePromptOptimizerConfig(input);
140
- atomicWritePrivate(configFile(), JSON.stringify(next, null, 2));
199
+ persistConfig(next);
141
200
  cache = next;
142
201
  cacheSource = 'custom';
143
- return { source: cacheSource, config: structuredClone(cache) };
202
+ cacheFeedback = importFeedback(input);
203
+ return snapshot();
144
204
  }
145
205
 
146
206
  export function setPromptOptimizerEnabled(enabled) {
147
207
  if (typeof enabled !== 'boolean') throw new Error('enabled 必须是布尔值');
148
- const next = normalizePromptOptimizerConfig({ ...loadPromptOptimizerConfig().config, enabled });
149
- atomicWritePrivate(configFile(), JSON.stringify(next, null, 2));
208
+ const loaded = loadPromptOptimizerConfig();
209
+ if (loaded.feedback.warnings.some(warning => warning.code === 'config-fallback' && warning.isolation !== 'saved')) {
210
+ throw new Error('原配置尚未隔离保留,不能只更新入口开关;请先导入有效配置或明确恢复全部设置');
211
+ }
212
+ const next = normalizePromptOptimizerConfig({ ...loaded.config, enabled });
213
+ persistConfig(next);
214
+ cache = next;
215
+ cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
216
+ cacheFeedback = { warnings: cacheFeedback.warnings.filter(warning => warning.code === 'config-fallback') };
217
+ return snapshot();
218
+ }
219
+
220
+ /** 只更新指令;无法读取原设置时不得声称保留了路由与预算。 */
221
+ export function resetPromptOptimizerInstructions() {
222
+ const loaded = loadPromptOptimizerConfig();
223
+ if (loaded.feedback.warnings.some(warning => warning.code === 'config-fallback')) {
224
+ throw new Error('当前配置无法读取,不能保证保留模型与生成参数;请先导入有效配置或选择恢复全部设置');
225
+ }
226
+ const next = { ...loaded.config, systemPrompt: DEFAULT_VALUE.systemPrompt, targets: structuredClone(DEFAULT_VALUE.targets) };
227
+ persistConfig(next);
150
228
  cache = next;
151
229
  cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
152
- return { source: cacheSource, config: structuredClone(cache) };
230
+ cacheFeedback = { warnings: [] };
231
+ return snapshot();
153
232
  }
154
233
 
155
234
  export function resetPromptOptimizerConfig() {
156
235
  const next = structuredClone(DEFAULT_VALUE);
157
- atomicWritePrivate(configFile(), JSON.stringify(next, null, 2));
236
+ persistConfig(next);
158
237
  cache = next;
159
238
  cacheSource = 'default';
160
- return { source: cacheSource, config: structuredClone(cache) };
239
+ cacheFeedback = { warnings: [] };
240
+ return snapshot();
161
241
  }
162
242
 
163
243
  /** 仅供测试/热重载丢弃进程内缓存。 */
164
244
  export function resetPromptOptimizerConfigCache() {
165
245
  cache = null;
166
246
  cacheSource = 'default';
247
+ cacheFeedback = { warnings: [] };
167
248
  }
168
249
 
169
250
  export function promptOptimizerConfigFile() {