@nanhara/hara 0.70.0 → 0.95.2

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 (47) hide show
  1. package/CHANGELOG.md +395 -0
  2. package/README.md +7 -2
  3. package/dist/agent/compact.js +10 -0
  4. package/dist/agent/context-report.js +33 -0
  5. package/dist/agent/failover.js +53 -0
  6. package/dist/agent/loop.js +120 -13
  7. package/dist/agent/rewind.js +20 -0
  8. package/dist/agent/route.js +47 -0
  9. package/dist/checkpoints.js +91 -0
  10. package/dist/config.js +33 -9
  11. package/dist/context/subdir-hints.js +76 -0
  12. package/dist/cron/runner.js +7 -4
  13. package/dist/exec/jobs.js +82 -0
  14. package/dist/gateway/dingtalk.js +209 -0
  15. package/dist/gateway/discord.js +164 -0
  16. package/dist/gateway/feishu.js +144 -0
  17. package/dist/gateway/matrix.js +188 -0
  18. package/dist/gateway/mattermost.js +206 -0
  19. package/dist/gateway/serve.js +339 -0
  20. package/dist/gateway/sessions.js +89 -0
  21. package/dist/gateway/signal.js +220 -0
  22. package/dist/gateway/slack.js +190 -0
  23. package/dist/gateway/telegram.js +115 -0
  24. package/dist/gateway/tmux-routes.js +135 -0
  25. package/dist/gateway/tts.js +100 -0
  26. package/dist/gateway/wecom.js +383 -0
  27. package/dist/gateway/weixin.js +590 -0
  28. package/dist/index.js +1073 -120
  29. package/dist/org-fleet/enroll.js +28 -5
  30. package/dist/plugins/plugins.js +49 -1
  31. package/dist/profile/profile.js +436 -0
  32. package/dist/providers/anthropic.js +28 -1
  33. package/dist/providers/openai.js +16 -1
  34. package/dist/sandbox.js +15 -12
  35. package/dist/security/external-content.js +57 -0
  36. package/dist/security/permissions.js +211 -0
  37. package/dist/session/session-model.js +36 -0
  38. package/dist/statusbar.js +12 -3
  39. package/dist/tools/builtin.js +49 -2
  40. package/dist/tools/external_agent.js +118 -0
  41. package/dist/tools/send.js +35 -0
  42. package/dist/tools/skill.js +6 -2
  43. package/dist/tools/todo.js +65 -8
  44. package/dist/tools/web.js +4 -3
  45. package/dist/tui/App.js +241 -30
  46. package/dist/tui/run.js +36 -1
  47. package/package.json +4 -2
