@nanhara/hara 0.122.2 → 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 (64) hide show
  1. package/CHANGELOG.md +108 -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 +31 -17
  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/sessions.js +3 -3
  25. package/dist/gateway/telegram.js +279 -29
  26. package/dist/gateway/tts.js +182 -38
  27. package/dist/hooks.js +22 -22
  28. package/dist/index.js +466 -158
  29. package/dist/memory/store.js +8 -5
  30. package/dist/org/planner.js +11 -6
  31. package/dist/org/projects.js +3 -3
  32. package/dist/process-identity.js +52 -0
  33. package/dist/providers/bounded-turn.js +42 -0
  34. package/dist/recall.js +69 -1
  35. package/dist/runtime.js +24 -0
  36. package/dist/sandbox.js +54 -4
  37. package/dist/search/embed.js +13 -2
  38. package/dist/search/hybrid.js +6 -3
  39. package/dist/search/semindex.js +87 -5
  40. package/dist/security/guardian.js +3 -15
  41. package/dist/security/permissions.js +11 -0
  42. package/dist/serve/server.js +70 -42
  43. package/dist/serve/sessions.js +4 -3
  44. package/dist/sync-sleep.js +46 -0
  45. package/dist/tools/agent.js +1 -1
  46. package/dist/tools/ask_user.js +5 -1
  47. package/dist/tools/builtin.js +5 -2
  48. package/dist/tools/codebase.js +67 -18
  49. package/dist/tools/computer.js +149 -68
  50. package/dist/tools/cron.js +72 -18
  51. package/dist/tools/edit.js +3 -2
  52. package/dist/tools/external_agent.js +66 -12
  53. package/dist/tools/memory.js +25 -7
  54. package/dist/tools/patch.js +11 -2
  55. package/dist/tools/registry.js +16 -1
  56. package/dist/tools/search.js +68 -9
  57. package/dist/tools/send.js +1 -1
  58. package/dist/tools/skill.js +1 -1
  59. package/dist/tools/task.js +3 -3
  60. package/dist/tools/web.js +43 -13
  61. package/dist/tui/App.js +93 -25
  62. package/dist/vision.js +5 -6
  63. package/package.json +2 -2
  64. package/runtime-bootstrap.cjs +22 -0
@@ -8,6 +8,7 @@ import { createHash, randomBytes } from "node:crypto";
8
8
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
+ import { sleepSync } from "../sync-sleep.js";
11
12
  /** Idle window before a chat auto-rotates to a FRESH session (hours). A WeChat/Feishu chat is one
12
13
  * endless surface — without this, days-old context piles onto every new ask and the agent answers
13
14
  * from stale state (the "gateway answers with old context" report). Default 8h: overnight gaps start
@@ -23,7 +24,6 @@ const dir = () => join(haraDir(), "gateway");
23
24
  const file = () => join(dir(), "chats.json");
24
25
  const lockFile = () => `${file()}.lock`;
25
26
  const reclaimFile = () => `${lockFile()}.reclaim`;
26
- const waitCell = new Int32Array(new SharedArrayBuffer(4));
27
27
  const LOCK_WAIT_MS = 5_000;
28
28
  function errorCode(error) {
29
29
  return typeof error === "object" && error !== null && "code" in error
@@ -114,7 +114,7 @@ function acquireLock() {
114
114
  }
115
115
  if (Date.now() >= deadline)
116
116
  throw new Error(`Timed out waiting for gateway session store lock: ${lockFile()}`);
117
- Atomics.wait(waitCell, 0, 0, 10);
117
+ sleepSync(10);
118
118
  continue;
119
119
  }
120
120
  try {
@@ -157,7 +157,7 @@ function acquireLock() {
157
157
  if (Date.now() >= deadline) {
158
158
  throw new Error(`Timed out waiting for gateway session store lock: ${lockFile()}`);
159
159
  }
160
- Atomics.wait(waitCell, 0, 0, 10);
160
+ sleepSync(10);
161
161
  }
162
162
  }
163
163
  }
@@ -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
  }