agent-hub-runner 0.3.3 → 0.5.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.
package/README.md CHANGED
@@ -16,8 +16,8 @@ agent-hub 的电脑运行器。装在你自己的电脑上,配对之后,你
16
16
  ## 需要
17
17
 
18
18
  - **Node 22 或更新**(终端里运行 `node -v` 看版本)。还没装:去 [nodejs.org](https://nodejs.org) 下载 LTS 版(Mac 选 .pkg 安装包),或者 `brew install node`。
19
- - **macOS**:给 bot 选目录时会在 Mac 上弹出系统的「选文件夹」窗口;开机自启也只支持 Mac。别的系统能连上,但选不了目录。
20
- - bot 跑命令时用 `bash`(macOS、Linux 自带)。
19
+ - **macOS / Windows**:给 bot 选自定义目录时会弹系统选文件夹窗口;macOS 和 Windows 都支持开机自启。
20
+ - bot 跑命令时 macOS / Linux 用 `bash`,Windows 用 PowerShell。
21
21
 
22
22
  ## 第一次连接
23
23
 
@@ -30,7 +30,15 @@ agent-hub 的电脑运行器。装在你自己的电脑上,配对之后,你
30
30
 
31
31
  配对码 5 分钟有效、只能用一次;过期了在抽屉里点「换一个码」。配对成功后直接连上。
32
32
  3. Mac 上会问「开机自动连上?(Y/n)」:回车就是「是」,以后开机自己连上,终端可以关;选「否」的话终端窗口别关。
33
- 4. 抽屉里第 2 步会自动打勾。第 3 步点「选择目录…」,在 Mac 弹出的窗口里选这个 bot 能用的文件夹。每个 bot 各选各的。
33
+ 4. 抽屉里第 2 步会自动打勾。之后到网页右侧「bot设置 → 执行环境」给这个 bot 选电脑;默认使用这台电脑的共享资料库,需要时可点「选择自定义目录」在本机弹窗里选文件夹。
34
+
35
+ ## 多台电脑、共享资料库和整台电脑访问(0.5.0 起)
36
+
37
+ - 同一个账号可以连多台电脑;每台运行器有自己的配置和令牌。
38
+ - 每台电脑有一个**固定的共享资料库**:`~/.agent-runner/library`(Windows 是当前用户目录下的 `.agent-runner\library`)。程序自动创建,不能改成别的路径;升级、重启、自更新都不会清空。它是应用数据,不是临时缓存。
39
+ - bot 绑定这台电脑后默认就使用共享资料库;单个 bot 可以在网页 bot 设置里改成别的目录。
40
+ - 绑定后 bot 可以读写整台电脑,**不再限制在某个授权文件夹**;实际仍受运行器所在系统账号本身的权限限制。read 不弹批准,write/edit 自动允许并留记录,跑命令仍按「帮我批准命令行」决定是否要你点。只绑定你信任的电脑。
41
+ - 后台任务创建时冻结当时的电脑和目录;中途在网页切绑不影响旧任务,新消息和新任务用新绑定。
34
42
 
35
43
  `@` 后面的地址默认是 https;`localhost`、`127.x` 默认 http;也可以写全 `码@http://…`。老写法 `pair <配对码> --server <地址>` 照样能用。只配对不连:加 `--no-start`。
36
44
 
@@ -63,14 +71,15 @@ npx -y agent-hub-runner@latest browser status # 看浏览器装没装
63
71
 
64
72
  ## 配置文件
65
73
 
66
- - 默认在 `~/.agent-runner/config.json`(权限 600),里面有服务器地址、设备 id、长期令牌、电脑名和每个 bot 的目录。别发给别人。
74
+ - 默认在 `~/.agent-runner/config.json`(权限 600),里面有服务器地址、设备 id、长期令牌、电脑名和每个 bot 的工作目录。别发给别人。
75
+ - 共享资料库固定在同一应用目录下的 `library/`,不写进配置,也没有修改命令。
67
76
  - 想放别处:设环境变量 `AGENT_RUNNER_CONFIG=/某个路径/config.json`。
68
77
  - 文件坏了:删掉它,回网页拿新配对码重新配对。
69
78
 
70
79
 
71
80
  ## 项目上下文快照(0.3.0;`.claude/skills` 起 0.3.1)
72
81
 
73
- 服务器在每轮主对话开始时可能调用只读操作 `context_snapshot`(没有路径参数):在**当前 bot 的授权文件夹根**里读取一份 AGENTS/CLAUDE 规则,并列出 `.pi/skills`、`.agents/skills`、`.claude/skills` 下技能的名称与说明(不上传技能全文)。目录外路径和越界符号链接一律拒绝。基础快照需要运行器 **0.3.0+**;发现 `.claude/skills` 需要 **0.3.1+**。
82
+ 服务器在每轮主对话开始时可能调用只读操作 `context_snapshot`(没有路径参数):在**当前 bot 的默认工作目录**里读取一份 AGENTS/CLAUDE 规则,并列出 `.pi/skills`、`.agents/skills`、`.claude/skills` 下技能的名称与说明(不上传技能全文)。R758 起目录不再是权限边界,但项目规则仍只从默认工作目录开始找。基础快照需要运行器 **0.3.0+**;发现 `.claude/skills` 需要 **0.3.1+**。
74
83
 
75
84
  ## 常见问题
76
85
 
@@ -79,10 +88,13 @@ npx -y agent-hub-runner@latest browser status # 看浏览器装没装
79
88
  - 「权限不够」/ `EACCES`:别用 sudo;运行 `sudo chown -R "$(whoami)" ~/.npm ~/.agent-runner` 修好再试。
80
89
  - 「找不到服务器」「连不上服务器」「超时」:检查网络;命令要整条原样复制。
81
90
  - 「配对码过期了 / 已经用过了 / 已经作废了」:网页上点「换一个码」再运行新命令。
82
- - 「这个账号已经连着一台电脑了」:一个账号同时只能连一台,先在网页抽屉「连着的电脑」里点「断开」。
91
+ - 「这个账号已经连着一台电脑了」:这是旧版提示;0.5.0 起一个账号可以连多台,旧版可以升级后再配。
92
+ - 想再加一台电脑:网页「我的电脑」抽屉里点「添加电脑」,拿新码到另一台电脑运行。
83
93
  - 开机自启报「Bootstrap failed: 5: Input/output error」:这个后台任务以前被 macOS 记成了「已禁用」。0.2.1 起 `autostart on` 会自己解除并重试,直接再运行一次 `npx -y agent-hub-runner@latest autostart on` 就行;还不行就按它给的那条 `launchctl enable …` 命令运行后再试,或重启电脑再试。
84
94
  - 升级后网页还说「运行器版本太旧」:旧版本的运行器还在后台跑着(升级前开的终端,或者旧的开机自启),新版一看有人连着就退出了。0.2.2 起新版会自己把旧版本停掉、接上服务器;从 0.2.1 或更早升上来的,运行一次 `npx -y agent-hub-runner@latest autostart on` 就行,它会先停掉正在跑的那个,等新版本真连上了才说「已设开机自启」。
85
95
  - 网页上点「升级」(0.3.2 起):抽屉里有新版本时点一下,运行器自己去 npm 拉最新版、校验后换上并重启,不用开终端。0.3.1 及更早不认这个按钮,仍用上面那条命令升一次。
96
+ - 联网搜索(0.4.0 起):bot 设置里打开「允许联网搜索」后,bot 可以上网查资料。默认用 DuckDuckGo,不用注册、不用 key。想更稳可以在「我的电脑」抽屉里换成 Tavily 或 Brave(去官网注册,把 key 粘进来),或者填一个 SearXNG 地址。key 只存在你电脑上,服务器不留。0.3.x 及更早没有这项,抽屉里会给升级命令。
97
+ - 共享资料库和整台电脑访问需要 **0.5.0**;旧运行器仍能连接,但不知道默认资料库,也不会按新规则放开目录。
86
98
 
87
99
  ## 断开、卸载
88
100
 
package/autostart.mjs CHANGED
@@ -20,7 +20,7 @@ export const THROTTLE_S = 10;
20
20
  /** 复制到 ~/.agent-runner/bin/<版本>/ 的文件(package.json 用来读版本号) */
21
21
  // @意图#R729(2026-11-04): 多复制 browser.mjs(runner.mjs 要 import 它)
22
22
  // @意图#R690(2026-11-01): 多复制 changes.mjs(runner.mjs 要 import 它,少了开机自启的副本起不来)
23
- export const COPY_FILES = ["runner.mjs", "ops.mjs", "project-context.mjs", "edit.mjs", "changes.mjs", "browser.mjs", "autostart.mjs", "package.json"];
23
+ export const COPY_FILES = ["runner.mjs", "ops.mjs", "project-context.mjs", "edit.mjs", "changes.mjs", "browser.mjs", "autostart.mjs", "search.mjs", "package.json"]; // search.mjs:R751 联网搜索(零依赖,拷文件即可)
24
24
  /** 优先用的稳定 node 路径(Homebrew 升级 node 后这两个软链还在;nvm 的带版本号路径卸了就没了) */
25
25
  export const STABLE_NODES = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
26
26
  export const ONLY_MAC = "开机自启目前只支持 Mac 和 Windows";
package/ops.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // @意图#R636(2026-10-27): 运行器在你电脑上真正执行 read / write / edit / bash。路径只在这里判断(S6 结论:服务器不解析用户电脑的路径):
2
- // 先按授权目录换算成真实路径(解开符号链接,包括指向不存在目标的符号链接),不在授权目录里的一律拒绝;bash 的工作目录就是授权目录。
1
+ // @意图#R636(2026-10-27): 运行器在你电脑上真正执行 read / write / edit / bash。路径只在这里判断(S6 结论:服务器不解析用户电脑的路径)。
2
+ // @意图#R758(2026-11-08): 绑定后整台电脑都可读写,工作目录只是默认 cwd(相对路径起点);仍然解开符号链接,不越过 OS 账号权限。
3
3
  // 第一轮没有沙箱(king 定的范围):bash 里的命令本身能碰到目录外的东西,只能靠你看清批准卡片再点。
4
4
  import { spawn, spawnSync } from "node:child_process";
5
5
  import fs from "node:fs/promises";
@@ -44,14 +44,10 @@ export async function realOf(abs, depth = 0) {
44
44
 
45
45
  export const inside = (real, root) => real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
46
46
 
47
- /** 换算并检查:在授权目录里返回真实路径,不在就抛「目录外路径已拒绝」。 */
48
- // @意图#R636(2026-10-27): 授权目录外的路径一律拒绝
47
+ /** 换算成真实路径;R758 起不再限制在 root 内,root 只用来解析相对路径。 */
49
48
  export async function resolveInside(p, root) {
50
49
  if (typeof p !== "string" || !p.trim()) throw new Error("路径是空的");
51
- let real;
52
- try { real = await realOf(expandPath(p, root)); } catch (e) { throw new Error(`无法解析路径:${p}(${e?.code ?? e?.message ?? e})`); }
53
- if (!inside(real, root)) throw new Error(`目录外路径已拒绝:${p}(授权目录是 ${root})`);
54
- return real;
50
+ try { return await realOf(expandPath(p, root)); } catch (e) { throw new Error(`无法解析路径:${p}(${e?.code ?? e?.message ?? e})`); }
55
51
  }
56
52
 
57
53
  function imageMime(buf) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub-runner",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准),还能用它自己的独立浏览器",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "changes.mjs",
15
15
  "browser.mjs",
16
16
  "autostart.mjs",
17
+ "search.mjs",
17
18
  "bin/agent-hub-runner.mjs",
18
19
  "README.md"
19
20
  ],
package/runner.mjs CHANGED
@@ -28,9 +28,10 @@ import { fileURLToPath } from "node:url";
28
28
  import { runOp } from "./ops.mjs";
29
29
  import { buildChangeRecord, changesDirFor, CHANGE_ID_RE, createChangeStore } from "./changes.mjs";
30
30
  import { browserStatus, createBrowserManager, installBrowser } from "./browser.mjs";
31
+ import { searchWeb, cleanSearchConfig, searchConfigStatus } from "./search.mjs"; // R751
31
32
  import { autostartOff, autostartOffWindows, autostartOn, autostartOnWindows, autostartStatus, autostartStatusWindows, COPY_FILES, installCopy, integrityOk, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
32
33
 
33
- export const PROTOCOL_VERSION = 1;
34
+ export const PROTOCOL_VERSION = 2;
34
35
  /** npm 包名,也是装好后的命令名 */
35
36
  export const PKG_BIN = "agent-hub-runner";
36
37
  /** R666:抽屉里给的、npx 运行时提示里写的命令前缀(@latest:每次都用最新版,也不会被电脑上旧的全局安装盖掉) */
@@ -49,6 +50,8 @@ export const CLOSE_REPLACED = 4409;
49
50
 
50
51
  // @意图#R666(2026-10-29): 默认目录可用 AGENT_RUNNER_HOME 换(测试、开机自启的副本用);不设时和原来一样是 ~/.agent-runner/config.json
51
52
  export const configPath = () => process.env.AGENT_RUNNER_CONFIG || path.join(runnerHome(), "config.json");
53
+ /** R758:这台电脑固定的共享资料库(程序自己管理,不提供修改入口);测试可用 AGENT_RUNNER_HOME 隔离。 */
54
+ export const libraryDir = (env = process.env) => path.join(runnerHome(env), "library");
52
55
 
53
56
  const stamp = () => new Date().toLocaleTimeString("zh-CN", { hour12: false });
54
57
  const say = (s) => console.log(`[${stamp()}] ${s}`);
@@ -187,9 +190,8 @@ export function pickDirWindows({ signal, spawn: spawnFn = spawn } = {}) {
187
190
 
188
191
  // @意图#R639(2026-10-27): 配对时的 --dir 和网页上选的目录走同一套检查
189
192
  export async function checkDir(dir) {
190
- const real = await fs.realpath(path.resolve(dir)).catch(() => { throw new Error(`授权目录不存在:${dir}`); });
191
- if (!statSync(real).isDirectory()) throw new Error(`授权目录不是目录:${dir}`);
192
- if (real === path.parse(real).root) throw new Error("不能把整个磁盘根目录授权出去");
193
+ const real = await fs.realpath(path.resolve(dir)).catch(() => { throw new Error(`工作目录不存在:${dir}`); });
194
+ if (!statSync(real).isDirectory()) throw new Error(`工作目录不是目录:${dir}`);
193
195
  return real;
194
196
  }
195
197
 
@@ -306,8 +308,10 @@ const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
306
308
  // 新消息 change_get {id, change_id} → change_result {id, ok:true, found, record},不看这个 bot 有没有目录(换了目录也能看旧记录)
307
309
  // @意图#R729(2026-11-04): 新消息 browser {id, bot_id, action, args} → result(截图或元素清单)、browser_open {id, bot_id} → result(网页点「打开 bot 的浏览器」);
308
310
  // 浏览器不看这个 bot 有没有选目录(资料目录在 ~/.agent-runner/browser-profiles/<bot_id>/,和授权目录无关);断线不关浏览器(重连后接着用)
309
- export function createSession(cfg, { log = say, platform = process.platform, pickDir = platform === "win32" ? pickDirWindows : pickDirMac, file = configPath(), changes, browser } = {}) {
310
- let dirs = botDirsOf(cfg); // bot_id → 授权目录
311
+ export function createSession(cfg, { log = say, platform = process.platform, pickDir = platform === "win32" ? pickDirWindows : pickDirMac, file = configPath(), changes, browser, search } = {}) {
312
+ const searchImpl = search ?? searchWeb; // R751:测试注入,默认真搜
313
+ const sharedLibrary = libraryDir();
314
+ let dirs = botDirsOf(cfg); // bot_id → 工作目录
311
315
  let browsers = browser; // R729:每个 bot 一只浏览器,用到时才建
312
316
  const browserMgr = () => (browsers ??= createBrowserManager({ log }));
313
317
  const jobs = new Map(); // 调用 id → AbortController
@@ -330,8 +334,11 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
330
334
  async function handleCall(send, msg) {
331
335
  const { id, op, args } = msg;
332
336
  const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
333
- const dir = bot ? dirs[bot] : undefined; // 这次调用用开始时这个 bot 的目录;中途换目录不影响它
337
+ // R758:任务/这一轮冻结的目录优先;没有就按 bot 当前目录。中途换目录不影响已经发出去的调用。
338
+ const frozen = typeof msg.dir === "string" && msg.dir.trim() ? msg.dir.trim() : undefined;
339
+ let dir = bot ? (frozen ?? dirs[bot]) : undefined;
334
340
  if (!dir) { send({ type: "result", id, ok: false, error: NO_DIR }); return; }
341
+ try { dir = await checkDir(dir); } catch (e) { send({ type: "result", id, ok: false, error: e?.message ?? String(e) }); return; }
335
342
  const ac = new AbortController();
336
343
  jobs.set(id, ac);
337
344
  log(`[${bot}] ${op === "bash" ? `bash:${args?.command}` : `${op}:${args?.path}`}`);
@@ -362,6 +369,32 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
362
369
  send({ type: "result", id, ok: false, error: e?.message ?? String(e) });
363
370
  }
364
371
  }
372
+ // @意图#R751(2026-11-07): 联网搜索:不看 bot 有没有选目录(搜索不碰文件);按本机配置选 provider,key 只在这台电脑上
373
+ async function handleSearch(send, msg) {
374
+ const { id } = msg;
375
+ const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
376
+ const query = typeof msg.args?.query === "string" ? msg.args.query : "";
377
+ log(`[${bot}] 搜索:${query}`);
378
+ try {
379
+ const r = await searchImpl(query, { count: msg.args?.count, search: cfg.search });
380
+ send({ type: "result", id, ok: true, provider: r.provider, results: r.results, cached: r.cached === true });
381
+ } catch (e) {
382
+ send({ type: "result", id, ok: false, error: e?.message ?? String(e) });
383
+ }
384
+ }
385
+ // 网页保存搜索配置:key 只写本机配置文件,不回显;服务器只拿 provider/hasKey 状态
386
+ async function handleSearchConfig(send, msg) {
387
+ const { id } = msg;
388
+ try {
389
+ cfg.search = cleanSearchConfig(msg.config);
390
+ saveConfig(cfg, file);
391
+ log(`搜索配置已保存:${cfg.search.provider}${cfg.search.apiKey ? "(有 key)" : ""}`);
392
+ send({ type: "result", id, ok: true, ...searchConfigStatus(cfg.search) });
393
+ } catch (e) {
394
+ send({ type: "result", id, ok: false, error: e?.message ?? String(e) });
395
+ }
396
+ }
397
+
365
398
  // @意图#R729(2026-11-04): 网页点「打开 bot 的浏览器」:在这台电脑上弹出有窗口的浏览器(后台的换成有窗口的,恢复打开的网址)
366
399
  async function handleBrowserOpen(send, msg) {
367
400
  const { id } = msg;
@@ -375,6 +408,24 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
375
408
  }
376
409
  }
377
410
 
411
+ // R758:服务器同步 bot 当前绑定。dir 空 = 用固定共享资料库;自定义路径来自本机选目录或服务器已保存的值。
412
+ async function handleSetBinding(send, msg) {
413
+ const { id } = msg;
414
+ const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
415
+ if (!bot) { send({ type: "result", id, ok: false, error: "缺 bot_id" }); return; }
416
+ try {
417
+ const wanted = typeof msg.dir === "string" && msg.dir.trim() ? msg.dir.trim() : sharedLibrary;
418
+ if (wanted === sharedLibrary) mkdirSync(sharedLibrary, { recursive: true, mode: 0o700 });
419
+ const real = await checkDir(wanted);
420
+ dirs = { ...dirs, [bot]: real };
421
+ cfg.bot_dirs = { ...dirs };
422
+ saveConfig(cfg, file);
423
+ send({ type: "result", id, ok: true, bot_id: bot, dir: real });
424
+ } catch (e) {
425
+ send({ type: "result", id, ok: false, error: e?.message ?? String(e) });
426
+ }
427
+ }
428
+
378
429
  async function handlePick(send, msg) {
379
430
  const { id } = msg;
380
431
  const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
@@ -408,10 +459,13 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
408
459
  cleanChanges() { return changeStore().clean(true).catch(() => 0); },
409
460
  onMessage(msg, send) {
410
461
  if (msg.type === "call") return handleCall(send, msg);
462
+ if (msg.type === "set_binding") return handleSetBinding(send, msg);
411
463
  if (msg.type === "pick_dir") return handlePick(send, msg);
412
464
  if (msg.type === "change_get") return handleChangeGet(send, msg);
413
465
  if (msg.type === "browser") return handleBrowser(send, msg); // R729
414
466
  if (msg.type === "browser_open") return handleBrowserOpen(send, msg); // R729
467
+ if (msg.type === "search") return handleSearch(send, msg); // R751
468
+ if (msg.type === "search_config") return handleSearchConfig(send, msg); // R751
415
469
  if (msg.type === "abort") { jobs.get(msg.id)?.abort(); picks.get(msg.id)?.abort(); }
416
470
  return undefined;
417
471
  },
@@ -461,8 +515,8 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
461
515
  const sock = new WebSocket(wsUrlOf(cfg.server));
462
516
  ws = sock;
463
517
  sock.addEventListener("open", () => {
464
- send(sock, { type: "hello", version: PROTOCOL_VERSION, device_id: cfg.device_id, token: cfg.token, bot_dirs: session.botDirs,
465
- name: cfg.name ?? os.hostname(), os: `${process.platform} ${os.release()}`, runner_version: RUNNER_VERSION, autostart: background, browser: browserInfo() });
518
+ send(sock, { type: "hello", version: PROTOCOL_VERSION, device_id: cfg.device_id, token: cfg.token, bot_dirs: session.botDirs, library_dir: libraryDir(),
519
+ name: cfg.name ?? os.hostname(), os: `${process.platform} ${os.release()}`, runner_version: RUNNER_VERSION, autostart: background, browser: browserInfo(), search: searchConfigStatus(cfg.search) });
466
520
  beat = setInterval(() => send(sock, { type: "ping", browser: browserInfo() }), HEARTBEAT_MS);
467
521
  });
468
522
  sock.addEventListener("message", (ev) => {
package/search.mjs ADDED
@@ -0,0 +1,174 @@
1
+ // @意图#R751(2026-11-07): king 确认 R751:运行器内置联网搜索,网页开关可选。
2
+ // 默认 DuckDuckGo 抓公开结果页(免注册免 key),可选 Tavily / Brave key、SearXNG 实例地址。
3
+ // key 只存本机 config.json(600),由网页下发(runner.mjs 的 search_config),服务器不存。
4
+ // 零依赖:DDG 的 HTML 解析自己写——开机自启安装只拷文件不装依赖(autostart.mjs 的 COPY_FILES),引第三方库会让自启那份跑不起来。
5
+ // 参考 pi-web-access 的 duckduckgo.ts 的做法(抓 html.duckduckgo.com/html/ 再解析);DDG 是非官方接口,改版/限流会失败,报错要说人话。
6
+
7
+ export const SEARCH_PROVIDERS = ["duckduckgo", "tavily", "brave", "searxng"];
8
+ export const SEARCH_CACHE_MS = 5 * 60_000; // 同一个查询 5 分钟内不重查,防模型刷搜索
9
+ export const SEARCH_TIMEOUT_MS = 20_000;
10
+ const DDG_URL = "https://html.duckduckgo.com/html/";
11
+ const UA = "Mozilla/5.0 (compatible; agent-hub-runner/1.0; +https://github.com/cs1145994973/agent-hub-runner)";
12
+ const CACHE_MAX = 50;
13
+ const cache = new Map(); // key → { at, results }
14
+
15
+ export function normalizeCount(n) {
16
+ if (typeof n !== "number" || !Number.isFinite(n)) return 5;
17
+ return Math.max(1, Math.min(Math.floor(n), 10));
18
+ }
19
+
20
+ /** 去标签 + 解码常见 HTML 实体(DDG 的标题/摘要里有 <b> 和实体) */
21
+ export function decodeHtml(s) {
22
+ return String(s ?? "")
23
+ .replace(/<[^>]*>/g, "")
24
+ .replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (m, ent) => {
25
+ const lower = ent.toLowerCase();
26
+ if (lower.startsWith("#x")) return String.fromCodePoint(parseInt(lower.slice(2), 16));
27
+ if (lower.startsWith("#")) return String.fromCodePoint(parseInt(lower.slice(1), 10));
28
+ const named = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
29
+ return named[lower] ?? m;
30
+ })
31
+ .replace(/\s+/g, " ")
32
+ .trim();
33
+ }
34
+
35
+ function resultUrl(href) {
36
+ try {
37
+ const link = new URL(href, DDG_URL);
38
+ const dest = link.searchParams.get("uddg") ?? link.href;
39
+ const url = new URL(dest);
40
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** 解析 DDG 结果页:按 class 含独立单词 result 的 div 分块,跳过广告块,提 result__a / result__snippet */
47
+ export function parseDuckDuckGo(html, count = 5) {
48
+ const starts = [...String(html).matchAll(/<div[^>]*class="[^"]*\bresult\b[^"]*"[^>]*>/g)];
49
+ const results = [];
50
+ let parseable = 0;
51
+ for (let i = 0; i < starts.length && results.length < count; i++) {
52
+ const begin = starts[i].index;
53
+ const end = i + 1 < starts.length ? starts[i + 1].index : html.length;
54
+ const block = html.slice(begin, end);
55
+ if (/result--ad/.test(starts[i][0])) continue;
56
+ const a = block.match(/<a\b[^>]*result__a[^>]*>([\s\S]*?)<\/a>/);
57
+ if (!a) continue;
58
+ const href = a[0].match(/href="([^"]+)"/)?.[1] ?? "";
59
+ const title = decodeHtml(a[1]);
60
+ const url = href ? resultUrl(href) : null;
61
+ if (!title || !url) continue;
62
+ parseable++;
63
+ const snip = block.match(/<(?:a|div)\b[^>]*result__snippet[^>]*>([\s\S]*?)<\/(?:a|div)>/);
64
+ results.push({ title, url, snippet: snip ? decodeHtml(snip[1]) : "" });
65
+ }
66
+ if (parseable === 0) throw new Error("DuckDuckGo 这次没解析到结果:可能被限流或页面结构变了。可以换个说法再试、稍后再试,或在「我的电脑」里改配 Tavily / Brave key。");
67
+ return results;
68
+ }
69
+
70
+ async function request(fetchImpl, url, init, signal) {
71
+ const res = await fetchImpl(url, {
72
+ ...init,
73
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(SEARCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(SEARCH_TIMEOUT_MS),
74
+ });
75
+ if (!res.ok) {
76
+ const body = (await res.text().catch(() => "")).slice(0, 200);
77
+ throw new Error(`搜索请求失败(HTTP ${res.status})${res.status === 403 || res.status === 429 ? ":可能被限流,稍后再试" : ""}${body ? `:${body}` : ""}`);
78
+ }
79
+ return res;
80
+ }
81
+
82
+ async function searchDuckDuckGo(query, { count, fetchImpl, signal }) {
83
+ const url = new URL(DDG_URL);
84
+ url.searchParams.set("q", query);
85
+ const res = await request(fetchImpl, url, { headers: { Accept: "text/html", "User-Agent": UA } }, signal);
86
+ return parseDuckDuckGo(await res.text(), count);
87
+ }
88
+
89
+ async function searchTavily(query, { count, apiKey, fetchImpl, signal }) {
90
+ const res = await request(fetchImpl, "https://api.tavily.com/search", {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
93
+ body: JSON.stringify({ query, max_results: count }),
94
+ }, signal);
95
+ const data = await res.json();
96
+ return (data.results ?? []).slice(0, count).map((r) => ({ title: String(r.title ?? ""), url: String(r.url ?? ""), snippet: String(r.content ?? "") }));
97
+ }
98
+
99
+ async function searchBrave(query, { count, apiKey, fetchImpl, signal }) {
100
+ const url = new URL("https://api.search.brave.com/res/v1/web/search");
101
+ url.searchParams.set("q", query);
102
+ url.searchParams.set("count", String(count));
103
+ const res = await request(fetchImpl, url, { headers: { Accept: "application/json", "X-Subscription-Token": apiKey } }, signal);
104
+ const data = await res.json();
105
+ return (data.web?.results ?? []).slice(0, count).map((r) => ({ title: String(r.title ?? ""), url: String(r.url ?? ""), snippet: String(r.description ?? "") }));
106
+ }
107
+
108
+ async function searchSearxng(query, { count, searxngUrl, fetchImpl, signal }) {
109
+ const base = String(searxngUrl).replace(/\/+$/, "");
110
+ const url = new URL(`${base}/search`);
111
+ url.searchParams.set("q", query);
112
+ url.searchParams.set("format", "json");
113
+ const res = await request(fetchImpl, url, { headers: { Accept: "application/json", "User-Agent": UA } }, signal);
114
+ const data = await res.json();
115
+ return (data.results ?? []).slice(0, count).map((r) => ({ title: String(r.title ?? ""), url: String(r.url ?? ""), snippet: String(r.content ?? "") }));
116
+ }
117
+
118
+ /** 网页下发的配置写本地前的校验(runner.mjs 的 search_config 也用) */
119
+ export function cleanSearchConfig(raw) {
120
+ const provider = SEARCH_PROVIDERS.includes(raw?.provider) ? raw.provider : "duckduckgo";
121
+ const out = { provider };
122
+ if (provider === "tavily" || provider === "brave") {
123
+ const key = typeof raw?.apiKey === "string" ? raw.apiKey.trim() : "";
124
+ if (!key) throw new Error(`${provider} 要填 API key`);
125
+ if (key.length > 200) throw new Error("API key 太长了,检查一下是不是复制错了");
126
+ out.apiKey = key;
127
+ }
128
+ if (provider === "searxng") {
129
+ const url = String(raw?.searxngUrl ?? "").trim();
130
+ const u = new URL(url); // 不合法直接抛
131
+ if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("SearXNG 地址要以 http(s):// 开头");
132
+ out.searxngUrl = url;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ export function searchConfigStatus(search) {
138
+ const provider = SEARCH_PROVIDERS.includes(search?.provider) ? search.provider : "duckduckgo";
139
+ return { provider, hasKey: typeof search?.apiKey === "string" && search.apiKey.length > 0 };
140
+ }
141
+
142
+ /**
143
+ * 搜一次。opts: { count, search(config.json 的 search 节), fetchImpl(测试注入), signal }
144
+ * 返回 { provider, results: [{ title, url, snippet }] };失败抛中文错误。
145
+ */
146
+ export async function searchWeb(query, opts = {}) {
147
+ const q = String(query ?? "").trim();
148
+ if (!q) throw new Error("搜索词不能为空");
149
+ const search = opts.search ?? {};
150
+ const provider = SEARCH_PROVIDERS.includes(search.provider) ? search.provider : "duckduckgo";
151
+ const count = normalizeCount(opts.count);
152
+ const fetchImpl = opts.fetchImpl ?? fetch;
153
+ const cacheKey = `${provider}|${provider === "searxng" ? search.searxngUrl ?? "" : ""}|${count}|${q}`;
154
+ const hit = cache.get(cacheKey);
155
+ if (hit && Date.now() - hit.at < SEARCH_CACHE_MS) return { provider, results: hit.results, cached: true };
156
+
157
+ const args = { count, apiKey: search.apiKey, searxngUrl: search.searxngUrl, fetchImpl, signal: opts.signal };
158
+ let results;
159
+ if (provider === "tavily") {
160
+ if (!args.apiKey) throw new Error("选了 Tavily 但没配 key:在「我的电脑」里填上,或切回 DuckDuckGo。");
161
+ results = await searchTavily(q, args);
162
+ } else if (provider === "brave") {
163
+ if (!args.apiKey) throw new Error("选了 Brave 但没配 key:在「我的电脑」里填上,或切回 DuckDuckGo。");
164
+ results = await searchBrave(q, args);
165
+ } else if (provider === "searxng") {
166
+ if (!args.searxngUrl) throw new Error("选了 SearXNG 但没填实例地址:在「我的电脑」里填上,或切回 DuckDuckGo。");
167
+ results = await searchSearxng(q, args);
168
+ } else {
169
+ results = await searchDuckDuckGo(q, args);
170
+ }
171
+ cache.set(cacheKey, { at: Date.now(), results });
172
+ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
173
+ return { provider, results };
174
+ }