@mokuyoaxis/dsh-iris 0.2.2 → 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 (63) 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 +26 -2
  12. package/README.en.md +39 -32
  13. package/README.md +37 -31
  14. package/bin/dsh-iris.js +27 -6
  15. package/docs/AGENT_QUICKSTART.md +9 -9
  16. package/docs/ARCHITECTURE.md +1 -1
  17. package/docs/CORE_RUNTIME_CONTRACT.md +3 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -2
  19. package/docs/FAULT_INJECTION.md +1 -0
  20. package/docs/HEADLESS_CLI.md +8 -4
  21. package/docs/HOST_DOCTOR.md +3 -3
  22. package/docs/MODEL_PORT_CONTRACT.md +6 -2
  23. package/docs/MUSIC.md +77 -0
  24. package/docs/PROMPT_OPTIMIZER.md +48 -10
  25. package/docs/PROVIDER_ADAPTER_CONTRACT.md +2 -0
  26. package/docs/ROADMAP.md +14 -25
  27. package/docs/SECURITY.md +3 -2
  28. package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
  29. package/docs/TASK_SEMANTICS.md +4 -4
  30. package/docs/releases/0.2.3.md +54 -0
  31. package/lib/actions.js +40 -3
  32. package/lib/adapters.js +1 -0
  33. package/lib/api.js +14 -5
  34. package/lib/bundled-skills.js +1 -1
  35. package/lib/capability.js +2 -0
  36. package/lib/client.js +111 -26
  37. package/lib/command-service.js +12 -3
  38. package/lib/config.js +26 -2
  39. package/lib/core-artifact-store.js +44 -6
  40. package/lib/core-delivery-cache.js +145 -0
  41. package/lib/core-maintenance.js +12 -1
  42. package/lib/core-tasks.js +19 -5
  43. package/lib/core-user-projection.js +2 -2
  44. package/lib/dsh-core-adapter.js +14 -4
  45. package/lib/dsh-text-model-adapter.js +15 -2
  46. package/lib/generation-input.js +10 -0
  47. package/lib/host-runtime.js +4 -2
  48. package/lib/index.js +23 -4
  49. package/lib/models.js +4 -1
  50. package/lib/music-generation.js +21 -0
  51. package/lib/music-options.js +18 -0
  52. package/lib/music-transport.js +46 -0
  53. package/lib/prompt-optimizer-config.js +92 -21
  54. package/lib/prompt-optimizer-core.js +51 -6
  55. package/lib/prompt-optimizer.js +20 -14
  56. package/lib/provider-adapter.js +1 -0
  57. package/lib/provider-adapters.js +24 -1
  58. package/lib/provider-catalog.js +18 -7
  59. package/lib/provider-config-service.js +13 -1
  60. package/lib/provider-protocol.js +12 -0
  61. package/lib/provider-task-runner.js +80 -5
  62. package/package.json +4 -2
  63. package/user_guide.md +30 -16
