@nanhara/hara 0.121.0 → 0.122.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 (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
@@ -6,17 +6,65 @@
6
6
  // Opt-in: config lives in the user's ~/.hara/flows.json — no file, no flows (zero behaviour change).
7
7
  // Platform-agnostic: matching only reads the generic InboundMsg fields each adapter populates (chatType,
8
8
  // mentions), so a flow works on whatever platform surfaces the data it asks for.
9
- import { readFileSync } from "node:fs";
9
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { homedir } from "node:os";
12
- import { deliverResult } from "../cron/deliver.js";
12
+ import { deliverResult, parseDeliver } from "../cron/deliver.js";
13
+ import { addPending } from "./flows-pending.js";
14
+ import { redactSensitiveValue } from "../security/secrets.js";
13
15
  const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
16
+ function isStrings(value) {
17
+ return typeof value === "string" || (Array.isArray(value) && value.every((item) => typeof item === "string"));
18
+ }
19
+ function validFlow(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value))
21
+ return false;
22
+ const f = value;
23
+ if (typeof f.name !== "string" || !f.name.trim() || typeof f.do !== "string" || !f.do.trim())
24
+ return false;
25
+ if (f.enabled !== undefined && typeof f.enabled !== "boolean")
26
+ return false;
27
+ if (f.guard !== undefined && typeof f.guard !== "string")
28
+ return false;
29
+ if (f.cwd !== undefined && typeof f.cwd !== "string")
30
+ return false;
31
+ if (f.schema !== undefined && (!f.schema || typeof f.schema !== "object" || Array.isArray(f.schema)))
32
+ return false;
33
+ if (f.deliver !== undefined && !isStrings(f.deliver))
34
+ return false;
35
+ if (f.notifyOn !== undefined && (!Array.isArray(f.notifyOn) || !f.notifyOn.every((item) => typeof item === "string")))
36
+ return false;
37
+ if (f.replyOn !== undefined && (!Array.isArray(f.replyOn) || !f.replyOn.every((item) => typeof item === "string")))
38
+ return false;
39
+ if (f.log !== undefined && typeof f.log !== "boolean")
40
+ return false;
41
+ if (f.reply !== undefined && typeof f.reply !== "boolean")
42
+ return false;
43
+ if (f.on !== undefined) {
44
+ if (!f.on || typeof f.on !== "object" || Array.isArray(f.on))
45
+ return false;
46
+ const on = f.on;
47
+ if (on.platform !== undefined && typeof on.platform !== "string")
48
+ return false;
49
+ if (on.chat !== undefined && !isStrings(on.chat))
50
+ return false;
51
+ if (on.chatType !== undefined && !["p2p", "group", "any"].includes(String(on.chatType)))
52
+ return false;
53
+ if (on.mention !== undefined && !isStrings(on.mention))
54
+ return false;
55
+ if (on.keyword !== undefined && !isStrings(on.keyword))
56
+ return false;
57
+ if (on.ignoreKeyword !== undefined && !isStrings(on.ignoreKeyword))
58
+ return false;
59
+ }
60
+ return true;
61
+ }
14
62
  /** Load ~/.hara/flows.json — accepts a bare array or `{ "flows": [...] }`. Missing/malformed → [] (never throws). */
