@co0ontty/wand 4.85.1 → 4.87.0-beta.gd2f0f8f

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 (108) hide show
  1. package/README.md +4 -0
  2. package/dist/agent-dispatch.js +5 -3
  3. package/dist/ai-team-runner.js +1 -0
  4. package/dist/ai-team-types.d.ts +1 -0
  5. package/dist/ai-team-types.js +6 -2
  6. package/dist/build-info.json +4 -4
  7. package/dist/cli.js +12 -6
  8. package/dist/config.d.ts +1 -1
  9. package/dist/config.js +33 -0
  10. package/dist/core-runner.js +1 -1
  11. package/dist/daemon-maintenance-state.d.ts +6 -0
  12. package/dist/daemon-maintenance-state.js +9 -0
  13. package/dist/daemon-maintenance-targets.d.ts +20 -0
  14. package/dist/daemon-maintenance-targets.js +183 -0
  15. package/dist/daemon-maintenance.d.ts +53 -0
  16. package/dist/daemon-maintenance.js +164 -0
  17. package/dist/decision-expert-employee.d.ts +6 -0
  18. package/dist/decision-expert-employee.js +22 -0
  19. package/dist/decision-expert-identity.d.ts +7 -0
  20. package/dist/decision-expert-identity.js +7 -0
  21. package/dist/decision-hardware.d.ts +19 -0
  22. package/dist/decision-hardware.js +19 -0
  23. package/dist/decision-service.d.ts +6 -1
  24. package/dist/decision-service.js +60 -1
  25. package/dist/decision-types.d.ts +1 -0
  26. package/dist/decision-types.js +12 -0
  27. package/dist/git-quick-commit.js +1 -1
  28. package/dist/laya-model-manifest.d.ts +11 -0
  29. package/dist/laya-model-manifest.js +18 -0
  30. package/dist/local-model-setup.d.ts +46 -0
  31. package/dist/local-model-setup.js +248 -0
  32. package/dist/local-model-types.d.ts +34 -0
  33. package/dist/local-model-types.js +1 -0
  34. package/dist/message-truncator.d.ts +4 -2
  35. package/dist/message-truncator.js +20 -6
  36. package/dist/missions.js +6 -3
  37. package/dist/model-file-store.d.ts +13 -0
  38. package/dist/model-file-store.js +108 -0
  39. package/dist/model-groups.js +2 -2
  40. package/dist/model-setup-process.d.ts +2 -0
  41. package/dist/model-setup-process.js +80 -0
  42. package/dist/models.js +3 -3
  43. package/dist/process-manager.js +21 -8
  44. package/dist/provider-catalog.d.ts +13 -1
  45. package/dist/provider-catalog.js +14 -2
  46. package/dist/render-binary.d.ts +1 -1
  47. package/dist/render-binary.js +4 -4
  48. package/dist/render-daemon-client.d.ts +12 -1
  49. package/dist/render-daemon-client.js +72 -4
  50. package/dist/render-host.d.ts +1 -0
  51. package/dist/render-host.js +9 -56
  52. package/dist/render-structured-host.d.ts +1 -0
  53. package/dist/render-structured-host.js +2 -2
  54. package/dist/server-employee-routes.js +3 -3
  55. package/dist/server-local-model-routes.d.ts +8 -0
  56. package/dist/server-local-model-routes.js +62 -0
  57. package/dist/server-resume-routes.js +1 -1
  58. package/dist/server-session-routes.js +34 -8
  59. package/dist/server-settings-routes.js +2 -0
  60. package/dist/server-speech-routes.d.ts +8 -0
  61. package/dist/server-speech-routes.js +87 -0
  62. package/dist/server-task-routes.js +3 -1
  63. package/dist/server-workspace-routes.js +2 -0
  64. package/dist/server.js +63 -18
  65. package/dist/session-completion-state.d.ts +1 -0
  66. package/dist/session-completion-state.js +1 -0
  67. package/dist/session-transport.js +1 -0
  68. package/dist/silicon-employee-dispatch.js +5 -2
  69. package/dist/silicon-employee-draft.js +1 -1
  70. package/dist/speech-models.d.ts +24 -0
  71. package/dist/speech-models.js +16 -0
  72. package/dist/speech-service.d.ts +57 -0
  73. package/dist/speech-service.js +399 -0
  74. package/dist/speech-types.d.ts +47 -0
  75. package/dist/speech-types.js +3 -0
  76. package/dist/storage.d.ts +2 -0
  77. package/dist/storage.js +13 -0
  78. package/dist/structured-failure.js +1 -1
  79. package/dist/structured-session-manager.d.ts +15 -2
  80. package/dist/structured-session-manager.js +58 -19
  81. package/dist/terminal-daemon-build.d.ts +4 -0
  82. package/dist/terminal-daemon-build.js +54 -0
  83. package/dist/terminal-daemon-protocol.d.ts +1 -1
  84. package/dist/terminal-daemon-server.js +20 -1
  85. package/dist/terminal-host.d.ts +4 -0
  86. package/dist/terminal-host.js +7 -4
  87. package/dist/tui/commands.js +1 -1
  88. package/dist/types.d.ts +14 -2
  89. package/dist/web-ui/chat-history-window.d.ts +23 -0
  90. package/dist/web-ui/chat-history-window.js +29 -0
  91. package/dist/web-ui/content/ai-teams.js +12 -12
  92. package/dist/web-ui/content/scripts.js +316 -233
  93. package/dist/web-ui/content/styles.css +1 -1
  94. package/dist/web-ui/content/tailwind.css +1 -1
  95. package/dist/web-ui/embedded-assets.js +3 -3
  96. package/dist/web-ui/index.js +3 -3
  97. package/dist/web-ui/provider-identity.d.ts +28 -1
  98. package/dist/web-ui/provider-identity.js +31 -2
  99. package/dist/web-ui/session-activity.d.ts +1 -0
  100. package/dist/web-ui/session-activity.js +5 -2
  101. package/dist/web-ui/speech-audio.d.ts +2 -0
  102. package/dist/web-ui/speech-audio.js +41 -0
  103. package/dist/web-ui/vendor-loader.d.ts +2 -0
  104. package/dist/web-ui/vendor-loader.js +50 -0
  105. package/dist/ws-broadcast.js +10 -4
  106. package/package.json +7 -3
  107. package/scripts/install-laya-runtime.js +67 -0
  108. package/scripts/install-speech-runtime.js +82 -0
