@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,47 @@
1
+ // Words that signal real coding/action work → keep the primary (strong) model. Broad on purpose: routing
2
+ // should fire only on clearly trivial, non-actionable turns (questions, lookups, chit-chat).
3
+ const COMPLEX = /\b(debug|refactor|implement|fix(es|ed|ing)?|build|test|deploy|migrat\w*|optimi[sz]e|architect|design|review|trace|profile|benchmark|docker|kubernetes|compile|exception|error|bug|patch|diff|commit|merge|rebase|rename|add|remove|delete|update|change|write|create|edit|run|install|generate|convert|parse|format|lint|setup|configure|wire|hook|render|fetch|query|schema|class|function|async|await|import|export)\b/i;
4
+ /** The text of the most recent genuine user message (tool results are role:"tool", so this is stable
5
+ * across a turn's tool rounds). */
6
+ export function lastUserText(history) {
7
+ for (let i = history.length - 1; i >= 0; i--) {
8
+ const m = history[i];
9
+ if (m.role === "user")
10
+ return typeof m.content === "string" ? m.content : "";
11
+ }
12
+ return "";
13
+ }
14
+ /** True if a turn is trivial enough to hand to the cheap/general model: short, single-line, no code,
15
+ * no URL, and no coding/action keyword. Defaults to FALSE (stay on the strong model) when unsure. */
16
+ export function isTrivialTurn(text) {
17
+ const t = (text ?? "").trim();
18
+ if (!t)
19
+ return false;
20
+ if (t.length > 160)
21
+ return false; // long → probably substantive
22
+ if (t.split(/\s+/).length > 28)
23
+ return false;
24
+ if (t.includes("\n"))
25
+ return false; // multi-line / paste
26
+ if (t.includes("`"))
27
+ return false; // inline code / fences
28
+ if (/https?:\/\//i.test(t))
29
+ return false; // a URL to act on
30
+ if (/[{}();=]|=>|::|\/|\\/.test(t))
31
+ return false; // code-ish / a path
32
+ if (COMPLEX.test(t))
33
+ return false; // an action/coding verb
34
+ return true;
35
+ }
36
+ /** Wrap a primary + alternate provider so each turn routes to the alternate when the latest user message is
37
+ * trivial, else the primary. Decided per turn from history (stable across tool rounds). */
38
+ export function routingProvider(primary, alt) {
39
+ return {
40
+ id: primary.id,
41
+ model: primary.model, // reported model = primary; routing is transparent
42
+ turn(args) {
43
+ const useAlt = isTrivialTurn(lastUserText(args.history));
44
+ return (useAlt ? alt : primary).turn(args);
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,91 @@
1
+ // File-state checkpoints via a SHADOW git repo — durable "undo the agent's file changes" beyond the
2
+ // edit-only in-memory undo (which misses bash-made changes). The shadow repo lives OUTSIDE the project
3
+ // (~/.hara/checkpoints/<hash>/git) with GIT_DIR there + GIT_WORK_TREE = the project root, so it captures the
4
+ // WHOLE tree, NEVER touches the user's real .git/index, and the model never sees it. Restore is
5
+ // snapshot-then-checkout: SAFE — it reverts changed/deleted files to the checkpoint and never deletes files
6
+ // created since (so a stray restore can't nuke new work; it's also itself undoable via the auto-snapshot).
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { createHash } from "node:crypto";
12
+ import { findProjectRoot } from "./context/agents-md.js";
13
+ // Heavy/derived dirs the shadow repo must never snapshot (in addition to the project's own .gitignore).
14
+ const EXCLUDES = ["node_modules/", ".git/", "dist/", "build/", "out/", ".next/", "target/", ".venv/", "venv/", "__pycache__/", ".hara/", ".cache/", ".turbo/", "coverage/", "*.log", ".DS_Store"];
15
+ function shadowGitDir(root) {
16
+ return join(homedir(), ".hara", "checkpoints", createHash("sha256").update(root).digest("hex").slice(0, 16), "git");
17
+ }
18
+ function git(root, gitDir, args) {
19
+ return execFileSync("git", args, {
20
+ cwd: root,
21
+ env: { ...process.env, GIT_DIR: gitDir, GIT_WORK_TREE: root, GIT_AUTHOR_NAME: "hara", GIT_AUTHOR_EMAIL: "hara@local", GIT_COMMITTER_NAME: "hara", GIT_COMMITTER_EMAIL: "hara@local" },
22
+ encoding: "utf8",
23
+ stdio: ["ignore", "pipe", "ignore"],
24
+ maxBuffer: 64 * 1024 * 1024,
25
+ }).toString();
26
+ }
27
+ function ensureRepo(root, gitDir) {
28
+ try {
29
+ if (!existsSync(join(gitDir, "HEAD"))) {
30
+ mkdirSync(gitDir, { recursive: true });
31
+ git(root, gitDir, ["init", "-q"]);
32
+ mkdirSync(join(gitDir, "info"), { recursive: true });
33
+ writeFileSync(join(gitDir, "info", "exclude"), EXCLUDES.join("\n") + "\n");
34
+ }
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /** Snapshot the current working tree into the shadow repo. Returns the short sha, or null on failure. */
42
+ export function checkpoint(cwd, label) {
43
+ const root = findProjectRoot(cwd);
44
+ const gitDir = shadowGitDir(root);
45
+ if (!ensureRepo(root, gitDir))
46
+ return null;
47
+ try {
48
+ git(root, gitDir, ["add", "-A"]);
49
+ git(root, gitDir, ["commit", "-q", "--allow-empty", "--no-gpg-sign", "-m", (label || "checkpoint").slice(0, 120)]);
50
+ return git(root, gitDir, ["rev-parse", "--short", "HEAD"]).trim();
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /** Recent checkpoints, newest first. */
57
+ export function listCheckpoints(cwd, n = 15) {
58
+ const gitDir = shadowGitDir(findProjectRoot(cwd));
59
+ if (!existsSync(join(gitDir, "HEAD")))
60
+ return [];
61
+ try {
62
+ const out = git(findProjectRoot(cwd), gitDir, ["log", `-n${n}`, "--format=%h\x1f%ct\x1f%s"]).trim();
63
+ if (!out)
64
+ return [];
65
+ return out.split("\n").map((l) => {
66
+ const [sha, ct, ...rest] = l.split("\x1f");
67
+ return { sha, when: Number(ct) * 1000, label: rest.join("\x1f") };
68
+ });
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ }
74
+ /** Restore the working tree's changed/deleted files to a checkpoint (snapshots current first, so it's
75
+ * undoable). Files created AFTER the checkpoint are left in place — nothing is deleted. Returns the count of
76
+ * files restored, or null on failure. */
77
+ export function restoreCheckpoint(cwd, ref) {
78
+ const root = findProjectRoot(cwd);
79
+ const gitDir = shadowGitDir(root);
80
+ if (!existsSync(join(gitDir, "HEAD")))
81
+ return null;
82
+ try {
83
+ checkpoint(cwd, `before restore to ${ref}`); // make the restore itself undoable
84
+ const changed = git(root, gitDir, ["diff", "--name-only", ref, "--"]).trim(); // files differing now vs ref
85
+ git(root, gitDir, ["checkout", ref, "--", "."]);
86
+ return changed ? changed.split("\n").length : 0;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
package/dist/config.js CHANGED
@@ -12,7 +12,7 @@ const PROVIDER_DEFAULTS = {
12
12
  openai: { model: "gpt-4o-mini", envKey: "OPENAI_API_KEY" },
13
13
  "hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
14
14
  };
15
- export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "notify", "vimMode"];
15
+ export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "notify", "vimMode", "autoCompact", "fileCheckpoints", "fallbackModel", "fallbackBaseURL", "fallbackApiKey"];
16
16
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
17
17
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
18
18
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
@@ -112,6 +112,9 @@ export function loadConfig(opts = {}) {
112
112
  const embedModel = process.env.HARA_EMBED_MODEL ?? merged.embedModel;
113
113
  const embedBaseURL = process.env.HARA_EMBED_BASE_URL ?? merged.embedBaseURL;
114
114
  const embedApiKey = process.env.HARA_EMBED_API_KEY ?? merged.embedApiKey;
115
+ const routeModel = process.env.HARA_ROUTE_MODEL ?? merged.routeModel;
116
+ const routeBaseURL = process.env.HARA_ROUTE_BASE_URL ?? merged.routeBaseURL;
117
+ const routeApiKey = process.env.HARA_ROUTE_API_KEY ?? merged.routeApiKey;
115
118
  const mcpServers = {
116
119
  ...(globalBase.mcpServers ?? {}),
117
120
  ...(project.mcpServers ?? {}),
@@ -120,7 +123,12 @@ export function loadConfig(opts = {}) {
120
123
  const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
121
124
  const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
122
125
  const vimMode = process.env.HARA_VIM === "1" || merged.vimMode === true || merged.vimMode === "true";
123
- return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, hooks, notify, vimMode, mcpServers, cwd: process.cwd() };
126
+ const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
127
+ const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
128
+ const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
129
+ const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
130
+ const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
131
+ return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, hooks, notify, vimMode, autoCompact, fileCheckpoints, fallbackModel, fallbackBaseURL, fallbackApiKey, mcpServers, cwd: process.cwd() };
124
132
  }
125
133
  export function providerEnvKey(provider) {
126
134
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
@@ -0,0 +1,76 @@
1
+ // Lazy per-directory project docs — when a tool touches a directory we haven't seen yet, surface that dir's
2
+ // AGENTS.md / CLAUDE.md (the local conventions for that package) by appending it to the tool result, so a
3
+ // monorepo's per-package rules reach the model exactly when work moves into that package. Startup already
4
+ // loads root→cwd (agents-md.ts); this covers the subdirs the agent navigates INTO. Each dir loaded once.
5
+ import { readFileSync, existsSync, statSync } from "node:fs";
6
+ import { join, dirname, resolve, relative, isAbsolute } from "node:path";
7
+ import { findProjectRoot } from "./agents-md.js";
8
+ const FILENAMES = ["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"];
9
+ const MAX = 8 * 1024;
10
+ const loaded = new Set(); // dirs whose local doc we've already injected (per process / session)
11
+ function isDir(p) {
12
+ try {
13
+ return statSync(p).isDirectory();
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /** Candidate file/dir paths referenced by a tool call (its `path`, or path-like tokens in a shell command). */
20
+ function pathsFrom(input) {
21
+ const out = [];
22
+ if (typeof input?.path === "string")
23
+ out.push(input.path);
24
+ if (typeof input?.command === "string")
25
+ for (const m of input.command.matchAll(/[\w.@~+-]*\/[\w./@+-]+/g))
26
+ out.push(m[0]);
27
+ return out;
28
+ }
29
+ /** Project docs for any NEW directory (strictly under cwd) this tool call touches — appendable to the tool
30
+ * result. Returns "" when nothing new. Each directory is checked/loaded at most once per session. */
31
+ export function subdirHint(input, cwd) {
32
+ const base = resolve(cwd);
33
+ const root = findProjectRoot(cwd);
34
+ const parts = [];
35
+ for (const raw of pathsFrom(input)) {
36
+ const absPath = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
37
+ const startDir = isDir(absPath) ? absPath : dirname(absPath);
38
+ const rel = relative(base, startDir);
39
+ if (!rel || rel.startsWith("..") || isAbsolute(rel))
40
+ continue; // not strictly under cwd → startup already covered it
41
+ // collect unseen dirs from startDir up to (but excluding) cwd
42
+ const chain = [];
43
+ let d = startDir;
44
+ while (d.startsWith(base) && d !== base) {
45
+ if (!loaded.has(d))
46
+ chain.unshift(d);
47
+ if (d === root)
48
+ break;
49
+ const parent = dirname(d);
50
+ if (parent === d)
51
+ break;
52
+ d = parent;
53
+ }
54
+ for (const cd of chain) {
55
+ loaded.add(cd); // mark checked even if no doc here, so we don't re-scan it
56
+ for (const name of FILENAMES) {
57
+ const fp = join(cd, name);
58
+ if (!existsSync(fp))
59
+ continue;
60
+ try {
61
+ let txt = readFileSync(fp, "utf8").trim();
62
+ if (!txt)
63
+ break;
64
+ if (Buffer.byteLength(txt, "utf8") > MAX)
65
+ txt = txt.slice(0, MAX) + "\n…[truncated]";
66
+ parts.push(`<!-- ${name} @ ${cd} — local conventions for this directory -->\n${txt}`);
67
+ }
68
+ catch {
69
+ /* ignore unreadable */
70
+ }
71
+ break; // first filename match per dir
72
+ }
73
+ }
74
+ }
75
+ return parts.length ? "\n\n" + parts.join("\n\n") : "";
76
+ }
@@ -10,11 +10,14 @@ import { isDue } from "./schedule.js";
10
10
  export function dueJobs(jobs, nowMs) {
11
11
  return jobs.filter((j) => j.enabled && isDue(j, nowMs));
12
12
  }
13
- /** How to invoke hara again handles both `node dist/index.js` (argv[1] is a script) and the compiled
14
- * single-binary (argv[1] is a user arg, so re-invoke the binary directly). Used by the tick + by install. */
13
+ /** How to invoke hara again. Under node, argv[1] is the entry to hand back to node — either `dist/index.js`
14
+ * OR the installed `hara` bin symlink (node runs both); as a compiled single-binary, execPath itself IS hara
15
+ * (argv[1] is a user arg), so re-invoke the binary directly. Used by the cron tick + the chat gateway.
16
+ * Discriminator is whether execPath is node — NOT argv[1]'s extension (the bin symlink has no `.js`). */
15
17
  export function selfArgv() {
16
- const a1 = process.argv[1];
17
- return a1 && /\.[cm]?js$|\.ts$/.test(a1) ? [process.execPath, a1] : [process.execPath];
18
+ const exec = process.execPath;
19
+ const underNode = /(^|[\\/])node(\.exe)?$/i.test(exec);
20
+ return underNode && process.argv[1] ? [exec, process.argv[1]] : [exec];
18
21
  }
19
22
  const lockPath = () => join(cronDir(), ".tick.lock");
20
23
  // Generous: a live-PID owner is respected this long (so a genuinely long job isn't double-fired); past it
@@ -0,0 +1,82 @@
1
+ // Background shell jobs — run long-lived commands (dev servers, `tsc --watch`, long builds) WITHOUT blocking
2
+ // the agent, then tail/kill them. Pipe-based (no PTY yet; a true interactive PTY can come later behind an
3
+ // optional dep). Same sandbox write-confinement as runShell (shared `shellCommand`). Jobs are the agent's
4
+ // own child processes and are terminated when hara exits.
5
+ import { spawn } from "node:child_process";
6
+ import { shellCommand } from "../sandbox.js";
7
+ const MAX_BUF = 64 * 1024; // retain only the tail of each job's combined output
8
+ const jobs = new Map();
9
+ let seq = 0;
10
+ let hooked = false;
11
+ function ensureExitCleanup() {
12
+ if (hooked)
13
+ return;
14
+ hooked = true;
15
+ process.on("exit", killAllJobs); // sync; runs on normal quit so background jobs don't orphan
16
+ }
17
+ /** Start a background shell job; returns its id immediately. Output is captured to a capped tail buffer. */
18
+ export function startJob(command, cwd, mode) {
19
+ ensureExitCleanup();
20
+ const { cmd, args } = shellCommand(command, cwd, mode);
21
+ const child = spawn(cmd, args, { cwd });
22
+ const job = { id: "j" + ++seq, command, child, status: "running", code: null, startedAt: Date.now(), buf: "" };
23
+ const onData = (d) => {
24
+ job.buf = (job.buf + d.toString()).slice(-MAX_BUF);
25
+ };
26
+ child.stdout?.on("data", onData);
27
+ child.stderr?.on("data", onData);
28
+ child.on("close", (code) => {
29
+ if (job.status === "running") {
30
+ job.status = "exited";
31
+ job.code = code;
32
+ }
33
+ });
34
+ child.on("error", (e) => {
35
+ if (job.status === "running") {
36
+ job.status = "exited";
37
+ job.code = -1;
38
+ job.buf = (job.buf + `\n[spawn error] ${e.message}`).slice(-MAX_BUF);
39
+ }
40
+ });
41
+ jobs.set(job.id, job);
42
+ return job.id;
43
+ }
44
+ export function listJobs() {
45
+ const now = Date.now();
46
+ return [...jobs.values()].map((j) => ({ id: j.id, command: j.command, status: j.status, code: j.code, ageMs: now - j.startedAt }));
47
+ }
48
+ /** Last `lines` lines of a job's output buffer, or null if there is no such job. */
49
+ export function tailJob(id, lines = 40) {
50
+ const j = jobs.get(id);
51
+ if (!j)
52
+ return null;
53
+ return j.buf.split("\n").slice(-lines).join("\n");
54
+ }
55
+ /** Terminate a running job (SIGTERM). Returns true only if it was running. */
56
+ export function killJob(id) {
57
+ const j = jobs.get(id);
58
+ if (!j || j.status !== "running")
59
+ return false;
60
+ try {
61
+ j.child.kill("SIGTERM");
62
+ }
63
+ catch {
64
+ /* already gone */
65
+ }
66
+ j.status = "killed";
67
+ return true;
68
+ }
69
+ /** Terminate every running job — registered on process exit so dev servers don't outlive hara. */
70
+ export function killAllJobs() {
71
+ for (const j of jobs.values()) {
72
+ if (j.status === "running") {
73
+ try {
74
+ j.child.kill("SIGTERM");
75
+ }
76
+ catch {
77
+ /* ignore */
78
+ }
79
+ j.status = "killed";
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,209 @@
1
+ // DingTalk (钉钉) adapter for `hara gateway` — connects to DingTalk's Stream Mode over Node's native global
2
+ // WebSocket (zero new dep on Node ≥ 22) so the LOCAL daemon dials OUT (no public webhook endpoint needed, like
3
+ // Feishu's long-connection). Creds from HARA_DINGTALK_CLIENT_ID / HARA_DINGTALK_CLIENT_SECRET (the app's
4
+ // AppKey/AppSecret on open.dingtalk.com). Replies go to the per-message `sessionWebhook` the inbound payload
5
+ // carries. Same ChatAdapter shape as Telegram/Discord/Feishu, so all the cross-platform gateway plumbing
6
+ // (system context, stuck-guard, allowlist) works unchanged. v1 limitations: file send is not supported (DingTalk
7
+ // bot replies via sessionWebhook only do text/markdown cards) and inbound images are noted but not downloaded.
8
+ import { chunkText } from "./telegram.js";
9
+ // Open a Stream connection here → response gives the WS endpoint + a ticket; then we dial endpoint?ticket=…
10
+ const OPEN_CONNECTION = "https://api.dingtalk.com/v1.0/gateway/connections/open";
11
+ const BOT_TOPIC = "/v1.0/im/bot/messages/get"; // the CALLBACK topic that carries a bot chat message
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
+ /** Parse a DingTalk bot message payload (the JSON.parse'd `data` field of a CALLBACK frame) → InboundMsg plus the
20
+ * per-message sessionWebhook used to reply (pure). Accepts text (and picture, marked "[图片]"); null otherwise.
21
+ * conversationId routes group/DM; senderStaffId is preferred for the allowlist (the org staff id), else senderId. */
22
+ export function parseDingtalkMessage(msg) {
23
+ if (!msg)
24
+ return null;
25
+ const chatId = String(msg.conversationId || msg.senderId || "");
26
+ if (!chatId)
27
+ return null;
28
+ const userId = String(msg.senderStaffId || msg.senderId || "");
29
+ const userName = String(msg.senderNick || userId);
30
+ const sessionWebhook = typeof msg.sessionWebhook === "string" ? msg.sessionWebhook : null;
31
+ const type = String(msg.msgtype || "");
32
+ let text = "";
33
+ if (type === "text")
34
+ text = String(msg.text?.content ?? "").trim();
35
+ else if (type === "picture")
36
+ text = "[图片]"; // v1: inbound image not downloaded (downloadCode in content)
37
+ else if (type === "richText")
38
+ text = flattenRichText(msg.content?.richText).trim();
39
+ if (!text)
40
+ return null; // unsupported type (audio/file/etc.) or empty
41
+ return { msg: { chatId, userId, userName, text }, sessionWebhook };
42
+ }
43
+ /** Flatten a DingTalk richText message (an array of {text}/{type} runs) into plain text (pure). */
44
+ function flattenRichText(runs) {
45
+ if (!Array.isArray(runs))
46
+ return "";
47
+ return runs.map((r) => (r && typeof r.text === "string" ? r.text : "")).join(" ").trim();
48
+ }
49
+ export function dingtalkAdapter(clientId, clientSecret) {
50
+ // chatId → the latest sessionWebhook seen for it (expires ~ a few hours; refreshed on each inbound message).
51
+ const webhooks = new Map();
52
+ return {
53
+ name: "dingtalk",
54
+ async send(chatId, text) {
55
+ const url = webhooks.get(String(chatId));
56
+ if (!url)
57
+ return; // no inbound seen yet → no webhook to reply through (replies must follow a message)
58
+ for (const part of chunkText(text || "(empty)")) {
59
+ await fetch(url, {
60
+ method: "POST",
61
+ headers: { "content-type": "application/json" },
62
+ body: JSON.stringify({ msgtype: "text", text: { content: part } }),
63
+ }).catch(() => { });
64
+ }
65
+ },
66
+ // sendFile intentionally omitted: DingTalk bot replies (sessionWebhook) can't upload arbitrary files in v1.
67
+ // The gateway surfaces "(this platform can't send files yet)" when the agent queues a file.
68
+ async start(onMessage, signal) {
69
+ if (!WSImpl) {
70
+ console.error("hara gateway: DingTalk needs Node ≥ 22 (global WebSocket). Upgrade Node.");
71
+ return;
72
+ }
73
+ while (!signal.aborted) {
74
+ await connectOnce(clientId, clientSecret, webhooks, onMessage, signal);
75
+ if (!signal.aborted)
76
+ await sleep(3000, signal); // reconnect backoff
77
+ }
78
+ },
79
+ };
80
+ }
81
+ /** One Stream connection: register → open the WS to the returned endpoint?ticket=…, then ACK every frame and
82
+ * dispatch bot messages. Resolves on close/abort; the caller reconnects (fresh registration each time). */
83
+ async function connectOnce(clientId, clientSecret, webhooks, onMessage, signal) {
84
+ let endpoint;
85
+ let ticket;
86
+ try {
87
+ const res = await fetch(OPEN_CONNECTION, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json", accept: "application/json" },
90
+ body: JSON.stringify({
91
+ clientId,
92
+ clientSecret,
93
+ subscriptions: [
94
+ { type: "EVENT", topic: "*" },
95
+ { type: "CALLBACK", topic: BOT_TOPIC },
96
+ ],
97
+ ua: "hara-cli/stream",
98
+ localIp: "127.0.0.1",
99
+ }),
100
+ signal,
101
+ });
102
+ if (!res.ok) {
103
+ console.error(`hara gateway: DingTalk connection register failed (HTTP ${res.status}) — check HARA_DINGTALK_CLIENT_ID/SECRET and that Stream mode is enabled.`);
104
+ return;
105
+ }
106
+ const j = (await res.json());
107
+ if (!j.endpoint || !j.ticket) {
108
+ console.error("hara gateway: DingTalk register returned no endpoint/ticket.");
109
+ return;
110
+ }
111
+ endpoint = j.endpoint;
112
+ ticket = j.ticket;
113
+ }
114
+ catch {
115
+ if (!signal.aborted)
116
+ console.error("hara gateway: DingTalk connection register error (network).");
117
+ return;
118
+ }
119
+ return new Promise((resolve) => {
120
+ const ws = new WSImpl(`${endpoint}?ticket=${encodeURIComponent(ticket)}`);
121
+ let hb = null;
122
+ const cleanup = () => {
123
+ if (hb)
124
+ clearInterval(hb);
125
+ signal.removeEventListener("abort", stop);
126
+ };
127
+ const stop = () => {
128
+ cleanup();
129
+ try {
130
+ ws.close();
131
+ }
132
+ catch {
133
+ /* already closing */
134
+ }
135
+ resolve();
136
+ };
137
+ signal.addEventListener("abort", stop, { once: true });
138
+ ws.addEventListener("open", () => {
139
+ // Application-level keepalive: a SYSTEM/ping frame keeps the connection registered between messages.
140
+ hb = setInterval(() => {
141
+ try {
142
+ ws.send(JSON.stringify({ type: "SYSTEM", headers: { topic: "ping" }, data: "{}" }));
143
+ }
144
+ catch {
145
+ /* socket gone — close handler resolves */
146
+ }
147
+ }, 30000);
148
+ });
149
+ ws.addEventListener("close", () => {
150
+ cleanup();
151
+ resolve();
152
+ });
153
+ ws.addEventListener("error", () => { });
154
+ ws.addEventListener("message", async (ev) => {
155
+ let frame;
156
+ try {
157
+ frame = JSON.parse(String(ev.data));
158
+ }
159
+ catch {
160
+ return;
161
+ }
162
+ const headers = frame?.headers ?? {};
163
+ const topic = String(headers.topic ?? "");
164
+ // ACK every frame with its messageId per the Stream ack protocol; reply data is a JSON-string `response`.
165
+ const ack = (response) => {
166
+ if (!headers.messageId)
167
+ return;
168
+ try {
169
+ ws.send(JSON.stringify({
170
+ code: 200,
171
+ headers: { messageId: headers.messageId, contentType: "application/json" },
172
+ message: "OK",
173
+ data: JSON.stringify({ response: response ?? {} }),
174
+ }));
175
+ }
176
+ catch {
177
+ /* socket gone */
178
+ }
179
+ };
180
+ if (frame?.type === "SYSTEM") {
181
+ if (topic === "disconnect") {
182
+ stop(); // server asked us to drop → close and let the caller re-register
183
+ return;
184
+ }
185
+ ack({}); // ping / connected / other system frames → just acknowledge
186
+ return;
187
+ }
188
+ if (topic === BOT_TOPIC) {
189
+ let payload;
190
+ try {
191
+ payload = JSON.parse(String(frame.data ?? "{}")); // CALLBACK data is a JSON string
192
+ }
193
+ catch {
194
+ ack({});
195
+ return;
196
+ }
197
+ ack({}); // ack first (DingTalk expects a prompt ack; the actual reply goes via sessionWebhook)
198
+ const parsed = parseDingtalkMessage(payload);
199
+ if (parsed) {
200
+ if (parsed.sessionWebhook)
201
+ webhooks.set(String(parsed.msg.chatId), parsed.sessionWebhook);
202
+ await onMessage(parsed.msg).catch(() => { });
203
+ }
204
+ return;
205
+ }
206
+ ack({}); // any other EVENT frame → acknowledge so DingTalk doesn't retry
207
+ });
208
+ });
209
+ }