ppxans-harness 2.4.0 → 3.2.0

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 (186) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +218 -265
  3. package/bin/ppx-channels.js +2 -2
  4. package/bin/ppx-serve.js +5 -5
  5. package/bin/ppx-setup.js +124 -0
  6. package/bin/ppx-web.js +140 -0
  7. package/bin/ppx.js +2 -2
  8. package/config/identity.md +6 -6
  9. package/config/ishiki.md +16 -16
  10. package/config/ppx.json +15 -151
  11. package/config/ppx.json.example +143 -0
  12. package/package.json +17 -10
  13. package/skills/.usage.json +6 -0
  14. package/skills/agent-professional-training/SKILL.md +94 -0
  15. package/skills/brainstorm/SKILL.md +24 -0
  16. package/skills/cupid-lover-comms/SKILL.md +37 -0
  17. package/skills/debug/SKILL.md +26 -0
  18. package/skills/plan/SKILL.md +25 -0
  19. package/skills/ponytail/SKILL.md +25 -0
  20. package/skills/ppx-memory/SKILL.md +91 -0
  21. package/skills/ppx-memory/scripts/cli.js +192 -0
  22. package/skills/ppx-memory/scripts/experience.js +133 -0
  23. package/skills/ppx-memory/scripts/fact-store.js +842 -0
  24. package/skills/ppx-memory/scripts/l0.js +52 -0
  25. package/skills/ppx-memory/scripts/l2.js +146 -0
  26. package/skills/ppx-memory/scripts/l3.js +112 -0
  27. package/skills/ppx-memory/scripts/memory-ticker.js +238 -0
  28. package/skills/ppx-memory/scripts/pii.js +42 -0
  29. package/skills/ppx-memory/scripts/schema.js +80 -0
  30. package/skills/ppx-memory/scripts/session.js +398 -0
  31. package/skills/ppx-memory/scripts/similarity.js +43 -0
  32. package/skills/ppx-memory/scripts/store.js +116 -0
  33. package/skills/ppx-memory/scripts/wal.js +38 -0
  34. package/skills/ppx-selfheal/SKILL.md +24 -0
  35. package/skills/ppx-selfheal/scripts/cli.js +80 -0
  36. package/skills/ppx-selfheal/scripts/healer.js +184 -0
  37. package/skills/ppx-selfheal/scripts/logger.js +17 -0
  38. package/skills/ppx-selfheal/scripts/store.js +116 -0
  39. package/skills/prompt-depth-kit/SKILL.md +28 -0
  40. package/skills/session-naming/SKILL.md +36 -0
  41. package/skills/verify/SKILL.md +25 -0
  42. package/src/agent/index.js +1340 -717
  43. package/src/agent/prompts.js +47 -3
  44. package/src/aml-server.js +197 -151
  45. package/src/ans/eviction.js +123 -143
  46. package/src/ans/guard.js +159 -120
  47. package/src/ans/lifecycle.js +96 -93
  48. package/src/ans/proactive.js +112 -129
  49. package/src/ans/reward.js +95 -111
  50. package/src/ans/values.js +15 -15
  51. package/src/audit/audit-chain.js +43 -7
  52. package/src/audit/verifier.js +157 -120
  53. package/src/bus/circuit-breaker.js +9 -1
  54. package/src/bus/runtime-bus.js +107 -93
  55. package/src/channels/base.js +57 -34
  56. package/src/channels/feishu.js +118 -126
  57. package/src/channels/http.js +1090 -592
  58. package/src/channels/index.js +111 -110
  59. package/src/channels/log.js +29 -29
  60. package/src/channels/wechat-crypto.js +73 -74
  61. package/src/channels/wechat.js +191 -197
  62. package/src/channels/workspace.js +94 -0
  63. package/src/channels-cli.js +126 -124
  64. package/src/cli.js +129 -120
  65. package/src/commands/index.js +142 -0
  66. package/src/config/channels.js +137 -170
  67. package/src/config/index.js +277 -224
  68. package/src/config/placeholder.js +31 -0
  69. package/src/config/providers.js +148 -188
  70. package/src/config/settings.js +156 -182
  71. package/src/core/policy.js +69 -18
  72. package/src/core/trace.js +8 -6
  73. package/src/edit/editblock.js +266 -0
  74. package/src/edit/snapshot.js +67 -0
  75. package/src/evidence/index.js +153 -0
  76. package/src/evolve/playbook.js +9 -10
  77. package/src/hooks/index.js +113 -0
  78. package/src/llm/client.js +188 -446
  79. package/src/llm/dsml.js +74 -74
  80. package/src/llm/embedder.js +41 -35
  81. package/src/llm/fence.js +52 -105
  82. package/src/llm/index.js +4 -4
  83. package/src/llm/local-embedder.js +94 -0
  84. package/src/llm/presets.js +113 -0
  85. package/src/llm/pricing.js +93 -0
  86. package/src/llm/retry.js +73 -73
  87. package/src/llm/router.js +92 -97
  88. package/src/mcp/admin.js +326 -0
  89. package/src/mcp/client.js +487 -375
  90. package/src/mcp/http.js +203 -0
  91. package/src/mcp/index.js +116 -116
  92. package/src/mcp/server.js +392 -0
  93. package/src/mcp/tasks.js +133 -0
  94. package/src/memory/asset-hub.js +6 -11
  95. package/src/memory/canvas.js +2 -5
  96. package/src/memory/compaction.js +28 -28
  97. package/src/memory/experience.js +133 -122
  98. package/src/memory/fact-store.js +914 -698
  99. package/src/memory/failure-episode.js +20 -11
  100. package/src/memory/fork.js +17 -8
  101. package/src/memory/index.js +8 -6
  102. package/src/memory/l0.js +53 -52
  103. package/src/memory/l2.js +145 -130
  104. package/src/memory/l3.js +111 -111
  105. package/src/memory/legion-board.js +71 -0
  106. package/src/memory/memory-ticker.js +239 -240
  107. package/src/memory/session.js +398 -397
  108. package/src/memory/sqlite-store.js +581 -0
  109. package/src/mode/blackboard.js +49 -49
  110. package/src/mode/graph.js +42 -41
  111. package/src/mode/index.js +64 -64
  112. package/src/mode/legion.js +54 -51
  113. package/src/mode/plan-exec.js +50 -50
  114. package/src/mode/router.js +28 -40
  115. package/src/orchestrator/agent-worker.js +69 -69
  116. package/src/orchestrator/dag.js +90 -83
  117. package/src/orchestrator/experts.js +76 -0
  118. package/src/orchestrator/index.js +1 -1
  119. package/src/orchestrator/legion.js +179 -187
  120. package/src/orchestrator/supervisor.js +6 -8
  121. package/src/permissions/index.js +378 -0
  122. package/src/persona/index.js +28 -29
  123. package/src/plugin/builtin.js +313 -212
  124. package/src/plugin/context.js +80 -79
  125. package/src/plugin/index.js +62 -62
  126. package/src/plugin/v3.js +73 -0
  127. package/src/protocol/index.js +148 -0
  128. package/src/repomap/index.js +309 -0
  129. package/src/review/index.js +393 -0
  130. package/src/seam/registry.js +3 -0
  131. package/src/seam/shell.js +55 -55
  132. package/src/security/injection.js +79 -0
  133. package/src/selfheal/evolve.js +67 -67
  134. package/src/selfheal/healer.js +184 -167
  135. package/src/selfheal/run.js +9 -9
  136. package/src/server.js +63 -60
  137. package/src/services/diagnose.js +180 -0
  138. package/src/services/learning-service.js +9 -0
  139. package/src/services/memory-health.js +34 -6
  140. package/src/services/memory-service.js +42 -9
  141. package/src/services/triage.js +138 -0
  142. package/src/session/parts.js +76 -0
  143. package/src/session/projection.js +73 -0
  144. package/src/session/rollout.js +54 -0
  145. package/src/session/turn.js +137 -0
  146. package/src/skills/lint.js +72 -0
  147. package/src/skills/loader.js +231 -150
  148. package/src/skills/search.js +58 -0
  149. package/src/skills/verify.js +95 -100
  150. package/src/tools/advanced.js +388 -352
  151. package/src/tools/builtin.js +384 -297
  152. package/src/tools/catalog.js +283 -159
  153. package/src/tools/command-guard.js +112 -112
  154. package/src/tools/custom.js +47 -47
  155. package/src/tools/delegate.js +383 -297
  156. package/src/tools/document.js +254 -253
  157. package/src/tools/git.js +151 -0
  158. package/src/tools/governance.js +47 -20
  159. package/src/tools/index.js +16 -11
  160. package/src/tools/methods.js +178 -178
  161. package/src/tools/ocr.js +59 -59
  162. package/src/tools/sandbox-worker.js +40 -0
  163. package/src/tools/sandbox.js +92 -0
  164. package/src/tools/seam.js +162 -125
  165. package/src/tools/selfmod.js +196 -176
  166. package/src/tools/v3.js +225 -0
  167. package/src/tools/vad.js +176 -0
  168. package/src/tools/voice.js +238 -0
  169. package/src/utils/async.js +14 -0
  170. package/src/utils/config-file.js +53 -0
  171. package/src/utils/crashguard.js +88 -0
  172. package/src/utils/http.js +53 -0
  173. package/src/utils/id.js +8 -0
  174. package/src/utils/json-state.js +33 -0
  175. package/src/utils/logger.js +17 -17
  176. package/src/utils/ndjson.js +25 -0
  177. package/src/utils/pii.js +42 -42
  178. package/src/utils/rate-limit.js +50 -0
  179. package/src/utils/schema.js +80 -0
  180. package/src/utils/similarity.js +43 -0
  181. package/src/utils/store.js +170 -108
  182. package/src/utils/text.js +15 -15
  183. package/src/utils/trace.js +153 -153
  184. package/src/utils/wal.js +39 -0
  185. package/src/utils/winutf8.js +16 -15
  186. package/src/wiki/index.js +170 -0