@@ -0,0 +1,399 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { constants, createReadStream } from "node:fs";
4
+ import { access, mkdir, mkdtemp, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { buildChildEnv, systemEnvValue } from "./env-utils.js";
8
+ import { SPEECH_MODELS, speechModel, speechModelUrl } from "./speech-models.js";
9
+ import { SPEECH_MAX_SECONDS, SPEECH_MAX_WAV_BYTES, SPEECH_SAMPLE_RATE } from "./speech-types.js";
10
+ export const SPEECH_SETTINGS_KEY = "pref:speechRecognition";
11
+ export class SpeechError extends Error {
12
+ code;
13
+ status;
14
+ constructor(code, message, status = 400) {
15
+ super(message);
16
+ this.code = code;
17
+ this.status = status;
18
+ }
19
+ }
20
+ export function defaultSpeechSettings() {
21
+ return { enabled: false, model: "base", acceleration: "auto", language: "auto", threads: Math.max(1, Math.min(4, os.availableParallelism() - 1)) };
22
+ }
23
+ export function parseSpeechSettings(value, previous = defaultSpeechSettings()) {
24
+ if (!value || typeof value !== "object" || Array.isArray(value))
25
+ throw new SpeechError("INVALID_SETTINGS", "语音配置无效。");
26
+ const patch = value;
27
+ if (Object.keys(patch).some((key) => !["enabled", "model", "acceleration", "language", "threads"].includes(key))) {
28
+ throw new SpeechError("INVALID_SETTINGS", "不支持的语音配置项。");
29
+ }
30
+ const settings = { ...previous, ...patch };
31
+ if (typeof settings.enabled !== "boolean" || !speechModel(settings.model)
32
+ || !["auto", "cpu", "gpu"].includes(settings.acceleration) || !["auto", "zh", "en"].includes(settings.language)
33
+ || !Number.isInteger(settings.threads) || settings.threads < 1 || settings.threads > 16) {
34
+ throw new SpeechError("INVALID_SETTINGS", "请选择有效模型、运行设备、语言与 1–16 个 CPU 线程。");
35
+ }
36
+ return settings;
37
+ }
38
+ /** Strict canonical WAV; all clients encode this exact shape. No decoder/ffmpeg needed on the host. */
39
+ export function validateSpeechWav(audio) {
40
+ if (audio.length < 44 + 3200 || audio.length > SPEECH_MAX_WAV_BYTES
41
+ || audio.toString("ascii", 0, 4) !== "RIFF" || audio.readUInt32LE(4) !== audio.length - 8
42
+ || audio.toString("ascii", 8, 12) !== "WAVE" || audio.toString("ascii", 12, 16) !== "fmt "
43
+ || audio.readUInt32LE(16) !== 16 || audio.readUInt16LE(20) !== 1 || audio.readUInt16LE(22) !== 1
44
+ || audio.readUInt32LE(24) !== SPEECH_SAMPLE_RATE || audio.readUInt32LE(28) !== SPEECH_SAMPLE_RATE * 2
45
+ || audio.readUInt16LE(32) !== 2 || audio.readUInt16LE(34) !== 16
46
+ || audio.toString("ascii", 36, 40) !== "data" || audio.readUInt32LE(40) !== audio.length - 44
47
+ || (audio.length - 44) % 2 !== 0) {
48
+ throw new SpeechError("INVALID_AUDIO", "请上传 0.1–60 秒、16 kHz 单声道 PCM16 WAV 音频。");
49
+ }
50
+ let peak = 0;
51
+ for (let offset = 44; offset < audio.length; offset += 2)
52
+ peak = Math.max(peak, Math.abs(audio.readInt16LE(offset)));
53
+ return { samples: (audio.length - 44) / 2, silent: peak < 160 };
54
+ }
55
+ async function fileHash(file) {
56
+ const hash = createHash("sha256");
57
+ for await (const chunk of createReadStream(file))
58
+ hash.update(chunk);
59
+ return hash.digest("hex");
60
+ }
61
+ /** A single bounded job per host avoids CPU/RAM amplification. Audio is temporary, never history. */
62
+ export class SpeechService {
63
+ storage;
64
+ options;
65
+ root;
66
+ models;
67
+ verified = new Map();
68
+ verifying = new Map();
69
+ download = null;
70
+ downloadAbort = null;
71
+ active = null;
72
+ disposed = false;
73
+ maintenance = false;
74
+ initialized = new Set();
75
+ constructor(storage, configDir, options = {}) {
76
+ this.storage = storage;
77
+ this.options = options;
78
+ this.root = path.join(configDir, "speech");
79
+ this.models = options.models ?? SPEECH_MODELS;
80
+ }
81
+ settings() {
82
+ try {
83
+ return parseSpeechSettings(this.storage.getPreference(SPEECH_SETTINGS_KEY, {}));
84
+ }
85
+ catch {
86
+ return defaultSpeechSettings();
87
+ }
88
+ }
89
+ configure(patch) {
90
+ const settings = parseSpeechSettings(patch, this.settings());
91
+ this.storage.setPreference(SPEECH_SETTINGS_KEY, settings);
92
+ return settings;
93
+ }
94
+ acquireMaintenance() {
95
+ if (this.disposed)
96
+ throw new SpeechError("UNAVAILABLE", "语音服务已关闭。", 503);
97
+ if (this.active || this.downloadAbort || this.maintenance)
98
+ throw new SpeechError("BUSY", "语音识别或模型下载正在进行,请稍后初始化。", 409);
99
+ this.maintenance = true;
100
+ }
101
+ releaseMaintenance() { this.maintenance = false; }
102
+ /** Explicit admin health check loads the selected model using synthetic silence, without enabling it. */
103
+ async initializeModel(id, signal) {
104
+ const model = this.models.find((value) => value.id === id);
105
+ if (!model)
106
+ throw new SpeechError("INVALID_MODEL", "模型不存在。");
107
+ const runtime = await this.runtime();
108
+ if (!runtime.executable || !await this.modelReady(model))
109
+ throw new SpeechError("NOT_READY", "请先下载模型并安装语音运行时。", 503);
110
+ signal.throwIfAborted();
111
+ await mkdir(path.join(this.root, "tmp"), { recursive: true, mode: 0o700 });
112
+ const directory = await mkdtemp(path.join(this.root, "tmp", "check-"));
113
+ try {
114
+ const wav = Buffer.alloc(8044);
115
+ wav.write("RIFF");
116
+ wav.writeUInt32LE(wav.length - 8, 4);
117
+ wav.write("WAVEfmt ", 8);
118
+ wav.writeUInt32LE(16, 16);
119
+ wav.writeUInt16LE(1, 20);
120
+ wav.writeUInt16LE(1, 22);
121
+ wav.writeUInt32LE(16000, 24);
122
+ wav.writeUInt32LE(32000, 28);
123
+ wav.writeUInt16LE(2, 32);
124
+ wav.writeUInt16LE(16, 34);
125
+ wav.write("data", 36);
126
+ wav.writeUInt32LE(8000, 40);
127
+ const input = path.join(directory, "check.wav"), output = path.join(directory, "check");
128
+ await writeFile(input, wav, { mode: 0o600 });
129
+ await this.run(runtime.executable, ["-m", this.modelPath(model), "-f", input, "-of", output, "-otxt", "-nt", "-l", "en", "-t", String(this.settings().threads),
130
+ ...(this.settings().acceleration === "cpu" || runtime.backend === "cpu" ? ["-ng"] : [])], directory, signal, this.options.timeoutMs ?? 120_000);
131
+ signal.throwIfAborted();
132
+ if (this.disposed)
133
+ throw new SpeechError("UNAVAILABLE", "语音服务已关闭。", 503);
134
+ this.initialized.add(id);
135
+ }
136
+ finally {
137
+ await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
138
+ }
139
+ }
140
+ modelPath(model) { return path.join(this.root, "models", `ggml-${model.id}.bin`); }
141
+ async modelReady(model) {
142
+ if (this.verifying.has(model.id))
143
+ return this.verifying.get(model.id);
144
+ const promise = this.verifyModel(model);
145
+ this.verifying.set(model.id, promise);
146
+ try {
147
+ return await promise;
148
+ }
149
+ finally {
150
+ this.verifying.delete(model.id);
151
+ }
152
+ }
153
+ async verifyModel(model) {
154
+ try {
155
+ const file = this.modelPath(model);
156
+ const info = await stat(file);
157
+ if (!info.isFile() || info.size !== model.size)
158
+ return false;
159
+ const key = `${info.size}:${info.mtimeMs}:${info.ctimeMs}`;
160
+ if (this.verified.get(model.id) === key)
161
+ return true;
162
+ if (await fileHash(file) !== model.sha256)
163
+ return false;
164
+ this.verified.set(model.id, key);
165
+ return true;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }
171
+ async runtime() {
172
+ let backend = this.options.backend ?? systemEnvValue("WAND_WHISPER_BACKEND");
173
+ if (!backend) {
174
+ try {
175
+ backend = JSON.parse(await readFile(path.join(this.root, "runtime.json"), "utf8")).backend;
176
+ }
177
+ catch { }
178
+ }
179
+ if (!["cpu", "metal", "cuda"].includes(backend ?? ""))
180
+ backend = process.platform === "darwin" ? "metal" : "cpu";
181
+ const name = process.platform === "win32" ? "whisper-cli.exe" : "whisper-cli";
182
+ const explicit = this.options.executable ?? systemEnvValue("WAND_WHISPER_BIN");
183
+ // Explicit bad paths must not silently run another binary. No shell, no request-provided command.
184
+ const candidates = explicit ? [explicit] : [path.join(this.root, "bin", name),
185
+ ...(buildChildEnv(true).PATH ?? "").split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, name))];
186
+ for (const candidate of candidates) {
187
+ if (!path.isAbsolute(candidate))
188
+ continue;
189
+ try {
190
+ await access(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK);
191
+ if ((await stat(candidate)).isFile())
192
+ return { executable: candidate, backend: backend };
193
+ }
194
+ catch { }
195
+ }
196
+ return { executable: null, backend: backend };
197
+ }
198
+ async status() {
199
+ const settings = this.settings();
200
+ const runtime = await this.runtime();
201
+ const models = await Promise.all(this.models.map(async (model) => ({ id: model.id, label: model.label,
202
+ description: model.description, size: model.size, downloaded: await this.modelReady(model) })));
203
+ const reason = this.disposed ? "语音服务已关闭。" : this.maintenance ? "语音运行时正在初始化,请稍后重试。" : !settings.enabled ? "服务端识别未启用,请在管理设置中启用。"
204
+ : !runtime.executable ? "服务端尚未安装 whisper.cpp,请在服务器运行语音运行时安装脚本。"
205
+ : !models.find((model) => model.id === settings.model)?.downloaded ? "服务端模型尚未下载或校验失败,请在管理设置中下载。"
206
+ : settings.acceleration === "gpu" && runtime.backend === "cpu" ? "当前运行时是 CPU 版,请改为自动 / CPU 或安装 GPU 版。" : null;
207
+ return { settings, ready: !reason, reason, runtime: { available: !!runtime.executable, backend: runtime.backend,
208
+ platform: process.platform, arch: process.arch }, models, download: this.download ? { ...this.download } : null,
209
+ maxDurationSeconds: SPEECH_MAX_SECONDS, busy: !!this.active || this.maintenance, initialized: this.initialized.has(settings.model) };
210
+ }
211
+ cancelDownload() { this.downloadAbort?.abort(); }
212
+ async startDownload(id) {
213
+ const model = this.models.find((item) => item.id === id);
214
+ if (!model)
215
+ throw new SpeechError("INVALID_MODEL", "模型不存在。");
216
+ if (this.disposed)
217
+ throw new SpeechError("UNAVAILABLE", "语音服务已关闭。", 503);
218
+ if (this.maintenance)
219
+ throw new SpeechError("BUSY", "语音运行时正在初始化,请稍后下载。", 409);
220
+ if (this.downloadAbort)
221
+ throw new SpeechError("DOWNLOAD_BUSY", "已有模型正在下载。", 409);
222
+ if (await this.modelReady(model))
223
+ return;
224
+ // Recheck after asynchronous verification, before reserving the only download slot.
225
+ if (this.disposed)
226
+ throw new SpeechError("UNAVAILABLE", "语音服务已关闭。", 503);
227
+ if (this.downloadAbort)
228
+ throw new SpeechError("DOWNLOAD_BUSY", "已有模型正在下载。", 409);
229
+ const abort = new AbortController();
230
+ this.downloadAbort = abort;
231
+ this.download = { model: model.id, received: 0, total: model.size, phase: "downloading" };
232
+ void this.downloadModel(model, abort).finally(() => { if (this.downloadAbort === abort)
233
+ this.downloadAbort = null; });
234
+ }
235
+ async downloadModel(model, abort) {
236
+ const destination = this.modelPath(model);
237
+ const partial = `${destination}.part`;
238
+ const timer = setTimeout(() => abort.abort(), 30 * 60_000);
239
+ timer.unref();
240
+ try {
241
+ await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
242
+ const response = await (this.options.fetch ?? fetch)(speechModelUrl(model), { signal: abort.signal });
243
+ if (!response.ok || !response.body)
244
+ throw new Error("download");
245
+ const declared = response.headers.get("content-length");
246
+ if (declared && Number(declared) !== model.size) {
247
+ await response.body.cancel();
248
+ throw new Error("size");
249
+ }
250
+ const file = await open(partial, "w", 0o600);
251
+ const hash = createHash("sha256");
252
+ let received = 0;
253
+ try {
254
+ for await (const chunk of response.body) {
255
+ if (abort.signal.aborted)
256
+ throw new Error("cancelled");
257
+ received += chunk.length;
258
+ if (received > model.size)
259
+ throw new Error("size");
260
+ hash.update(chunk);
261
+ // FileHandle.write may be partial: writeFile writes the entire bounded chunk at current position.
262
+ await file.writeFile(chunk);
263
+ this.download = { model: model.id, received, total: model.size, phase: "downloading" };
264
+ }
265
+ }
266
+ finally {
267
+ await file.close();
268
+ }
269
+ if (abort.signal.aborted || received !== model.size || hash.digest("hex") !== model.sha256)
270
+ throw new Error("integrity");
271
+ await rename(partial, destination);
272
+ this.verified.delete(model.id);
273
+ await this.modelReady(model);
274
+ this.download = null;
275
+ }
276
+ catch {
277
+ await rm(partial, { force: true }).catch(() => { });
278
+ this.download = { model: model.id, received: 0, total: model.size, phase: "failed", error: "模型下载失败或完整性校验未通过,请检查网络后重试。" };
279
+ }
280
+ finally {
281
+ clearTimeout(timer);
282
+ }
283
+ }
284
+ async transcribe(audio, signal) {
285
+ const { silent } = validateSpeechWav(audio);
286
+ if (signal?.aborted)
287
+ throw new SpeechError("CANCELLED", "识别已取消。", 499);
288
+ if (this.disposed)
289
+ throw new SpeechError("UNAVAILABLE", "语音服务已关闭。", 503);
290
+ if (this.active || this.maintenance)
291
+ throw new SpeechError("BUSY", "服务端正在识别或初始化,请稍后重试。", 429);
292
+ const abort = new AbortController();
293
+ this.active = abort;
294
+ const cancel = () => abort.abort();
295
+ signal?.addEventListener("abort", cancel, { once: true });
296
+ let directory = null;
297
+ try {
298
+ const settings = this.settings();
299
+ const model = this.models.find((item) => item.id === settings.model);
300
+ const runtime = await this.runtime();
301
+ if (!settings.enabled || !runtime.executable || !await this.modelReady(model)) {
302
+ throw new SpeechError("NOT_READY", "服务端语音识别未就绪,请先启用并安装运行时、下载模型。", 503);
303
+ }
304
+ if (settings.acceleration === "gpu" && runtime.backend === "cpu")
305
+ throw new SpeechError("NO_GPU", "当前是 CPU 运行时,请改为自动或 CPU。", 503);
306
+ if (abort.signal.aborted)
307
+ throw new SpeechError("CANCELLED", "识别已取消。", 499);
308
+ let backend = settings.acceleration === "cpu" ? "cpu" : runtime.backend;
309
+ if (silent)
310
+ return { text: "", model: model.id, backend };
311
+ await mkdir(path.join(this.root, "tmp"), { recursive: true, mode: 0o700 });
312
+ directory = await mkdtemp(path.join(this.root, "tmp", "voice-"));
313
+ const wav = path.join(directory, "audio.wav");
314
+ await writeFile(wav, audio, { mode: 0o600 });
315
+ const output = path.join(directory, "transcript");
316
+ // Whisper's fixed initial vocabulary biases domain words and Simplified Chinese,
317
+ // without rewriting the transcript or reading any private draft/history as context.
318
+ const context = settings.language === "en" ? "Wand, code, Git, API."
319
+ : "Wand, code, Git, API, 代码, 语音识别, 服务端, 客户端, 任务, 工作区。";
320
+ const args = ["-m", this.modelPath(model), "-f", wav, "-of", output, "-otxt", "-nt", "-l", settings.language, "-t", String(settings.threads), "--prompt", context];
321
+ const started = Date.now();
322
+ const timeout = this.options.timeoutMs ?? 120_000;
323
+ try {
324
+ await this.run(runtime.executable, [...args, ...(backend === "cpu" ? ["-ng"] : [])], directory, abort.signal, timeout);
325
+ }
326
+ catch (error) {
327
+ if (settings.acceleration !== "auto" || backend === "cpu" || abort.signal.aborted
328
+ || !(error instanceof SpeechError) || error.code !== "ENGINE_FAILED")
329
+ throw error;
330
+ // Only local inference is retried, never a message send. Explicit GPU selection does not fallback.
331
+ backend = "cpu";
332
+ await rm(`${output}.txt`, { force: true });
333
+ const remaining = timeout - (Date.now() - started);
334
+ if (remaining <= 0)
335
+ throw new SpeechError("TIMEOUT", "服务端识别超时,请选择较小模型。", 504);
336
+ await this.run(runtime.executable, [...args, "-ng"], directory, abort.signal, remaining);
337
+ }
338
+ const resultFile = `${output}.txt`;
339
+ if ((await stat(resultFile)).size > 32_768)
340
+ throw new SpeechError("INVALID_RESULT", "语音识别结果异常。", 502);
341
+ const text = (await readFile(resultFile, "utf8")).trim();
342
+ if (text.length > 8_000 || text.includes("\0"))
343
+ throw new SpeechError("INVALID_RESULT", "语音识别结果异常。", 502);
344
+ if (abort.signal.aborted)
345
+ throw new SpeechError("CANCELLED", "识别已取消。", 499);
346
+ return { text, model: model.id, backend };
347
+ }
348
+ catch (error) {
349
+ if (error instanceof SpeechError)
350
+ throw error;
351
+ throw new SpeechError("ENGINE_FAILED", "服务端语音识别失败,请检查运行时、模型与可用磁盘。", 503);
352
+ }
353
+ finally {
354
+ if (directory)
355
+ await rm(directory, { recursive: true, force: true }).catch(() => { });
356
+ signal?.removeEventListener("abort", cancel);
357
+ if (this.active === abort)
358
+ this.active = null;
359
+ }
360
+ }
361
+ run(executable, args, cwd, signal, timeout) {
362
+ return new Promise((resolve, reject) => {
363
+ if (signal.aborted) {
364
+ reject(new SpeechError("CANCELLED", "识别已取消。", 499));
365
+ return;
366
+ }
367
+ const child = spawn(executable, args, { cwd, env: buildChildEnv(false, {
368
+ LD_LIBRARY_PATH: systemEnvValue("LD_LIBRARY_PATH"), DYLD_LIBRARY_PATH: systemEnvValue("DYLD_LIBRARY_PATH"),
369
+ CUDA_VISIBLE_DEVICES: systemEnvValue("CUDA_VISIBLE_DEVICES"),
370
+ }), stdio: "ignore", windowsHide: true });
371
+ let failure = null;
372
+ let killTimer;
373
+ const stop = (error) => {
374
+ failure ??= error;
375
+ child.kill("SIGTERM");
376
+ killTimer ??= setTimeout(() => child.kill("SIGKILL"), 1_000);
377
+ killTimer.unref();
378
+ };
379
+ const cancel = () => stop(new SpeechError("CANCELLED", "识别已取消。", 499));
380
+ signal.addEventListener("abort", cancel, { once: true });
381
+ const timer = setTimeout(() => stop(new SpeechError("TIMEOUT", "服务端识别超时,请选择较小模型。", 504)), timeout);
382
+ timer.unref();
383
+ child.once("error", () => { failure = new SpeechError("ENGINE_FAILED", "无法启动服务端语音运行时,请检查安装。", 503); });
384
+ child.once("close", (code) => {
385
+ signal.removeEventListener("abort", cancel);
386
+ clearTimeout(timer);
387
+ if (killTimer)
388
+ clearTimeout(killTimer);
389
+ if (failure)
390
+ reject(failure);
391
+ else if (code !== 0)
392
+ reject(new SpeechError("ENGINE_FAILED", "服务端识别失败,请尝试 CPU 或较小模型。", 502));
393
+ else
394
+ resolve();
395
+ });
396
+ });
397
+ }
398
+ dispose() { this.disposed = true; this.downloadAbort?.abort(); this.active?.abort(); }
399
+ }
@@ -0,0 +1,47 @@
1
+ /** Shared, transport-only speech contract. Never expose executable paths or audio. */
2
+ export type SpeechAcceleration = "auto" | "cpu" | "gpu";
3
+ export type SpeechBackend = "cpu" | "metal" | "cuda";
4
+ export interface SpeechSettings {
5
+ enabled: boolean;
6
+ model: string;
7
+ acceleration: SpeechAcceleration;
8
+ language: "auto" | "zh" | "en";
9
+ threads: number;
10
+ }
11
+ export interface SpeechModelStatus {
12
+ id: string;
13
+ label: string;
14
+ description: string;
15
+ size: number;
16
+ downloaded: boolean;
17
+ }
18
+ export interface SpeechStatus {
19
+ settings: SpeechSettings;
20
+ ready: boolean;
21
+ reason: string | null;
22
+ runtime: {
23
+ available: boolean;
24
+ backend: SpeechBackend;
25
+ platform: string;
26
+ arch: string;
27
+ };
28
+ models: SpeechModelStatus[];
29
+ download: {
30
+ model: string;
31
+ received: number;
32
+ total: number;
33
+ phase: "downloading" | "failed";
34
+ error?: string;
35
+ } | null;
36
+ maxDurationSeconds: number;
37
+ busy: boolean;
38
+ initialized?: boolean;
39
+ }
40
+ export interface SpeechResult {
41
+ text: string;
42
+ model: string;
43
+ backend: SpeechBackend;
44
+ }
45
+ export declare const SPEECH_SAMPLE_RATE = 16000;
46
+ export declare const SPEECH_MAX_SECONDS = 60;
47
+ export declare const SPEECH_MAX_WAV_BYTES: number;
@@ -0,0 +1,3 @@
1
+ export const SPEECH_SAMPLE_RATE = 16_000;
2
+ export const SPEECH_MAX_SECONDS = 60;
3
+ export const SPEECH_MAX_WAV_BYTES = 44 + SPEECH_SAMPLE_RATE * 2 * SPEECH_MAX_SECONDS;
package/dist/storage.d.ts CHANGED
@@ -396,6 +396,8 @@ export declare class WandStorage {
396
396
  * 候选保留用户当下的设置;不存在时按 seed 建首条候选。
397
397
  */
398
398
  ensureSystemSiliconEmployee(seed?: SystemEmployeeSeed): SiliconEmployee;
399
+ /** Stable built-in decision employee; seed once and retain the user's ordered call chain. */
400
+ ensureDecisionExpertEmployee(): SiliconEmployee;
399
401
  saveSiliconEmployee(employee: SiliconEmployee): void;
400
402
  archiveSiliconEmployee(id: string, archivedAt?: string): void;
401
403
  unarchiveSiliconEmployee(id: string): void;
package/dist/storage.js CHANGED
@@ -19,6 +19,8 @@ import { SYSTEM_EMPLOYEE_KEY, systemEmployeeDefinition, systemEmployeeSeedAgents
19
19
  import { isThinkingEffort } from "./structured-provider-common.js";
20
20
  import { DEFAULT_EMPLOYEE_KEY } from "./ai-team-types.js";
21
21
  import { defaultEmployeeDefinition, legacyPtyRoleIdentity } from "./default-employee.js";
22
+ import { DECISION_EXPERT_KEY } from "./decision-expert-identity.js";
23
+ import { decisionExpertDefinition } from "./decision-expert-employee.js";
22
24
  import { normalizeEmployeeKnowledge } from "./employee-knowledge-content.js";
23
25
  import { EMPLOYEE_KNOWLEDGE_MAX_ENTRIES } from "./employee-knowledge-types.js";
24
26
  import { USER_MEMORY_MAX_EVENTS, USER_MEMORY_RETENTION_MS, } from "./user-memory-types.js";
@@ -2760,6 +2762,17 @@ export class WandStorage {
2760
2762
  }
2761
2763
  return existing;
2762
2764
  }
2765
+ /** Stable built-in decision employee; seed once and retain the user's ordered call chain. */
2766
+ ensureDecisionExpertEmployee() {
2767
+ const existing = this.getSystemSiliconEmployee(DECISION_EXPERT_KEY);
2768
+ const definition = decisionExpertDefinition(new Date().toISOString(), existing);
2769
+ if (!existing || existing.name !== definition.name || existing.duty !== definition.duty || existing.prompt !== definition.prompt
2770
+ || existing.avatar !== definition.avatar || existing.archivedAt || !existing.agents.length) {
2771
+ this.saveSiliconEmployee(definition);
2772
+ return definition;
2773
+ }
2774
+ return existing;
2775
+ }
2763
2776
  saveSiliconEmployee(employee) {
2764
2777
  const tags = isBuiltinSiliconEmployee(employee)
2765
2778
  ? siliconEmployeeTags(employee)
@@ -6,7 +6,7 @@ export function classifyProviderRejection(message) {
6
6
  if (/insufficient[_ -](?:quota|credits|balance)|quota[_ -](?:exceeded|exhausted)|credit balance is too low|(?:usage|spending) limit (?:has been )?(?:reached|exceeded)|you have hit your usage limit/i.test(text)
7
7
  || /^(?:错误[::]\s*)?(?:积分|额度|余额)(?:已)?(?:耗尽|用尽|不足)(?:[,,::。.!!\s].*)?$/.test(text))
8
8
  return "quota";
9
- if (/\b(?:invalid_api_key|authentication_error|invalid api key|incorrect api key|unauthorized)\b/i.test(text))
9
+ if (/\b(?:invalid_api_key|authentication_error|invalid api key|incorrect api key|unauthorized|forbidden)\b/i.test(text))
10
10
  return "authentication";
11
11
  if (/\b(?:rate_limit_exceeded|rate_limit_error|rate limit exceeded|too many requests)\b/i.test(text))
12
12
  return "rate-limit";
@@ -57,6 +57,12 @@ interface CreateStructuredSessionOptions {
57
57
  * 留空表示新建会话。
58
58
  */
59
59
  claudeSessionId?: string;
60
+ /**
61
+ * 已裁决的执行引擎;只对 pi 结构化会话有意义。
62
+ * `core` = Wand Agent(进程内 SDK harness),`cli` = Pi CLI。
63
+ * 调用方(新建会话路由)负责先裁决可用性,这里只负责落库与后续回合沿用。
64
+ */
65
+ engine?: "core" | "cli";
60
66
  }
61
67
  /**
62
68
  * 转发会话(AI 团队群聊):外观是普通结构化会话,但不起 CLI。
@@ -227,8 +233,15 @@ export declare class StructuredSessionManager {
227
233
  clearQueuedMessages(sessionId: string): SessionSnapshot;
228
234
  /** 仅空白独立对话能改目录:不建替代会话、不迁移任务/worktree 或已接受的输入。 */
229
235
  setSessionDirectory(sessionId: string, cwd: string): SessionSnapshot;
230
- /** 新建空白对话可原位换 CLI;接受过输入、恢复会话和自动化不跨 provider 搬历史。 */
231
- setSessionProvider(sessionId: string, provider: SessionProvider): SessionSnapshot;
236
+ /** 空白对话原位换执行工具;引擎也是工具身份,不跨工具搬历史或能力设置。 */
237
+ setSessionProvider(sessionId: string, provider: SessionProvider, engine?: "cli" | "sdk"): SessionSnapshot;
238
+ /**
239
+ * 新建 pi 结构化会话前的引擎裁决。
240
+ *
241
+ * 用户在新建会话里选的是两个不同的东西:Pi CLI(`cli`)与 Wand Agent(`sdk`,进程内 harness)。
242
+ * 显式选了 Wand Agent 就必须真的可用:不可用时抛错由路由告诉用户,不静默退回 CLI 冒充。
243
+ */
244
+ resolveNewSessionPiEngine(engine: "cli" | "sdk" | undefined): EngineResolution;
232
245
  getPiSettings(sessionId: string): {
233
246
  settings: PiSessionSettings;
234
247
  resolution: EngineResolution;