@nanhara/hara 0.122.3 → 0.122.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +15 -2
  3. package/dist/agent/limits.js +51 -0
  4. package/dist/agent/loop.js +367 -112
  5. package/dist/agent/repeat-guard.js +49 -12
  6. package/dist/checkpoints.js +9 -0
  7. package/dist/cli.js +2 -1
  8. package/dist/config.js +10 -2
  9. package/dist/context/agents-md.js +21 -2
  10. package/dist/context/mentions.js +84 -1
  11. package/dist/context/workspace-scope.js +49 -0
  12. package/dist/cron/deliver.js +61 -38
  13. package/dist/cron/runner.js +423 -65
  14. package/dist/cron/schedule.js +23 -7
  15. package/dist/cron/store.js +709 -43
  16. package/dist/fs-walk.js +279 -7
  17. package/dist/fs-write.js +9 -0
  18. package/dist/gateway/feishu.js +351 -64
  19. package/dist/gateway/flows-pending.js +28 -14
  20. package/dist/gateway/flows.js +306 -31
  21. package/dist/gateway/outbound-files.js +20 -6
  22. package/dist/gateway/runtime-state.js +1485 -0
  23. package/dist/gateway/serve.js +550 -242
  24. package/dist/gateway/telegram.js +279 -29
  25. package/dist/gateway/tts.js +182 -38
  26. package/dist/hooks.js +22 -22
  27. package/dist/index.js +466 -158
  28. package/dist/memory/store.js +8 -5
  29. package/dist/org/planner.js +11 -6
  30. package/dist/process-identity.js +52 -0
  31. package/dist/providers/bounded-turn.js +42 -0
  32. package/dist/recall.js +69 -1
  33. package/dist/runtime.js +24 -0
  34. package/dist/sandbox.js +54 -4
  35. package/dist/search/embed.js +13 -2
  36. package/dist/search/hybrid.js +6 -3
  37. package/dist/search/semindex.js +87 -5
  38. package/dist/security/guardian.js +3 -15
  39. package/dist/security/permissions.js +11 -0
  40. package/dist/serve/server.js +70 -42
  41. package/dist/serve/sessions.js +4 -3
  42. package/dist/tools/agent.js +1 -1
  43. package/dist/tools/ask_user.js +5 -1
  44. package/dist/tools/builtin.js +5 -2
  45. package/dist/tools/codebase.js +67 -18
  46. package/dist/tools/computer.js +149 -68
  47. package/dist/tools/cron.js +72 -18
  48. package/dist/tools/edit.js +3 -2
  49. package/dist/tools/external_agent.js +66 -12
  50. package/dist/tools/memory.js +25 -7
  51. package/dist/tools/patch.js +11 -2
  52. package/dist/tools/registry.js +16 -1
  53. package/dist/tools/search.js +68 -9
  54. package/dist/tools/send.js +1 -1
  55. package/dist/tools/skill.js +1 -1
  56. package/dist/tools/web.js +43 -13
  57. package/dist/tui/App.js +93 -25
  58. package/dist/vision.js +5 -6
  59. package/package.json +2 -2
  60. package/runtime-bootstrap.cjs +22 -0
@@ -2,8 +2,78 @@
2
2
  // fetch, zero new dep). Token from HARA_TELEGRAM_TOKEN. The generic `ChatAdapter` shape is what WeChat(iLink)
3
3
  // / Feishu plug into next.
4
4
  import { InboundMediaBudget, savePrivateResponse } from "./media.js";
5
+ import { gatewayRuntimeScope } from "./runtime-state.js";
5
6
  const API = "https://api.telegram.org";
