@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,339 @@
1
+ // `hara gateway` — an opt-in long-running daemon that lets you drive your LOCAL hara from a chat app
2
+ // (Telegram now; WeChat-iLink / Feishu via the same ChatAdapter next). Each inbound message → a fresh `hara`
3
+ // subprocess (the cron pattern) on that chat's session → the reply is sent back. This is hara's first
4
+ // persistent process; it is never required by the core CLI.
5
+ import { spawn } from "node:child_process";
6
+ import { telegramAdapter } from "./telegram.js";
7
+ import { chatContext, chatCd, newChatSession, setChatSession, toggleVoice } from "./sessions.js";
8
+ import { pickPaneForReply, capturePane, injectTmux, outputDelta } from "./tmux-routes.js";
9
+ import { synthesize } from "./tts.js";
10
+ import { selfArgv } from "../cron/runner.js";
11
+ import { listSessions, resolveSessionId, loadSession } from "../session/store.js";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
15
+ /** Parse a leading slash-command from a chat message (pure). null if it isn't one. */
16
+ export function parseCommand(text) {
17
+ const m = /^\/([a-z]+)\b\s*([\s\S]*)$/i.exec(text.trim());
18
+ return m ? { cmd: m[1].toLowerCase(), arg: m[2].trim() } : null;
19
+ }
20
+ /** Whether a user may drive the gateway. Empty allowlist = nobody (safe default — never wide-open). */
21
+ export function isAllowed(userId, allowlist) {
22
+ return allowlist.size > 0 && allowlist.has(String(userId));
23
+ }
24
+ /** Strip hara's CLI chrome from captured `-p` output so a chat reply is just the answer: MCP status lines
25
+ * (`mcp: …`) and the token-usage footer (`… · ↑N ↓N tok`). Colors are off when piped, so no ANSI to strip. */
26
+ export function cleanReply(raw) {
27
+ return raw
28
+ .split("\n")
29
+ .filter((ln) => !/^\s*mcp: /.test(ln) && !/·\s*↑\d+\s*↓\d+\s*tok\s*$/.test(ln))
30
+ .join("\n")
31
+ .trim();
32
+ }
33
+ let outboxSeq = 0;
34
+ /** Run hara headlessly on a chat's session. Returns its cleaned text reply plus any files the agent queued
35
+ * via send_file. The gateway env (HARA_GATEWAY + a per-message outbox file) is what makes send_file and the
36
+ * in-chat system context active in the subprocess; the daemon delivers the queued files after it exits. */
37
+ function runHara(text, sessionId, cwd, platform, images) {
38
+ const outbox = join(tmpdir(), `hara-outbox-${process.pid}-${Date.now()}-${outboxSeq++}.txt`);
39
+ return new Promise((res) => {
40
+ const self = selfArgv();
41
+ const child = spawn(self[0], [...self.slice(1), "-p", text, "--approval", "full-auto", "--resume", sessionId], {
42
+ cwd,
43
+ env: {
44
+ ...process.env,
45
+ HARA_GATEWAY: platform,
46
+ HARA_GATEWAY_OUTBOX: outbox,
47
+ ...(images?.length ? { HARA_GATEWAY_IMAGES: images.join("\n") } : {}),
48
+ },
49
+ });
50
+ let out = "";
51
+ const cap = (d) => {
52
+ out = (out + d.toString()).slice(-12000);
53
+ };
54
+ child.stdout.on("data", cap);
55
+ child.stderr.on("data", cap);
56
+ const finish = (reply) => {
57
+ let files = [];
58
+ try {
59
+ if (existsSync(outbox)) {
60
+ files = readFileSync(outbox, "utf8").split("\n").map((s) => s.trim()).filter(Boolean);
61
+ rmSync(outbox, { force: true });
62
+ }
63
+ }
64
+ catch {
65
+ /* outbox is best-effort; a missing/unreadable file just means nothing to send */
66
+ }
67
+ res({ reply, files });
68
+ };
69
+ child.on("error", (e) => finish(`(error: ${e.message})`));
70
+ child.on("close", () => finish(cleanReply(out) || "(no output)"));
71
+ });
72
+ }
73
+ /** Re-exported so `hara gateway --platform weixin --login` can run the QR flow. */
74
+ export { weixinLogin } from "./weixin.js";
75
+ async function buildAdapter(platform) {
76
+ if (platform === "weixin") {
77
+ const { loadWeixinCreds, weixinAdapter } = await import("./weixin.js");
78
+ const creds = loadWeixinCreds();
79
+ if (!creds) {
80
+ console.error("hara gateway: no WeChat login found. Run `hara gateway --platform weixin --login` first.");
81
+ return null;
82
+ }
83
+ // The iLink user_id is whoever scanned the QR — the bot owner. Auto-allow them so there's no wxid dance.
84
+ return { adapter: weixinAdapter(creds), ownerId: creds.user_id || undefined };
85
+ }
86
+ if (platform === "discord") {
87
+ const token = process.env.HARA_DISCORD_TOKEN;
88
+ if (!token) {
89
+ console.error("hara gateway: set HARA_DISCORD_TOKEN (Discord bot token) and HARA_GATEWAY_ALLOWED=<your discord user id>. Enable the Message Content Intent on the bot.");
90
+ return null;
91
+ }
92
+ const { discordAdapter } = await import("./discord.js");
93
+ return { adapter: discordAdapter(token) };
94
+ }
95
+ if (platform === "feishu" || platform === "lark") {
96
+ const appId = process.env.HARA_FEISHU_APP_ID;
97
+ const appSecret = process.env.HARA_FEISHU_APP_SECRET;
98
+ if (!appId || !appSecret) {
99
+ console.error("hara gateway: set HARA_FEISHU_APP_ID + HARA_FEISHU_APP_SECRET (Feishu app console) and HARA_GATEWAY_ALLOWED=<your open_id>. (HARA_FEISHU_DOMAIN=lark for larksuite.com.)");
100
+ return null;
101
+ }
102
+ const { feishuAdapter } = await import("./feishu.js");
103
+ return { adapter: feishuAdapter(appId, appSecret) };
104
+ }
105
+ if (platform === "slack") {
106
+ const appToken = process.env.HARA_SLACK_APP_TOKEN;
107
+ const botToken = process.env.HARA_SLACK_BOT_TOKEN;
108
+ if (!appToken || !botToken) {
109
+ console.error("hara gateway: set HARA_SLACK_APP_TOKEN (xapp-, Socket Mode app-level token w/ connections:write) + HARA_SLACK_BOT_TOKEN (xoxb-, bot token w/ chat:write,files:write,files:read,*:history) and HARA_GATEWAY_ALLOWED=<your slack user id>.");
110
+ return null;
111
+ }
112
+ const { slackAdapter } = await import("./slack.js");
113
+ return { adapter: slackAdapter(appToken, botToken) };
114
+ }
115
+ if (platform === "mattermost") {
116
+ const url = process.env.HARA_MATTERMOST_URL;
117
+ const token = process.env.HARA_MATTERMOST_TOKEN;
118
+ if (!url || !token) {
119
+ console.error("hara gateway: set HARA_MATTERMOST_URL (e.g. https://mm.example.com) + HARA_MATTERMOST_TOKEN (bot or personal-access token) and HARA_GATEWAY_ALLOWED=<your mattermost user id>.");
120
+ return null;
121
+ }
122
+ const { mattermostAdapter } = await import("./mattermost.js");
123
+ return { adapter: mattermostAdapter(url, token) };
124
+ }
125
+ if (platform === "matrix") {
126
+ const homeserver = process.env.HARA_MATRIX_HOMESERVER;
127
+ const token = process.env.HARA_MATRIX_TOKEN;
128
+ const userId = process.env.HARA_MATRIX_USER_ID;
129
+ if (!homeserver || !token || !userId) {
130
+ console.error("hara gateway: set HARA_MATRIX_HOMESERVER (e.g. https://matrix.org), HARA_MATRIX_TOKEN (access token), HARA_MATRIX_USER_ID (@bot:server) and HARA_GATEWAY_ALLOWED=<@you:server>. Unencrypted rooms only (no E2EE in v1).");
131
+ return null;
132
+ }
133
+ const { matrixAdapter } = await import("./matrix.js");
134
+ return { adapter: matrixAdapter(homeserver, token, userId), ownerId: userId };
135
+ }
136
+ if (platform === "dingtalk" || platform === "ding") {
137
+ const clientId = process.env.HARA_DINGTALK_CLIENT_ID;
138
+ const clientSecret = process.env.HARA_DINGTALK_CLIENT_SECRET;
139
+ if (!clientId || !clientSecret) {
140
+ console.error("hara gateway: set HARA_DINGTALK_CLIENT_ID + HARA_DINGTALK_CLIENT_SECRET (钉钉开放平台 app AppKey/AppSecret, Stream mode enabled on the bot) and HARA_GATEWAY_ALLOWED=<your senderStaffId>.");
141
+ return null;
142
+ }
143
+ const { dingtalkAdapter } = await import("./dingtalk.js");
144
+ return { adapter: dingtalkAdapter(clientId, clientSecret) };
145
+ }
146
+ if (platform === "wecom" || platform === "wework") {
147
+ const botId = process.env.HARA_WECOM_BOT_ID;
148
+ const secret = process.env.HARA_WECOM_SECRET;
149
+ if (!botId || !secret) {
150
+ console.error("hara gateway: set HARA_WECOM_BOT_ID + HARA_WECOM_SECRET (企业微信 admin console → AI Bot credentials) and HARA_GATEWAY_ALLOWED=<your wecom userid>. (HARA_WECOM_WS_URL overrides the gateway URL.)");
151
+ return null;
152
+ }
153
+ const { wecomAdapter } = await import("./wecom.js");
154
+ return { adapter: wecomAdapter(botId, secret, process.env.HARA_WECOM_WS_URL) };
155
+ }
156
+ if (platform === "signal") {
157
+ const rpcUrl = process.env.HARA_SIGNAL_RPC_URL;
158
+ const number = process.env.HARA_SIGNAL_NUMBER;
159
+ if (!rpcUrl || !number) {
160
+ console.error("hara gateway: set HARA_SIGNAL_RPC_URL (e.g. http://localhost:8080) + HARA_SIGNAL_NUMBER (the bot's registered phone, E.164) and HARA_GATEWAY_ALLOWED=<your signal number/uuid>. Requires a local signal-cli daemon: `signal-cli -a <number> daemon --http localhost:8080`.");
161
+ return null;
162
+ }
163
+ const { signalAdapter } = await import("./signal.js");
164
+ return { adapter: signalAdapter(rpcUrl, number), ownerId: number };
165
+ }
166
+ const token = process.env.HARA_TELEGRAM_TOKEN;
167
+ if (!token) {
168
+ console.error("hara gateway: set HARA_TELEGRAM_TOKEN (from @BotFather) and HARA_GATEWAY_ALLOWED=<your telegram user id>.");
169
+ return null;
170
+ }
171
+ return { adapter: telegramAdapter(token) };
172
+ }
173
+ /** Allowlist = the env ids ∪ the bot owner (on WeChat, whoever scanned the QR is always allowed). */
174
+ export function resolveAllowlist(envValue, ownerId) {
175
+ const set = new Set((envValue ?? "").split(",").map((s) => s.trim()).filter(Boolean));
176
+ if (ownerId)
177
+ set.add(ownerId);
178
+ return set;
179
+ }
180
+ /** The gateway's default workspace when no --cwd is given: a dedicated safe home under ~/.hara (like Hermes'
181
+ * ~/.hermes), NOT the launch dir — so a full-auto chat bot never lands on a real repo by accident. */
182
+ export function defaultWorkspace() {
183
+ const dir = join(homedir(), ".hara", "workspace");
184
+ mkdirSync(dir, { recursive: true });
185
+ const agents = join(dir, "AGENTS.md");
186
+ if (!existsSync(agents)) {
187
+ writeFileSync(agents, "# hara chat workspace\n\nDefault working directory for `hara gateway` (Telegram/WeChat). Each message runs here with `--approval full-auto`. A safe scratch — pass `--cwd <dir>` to point the gateway at a real project instead.\n");
188
+ }
189
+ return dir;
190
+ }
191
+ export async function runGateway(opts) {
192
+ const platform = opts.platform || "telegram";
193
+ const cwd = opts.cwd ?? defaultWorkspace(); // dir-free default: hara's own ~/.hara/workspace, like Hermes' ~/.hermes
194
+ const built = await buildAdapter(platform);
195
+ if (!built)
196
+ process.exit(1);
197
+ const { adapter, ownerId } = built;
198
+ const allowlist = resolveAllowlist(process.env.HARA_GATEWAY_ALLOWED, ownerId);
199
+ if (allowlist.size === 0) {
200
+ const hint = platform === "weixin" ? "your WeChat id" : "your Telegram user id (DM @userinfobot)";
201
+ console.error(`hara gateway: ⚠ HARA_GATEWAY_ALLOWED is empty — nobody is allowed. Set it to ${hint}.`);
202
+ }
203
+ else if (ownerId) {
204
+ console.error(`hara gateway: bot owner auto-allowed (${ownerId}).`);
205
+ }
206
+ const ac = new AbortController();
207
+ process.on("SIGINT", () => ac.abort());
208
+ process.on("SIGTERM", () => ac.abort());
209
+ console.error(`hara gateway: ${adapter.name} up · cwd=${cwd} · ${allowlist.size} allowed user(s) · Ctrl-C to stop`);
210
+ await adapter.start(async (m) => {
211
+ if (!isAllowed(m.userId, allowlist)) {
212
+ console.error(`hara gateway: ✗ message from ${m.userId} — not in allowlist. Add it to HARA_GATEWAY_ALLOWED to authorize.`);
213
+ await adapter.send(m.chatId, "⛔ not authorized.");
214
+ return;
215
+ }
216
+ // If a tmux session opted in (via `hara remote ask/bind`), this reply is its input → inject it into that
217
+ // pane, let it react, and reply with the session's NEW output (on-inbound relay — quiet + iLink-friendly:
218
+ // one reply per message, no continuous push). Owner-gated by the allowlist check above.
219
+ if (!parseCommand(m.text)) {
220
+ const pane = pickPaneForReply();
221
+ if (pane) {
222
+ console.error(`hara gateway: routed reply → tmux pane ${pane}`);
223
+ const before = capturePane(pane) ?? "";
224
+ injectTmux(pane, m.text);
225
+ // wait for the session's output to SETTLE (poll every 800ms; stable for ~1.6s → done; cap ~10s) so a
226
+ // slow response isn't missed and we don't capture mid-stream.
227
+ let after = "";
228
+ let stable = 0;
229
+ for (let i = 0; i < 12; i++) {
230
+ await new Promise((r) => setTimeout(r, 800));
231
+ const cur = capturePane(pane) ?? "";
232
+ if (cur === after) {
233
+ if (++stable >= 2)
234
+ break;
235
+ }
236
+ else {
237
+ stable = 0;
238
+ after = cur;
239
+ }
240
+ }
241
+ const delta = outputDelta(before, after).trim();
242
+ const body = delta ? (delta.length > 1500 ? "…\n" + delta.slice(-1500) : delta) : "(已注入,暂无新输出 — 发 ? 再看)";
243
+ await adapter.send(m.chatId, `🖥 ${pane}\n${body}`);
244
+ return;
245
+ }
246
+ }
247
+ const ctx = chatContext(adapter.name, m.chatId, cwd); // this chat's current { cwd, sessionId }
248
+ const cmd = parseCommand(m.text);
249
+ if (cmd) {
250
+ if (cmd.cmd === "help")
251
+ return adapter.send(m.chatId, "commands:\n/pwd · /cd <dir> — project\n/sessions · /new · /resume <id> — threads\n/voice · /say <text> — speech · /send <path> — send a file\n/detach — stop injecting replies into bound tmux panes\n/help\nanything else = run hara here");
252
+ if (cmd.cmd === "detach") {
253
+ const { unbindBinds } = await import("./tmux-routes.js");
254
+ const n = unbindBinds();
255
+ return adapter.send(m.chatId, n ? `🔓 detached ${n} bound tmux pane(s) — replies go to hara again.` : "(no tmux panes were bound)");
256
+ }
257
+ if (cmd.cmd === "pwd")
258
+ return adapter.send(m.chatId, `📂 ${ctx.cwd}\n🧵 ${ctx.sessionId.slice(-18)}`);
259
+ if (cmd.cmd === "cd" || cmd.cmd === "project") {
260
+ if (!cmd.arg)
261
+ return adapter.send(m.chatId, `📂 ${ctx.cwd}\nusage: /cd <dir> (absolute, ~, or relative to here)`);
262
+ const target = resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir()));
263
+ if (!existsSync(target) || !statSync(target).isDirectory())
264
+ return adapter.send(m.chatId, `✗ not a directory: ${target}`);
265
+ const sid = chatCd(adapter.name, m.chatId, target);
266
+ return adapter.send(m.chatId, `📂 now in ${target}\n🧵 ${sid.slice(-18)} · /sessions lists this dir's threads`);
267
+ }
268
+ if (cmd.cmd === "new")
269
+ return adapter.send(m.chatId, `✨ new thread: ${newChatSession(adapter.name, m.chatId, cwd).slice(-18)}`);
270
+ if (cmd.cmd === "sessions") {
271
+ const list = listSessions(ctx.cwd).slice(0, 10).map((x) => `${x.id.slice(-18)} ${x.title || "(untitled)"}`).join("\n");
272
+ return adapter.send(m.chatId, `📂 ${ctx.cwd}\n${list || "(no threads in this dir yet)"}`);
273
+ }
274
+ if (cmd.cmd === "resume") {
275
+ const id = resolveSessionId(cmd.arg);
276
+ if (!id)
277
+ return adapter.send(m.chatId, `no session '${cmd.arg}'`);
278
+ const target = loadSession(id)?.meta.cwd || ctx.cwd; // follow the session's own dir so it runs in the right place
279
+ setChatSession(adapter.name, m.chatId, id, target);
280
+ return adapter.send(m.chatId, `↩ resumed ${id.slice(-18)}\n📂 ${target}`);
281
+ }
282
+ if (cmd.cmd === "voice") {
283
+ if (!adapter.sendFile)
284
+ return adapter.send(m.chatId, "this platform can't send voice yet.");
285
+ const on = toggleVoice(adapter.name, m.chatId);
286
+ return adapter.send(m.chatId, on ? "🔊 voice replies ON — I'll speak each reply too." : "🔇 voice replies OFF.");
287
+ }
288
+ if (cmd.cmd === "say") {
289
+ if (!adapter.sendFile)
290
+ return adapter.send(m.chatId, "this platform can't send voice yet.");
291
+ if (!cmd.arg)
292
+ return adapter.send(m.chatId, "usage: /say <text to speak>");
293
+ const audio = await synthesize(cmd.arg);
294
+ if (!audio)
295
+ return adapter.send(m.chatId, "✗ TTS failed (check HARA_TTS_* config).");
296
+ await adapter.sendFile(m.chatId, audio);
297
+ rmSync(audio, { force: true });
298
+ return;
299
+ }
300
+ if (cmd.cmd === "send") {
301
+ if (!adapter.sendFile)
302
+ return adapter.send(m.chatId, "this platform can't send files yet.");
303
+ const p = cmd.arg ? resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir())) : "";
304
+ if (!p || !existsSync(p) || !statSync(p).isFile())
305
+ return adapter.send(m.chatId, `✗ not a file: ${p || "(none)"}\nusage: /send <path> (abs, ~, or relative to current dir)`);
306
+ await adapter.sendFile(m.chatId, p);
307
+ return;
308
+ }
309
+ // any other slash word → treat as a normal task
310
+ }
311
+ await adapter.send(m.chatId, "⟳ working…");
312
+ const { reply, files } = await runHara(m.text, ctx.sessionId, ctx.cwd, adapter.name, m.images);
313
+ const hasReply = reply && reply !== "(no output)";
314
+ if (hasReply)
315
+ await adapter.send(m.chatId, reply);
316
+ else if (files.length)
317
+ await adapter.send(m.chatId, "📎");
318
+ // Deliver any files the agent queued via send_file (images inline, others as attachments).
319
+ for (const f of files) {
320
+ if (!adapter.sendFile) {
321
+ await adapter.send(m.chatId, "(this platform can't send files yet)");
322
+ break;
323
+ }
324
+ try {
325
+ await adapter.sendFile(m.chatId, f);
326
+ }
327
+ catch (e) {
328
+ await adapter.send(m.chatId, `✗ couldn't send ${f}: ${e.message}`);
329
+ }
330
+ }
331
+ if (hasReply && ctx.voice && adapter.sendFile) {
332
+ const audio = await synthesize(reply);
333
+ if (audio) {
334
+ await adapter.sendFile(m.chatId, audio);
335
+ rmSync(audio, { force: true });
336
+ }
337
+ }
338
+ }, ac.signal);
339
+ }
@@ -0,0 +1,89 @@
1
+ // Chat → working-dir + session mapping, so each chat is a continuous, resumable thread that can ROAM across
2
+ // projects. Persisted at ~/.hara/gateway/chats.json. A chat has a current cwd (switchable via /cd) and a
3
+ // session id scoped to that (chat, cwd) pair — so switching projects switches threads, and switching back
4
+ // resumes the right one. /sessions then lists the current dir's threads (codex-style), while the chat itself
5
+ // stays a single conversation front-end (hermes-style). The same model backs a future desktop app.
6
+ // Sessions are stored by id in ~/.hara/sessions, so these are real sessions resumable via `hara -p … --resume`.
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
10
+ import { createHash } from "node:crypto";
11
+ const dir = () => join(homedir(), ".hara", "gateway");
12
+ const file = () => join(dir(), "chats.json");
13
+ const mapKey = (platform, chatId) => `${platform}:${chatId}`;
14
+ function load() {
15
+ try {
16
+ return existsSync(file()) ? JSON.parse(readFileSync(file(), "utf8")) : {};
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ function save(m) {
23
+ mkdirSync(dir(), { recursive: true });
24
+ writeFileSync(file(), JSON.stringify(m, null, 2));
25
+ }
26
+ /** A stable, short, dir-specific suffix so each (chat, cwd) pair gets its own session thread. */
27
+ export function cwdTag(cwd) {
28
+ return createHash("sha256").update(cwd).digest("hex").slice(0, 6);
29
+ }
30
+ /** Derived session id for a (chat, cwd, fork): `<platform>-<chatId>-<cwdTag>[-fork]`. */
31
+ function deriveId(platform, chatId, cwd, fork) {
32
+ return `${platform}-${chatId}-${cwdTag(cwd)}${fork ? `-${fork}` : ""}`;
33
+ }
34
+ /** Get (or initialize) the chat's context: current working dir + the session id it drives. `defaultCwd` (the
35
+ * gateway's launch dir) is used only on first contact; a pre-cwd entry is migrated in place (keeps its id). */
36
+ export function chatContext(platform, chatId, defaultCwd) {
37
+ const m = load();
38
+ const k = mapKey(platform, chatId);
39
+ const e = m[k];
40
+ if (!e) {
41
+ const fresh = { cwd: defaultCwd, sessionId: deriveId(platform, chatId, defaultCwd, 0), fork: 0 };
42
+ m[k] = fresh;
43
+ save(m);
44
+ return { cwd: fresh.cwd, sessionId: fresh.sessionId, voice: false };
45
+ }
46
+ if (!e.cwd) {
47
+ e.cwd = defaultCwd; // migrate an old (pre-cwd) entry, preserving its existing sessionId
48
+ save(m);
49
+ }
50
+ return { cwd: e.cwd, sessionId: e.sessionId, voice: !!e.voice };
51
+ }
52
+ /** `/voice` — toggle whether this chat's replies are spoken (TTS audio); returns the new state. */
53
+ export function toggleVoice(platform, chatId) {
54
+ const m = load();
55
+ const k = mapKey(platform, chatId);
56
+ const e = m[k];
57
+ if (!e)
58
+ return false; // chatContext always runs first, so an entry exists; defensive no-op otherwise
59
+ e.voice = !e.voice;
60
+ save(m);
61
+ return !!e.voice;
62
+ }
63
+ /** `/cd <dir>` — switch the chat to a working dir; the session follows (its own per-project thread). */
64
+ export function chatCd(platform, chatId, cwd) {
65
+ const m = load();
66
+ const sessionId = deriveId(platform, chatId, cwd, 0);
67
+ m[mapKey(platform, chatId)] = { cwd, sessionId, fork: 0 };
68
+ save(m);
69
+ return sessionId;
70
+ }
71
+ /** `/new` — fork a fresh thread for the chat's CURRENT dir (the old one is preserved). */
72
+ export function newChatSession(platform, chatId, defaultCwd) {
73
+ const m = load();
74
+ const k = mapKey(platform, chatId);
75
+ const cur = m[k] ?? { cwd: defaultCwd, sessionId: "", fork: 0 };
76
+ const cwd = cur.cwd || defaultCwd;
77
+ const fork = (cur.fork ?? 0) + 1;
78
+ const sessionId = deriveId(platform, chatId, cwd, fork);
79
+ m[k] = { cwd, sessionId, fork };
80
+ save(m);
81
+ return sessionId;
82
+ }
83
+ /** `/resume <id>` — point the chat at a specific existing session, following its cwd so it runs in the right place. */
84
+ export function setChatSession(platform, chatId, sessionId, cwd) {
85
+ const m = load();
86
+ const k = mapKey(platform, chatId);
87
+ m[k] = { cwd, sessionId, fork: m[k]?.fork ?? 0 };
88
+ save(m);
89
+ }
@@ -0,0 +1,220 @@
1
+ // Signal adapter for `hara gateway` — talks to a LOCAL signal-cli daemon running in HTTP/JSON-RPC mode
2
+ // (built-in fetch, zero new dep; no WebSocket, no cloud API). The user installs signal-cli, registers/links the
3
+ // bot's phone number, and runs `signal-cli -a <number> daemon --http localhost:8080`. Inbound is drained via the
4
+ // JSON-RPC `receive` method on a long-poll loop (the robust zero-dep path — no SSE line-buffering quirks);
5
+ // outbound + attachment fetch go through JSON-RPC `send`/`getAttachment`. Creds from HARA_SIGNAL_RPC_URL
6
+ // (e.g. http://localhost:8080) + HARA_SIGNAL_NUMBER (the bot's registered phone, E.164). Same ChatAdapter shape
7
+ // as the others, so all cross-platform gateway plumbing (send_file, system context, stuck-guard, image
8
+ // attach/describe) works unchanged. Mirrors the Matrix long-poll model + the Discord download-media/sendFile
9
+ // patterns, and the hermes signal.py protocol/redaction behavior.
10
+ //
11
+ // EXTERNAL DEPENDENCY: signal-cli is NOT bundled — it's a separate program the user installs and runs (it is the
12
+ // only way to speak Signal's protocol; there is no official cloud API). The adapter just speaks HTTP/JSON-RPC to
13
+ // the daemon the user has running. See setup notes at the bottom of this file.
14
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
+ import { join, basename } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import { chunkText } from "./telegram.js";
18
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
+ // E.164 phone numbers (+15551234567) anywhere in a string → +155****4567 (mirrors hermes' _redact_phone).
20
+ const PHONE_RE = /\+[1-9]\d{6,14}/g;
21
+ /** Redact phone numbers for logging. Keeps a 4/4 head+tail window; never logs the full E.164. (pure) */
22
+ export function redactPhone(s) {
23
+ if (!s)
24
+ return "<none>";
25
+ return s.replace(PHONE_RE, (p) => (p.length <= 8 ? "****" : `${p.slice(0, 4)}****${p.slice(-4)}`));
26
+ }
27
+ const isImageExt = (ext) => /^\.(png|jpe?g|gif|webp)$/i.test(ext);
28
+ /** Guess a file extension from an attachment's contentType / filename (signal-cli gives us both as hints). (pure) */
29
+ export function attachmentExt(contentType, filename) {
30
+ const ct = (contentType ?? "").toLowerCase();
31
+ if (ct.startsWith("image/")) {
32
+ if (ct.includes("png"))
33
+ return ".png";
34
+ if (ct.includes("jpeg") || ct.includes("jpg"))
35
+ return ".jpg";
36
+ if (ct.includes("gif"))
37
+ return ".gif";
38
+ if (ct.includes("webp"))
39
+ return ".webp";
40
+ }
41
+ const fromName = (filename ?? "").toLowerCase().match(/\.(png|jpe?g|gif|webp|mp4|mp3|ogg|m4a|pdf|txt)$/);
42
+ if (fromName)
43
+ return fromName[0];
44
+ if (ct.startsWith("audio/"))
45
+ return ".ogg";
46
+ if (ct.startsWith("video/"))
47
+ return ".mp4";
48
+ return ".bin";
49
+ }
50
+ /** Sniff a file extension from magic bytes — fallback when signal-cli's base64 attachment lacks a contentType. (pure) */
51
+ export function extFromBytes(data) {
52
+ if (data.length >= 4 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
53
+ return ".png";
54
+ if (data.length >= 2 && data[0] === 0xff && data[1] === 0xd8)
55
+ return ".jpg";
56
+ if (data.length >= 4 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38)
57
+ return ".gif";
58
+ if (data.length >= 12 && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50)
59
+ return ".webp";
60
+ return ".bin";
61
+ }
62
+ /** Parse one signal-cli envelope → InboundMsg + the image attachments to fetch (pure; the fetch/download happens in
63
+ * start()). Mirrors hermes' _handle_envelope: unwraps the {envelope:{...}} shape, handles plain dataMessage + edits,
64
+ * filters our own outbound + stories. Filtering of OUR OWN number is done here via `selfNumber`. Returns the msg +
65
+ * the image attachment refs (only images — other media is ignored, like Telegram/Matrix). null = skip. */
66
+ export function parseSignalMessage(raw, selfNumber) {
67
+ if (!raw || typeof raw !== "object")
68
+ return null;
69
+ const env = raw.envelope ?? raw; // signal-cli wraps payloads as { envelope: {...} }
70
+ // Stories / typing / receipts / sync echoes carry no inbound dataMessage → skip. Also skip our own outbound
71
+ // (syncMessage is the echo of what WE sent from another linked device — never treat it as user input).
72
+ if (env.syncMessage)
73
+ return null;
74
+ if (env.storyMessage)
75
+ return null;
76
+ if (env.typingMessage || env.receiptMessage)
77
+ return null;
78
+ const sender = env.sourceNumber || env.sourceUuid || env.source;
79
+ if (!sender)
80
+ return null;
81
+ if (selfNumber && (env.sourceNumber === selfNumber || env.source === selfNumber))
82
+ return null; // our own message
83
+ // editMessage carries its updated dataMessage nested inside (mirrors hermes).
84
+ const data = env.dataMessage ?? env.editMessage?.dataMessage;
85
+ if (!data || typeof data !== "object")
86
+ return null;
87
+ const group = data.groupInfo;
88
+ const groupId = group?.groupId;
89
+ const chatId = groupId ? `group:${groupId}` : String(sender);
90
+ const atts = Array.isArray(data.attachments) ? data.attachments : [];
91
+ const images = atts
92
+ .filter((a) => a?.id && (String(a.contentType ?? "").startsWith("image/") || isImageExt(attachmentExt(a?.contentType, a?.filename))))
93
+ .map((a) => ({ id: String(a.id), contentType: a?.contentType, filename: a?.filename }));
94
+ const text = typeof data.message === "string" ? data.message : "";
95
+ if (!text && images.length === 0)
96
+ return null; // no text + no image (reaction/sticker/other media) → skip
97
+ return {
98
+ msg: {
99
+ chatId,
100
+ userId: String(sender),
101
+ userName: env.sourceName || String(sender),
102
+ text: text || "[图片]",
103
+ },
104
+ images,
105
+ };
106
+ }
107
+ export function signalAdapter(rpcUrl, selfNumber) {
108
+ const base = rpcUrl.replace(/\/+$/, ""); // trim trailing slashes
109
+ let rpcSeq = 0;
110
+ /** One JSON-RPC 2.0 call to the signal-cli daemon. Returns `result` (any) or null on error. */
111
+ async function rpc(method, params, signal) {
112
+ const id = `${method}_${++rpcSeq}`;
113
+ try {
114
+ const res = await fetch(`${base}/api/v1/rpc`, {
115
+ method: "POST",
116
+ headers: { "content-type": "application/json" },
117
+ body: JSON.stringify({ jsonrpc: "2.0", method, params, id }),
118
+ signal,
119
+ });
120
+ if (!res.ok)
121
+ return null;
122
+ const j = (await res.json());
123
+ if (j.error) {
124
+ console.error(`hara gateway[signal]: RPC ${method} error:`, redactPhone(JSON.stringify(j.error)));
125
+ return null;
126
+ }
127
+ return j.result ?? null;
128
+ }
129
+ catch (e) {
130
+ if (signal?.aborted)
131
+ return null;
132
+ return null;
133
+ }
134
+ }
135
+ /** Recipient/group routing shared by send + sendFile (mirrors hermes). */
136
+ const target = (chatId) => {
137
+ const id = String(chatId);
138
+ return id.startsWith("group:") ? { groupId: id.slice(6) } : { recipient: [id] };
139
+ };
140
+ /** Fetch a signal-cli attachment by id (base64 over JSON-RPC) → a local path under ~/.hara/signal/media. */
141
+ async function downloadAttachment(ref) {
142
+ try {
143
+ const result = await rpc("getAttachment", { account: selfNumber, id: ref.id });
144
+ // signal-cli returns either a raw base64 string or { data: "base64..." }
145
+ const b64 = typeof result === "string" ? result : result && typeof result === "object" ? result.data : null;
146
+ if (!b64 || typeof b64 !== "string")
147
+ return null;
148
+ const bytes = Buffer.from(b64, "base64");
149
+ let ext = attachmentExt(ref.contentType, ref.filename);
150
+ if (ext === ".bin")
151
+ ext = extFromBytes(bytes); // last-resort magic-byte sniff
152
+ const dir = join(homedir(), ".hara", "signal", "media");
153
+ mkdirSync(dir, { recursive: true });
154
+ const path = join(dir, `sig_${Date.now()}_${ref.id.slice(-12)}${ext}`);
155
+ writeFileSync(path, bytes);
156
+ return path;
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ return {
163
+ name: "signal",
164
+ async send(chatId, text) {
165
+ for (const part of chunkText(text || "(empty)", 4000)) {
166
+ await rpc("send", { account: selfNumber, message: part, ...target(chatId) });
167
+ }
168
+ },
169
+ async sendFile(chatId, filePath) {
170
+ // Signal has no separate photo/document endpoints — everything is an `attachments` path on `send`.
171
+ // signal-cli reads the file off the local disk by path, so we just hand it the path (no upload step).
172
+ await rpc("send", { account: selfNumber, message: "", attachments: [filePath], ...target(chatId) }).catch(() => { });
173
+ // (readFileSync/basename imported for parity with the other adapters' file plumbing; signal-cli reads by path.)
174
+ void readFileSync;
175
+ void basename;
176
+ },
177
+ async start(onMessage, signal) {
178
+ console.error(`hara gateway[signal]: polling signal-cli daemon at ${base} as ${redactPhone(selfNumber)} (ensure \`signal-cli -a <number> daemon --http\` is running).`);
179
+ while (!signal.aborted) {
180
+ try {
181
+ // JSON-RPC `receive` drains all envelopes queued since the last call. timeout is the long-poll seconds
182
+ // the daemon will hold the request open waiting for new messages (server-side block → low-latency, low-spin).
183
+ const result = await rpc("receive", { account: selfNumber, timeout: 30 }, signal);
184
+ const envelopes = Array.isArray(result) ? result : result ? [result] : [];
185
+ for (const raw of envelopes) {
186
+ const parsed = parseSignalMessage(raw, selfNumber);
187
+ if (!parsed)
188
+ continue;
189
+ for (const ref of parsed.images) {
190
+ const path = await downloadAttachment(ref);
191
+ if (path)
192
+ (parsed.msg.images ??= []).push(path);
193
+ }
194
+ await onMessage(parsed.msg).catch(() => { });
195
+ }
196
+ if (envelopes.length === 0)
197
+ await sleep(500); // daemon returned immediately (no long-poll support) → gentle spin
198
+ }
199
+ catch {
200
+ if (signal.aborted)
201
+ break;
202
+ await sleep(2000); // daemon down / network blip → back off + retry (reconnect on drop)
203
+ }
204
+ }
205
+ },
206
+ };
207
+ }
208
+ // ─────────────────────────────────────────────────────────────────────────────
209
+ // signal-cli setup (the user does this once, OUTSIDE hara):
210
+ //
211
+ // 1. Install signal-cli brew install signal-cli (or download a release; needs a JRE)
212
+ // 2. Register OR link the number (a) register a NEW number:
213
+ // signal-cli -a +1555… register # solve the captcha it prompts for
214
+ // signal-cli -a +1555… verify 123456 # the SMS code
215
+ // (b) OR link to an EXISTING phone as a secondary device:
216
+ // signal-cli link -n "hara" # scan the QR from Signal app → Linked devices
217
+ // 3. Run the HTTP/JSON-RPC daemon signal-cli -a +1555… daemon --http localhost:8080
218
+ // 4. Point hara at it HARA_SIGNAL_RPC_URL=http://localhost:8080 HARA_SIGNAL_NUMBER=+1555…
219
+ // hara gateway --platform signal
220
+ // ─────────────────────────────────────────────────────────────────────────────