@nanhara/hara 0.121.1 → 0.122.1

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 (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  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 +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. package/runtime-bootstrap.cjs +72 -0
@@ -8,11 +8,8 @@
8
8
  // every binaries release fail. This form works under both resolutions.
9
9
  import * as larkNs from "@larksuiteoapi/node-sdk";
10
10
  const lark = (larkNs.default ?? larkNs);
11
- import { createReadStream, createWriteStream, mkdirSync } from "node:fs";
12
- import { pipeline } from "node:stream/promises";
13
- import { join, basename } from "node:path";
14
- import { homedir } from "node:os";
15
11
  import { chunkText } from "./telegram.js";
12
+ import { InboundMediaBudget, cleanupTransientMedia, savePrivateMedia } from "./media.js";
16
13
  /** Normalize a Feishu message's parsed content by type → text + any media keys (pure; download done by caller). */
17
14
  export function parseFeishuContent(messageType, content) {
18
15
  if (messageType === "text")
@@ -41,31 +38,39 @@ export function flattenPost(content) {
41
38
  }
42
39
  return out.join(" ").trim();
43
40
  }
44
- async function downloadFeishuResource(client, messageId, fileKey, type) {
41
+ async function downloadFeishuResource(client, messageId, fileKey, type, options) {
45
42
  try {
46
- const resp = await client.im.messageResource.get({ path: { message_id: messageId, file_key: fileKey }, params: { type } });
47
- const dir = join(homedir(), ".hara", "feishu", "media");
48
- mkdirSync(dir, { recursive: true });
49
- const path = join(dir, `fs_${Date.now()}_${fileKey.slice(-8)}.${type === "image" ? "jpg" : "bin"}`);
50
- if (typeof resp?.writeFile === "function")
51
- await resp.writeFile(path);
52
- else if (typeof resp?.getReadableStream === "function")
53
- await pipeline(resp.getReadableStream(), createWriteStream(path));
54
- else
43
+ const resp = await client.im.messageResource.get({ path: { message_id: messageId, file_key: fileKey }, params: { type } }, { signal: options.signal });
44
+ if (typeof resp?.getReadableStream !== "function")
55
45
  return null;
56
- return path;
46
+ return await savePrivateMedia(resp.getReadableStream(), {
47
+ platform: "feishu",
48
+ filenameHint: type === "image" ? "image.jpg" : "file.bin",
49
+ contentType: resp?.headers?.["content-type"],
50
+ ...options,
51
+ });
57
52
  }
58
53
  catch {
59
54
  return null;
60
55
  }
61
56
  }
62
- /** Build an InboundMsg from a Feishu im.message.receive_v1 event (downloads media). null = skip (not a DM / empty). */
63
- async function toInbound(client, data) {
57
+ /** Build an InboundMsg from a Feishu im.message.receive_v1 event (downloads media). Handles BOTH p2p (DM) and
58
+ * group messages — group @-mentions are surfaced (with `isSelf`) so gateway flows can target them. null = skip. */
59
+ async function toInbound(client, data, botOpenId, signal, shouldDownload) {
64
60
  const msg = data?.message;
65
- if (!msg?.chat_id || msg.chat_type !== "p2p")
66
- return null; // v1: direct messages only
61
+ if (!msg?.chat_id)
62
+ return null;
63
+ const chatType = msg.chat_type === "p2p" ? "p2p" : msg.chat_type === "group" ? "group" : undefined;
67
64
  const sender = data?.sender?.sender_id;
68
65
  const userId = String(sender?.open_id || sender?.user_id || msg.chat_id);
66
+ const rawMentions = Array.isArray(msg.mentions) ? msg.mentions : [];
67
+ const mentions = rawMentions.length
68
+ ? rawMentions.map((x) => ({
69
+ id: x?.id?.open_id || x?.id?.user_id || (typeof x?.id === "string" ? x.id : undefined),
70
+ name: x?.name,
71
+ isSelf: !!botOpenId && x?.id?.open_id === botOpenId,
72
+ }))
73
+ : undefined;
69
74
  let content = {};
70
75
  try {
71
76
  content = JSON.parse(msg.content ?? "{}");
@@ -76,73 +81,167 @@ async function toInbound(client, data) {
76
81
  const parsed = parseFeishuContent(String(msg.message_type), content);
77
82
  let text = parsed.text;
78
83
  const images = [];
79
- if (parsed.imageKey) {
80
- const p = await downloadFeishuResource(client, msg.message_id, parsed.imageKey, "image");
81
- if (p) {
82
- images.push(p);
83
- text = "[图片]";
84
+ const transientFiles = [];
85
+ const mediaMarker = parsed.imageKey ? "[图片]" : parsed.fileKey ? "[附件]" : "";
86
+ const base = {
87
+ chatId: String(msg.chat_id),
88
+ userId,
89
+ userName: userId,
90
+ text: text || mediaMarker,
91
+ chatType,
92
+ mentions,
93
+ };
94
+ let handedOff = false;
95
+ try {
96
+ if (shouldDownload?.(base) === true && (parsed.imageKey || parsed.fileKey)) {
97
+ const budget = new InboundMediaBudget("feishu", signal);
98
+ if (parsed.imageKey) {
99
+ const p = await budget.download((options) => downloadFeishuResource(client, msg.message_id, parsed.imageKey, "image", options));
100
+ if (p) {
101
+ images.push(p);
102
+ transientFiles.push(p);
103
+ text = "[图片]";
104
+ }
105
+ }
106
+ else if (parsed.fileKey) {
107
+ const p = await budget.download((options) => downloadFeishuResource(client, msg.message_id, parsed.fileKey, "file", options));
108
+ const label = parsed.fileName === "audio" ? "语音" : `文件 ${parsed.fileName ?? ""}`.trim();
109
+ if (p) {
110
+ transientFiles.push(p);
111
+ text = `[${label}: ${p}]`;
112
+ }
113
+ }
84
114
  }
115
+ // Make @-placeholders readable: Feishu puts "@_user_1" tokens in text + a mentions[] carrying their names.
116
+ for (const x of rawMentions)
117
+ if (x?.key && x?.name)
118
+ text = text.split(String(x.key)).join(`@${x.name}`);
119
+ text = text.trim();
120
+ if (!text && !images.length && !(mentions && mentions.length))
121
+ return null;
122
+ const inbound = {
123
+ ...base,
124
+ text: text || mediaMarker || "[消息]",
125
+ images: images.length ? images : undefined,
126
+ transientFiles: transientFiles.length ? transientFiles : undefined,
127
+ };
128
+ handedOff = true;
129
+ return inbound;
85
130
  }
86
- else if (parsed.fileKey) {
87
- const p = await downloadFeishuResource(client, msg.message_id, parsed.fileKey, "file");
88
- const label = parsed.fileName === "audio" ? "语音" : `文件 ${parsed.fileName ?? ""}`.trim();
89
- if (p)
90
- text = `[${label}: ${p}]`;
131
+ finally {
132
+ if (!handedOff && transientFiles.length)
133
+ await cleanupTransientMedia("feishu", transientFiles);
91
134
  }
92
- text = text.trim();
93
- if (!text && !images.length)
94
- return null;
95
- return { chatId: String(msg.chat_id), userId, userName: userId, text: text || "[图片]", images: images.length ? images : undefined };
96
135
  }
97
136
  export function feishuAdapter(appId, appSecret) {
98
137
  const domain = process.env.HARA_FEISHU_DOMAIN === "lark" ? lark.Domain.Lark : lark.Domain.Feishu;
99
138
  const client = new lark.Client({ appId, appSecret, domain });
100
- const wsClient = new lark.WSClient({ appId, appSecret, domain });
101
- const sendMsg = (chatId, msgType, content) => client.im.message
102
- .create({ params: { receive_id_type: "chat_id" }, data: { receive_id: String(chatId), msg_type: msgType, content: JSON.stringify(content) } })
103
- .catch(() => undefined);
139
+ // Self-healing long connection. The SDK's pong watchdog (wsConfig.pingTimeout) is OFF by default, which lets a
140
+ // silently-dropped socket stay "ready" forever while events just stop — the exact failure that made this
141
+ // gateway go deaf. Turning it on means: no inbound frame within pingTimeout seconds of a ping presumed dead
142
+ // → reconnect. handshakeTimeoutMs stops a stuck DNS/proxy handshake from hanging. Lifecycle logs give
143
+ // visibility so a reconnect is observable instead of a mystery silence.
144
+ const wsClient = new lark.WSClient({
145
+ appId,
146
+ appSecret,
147
+ domain,
148
+ autoReconnect: true,
149
+ wsConfig: { pingTimeout: 10 },
150
+ handshakeTimeoutMs: 15_000,
151
+ onReconnecting: () => console.error("hara feishu: ⟳ ws reconnecting…"),
152
+ onReconnected: () => console.error("hara feishu: ✓ ws reconnected"),
153
+ onError: (err) => console.error(`hara feishu: ws error — ${err?.message ?? err}`),
154
+ });
155
+ // The bot's own open_id — resolved once, lazily — so a group message that @-mentions the bot can be flagged
156
+ // isSelf (what a flow's `mention:"self"` triggers on). Failure degrades gracefully: isSelf just stays false.
157
+ let botOpenId;
158
+ let botOpenIdAttemptAt = Number.NEGATIVE_INFINITY;
159
+ const ensureBotOpenId = async () => {
160
+ if (botOpenId)
161
+ return botOpenId;
162
+ if (Date.now() - botOpenIdAttemptAt < 60_000)
163
+ return undefined;
164
+ botOpenIdAttemptAt = Date.now();
165
+ try {
166
+ const r = await client.request({ method: "GET", url: "/open-apis/bot/v3/info" });
167
+ botOpenId = r?.bot?.open_id ?? r?.data?.bot?.open_id ?? undefined;
168
+ }
169
+ catch {
170
+ botOpenId = undefined;
171
+ }
172
+ return botOpenId;
173
+ };
174
+ const sendMsg = async (chatId, msgType, content) => {
175
+ const response = await client.im.message.create({
176
+ params: { receive_id_type: "chat_id" },
177
+ data: { receive_id: String(chatId), msg_type: msgType, content: JSON.stringify(content) },
178
+ });
179
+ if (typeof response?.code === "number" && response.code !== 0) {
180
+ throw new Error(`Feishu send failed: code=${response.code}${response.msg ? ` · ${response.msg}` : ""}`);
181
+ }
182
+ return response;
183
+ };
104
184
  return {
105
185
  name: "feishu",
106
186
  async send(chatId, text) {
107
187
  for (const part of chunkText(text || "(empty)", 4000))
108
188
  await sendMsg(chatId, "text", { text: part });
109
189
  },
110
- async sendFile(chatId, filePath) {
111
- const name = basename(filePath);
112
- const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
190
+ // Track + recall: lets the gateway clean up transient UX messages ("⟳ working…") once the real reply
191
+ // lands Feishu permits deleting the bot's own messages (DELETE im/v1/messages/:id).
192
+ async sendTracked(chatId, text) {
193
+ const r = await sendMsg(chatId, "text", { text });
194
+ return r?.data?.message_id ?? r?.message_id ?? undefined;
195
+ },
196
+ async recall(_chatId, messageId) {
113
197
  try {
114
- if (isImg) {
115
- const up = await client.im.image.create({ data: { image_type: "message", image: createReadStream(filePath) } });
116
- const key = up?.image_key ?? up?.data?.image_key;
117
- if (key)
118
- await sendMsg(chatId, "image", { image_key: key });
119
- }
120
- else {
121
- const up = await client.im.file.create({ data: { file_type: "stream", file_name: name, file: createReadStream(filePath) } });
122
- const key = up?.file_key ?? up?.data?.file_key;
123
- if (key)
124
- await sendMsg(chatId, "file", { file_key: key });
125
- }
198
+ await client.im.message.delete({ path: { message_id: messageId } });
126
199
  }
127
200
  catch {
128
- /* upload/send failedsurfaced upstream as "no file delivered" */
201
+ /* best-effort cleanupan unrecallable message just stays */
129
202
  }
130
203
  },
131
- async start(onMessage, signal) {
204
+ async sendFile(chatId, file) {
205
+ const name = file.safeName;
206
+ const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
207
+ if (isImg) {
208
+ const up = await client.im.image.create({ data: { image_type: "message", image: file.bytes } });
209
+ const key = up?.image_key ?? up?.data?.image_key;
210
+ if (!key)
211
+ throw new Error("Feishu image upload returned no image_key");
212
+ await sendMsg(chatId, "image", { image_key: key });
213
+ }
214
+ else {
215
+ const up = await client.im.file.create({ data: { file_type: "stream", file_name: name, file: file.bytes } });
216
+ const key = up?.file_key ?? up?.data?.file_key;
217
+ if (!key)
218
+ throw new Error("Feishu file upload returned no file_key");
219
+ await sendMsg(chatId, "file", { file_key: key });
220
+ }
221
+ },
222
+ async start(onMessage, signal, shouldDownload) {
132
223
  const eventDispatcher = new lark.EventDispatcher({}).register({
133
224
  "im.message.receive_v1": async (data) => {
134
- const m = await toInbound(client, data);
225
+ const m = await toInbound(client, data, await ensureBotOpenId(), signal, shouldDownload);
135
226
  if (m)
136
- await onMessage(m).catch(() => { });
227
+ await onMessage(m).catch((error) => console.error(`hara feishu: message handling failed — ${error instanceof Error ? error.message : String(error)}`));
137
228
  },
138
229
  });
139
- wsClient.start({ eventDispatcher }); // runs its own background long-connection (auto-reconnect)
140
- // keep the adapter alive until the gateway aborts (the WSClient manages the socket itself)
230
+ wsClient.start({ eventDispatcher }); // runs its own background long-connection (auto-reconnect + watchdog)
231
+ // Keep the adapter alive until the gateway aborts, then CLOSE the WSClient so its socket + timers release
232
+ // the event loop and the process actually exits on SIGTERM (previously the live connection pinned the
233
+ // loop, so only kill -9 worked — and a hard kill leaves a dirty disconnect that Feishu can throttle).
141
234
  await new Promise((resolve) => {
142
235
  if (signal.aborted)
143
236
  return resolve();
144
237
  signal.addEventListener("abort", () => resolve(), { once: true });
145
238
  });
239
+ try {
240
+ wsClient.close();
241
+ }
242
+ catch {
243
+ /* best-effort clean shutdown */
244
+ }
146
245
  },
147
246
  };
148
247
  }