6
7
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
+ const processOutboundTails = new Map();
9
+ /** Keeps each logical outbound message/file atomic within one credential-scoped chat while unrelated chats
10
+ * stay concurrent. The map is process-global rather than adapter-local because cron/flow delivery constructs
11
+ * short-lived adapters; those instances must not interleave chunks with the long-lived gateway adapter. */
12
+ export class PerChatOutboundLane {
13
+ scope;
14
+ constructor(platform = "gateway", connectionIdentity = "default") {
15
+ // `gatewayRuntimeScope` hashes the credential identity. Raw tokens/app ids never enter map keys, logs, or
16
+ // persisted state, while two adapters for the same bot still share one lane.
17
+ this.scope = gatewayRuntimeScope(platform, connectionIdentity);
18
+ }
19
+ run(chatId, task) {
20
+ const key = `${this.scope}\0${String(chatId)}`;
21
+ const previous = processOutboundTails.get(key) ?? Promise.resolve();
22
+ const operation = previous.then(task, task);
23
+ // A failure is returned to its caller but cannot poison later sends in the same chat.
24
+ const tail = operation.then(() => undefined, () => undefined);
25
+ processOutboundTails.set(key, tail);
26
+ void tail.then(() => {
27
+ if (processOutboundTails.get(key) === tail)
28
+ processOutboundTails.delete(key);
29
+ });
30
+ return operation;
31
+ }
32
+ }
33
+ const DEFAULT_TEXT_TRANSFER_TIMEOUT_MS = 30_000;
34
+ const DEFAULT_FILE_TRANSFER_TIMEOUT_MS = 120_000;
35
+ const MAX_OUTBOUND_TRANSFER_TIMEOUT_MS = 120_000;
36
+ const MIN_OUTBOUND_TRANSFER_TIMEOUT_MS = 50;
37
+ /** Operator/test override remains bounded: outbound transport can never disable or exceed the hard ceiling. */
38
+ export function outboundTransferTimeoutMs(kind, value = process.env.HARA_GATEWAY_OUTBOUND_TIMEOUT_MS) {
39
+ const fallback = kind === "file" ? DEFAULT_FILE_TRANSFER_TIMEOUT_MS : DEFAULT_TEXT_TRANSFER_TIMEOUT_MS;
40
+ if (value === undefined || value === "")
41
+ return fallback;
42
+ const parsed = typeof value === "number" ? value : Number(value);
43
+ return Number.isFinite(parsed)
44
+ ? Math.max(MIN_OUTBOUND_TRANSFER_TIMEOUT_MS, Math.min(MAX_OUTBOUND_TRANSFER_TIMEOUT_MS, Math.trunc(parsed)))
45
+ : fallback;
46
+ }
47
+ /** Cooperative cancellation plus a hard caller deadline. The hard race is intentional: some transports
48
+ * ignore AbortSignal, but shutdown must still settle promptly. Adapters place this deadline outside their
49
+ * process-global lane so an ambiguous late transfer remains quarantined until it really settles. */
50
+ export async function withOutboundDeadline(label, parentSignal, timeoutMs, transfer) {
51
+ if (parentSignal?.aborted)
52
+ throw new Error(`${label} cancelled`);
53
+ const controller = new AbortController();
54
+ let rejectStopped;
55
+ const stopped = new Promise((_resolve, reject) => { rejectStopped = reject; });
56
+ let stoppedOnce = false;
57
+ const stop = (reason) => {
58
+ if (stoppedOnce)
59
+ return;
60
+ stoppedOnce = true;
61
+ controller.abort(reason);
62
+ rejectStopped(reason);
63
+ };
64
+ const onParentAbort = () => stop(new Error(`${label} cancelled`));
65
+ parentSignal?.addEventListener("abort", onParentAbort, { once: true });
66
+ const timer = setTimeout(() => stop(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
67
+ try {
68
+ // Starting through a microtask turns synchronous SDK throws into the same bounded promise path.
69
+ const running = Promise.resolve().then(() => transfer(controller.signal));
70
+ return await Promise.race([running, stopped]);
71
+ }
72
+ finally {
73
+ clearTimeout(timer);
74
+ parentSignal?.removeEventListener("abort", onParentAbort);
75
+ }
76
+ }
7
77
  /** Extract an InboundMsg from a Telegram getUpdates result item (pure). Accepts text and photo messages
8
78
  * (photo → caption or a "[图片]" marker; the image itself is downloaded in start()). null otherwise. */
9
79
  export function parseTelegramUpdate(u) {
@@ -19,6 +89,8 @@ export function parseTelegramUpdate(u) {
19
89
  userId: m.from?.id ?? 0,
20
90
  userName: m.from?.username || m.from?.first_name || String(m.from?.id ?? ""),
21
91
  text: text || "[图片]",
92
+ ...(Number.isSafeInteger(u?.update_id) ? { messageId: String(u.update_id) } : {}),
93
+ ...(Number.isFinite(Number(m.date)) && Number(m.date) > 0 ? { createdAtMs: Math.trunc(Number(m.date) * 1_000) } : {}),
22
94
  ...(m.chat?.type === "private" ? { chatType: "p2p" } : ["group", "supergroup"].includes(m.chat?.type) ? { chatType: "group" } : {}),
23
95
  };
24
96
  }
@@ -44,19 +116,114 @@ async function downloadTelegramFile(base, token, fileId, options) {
44
116
  return null;
45
117
  }
46
118
  }
47
- /** Split text into chunks ≤ max (Telegram caps a message at 4096 chars) (pure). */
119
+ let messageSegmenter;
120
+ function messageGraphemes(text) {
121
+ // Keep user-perceived characters (ZWJ emoji, flags, combining marks, CRLF) together. The fallback still
122
+ // protects surrogate pairs on runtimes without full ICU, although supported Hara runtimes provide it.
123
+ if (messageSegmenter === undefined) {
124
+ messageSegmenter = typeof Intl.Segmenter === "function"
125
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
126
+ : null;
127
+ }
128
+ return messageSegmenter
129
+ ? Array.from(messageSegmenter.segment(text), (entry) => entry.segment)
130
+ : Array.from(text);
131
+ }
132
+ function splitOversizedGrapheme(grapheme, max) {
133
+ const pieces = [];
134
+ let piece = "";
135
+ for (const point of grapheme) {
136
+ if (point.length > max) {
137
+ if (piece) {
138
+ pieces.push(piece);
139
+ piece = "";
140
+ }
141
+ // Only possible when max=1 and the code point is an astral character. The platform's UTF-16 bound is
142
+ // non-negotiable, so this is the sole case where even a code point must be split into code units.
143
+ for (let at = 0; at < point.length; at += max)
144
+ pieces.push(point.slice(at, at + max));
145
+ continue;
146
+ }
147
+ if (piece.length + point.length > max) {
148
+ pieces.push(piece);
149
+ piece = point;
150
+ }
151
+ else {
152
+ piece += point;
153
+ }
154
+ }
155
+ if (piece)
156
+ pieces.push(piece);
157
+ return pieces;
158
+ }
159
+ /** Split text into chunks whose JavaScript UTF-16 length is ≤ max (pure). */
48
160
  export function chunkText(text, max = 4000) {
161
+ if (!Number.isSafeInteger(max) || max < 1)
162
+ throw new RangeError("chunk size must be a positive integer");
49
163
  if (text.length <= max)
50
164
  return [text];
165
+ const units = messageGraphemes(text).flatMap((grapheme) => {
166
+ // A maliciously huge combining sequence cannot bypass the service limit. Splitting such a single cluster
167
+ // is the only case where both the hard platform bound and grapheme integrity cannot be satisfied.
168
+ return grapheme.length <= max ? [grapheme] : splitOversizedGrapheme(grapheme, max);
169
+ });
170
+ const widths = units.map((unit) => unit.length);
51
171
  const out = [];
52
- for (let i = 0; i < text.length; i += max)
53
- out.push(text.slice(i, i + max));
172
+ let start = 0;
173
+ while (start < units.length) {
174
+ let end = start;
175
+ let width = 0;
176
+ while (end < units.length && width + widths[end] <= max)
177
+ width += widths[end++];
178
+ if (end < units.length) {
179
+ const softFloor = Math.max(1, Math.floor(max / 2));
180
+ let breakAt = -1;
181
+ // Paragraph/line boundaries preserve the shape of long agent replies. Whitespace is the fallback;
182
+ // CJK prose without either still gets a safe hard grapheme boundary.
183
+ let candidateWidth = width;
184
+ for (let i = end - 1; i >= start; i--) {
185
+ if (candidateWidth < softFloor)
186
+ break;
187
+ if (units[i].includes("\n")) {
188
+ breakAt = i + 1;
189
+ break;
190
+ }
191
+ candidateWidth -= widths[i];
192
+ }
193
+ if (breakAt < 0) {
194
+ candidateWidth = width;
195
+ for (let i = end - 1; i >= start; i--) {
196
+ if (candidateWidth < softFloor)
197
+ break;
198
+ if (/^\s+$/u.test(units[i])) {
199
+ breakAt = i + 1;
200
+ break;
201
+ }
202
+ candidateWidth -= widths[i];
203
+ }
204
+ }
205
+ if (breakAt > start)
206
+ end = breakAt;
207
+ }
208
+ out.push(units.slice(start, end).join(""));
209
+ start = end;
210
+ }
54
211
  return out;
55
212
  }
56
213
  export function telegramAdapter(token) {
57
214
  const base = `${API}/bot${token}`;
58
- const request = async (method, init) => {
59
- const response = await fetch(`${base}/${method}`, init);
215
+ const outbound = new PerChatOutboundLane("telegram", token);
216
+ const request = async (method, init, signal) => {
217
+ let response;
218
+ try {
219
+ response = await fetch(`${base}/${method}`, { ...init, signal });
220
+ }
221
+ catch {
222
+ if (signal.aborted && signal.reason instanceof Error)
223
+ throw signal.reason;
224
+ // Never let a fetch implementation echo the bot-token URL/body through gateway logs or owner replies.
225
+ throw new Error(`Telegram ${method} transport failed`);
226
+ }
60
227
  let body;
61
228
  try {
62
229
  body = await response.json();
@@ -71,39 +238,103 @@ export function telegramAdapter(token) {
71
238
  };
72
239
  return {
73
240
  name: "telegram",
74
- async send(chatId, text) {
75
- for (const part of chunkText(text || "(empty)")) {
76
- await request("sendMessage", {
77
- method: "POST",
78
- headers: { "content-type": "application/json" },
79
- body: JSON.stringify({ chat_id: chatId, text: part }),
241
+ async send(chatId, text, signal) {
242
+ await withOutboundDeadline("Telegram send", signal, outboundTransferTimeoutMs("text"), async (transferSignal) => {
243
+ return outbound.run(chatId, async () => {
244
+ if (transferSignal.aborted) {
245
+ throw transferSignal.reason instanceof Error ? transferSignal.reason : new Error("Telegram send cancelled");
246
+ }
247
+ for (const part of chunkText(text || "(empty)")) {
248
+ await request("sendMessage", {
249
+ method: "POST",
250
+ headers: { "content-type": "application/json" },
251
+ body: JSON.stringify({ chat_id: chatId, text: part }),
252
+ }, transferSignal);
253
+ }
80
254
  });
81
- }
255
+ });
82
256
  },
83
- async sendFile(chatId, file) {
84
- // images sendPhoto (inline preview); everything else → sendDocument (keeps the filename)
85
- const name = file.safeName;
86
- const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
87
- const form = new FormData();
88
- form.append("chat_id", String(chatId));
89
- form.append(isImg ? "photo" : "document", new Blob([new Uint8Array(file.bytes)]), name);
90
- await request(isImg ? "sendPhoto" : "sendDocument", { method: "POST", body: form });
257
+ async sendFile(chatId, file, signal) {
258
+ await withOutboundDeadline("Telegram upload", signal, outboundTransferTimeoutMs("file"), async (transferSignal) => {
259
+ return outbound.run(chatId, async () => {
260
+ if (transferSignal.aborted) {
261
+ throw transferSignal.reason instanceof Error ? transferSignal.reason : new Error("Telegram upload cancelled");
262
+ }
263
+ // images → sendPhoto (inline preview); everything else sendDocument (keeps the filename)
264
+ const name = file.safeName;
265
+ const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
266
+ const form = new FormData();
267
+ form.append("chat_id", String(chatId));
268
+ form.append(isImg ? "photo" : "document", new Blob([new Uint8Array(file.bytes)]), name);
269
+ await request(isImg ? "sendPhoto" : "sendDocument", { method: "POST", body: form }, transferSignal);
270
+ });
271
+ });
91
272
  },
92
273
  async start(onMessage, signal, shouldDownload) {
93
274
  let offset = 0;
275
+ let consecutiveFailures = 0;
276
+ let outageAlerted = false;
277
+ const postAck = new Array();
94
278
  while (!signal.aborted) {
95
279
  try {
96
- const res = await fetch(`${base}/getUpdates?timeout=30&offset=${offset}`, { signal });
97
- if (!res.ok) {
98
- await sleep(2000);
99
- continue;
280
+ // Telegram's `timeout=30` is only a server-side long-poll hint. A half-open proxy/socket can ignore
281
+ // it indefinitely, so the client adds a hard margin and aborts the actual fetch after 40 seconds.
282
+ const acknowledgedCount = postAck.length;
283
+ const j = await withOutboundDeadline("Telegram poll", signal, 40_000, async (pollSignal) => {
284
+ let response;
285
+ try {
286
+ response = await fetch(`${base}/getUpdates?timeout=30&offset=${offset}`, { signal: pollSignal });
287
+ }
288
+ catch {
289
+ if (pollSignal.aborted && pollSignal.reason instanceof Error)
290
+ throw pollSignal.reason;
291
+ // Fetch/proxy errors can include the complete request URL; never let the bot token reach logs.
292
+ throw new Error("Telegram getUpdates transport failed");
293
+ }
294
+ if (!response.ok)
295
+ throw new Error(`Telegram getUpdates failed: HTTP ${response.status}`);
296
+ try {
297
+ // Keep body consumption inside the hard deadline too. Receiving headers is not completion: a
298
+ // half-open peer can otherwise leave response.json() pending forever after the timer is cleared.
299
+ return await response.json();
300
+ }
301
+ catch {
302
+ if (pollSignal.aborted && pollSignal.reason instanceof Error)
303
+ throw pollSignal.reason;
304
+ throw new Error("Telegram getUpdates returned an invalid response");
305
+ }
306
+ });
307
+ // This successful request carried the offset advanced by the previous iteration, so Telegram has
308
+ // now durably forgotten those updates. Only then may their private execution markers be removed.
309
+ const acknowledged = postAck.splice(0, acknowledgedCount);
310
+ for (const entry of acknowledged) {
311
+ try {
312
+ await entry.cleanup();
313
+ }
314
+ catch {
315
+ entry.failures++;
316
+ if (entry.failures === 1) {
317
+ console.error("hara telegram: acknowledged-event cleanup failed; private marker will retry");
318
+ }
319
+ if (entry.failures >= 5) {
320
+ console.error("hara telegram: ALERT acknowledged-event cleanup suspended after 5 failures; private marker retained for manual recovery");
321
+ }
322
+ else {
323
+ postAck.push(entry);
324
+ }
325
+ }
100
326
  }
101
- const j = (await res.json());
102
327
  for (const u of j.result ?? []) {
103
- offset = Math.max(offset, (u.update_id ?? 0) + 1);
328
+ const updateId = Number(u?.update_id);
329
+ if (!Number.isSafeInteger(updateId) || updateId < 0 || !Number.isSafeInteger(updateId + 1)) {
330
+ throw new Error("Telegram getUpdates returned an invalid update_id");
331
+ }
332
+ const nextOffset = updateId + 1;
104
333
  const msg = parseTelegramUpdate(u);
105
- if (!msg)
334
+ if (!msg) {
335
+ offset = Math.max(offset, nextOffset);
106
336
  continue;
337
+ }
107
338
  const fid = photoFileId(u); // a photo message → download it so the agent can SEE it
108
339
  if (fid && shouldDownload?.(msg) === true) {
109
340
  const budget = new InboundMediaBudget("telegram", signal);
@@ -113,12 +344,31 @@ export function telegramAdapter(token) {
113
344
  msg.transientFiles = [path];
114
345
  }
115
346
  }
116
- await onMessage(msg).catch(() => { });
347
+ // Acknowledge only after the whole gateway callback succeeds. On failure the offset stays put,
348
+ // so Telegram redelivers this update and later batch entries cannot overtake it.
349
+ const cleanup = await onMessage(msg);
350
+ offset = Math.max(offset, nextOffset);
351
+ if (cleanup)
352
+ postAck.push({ cleanup, failures: 0 });
353
+ if (signal.aborted)
354
+ break;
117
355
  }
356
+ if (outageAlerted)
357
+ console.error("hara telegram: ✓ polling recovered");
358
+ consecutiveFailures = 0;
359
+ outageAlerted = false;
118
360
  }
119
- catch {
361
+ catch (error) {
120
362
  if (signal.aborted)
121
363
  break;
364
+ consecutiveFailures++;
365
+ if (consecutiveFailures === 1) {
366
+ console.error(`hara telegram: update handling failed — ${error instanceof Error ? error.message : String(error)}`);
367
+ }
368
+ else if (consecutiveFailures === 3 || consecutiveFailures % 10 === 0) {
369
+ outageAlerted = true;
370
+ console.error(`hara telegram: ALERT polling/handling has failed ${consecutiveFailures} consecutive times`);
371
+ }
122
372
  await sleep(2000); // network blip → back off + retry
123
373
  }
124
374
  }
@@ -11,8 +11,18 @@
11
11
  import { spawn } from "node:child_process";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
- import { writeFileSync } from "node:fs";
14
+ import { chmodSync, lstatSync, rmSync, writeFileSync } from "node:fs";
15
15
  import { randomUUID } from "node:crypto";
16
+ import { terminateSubprocessTree } from "../security/subprocess-env.js";
17
+ const DEFAULT_TTS_TIMEOUT_MS = 60_000;
18
+ const MAX_TTS_TIMEOUT_MS = 120_000;
19
+ /** TTS cannot opt out of a deadline. Small values remain available for deterministic health checks/tests. */
20
+ export function ttsTimeoutMs(value = process.env.HARA_TTS_TIMEOUT_MS) {
21
+ const parsed = typeof value === "number" ? value : Number(value);
22
+ return Number.isFinite(parsed) && parsed > 0
23
+ ? Math.max(50, Math.min(Math.floor(parsed), MAX_TTS_TIMEOUT_MS))
24
+ : DEFAULT_TTS_TIMEOUT_MS;
25
+ }
16
26
  /** Read TTS settings from the environment (fully configurable — nothing hardcoded). */
17
27
  export function ttsConfigFromEnv(env = process.env) {
18
28
  return {
@@ -22,6 +32,8 @@ export function ttsConfigFromEnv(env = process.env) {
22
32
  baseURL: (env.HARA_TTS_BASE_URL || "").trim(),
23
33
  apiKey: (env.HARA_TTS_API_KEY || "").trim(),
24
34
  cmd: (env.HARA_TTS_CMD || "").trim(),
35
+ // Passing an explicit fixture env must not accidentally fall back to the live process environment.
36
+ timeoutMs: ttsTimeoutMs(env.HARA_TTS_TIMEOUT_MS ?? Number.NaN),
25
37
  };
26
38
  }
27
39
  /** Normalize a reply for speech: collapse whitespace, drop code fences, cap length (long audio is unwanted). */
@@ -32,38 +44,113 @@ export function ttsCleanText(text, max = 1200) {
32
44
  .trim()
33
45
  .slice(0, max);
34
46
  }
35
- function run(cmd, args, stdin) {
36
- return new Promise((resolve) => {
37
- const c = spawn(cmd, args, { stdio: [stdin != null ? "pipe" : "ignore", "ignore", "ignore"] });
38
- c.on("error", () => resolve(false));
39
- c.on("close", (code) => resolve(code === 0));
40
- if (stdin != null)
41
- c.stdin?.end(stdin);
47
+ function abortReason(signal) {
48
+ return signal.reason instanceof Error ? signal.reason : new Error("TTS cancelled");
49
+ }
50
+ function throwIfAborted(signal) {
51
+ if (signal.aborted)
52
+ throw abortReason(signal);
53
+ }
54
+ /** Race even an API implementation that ignores AbortSignal, while still passing the signal to its fetch. */
55
+ function withAbort(task, signal) {
56
+ if (signal.aborted)
57
+ return Promise.reject(abortReason(signal));
58
+ return new Promise((resolve, reject) => {
59
+ let settled = false;
60
+ const finish = (fn) => {
61
+ if (settled)
62
+ return;
63
+ settled = true;
64
+ signal.removeEventListener("abort", onAbort);
65
+ fn();
66
+ };
67
+ const onAbort = () => finish(() => reject(abortReason(signal)));
68
+ signal.addEventListener("abort", onAbort, { once: true });
69
+ task.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
70
+ });
71
+ }
72
+ /** Run local TTS in its own process group. Cancellation escalates TERM→KILL for the whole descendant tree. */
73
+ function run(cmd, args, signal, stdin) {
74
+ throwIfAborted(signal);
75
+ return new Promise((resolve, reject) => {
76
+ const processGroup = process.platform !== "win32";
77
+ let child;
78
+ try {
79
+ child = spawn(cmd, args, {
80
+ stdio: [stdin != null ? "pipe" : "ignore", "ignore", "ignore"],
81
+ detached: processGroup,
82
+ windowsHide: true,
83
+ });
84
+ }
85
+ catch {
86
+ resolve(false);
87
+ return;
88
+ }
89
+ let settled = false;
90
+ let stopping = false;
91
+ let cancelTermination;
92
+ const settle = (ok, error) => {
93
+ if (settled)
94
+ return;
95
+ settled = true;
96
+ signal.removeEventListener("abort", stop);
97
+ // Cancel only the API fallback. If TERM closed the direct child while a descendant survived, the helper's
98
+ // referenced force timer still kills the owned process group after the grace period.
99
+ cancelTermination?.();
100
+ child.stdin?.destroy();
101
+ if (error)
102
+ reject(error);
103
+ else
104
+ resolve(ok);
105
+ };
106
+ const stop = () => {
107
+ if (settled || stopping)
108
+ return;
109
+ stopping = true;
110
+ cancelTermination = terminateSubprocessTree(child, {
111
+ processGroup,
112
+ graceMs: 250,
113
+ fallbackMs: 250,
114
+ onFallback: () => settle(false, abortReason(signal)),
115
+ });
116
+ };
117
+ child.once("error", () => settle(false, stopping ? abortReason(signal) : undefined));
118
+ child.once("close", (code) => settle(code === 0, stopping ? abortReason(signal) : undefined));
119
+ child.stdin?.on("error", () => { });
120
+ signal.addEventListener("abort", stop, { once: true });
121
+ if (signal.aborted)
122
+ stop();
123
+ if (stdin != null && !stopping)
124
+ child.stdin?.end(stdin);
42
125
  });
43
126
  }
44
127
  // local: macOS `say` → m4a (AAC). Zero config; HARA_TTS_VOICE picks the voice (default Tingting / zh).
45
- async function sayTts(text, out, cfg) {
46
- return run("say", ["-v", cfg.voice || "Tingting", "-o", out, "--file-format", "m4af", "--data-format", "aac", text]);
128
+ async function sayTts(text, out, cfg, signal) {
129
+ return run("say", ["-v", cfg.voice || "Tingting", "-o", out, "--file-format", "m4af", "--data-format", "aac", text], signal);
47
130
  }
48
131
  // API: OpenAI-compatible /audio/speech. Works against OpenAI, Aliyun DashScope (compatible-mode), or a local
49
132
  // server exposing the same shape — set HARA_TTS_BASE_URL + HARA_TTS_API_KEY + HARA_TTS_MODEL + HARA_TTS_VOICE.
50
- async function openaiTts(text, out, cfg) {
133
+ async function openaiTts(text, out, cfg, signal) {
51
134
  if (!cfg.apiKey && !cfg.baseURL)
52
135
  return false; // not configured → unavailable
53
- const { default: OpenAI } = (await import("openai"));
54
- const client = new OpenAI({ baseURL: cfg.baseURL || undefined, apiKey: cfg.apiKey || "x" });
55
- const res = await client.audio.speech.create({ model: cfg.model || "tts-1", voice: cfg.voice || "alloy", input: text });
56
- const buf = Buffer.from(await res.arrayBuffer());
57
- if (!buf.length)
58
- return false;
59
- writeFileSync(out, buf);
60
- return true;
136
+ return withAbort((async () => {
137
+ const { default: OpenAI } = (await import("openai"));
138
+ throwIfAborted(signal);
139
+ const client = new OpenAI({ baseURL: cfg.baseURL || undefined, apiKey: cfg.apiKey || "x" });
140
+ const res = await client.audio.speech.create({ model: cfg.model || "tts-1", voice: cfg.voice || "alloy", input: text }, { signal });
141
+ const buf = Buffer.from(await res.arrayBuffer());
142
+ throwIfAborted(signal);
143
+ if (!buf.length)
144
+ return false;
145
+ writeFileSync(out, buf, { flag: "wx", mode: 0o600 });
146
+ return true;
147
+ })(), signal);
61
148
  }
62
149
  // local: a configurable command (e.g. VoxCPM). `{out}` → output path; the text is piped on stdin.
63
- async function cmdTts(text, out, cfg) {
150
+ async function cmdTts(text, out, cfg, signal) {
64
151
  if (!cfg.cmd)
65
152
  return false;
66
- return run("sh", ["-c", cfg.cmd.replace(/\{out\}/g, out)], text);
153
+ return run("sh", ["-c", cfg.cmd.replace(/\{out\}/g, out)], signal, text);
67
154
  }
68
155
  const PROVIDERS = {
69
156
  say: sayTts,
@@ -71,30 +158,87 @@ const PROVIDERS = {
71
158
  cmd: cmdTts,
72
159
  };
73
160
  const EXT = { say: "m4a", openai: "mp3", cmd: "wav" };
74
- /** Synthesize `text` to a temp audio file; returns its path, or null on failure. Falls back to local `say`
75
- * if a configured provider fails (so a misconfigured API never silently kills voice replies). */
76
- export async function synthesize(text, cfg = ttsConfigFromEnv()) {
161
+ function isAbortSignal(value) {
162
+ return Boolean(value && typeof value.addEventListener === "function" && typeof value.aborted === "boolean");
163
+ }
164
+ function safeProviderError(error, cfg) {
165
+ let message = error instanceof Error ? error.message : String(error);
166
+ for (const [secret, replacement] of [[cfg.apiKey, "[redacted]"], [cfg.baseURL, "[redacted-url]"]]) {
167
+ if (secret)
168
+ message = message.split(secret).join(replacement);
169
+ }
170
+ return message;
171
+ }
172
+ function secureCompletedAudio(path) {
173
+ try {
174
+ const stat = lstatSync(path);
175
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size <= 0)
176
+ return false;
177
+ chmodSync(path, 0o600);
178
+ return true;
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ /** Synthesize `text` to a temp audio file; returns its path, or null on provider/deadline failure. The legacy
185
+ * `synthesize(text, config)` form remains valid; gateway callers use `synthesize(text, signal, config?)`. */
186
+ export async function synthesize(text, signalOrConfig, explicitConfig) {
187
+ const parentSignal = isAbortSignal(signalOrConfig) ? signalOrConfig : undefined;
188
+ if (parentSignal?.aborted)
189
+ throw abortReason(parentSignal);
77
190
  const clean = ttsCleanText(text);
78
191
  if (!clean)
79
192
  return null;
193
+ const cfg = isAbortSignal(signalOrConfig)
194
+ ? (explicitConfig ?? ttsConfigFromEnv())
195
+ : (explicitConfig ?? signalOrConfig ?? ttsConfigFromEnv());
196
+ const deadline = new AbortController();
197
+ const timeoutMs = ttsTimeoutMs(cfg.timeoutMs);
198
+ const timeoutError = new Error(`TTS timed out after ${timeoutMs}ms`);
199
+ const timeout = setTimeout(() => deadline.abort(timeoutError), timeoutMs);
200
+ const cancel = () => deadline.abort(parentSignal?.reason instanceof Error ? parentSignal.reason : new Error("TTS cancelled"));
201
+ parentSignal?.addEventListener("abort", cancel, { once: true });
80
202
  const provider = PROVIDERS[cfg.provider] ? cfg.provider : "say";
81
- const out = join(tmpdir(), `hara-tts-${randomUUID().slice(0, 8)}.${EXT[provider] || "wav"}`);
203
+ const out = join(tmpdir(), `hara-tts-${randomUUID()}.${EXT[provider] || "wav"}`);
82
204
  try {
83
- if (await PROVIDERS[provider](clean, out, cfg))
84
- return out;
85
- }
86
- catch (e) {
87
- console.error(`tts(${provider}): ${e?.message ?? e}`);
88
- }
89
- if (provider !== "say") {
90
205
  try {
91
- const o2 = join(tmpdir(), `hara-tts-${randomUUID().slice(0, 8)}.m4a`);
92
- if (await sayTts(clean, o2, cfg))
93
- return o2; // graceful fallback to local
206
+ if (await PROVIDERS[provider](clean, out, cfg, deadline.signal) && secureCompletedAudio(out))
207
+ return out;
94
208
  }
95
- catch {
96
- /* give up */
209
+ catch (error) {
210
+ if (parentSignal?.aborted)
211
+ throw abortReason(parentSignal);
212
+ if (deadline.signal.aborted) {
213
+ console.error(`tts(${provider}): ${timeoutError.message}`);
214
+ return null;
215
+ }
216
+ console.error(`tts(${provider}): ${safeProviderError(error, cfg)}`);
97
217
  }
218
+ rmSync(out, { force: true });
219
+ if (provider !== "say" && !deadline.signal.aborted) {
220
+ const fallback = join(tmpdir(), `hara-tts-${randomUUID()}.m4a`);
221
+ try {
222
+ if (await sayTts(clean, fallback, cfg, deadline.signal) && secureCompletedAudio(fallback))
223
+ return fallback; // graceful local fallback
224
+ }
225
+ catch (error) {
226
+ if (parentSignal?.aborted)
227
+ throw abortReason(parentSignal);
228
+ if (deadline.signal.aborted)
229
+ console.error(`tts(say): ${timeoutError.message}`);
230
+ else
231
+ console.error(`tts(say): ${safeProviderError(error, cfg)}`);
232
+ }
233
+ rmSync(fallback, { force: true });
234
+ }
235
+ return null;
236
+ }
237
+ finally {
238
+ clearTimeout(timeout);
239
+ parentSignal?.removeEventListener("abort", cancel);
240
+ // Returned paths are owned by the caller. Every other partial/late provider output is removed here.
241
+ if (deadline.signal.aborted)
242
+ rmSync(out, { force: true });
98
243
  }
99
- return null;
100
244
  }