15
63
  export function loadFlows() {
16
64
  try {
17
65
  const parsed = JSON.parse(readFileSync(join(homedir(), ".hara", "flows.json"), "utf8"));
18
66
  const flows = Array.isArray(parsed) ? parsed : parsed?.flows;
19
- return Array.isArray(flows) ? flows.filter((f) => f && f.enabled !== false && f.name && f.do) : [];
67
+ return Array.isArray(flows) ? flows.filter((f) => validFlow(f) && f.enabled !== false).slice(0, 64) : [];
20
68
  }
21
69
  catch {
22
70
  return [];
@@ -49,40 +97,367 @@ export function matchFlow(r, m, platform) {
49
97
  const kws = asArray(on.keyword);
50
98
  if (kws.length && !kws.some((k) => (m.text ?? "").includes(k)))
51
99
  return false;
100
+ const mutes = asArray(on.ignoreKeyword);
101
+ if (mutes.length && mutes.some((k) => (m.text ?? "").includes(k)))
102
+ return false;
52
103
  return true;
53
104
  }
105
+ /** Append a run record to ~/.hara/flows-log.jsonl — the reviewable trail of everything the flow judged,
106
+ * including runs the noise policy chose NOT to push (judged + logged ≠ delivered). */
107
+ export function appendFlowLog(entry) {
108
+ try {
109
+ const dir = join(homedir(), ".hara");
110
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
111
+ chmodSync(dir, 0o700);
112
+ const file = join(dir, "flows-log.jsonl");
113
+ if (existsSync(file) && statSync(file).size >= 1_000_000) {
114
+ const rotated = `${file}.1`;
115
+ rmSync(rotated, { force: true });
116
+ renameSync(file, rotated);
117
+ chmodSync(rotated, 0o600);
118
+ }
119
+ const safe = redactSensitiveValue({ at: new Date().toISOString(), ...entry }).value;
120
+ appendFileSync(file, JSON.stringify(safe) + "\n", { encoding: "utf8", mode: 0o600 });
121
+ try {
122
+ chmodSync(file, 0o600);
123
+ }
124
+ catch {
125
+ /* best-effort on filesystems without POSIX modes */
126
+ }
127
+ }
128
+ catch {
129
+ /* logging is best-effort */
130
+ }
131
+ }
132
+ const senderFlowRate = new Map();
133
+ const chatFlowRate = new Map();
134
+ let globalFlowRate = [];
135
+ let activeFlowRuns = 0;
136
+ const MAX_ACTIVE_FLOW_RUNS = 4;
137
+ const RATE_MINUTE_MS = 60_000;
138
+ const RATE_HOUR_MS = 60 * RATE_MINUTE_MS;
139
+ const MAX_SENDER_RUNS_PER_MINUTE = 5;
140
+ const MAX_CHAT_RUNS_PER_MINUTE = 10;
141
+ const MAX_CHAT_RUNS_PER_HOUR = 60;
142
+ const MAX_GLOBAL_RUNS_PER_MINUTE = 20;
143
+ const MAX_GLOBAL_RUNS_PER_HOUR = 120;
144
+ const MAX_SENDER_RATE_BUCKETS = 1_024;
145
+ const MAX_CHAT_RATE_BUCKETS = 1_024;
146
+ const MAX_RATE_KEY_PART_CHARS = 160;
147
+ const RATE_SWEEP_INTERVAL = 64;
148
+ let claimsUntilRateSweep = RATE_SWEEP_INTERVAL;
149
+ function boundedRateKey(...parts) {
150
+ return parts
151
+ .map((value) => {
152
+ const part = String(value ?? "").slice(0, MAX_RATE_KEY_PART_CHARS);
153
+ return `${part.length}:${part}`;
154
+ })
155
+ .join("");
156
+ }
157
+ function pruneRateTimes(times, now, windowMs, maxEntries) {
158
+ const live = [];
159
+ // Walk newest-first so even a bucket left by an older implementation cannot cause an unbounded copy.
160
+ for (let i = times.length - 1; i >= 0 && live.length < maxEntries; i--) {
161
+ const at = times[i];
162
+ // A backwards wall-clock adjustment must not reopen the limiter; future timestamps remain live.
163
+ if (Number.isFinite(at) && (at > now || now - at < windowMs))
164
+ live.push(at);
165
+ }
166
+ live.reverse();
167
+ return live;
168
+ }
169
+ function sweepRateMap(map, now, windowMs, maxEntriesPerBucket) {
170
+ for (const [key, times] of map) {
171
+ const live = pruneRateTimes(times, now, windowMs, maxEntriesPerBucket);
172
+ if (live.length)
173
+ map.set(key, live);
174
+ else
175
+ map.delete(key);
176
+ }
177
+ }
178
+ function prepareRateBucket(map, key, now, windowMs, maxBuckets, maxEntriesPerBucket) {
179
+ const current = map.get(key);
180
+ if (current) {
181
+ const live = pruneRateTimes(current, now, windowMs, maxEntriesPerBucket);
182
+ if (live.length)
183
+ map.set(key, live);
184
+ else
185
+ map.delete(key);
186
+ return live;
187
+ }
188
+ if (map.size >= maxBuckets) {
189
+ sweepRateMap(map, now, windowMs, maxEntriesPerBucket);
190
+ if (map.size >= maxBuckets)
191
+ return undefined;
192
+ }
193
+ return [];
194
+ }
195
+ function countRateTimes(times, now, windowMs) {
196
+ let count = 0;
197
+ for (const at of times)
198
+ if (at > now || now - at < windowMs)
199
+ count++;
200
+ return count;
201
+ }
202
+ /** @internal Clears process-local flow quotas between isolated tests. Never call while flow runs are active. */
203
+ export function resetFlowRateStateForTests() {
204
+ senderFlowRate.clear();
205
+ chatFlowRate.clear();
206
+ globalFlowRate = [];
207
+ activeFlowRuns = 0;
208
+ claimsUntilRateSweep = RATE_SWEEP_INTERVAL;
209
+ }
210
+ function claimFlowRun(rule, m, platform) {
211
+ const now = Date.now();
212
+ if (--claimsUntilRateSweep <= 0) {
213
+ sweepRateMap(senderFlowRate, now, RATE_MINUTE_MS, MAX_SENDER_RUNS_PER_MINUTE);
214
+ sweepRateMap(chatFlowRate, now, RATE_HOUR_MS, MAX_CHAT_RUNS_PER_HOUR);
215
+ claimsUntilRateSweep = RATE_SWEEP_INTERVAL;
216
+ }
217
+ globalFlowRate = pruneRateTimes(globalFlowRate, now, RATE_HOUR_MS, MAX_GLOBAL_RUNS_PER_HOUR);
218
+ if (activeFlowRuns >= MAX_ACTIVE_FLOW_RUNS)
219
+ return false;
220
+ const senderKey = boundedRateKey(rule.name, platform, m.chatId, m.userId);
221
+ const chatKey = boundedRateKey(rule.name, platform, m.chatId);
222
+ const senderMinute = prepareRateBucket(senderFlowRate, senderKey, now, RATE_MINUTE_MS, MAX_SENDER_RATE_BUCKETS, MAX_SENDER_RUNS_PER_MINUTE);
223
+ const chatHour = prepareRateBucket(chatFlowRate, chatKey, now, RATE_HOUR_MS, MAX_CHAT_RATE_BUCKETS, MAX_CHAT_RUNS_PER_HOUR);
224
+ // A full identity table is itself an abuse signal. Reject new identities instead of evicting a live
225
+ // bucket and letting an attacker cycle through unbounded sender/chat ids.
226
+ if (!senderMinute || !chatHour)
227
+ return false;
228
+ if (senderMinute.length >= MAX_SENDER_RUNS_PER_MINUTE ||
229
+ countRateTimes(chatHour, now, RATE_MINUTE_MS) >= MAX_CHAT_RUNS_PER_MINUTE ||
230
+ chatHour.length >= MAX_CHAT_RUNS_PER_HOUR ||
231
+ countRateTimes(globalFlowRate, now, RATE_MINUTE_MS) >= MAX_GLOBAL_RUNS_PER_MINUTE ||
232
+ globalFlowRate.length >= MAX_GLOBAL_RUNS_PER_HOUR) {
233
+ return false;
234
+ }
235
+ senderMinute.push(now);
236
+ chatHour.push(now);
237
+ globalFlowRate.push(now);
238
+ senderFlowRate.set(senderKey, senderMinute);
239
+ chatFlowRate.set(chatKey, chatHour);
240
+ activeFlowRuns++;
241
+ return true;
242
+ }
243
+ /** Extract the flow agent's structured result — {disposition,briefing,draft,dispatch?} — from its text
244
+ * output. Tolerant of surrounding prose/fences; null if there's no parseable object (caller falls back). */
245
+ export function parseAgentResult(raw) {
246
+ let value;
247
+ try {
248
+ value = JSON.parse(raw.trim()); // schema-enforced runs emit exactly the JSON
249
+ }
250
+ catch {
251
+ /* fall through to prose-fishing */
252
+ }
253
+ if (value === undefined) {
254
+ const m = raw.match(/\{[\s\S]*\}/);
255
+ if (!m)
256
+ return null;
257
+ try {
258
+ value = JSON.parse(m[0]);
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ }
264
+ if (!value || typeof value !== "object" || Array.isArray(value))
265
+ return null;
266
+ const v = value;
267
+ const dispatch = v.dispatch && typeof v.dispatch === "object" && !Array.isArray(v.dispatch) ? v.dispatch : undefined;
268
+ const route = v.route && typeof v.route === "object" && !Array.isArray(v.route) ? v.route : undefined;
269
+ return {
270
+ ...(typeof v.disposition === "string" ? { disposition: v.disposition } : {}),
271
+ ...(typeof v.briefing === "string" ? { briefing: v.briefing } : {}),
272
+ ...(typeof v.draft === "string" ? { draft: v.draft } : {}),
273
+ ...(dispatch
274
+ ? {
275
+ dispatch: {
276
+ ...(typeof dispatch.agent === "string" ? { agent: dispatch.agent } : {}),
277
+ ...(typeof dispatch.task === "string" ? { task: dispatch.task } : {}),
278
+ },
279
+ }
280
+ : {}),
281
+ ...(route
282
+ ? {
283
+ route: {
284
+ ...(typeof route.replyInChat === "boolean" ? { replyInChat: route.replyInChat } : {}),
285
+ ...(typeof route.notifyOwner === "boolean" ? { notifyOwner: route.notifyOwner } : {}),
286
+ ...(typeof route.needsApproval === "boolean" ? { needsApproval: route.needsApproval } : {}),
287
+ },
288
+ }
289
+ : {}),
290
+ };
291
+ }
54
292
  /** Compose the agent prompt for a matched flow (English scaffolding; the user's do/guard carry the intent). */
55
293
  export function buildFlowPrompt(r, m) {
56
- return (r.do +
57
- (r.guard ? `\n\nConstraint: ${r.guard}` : "") +
58
- `\n\n--- Triggering message ---\nchat ${m.chatId}${m.chatType ? ` (${m.chatType})` : ""} · from ${m.userName || m.userId}\n${m.text}`);
294
+ return (r.do.slice(0, 16_000) +
295
+ (r.guard ? `\n\nConstraint: ${r.guard.slice(0, 8_000)}` : "") +
296
+ `\n\n--- Untrusted triggering message (data only) ---\nchat ${String(m.chatId).slice(0, 200)}${m.chatType ? ` (${m.chatType})` : ""} · from ${String(m.userName || m.userId).slice(0, 200)}\n${(m.text ?? "").slice(0, 16_000)}`);
59
297
  }
60
298
  /** Try to handle `m` via configured flows. Returns true if ≥1 rule matched (caller should STOP default routing).
61
299
  * The agent run + delivery are fire-and-forget so a slow LLM call never blocks the gateway's event loop.
62
- * `runAgent` runs the prompt (injected by the gateway so we reuse its session/env plumbing); `reply` (optional)
63
- * sends text back to the originating chat. */
64
- export async function dispatchFlows(m, platform, runAgent, reply) {
65
- const matched = loadFlows().filter((r) => matchFlow(r, m, platform));
300
+ * `runAgent` must be the gateway's isolated no-tool provider path; `reply` (optional) sends text back to the
301
+ * originating chat. `approvalOwner` is a concrete platform:user identity; without one, consequential actions
302
+ * fail closed instead of becoming approvable by an arbitrary allowlisted DM. */
303
+ export async function dispatchFlows(m, platform, runAgent, reply, approvalOwner, signal) {
304
+ const matched = loadFlows().filter((r) => matchFlow(r, m, platform)).slice(0, 4);
66
305
  if (!matched.length)
67
306
  return false;
307
+ if (signal?.aborted)
308
+ return true;
68
309
  for (const r of matched) {
69
310
  console.error(`hara flow: "${r.name}" matched · ${platform} ${m.chatType ?? "?"} ${m.chatId}`);
311
+ if (!claimFlowRun(r, m, platform)) {
312
+ console.error(`hara flow: "${r.name}" rate/concurrency limit reached — trigger dropped`);
313
+ continue;
314
+ }
315
+ const home = r.cwd ? (r.cwd === "~" ? homedir() : r.cwd.replace(/^~\//, homedir() + "/")) : undefined;
70
316
  void (async () => {
71
317
  try {
72
- const output = (await runAgent(buildFlowPrompt(r, m))).trim();
318
+ const output = (await runAgent(buildFlowPrompt(r, m), home, r.schema, signal)).trim();
319
+ if (signal?.aborted)
320
+ return;
73
321
  if (!output)
74
322
  return;
75
- if (r.deliver) {
76
- const err = await deliverResult(r.deliver, output);
323
+ const parsed = parseAgentResult(output);
324
+ const dispo = parsed?.disposition;
325
+ // Draft sanitation: leaked tool-call/XML markup is model plumbing, not a human-readable message —
326
+ // never park or post it (a real incident sent "<tool_call>dispatch…" to a group). Drop such drafts.
327
+ if (parsed?.draft && /<\/?(tool_call|arg_key|arg_value|invoke|function)/i.test(parsed.draft)) {
328
+ console.error(`hara flow "${r.name}": draft contained tool-call markup — dropped`);
329
+ parsed.draft = "";
330
+ }
331
+ let briefing = parsed?.briefing?.trim() || output;
332
+ const park = (action) => {
333
+ if (!approvalOwner) {
334
+ console.error(`hara flow "${r.name}": approval required but no unique owner is configured — action dropped`);
335
+ briefing += "\n\n⚠ 需要审批,但网关未配置唯一主人;动作已安全丢弃。设置 HARA_GATEWAY_OWNER 后重试。";
336
+ return undefined;
337
+ }
338
+ const executionTarget = action.kind === "org" ? action.origin : action.target;
339
+ if (executionTarget) {
340
+ const deliverable = parseDeliver(executionTarget);
341
+ if ("error" in deliverable) {
342
+ console.error(`hara flow "${r.name}": approval target cannot be delivered later — ${deliverable.error}`);
343
+ briefing += `\n\n⚠ 当前平台只支持即时回复,暂不支持离线审批后回发;动作已安全丢弃。`;
344
+ return undefined;
345
+ }
346
+ }
347
+ return addPending({ ...action, owner: approvalOwner });
348
+ };
349
+ // Every judged run lands in the log — the reviewable trail even when the noise policy stays silent.
350
+ if (r.log !== false) {
351
+ appendFlowLog({
352
+ flow: r.name,
353
+ chat: String(m.chatId),
354
+ from: String(m.userName || m.userId),
355
+ text: (m.text ?? "").slice(0, 300),
356
+ disposition: dispo ?? null,
357
+ briefing: briefing.slice(0, 500),
358
+ ...(parsed?.draft ? { draft: parsed.draft.slice(0, 500) } : {}),
359
+ ...(parsed?.dispatch ? { dispatch: parsed.dispatch } : {}),
360
+ });
361
+ }
362
+ // AI-decided routing (preferred): the agent judged reply/notify/approval per the rule's natural-
363
+ // language policy — code below only EXECUTES that judgment. The enum gates (replyOn/notifyOn) further
364
+ // down remain as legacy fallback for rules whose agents don't emit a route.
365
+ if (parsed?.route) {
366
+ const route = parsed.route;
367
+ const wantsDispatch = !!(parsed.dispatch?.agent && parsed.dispatch.task);
368
+ const draft = parsed.draft?.trim();
369
+ // A model may recommend replyInChat, but it cannot grant itself send authority. Auto-reply is an
370
+ // explicit config capability (`replyOn` + matching disposition); every other drafted send is parked.
371
+ const canAutoReply = !!(draft && dispo && r.replyOn?.includes(dispo));
372
+ if (wantsDispatch) {
373
+ // Delegation stays owner-gated regardless of the route — an authorization boundary, not a judgment.
374
+ const pendingDispatch = park({
375
+ kind: "org",
376
+ target: parsed.dispatch.agent,
377
+ draft: parsed.dispatch.task,
378
+ context: `派单 ${parsed.dispatch.agent}: ${parsed.dispatch.task.slice(0, 30)}`,
379
+ notify: r.deliver,
380
+ origin: `${platform}:${m.chatId}`,
381
+ });
382
+ if (pendingDispatch)
383
+ briefing += `\n\n[${pendingDispatch.id}] 拟派单:${parsed.dispatch.agent} ← ${parsed.dispatch.task}\n—— /approve ${pendingDispatch.id} 派出 · /reject ${pendingDispatch.id} 取消`;
384
+ }
385
+ if (draft && (route.needsApproval || route.replyInChat) && (!canAutoReply || route.needsApproval || wantsDispatch)) {
386
+ const pendingSend = park({ kind: "send", target: `${platform}:${m.chatId}`, draft, context: (parsed.briefing || parsed.draft).slice(0, 40), notify: r.deliver });
387
+ if (pendingSend) {
388
+ briefing += `\n\n[${pendingSend.id}] 拟发到群:${draft}\n—— /approve ${pendingSend.id} 发出 · /edit ${pendingSend.id} <内容> · /reject ${pendingSend.id}`;
389
+ }
390
+ }
391
+ else if (route.replyInChat && canAutoReply && reply) {
392
+ if (!signal?.aborted)
393
+ await reply(draft).catch(() => { });
394
+ console.error(`hara flow "${r.name}": answered in-chat (route)`);
395
+ }
396
+ if (route.notifyOwner || route.needsApproval || wantsDispatch || (route.replyInChat && !canAutoReply)) {
397
+ for (const d of asArray(r.deliver)) {
398
+ if (signal?.aborted)
399
+ return;
400
+ const err = await deliverResult(d, briefing);
401
+ if (err)
402
+ console.error(`hara flow "${r.name}": deliver(${d}) failed — ${err}`);
403
+ }
404
+ }
405
+ else
406
+ console.error(`hara flow "${r.name}": routed silently (no owner notify)`);
407
+ return;
408
+ }
409
+ // Auto-answer lane (replyOn): safe Q&A goes straight back to the origin chat — no owner round-trip.
410
+ // Runs BEFORE the noise gate so an answered question needs no WeChat push at all.
411
+ if (r.replyOn?.length && dispo && r.replyOn.includes(dispo) && parsed?.draft?.trim() && reply) {
412
+ if (!signal?.aborted)
413
+ await reply(parsed.draft.trim()).catch(() => { });
414
+ console.error(`hara flow "${r.name}": ${dispo} — answered in-chat (replyOn)`);
415
+ if (!r.notifyOn?.length || !r.notifyOn.includes(dispo))
416
+ return; // answered; only ALSO brief the owner if asked to
417
+ }
418
+ // Noise policy (updatable config, hot-reloaded per message): only dispositions in notifyOn interrupt
419
+ // the owner. The AGENT judges the disposition; the CONFIG decides which judgments are push-worthy.
420
+ if (r.notifyOn?.length && (!dispo || !r.notifyOn.includes(dispo))) {
421
+ console.error(`hara flow "${r.name}": ${dispo ?? "missing disposition"} — logged, not delivered (notifyOn=${r.notifyOn.join(",")})`);
422
+ return;
423
+ }
424
+ // Drafted reply → park for approval; approved = posted back to the ORIGIN chat.
425
+ if (parsed?.draft?.trim() && (dispo === "reply" || dispo === "handle")) {
426
+ const pendingSend = park({ kind: "send", target: `${platform}:${m.chatId}`, draft: parsed.draft.trim(), context: (parsed.briefing || parsed.draft).slice(0, 40), notify: r.deliver });
427
+ if (pendingSend) {
428
+ briefing += `\n\n[${pendingSend.id}] 拟发到群:${parsed.draft.trim()}\n—— /approve ${pendingSend.id} 发出 · /edit ${pendingSend.id} <内容> · /reject ${pendingSend.id}`;
429
+ }
430
+ }
431
+ // Agent-proposed delegation → park an org dispatch; approved = that agent runs AT ITS HOME (global
432
+ // index resolution). Delegation is still human-gated: proposing is free, executing needs the owner.
433
+ if (parsed?.dispatch?.agent && parsed.dispatch.task) {
434
+ const pendingDispatch = park({
435
+ kind: "org",
436
+ target: parsed.dispatch.agent,
437
+ draft: parsed.dispatch.task,
438
+ context: `派单 ${parsed.dispatch.agent}: ${parsed.dispatch.task.slice(0, 30)}`,
439
+ notify: r.deliver,
440
+ origin: `${platform}:${m.chatId}`, // the asker's chat — an approved task's result goes back here
441
+ });
442
+ if (pendingDispatch)
443
+ briefing += `\n\n[${pendingDispatch.id}] 拟派单:${parsed.dispatch.agent} ← ${parsed.dispatch.task}\n—— /approve ${pendingDispatch.id} 派出 · /reject ${pendingDispatch.id} 取消`;
444
+ }
445
+ for (const d of asArray(r.deliver)) {
446
+ if (signal?.aborted)
447
+ return;
448
+ const err = await deliverResult(d, briefing);
77
449
  if (err)
78
- console.error(`hara flow "${r.name}": deliver failed — ${err}`);
450
+ console.error(`hara flow "${r.name}": deliver(${d}) failed — ${err}`);
79
451
  }
80
- if (r.reply && reply)
81
- await reply(output).catch(() => { });
452
+ if (r.reply && reply && !signal?.aborted)
453
+ await reply(briefing).catch(() => { });
82
454
  }
83
455
  catch (e) {
84
456
  console.error(`hara flow "${r.name}": ${e instanceof Error ? e.message : String(e)}`);
85
457
  }
458
+ finally {
459
+ activeFlowRuns = Math.max(0, activeFlowRuns - 1);
460
+ }
86
461
  })();
87
462
  }
88
463
  return true;
@@ -7,9 +7,9 @@
7
7
  // LIMITATION (v1): NO end-to-end encryption. Encrypted rooms (m.room.encrypted events) are skipped — only
8
8
  // plaintext rooms work. E2EE would need libolm + a crypto store (see hermes' matrix-nio adapter), which breaks
9
9
  // the zero-dep constraint. Invite this bot into UNENCRYPTED rooms only.
10
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
11
- import { join, basename } from "node:path";
12
- import { homedir } from "node:os";
10
+ import { readFileSync } from "node:fs";
11
+ import { basename } from "node:path";
12
+ import { InboundMediaBudget, savePrivateResponse } from "./media.js";
13
13
  import { chunkText } from "./telegram.js";
14
14
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
15
15
  const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
@@ -35,10 +35,49 @@ export function parseMxc(mxc) {
35
35
  return null;
36
36
  return { server, mediaId };
37
37
  }
38
+ /** Read the latest m.direct account-data event from a /sync response. null means the response carried no
39
+ * update, while an empty Set means it explicitly cleared the mapping. Values are bounded and room-id shaped. */
40
+ export function matrixDirectRoomsFromSync(sync) {
41
+ const events = Array.isArray(sync?.account_data?.events) ? sync.account_data.events : [];
42
+ let found = false;
43
+ let content;
44
+ for (const event of events) {
45
+ if (event?.type !== "m.direct")
46
+ continue;
47
+ found = true;
48
+ content = event.content;
49
+ }
50
+ if (!found)
51
+ return null;
52
+ const rooms = new Set();
53
+ if (!content || typeof content !== "object" || Array.isArray(content))
54
+ return rooms;
55
+ for (const value of Object.values(content)) {
56
+ if (!Array.isArray(value))
57
+ continue;
58
+ for (const room of value) {
59
+ if (typeof room !== "string" || !room.startsWith("!") || room.length > 512)
60
+ continue;
61
+ rooms.add(room);
62
+ if (rooms.size >= 1000)
63
+ return rooms;
64
+ }
65
+ }
66
+ return rooms;
67
+ }
68
+ /** Matrix rooms have no intrinsic DM type. We accept p2p only when m.direct names the room AND the live
69
+ * joined-members response proves the room contains exactly this bot and this sender. Stale/shared mappings
70
+ * and malformed/unknown member shapes stay group-classified. */
71
+ export function matrixChatType(roomId, selfUserId, sender, directRooms, joinedMembers) {
72
+ if (!directRooms.has(roomId) || !joinedMembers || typeof joinedMembers !== "object" || Array.isArray(joinedMembers))
73
+ return "group";
74
+ const members = Object.keys(joinedMembers);
75
+ return members.length === 2 && members.includes(selfUserId) && members.includes(sender) ? "p2p" : "group";
76
+ }
38
77
  /** Extract an InboundMsg from a single Matrix timeline event (pure). Accepts m.room.message of msgtype m.text
39
78
  * (and friends carrying a body) plus m.image (→ marker text + the mxc url to download in start()). Ignores our
40
79
  * own messages and encrypted events. Returns the InboundMsg + the image's mxc url (null if none). null = skip. */
41
- export function parseMatrixEvent(event, selfUserId) {
80
+ export function parseMatrixEvent(event, selfUserId, chatType = "group") {
42
81
  if (!event || event.type !== "m.room.message")
43
82
  return null; // encrypted (m.room.encrypted) / state events → skip
44
83
  const sender = typeof event.sender === "string" ? event.sender : "";
@@ -65,25 +104,39 @@ export function parseMatrixEvent(event, selfUserId) {
65
104
  userId: sender,
66
105
  userName: sender,
67
106
  text: text || "[图片]",
107
+ chatType,
68
108
  },
69
109
  imageMxc,
70
110
  };
71
111
  }
112
+ async function resolveMatrixChatType(homeserver, token, roomId, selfUserId, sender, directRooms, signal) {
113
+ if (!directRooms.has(roomId))
114
+ return "group";
115
+ try {
116
+ const res = await fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`, {
117
+ headers: { Authorization: `Bearer ${token}` },
118
+ signal,
119
+ });
120
+ if (!res.ok)
121
+ return "group";
122
+ const body = (await res.json());
123
+ return matrixChatType(roomId, selfUserId, sender, directRooms, body?.joined);
124
+ }
125
+ catch {
126
+ return "group";
127
+ }
128
+ }
72
129
  /** Download a Matrix mxc:// media to a local path under ~/.hara/matrix/media (resolves via /_matrix/media/v3/download). */
73
- async function downloadMatrixMedia(homeserver, token, mxc) {
130
+ async function downloadMatrixMedia(homeserver, token, mxc, options) {
74
131
  const parts = parseMxc(mxc);
75
132
  if (!parts)
76
133
  return null;
77
134
  try {
78
135
  const url = `${homeserver}/_matrix/media/v3/download/${encodeURIComponent(parts.server)}/${encodeURIComponent(parts.mediaId)}`;
79
- const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
136
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: options.signal });
80
137
  if (!r.ok)
81
138
  return null;
82
- const dir = join(homedir(), ".hara", "matrix", "media");
83
- mkdirSync(dir, { recursive: true });
84
- const path = join(dir, `mx_${Date.now()}_${parts.mediaId.slice(-12)}`);
85
- writeFileSync(path, Buffer.from(await r.arrayBuffer()));
86
- return path;
139
+ return await savePrivateResponse(r, { platform: "matrix", filenameHint: parts.mediaId, ...options });
87
140
  }
88
141
  catch {
89
142
  return null;
@@ -135,13 +188,20 @@ export function matrixAdapter(homeserver, token, selfUserId) {
135
188
  /* upload/send failed — surfaced upstream as "no file delivered" */
136
189
  }
137
190
  },
138
- async start(onMessage, signal) {
191
+ async start(onMessage, signal, shouldDownload) {
192
+ let directRooms = new Set();
193
+ const updateDirectRooms = (sync) => {
194
+ const next = matrixDirectRoomsFromSync(sync);
195
+ if (next)
196
+ directRooms = next;
197
+ };
139
198
  // Prime the cursor: a since-less, timeout=0 sync skips backlog so we only see messages from now on.
140
199
  let since = "";
141
200
  try {
142
201
  const r = await fetch(`${base}/_matrix/client/v3/sync?timeout=0`, { headers: auth, signal });
143
202
  if (r.ok) {
144
203
  const j = (await r.json());
204
+ updateDirectRooms(j);
145
205
  if (typeof j.next_batch === "string")
146
206
  since = j.next_batch;
147
207
  }
@@ -159,6 +219,7 @@ export function matrixAdapter(homeserver, token, selfUserId) {
159
219
  continue;
160
220
  }
161
221
  const j = (await res.json());
222
+ updateDirectRooms(j);
162
223
  if (typeof j.next_batch === "string")
163
224
  since = j.next_batch;
164
225
  const join = j.rooms?.join ?? {};
@@ -168,10 +229,14 @@ export function matrixAdapter(homeserver, token, selfUserId) {
168
229
  const parsed = parseMatrixEvent(event, selfUserId);
169
230
  if (!parsed)
170
231
  continue;
171
- if (parsed.imageMxc) {
172
- const path = await downloadMatrixMedia(base, token, parsed.imageMxc);
173
- if (path)
232
+ parsed.msg.chatType = await resolveMatrixChatType(base, token, roomId, selfUserId, String(parsed.msg.userId), directRooms, signal);
233
+ if (parsed.imageMxc && shouldDownload?.(parsed.msg) === true) {
234
+ const budget = new InboundMediaBudget("matrix", signal);
235
+ const path = await budget.download((options) => downloadMatrixMedia(base, token, parsed.imageMxc, options));
236
+ if (path) {
174
237
  parsed.msg.images = [path];
238
+ parsed.msg.transientFiles = [path];
239
+ }
175
240
  }
176
241
  await onMessage(parsed.msg).catch(() => { });
177
242
  }