@@ -0,0 +1,188 @@
1
+ // Matrix adapter for `hara gateway` — client-server API with a /sync long-poll (built-in fetch, zero new dep;
2
+ // no WebSocket). Mirrors the Telegram long-poll model and the Discord download-media/sendFile patterns. Creds
3
+ // from HARA_MATRIX_HOMESERVER (e.g. https://matrix.org) + HARA_MATRIX_TOKEN (access token) + HARA_MATRIX_USER_ID
4
+ // (the bot's own @user:server, for self-filtering). Same ChatAdapter shape as the others, so all cross-platform
5
+ // gateway plumbing (send_file, system context, stuck-guard, image attach/describe) works unchanged.
6
+ //
7
+ // LIMITATION (v1): NO end-to-end encryption. Encrypted rooms (m.room.encrypted events) are skipped — only
8
+ // plaintext rooms work. E2EE would need libolm + a crypto store (see hermes' matrix-nio adapter), which breaks
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";
13
+ import { chunkText } from "./telegram.js";
14
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
15
+ const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
16
+ const mimeFromExt = (name) => {
17
+ const ext = name.toLowerCase().split(".").pop() ?? "";
18
+ const map = {
19
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
20
+ mp4: "video/mp4", mp3: "audio/mpeg", ogg: "audio/ogg", pdf: "application/pdf", txt: "text/plain",
21
+ };
22
+ return map[ext] ?? "application/octet-stream";
23
+ };
24
+ /** Parse `mxc://server/mediaId` → its two parts (pure). null if not an mxc URL. */
25
+ export function parseMxc(mxc) {
26
+ if (typeof mxc !== "string" || !mxc.startsWith("mxc://"))
27
+ return null;
28
+ const rest = mxc.slice(6); // strip "mxc://"
29
+ const slash = rest.indexOf("/");
30
+ if (slash < 0)
31
+ return null;
32
+ const server = rest.slice(0, slash);
33
+ const mediaId = rest.slice(slash + 1);
34
+ if (!server || !mediaId)
35
+ return null;
36
+ return { server, mediaId };
37
+ }
38
+ /** Extract an InboundMsg from a single Matrix timeline event (pure). Accepts m.room.message of msgtype m.text
39
+ * (and friends carrying a body) plus m.image (→ marker text + the mxc url to download in start()). Ignores our
40
+ * own messages and encrypted events. Returns the InboundMsg + the image's mxc url (null if none). null = skip. */
41
+ export function parseMatrixEvent(event, selfUserId) {
42
+ if (!event || event.type !== "m.room.message")
43
+ return null; // encrypted (m.room.encrypted) / state events → skip
44
+ const sender = typeof event.sender === "string" ? event.sender : "";
45
+ const roomId = typeof event.__roomId === "string" ? event.__roomId : ""; // injected by start() (events carry no room id)
46
+ if (!sender || !roomId)
47
+ return null;
48
+ if (sender === selfUserId)
49
+ return null; // ignore our own messages
50
+ const content = event.content ?? {};
51
+ const msgtype = typeof content.msgtype === "string" ? content.msgtype : "";
52
+ const body = typeof content.body === "string" ? content.body : "";
53
+ let imageMxc = null;
54
+ let text = body;
55
+ if (msgtype === "m.image") {
56
+ imageMxc = typeof content.url === "string" && content.url.startsWith("mxc://") ? content.url : null;
57
+ text = body || "[图片]";
58
+ }
59
+ else if (!body) {
60
+ return null; // text-less non-image (sticker/location/reaction-as-message/etc.)
61
+ }
62
+ return {
63
+ msg: {
64
+ chatId: roomId,
65
+ userId: sender,
66
+ userName: sender,
67
+ text: text || "[图片]",
68
+ },
69
+ imageMxc,
70
+ };
71
+ }
72
+ /** 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) {
74
+ const parts = parseMxc(mxc);
75
+ if (!parts)
76
+ return null;
77
+ try {
78
+ const url = `${homeserver}/_matrix/media/v3/download/${encodeURIComponent(parts.server)}/${encodeURIComponent(parts.mediaId)}`;
79
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
80
+ if (!r.ok)
81
+ 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;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ export function matrixAdapter(homeserver, token, selfUserId) {
93
+ const base = homeserver.replace(/\/+$/, ""); // trim trailing slashes
94
+ const auth = { Authorization: `Bearer ${token}` };
95
+ let txnSeq = Date.now(); // monotonically increasing txn id base (survives clock-ish across a process run)
96
+ const nextTxn = () => `hara${++txnSeq}`;
97
+ return {
98
+ name: "matrix",
99
+ async send(chatId, text) {
100
+ for (const part of chunkText(text || "(empty)", 4000)) {
101
+ const txn = nextTxn();
102
+ await fetch(`${base}/_matrix/client/v3/rooms/${encodeURIComponent(String(chatId))}/send/m.room.message/${txn}`, {
103
+ method: "PUT",
104
+ headers: { ...auth, "content-type": "application/json" },
105
+ body: JSON.stringify({ msgtype: "m.text", body: part }),
106
+ }).catch(() => { });
107
+ }
108
+ },
109
+ async sendFile(chatId, filePath) {
110
+ const name = basename(filePath);
111
+ const mime = mimeFromExt(name);
112
+ try {
113
+ // 1) upload bytes → content_uri (mxc://)
114
+ const up = await fetch(`${base}/_matrix/media/v3/upload?filename=${encodeURIComponent(name)}`, {
115
+ method: "POST",
116
+ headers: { ...auth, "content-type": mime },
117
+ body: readFileSync(filePath),
118
+ });
119
+ if (!up.ok)
120
+ return;
121
+ const j = (await up.json());
122
+ const mxc = j?.content_uri;
123
+ if (!mxc)
124
+ return;
125
+ // 2) send a referencing m.room.message — m.image for images, m.file otherwise
126
+ const msgtype = isImage(name, mime) ? "m.image" : "m.file";
127
+ const txn = nextTxn();
128
+ await fetch(`${base}/_matrix/client/v3/rooms/${encodeURIComponent(String(chatId))}/send/m.room.message/${txn}`, {
129
+ method: "PUT",
130
+ headers: { ...auth, "content-type": "application/json" },
131
+ body: JSON.stringify({ msgtype, body: name, filename: name, url: mxc, info: { mimetype: mime } }),
132
+ }).catch(() => { });
133
+ }
134
+ catch {
135
+ /* upload/send failed — surfaced upstream as "no file delivered" */
136
+ }
137
+ },
138
+ async start(onMessage, signal) {
139
+ // Prime the cursor: a since-less, timeout=0 sync skips backlog so we only see messages from now on.
140
+ let since = "";
141
+ try {
142
+ const r = await fetch(`${base}/_matrix/client/v3/sync?timeout=0`, { headers: auth, signal });
143
+ if (r.ok) {
144
+ const j = (await r.json());
145
+ if (typeof j.next_batch === "string")
146
+ since = j.next_batch;
147
+ }
148
+ }
149
+ catch {
150
+ if (signal.aborted)
151
+ return;
152
+ }
153
+ while (!signal.aborted) {
154
+ try {
155
+ const q = `timeout=30000${since ? `&since=${encodeURIComponent(since)}` : ""}`;
156
+ const res = await fetch(`${base}/_matrix/client/v3/sync?${q}`, { headers: auth, signal });
157
+ if (!res.ok) {
158
+ await sleep(2000);
159
+ continue;
160
+ }
161
+ const j = (await res.json());
162
+ if (typeof j.next_batch === "string")
163
+ since = j.next_batch;
164
+ const join = j.rooms?.join ?? {};
165
+ for (const [roomId, room] of Object.entries(join)) {
166
+ for (const event of room?.timeline?.events ?? []) {
167
+ event.__roomId = roomId; // events carry no room id in /sync — inject it for the pure parser
168
+ const parsed = parseMatrixEvent(event, selfUserId);
169
+ if (!parsed)
170
+ continue;
171
+ if (parsed.imageMxc) {
172
+ const path = await downloadMatrixMedia(base, token, parsed.imageMxc);
173
+ if (path)
174
+ parsed.msg.images = [path];
175
+ }
176
+ await onMessage(parsed.msg).catch(() => { });
177
+ }
178
+ }
179
+ }
180
+ catch {
181
+ if (signal.aborted)
182
+ break;
183
+ await sleep(2000); // network blip → back off + retry
184
+ }
185
+ }
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,206 @@
1
+ // Mattermost adapter for `hara gateway` — connects to a self-hosted (or cloud) Mattermost over the v4
2
+ // WebSocket (Node's native global WebSocket, zero new dep on Node ≥ 22) for inbound events, and the v4 REST
3
+ // API for outbound. Server from HARA_MATTERMOST_URL, token (bot or personal-access) from HARA_MATTERMOST_TOKEN;
4
+ // allow users via HARA_GATEWAY_ALLOWED (Mattermost user ids). Same ChatAdapter shape as Telegram/Discord, so all
5
+ // the cross-platform gateway plumbing (send_file, in-chat system context, stuck-guard, image attach/describe)
6
+ // works unchanged. Auth is a WS "authentication_challenge" rather than an HTTP header.
7
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
+ import { join, basename } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { chunkText } from "./telegram.js";
11
+ const WSImpl = globalThis.WebSocket;
12
+ const sleep = (ms, signal) => new Promise((r) => {
13
+ if (signal?.aborted)
14
+ return r();
15
+ const t = setTimeout(r, ms);
16
+ signal?.addEventListener?.("abort", () => { clearTimeout(t); r(); }, { once: true });
17
+ });
18
+ const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
19
+ /** Strip a trailing slash and any `/api/v4` suffix so we can rebuild paths cleanly (pure). */
20
+ function normalizeBase(raw) {
21
+ return raw.trim().replace(/\/+$/, "").replace(/\/api\/v4$/i, "");
22
+ }
23
+ /** http(s):// base → wss(s):// websocket endpoint (pure). */
24
+ function wsUrlFromBase(base) {
25
+ return normalizeBase(base).replace(/^http/i, "ws") + "/api/v4/websocket";
26
+ }
27
+ async function downloadMattermostFile(base, token, fileId, name) {
28
+ try {
29
+ const r = await fetch(`${normalizeBase(base)}/api/v4/files/${encodeURIComponent(fileId)}`, {
30
+ headers: { Authorization: `Bearer ${token}` },
31
+ });
32
+ if (!r.ok)
33
+ return null;
34
+ const dir = join(homedir(), ".hara", "mattermost", "media");
35
+ mkdirSync(dir, { recursive: true });
36
+ const path = join(dir, `mm_${Date.now()}_${basename(name) || "file.bin"}`);
37
+ writeFileSync(path, Buffer.from(await r.arrayBuffer()));
38
+ return path;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /** Parse a Mattermost "posted" event's post → InboundMsg + its image file ids (pure; download happens in
45
+ * start()). The post is the already-parsed object (the event's data.post is a JSON string the caller decodes).
46
+ * null = ignore (own post / system post / empty). */
47
+ export function parseMattermostPost(post, selfUserId) {
48
+ if (!post?.channel_id || !post?.user_id)
49
+ return null;
50
+ if (post.user_id === selfUserId)
51
+ return null; // ignore our own posts
52
+ if (post.type)
53
+ return null; // system post (join/leave/etc.) — type "" is a normal user post
54
+ const fileIds = Array.isArray(post.file_ids) ? post.file_ids.map((f) => String(f)) : [];
55
+ // file_ids alone don't tell us the mime; the caller resolves per-file. Here we surface them all and let the
56
+ // download step filter to images. To stay pure we pass them through as candidate image ids.
57
+ const imageFileIds = fileIds;
58
+ const text = String(post.message ?? "");
59
+ if (!text && !imageFileIds.length)
60
+ return null;
61
+ return {
62
+ msg: {
63
+ chatId: String(post.channel_id),
64
+ userId: String(post.user_id),
65
+ userName: String(post.user_id), // enriched by start() from the event's sender_name when available
66
+ text: text || "[图片]",
67
+ },
68
+ imageFileIds,
69
+ };
70
+ }
71
+ export function mattermostAdapter(serverUrl, token) {
72
+ const base = normalizeBase(serverUrl);
73
+ const api = `${base}/api/v4`;
74
+ const auth = { Authorization: `Bearer ${token}` };
75
+ return {
76
+ name: "mattermost",
77
+ async send(chatId, text) {
78
+ for (const part of chunkText(text || "(empty)", 4000)) {
79
+ await fetch(`${api}/posts`, {
80
+ method: "POST",
81
+ headers: { ...auth, "content-type": "application/json" },
82
+ body: JSON.stringify({ channel_id: chatId, message: part }),
83
+ }).catch(() => { });
84
+ }
85
+ },
86
+ async sendFile(chatId, filePath) {
87
+ // upload the file (multipart) → get a file_id, then create a post referencing it
88
+ const form = new FormData();
89
+ form.append("channel_id", String(chatId));
90
+ form.append("files", new Blob([readFileSync(filePath)]), basename(filePath));
91
+ const up = await fetch(`${api}/files`, { method: "POST", headers: auth, body: form }).catch(() => null);
92
+ if (!up || !up.ok)
93
+ return;
94
+ const j = (await up.json().catch(() => null));
95
+ const fileId = j?.file_infos?.[0]?.id;
96
+ if (!fileId)
97
+ return;
98
+ await fetch(`${api}/posts`, {
99
+ method: "POST",
100
+ headers: { ...auth, "content-type": "application/json" },
101
+ body: JSON.stringify({ channel_id: chatId, message: "", file_ids: [fileId] }),
102
+ }).catch(() => { });
103
+ },
104
+ async start(onMessage, signal) {
105
+ if (!WSImpl) {
106
+ console.error("hara gateway: Mattermost needs Node ≥ 22 (global WebSocket). Upgrade Node.");
107
+ return;
108
+ }
109
+ while (!signal.aborted) {
110
+ await connectOnce(base, token, onMessage, signal);
111
+ if (!signal.aborted)
112
+ await sleep(3000, signal); // reconnect backoff
113
+ }
114
+ },
115
+ };
116
+ }
117
+ /** One WS connection: send authentication_challenge, then dispatch "posted" events. Resolves on close/abort;
118
+ * the caller reconnects. v1 keeps it simple — fresh auth each time, no resume. The bot's own user id is
119
+ * resolved via GET /users/me so we can drop our own echoes. */
120
+ function connectOnce(base, token, onMessage, signal) {
121
+ return new Promise((resolve) => {
122
+ const ws = new WSImpl(wsUrlFromBase(base));
123
+ let seq = 1;
124
+ let selfId = "";
125
+ const stop = () => {
126
+ signal.removeEventListener("abort", stop);
127
+ try {
128
+ ws.close();
129
+ }
130
+ catch {
131
+ /* already closing */
132
+ }
133
+ resolve();
134
+ };
135
+ signal.addEventListener("abort", stop, { once: true });
136
+ ws.addEventListener("close", () => {
137
+ signal.removeEventListener("abort", stop);
138
+ resolve();
139
+ });
140
+ ws.addEventListener("error", () => { });
141
+ ws.addEventListener("open", async () => {
142
+ // resolve our own user id first so parseMattermostPost can ignore our echoes
143
+ try {
144
+ const me = await fetch(`${base}/api/v4/users/me`, { headers: { Authorization: `Bearer ${token}` } });
145
+ if (me.ok)
146
+ selfId = String(((await me.json())?.id) ?? "");
147
+ }
148
+ catch {
149
+ /* best-effort; without it we just won't filter our own posts */
150
+ }
151
+ try {
152
+ ws.send(JSON.stringify({ seq: seq++, action: "authentication_challenge", data: { token } }));
153
+ }
154
+ catch {
155
+ /* socket gone */
156
+ }
157
+ });
158
+ ws.addEventListener("message", async (ev) => {
159
+ let p;
160
+ try {
161
+ p = JSON.parse(String(ev.data));
162
+ }
163
+ catch {
164
+ return;
165
+ }
166
+ if (p.event !== "posted")
167
+ return;
168
+ let post;
169
+ try {
170
+ post = JSON.parse(String(p.data?.post ?? ""));
171
+ }
172
+ catch {
173
+ return;
174
+ }
175
+ const parsed = parseMattermostPost(post, selfId);
176
+ if (!parsed)
177
+ return;
178
+ // enrich the display name from the event payload when the server includes it
179
+ const senderName = String(p.data?.sender_name ?? "").replace(/^@/, "");
180
+ if (senderName)
181
+ parsed.msg.userName = senderName;
182
+ for (const fid of parsed.imageFileIds) {
183
+ // resolve the file's mime/name, then download only images so the agent can SEE them
184
+ let name = "image";
185
+ let mime = "";
186
+ try {
187
+ const info = await fetch(`${base}/api/v4/files/${encodeURIComponent(fid)}/info`, { headers: { Authorization: `Bearer ${token}` } });
188
+ if (info.ok) {
189
+ const ij = (await info.json());
190
+ name = String(ij?.name ?? name);
191
+ mime = String(ij?.mime_type ?? "");
192
+ }
193
+ }
194
+ catch {
195
+ /* fall back to extension sniff below */
196
+ }
197
+ if (!isImage(name, mime))
198
+ continue;
199
+ const path = await downloadMattermostFile(base, token, fid, name);
200
+ if (path)
201
+ (parsed.msg.images ??= []).push(path);
202
+ }
203
+ await onMessage(parsed.msg).catch(() => { });
204
+ });
205
+ });
206
+ }