@nanhara/hara 0.70.0 → 0.89.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.
@@ -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
+ }
@@ -0,0 +1,282 @@
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 { synthesize } from "./tts.js";
9
+ import { selfArgv } from "../cron/runner.js";
10
+ import { listSessions, resolveSessionId, loadSession } from "../session/store.js";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
14
+ /** Parse a leading slash-command from a chat message (pure). null if it isn't one. */
15
+ export function parseCommand(text) {
16
+ const m = /^\/([a-z]+)\b\s*([\s\S]*)$/i.exec(text.trim());
17
+ return m ? { cmd: m[1].toLowerCase(), arg: m[2].trim() } : null;
18
+ }
19
+ /** Whether a user may drive the gateway. Empty allowlist = nobody (safe default — never wide-open). */
20
+ export function isAllowed(userId, allowlist) {
21
+ return allowlist.size > 0 && allowlist.has(String(userId));
22
+ }
23
+ /** Strip hara's CLI chrome from captured `-p` output so a chat reply is just the answer: MCP status lines
24
+ * (`mcp: …`) and the token-usage footer (`… · ↑N ↓N tok`). Colors are off when piped, so no ANSI to strip. */
25
+ export function cleanReply(raw) {
26
+ return raw
27
+ .split("\n")
28
+ .filter((ln) => !/^\s*mcp: /.test(ln) && !/·\s*↑\d+\s*↓\d+\s*tok\s*$/.test(ln))
29
+ .join("\n")
30
+ .trim();
31
+ }
32
+ let outboxSeq = 0;
33
+ /** Run hara headlessly on a chat's session. Returns its cleaned text reply plus any files the agent queued
34
+ * via send_file. The gateway env (HARA_GATEWAY + a per-message outbox file) is what makes send_file and the
35
+ * in-chat system context active in the subprocess; the daemon delivers the queued files after it exits. */
36
+ function runHara(text, sessionId, cwd, platform, images) {
37
+ const outbox = join(tmpdir(), `hara-outbox-${process.pid}-${Date.now()}-${outboxSeq++}.txt`);
38
+ return new Promise((res) => {
39
+ const self = selfArgv();
40
+ const child = spawn(self[0], [...self.slice(1), "-p", text, "--approval", "full-auto", "--resume", sessionId], {
41
+ cwd,
42
+ env: {
43
+ ...process.env,
44
+ HARA_GATEWAY: platform,
45
+ HARA_GATEWAY_OUTBOX: outbox,
46
+ ...(images?.length ? { HARA_GATEWAY_IMAGES: images.join("\n") } : {}),
47
+ },
48
+ });
49
+ let out = "";
50
+ const cap = (d) => {
51
+ out = (out + d.toString()).slice(-12000);
52
+ };
53
+ child.stdout.on("data", cap);
54
+ child.stderr.on("data", cap);
55
+ const finish = (reply) => {
56
+ let files = [];
57
+ try {
58
+ if (existsSync(outbox)) {
59
+ files = readFileSync(outbox, "utf8").split("\n").map((s) => s.trim()).filter(Boolean);
60
+ rmSync(outbox, { force: true });
61
+ }
62
+ }
63
+ catch {
64
+ /* outbox is best-effort; a missing/unreadable file just means nothing to send */
65
+ }
66
+ res({ reply, files });
67
+ };
68
+ child.on("error", (e) => finish(`(error: ${e.message})`));
69
+ child.on("close", () => finish(cleanReply(out) || "(no output)"));
70
+ });
71
+ }
72
+ /** Re-exported so `hara gateway --platform weixin --login` can run the QR flow. */
73
+ export { weixinLogin } from "./weixin.js";
74
+ async function buildAdapter(platform) {
75
+ if (platform === "weixin") {
76
+ const { loadWeixinCreds, weixinAdapter } = await import("./weixin.js");
77
+ const creds = loadWeixinCreds();
78
+ if (!creds) {
79
+ console.error("hara gateway: no WeChat login found. Run `hara gateway --platform weixin --login` first.");
80
+ return null;
81
+ }
82
+ // The iLink user_id is whoever scanned the QR — the bot owner. Auto-allow them so there's no wxid dance.
83
+ return { adapter: weixinAdapter(creds), ownerId: creds.user_id || undefined };
84
+ }
85
+ if (platform === "discord") {
86
+ const token = process.env.HARA_DISCORD_TOKEN;
87
+ if (!token) {
88
+ 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.");
89
+ return null;
90
+ }
91
+ const { discordAdapter } = await import("./discord.js");
92
+ return { adapter: discordAdapter(token) };
93
+ }
94
+ if (platform === "feishu" || platform === "lark") {
95
+ const appId = process.env.HARA_FEISHU_APP_ID;
96
+ const appSecret = process.env.HARA_FEISHU_APP_SECRET;
97
+ if (!appId || !appSecret) {
98
+ 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.)");
99
+ return null;
100
+ }
101
+ const { feishuAdapter } = await import("./feishu.js");
102
+ return { adapter: feishuAdapter(appId, appSecret) };
103
+ }
104
+ if (platform === "slack") {
105
+ const appToken = process.env.HARA_SLACK_APP_TOKEN;
106
+ const botToken = process.env.HARA_SLACK_BOT_TOKEN;
107
+ if (!appToken || !botToken) {
108
+ 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>.");
109
+ return null;
110
+ }
111
+ const { slackAdapter } = await import("./slack.js");
112
+ return { adapter: slackAdapter(appToken, botToken) };
113
+ }
114
+ if (platform === "mattermost") {
115
+ const url = process.env.HARA_MATTERMOST_URL;
116
+ const token = process.env.HARA_MATTERMOST_TOKEN;
117
+ if (!url || !token) {
118
+ 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>.");
119
+ return null;
120
+ }
121
+ const { mattermostAdapter } = await import("./mattermost.js");
122
+ return { adapter: mattermostAdapter(url, token) };
123
+ }
124
+ if (platform === "matrix") {
125
+ const homeserver = process.env.HARA_MATRIX_HOMESERVER;
126
+ const token = process.env.HARA_MATRIX_TOKEN;
127
+ const userId = process.env.HARA_MATRIX_USER_ID;
128
+ if (!homeserver || !token || !userId) {
129
+ 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).");
130
+ return null;
131
+ }
132
+ const { matrixAdapter } = await import("./matrix.js");
133
+ return { adapter: matrixAdapter(homeserver, token, userId), ownerId: userId };
134
+ }
135
+ if (platform === "dingtalk" || platform === "ding") {
136
+ const clientId = process.env.HARA_DINGTALK_CLIENT_ID;
137
+ const clientSecret = process.env.HARA_DINGTALK_CLIENT_SECRET;
138
+ if (!clientId || !clientSecret) {
139
+ 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>.");
140
+ return null;
141
+ }
142
+ const { dingtalkAdapter } = await import("./dingtalk.js");
143
+ return { adapter: dingtalkAdapter(clientId, clientSecret) };
144
+ }
145
+ const token = process.env.HARA_TELEGRAM_TOKEN;
146
+ if (!token) {
147
+ console.error("hara gateway: set HARA_TELEGRAM_TOKEN (from @BotFather) and HARA_GATEWAY_ALLOWED=<your telegram user id>.");
148
+ return null;
149
+ }
150
+ return { adapter: telegramAdapter(token) };
151
+ }
152
+ /** Allowlist = the env ids ∪ the bot owner (on WeChat, whoever scanned the QR is always allowed). */
153
+ export function resolveAllowlist(envValue, ownerId) {
154
+ const set = new Set((envValue ?? "").split(",").map((s) => s.trim()).filter(Boolean));
155
+ if (ownerId)
156
+ set.add(ownerId);
157
+ return set;
158
+ }
159
+ /** The gateway's default workspace when no --cwd is given: a dedicated safe home under ~/.hara (like Hermes'
160
+ * ~/.hermes), NOT the launch dir — so a full-auto chat bot never lands on a real repo by accident. */
161
+ export function defaultWorkspace() {
162
+ const dir = join(homedir(), ".hara", "workspace");
163
+ mkdirSync(dir, { recursive: true });
164
+ const agents = join(dir, "AGENTS.md");
165
+ if (!existsSync(agents)) {
166
+ 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");
167
+ }
168
+ return dir;
169
+ }
170
+ export async function runGateway(opts) {
171
+ const platform = opts.platform || "telegram";
172
+ const cwd = opts.cwd ?? defaultWorkspace(); // dir-free default: hara's own ~/.hara/workspace, like Hermes' ~/.hermes
173
+ const built = await buildAdapter(platform);
174
+ if (!built)
175
+ process.exit(1);
176
+ const { adapter, ownerId } = built;
177
+ const allowlist = resolveAllowlist(process.env.HARA_GATEWAY_ALLOWED, ownerId);
178
+ if (allowlist.size === 0) {
179
+ const hint = platform === "weixin" ? "your WeChat id" : "your Telegram user id (DM @userinfobot)";
180
+ console.error(`hara gateway: ⚠ HARA_GATEWAY_ALLOWED is empty — nobody is allowed. Set it to ${hint}.`);
181
+ }
182
+ else if (ownerId) {
183
+ console.error(`hara gateway: bot owner auto-allowed (${ownerId}).`);
184
+ }
185
+ const ac = new AbortController();
186
+ process.on("SIGINT", () => ac.abort());
187
+ process.on("SIGTERM", () => ac.abort());
188
+ console.error(`hara gateway: ${adapter.name} up · cwd=${cwd} · ${allowlist.size} allowed user(s) · Ctrl-C to stop`);
189
+ await adapter.start(async (m) => {
190
+ if (!isAllowed(m.userId, allowlist)) {
191
+ console.error(`hara gateway: ✗ message from ${m.userId} — not in allowlist. Add it to HARA_GATEWAY_ALLOWED to authorize.`);
192
+ await adapter.send(m.chatId, "⛔ not authorized.");
193
+ return;
194
+ }
195
+ const ctx = chatContext(adapter.name, m.chatId, cwd); // this chat's current { cwd, sessionId }
196
+ const cmd = parseCommand(m.text);
197
+ if (cmd) {
198
+ if (cmd.cmd === "help")
199
+ 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/help\nanything else = run hara here");
200
+ if (cmd.cmd === "pwd")
201
+ return adapter.send(m.chatId, `📂 ${ctx.cwd}\n🧵 ${ctx.sessionId.slice(-18)}`);
202
+ if (cmd.cmd === "cd" || cmd.cmd === "project") {
203
+ if (!cmd.arg)
204
+ return adapter.send(m.chatId, `📂 ${ctx.cwd}\nusage: /cd <dir> (absolute, ~, or relative to here)`);
205
+ const target = resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir()));
206
+ if (!existsSync(target) || !statSync(target).isDirectory())
207
+ return adapter.send(m.chatId, `✗ not a directory: ${target}`);
208
+ const sid = chatCd(adapter.name, m.chatId, target);
209
+ return adapter.send(m.chatId, `📂 now in ${target}\n🧵 ${sid.slice(-18)} · /sessions lists this dir's threads`);
210
+ }
211
+ if (cmd.cmd === "new")
212
+ return adapter.send(m.chatId, `✨ new thread: ${newChatSession(adapter.name, m.chatId, cwd).slice(-18)}`);
213
+ if (cmd.cmd === "sessions") {
214
+ const list = listSessions(ctx.cwd).slice(0, 10).map((x) => `${x.id.slice(-18)} ${x.title || "(untitled)"}`).join("\n");
215
+ return adapter.send(m.chatId, `📂 ${ctx.cwd}\n${list || "(no threads in this dir yet)"}`);
216
+ }
217
+ if (cmd.cmd === "resume") {
218
+ const id = resolveSessionId(cmd.arg);
219
+ if (!id)
220
+ return adapter.send(m.chatId, `no session '${cmd.arg}'`);
221
+ const target = loadSession(id)?.meta.cwd || ctx.cwd; // follow the session's own dir so it runs in the right place
222
+ setChatSession(adapter.name, m.chatId, id, target);
223
+ return adapter.send(m.chatId, `↩ resumed ${id.slice(-18)}\n📂 ${target}`);
224
+ }
225
+ if (cmd.cmd === "voice") {
226
+ if (!adapter.sendFile)
227
+ return adapter.send(m.chatId, "this platform can't send voice yet.");
228
+ const on = toggleVoice(adapter.name, m.chatId);
229
+ return adapter.send(m.chatId, on ? "🔊 voice replies ON — I'll speak each reply too." : "🔇 voice replies OFF.");
230
+ }
231
+ if (cmd.cmd === "say") {
232
+ if (!adapter.sendFile)
233
+ return adapter.send(m.chatId, "this platform can't send voice yet.");
234
+ if (!cmd.arg)
235
+ return adapter.send(m.chatId, "usage: /say <text to speak>");
236
+ const audio = await synthesize(cmd.arg);
237
+ if (!audio)
238
+ return adapter.send(m.chatId, "✗ TTS failed (check HARA_TTS_* config).");
239
+ await adapter.sendFile(m.chatId, audio);
240
+ rmSync(audio, { force: true });
241
+ return;
242
+ }
243
+ if (cmd.cmd === "send") {
244
+ if (!adapter.sendFile)
245
+ return adapter.send(m.chatId, "this platform can't send files yet.");
246
+ const p = cmd.arg ? resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir())) : "";
247
+ if (!p || !existsSync(p) || !statSync(p).isFile())
248
+ return adapter.send(m.chatId, `✗ not a file: ${p || "(none)"}\nusage: /send <path> (abs, ~, or relative to current dir)`);
249
+ await adapter.sendFile(m.chatId, p);
250
+ return;
251
+ }
252
+ // any other slash word → treat as a normal task
253
+ }
254
+ await adapter.send(m.chatId, "⟳ working…");
255
+ const { reply, files } = await runHara(m.text, ctx.sessionId, ctx.cwd, adapter.name, m.images);
256
+ const hasReply = reply && reply !== "(no output)";
257
+ if (hasReply)
258
+ await adapter.send(m.chatId, reply);
259
+ else if (files.length)
260
+ await adapter.send(m.chatId, "📎");
261
+ // Deliver any files the agent queued via send_file (images inline, others as attachments).
262
+ for (const f of files) {
263
+ if (!adapter.sendFile) {
264
+ await adapter.send(m.chatId, "(this platform can't send files yet)");
265
+ break;
266
+ }
267
+ try {
268
+ await adapter.sendFile(m.chatId, f);
269
+ }
270
+ catch (e) {
271
+ await adapter.send(m.chatId, `✗ couldn't send ${f}: ${e.message}`);
272
+ }
273
+ }
274
+ if (hasReply && ctx.voice && adapter.sendFile) {
275
+ const audio = await synthesize(reply);
276
+ if (audio) {
277
+ await adapter.sendFile(m.chatId, audio);
278
+ rmSync(audio, { force: true });
279
+ }
280
+ }
281
+ }, ac.signal);
282
+ }
@@ -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
+ }