@@ -0,0 +1,176 @@
1
+ // src/tools/vad.js - 语音活动检测 (VAD, 零依赖)
2
+ //
3
+ // 用途: 语音消息/音频切分 —— 找出"有人说话"的片段, 供 ASR 只转写有效段 (省时省钱),
4
+ // 或语音通道做"说到停"的断句。
5
+ //
6
+ // 双后端:
7
+ // energy (默认, 零依赖): 解析 WAV (PCM 16bit) → 按帧算 RMS → 能量阈值 + 滞回判定语音段。
8
+ // 对干净录音效果够用; 嘈杂环境弱于神经网络方案。
9
+ // silero (可选): 动态 import onnxruntime-node 跑 Silero-VAD ONNX 模型 —— 包或模型缺失时
10
+ // 返回明确错误 (不静默降级, 让调用方自己选能量版)。
11
+ // 模型: silero_vad.onnx (https://github.com/snakers4/silero-vad), config 路径传入。
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { TOOL_ERROR_PREFIX } from "./seam.js";
15
+
16
+ function err(name, msg) {
17
+ return `${TOOL_ERROR_PREFIX} ${name}: ${msg}`;
18
+ }
19
+
20
+ // ---- WAV 解析 (16-bit PCM only, 零依赖) ----
21
+ export function parseWavPcm16(buf) {
22
+ if (buf.length < 44 || buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") {
23
+ throw new Error("不是 WAV (RIFF) 文件");
24
+ }
25
+ let off = 12;
26
+ let fmt = null;
27
+ let data = null;
28
+ while (off + 8 <= buf.length) {
29
+ const id = buf.toString("ascii", off, off + 4);
30
+ const size = buf.readUInt32LE(off + 4);
31
+ if (id === "fmt ") {
32
+ fmt = {
33
+ format: buf.readUInt16LE(off + 8),
34
+ channels: buf.readUInt16LE(off + 10),
35
+ sampleRate: buf.readUInt32LE(off + 12),
36
+ bitsPerSample: buf.readUInt16LE(off + 22),
37
+ };
38
+ } else if (id === "data") {
39
+ data = buf.subarray(off + 8, off + 8 + size);
40
+ }
41
+ off += 8 + size + (size % 2);
42
+ }
43
+ if (!fmt || !data) throw new Error("WAV 缺少 fmt/data 块");
44
+ if (fmt.format !== 1) throw new Error(`仅支持 PCM (format=1), 当前 format=${fmt.format}`);
45
+ if (fmt.bitsPerSample !== 16) throw new Error(`仅支持 16-bit (当前 ${fmt.bitsPerSample}-bit), 请先转码`);
46
+ return { sampleRate: fmt.sampleRate, channels: fmt.channels, samples: data };
47
+ }
48
+
49
+ // 16-bit 交错样本 → 单声道 Float32 (-1..1)
50
+ function toMono(samples, channels) {
51
+ const n = Math.floor(samples.length / 2 / channels);
52
+ const out = new Float32Array(n);
53
+ for (let i = 0; i < n; i++) {
54
+ let acc = 0;
55
+ for (let c = 0; c < channels; c++) acc += samples.readInt16LE((i * channels + c) * 2);
56
+ out[i] = acc / channels / 32768;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // ---- 能量 VAD: 按帧 RMS + 滞回 (进入阈值 > 退出阈值, 防抖) ----
62
+ export function energyVad(pcm, { sampleRate = 16000, frameMs = 30, threshold = 0.015, hangoverMs = 300, minSpeechMs = 120 } = {}) {
63
+ const frameLen = Math.max(1, Math.round((sampleRate * frameMs) / 1000));
64
+ const hangFrames = Math.max(1, Math.round(hangoverMs / frameMs));
65
+ const minSpeechFrames = Math.max(1, Math.round(minSpeechMs / frameMs));
66
+ const frames = [];
67
+ for (let i = 0; i + frameLen <= pcm.length; i += frameLen) {
68
+ let sum = 0;
69
+ for (let j = 0; j < frameLen; j++) { const v = pcm[i + j]; sum += v * v; }
70
+ frames.push(Math.sqrt(sum / frameLen)); // RMS
71
+ }
72
+ // 自适应底噪: 取最安静的 20% 帧均值, 阈值 = max(固定下限, 底噪×4)
73
+ const sorted = [...frames].sort((a, b) => a - b);
74
+ const noise = sorted.slice(0, Math.max(1, Math.floor(sorted.length * 0.2))).reduce((a, b) => a + b, 0) / Math.max(1, Math.floor(sorted.length * 0.2));
75
+ const enterAt = Math.max(threshold, noise * 4);
76
+ const exitAt = Math.max(threshold * 0.6, noise * 2.5);
77
+ const segments = [];
78
+ let inSpeech = false, start = 0, quietRun = 0;
79
+ frames.forEach((rms, idx) => {
80
+ if (!inSpeech && rms > enterAt) { inSpeech = true; start = idx; quietRun = 0; }
81
+ else if (inSpeech) {
82
+ if (rms < exitAt) { quietRun++; if (quietRun >= hangFrames) { inSpeech = false; if ((idx - quietRun + 1 - start) >= minSpeechFrames) segments.push([start, idx - quietRun + 1]); } }
83
+ else quietRun = 0;
84
+ }
85
+ });
86
+ if (inSpeech && frames.length - start >= minSpeechFrames) segments.push([start, frames.length]);
87
+ const speechFrames = segments.reduce((a, [s, e]) => a + (e - s), 0);
88
+ return {
89
+ backend: "energy",
90
+ durationMs: Math.round((frames.length * frameMs)),
91
+ noiseFloor: Number(noise.toFixed(5)),
92
+ threshold: Number(enterAt.toFixed(5)),
93
+ speechRatio: frames.length ? Number((speechFrames / frames.length).toFixed(3)) : 0,
94
+ segments: segments.map(([s, e]) => ({ startMs: Math.round(s * frameMs), endMs: Math.round(e * frameMs) })),
95
+ };
96
+ }
97
+
98
+ // ---- Silero 后端 (可选): onnxruntime-node + silero_vad.onnx ----
99
+ async function sileroVad(abs, opts) {
100
+ let ort;
101
+ try { ort = await import("onnxruntime-node"); } catch {
102
+ return err("vad_detect", "silero 后端需要 onnxruntime-node (npm i onnxruntime-node), 未安装。改用默认 energy 后端 (零依赖)");
103
+ }
104
+ if (!opts.model) return err("vad_detect", "silero 后端需指定模型路径: args.model = silero_vad.onnx 的绝对路径");
105
+ const wav = parseWavPcm16(fs.readFileSync(abs));
106
+ const mono = toMono(wav.samples, wav.channels);
107
+ const session = await ort.InferenceSession.create(opts.model);
108
+ // Silero-VAD v4/v5: 逐帧 (512 样本 @16k) 推理, state (h/c) 迭代传递
109
+ const frame = 512;
110
+ let h = new ort.Tensor("float32", new Float32Array(2 * 64), [2, 1, 64]);
111
+ let c = new ort.Tensor("float32", new Float32Array(2 * 64), [2, 1, 64]);
112
+ const probs = [];
113
+ for (let i = 0; i + frame <= mono.length; i += frame) {
114
+ const input = new ort.Tensor("float32", mono.subarray(i, i + frame), [1, frame]);
115
+ const sr = new ort.Tensor("int64", BigInt64Array.from([BigInt(wav.sampleRate)]), []);
116
+ const feeds = { input, h, c, sample_rate: sr };
117
+ const out = await session.run(feeds);
118
+ probs.push(out.output?.data?.[0] ?? 0);
119
+ h = out.hn || h; c = out.cn || c;
120
+ }
121
+ const enterAt = opts.threshold ?? 0.5;
122
+ const segments = [];
123
+ let inSpeech = false, start = 0, quiet = 0;
124
+ const frameMs = Math.round((frame / wav.sampleRate) * 1000);
125
+ probs.forEach((p, idx) => {
126
+ if (!inSpeech && p > enterAt) { inSpeech = true; start = idx; quiet = 0; }
127
+ else if (inSpeech) {
128
+ if (p < enterAt * 0.6) { quiet++; if (quiet >= Math.round(300 / frameMs)) { inSpeech = false; segments.push([start, idx - quiet + 1]); } }
129
+ else quiet = 0;
130
+ }
131
+ });
132
+ if (inSpeech) segments.push([start, probs.length]);
133
+ return JSON.stringify({
134
+ backend: "silero",
135
+ durationMs: Math.round((mono.length / wav.sampleRate) * 1000),
136
+ speechRatio: probs.length ? Number((segments.reduce((a, [s, e]) => a + (e - s), 0) / probs.length).toFixed(3)) : 0,
137
+ segments: segments.map(([s, e]) => ({ startMs: s * frameMs, endMs: e * frameMs })),
138
+ });
139
+ }
140
+
141
+ export function registerVadTools(catalog, { rootDir = process.cwd() } = {}) {
142
+ catalog.register({
143
+ name: "vad_detect",
144
+ description: "语音活动检测 (VAD): 找出音频里有人说话的时间段。默认零依赖能量算法 (16-bit PCM WAV); 可选 silero 神经网络后端 (需 onnxruntime-node + onnx 模型)。用于 ASR 前切分、语音断句。",
145
+ parameters: {
146
+ type: "object",
147
+ properties: {
148
+ path: { type: "string", description: "WAV 文件路径 (16-bit PCM; 其他格式请先转码)" },
149
+ backend: { type: "string", enum: ["energy", "silero"], description: "energy=零依赖能量阈值 (默认); silero=神经网络 (需 onnxruntime-node)" },
150
+ model: { type: "string", description: "silero 后端: silero_vad.onnx 模型路径" },
151
+ threshold: { type: "number", description: "能量后端: RMS 进入阈值 (默认 0.015 自适应底噪); silero 后端: 概率阈值 (默认 0.5)" },
152
+ },
153
+ required: ["path"],
154
+ },
155
+ category: "net",
156
+ power: "user",
157
+ idempotent: true,
158
+ capability: { readOnly: true, riskLevel: "low", sideEffect: "none" },
159
+ execute: async (args) => {
160
+ const abs = path.isAbsolute(args.path) ? args.path : path.resolve(rootDir, args.path);
161
+ if (!fs.existsSync(abs)) return err("vad_detect", `文件不存在: ${args.path}`);
162
+ if (args.backend === "silero") return sileroVad(abs, args);
163
+ let wav;
164
+ try {
165
+ wav = parseWavPcm16(fs.readFileSync(abs));
166
+ } catch (e) {
167
+ return err("vad_detect", `${e.message} (仅支持 16-bit PCM WAV; mp3/m4a 请先转码: ffmpeg -i in.mp3 -acodec pcm_s16le -ar 16000 out.wav)`);
168
+ }
169
+ if (wav.sampleRate < 8000 || wav.sampleRate > 192000) return err("vad_detect", `采样率异常: ${wav.sampleRate}Hz`);
170
+ const mono = toMono(wav.samples, wav.channels);
171
+ const r = energyVad(mono, { sampleRate: wav.sampleRate, threshold: Number(args.threshold) || 0.015 });
172
+ return JSON.stringify(r);
173
+ },
174
+ });
175
+ return catalog;
176
+ }
@@ -0,0 +1,238 @@
1
+ // src/tools/voice.js - 语音能力 (ASR 语音转文本 + TTS 文本转语音)
2
+ // 设计原则: 零运行时依赖 —— 走 OpenAI 兼容 HTTP 端点
3
+ // ASR: POST {base_url}/audio/transcriptions (multipart/form-data)
4
+ // TTS: POST {base_url}/audio/speech (返回二进制音频)
5
+ // multipart 用 Node 内置的 FormData + Blob (18+ 全局), 不引入任何 npm 包。
6
+ // 兼容: OpenAI / 硅基流动 / 火山 / 智谱 / 本地 whisper.cpp server 等任意 OpenAI 兼容端点。
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { TOOL_ERROR_PREFIX } from "./seam.js";
10
+ import { info } from "../utils/logger.js";
11
+
12
+ // 常见音频扩展名 (用于路径与格式校验)
13
+ const AUDIO_EXT = new Set([".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".webm", ".mp4", ".amr", ".silk"]);
14
+ const MAX_ASR_BYTES = 25 * 1024 * 1024; // 25MB: OpenAI 兼容端点通用上限
15
+
16
+ function err(name, msg) {
17
+ return `${TOOL_ERROR_PREFIX} ${name}: ${msg}`;
18
+ }
19
+
20
+ // 从配置解析一段语音端点 (asr / tts); 传入**完整 config**, 内部读 config.voice[kind]。
21
+ // 返回 {baseUrl, apiKey, model, ...} 或 null (未配置 / 声明了 key 环境变量但没设值)
22
+ // v3.1: voice.asr 支持 backend:"local" —— 走本地 whisper 绑定 (nodejs-whisper, 可选依赖),
23
+ // 配 local 时 resolveVoice 返回 null 不影响本地路径 (本地判定在 resolveLocalAsr)。
24
+ export function resolveVoice(config = {}, kind = "asr") {
25
+ const sec = config?.voice?.[kind];
26
+ if (!sec || typeof sec !== "object") return null;
27
+ if (sec.backend === "local") return null; // 本地后端不走 OpenAI 兼容端点
28
+ const baseUrl = String(sec.base_url || "").replace(/\/+$/, "");
29
+ if (!baseUrl) return null;
30
+ const apiKey = sec.api_key || (sec.api_key_env ? process.env[sec.api_key_env] : "") || "";
31
+ if (sec.api_key_env && !apiKey) return null; // 声明了 key 环境变量但没设 → 视为未配置
32
+ return {
33
+ baseUrl,
34
+ apiKey,
35
+ model: String(sec.model || (kind === "asr" ? "whisper-1" : "tts-1")),
36
+ voice: String(sec.voice || "alloy"),
37
+ format: String(sec.format || "mp3"),
38
+ timeoutMs: Number(sec.timeout_ms) || 120000,
39
+ };
40
+ }
41
+
42
+ // 本地 ASR 配置解析 (v3.1): voice.asr = { backend: "local", model, model_root_path?, auto_download?, language? }
43
+ // 依赖 nodejs-whisper (可选依赖, 动态 import; 未安装时报错并给出安装指引)
44
+ export function resolveLocalAsr(config = {}) {
45
+ const sec = config?.voice?.asr;
46
+ if (!sec || typeof sec !== "object" || sec.backend !== "local") return null;
47
+ return {
48
+ model: String(sec.model || "base"),
49
+ modelRootPath: sec.model_root_path ? String(sec.model_root_path) : undefined,
50
+ autoDownload: sec.auto_download !== false,
51
+ defaultLanguage: sec.language ? String(sec.language) : undefined,
52
+ timeoutMs: Number(sec.timeout_ms) || 300000, // 本地 CPU 推理给足 5 分钟
53
+ };
54
+ }
55
+
56
+ // 本地转写实现: 优先 nodejs-whisper (whisper.cpp 绑定, 全格式自动转 WAV), 失败给出可执行的安装指引
57
+ async function localTranscribe(abs, opts, language) {
58
+ try {
59
+ const mod = await import("nodejs-whisper");
60
+ const out = await mod.nodewhisper(abs, {
61
+ modelName: opts.model,
62
+ modelRootPath: opts.modelRootPath,
63
+ autoDownloadModelName: opts.autoDownload ? opts.model : undefined,
64
+ whisperOptions: { outputInJson: true, wordTimestamps: false, ...(language ? { } : {}) },
65
+ });
66
+ // nodejs-whisper 的 JSON 输出为分段数组 [{start,end,text}], 拼接; 字符串则直用
67
+ if (Array.isArray(out)) {
68
+ return out.map((s) => (s && (s.text || s.speech)) || "").join("").trim();
69
+ }
70
+ return String(out || "").trim();
71
+ } catch (e) {
72
+ const hint = e?.code === "ERR_MODULE_NOT_FOUND" || String(e?.message || "").includes("Cannot find package")
73
+ ? "本地 ASR 依赖未安装。安装: npm i nodejs-whisper (Windows 需先装 MinGW-w64/MSYS2 提供 make), 或改用云端端点 voice.asr { base_url, api_key_env }"
74
+ : `本地转写失败: ${e.message || e}`;
75
+ throw new Error(hint);
76
+ }
77
+ }
78
+
79
+ // 语音能力是否就绪 (供 list_capabilities / 诊断展示)
80
+ export function voiceStatus(config = {}) {
81
+ const asrSec = config?.voice?.asr;
82
+ return {
83
+ asr: asrSec?.backend === "local" ? "local" : !!resolveVoice(config, "asr"),
84
+ tts: !!resolveVoice(config, "tts"),
85
+ enabled: config?.voice?.enabled !== false,
86
+ };
87
+ }
88
+
89
+ const MIME = {
90
+ ".wav": "audio/wav", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac",
91
+ ".flac": "audio/flac", ".ogg": "audio/ogg", ".opus": "audio/opus", ".webm": "audio/webm",
92
+ ".mp4": "audio/mp4", ".amr": "audio/amr", ".silk": "audio/silk",
93
+ };
94
+
95
+ export function registerVoiceTools(catalog, { config = {}, rootDir = process.cwd() } = {}) {
96
+ // ---- ASR: 语音转文本 ----
97
+ catalog.register({
98
+ name: "voice_transcribe",
99
+ description: "把语音/音频文件转成文字 (ASR)。支持 wav/mp3/m4a/flac/ogg/webm 等, 上限 25MB。用于处理语音消息、会议录音、口述笔记。",
100
+ parameters: {
101
+ type: "object",
102
+ properties: {
103
+ path: { type: "string", description: "音频文件路径 (相对工作目录或绝对路径)" },
104
+ language: { type: "string", description: "语言代码 (如 zh / en), 留空自动检测" },
105
+ prompt: { type: "string", description: "提示词: 引导术语/人名/专有名词的识别 (可选)" },
106
+ },
107
+ required: ["path"],
108
+ },
109
+ category: "net",
110
+ power: "user",
111
+ idempotent: true,
112
+ capability: { readOnly: true, riskLevel: "low", sideEffect: "network" },
113
+ execute: async (args) => {
114
+ // v3.1: 本地 ASR 后端 (voice.asr.backend="local") —— 零 API key, 离线可用
115
+ const localOpts = resolveLocalAsr(config);
116
+ if (localOpts) {
117
+ const abs = path.isAbsolute(args.path) ? args.path : path.resolve(rootDir, args.path);
118
+ if (!fs.existsSync(abs)) return err("voice_transcribe", `文件不存在: ${args.path}`);
119
+ const st = fs.statSync(abs);
120
+ if (st.size > MAX_ASR_BYTES) {
121
+ return err("voice_transcribe", `文件过大 (${(st.size / 1048576).toFixed(1)}MB > 25MB), 请先切片`);
122
+ }
123
+ const ext = path.extname(abs).toLowerCase();
124
+ if (!AUDIO_EXT.has(ext)) return err("voice_transcribe", `不支持的音频格式: ${ext}`);
125
+ try {
126
+ const text = await localTranscribe(abs, localOpts, args.language || localOpts.defaultLanguage);
127
+ info(`[voice] 本地 ASR 完成: ${path.basename(abs)} (${(st.size / 1024).toFixed(0)}KB)`);
128
+ return text || "(识别结果为空)";
129
+ } catch (e) {
130
+ return err("voice_transcribe", e.message || String(e));
131
+ }
132
+ }
133
+ const v = resolveVoice(config, "asr");
134
+ if (!v) {
135
+ return err("voice_transcribe", "未配置 ASR。二选一: ① 云端 config/ppx.json → voice.asr { base_url, api_key_env, model }; ② 本地 voice.asr { backend: \"local\", model: \"base\" } (需 npm i nodejs-whisper)");
136
+ }
137
+ const abs = path.isAbsolute(args.path) ? args.path : path.resolve(rootDir, args.path);
138
+ if (!fs.existsSync(abs)) return err("voice_transcribe", `文件不存在: ${args.path}`);
139
+ const st = fs.statSync(abs);
140
+ if (st.size > MAX_ASR_BYTES) {
141
+ return err("voice_transcribe", `文件过大 (${(st.size / 1048576).toFixed(1)}MB > 25MB), 请先切片`);
142
+ }
143
+ const ext = path.extname(abs).toLowerCase();
144
+ if (!AUDIO_EXT.has(ext)) return err("voice_transcribe", `不支持的音频格式: ${ext}`);
145
+
146
+ const form = new FormData();
147
+ form.append("file", new Blob([fs.readFileSync(abs)], { type: MIME[ext] || "application/octet-stream" }), path.basename(abs));
148
+ form.append("model", v.model);
149
+ if (args.language) form.append("language", String(args.language));
150
+ if (args.prompt) form.append("prompt", String(args.prompt).slice(0, 800));
151
+
152
+ const ctrl = new AbortController();
153
+ const timer = setTimeout(() => ctrl.abort(), v.timeoutMs);
154
+ try {
155
+ const res = await fetch(`${v.baseUrl}/audio/transcriptions`, {
156
+ method: "POST",
157
+ headers: v.apiKey ? { Authorization: `Bearer ${v.apiKey}` } : {},
158
+ body: form,
159
+ signal: ctrl.signal,
160
+ });
161
+ const text = await res.text();
162
+ if (!res.ok) return err("voice_transcribe", `HTTP ${res.status}: ${text.slice(0, 300)}`);
163
+ let out = text;
164
+ try {
165
+ const j = JSON.parse(text);
166
+ out = j.text ?? j.result ?? text;
167
+ } catch { /* 非 JSON 就直接用原文 */ }
168
+ info(`[voice] ASR 完成: ${path.basename(abs)} (${(st.size / 1024).toFixed(0)}KB)`);
169
+ return String(out).trim() || "(识别结果为空)";
170
+ } catch (e) {
171
+ return err("voice_transcribe", e.name === "AbortError" ? `请求超时 (${v.timeoutMs}ms)` : String(e.message || e));
172
+ } finally {
173
+ clearTimeout(timer);
174
+ }
175
+ },
176
+ });
177
+
178
+ // ---- TTS: 文本转语音 ----
179
+ catalog.register({
180
+ name: "voice_speak",
181
+ description: "把文字合成语音文件 (TTS)。返回保存路径。用于生成语音播报、回复语音消息、口播稿试听。",
182
+ parameters: {
183
+ type: "object",
184
+ properties: {
185
+ text: { type: "string", description: "要合成的文本" },
186
+ out: { type: "string", description: "输出文件路径 (默认 data/voice/tts-<时间戳>.<格式>)" },
187
+ voice: { type: "string", description: "音色名 (默认取配置, 如 alloy / zh-CN-XiaoxiaoNeural)" },
188
+ format: { type: "string", enum: ["mp3", "wav", "opus", "aac", "flac"], description: "音频格式, 默认 mp3" },
189
+ },
190
+ required: ["text"],
191
+ },
192
+ category: "net",
193
+ power: "user",
194
+ capability: { riskLevel: "low", sideEffect: "filesystem+network" },
195
+ execute: async (args) => {
196
+ const v = resolveVoice(config, "tts");
197
+ if (!v) {
198
+ return err("voice_speak", "未配置 TTS 端点。在 config/ppx.json 里加 voice.tts { base_url, api_key_env, model, voice }");
199
+ }
200
+ const text = String(args.text || "").trim();
201
+ if (!text) return err("voice_speak", "text 不能为空");
202
+ if (text.length > 4000) return err("voice_speak", `文本过长 (${text.length} > 4000 字), 请分段合成`);
203
+ const format = String(args.format || v.format || "mp3");
204
+
205
+ const ctrl = new AbortController();
206
+ const timer = setTimeout(() => ctrl.abort(), v.timeoutMs);
207
+ try {
208
+ const res = await fetch(`${v.baseUrl}/audio/speech`, {
209
+ method: "POST",
210
+ headers: {
211
+ "content-type": "application/json",
212
+ ...(v.apiKey ? { Authorization: `Bearer ${v.apiKey}` } : {}),
213
+ },
214
+ body: JSON.stringify({ model: v.model, input: text, voice: args.voice || v.voice, response_format: format }),
215
+ signal: ctrl.signal,
216
+ });
217
+ if (!res.ok) {
218
+ const t = await res.text();
219
+ return err("voice_speak", `HTTP ${res.status}: ${t.slice(0, 300)}`);
220
+ }
221
+ const buf = Buffer.from(await res.arrayBuffer());
222
+ const out = args.out
223
+ ? (path.isAbsolute(args.out) ? args.out : path.resolve(rootDir, args.out))
224
+ : path.join(rootDir, "data", "voice", `tts-${Date.now()}.${format}`);
225
+ fs.mkdirSync(path.dirname(out), { recursive: true });
226
+ fs.writeFileSync(out, buf);
227
+ info(`[voice] TTS 完成: ${buf.length} 字节 → ${out}`);
228
+ return JSON.stringify({ ok: true, path: out, bytes: buf.length, format, chars: text.length });
229
+ } catch (e) {
230
+ return err("voice_speak", e.name === "AbortError" ? `请求超时 (${v.timeoutMs}ms)` : String(e.message || e));
231
+ } finally {
232
+ clearTimeout(timer);
233
+ }
234
+ },
235
+ });
236
+
237
+ return catalog;
238
+ }
@@ -0,0 +1,14 @@
1
+ // src/utils/async.js - 异步小工具 (唯一实现, 2026-09-18 重构收敛)
2
+ // 背景: withTimeout 原先在 orchestrator/supervisor.js 与 tools/delegate.js 各写一份,
3
+ // 逐字相同 (含定时器清理注释)。收敛后 orchestrator 仍 re-export 同名函数, 对外接口不变。
4
+
5
+ // 带超时等待 (防子 agent 卡死)。
6
+ // 定时器必须清理, 否则 promise 快速 resolve 后残留的 setTimeout 仍会挂住事件循环
7
+ // (最长到超时时间), 阻止进程正常退出。
8
+ export function withTimeout(p, ms, label) {
9
+ let timer;
10
+ const timeout = new Promise((_, rej) => {
11
+ timer = setTimeout(() => rej(new Error(`${label}超时 (${ms / 1000}s)`)), ms);
12
+ });
13
+ return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
14
+ }
@@ -0,0 +1,53 @@
1
+ // src/utils/config-file.js - config/ppx.json 共享读写 (唯一实现)
2
+ // 职责:
3
+ // - configFilePath(root): 统一配置文件路径 (原来 providers/channels/settings 三处各写一份)
4
+ // - readPpxConfig(root): 读整个 config JSON, 缺文件/损坏返回 fallback
5
+ // - writeConfigAtomic: 备份 (保留最近 3 个 .bak) → 原子写 (.tmp + rename), 防中途崩溃损坏
6
+ // 收录自 src/config/{providers,channels,settings}.js 的逐字重复实现 (2026-09-18 重构),
7
+ // 以后改备份策略/写盘语义只动这一处。
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { atomicWrite } from "./store.js";
11
+ import { warn } from "./logger.js";
12
+ import { debug } from "../utils/logger.js";
13
+
14
+ // 备份文件最多保留个数
15
+ const MAX_BACKUPS = 3;
16
+
17
+ export function configFilePath(root) {
18
+ return path.join(root, "config", "ppx.json");
19
+ }
20
+
21
+ // 读取整个 config, 文件不存在或 JSON 损坏时返回 fallback
22
+ export function readPpxConfig(root, fallback = {}) {
23
+ const p = configFilePath(root);
24
+ if (!fs.existsSync(p)) return fallback;
25
+ try {
26
+ return JSON.parse(fs.readFileSync(p, "utf8"));
27
+ } catch (e) {
28
+ warn("config/ppx.json 读取失败:", e.message);
29
+ return fallback;
30
+ }
31
+ }
32
+
33
+ // 写盘: 先备份 (最多保留最近 MAX_BACKUPS 个 .bak-时间戳), 再原子写 (.tmp + rename)
34
+ export function writeConfigAtomic(root, cfg) {
35
+ const p = configFilePath(root);
36
+ if (fs.existsSync(p)) {
37
+ const bak = p + ".bak-" + new Date().toISOString().replace(/[:.]/g, "-");
38
+ try { fs.copyFileSync(p, bak); } catch (e) { warn("备份失败:", e.message); }
39
+ try {
40
+ const dir = path.dirname(p);
41
+ const base = path.basename(p);
42
+ const baks = fs.readdirSync(dir)
43
+ .filter((f) => f.startsWith(base + ".bak-"))
44
+ .map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs }));
45
+ baks.sort((a, b) => b.t - a.t);
46
+ for (const old of baks.slice(MAX_BACKUPS)) {
47
+ try { fs.unlinkSync(path.join(dir, old.f)); } catch (e) { debug(`[utils/config-file] 已忽略异常: ${e && e.message ? e.message : e}`); }
48
+ }
49
+ } catch (e) { debug(`[utils/config-file] 已忽略异常: ${e && e.message ? e.message : e}`); }
50
+ }
51
+ // 原子写: store.atomicWrite 带随机 .tmp 后缀 + Windows rename 重试 (EPERM/EEXIST 兜底)
52
+ atomicWrite(p, JSON.stringify(cfg, null, 2));
53
+ }
@@ -0,0 +1,88 @@
1
+ // src/utils/crashguard.js - 全局异常兜底 (2026-09-17 体检新增, P1)
2
+ //
3
+ // 问题: 全项目此前只处理 SIGINT/SIGTERM, 没有 uncaughtException / unhandledRejection 兜底。
4
+ // Node >= 15 的默认行为是未处理的 Promise 拒绝直接终止进程 —— 对一个"双击即用"的
5
+ // 桌面产品, 表现为窗口里的服务突然消失、用户只看到"未连接", 且没有任何线索。
6
+ //
7
+ // 策略 (可配置):
8
+ // 默认: 记录完整堆栈 + 回调通知 (供总线广播/落盘), 然后**继续运行**。
9
+ // 理由: 桌面场景下"带伤继续"远好于"静默消失"; 绝大多数异常来自单个请求/工具,
10
+ // 进程级状态并未损坏。
11
+ // PPX_EXIT_ON_UNCAUGHT=1: 恢复"记录后退出"的传统行为 (适合作为 systemd/pm2 托管进程,
12
+ // 由外部守护拉起)。退出码 1。
13
+ //
14
+ // 结构: 上报逻辑抽成独立工厂 createCrashReporter, 与 process 事件解耦 —— 便于单测,
15
+ // 也避免测试进程里手动 emit 事件被 node:test 判为用例失败。
16
+ const DEDUPE_WINDOW_MS = 5000;
17
+ let installed = false; // 模块级单例标记: 兜底只需装一次
18
+
19
+ function signature(err) {
20
+ return String(err?.stack || err?.message || err || "unknown").split("\n").slice(0, 3).join("|");
21
+ }
22
+
23
+ /**
24
+ * 创建一个"带去重折叠"的异常上报器。
25
+ * @param {object} opts
26
+ * @param {string} [opts.tag="ppx"] 日志前缀
27
+ * @param {function} [opts.onError] (err, kind) => void, 额外回调 (广播/落盘)
28
+ * @param {object} [opts.logger] 日志器 (需有 warn/error), 默认 console
29
+ * @param {number} [opts.dedupeMs] 同类错误折叠窗口
30
+ * @returns {(kind: string, err: unknown) => void} report
31
+ */
32
+ export function createCrashReporter({ tag = "ppx", onError = null, logger = null, dedupeMs = DEDUPE_WINDOW_MS } = {}) {
33
+ const log = logger || { error: (...a) => console.error(...a), warn: (...a) => console.warn(...a) };
34
+ let lastSig = "";
35
+ let lastAt = 0;
36
+ let dupes = 0;
37
+
38
+ return function report(kind, err) {
39
+ const now = Date.now();
40
+ const sig = signature(err);
41
+ if (sig === lastSig && now - lastAt < dedupeMs) {
42
+ dupes++;
43
+ if (dupes % 20 === 0) log.warn(`[${tag}] ${kind} 重复出现 ${dupes} 次 (同类错误已折叠)`);
44
+ return;
45
+ }
46
+ lastSig = sig;
47
+ lastAt = now;
48
+ dupes = 0;
49
+ const detail = err?.stack || err?.message || String(err);
50
+ log.error(`[${tag}] 捕获 ${kind} (进程保持运行): ${detail}`);
51
+ try { onError && onError(err, kind); } catch { /* 兜底本身不得再抛 */ }
52
+ };
53
+ }
54
+
55
+ /**
56
+ * 安装全局异常兜底 (幂等: 重复调用只安装一次)。
57
+ * @param {object} opts 见 createCrashReporter; 另支持 exitOnUncaught 覆盖环境变量
58
+ * @returns {function} 卸载函数
59
+ */
60
+ export function installCrashGuard(opts = {}) {
61
+ if (installed) return () => {};
62
+ installed = true;
63
+
64
+ const log = opts.logger || { error: (...a) => console.error(...a), warn: (...a) => console.warn(...a) };
65
+ const report = createCrashReporter(opts);
66
+ const exitOn = opts.exitOnUncaught != null
67
+ ? !!opts.exitOnUncaught
68
+ : String(process.env.PPX_EXIT_ON_UNCAUGHT || "") === "1";
69
+
70
+ const onUncaught = (err) => {
71
+ report("uncaughtException", err);
72
+ if (exitOn) {
73
+ log.error("[ppx] PPX_EXIT_ON_UNCAUGHT=1 → 退出 (退出码 1), 请交由外部守护拉起");
74
+ process.exit(1);
75
+ }
76
+ };
77
+ // 未处理的 Promise 拒绝在 Node >= 15 会终止进程; 桌面形态下改为记录后继续
78
+ const onRejection = (reason) => report("unhandledRejection", reason);
79
+
80
+ process.on("uncaughtException", onUncaught);
81
+ process.on("unhandledRejection", onRejection);
82
+
83
+ return () => {
84
+ process.off("uncaughtException", onUncaught);
85
+ process.off("unhandledRejection", onRejection);
86
+ installed = false;
87
+ };
88
+ }
@@ -0,0 +1,53 @@
1
+ // src/utils/http.js - HTTP 请求/响应小工具 (唯一实现, 2026-09-18 重构收敛)
2
+ // 收敛前重复情况:
3
+ // - readBody 在 channels/base、channels/http、mcp/http、aml-server 各写一份 (读循环 + 上限判定)
4
+ // - JSON 响应样板 (writeHead + Content-Type + JSON.stringify + end) 在四处共约 20 处手写
5
+ // 提取后语义由本文件单点决定; 各调用方保留自己的错误码/降级策略 (413 写响应 vs 返回 null vs 抛错)。
6
+
7
+ // 读满请求体。
8
+ // maxBytes > 0 时: 累计字节数超限即停止累积并返回 null, 由调用方决定回 413 / 抛错。
9
+ // 注意: 超限后仍把剩余数据读完 (只丢弃不累积), 不能中途 break —— 提前跳出 for await
10
+ // 会销毁请求流, 客户端还在上传时收不到响应, 表现为请求挂死而非 413。
11
+ // 2026-09-18 修复: 原实现按块 `body += chunk` 逐块 toString, TCP 分块边界落在多字节
12
+ // UTF-8 字符中间时产生 U+FFFD, 中文请求体被静默损坏。现先 Buffer.concat 再整体 decode。
13
+ export async function readBody(req, { maxBytes = 0 } = {}) {
14
+ const chunks = [];
15
+ let total = 0;
16
+ let overflow = false;
17
+ for await (const chunk of req) {
18
+ if (overflow) continue; // 已超限: 只消费不累积, 保证连接正常关闭
19
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
20
+ chunks.push(buf);
21
+ total += buf.length;
22
+ if (maxBytes > 0 && total > maxBytes) { overflow = true; chunks.length = 0; total = 0; }
23
+ }
24
+ return overflow ? null : Buffer.concat(chunks).toString("utf8");
25
+ }
26
+
27
+ // 读满请求体并解析 JSON。
28
+ // 空体 → {} (与 channels/http 既有语义一致); 超限 → null; JSON 非法 → 抛错 (调用方转 400)。
29
+ export async function readJsonBody(req, { maxBytes = 0 } = {}) {
30
+ const body = await readBody(req, { maxBytes });
31
+ if (body === null) return null;
32
+ return JSON.parse(body || "{}");
33
+ }
34
+
35
+ // SSE 响应头 (channels/http 与 mcp/http 的流式响应共用, 原先各写一份字面量)
36
+ // 冻结以防调用方误改共享对象; flushHeaders 的时机仍由调用方决定 (发头后应立即 flush)。
37
+ export const SSE_HEADERS = Object.freeze({
38
+ "Content-Type": "text/event-stream",
39
+ "Cache-Control": "no-cache",
40
+ "Connection": "keep-alive",
41
+ "X-Accel-Buffering": "no",
42
+ });
43
+
44
+ // 统一 JSON 响应。
45
+ // obj === undefined 时只结束响应不写 body (channels/http 的 204 语义)。
46
+ // contentLength=true 时补 Content-Length (aml-server 需要)。
47
+ export function sendJson(res, code, obj, { headers = {}, contentLength = false } = {}) {
48
+ const body = obj === undefined ? "" : JSON.stringify(obj);
49
+ const h = { "Content-Type": "application/json", ...headers };
50
+ if (contentLength) h["Content-Length"] = Buffer.byteLength(body);
51
+ res.writeHead(code, h);
52
+ res.end(body);
53
+ }
@@ -0,0 +1,8 @@
1
+ // src/utils/id.js - 短 ID 生成 (唯一实现, 2026-09-18 重构收敛)
2
+ // 原先 trace/playbook/mcp-tasks/asset-hub/failure-episode 各写一份
3
+ // `prefix + Date.now().toString(36) + Math.random().toString(36).slice(2, n)`, 易漂移。
4
+ // randEnd 传 Math.random().toString(36).slice(2, randEnd) 的结束下标, 与历史格式逐字节一致
5
+ // (各调用方原有 randEnd: 5/6/8, 保持不变 —— ID 只要求唯一, 不保证加密安全)。
6
+ export function shortId(prefix = "", randEnd = 6) {
7
+ return prefix + Date.now().toString(36) + Math.random().toString(36).slice(2, randEnd);
8
+ }