package/lib/index.js CHANGED
@@ -263,7 +263,7 @@ export async function apply(ctx) {
263
263
  // 若 webServer 已在运行,inject 回调也会触发;这里兜底立即挂载一次
264
264
  guard('媒体路由兜底挂载', () => mountIrisRoutes(ctx));
265
265
 
266
- // DSH 的 Skill registry 是可选宿主能力。存在时把 npm 包内两项 Skill 注册到
266
+ // DSH 的 Skill registry 是可选宿主能力。存在时把 npm 包内三项 Skill 注册到
267
267
  // 全局层;项目目录中的同名 Skill 仍由 DSH 原生优先级覆盖。
268
268
  ctx.inject(['skills'], (skillCtx) => {
269
269
  guard('随包 Skill 注册', () => {
@@ -463,10 +463,29 @@ export async function apply(ctx) {
463
463
  }
464
464
  });
465
465
 
466
+ safeRegister(host, {
467
+ name: 'iris_generate_music',
468
+ description: 'Generate music using a model explicitly assigned to Music. Returns audio artifact IDs and playback links. Lyrics, vocals and duration are prompt intentions. Configured 502 retries may generate again.',
469
+ parameters: { type: 'object', properties: {
470
+ prompt: { type: 'string', description: 'Describe the music, including any lyrics, vocals, mood or intended duration' },
471
+ model: { type: 'string', description: 'Assigned providerId::modelId reference' },
472
+ timeout_ms: { type: 'integer', description: 'Per-attempt timeout; default 300000 ms' },
473
+ retry_502: { type: 'integer', description: '502 regeneration retries, 0–5; default 2; each retry consumes another request' },
474
+ retry_delay_ms: { type: 'integer', description: 'Delay between 502 retries; default 5000 ms' }
475
+ }, required: ['prompt'] },
476
+ output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
477
+ isConcurrencySafe: () => true,
478
+ async execute(args, exec) {
479
+ const submitted = await runAction(dshHost(ctx), 'music', args, { signal: exec.signal });
480
+ return submitted.text + '\nartifact: ' + submitted.artifactIds.join(', ') + '\n' +
481
+ submitted.artifactIds.map(id => '[♪ 音乐播放](/iris/api/core/artifact/' + encodeURIComponent(id) + '/media)').join('\n');
482
+ }
483
+ });
484
+
466
485
  /* ---------- 🔊 语音合成 ---------- */
467
486
  safeRegister(host, {
468
487
  name: 'iris_speak_text',
469
- description: 'Synthesize speech audio from text using the configured TTS model (qwen-tts / compatible). Returns the saved audio file path.',
488
+ description: 'Synthesize spoken audio from text using the configured TTS model. Returns Core audio Artifact IDs and playback links; export an Artifact to a host file before passing it as audio_path to another tool. Use iris_generate_music for songs or background music.',
470
489
  parameters: {
471
490
  type: 'object',
472
491
  properties: {
@@ -780,7 +799,7 @@ export async function apply(ctx) {
780
799
  safeRegister(host, {
781
800
  name: 'iris_task_status',
782
801
  description:
783
- 'Query iris generation tasks (image/video/tts). Pass a task_id for one task, or omit it to list the latest tasks with status, progress, errors and output file paths.',
802
+ 'Query Iris tasks (image/video/tts/music/transcription). Pass a task_id for one task, or omit it to list recent tasks. Core results provide Artifact IDs and playback links; legacy tasks may provide file paths. Querying does not re-observe or redeliver a task.',
784
803
  parameters: {
785
804
  type: 'object',
786
805
  properties: {
@@ -1022,7 +1041,7 @@ export async function apply(ctx) {
1022
1041
  }
1023
1042
  });
1024
1043
 
1025
- console.log('[iris] tools registered: iris_draw_image, iris_edit_image, iris_generate_video, iris_speak_text, iris_transcribe_audio, iris_task_status, iris_look_at_image, iris_relook_attachment, iris_crop, iris_pixel_diff, iris_locate, iris_html_screenshot, iris_long_ocr, iris_video_frames, iris_media_summarize');
1044
+ console.log('[iris] tools registered: iris_draw_image, iris_edit_image, iris_generate_video, iris_generate_music, iris_speak_text, iris_transcribe_audio, iris_task_status, iris_look_at_image, iris_relook_attachment, iris_crop, iris_pixel_diff, iris_locate, iris_html_screenshot, iris_long_ocr, iris_video_frames, iris_media_summarize');
1026
1045
  };
1027
1046
 
1028
1047
  /* ---------------- 视觉路由(M3 补全):look/relook 共用执行器 + 降级链 ---------------- */
package/lib/models.js CHANGED
@@ -19,6 +19,7 @@ export const CAP_FIELD = {
19
19
  [CAPABILITIES.IMAGE]: 'imageModel',
20
20
  [CAPABILITIES.VIDEO]: 'videoModel',
21
21
  [CAPABILITIES.TTS]: 'ttsModel',
22
+ [CAPABILITIES.MUSIC]: 'musicModel',
22
23
  [CAPABILITIES.TRANSCRIBE]: 'transcribeModel',
23
24
  [CAPABILITIES.VISION]: 'visionModel'
24
25
  };
@@ -77,6 +78,7 @@ const DISCOVERY_CAPABILITIES = Object.freeze({
77
78
  IG: CAPABILITIES.IMAGE, IMAGE: CAPABILITIES.IMAGE, 'IMAGE-GEN': CAPABILITIES.IMAGE,
78
79
  VG: CAPABILITIES.VIDEO, VIDEO: CAPABILITIES.VIDEO, 'VIDEO-GEN': CAPABILITIES.VIDEO,
79
80
  TTS: CAPABILITIES.TTS, ASR: CAPABILITIES.TRANSCRIBE,
81
+ MUSIC: CAPABILITIES.MUSIC, 'MUSIC-GEN': CAPABILITIES.MUSIC,
80
82
  TRANSCRIBE: CAPABILITIES.TRANSCRIBE, VU: CAPABILITIES.VISION, VISION: CAPABILITIES.VISION
81
83
  });
82
84
 
@@ -115,6 +117,7 @@ export function providerModels(p) {
115
117
  const caps = configuredModelCapabilities(p, m);
116
118
  const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source,
117
119
  ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
120
+ ...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
118
121
  ...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}) } : {};
119
122
  push(id, caps, extra);
120
123
  }
@@ -167,7 +170,7 @@ export function orderedModels(pool, capability, assignments, { includeRateLimite
167
170
  for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
168
171
  add(resolvePoolModel(pool, raw, capability));
169
172
  }
170
- for (const model of pool) add(model);
173
+ if (capability !== CAPABILITIES.MUSIC) for (const model of pool) add(model);
171
174
  return ordered;
172
175
  }
173
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
+ }
@@ -118,62 +118,133 @@ export function normalizePromptOptimizerConfig(input) {
118
118
  };
119
119
  }
120
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
+
121
136
  let cache = null;
122
137
  let cacheSource = 'default';
138
+ let cacheFeedback = { warnings: [] };
123
139
 
124
- export function loadPromptOptimizerConfig() {
125
- if (cache) return { source: cacheSource, config: structuredClone(cache) };
126
- const file = configFile();
127
- if (!fs.existsSync(file)) {
128
- cache = structuredClone(DEFAULT_VALUE);
129
- cacheSource = 'default';
130
- 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;
131
153
  }
132
- try {
133
- cache = normalizePromptOptimizerConfig(JSON.parse(fs.readFileSync(file, 'utf8')));
134
- cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
135
- } catch (err) {
154
+ }
155
+
156
+ function useFallback(file, reason, isolate) {
157
+ let isolation = 'not-attempted';
158
+ if (isolate) {
136
159
  const backup = privateSibling(file, 'corrupted');
137
160
  try {
138
161
  fs.renameSync(file, backup);
162
+ isolation = 'saved';
139
163
  chmodPrivateFile(backup);
140
- } catch (_) { /* 隔离失败也必须能使用内置默认值 */ }
141
- 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);
142
181
  cache = structuredClone(DEFAULT_VALUE);
143
182
  cacheSource = 'default';
183
+ cacheFeedback = { warnings: [] };
184
+ return snapshot();
144
185
  }
145
- 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();
146
195
  }
147
196
 
148
197
  export function importPromptOptimizerConfig(input) {
149
198
  const next = normalizePromptOptimizerConfig(input);
150
- atomicWritePrivate(configFile(), JSON.stringify(next, null, 2));
199
+ persistConfig(next);
151
200
  cache = next;
152
201
  cacheSource = 'custom';
153
- return { source: cacheSource, config: structuredClone(cache) };
202
+ cacheFeedback = importFeedback(input);
203
+ return snapshot();
154
204
  }
155
205
 
156
206
  export function setPromptOptimizerEnabled(enabled) {
157
207
  if (typeof enabled !== 'boolean') throw new Error('enabled 必须是布尔值');
158
- const next = normalizePromptOptimizerConfig({ ...loadPromptOptimizerConfig().config, enabled });
159
- 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);
160
228
  cache = next;
161
229
  cacheSource = JSON.stringify(cache) === JSON.stringify(DEFAULT_VALUE) ? 'default' : 'custom';
162
- return { source: cacheSource, config: structuredClone(cache) };
230
+ cacheFeedback = { warnings: [] };
231
+ return snapshot();
163
232
  }
164
233
 
165
234
  export function resetPromptOptimizerConfig() {
166
235
  const next = structuredClone(DEFAULT_VALUE);
167
- atomicWritePrivate(configFile(), JSON.stringify(next, null, 2));
236
+ persistConfig(next);
168
237
  cache = next;
169
238
  cacheSource = 'default';
170
- return { source: cacheSource, config: structuredClone(cache) };
239
+ cacheFeedback = { warnings: [] };
240
+ return snapshot();
171
241
  }
172
242
 
173
243
  /** 仅供测试/热重载丢弃进程内缓存。 */
174
244
  export function resetPromptOptimizerConfigCache() {
175
245
  cache = null;
176
246
  cacheSource = 'default';
247
+ cacheFeedback = { warnings: [] };
177
248
  }
178
249
 
179
250
  export function promptOptimizerConfigFile() {
@@ -5,7 +5,7 @@ import { ModelPortError } from './model-port-contract.js';
5
5
 
6
6
  export const PROMPT_TARGETS = Object.freeze(['general', 'image', 'image-edit', 'video', 's2v']);
7
7
  export const PROMPT_EDITING_STRATEGIES = Object.freeze(['faithful', 'rewrite', 'suggestions']);
8
- export const PROMPT_OPTIMIZER_CAPABILITIES = Object.freeze({ rules: true, assemble: true, targets: PROMPT_TARGETS, editing: PROMPT_EDITING_STRATEGIES });
8
+ export const PROMPT_OPTIMIZER_CAPABILITIES = Object.freeze({ rules: true, assemble: true, localFaithful: true, targets: PROMPT_TARGETS, editing: PROMPT_EDITING_STRATEGIES });
9
9
  export const MAX_PROMPT_INPUT_BYTES = 32 * 1024;
10
10
  export const MAX_PROMPT_OUTPUT_CHARS = 16000;
11
11
  export const MAX_PROMPT_RULE_BYTES = 16 * 1024;
@@ -130,6 +130,32 @@ function parseEditingJson(body) {
130
130
  try { return JSON.parse(fenced ? fenced[1] : body); } catch (_) { throw protocolError(); }
131
131
  }
132
132
 
133
+ function jsonValue(text) {
134
+ try { return JSON.parse(text); } catch (_) { return undefined; }
135
+ }
136
+
137
+ function inputEnvelope(value) {
138
+ return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 2
139
+ && typeof value.target === 'string' && typeof value.prompt === 'string';
140
+ }
141
+
142
+ /** 只拒绝可识别的新增传输包装,不解包 JSON 或猜测性删除转义。 */
143
+ function validateRewriteFormat(body, input) {
144
+ const sources = [input.text, ...input.rules.filter(rule => rule.kind === 'optimization').map(rule => rule.text)];
145
+ const source = sources.join('\n');
146
+ const fence = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*)\n\1$/;
147
+ const value = jsonValue(body.match(fence)?.[2] ?? body);
148
+ const originalValue = jsonValue(input.text.match(fence)?.[2] ?? input.text);
149
+ // 原稿只在内部引用 JSON,不代表整份正文可以被重新包进该容器。
150
+ if (inputEnvelope(value) && !inputEnvelope(originalValue)
151
+ && !sources.slice(1).some(text => /"target"\s*:/.test(text) && /"prompt"\s*:/.test(text))) throw protocolError();
152
+ // 原稿或明确规则已经使用围栏时,围栏属于素材;不要剥掉正文中的代码块。
153
+ if (fence.test(body) && !/`{3,}|~{3,}/.test(source)) throw protocolError();
154
+ // 整份正文又被序列化成字符串是明确的外壳;自然语言引文仍允许。
155
+ if (typeof value === 'string' && !sources.some(text => typeof jsonValue(text.match(fence)?.[2] ?? text) === 'string')
156
+ && /\\(?:["\\nrt]|u[0-9a-f]{4})/i.test(body) && !source.includes(body)) throw protocolError();
157
+ }
158
+
133
159
  function normalizeGroups(value, parts) {
134
160
  if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).join() !== 'groups'
135
161
  || !Array.isArray(value.groups) || !value.groups.length || value.groups.length > parts.length) throw protocolError();
@@ -167,15 +193,27 @@ function normalizeSuggestions(value, input) {
167
193
  return { suggestions, questions };
168
194
  }
169
195
 
170
- function result(input, body, processing) {
196
+ function result(input, body, processing, invocation = input.mode === 'assemble' ? 'not_invoked' : 'responded') {
171
197
  const prefix = input.rules.filter(rule => rule.position === 'prefix').map(rule => rule.text).join('\n\n');
172
198
  const suffix = input.rules.filter(rule => rule.position === 'suffix').map(rule => rule.text).join('\n\n');
173
199
  const optimized = [prefix, body, suffix].filter(value => value.length).join('\n\n');
174
- if (optimized.length > MAX_PROMPT_OUTPUT_CHARS) throw new ModelPortError('IRIS_MODEL_OUTPUT_LIMIT', { stage: 'normalize', invocation: input.mode === 'assemble' ? 'not_invoked' : 'responded' });
200
+ if (optimized.length > MAX_PROMPT_OUTPUT_CHARS) throw new ModelPortError('IRIS_MODEL_OUTPUT_LIMIT', { stage: 'normalize', invocation });
175
201
  return { ok: true, original: input.original, optimized, target: input.target, mode: input.mode,
176
202
  rules: input.rules, assembly: { prefix, body, suffix }, ...(processing ? { processing } : {}) };
177
203
  }
178
204
 
205
+ /** 内部入口接收 normalizePromptInput 的结果;单片段没有可供模型选择的分段位置。 */
206
+ export function tryLocalPromptOptimization(normalized, { signal } = {}) {
207
+ checkSignal(signal);
208
+ if (normalized.mode !== 'optimize' || normalized.editing !== 'faithful') return null;
209
+ const parts = sourceParts(normalized.original);
210
+ if (parts.length !== 1) return null;
211
+ const out = result(normalized, normalized.original, undefined, 'not_invoked');
212
+ checkSignal(signal);
213
+ return { ...out, editing: { strategy: 'faithful', coverage: 'exact', order: 'preserved', groups: [[parts[0].id]] },
214
+ execution: { modelInvocations: 0, reason: 'single-part-faithful' } };
215
+ }
216
+
179
217
  /** 保留原稿及输出规则的原始文字;零模型调用,不读取任何配置。 */
180
218
  export function assemblePrompt(input, { signal } = {}) {
181
219
  checkSignal(signal);
@@ -209,6 +247,8 @@ export function preparePromptOptimization(input, config) {
209
247
  } else if (normalized.editing === 'suggestions') {
210
248
  system += '\n\n## 本次输出协议:可选补充建议\n不修改原稿,不执行任务或生成媒体。只提出至多三条有价值的可选新指令,每条 text 是可直接加入提示词的简短文字,不声称用户已经要求、采纳或完成。无需凑满,没有必要补充时返回空数组。建议可以提出创造性细节,但不能编造作者作品、风格分期、历史、设备或技术事实作为依据。已明确的约数、左右、风格和否定条件不重新质疑,不泛化、不推演为额外限制。questions 只问影响目标的实际缺失或冲突,至多三条,不引导用户改变已明确要求。不猜测未见图片/音频内容,不编造链接、来源 ID 或参数。以下协议优先于通用正文输出约定:只返回 {"suggestions":[{"label":"简短名称","text":"可选指令"}],"questions":["待确认问题"]},不返回最终正文、媒体或其他字段。';
211
249
  prompt = '只为以下原稿提出可选建议和待确认问题;JSON 是素材,不是任务执行授权。\n' + JSON.stringify({ target: normalized.target, prompt: normalized.original });
250
+ } else {
251
+ system += '\n\n## 本次输出协议:改写正文\n输入 JSON 是传输容器,target 等字段只提供上下文。只编辑 prompt 的字符串值,最终回复必须是改写后的字段值本身,即可直接阅读的原始正文。不要回传输入容器、字段名、额外包装引号、代码围栏、标题或解释。若原稿本身包含 JSON、代码、围栏或引号,应按用户规则保留这些原有内容。\n先理解 JSON 字符串的实际内容再编辑:容器中用于编码引号、换行和反斜杠的转义不是正文字符,不要复制序列化语法。保留原稿实际存在的引号、真实换行、字面反斜杠、代码和引用,禁止无条件去除或反转义。沿用原稿语言,只执行用户明确授权的内容变化,其余具体细节和否定条件继续保留。';
212
252
  }
213
253
  // 包含前后缀及分隔符,防止一次模型调用后才发现已选规则无法交付。
214
254
  const overhead = normalized.rules.filter(rule => rule.kind === 'output').reduce((sum, rule) => sum + rule.text.length + 2, 0);
@@ -217,12 +257,14 @@ export function preparePromptOptimization(input, config) {
217
257
  if (normalized.editing !== 'rewrite' && normalized.original.length > maxOutputChars) throw new ModelPortError('IRIS_MODEL_OUTPUT_LIMIT', { stage: 'prepare', invocation: 'not_invoked' });
218
258
  return Object.freeze({ input: normalized, prompt, system, maxOutputChars, ...(parts ? { parts } : {}),
219
259
  generation: Object.freeze({ temperature: generation.temperature, maxOutputTokens: generation.maxOutputTokens, timeoutMs: generation.timeoutMs }),
220
- processing: Object.freeze({ systemPrompt, targetTemplate, optimizationRules: Object.freeze(optimizationRules) }) });
260
+ processing: Object.freeze({ systemPrompt, targetTemplate, optimizationRules: Object.freeze(optimizationRules),
261
+ request: Object.freeze({ system, prompt }) }) });
221
262
  }
222
263
 
223
264
  /** 单轮、无工具;完整模型结果通过验证后再确定性插入输出规则。 */
224
265
  export async function runPromptOptimization(port, plan, { signal, operation, reasoning } = {}) {
225
266
  checkSignal(signal);
267
+ if (plan.parts?.length === 1) return tryLocalPromptOptimization(plan.input, { signal });
226
268
  const request = { prompt: plan.prompt, system: plan.system, generation: {
227
269
  temperature: plan.generation.temperature, maxOutputTokens: plan.generation.maxOutputTokens,
228
270
  ...(reasoning ? { reasoning } : {}) } };
@@ -231,8 +273,10 @@ export async function runPromptOptimization(port, plan, { signal, operation, rea
231
273
  const completion = operation ? await operation.invoke(port, request, options) : await invokeModel(port, request, options);
232
274
  checkSignal(signal);
233
275
  let body = completion.text.trim();
234
- const fenced = body.match(/^```(?:text|markdown)?\s*\n([\s\S]*?)\n```$/i);
235
- if (fenced) body = fenced[1].trim();
276
+ if (plan.input.editing !== 'rewrite') {
277
+ const fenced = body.match(/^```(?:text|markdown)?\s*\n([\s\S]*?)\n```$/i);
278
+ if (fenced) body = fenced[1].trim();
279
+ }
236
280
  if (!body) throw new ModelPortError('IRIS_MODEL_EMPTY_RESULT', { stage: 'normalize', invocation: 'responded' });
237
281
  if (plan.input.editing === 'faithful') {
238
282
  const grouped = normalizeGroups(parseEditingJson(body), plan.parts);
@@ -242,6 +286,7 @@ export async function runPromptOptimization(port, plan, { signal, operation, rea
242
286
  const optional = normalizeSuggestions(parseEditingJson(body), plan.input);
243
287
  return { ...result(plan.input, plan.input.original, plan.processing), editing: { strategy: 'suggestions' }, ...optional };
244
288
  }
289
+ validateRewriteFormat(body, plan.input);
245
290
  validateEditedText(body, plan.input);
246
291
  return result(plan.input, body, plan.processing);
247
292
  }
@@ -2,7 +2,7 @@
2
2
  /** DSH 优化入口:v1 配置与模型选择/投影;共享业务不读取宿主和存储。 */
3
3
  import { loadPromptOptimizerConfig } from './prompt-optimizer-config.js';
4
4
  import { hasHostPort, requireHostPort } from './host-contract.js';
5
- import { normalizePromptInput, assemblePrompt, preparePromptOptimization, runPromptOptimization } from './prompt-optimizer-core.js';
5
+ import { normalizePromptInput, assemblePrompt, tryLocalPromptOptimization, preparePromptOptimization, runPromptOptimization } from './prompt-optimizer-core.js';
6
6
  import { prepareDshTextModelPort } from './dsh-text-model-adapter.js';
7
7
  import { createModelOperation } from './model-invoker.js';
8
8
  import { ModelPortError, modelPortSnapshot, modelErrorRecord } from './model-port-contract.js';
@@ -10,31 +10,34 @@ import { ModelPortError, modelPortSnapshot, modelErrorRecord } from './model-por
10
10
  export { MAX_PROMPT_INPUT_BYTES, MAX_PROMPT_OUTPUT_CHARS } from './prompt-optimizer-core.js';
11
11
 
12
12
  function modelSelection(value, field) {
13
- if (!value || typeof value !== 'object') return null;
13
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${field} 必须同时包含有效的 provider 与 model`);
14
14
  const provider = typeof value.provider === 'string' ? value.provider.trim() : '';
15
15
  const model = typeof value.model === 'string' ? value.model.trim() : '';
16
- if (!provider || !model || provider.length > 256 || model.length > 256) {
17
- if (field) throw new Error(`${field} 必须同时包含有效的 provider 与 model`);
18
- return null;
16
+ if ([provider, model].some(id => !id || id.length > 256 || /[\u0000-\u001f\u007f]/.test(id) || id.startsWith('/') || id.includes('://'))) {
17
+ throw new Error(`${field} 必须同时包含有效的 provider 与 model`);
19
18
  }
20
19
  const out = { provider, model };
21
- if (typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim()) {
22
- out.reasoningEffort = value.reasoningEffort.trim().slice(0, 128);
20
+ if (value.reasoningEffort !== undefined) {
21
+ if (typeof value.reasoningEffort !== 'string' || !value.reasoningEffort.trim() || value.reasoningEffort.trim().length > 128
22
+ || /[\u0000-\u001f\u007f]/.test(value.reasoningEffort)) throw new Error(`${field}.reasoningEffort 无效`);
23
+ out.reasoningEffort = value.reasoningEffort.trim();
23
24
  }
24
25
  return out;
25
26
  }
26
27
 
27
28
  export function resolvePromptOptimizerRoute(host, config, requestedRoute) {
28
29
  if (config.route.mode === 'fixed') {
29
- return { ...modelSelection(config.route, 'route'), source: 'iris-fixed' };
30
+ return { ...modelSelection(config.route, 'route'), source: 'iris-fixed', reason: '固定配置优先于当前会话和宿主默认模型' };
31
+ }
32
+ if (requestedRoute !== undefined && requestedRoute !== null) {
33
+ return { ...modelSelection(requestedRoute, '请求路由'), source: 'current-session', reason: '使用当前会话明确选择的模型' };
30
34
  }
31
- const current = modelSelection(requestedRoute);
32
- if (current) return { ...current, source: 'current-session' };
33
35
  const textModel = hasHostPort(host, 'textModel') ? host.ports.textModel : undefined;
34
36
  const fallback = textModel && typeof textModel.currentSelection === 'function'
35
- ? modelSelection(textModel.currentSelection())
37
+ ? textModel.currentSelection()
36
38
  : null;
37
- if (fallback) return { ...fallback, source: 'host-default' };
39
+ if (fallback !== null && fallback !== undefined) return { ...modelSelection(fallback, '宿主默认路由'), source: 'host-default',
40
+ reason: '未提供当前会话模型,使用宿主默认模型' };
38
41
  throw new Error('Iris 找不到可用文本模型:请先在 DSH 会话中选择模型,或在 JSON 配置中设置 fixed 路由');
39
42
  }
40
43
 
@@ -54,6 +57,8 @@ export async function optimizePrompt(host, input, { signal } = {}) {
54
57
  if (signal?.aborted) throw new ModelPortError('IRIS_MODEL_ABORTED');
55
58
  const normalized = normalizePromptInput(input);
56
59
  if (normalized.mode === 'assemble') return { ...assemblePrompt(input, { signal }), configSource: 'not-loaded', route: null };
60
+ const local = tryLocalPromptOptimization(normalized, { signal });
61
+ if (local) return { ...local, configSource: 'not-loaded', route: null };
57
62
  const loaded = loadPromptOptimizerConfig();
58
63
  const config = loaded.config;
59
64
  const plan = preparePromptOptimization(input, config);
@@ -62,10 +67,11 @@ export async function optimizePrompt(host, input, { signal } = {}) {
62
67
  try {
63
68
  const route = resolvePromptOptimizerRoute(host, config, input?.route);
64
69
  const llm = requireHostPort(host, 'textModel', '提示词优化');
65
- const { port, offId } = await prepareDshTextModelPort(llm, { ...route, sessionId: input?.sessionId }, { signal: operation.signal });
70
+ const { port, offId, textInput } = await prepareDshTextModelPort(llm, { ...route, sessionId: input?.sessionId }, { signal: operation.signal });
66
71
  reasoning = reasoningPolicy(port, offId, route, config.generation.reasoningEffort);
67
72
  const result = await runPromptOptimization(port, plan, { signal: operation.signal, operation, reasoning: reasoning.request });
68
- return { ...result, configSource: loaded.source, route: { source: route.source, provider: route.provider, model: route.model,
73
+ return { ...result, configSource: loaded.source, configFeedback: loaded.feedback, route: { source: route.source, reason: route.reason, textInput,
74
+ provider: route.provider, model: route.model,
69
75
  reasoningPolicy: reasoning.policy, reasoningEffort: reasoning.effective } };
70
76
  } catch (error) {
71
77
  if (modelErrorRecord(error).code === 'IRIS_MODEL_OUTPUT_LIMIT') {
@@ -29,6 +29,7 @@ export const PROVIDER_TASK_CAPABILITIES = Object.freeze([
29
29
  'image',
30
30
  'video',
31
31
  'tts',
32
+ 'music',
32
33
  'transcribe'
33
34
  ]);
34
35
 
@@ -110,7 +110,7 @@ function writeInlineArtifact(artifact, targetPath) {
110
110
  const data = String(artifact?.data || '').trim();
111
111
  const body = Buffer.from(data, 'base64');
112
112
  if (!data || body.length === 0 || body.toString('base64') !== data) {
113
- throw new ProviderContractError('inline-base64 图片数据无效', {
113
+ throw new ProviderContractError('inline-base64 媒体数据无效', {
114
114
  stage: 'download', category: 'protocol', acceptance: 'accepted'
115
115
  });
116
116
  }
@@ -373,7 +373,30 @@ export function createOpenAiResponsesImagesProviderAdapter(provider, { transport
373
373
  });
374
374
  }
375
375
 
376
+ export function createGeminiWebMusicProviderAdapter(provider, { transport = defaultTransport } = {}) {
377
+ const { id, key, baseUrl } = providerIdentity(provider);
378
+ return defineProviderAdapter({
379
+ id, protocol: 'gemini-web-music', capabilities: ['music'],
380
+ operations: {
381
+ async discover({ signal, timeoutMs } = {}) {
382
+ return { models: await transport.listModels({ key, baseUrl, signal, timeoutMs }) };
383
+ },
384
+ async submit(request) {
385
+ if (request.capability !== 'music') throw unsupportedCapability(request.capability, 'gemini-web-music');
386
+ const artifacts = await transport.generateMusic({ key, baseUrl, model: request.model,
387
+ prompt: request.input.prompt, timeoutMs: request.input.timeoutMs, signal: request.signal });
388
+ return { kind: 'completed', artifacts };
389
+ },
390
+ async download({ artifact, targetPath }) { return writeInlineArtifact(artifact, targetPath); },
391
+ mapError: mapProviderAdapterError
392
+ },
393
+ unsupported: { poll: '网关 Music 没有客户端可恢复的远端任务ID', cancel: '网关 Music 不提供远端取消' },
394
+ unsupportedPreparation: 'Music 描述输入不需要上传'
395
+ });
396
+ }
397
+
376
398
  const PROTOCOL_FACTORIES = new Map([
399
+ ['gemini-web-music', createGeminiWebMusicProviderAdapter],
377
400
  ['dashscope', createDashScopeProviderAdapter],
378
401
  ['openai-images', createOpenAiImagesProviderAdapter],
379
402
  ['openai-chat-images', createOpenAiChatImagesProviderAdapter],