@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,190 @@
1
+ // Slack adapter for `hara gateway` — uses Socket Mode so the local daemon connects OUT over Node's native
2
+ // global WebSocket (zero new dep on Node ≥ 22): apps.connections.open (app-level token, xapp-) hands back a
3
+ // wss:// URL → we connect, ACK every envelope, and turn message events into InboundMsg. Outbound is the Web
4
+ // API (bot token, xoxb-). Same ChatAdapter shape as Discord/Telegram, so all cross-platform gateway plumbing
5
+ // (send_file, in-chat system context, stuck-guard, image attach/describe) works unchanged. Two tokens are
6
+ // required because Socket Mode (xapp-) and the Web API (xoxb-) are separate auth scopes.
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 WEB = "https://slack.com/api";
12
+ const WSImpl = globalThis.WebSocket;
13
+ const sleep = (ms, signal) => new Promise((r) => {
14
+ if (signal?.aborted)
15
+ return r();
16
+ const t = setTimeout(r, ms);
17
+ signal?.addEventListener?.("abort", () => { clearTimeout(t); r(); }, { once: true });
18
+ });
19
+ const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
20
+ /** Call a Slack Web API method with a token (form-encoded, the most widely-accepted shape). Returns the parsed
21
+ * JSON; callers check `.ok`. Never throws — a failed call resolves to `{ ok: false }`. */
22
+ async function slackApi(method, token, body) {
23
+ try {
24
+ const r = await fetch(`${WEB}/${method}`, {
25
+ method: "POST",
26
+ headers: { Authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8" },
27
+ body: JSON.stringify(body),
28
+ });
29
+ return await r.json();
30
+ }
31
+ catch {
32
+ return { ok: false };
33
+ }
34
+ }
35
+ /** Slack file url_private is gated — it needs the BOT token as a Bearer header (a plain GET returns the login
36
+ * page HTML, not the bytes). Download into ~/.hara/slack/media so the agent can SEE it. */
37
+ async function downloadSlackFile(url, name, botToken) {
38
+ try {
39
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${botToken}` } });
40
+ if (!r.ok)
41
+ return null;
42
+ const dir = join(homedir(), ".hara", "slack", "media");
43
+ mkdirSync(dir, { recursive: true });
44
+ const path = join(dir, `sl_${Date.now()}_${basename(name) || "file.bin"}`);
45
+ writeFileSync(path, Buffer.from(await r.arrayBuffer()));
46
+ return path;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** Parse a Slack Events API `event` (the inner `payload.event`) → InboundMsg + its image file refs (pure;
53
+ * download happens in start()). null = ignore (not a user message / our own / an edit/delete subtype / empty).
54
+ * Mirrors discord.ts's parseDiscordMessage so it's unit-testable without the network. */
55
+ export function parseSlackEvent(event, selfId) {
56
+ if (event?.type !== "message")
57
+ return null;
58
+ if (event.bot_id || (selfId && event.user === selfId))
59
+ return null; // ignore bots + our own messages
60
+ // message_changed / message_deleted / channel_join … carry no fresh user text we want to act on
61
+ if (event.subtype && event.subtype !== "file_share")
62
+ return null;
63
+ if (!event.channel || !event.user)
64
+ return null;
65
+ const files = Array.isArray(event.files) ? event.files : [];
66
+ const imageUrls = files
67
+ .filter((f) => isImage(String(f?.name ?? ""), f?.mimetype))
68
+ .map((f) => ({ url: String(f?.url_private_download || f?.url_private || ""), name: String(f?.name ?? "image") }))
69
+ .filter((f) => f.url);
70
+ const text = String(event.text ?? "");
71
+ if (!text && !imageUrls.length)
72
+ return null;
73
+ return {
74
+ msg: {
75
+ chatId: String(event.channel),
76
+ userId: String(event.user),
77
+ userName: String(event.user), // Slack events carry only the id; users.info would resolve a name (skipped for zero extra calls)
78
+ text: text || "[图片]",
79
+ },
80
+ imageUrls,
81
+ };
82
+ }
83
+ export function slackAdapter(appToken, botToken) {
84
+ return {
85
+ name: "slack",
86
+ async send(chatId, text) {
87
+ // Slack hard-caps a message at 40k chars; 3500 keeps us clear of mrkdwn expansion + block limits.
88
+ for (const part of chunkText(text || "(empty)", 3500)) {
89
+ await slackApi("chat.postMessage", botToken, { channel: chatId, text: part });
90
+ }
91
+ },
92
+ async sendFile(chatId, filePath) {
93
+ // 3-step external-upload flow (getUploadURLExternal → PUT bytes → completeUploadExternal): the current,
94
+ // non-deprecated path that works with just files:write (the old files.upload can 404/missing_scope).
95
+ try {
96
+ const bytes = readFileSync(filePath);
97
+ const name = basename(filePath);
98
+ const url = await slackApi("files.getUploadURLExternal", botToken, { filename: name, length: bytes.length });
99
+ if (!url?.ok || !url.upload_url || !url.file_id)
100
+ return;
101
+ const up = await fetch(url.upload_url, { method: "POST", body: new Blob([bytes]) }); // presigned URL: no auth header
102
+ if (!up.ok)
103
+ return;
104
+ await slackApi("files.completeUploadExternal", botToken, { files: [{ id: url.file_id, title: name }], channel_id: String(chatId) });
105
+ }
106
+ catch {
107
+ /* upload/send failed — surfaced upstream as "no file delivered" */
108
+ }
109
+ },
110
+ async start(onMessage, signal) {
111
+ if (!WSImpl) {
112
+ console.error("hara gateway: Slack needs Node ≥ 22 (global WebSocket). Upgrade Node.");
113
+ return;
114
+ }
115
+ // Resolve our own bot user id once so parseSlackEvent can drop our own messages (echo-loop guard).
116
+ const auth = await slackApi("auth.test", botToken, {});
117
+ const selfId = String(auth?.user_id ?? "");
118
+ while (!signal.aborted) {
119
+ const wss = await openSocketUrl(appToken);
120
+ if (wss)
121
+ await connectOnce(wss, botToken, selfId, onMessage, signal);
122
+ if (!signal.aborted)
123
+ await sleep(3000, signal); // reconnect backoff (and re-open: each URL is single-use)
124
+ }
125
+ },
126
+ };
127
+ }
128
+ /** Ask Slack for a fresh single-use Socket Mode wss:// URL (app-level token). null on failure → caller backs off. */
129
+ async function openSocketUrl(appToken) {
130
+ const j = await slackApi("apps.connections.open", appToken, {});
131
+ return j?.ok && typeof j.url === "string" ? j.url : null;
132
+ }
133
+ /** One Socket Mode connection: receive envelopes, ACK each by echoing its envelope_id, and dispatch message
134
+ * events. Resolves on close/disconnect/abort; the caller re-opens a new URL and reconnects. v1 keeps it simple —
135
+ * no buffered-payload backpressure (the daemon spawns one hara per message anyway). */
136
+ function connectOnce(url, botToken, selfId, onMessage, signal) {
137
+ return new Promise((resolve) => {
138
+ const ws = new WSImpl(url);
139
+ const stop = () => {
140
+ signal.removeEventListener("abort", stop);
141
+ try {
142
+ ws.close();
143
+ }
144
+ catch {
145
+ /* already closing */
146
+ }
147
+ resolve();
148
+ };
149
+ signal.addEventListener("abort", stop, { once: true });
150
+ ws.addEventListener("close", () => {
151
+ signal.removeEventListener("abort", stop);
152
+ resolve();
153
+ });
154
+ ws.addEventListener("error", () => { });
155
+ ws.addEventListener("message", async (ev) => {
156
+ let p;
157
+ try {
158
+ p = JSON.parse(String(ev.data));
159
+ }
160
+ catch {
161
+ return;
162
+ }
163
+ // ACK first — Slack redelivers (and eventually disconnects the socket) for any envelope we don't ack in ~3s.
164
+ if (p.envelope_id) {
165
+ try {
166
+ ws.send(JSON.stringify({ envelope_id: p.envelope_id }));
167
+ }
168
+ catch {
169
+ /* socket gone */
170
+ }
171
+ }
172
+ if (p.type === "disconnect") {
173
+ stop(); // server is rotating us off (refresh / too_many_connections) → drop and let the caller reconnect
174
+ return;
175
+ }
176
+ if (p.type === "events_api") {
177
+ const parsed = parseSlackEvent(p.payload?.event, selfId);
178
+ if (parsed) {
179
+ for (const im of parsed.imageUrls) {
180
+ const path = await downloadSlackFile(im.url, im.name, botToken);
181
+ if (path)
182
+ (parsed.msg.images ??= []).push(path);
183
+ }
184
+ await onMessage(parsed.msg).catch(() => { });
185
+ }
186
+ }
187
+ // type "hello" (connection established) and slash_commands/interactive envelopes: acked above, nothing else to do.
188
+ });
189
+ });
190
+ }
@@ -0,0 +1,115 @@
1
+ // Telegram adapter for `hara gateway` — long-poll getUpdates + sendMessage over the Bot API (built-in
2
+ // fetch, zero new dep). Token from HARA_TELEGRAM_TOKEN. The generic `ChatAdapter` shape is what WeChat(iLink)
3
+ // / Feishu plug into next.
4
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { join, basename } from "node:path";
6
+ import { homedir } from "node:os";
7
+ const API = "https://api.telegram.org";
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ /** Extract an InboundMsg from a Telegram getUpdates result item (pure). Accepts text and photo messages
10
+ * (photo → caption or a "[图片]" marker; the image itself is downloaded in start()). null otherwise. */
11
+ export function parseTelegramUpdate(u) {
12
+ const m = u?.message;
13
+ if (!m || !m.chat?.id)
14
+ return null;
15
+ const text = typeof m.text === "string" ? m.text : typeof m.caption === "string" ? m.caption : "";
16
+ const hasPhoto = Array.isArray(m.photo) && m.photo.length > 0;
17
+ if (!text && !hasPhoto)
18
+ return null; // not text or a photo (sticker/location/etc.)
19
+ return {
20
+ chatId: m.chat.id,
21
+ userId: m.from?.id ?? 0,
22
+ userName: m.from?.username || m.from?.first_name || String(m.from?.id ?? ""),
23
+ text: text || "[图片]",
24
+ };
25
+ }
26
+ /** The largest photo's file_id (Telegram sends `photo` as an ascending size array) (pure). null if none. */
27
+ export function photoFileId(u) {
28
+ const photo = u?.message?.photo;
29
+ return Array.isArray(photo) && photo.length ? (photo[photo.length - 1]?.file_id ?? null) : null;
30
+ }
31
+ /** Download a Telegram file by file_id → a local path under ~/.hara/telegram/media (getFile then the file CDN). */
32
+ async function downloadTelegramFile(base, token, fileId) {
33
+ try {
34
+ const r = await fetch(`${base}/getFile?file_id=${encodeURIComponent(fileId)}`);
35
+ const j = (await r.json());
36
+ const fp = j?.result?.file_path;
37
+ if (!fp)
38
+ return null;
39
+ const dl = await fetch(`${API}/file/bot${token}/${fp}`);
40
+ if (!dl.ok)
41
+ return null;
42
+ const dir = join(homedir(), ".hara", "telegram", "media");
43
+ mkdirSync(dir, { recursive: true });
44
+ const path = join(dir, `tg_${Date.now()}_${basename(fp)}`);
45
+ writeFileSync(path, Buffer.from(await dl.arrayBuffer()));
46
+ return path;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** Split text into chunks ≤ max (Telegram caps a message at 4096 chars) (pure). */
53
+ export function chunkText(text, max = 4000) {
54
+ if (text.length <= max)
55
+ return [text];
56
+ const out = [];
57
+ for (let i = 0; i < text.length; i += max)
58
+ out.push(text.slice(i, i + max));
59
+ return out;
60
+ }
61
+ export function telegramAdapter(token) {
62
+ const base = `${API}/bot${token}`;
63
+ return {
64
+ name: "telegram",
65
+ async send(chatId, text) {
66
+ for (const part of chunkText(text || "(empty)")) {
67
+ await fetch(`${base}/sendMessage`, {
68
+ method: "POST",
69
+ headers: { "content-type": "application/json" },
70
+ body: JSON.stringify({ chat_id: chatId, text: part }),
71
+ }).catch(() => { });
72
+ }
73
+ },
74
+ async sendFile(chatId, filePath) {
75
+ // images → sendPhoto (inline preview); everything else → sendDocument (keeps the filename)
76
+ const name = basename(filePath);
77
+ const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
78
+ const form = new FormData();
79
+ form.append("chat_id", String(chatId));
80
+ form.append(isImg ? "photo" : "document", new Blob([readFileSync(filePath)]), name);
81
+ await fetch(`${base}/${isImg ? "sendPhoto" : "sendDocument"}`, { method: "POST", body: form }).catch(() => { });
82
+ },
83
+ async start(onMessage, signal) {
84
+ let offset = 0;
85
+ while (!signal.aborted) {
86
+ try {
87
+ const res = await fetch(`${base}/getUpdates?timeout=30&offset=${offset}`, { signal });
88
+ if (!res.ok) {
89
+ await sleep(2000);
90
+ continue;
91
+ }
92
+ const j = (await res.json());
93
+ for (const u of j.result ?? []) {
94
+ offset = Math.max(offset, (u.update_id ?? 0) + 1);
95
+ const msg = parseTelegramUpdate(u);
96
+ if (!msg)
97
+ continue;
98
+ const fid = photoFileId(u); // a photo message → download it so the agent can SEE it
99
+ if (fid) {
100
+ const path = await downloadTelegramFile(base, token, fid);
101
+ if (path)
102
+ msg.images = [path];
103
+ }
104
+ await onMessage(msg).catch(() => { });
105
+ }
106
+ }
107
+ catch {
108
+ if (signal.aborted)
109
+ break;
110
+ await sleep(2000); // network blip → back off + retry
111
+ }
112
+ }
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,135 @@
1
+ // tmux reply routing — lets a chat reply (WeChat) be injected back into an already-running tmux session
2
+ // (e.g. a Claude Code / codex / hara you started yourself), so "ping me on WeChat → I reply from outside →
3
+ // that session continues" works WITHOUT the daemon owning the process. The asking session registers its tmux
4
+ // pane (via the wechat-send `--ask` flow); the gateway daemon (sole WeChat receiver) injects the owner's reply
5
+ // into the oldest live registered pane with `tmux send-keys`. Borrows the ccgram keystroke-injection pattern.
6
+ //
7
+ // Safety: the daemon only reaches this AFTER its allow-list gate (so only the owner can trigger it), and it
8
+ // ONLY injects into panes that opted in by registering — never an arbitrary pane.
9
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { execFileSync } from "node:child_process";
11
+ import { join } from "node:path";
12
+ import { homedir } from "node:os";
13
+ function dir() {
14
+ return join(homedir(), ".hara", "gateway");
15
+ }
16
+ function storePath() {
17
+ return join(dir(), "tmux-routes.json");
18
+ }
19
+ function load() {
20
+ try {
21
+ const j = JSON.parse(readFileSync(storePath(), "utf8"));
22
+ return Array.isArray(j?.routes) ? j.routes : [];
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ }
28
+ function save(routes) {
29
+ mkdirSync(dir(), { recursive: true });
30
+ writeFileSync(storePath(), JSON.stringify({ routes }, null, 2));
31
+ }
32
+ /** Register (or refresh) a pane as awaiting a reply. De-dups by pane. mode "once" (default) = consumed after one
33
+ * reply; "bind" = persistent (every reply injects until unbound). */
34
+ export function registerTmuxRoute(pane, peer, cwd, mode = "once", now = Date.now()) {
35
+ const routes = load().filter((r) => r.pane !== pane);
36
+ routes.push({ pane, peer, cwd, ts: now, mode });
37
+ save(routes);
38
+ }
39
+ /** Remove a pane's route(s). Returns how many were removed. */
40
+ export function unbindPane(pane) {
41
+ const before = load();
42
+ const after = before.filter((r) => r.pane !== pane);
43
+ save(after);
44
+ return before.length - after.length;
45
+ }
46
+ /** All current routes (for `hara remote status`). */
47
+ export function listRoutes() {
48
+ return load();
49
+ }
50
+ /** Remove all persistent "bind" routes (the chat `/detach` command). Returns how many were removed. */
51
+ export function unbindBinds() {
52
+ const before = load();
53
+ const after = before.filter((r) => r.mode === "bind" ? false : true);
54
+ save(after);
55
+ return before.length - after.length;
56
+ }
57
+ /** Pure: pick the OLDEST live registered pane (FIFO — the longest-waiting ask answers first); return it plus the
58
+ * routes to keep. A "once" route is consumed after use; a "bind" route persists. Dead panes are always pruned. */
59
+ export function pickRoute(routes, isAlive) {
60
+ const live = routes.filter((r) => isAlive(r.pane)).sort((a, b) => a.ts - b.ts);
61
+ const chosen = live[0] ?? null;
62
+ const remaining = chosen && chosen.mode !== "bind" ? live.filter((r) => r.pane !== chosen.pane) : live;
63
+ return { chosen, remaining };
64
+ }
65
+ /** Is this tmux pane still alive? Checks membership in `list-panes -a` (display-message -t is too lenient and
66
+ * falls back to the active pane for a bogus target). false if tmux isn't running or the pane is gone. */
67
+ export function paneAlive(pane) {
68
+ try {
69
+ const out = execFileSync("tmux", ["list-panes", "-a", "-F", "#{pane_id}"], { encoding: "utf8", timeout: 3000 });
70
+ return out.split("\n").map((s) => s.trim()).includes(pane);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /** Type `text` into a tmux pane as if the user typed it, then press Enter (submits the line / sends the turn). */
77
+ export function injectTmux(pane, text) {
78
+ execFileSync("tmux", ["send-keys", "-t", pane, "-l", "--", text], { timeout: 3000 });
79
+ execFileSync("tmux", ["send-keys", "-t", pane, "Enter"], { timeout: 3000 });
80
+ }
81
+ /** Persistent ("bind") routes only — the panes whose OUTPUT we relay back to chat (two-way remote terminal). */
82
+ export function boundRoutes() {
83
+ return load().filter((r) => r.mode === "bind");
84
+ }
85
+ /** Capture a tmux pane's visible text (plain, no ANSI). null if unavailable. */
86
+ export function capturePane(pane) {
87
+ try {
88
+ return execFileSync("tmux", ["capture-pane", "-p", "-t", pane], { encoding: "utf8", timeout: 3000 });
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ /** Pure: the NEW output to relay, given what we last sent and the current pane capture. "" = nothing new.
95
+ * Handles the common append case, anchors on the last sent line when the pane has scrolled, and falls back to
96
+ * the tail when it can't re-anchor. */
97
+ export function outputDelta(lastSent, current) {
98
+ if (current === lastSent)
99
+ return "";
100
+ if (!lastSent)
101
+ return current; // caller decides whether to baseline (skip) or send on first sight
102
+ if (current.startsWith(lastSent))
103
+ return current.slice(lastSent.length);
104
+ const lines = lastSent.split("\n").filter((l) => l.trim());
105
+ const anchor = lines[lines.length - 1];
106
+ if (anchor) {
107
+ const idx = current.lastIndexOf(anchor);
108
+ if (idx >= 0)
109
+ return current.slice(idx + anchor.length);
110
+ }
111
+ return current.split("\n").slice(-20).join("\n"); // scrolled past our anchor → send the tail
112
+ }
113
+ /** Pick (and consume per mode) the oldest live registered pane WITHOUT injecting — so the caller can capture the
114
+ * pane before/after injecting and relay just the new output. Returns the pane id, or null if none pending. */
115
+ export function pickPaneForReply() {
116
+ const { chosen, remaining } = pickRoute(load(), paneAlive);
117
+ save(remaining);
118
+ return chosen?.pane ?? null;
119
+ }
120
+ /** Daemon entrypoint: deliver an inbound reply to the oldest live registered pane. Returns the pane id injected
121
+ * into, or null if there was no pending route (→ caller treats the message as a normal task). One-shot: the
122
+ * chosen route is consumed and dead panes are pruned. */
123
+ export function deliverToTmux(text) {
124
+ const { chosen, remaining } = pickRoute(load(), paneAlive);
125
+ save(remaining);
126
+ if (!chosen)
127
+ return null;
128
+ try {
129
+ injectTmux(chosen.pane, text);
130
+ return chosen.pane;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
@@ -0,0 +1,100 @@
1
+ // Pluggable text-to-speech for the chat gateway's voice replies. Mirrors the video project's provider design:
2
+ // a config-driven registry, nothing vendor-hardcoded, both API and local. Selected by env:
3
+ // HARA_TTS_PROVIDER = say | openai | cmd (default: say)
4
+ // HARA_TTS_VOICE / HARA_TTS_MODEL / HARA_TTS_BASE_URL / HARA_TTS_API_KEY / HARA_TTS_CMD
5
+ // Providers:
6
+ // say — local macOS `say` (zero-config default; fast, ~0.5s; Chinese voices e.g. Tingting) → m4a
7
+ // openai — any OpenAI-compatible /audio/speech endpoint (point BASE_URL at Aliyun DashScope or a local
8
+ // TTS server); reuses the existing `openai` dep, no new dependency
9
+ // cmd — a configurable local command (point it at VoxCPM or any local TTS); the text is piped on stdin,
10
+ // `{out}` in the command is replaced with the output path
11
+ import { spawn } from "node:child_process";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { writeFileSync } from "node:fs";
15
+ import { randomUUID } from "node:crypto";
16
+ /** Read TTS settings from the environment (fully configurable — nothing hardcoded). */
17
+ export function ttsConfigFromEnv(env = process.env) {
18
+ return {
19
+ provider: (env.HARA_TTS_PROVIDER || "say").trim(),
20
+ voice: (env.HARA_TTS_VOICE || "").trim(),
21
+ model: (env.HARA_TTS_MODEL || "").trim(),
22
+ baseURL: (env.HARA_TTS_BASE_URL || "").trim(),
23
+ apiKey: (env.HARA_TTS_API_KEY || "").trim(),
24
+ cmd: (env.HARA_TTS_CMD || "").trim(),
25
+ };
26
+ }
27
+ /** Normalize a reply for speech: collapse whitespace, drop code fences, cap length (long audio is unwanted). */
28
+ export function ttsCleanText(text, max = 1200) {
29
+ return text
30
+ .replace(/```[\s\S]*?```/g, " (code omitted) ")
31
+ .replace(/\s+/g, " ")
32
+ .trim()
33
+ .slice(0, max);
34
+ }
35
+ function run(cmd, args, stdin) {
36
+ return new Promise((resolve) => {
37
+ const c = spawn(cmd, args, { stdio: [stdin != null ? "pipe" : "ignore", "ignore", "ignore"] });
38
+ c.on("error", () => resolve(false));
39
+ c.on("close", (code) => resolve(code === 0));
40
+ if (stdin != null)
41
+ c.stdin?.end(stdin);
42
+ });
43
+ }
44
+ // local: macOS `say` → m4a (AAC). Zero config; HARA_TTS_VOICE picks the voice (default Tingting / zh).
45
+ async function sayTts(text, out, cfg) {
46
+ return run("say", ["-v", cfg.voice || "Tingting", "-o", out, "--file-format", "m4af", "--data-format", "aac", text]);
47
+ }
48
+ // API: OpenAI-compatible /audio/speech. Works against OpenAI, Aliyun DashScope (compatible-mode), or a local
49
+ // server exposing the same shape — set HARA_TTS_BASE_URL + HARA_TTS_API_KEY + HARA_TTS_MODEL + HARA_TTS_VOICE.
50
+ async function openaiTts(text, out, cfg) {
51
+ if (!cfg.apiKey && !cfg.baseURL)
52
+ return false; // not configured → unavailable
53
+ const { default: OpenAI } = (await import("openai"));
54
+ const client = new OpenAI({ baseURL: cfg.baseURL || undefined, apiKey: cfg.apiKey || "x" });
55
+ const res = await client.audio.speech.create({ model: cfg.model || "tts-1", voice: cfg.voice || "alloy", input: text });
56
+ const buf = Buffer.from(await res.arrayBuffer());
57
+ if (!buf.length)
58
+ return false;
59
+ writeFileSync(out, buf);
60
+ return true;
61
+ }
62
+ // local: a configurable command (e.g. VoxCPM). `{out}` → output path; the text is piped on stdin.
63
+ async function cmdTts(text, out, cfg) {
64
+ if (!cfg.cmd)
65
+ return false;
66
+ return run("sh", ["-c", cfg.cmd.replace(/\{out\}/g, out)], text);
67
+ }
68
+ const PROVIDERS = {
69
+ say: sayTts,
70
+ openai: openaiTts,
71
+ cmd: cmdTts,
72
+ };
73
+ const EXT = { say: "m4a", openai: "mp3", cmd: "wav" };
74
+ /** Synthesize `text` to a temp audio file; returns its path, or null on failure. Falls back to local `say`
75
+ * if a configured provider fails (so a misconfigured API never silently kills voice replies). */
76
+ export async function synthesize(text, cfg = ttsConfigFromEnv()) {
77
+ const clean = ttsCleanText(text);
78
+ if (!clean)
79
+ return null;
80
+ const provider = PROVIDERS[cfg.provider] ? cfg.provider : "say";
81
+ const out = join(tmpdir(), `hara-tts-${randomUUID().slice(0, 8)}.${EXT[provider] || "wav"}`);
82
+ try {
83
+ if (await PROVIDERS[provider](clean, out, cfg))
84
+ return out;
85
+ }
86
+ catch (e) {
87
+ console.error(`tts(${provider}): ${e?.message ?? e}`);
88
+ }
89
+ if (provider !== "say") {
90
+ try {
91
+ const o2 = join(tmpdir(), `hara-tts-${randomUUID().slice(0, 8)}.m4a`);
92
+ if (await sayTts(clean, o2, cfg))
93
+ return o2; // graceful fallback to local
94
+ }
95
+ catch {
96
+ /* give up */
97
+ }
98
+ }
99
+ return null;
100
+ }