hob-bot 1.0.4 → 1.2.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.
Files changed (82) hide show
  1. package/README.md +76 -6
  2. package/README.zh-CN.md +76 -6
  3. package/dist/web/assets/DesktopView-CEBJFAIN.js +5 -0
  4. package/dist/web/assets/index-Di5lyTHO.css +1 -0
  5. package/dist/web/assets/index-y09i1RGd.js +267 -0
  6. package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
  7. package/dist/web/index.html +2 -2
  8. package/dist/web/mock/desktop.jpg +0 -0
  9. package/dist/web/mock/photo.jpg +0 -0
  10. package/dist/web/mock/photo2.jpg +0 -0
  11. package/dist/web/mock/sunset.html +90 -0
  12. package/package.json +7 -1
  13. package/server/src/actions.ts +16 -56
  14. package/server/src/assistant-skills.ts +215 -0
  15. package/server/src/boot.ts +1 -0
  16. package/server/src/claude-status.ts +47 -0
  17. package/server/src/config.ts +3 -1
  18. package/server/src/delegations.ts +21 -11
  19. package/server/src/desktop-control.ts +152 -0
  20. package/server/src/desktop-events.ts +63 -0
  21. package/server/src/desktop-public.ts +43 -0
  22. package/server/src/desktop-tools.ts +161 -0
  23. package/server/src/desktop.ts +76 -0
  24. package/server/src/embedding.ts +52 -0
  25. package/server/src/files.ts +63 -43
  26. package/server/src/http.ts +325 -13
  27. package/server/src/job-view.ts +52 -0
  28. package/server/src/jobs.ts +326 -44
  29. package/server/src/knowledge-index.ts +123 -0
  30. package/server/src/knowledge-tools.ts +94 -0
  31. package/server/src/knowledge.ts +451 -0
  32. package/server/src/lore-entry.ts +10 -0
  33. package/server/src/main.ts +6 -0
  34. package/server/src/mcp.ts +745 -0
  35. package/server/src/memory.ts +11 -4
  36. package/server/src/now.ts +33 -0
  37. package/server/src/overview.ts +4 -4
  38. package/server/src/pages.ts +1 -1
  39. package/server/src/pi.ts +92 -16
  40. package/server/src/prompt.ts +51 -156
  41. package/server/src/providers.ts +3 -2
  42. package/server/src/push.ts +39 -5
  43. package/server/src/router-activity.ts +6 -0
  44. package/server/src/router-tools.ts +184 -39
  45. package/server/src/router.ts +154 -17
  46. package/server/src/routines.ts +14 -4
  47. package/server/src/runtime.ts +15 -0
  48. package/server/src/settings.ts +43 -1
  49. package/server/src/store.ts +4 -3
  50. package/server/src/transcript.ts +57 -4
  51. package/server/src/worker-tools.ts +14 -12
  52. package/shared/knowledge.ts +92 -0
  53. package/shared/mcp.ts +91 -0
  54. package/shared/skills.ts +18 -0
  55. package/shared/types.ts +104 -20
  56. package/vendor/claude-bridge/HOB-PATCH.md +10 -0
  57. package/vendor/claude-bridge/LICENSE +21 -0
  58. package/vendor/claude-bridge/package.json +62 -0
  59. package/vendor/claude-bridge/src/agents-md.ts +14 -0
  60. package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
  61. package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
  62. package/vendor/claude-bridge/src/attachments.ts +135 -0
  63. package/vendor/claude-bridge/src/config.ts +105 -0
  64. package/vendor/claude-bridge/src/convert.ts +222 -0
  65. package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
  66. package/vendor/claude-bridge/src/index.ts +2503 -0
  67. package/vendor/claude-bridge/src/log-paths.ts +19 -0
  68. package/vendor/claude-bridge/src/mcp-server.ts +169 -0
  69. package/vendor/claude-bridge/src/models.ts +164 -0
  70. package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
  71. package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
  72. package/vendor/claude-bridge/src/query-state.ts +118 -0
  73. package/vendor/claude-bridge/src/rate-limit.ts +48 -0
  74. package/vendor/claude-bridge/src/session-cwd.ts +38 -0
  75. package/vendor/claude-bridge/src/session-verify.ts +38 -0
  76. package/vendor/claude-bridge/src/skills.ts +20 -0
  77. package/vendor/claude-bridge/src/transcript.ts +86 -0
  78. package/vendor/claude-bridge/src/usage.ts +53 -0
  79. package/vendor/lore-pi-extension/package.json +1 -1
  80. package/vendor/lore-pi-extension/tools.ts +8 -5
  81. package/dist/web/assets/index-BCGEZvgg.css +0 -1
  82. package/dist/web/assets/index-BqkIAlk9.js +0 -258
@@ -1,6 +1,6 @@
1
1
  // /api/files 的取文件规则:路径先解开符号链接,再判断能不能给页面看。
2
- import { closeSync, openSync, readSync, readdirSync, realpathSync, statSync } from "node:fs";
3
- import { basename, join, relative, resolve, sep } from "node:path";
2
+ import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, realpathSync, statSync } from "node:fs";
3
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { fileKind, isTextKind, mimeOf } from "../../shared/files.ts";
5
5
  import type { FileKind, JobFileEntry, JobFilesView } from "../../shared/types.ts";
6
6
 
@@ -11,13 +11,15 @@ const DENY_ROOT_DIRS = new Set(["engine", "lore"]);
11
11
  /** 备份文件一律不给(凭据的备份也在里面)。 */
12
12
  const DENY_ANY_FILE = /\.bak/i;
13
13
 
14
+ /** 任务工作目录里不对页面开放的:密钥目录、git 内部、.env、私钥。 */
15
+ const DENY_WORK_DIRS = /(^|[/\\])\.(ssh|gnupg|aws|git)([/\\]|$)/;
16
+ const DENY_WORK_FILE = /^(\.env.*|id_[a-z0-9]+|.*\.(pem|key))$/i;
17
+
14
18
  /** 预览面板最多拉 1 MB 文本。 */
15
19
  export const MAX_TEXT_BYTES = 1024 * 1024;
16
20
 
17
21
  /** 任务工作目录里最多列 200 个文件。 */
18
22
  const MAX_JOB_FILES = 200;
19
- /** 递归太深的目录不进去(产出文件不该有这么深)。 */
20
- const MAX_DEPTH = 8;
21
23
  const SKIP_DIRS = new Set(["node_modules", ".git"]);
22
24
 
23
25
  export interface FileInfo {
@@ -68,10 +70,11 @@ function headOf(path: string): Uint8Array {
68
70
  }
69
71
 
70
72
  /**
71
- * 解析一个路径:realpath 后必须还在数据目录里(符号链接指出去就是 403),
73
+ * 解析一个路径:realpath 后必须在数据目录或某个任务的工作目录里(符号链接指出去就是 403),
72
74
  * 凭据和内部状态文件不开放,不是普通文件当 404。
75
+ * 包含数据目录的工作目录(家目录、根目录这类)太宽,不算数。
73
76
  */
74
- export function lookupFile(home: string, requested: string): Lookup {
77
+ export function lookupFile(home: string, requested: string, workDirs: string[] = []): Lookup {
75
78
  const root = realpathSync(resolve(home));
76
79
  let path: string;
77
80
  let size: number;
@@ -85,58 +88,75 @@ export function lookupFile(home: string, requested: string): Lookup {
85
88
  } catch {
86
89
  return { ok: false, status: 404, error: "文件不存在" };
87
90
  }
88
- if (!path.startsWith(`${root}${sep}`)) return { ok: false, status: 403, error: "只允许读 Hob 自己的文件" };
89
- if (denied(root, path)) return { ok: false, status: 403, error: "这个文件不对页面开放" };
91
+ const inHome = path.startsWith(`${root}${sep}`);
92
+ const work = inHome ? undefined : workRoots(root, workDirs).find((dir) => path.startsWith(`${dir}${sep}`));
93
+ if (!inHome && !work) return { ok: false, status: 403, error: "只允许读 Hob 自己的文件" };
94
+ const relativePath = relative(inHome ? root : work!, path);
95
+ if ((inHome && denied(root, path)) || DENY_WORK_DIRS.test(relativePath) || DENY_WORK_FILE.test(basename(path)) || DENY_ANY_FILE.test(basename(path))) {
96
+ return { ok: false, status: 403, error: "这个文件不对页面开放" };
97
+ }
90
98
  const name = basename(path);
91
99
  const kind = fileKind(name) ?? (looksText(headOf(path)) ? "text" : "other");
92
100
  const text = isTextKind(kind);
93
101
  return {
94
102
  ok: true,
95
- info: { path, name, size, modifiedAt, kind, text, mime: mimeOf(name), ...(/^pages[/\\][0-9a-f-]{36}\.html$/i.test(relative(root, path)) ? { page: true } : {}) },
103
+ info: { path, name, size, modifiedAt, kind, text, mime: mimeOf(name), ...(/^(?:pages[/\\][0-9a-f-]{36}|knowledge[/\\]pages[/\\][0-9a-f]{64})\.html$/i.test(relative(root, path)) ? { page: true } : {}) },
96
104
  };
97
105
  }
98
106
 
99
- function walk(dir: string, base: string, depth: number, out: JobFileEntry[]): boolean {
100
- if (depth > MAX_DEPTH) return false;
107
+ function workRoots(root: string, dirs: string[]): string[] {
108
+ const out: string[] = [];
109
+ for (const dir of new Set(dirs)) {
110
+ try {
111
+ const real = realpathSync(resolve(dir));
112
+ if (!`${root}${sep}`.startsWith(`${real === sep ? "" : real}${sep}`)) out.push(real);
113
+ } catch {
114
+ // 目录已经删了:不算
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function visibleEntries(dir: string) {
121
+ return readdirSync(dir, { withFileTypes: true }).filter((entry) =>
122
+ !entry.isSymbolicLink() && (entry.isFile() || (entry.isDirectory() && !SKIP_DIRS.has(entry.name))));
123
+ }
124
+
125
+ /** 一次只列一层;拒绝越界和符号链接。工作目录还没建出来时当作空的。 */
126
+ export function listJobFiles(workDir: string, subdir = ""): JobFilesView {
127
+ const parts = subdir.split(/[\\/]/).filter(Boolean);
128
+ if (isAbsolute(subdir) || parts.some((part) => part === ".." || SKIP_DIRS.has(part))) throw new Error("工作目录路径无效");
129
+ const base = resolve(workDir);
130
+ if (!parts.length && !existsSync(base)) return { files: [], total: 0, fileCount: 0, directoryCount: 0 };
131
+ let dir = base;
101
132
  let entries;
102
133
  try {
103
- entries = readdirSync(dir, { withFileTypes: true });
104
- } catch {
105
- return false;
134
+ for (const part of parts) {
135
+ dir = join(dir, part);
136
+ const info = lstatSync(dir);
137
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("不是文件夹");
138
+ }
139
+ entries = visibleEntries(dir);
140
+ } catch (error) {
141
+ const code = (error as NodeJS.ErrnoException).code;
142
+ throw new Error(code === "ENOENT" ? "这个文件夹已经不在了" : code === "EACCES" ? "没有权限读取这个文件夹" : "工作目录路径无效");
106
143
  }
144
+ const all: JobFileEntry[] = [];
107
145
  for (const entry of entries) {
108
- // 符号链接不跟:可能指到目录外面,也可能绕成环
109
- if (entry.isSymbolicLink()) continue;
110
146
  const path = join(dir, entry.name);
111
- if (entry.isDirectory()) {
112
- if (SKIP_DIRS.has(entry.name)) continue;
113
- if (walk(path, base, depth + 1, out)) return true;
114
- continue;
115
- }
116
- if (!entry.isFile()) continue;
117
- let stats;
147
+ const common = { path, rel: relative(base, path), name: entry.name };
118
148
  try {
119
- stats = statSync(path);
149
+ if (entry.isDirectory()) {
150
+ all.push({ ...common, type: "directory", count: visibleEntries(path).length });
151
+ } else {
152
+ const stats = statSync(path);
153
+ all.push({ ...common, type: "file", size: stats.size, modifiedAt: stats.mtime.toISOString(), kind: fileKind(entry.name) ?? (looksText(headOf(path)) ? "text" : "other") });
154
+ }
120
155
  } catch {
121
- continue;
156
+ // 列的时候刚被删掉或读不了(在跑的任务常有临时文件):跳过这一项
122
157
  }
123
- out.push({
124
- path,
125
- rel: relative(base, path),
126
- name: entry.name,
127
- size: stats.size,
128
- modifiedAt: stats.mtime.toISOString(),
129
- kind: fileKind(entry.name) ?? (looksText(headOf(path)) ? "text" : "other"),
130
- });
131
- if (out.length >= MAX_JOB_FILES + 1) return true;
132
158
  }
133
- return false;
134
- }
135
-
136
- /** 任务工作目录里的文件:递归、修改时间倒序、最多 200 个,跳过 node_modules 和 .git。 */
137
- export function listJobFiles(workDir: string): JobFilesView {
138
- const all: JobFileEntry[] = [];
139
- walk(workDir, workDir, 0, all);
140
- all.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
141
- return { files: all.slice(0, MAX_JOB_FILES), hasMore: all.length > MAX_JOB_FILES };
159
+ all.sort((a, b) => a.type !== b.type ? (a.type === "directory" ? -1 : 1) : a.type === "file" && b.type === "file" ? b.modifiedAt.localeCompare(a.modifiedAt) || a.name.localeCompare(b.name) : a.name.localeCompare(b.name));
160
+ const directoryCount = all.filter((entry) => entry.type === "directory").length;
161
+ return { files: all.slice(0, MAX_JOB_FILES), total: all.length, directoryCount, fileCount: all.length - directoryCount };
142
162
  }
@@ -1,5 +1,5 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { Hono } from "hono";
5
5
  import { getCookie, setCookie } from "hono/cookie";
@@ -7,6 +7,7 @@ import { streamSSE } from "hono/streaming";
7
7
  import { serveStatic } from "@hono/node-server/serve-static";
8
8
  import type {
9
9
  AboutInfo,
10
+ ClaudeCodeSettings,
10
11
  Assistant,
11
12
  HobEvent,
12
13
  ImageKeyView,
@@ -22,13 +23,19 @@ import type {
22
23
  SettingsPatch,
23
24
  } from "../../shared/types.ts";
24
25
  import { parseSprite } from "../../shared/sprites.ts";
25
- import { answerApproval, createFromDraft, dismissDraft, mergeAssistant } from "./actions.ts";
26
+ import { answerApproval, archiveAssistant, createFromDraft, dismissDraft } from "./actions.ts";
26
27
  import { setPassword } from "./config.ts";
27
28
  import { newId } from "./ids.ts";
29
+ import { claudeStatus } from "./claude-status.ts";
30
+ import { checkDesktopUrl } from "./desktop.ts";
31
+ import { desktopProfile, openDesktopBrowser } from "./desktop-tools.ts";
28
32
  import { listJobFiles, lookupFile, MAX_TEXT_BYTES } from "./files.ts";
29
33
  import { thumbnail } from "./thumbs.ts";
30
34
  import { localLoreHome, readLocalLore, readLocalLoreConnection } from "./imports.ts";
35
+ import { importMcpServers, listMcpImports, mcpView, patchMcpServer, removeMcpServer, saveMcpServer, testMcpServer } from "./mcp.ts";
36
+ import type { McpServerPatch, McpServerSave } from "../../shared/mcp.ts";
31
37
  import { checkImageSettings, generateImage, imageConfigured, imageKey, IMAGE_KEY_SECRET } from "./image.ts";
38
+ import { checkEmbeddingSettings, embed, embeddingKey, embeddingModel, EMBEDDING_KEY_SECRET } from "./embedding.ts";
32
39
  import { checkAssistantModels, checkModelsPatch } from "./models.ts";
33
40
  import { saveLoreConnection, testLoreConnection } from "./memory.ts";
34
41
  import { verifyPassword } from "./password.ts";
@@ -38,6 +45,7 @@ import { SEARCH_PROVIDERS } from "../../shared/search.ts";
38
45
  import { WebSearch } from "./search.ts";
39
46
  import { clampDuty } from "./text.ts";
40
47
  import { deleteOwnSkill, importSkills, listImportSkills, skillViews } from "./skills.ts";
48
+ import { archiveAssistantSkill, listAssistantSkills, rollbackAssistantSkill, setAssistantSkillEnabled } from "./assistant-skills.ts";
41
49
  import type { SkillImportSource } from "../../shared/skills.ts";
42
50
 
43
51
  const COOKIE = "hob";
@@ -53,7 +61,7 @@ function issueCookie(config: HobRuntime["config"]): string {
53
61
  return `${token}.${signature(config.cookieSecret, token)}`;
54
62
  }
55
63
 
56
- function validCookie(config: HobRuntime["config"], value: string | undefined): boolean {
64
+ export function validCookie(config: HobRuntime["config"], value: string | undefined): boolean {
57
65
  if (!value) return false;
58
66
  const dot = value.lastIndexOf(".");
59
67
  if (dot <= 0) return false;
@@ -70,6 +78,8 @@ function validCookie(config: HobRuntime["config"], value: string | undefined): b
70
78
 
71
79
  export function createApp(runtime: HobRuntime, options: { webDir?: string; home: string }): Hono {
72
80
  const { store, bus, router, jobs, routines, config } = runtime; const app = new Hono();
81
+ // ponytail: 每次取文件都扫一遍 job.json(几十上百个),慢了再缓存
82
+ const workDirs = () => store.jobs().flatMap((job) => (job.cwd ? [job.cwd] : []));
73
83
 
74
84
  app.use("/api/*", async (c, next) => {
75
85
  if (c.req.path === "/api/login") return next();
@@ -132,6 +142,66 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
132
142
  return c.json(next);
133
143
  });
134
144
 
145
+ app.get("/api/desktop", (c) => c.json(runtime.desktop.view()));
146
+ app.put("/api/desktop", async (c) => {
147
+ if (runtime.desktop.deployed) return c.json({ error: "云桌面由部署配置,不能在此修改。" }, 403);
148
+ const body = await c.req.json().catch(() => ({})) as { revision?: unknown; url?: unknown; enabled?: unknown; token?: unknown };
149
+ if (typeof body.revision !== "number" || typeof body.url !== "string" || typeof body.enabled !== "boolean" || (body.token !== undefined && typeof body.token !== "string")) return c.json({ error: "云桌面配置无效。" }, 400);
150
+ try {
151
+ const url = checkDesktopUrl(body.url);
152
+ const saved = runtime.settings.setDesktop(body.revision, { url, enabled: body.enabled });
153
+ if (!saved) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
154
+ if (body.token !== undefined) writeSecret(options.home, "desktop:token", body.token);
155
+ runtime.desktopEvents.restart();
156
+ return c.json(runtime.desktop.view());
157
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
158
+ });
159
+ app.put("/api/desktop/public", async (c) => {
160
+ const body = await c.req.json().catch(() => ({})) as { revision?: unknown; assistants?: unknown };
161
+ if (typeof body.revision !== "number" || !Array.isArray(body.assistants) || !body.assistants.every((id) => typeof id === "string" && !!runtime.store.assistant(id))) return c.json({ error: "助手列表无效。" }, 400);
162
+ const current = runtime.settings.settings.desktop ?? { url: "", enabled: false };
163
+ const saved = runtime.settings.setDesktop(body.revision, { ...current, publicAssistants: [...new Set(body.assistants)] });
164
+ if (!saved) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
165
+ return c.json(runtime.desktop.view());
166
+ });
167
+ const validScreen = (id: string) => id === "public" || runtime.store.assistants().some((assistant) => desktopProfile(assistant.id) === id);
168
+ app.get("/api/desktop/screens", (c) => c.json({ screens: runtime.desktopEvents.screens, profiles: Object.fromEntries(runtime.store.assistants().map((assistant) => [assistant.id, desktopProfile(assistant.id)])) }));
169
+ app.post("/api/desktop/screens/:id/start", async (c) => {
170
+ const id = c.req.param("id");
171
+ if (!/^[a-z0-9-]{1,64}$/.test(id)) return c.json({ error: "屏幕 id 无效。" }, 400);
172
+ if (!validScreen(id)) return c.json({ error: "屏幕不存在。" }, 404);
173
+ try { await runtime.desktop.request(`/api/screens/${id}/start`, { method: "POST" }); return c.json({ ok: true }); }
174
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 502); }
175
+ });
176
+ app.get("/api/desktop/control", (c) => {
177
+ const screen = c.req.query("screen") ?? "hob";
178
+ if (!validScreen(screen)) return c.json({ error: "屏幕不存在。" }, 404);
179
+ return c.json(runtime.desktopControl.state(c.req.query("client") ?? "", screen));
180
+ });
181
+ app.post("/api/desktop/control", async (c) => {
182
+ const body = await c.req.json().catch(() => ({})) as { client?: string; action?: string; screen?: string };
183
+ const screen = typeof body.screen === "string" ? body.screen : "hob";
184
+ if (!validScreen(screen)) return c.json({ error: "屏幕不存在。" }, 404);
185
+ try {
186
+ if (body.action === "take") runtime.desktopControl.take(body.client ?? "", screen);
187
+ else if (body.action === "release") runtime.desktopControl.release(body.client ?? "", screen);
188
+ else throw new Error("操作无效。");
189
+ return c.json(runtime.desktopControl.state(body.client ?? "", screen));
190
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 409); }
191
+ });
192
+ // 接管后要自己登录网站:在云桌面里打开某个助手的浏览器(每个助手的登录状态分开)
193
+ app.post("/api/desktop/browser", async (c) => {
194
+ const body = await c.req.json().catch(() => ({})) as { assistantId?: unknown };
195
+ const id = typeof body.assistantId === "string" ? body.assistantId : "";
196
+ if (!runtime.store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
197
+ try { await openDesktopBrowser(runtime, id); return c.json({ ok: true }); }
198
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 502); }
199
+ });
200
+ app.post("/api/desktop/test", async (c) => {
201
+ try { return c.json({ ok: true, health: await (await runtime.desktop.request("/api/health")).json() }); }
202
+ catch (error) { return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }); }
203
+ });
204
+
135
205
  app.get("/api/skills", async (c) => {
136
206
  const memory = runtime.settings.memory();
137
207
  try { if (memory.provider === "lore" && !runtime.pi.loreSkillPaths.length) await runtime.pi.discoverLoreSkills(); }
@@ -153,6 +223,67 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
153
223
  catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
154
224
  });
155
225
 
226
+ // 设置 → MCP:列表(密钥不回传)。文件坏了返回错误,不当成空列表
227
+ app.get("/api/mcp", (c) => {
228
+ try {
229
+ return c.json(mcpView(options.home));
230
+ } catch (error) {
231
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
232
+ }
233
+ });
234
+
235
+ // 新建或改一个服务(名称在地址里;env / headers 逐键保留、替换、删除)
236
+ app.put("/api/mcp/:name", async (c) => {
237
+ const body = (await c.req.json().catch(() => ({}))) as McpServerSave;
238
+ try {
239
+ return c.json(await saveMcpServer(options.home, c.req.param("name"), body));
240
+ } catch (error) {
241
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
242
+ }
243
+ });
244
+
245
+ // 启用开关、提供方式、单独关掉的工具
246
+ app.patch("/api/mcp/:name", async (c) => {
247
+ const body = (await c.req.json().catch(() => ({}))) as McpServerPatch;
248
+ try {
249
+ return c.json(await patchMcpServer(options.home, c.req.param("name"), body));
250
+ } catch (error) {
251
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
252
+ }
253
+ });
254
+
255
+ app.delete("/api/mcp/:name", async (c) => {
256
+ try {
257
+ return c.json(await removeMcpServer(options.home, c.req.param("name")));
258
+ } catch (error) {
259
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
260
+ }
261
+ });
262
+
263
+ // 测试连接:连接 → 列工具 → 关闭;失败也是 200,原因在返回值里
264
+ app.post("/api/mcp/:name/test", async (c) => {
265
+ const body = (await c.req.json().catch(() => ({}))) as { refresh?: boolean };
266
+ try {
267
+ return c.json(await testMcpServer(options.home, c.req.param("name"), body.refresh !== false));
268
+ } catch (error) {
269
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
270
+ }
271
+ });
272
+
273
+ // 从本机导入:只读 pi / Claude Code / Cursor 的配置,同名跳过
274
+ app.get("/api/mcp/import", (c) => c.json(listMcpImports(options.home)));
275
+
276
+ app.post("/api/mcp/import", async (c) => {
277
+ const body = (await c.req.json().catch(() => ({}))) as { ids?: unknown };
278
+ const ids = Array.isArray(body.ids) ? body.ids.filter((id): id is string => typeof id === "string") : [];
279
+ if (!ids.length) return c.json({ error: "请选择要导入的服务。" }, 400);
280
+ try {
281
+ return c.json(await importMcpServers(options.home, ids));
282
+ } catch (error) {
283
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
284
+ }
285
+ });
286
+
156
287
  // 设置 → 模型:引擎注册表里现在能用的聊天模型 + 名单里哪些已经不可用
157
288
  app.get("/api/models", async (c) => {
158
289
  const models = await runtime.models.get();
@@ -187,6 +318,24 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
187
318
  });
188
319
 
189
320
  // 设置 → 服务商:列表(Key 只报配没配)、引擎自带的服务商目录、整份设置
321
+ app.get("/api/providers/claude-status", async (c) => c.json(await claudeStatus(runtime.home, runtime.settings.settings.claude?.executable || undefined)));
322
+ app.get("/api/claude", async (c) => {
323
+ await runtime.providers.apply();
324
+ return c.json({ settings: runtime.settings.get(), status: await claudeStatus(runtime.home, runtime.settings.settings.claude?.executable || undefined) });
325
+ });
326
+ app.put("/api/claude", async (c) => {
327
+ const body = await c.req.json().catch(() => ({})) as { revision?: number; claude?: ClaudeCodeSettings };
328
+ try {
329
+ if (!body.claude) throw new Error("Claude Code 设置无效。");
330
+ const settings = runtime.settings.setClaude(body.revision ?? -1, body.claude);
331
+ if (!settings) return c.json({ error: "设置已更改,请刷新后重试。" }, 409);
332
+ await runtime.providers.apply();
333
+ router.releaseSessions();
334
+ return c.json({ settings, status: await claudeStatus(runtime.home, settings.settings.claude?.executable || undefined) });
335
+ } catch (error) {
336
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
337
+ }
338
+ });
190
339
  app.get("/api/providers", async (c) => c.json(await runtime.providers.view()));
191
340
 
192
341
  // 新建或改一个服务商;Key 可选,留空就沿用已存的
@@ -296,6 +445,34 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
296
445
  } catch (error) { return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }); }
297
446
  });
298
447
 
448
+ app.get("/api/knowledge/embedding", (c) => c.json({ keyConfigured: Boolean(embeddingKey(options.home)) }));
449
+
450
+ app.put("/api/knowledge/embedding", async (c) => {
451
+ const body = await c.req.json().catch(() => ({})) as { revision?: number; embedding?: unknown; apiKey?: unknown };
452
+ if (typeof body.revision !== "number") return c.json({ error: "缺 revision" }, 400);
453
+ try {
454
+ const previous = embeddingModel(runtime.settings.settings.knowledge?.embedding ?? { source: "off" });
455
+ const settings = runtime.settings.setEmbedding(body.revision, body.embedding);
456
+ if (!settings) return c.json({ error: "设置已在别处修改,已刷新" }, 409);
457
+ const keyChanged = typeof body.apiKey === "string" && Boolean(body.apiKey.trim());
458
+ if (keyChanged) writeSecret(options.home, EMBEDDING_KEY_SECRET, String(body.apiKey).trim());
459
+ // 换模型要全部重算;只换 Key 时向量还能用,只重试之前失败的
460
+ if (previous !== embeddingModel(settings.settings.knowledge?.embedding ?? { source: "off" })) await runtime.knowledge.refreshIndexes(true);
461
+ else if (keyChanged) await runtime.knowledge.reindex();
462
+ return c.json({ settings, keyConfigured: Boolean(embeddingKey(options.home)) });
463
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
464
+ });
465
+
466
+ app.post("/api/knowledge/embedding/test", async (c) => {
467
+ const body = await c.req.json().catch(() => ({})) as { embedding?: unknown; apiKey?: unknown };
468
+ try {
469
+ const config = body.embedding ? checkEmbeddingSettings(body.embedding) : runtime.settings.settings.knowledge?.embedding ?? { source: "off" };
470
+ const key = typeof body.apiKey === "string" && body.apiKey.trim() ? body.apiKey.trim() : embeddingKey(options.home);
471
+ const vectors = await embed(config, key, ["知识库连接测试"]);
472
+ return c.json({ ok: true, dims: vectors[0]!.length });
473
+ } catch (error) { return c.json({ ok: false, error: error instanceof Error ? error.message : String(error) }); }
474
+ });
475
+
299
476
  // 设置 → 图像生成:接口的 Key 配没配(Key 永不回显)
300
477
  app.get("/api/image", (c) => {
301
478
  const view: ImageKeyView = { keyConfigured: Boolean(imageKey(options.home)) };
@@ -316,6 +493,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
316
493
  if (typeof body.apiKey === "string" && body.apiKey.trim()) {
317
494
  writeSecret(options.home, IMAGE_KEY_SECRET, body.apiKey.trim());
318
495
  }
496
+ router.releaseSessions();
319
497
  const saved: ImageSettingsSaved = { settings, keyConfigured: Boolean(imageKey(options.home)) };
320
498
  return c.json(saved);
321
499
  });
@@ -418,6 +596,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
418
596
  const endpoint = typeof body.endpoint === "string" && body.endpoint ? body.endpoint : undefined;
419
597
  const result = await runtime.push.test(endpoint);
420
598
  if (endpoint && result.skipped === "no-devices") return c.json({ error: "这台设备尚未订阅推送" }, 404);
599
+ if (!result.sent && result.error) return c.json({ error: `发送失败:${result.error}` }, 502);
421
600
  return c.json(result);
422
601
  });
423
602
 
@@ -472,6 +651,42 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
472
651
  return c.json({ job, steps: jobs.transcript(job.id) });
473
652
  });
474
653
 
654
+ app.get("/api/assistants/:id/skills", (c) => {
655
+ const id = c.req.param("id");
656
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
657
+ try { return c.json(listAssistantSkills(options.home, id)); }
658
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
659
+ });
660
+ app.patch("/api/assistants/:id/skills/:name", async (c) => {
661
+ const id = c.req.param("id");
662
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
663
+ const body = await c.req.json().catch(() => ({})) as { enabled?: unknown };
664
+ if (typeof body.enabled !== "boolean") return c.json({ error: "启用状态无效" }, 400);
665
+ try {
666
+ const skill = setAssistantSkillEnabled(options.home, id, c.req.param("name"), body.enabled);
667
+ bus.emit({ type: "skills", assistantId: id });
668
+ return c.json(skill);
669
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
670
+ });
671
+ app.post("/api/assistants/:id/skills/:name/rollback", (c) => {
672
+ const id = c.req.param("id");
673
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
674
+ try {
675
+ const skill = rollbackAssistantSkill(options.home, id, c.req.param("name"));
676
+ bus.emit({ type: "skills", assistantId: id });
677
+ return c.json(skill);
678
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
679
+ });
680
+ app.delete("/api/assistants/:id/skills/:name", (c) => {
681
+ const id = c.req.param("id");
682
+ if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
683
+ try {
684
+ archiveAssistantSkill(options.home, id, c.req.param("name"));
685
+ bus.emit({ type: "skills", assistantId: id });
686
+ return c.json({ ok: true });
687
+ } catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); }
688
+ });
689
+
475
690
  app.get("/api/assistants/:id/items", (c) => {
476
691
  const id = c.req.param("id");
477
692
  if (!store.assistant(id)) return c.json({ error: "助手不存在" }, 404);
@@ -538,6 +753,11 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
538
753
  bus.emit({ type: "profile", assistantId: id });
539
754
  return c.json({ ok: true, rule });
540
755
  }
756
+ if (body.knowledge !== undefined) {
757
+ if (!Array.isArray(body.knowledge) || body.knowledge.some((id) => typeof id !== "string")) return c.json({ error: "知识库绑定无效。" }, 400);
758
+ const collections = (await runtime.knowledge.list()).collections;
759
+ if (body.knowledge.some((id) => !collections.some((item) => item.id === id))) return c.json({ error: "知识库不存在。" }, 400);
760
+ }
541
761
  if (typeof body.sprite === "string" && body.sprite !== assistant.sprite) {
542
762
  const parsed = parseSprite(body.sprite);
543
763
  // custom 要有画法数据才收(画法在 prop 字段上,PATCH 不改它);否则渲染成空手火苗,不如拒绝
@@ -567,18 +787,19 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
567
787
  ...(models ? { models } : {}),
568
788
  ...(abilities ? { abilities } : {}),
569
789
  ...(typeof body.instructions === "string" ? { instructions: body.instructions } : {}),
790
+ ...(body.knowledge !== undefined ? { knowledge: [...new Set(body.knowledge)] } : {}),
570
791
  };
571
792
  store.saveAssistant(next);
572
- // 能派的模型写在 start_job 的说明里,换了就重开会话
573
- if (models) router.releaseSessions();
793
+ // 工具说明和注册取决于模型及助手能力,变化后重开会话
794
+ if (models || abilities) router.releaseSessions();
574
795
  const summary = router.summary(next);
575
796
  bus.emit({ type: "assistant", assistant: summary });
576
797
  return c.json({ assistant: summary });
577
798
  });
578
799
 
579
- // 并回 Hob:定时和规则转给主助手,对话和档案进归档,助手从列表移除
580
- app.post("/api/assistants/:id/merge", async (c) => {
581
- const result = await mergeAssistant(runtime, c.req.param("id"));
800
+ // 归档助手:档案、对话、规则和定时进归档,助手从列表移除
801
+ app.post("/api/assistants/:id/archive", async (c) => {
802
+ const result = await archiveAssistant(runtime, c.req.param("id"));
582
803
  return c.json(result.body as Record<string, unknown>, result.status as 200);
583
804
  });
584
805
 
@@ -650,6 +871,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
650
871
  instruction?: string;
651
872
  schedules?: string[];
652
873
  active?: boolean;
874
+ skill?: string | null;
653
875
  };
654
876
  try {
655
877
  const routine = routines.update(c.req.param("id"), body);
@@ -678,11 +900,18 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
678
900
  }
679
901
  });
680
902
 
903
+ app.post("/api/jobs/:id/sediment", (c) => {
904
+ const id = c.req.param("id");
905
+ if (!store.job(id)) return c.json({ error: "任务不存在" }, 404);
906
+ try { jobs.startSediment(id, "manual"); return c.json({ ok: true }); }
907
+ catch (error) { return c.json({ error: error instanceof Error ? error.message : String(error) }, 409); }
908
+ });
909
+
681
910
  app.post("/api/jobs/:id/reply", async (c) => {
682
911
  const body = (await c.req.json().catch(() => ({}))) as { text?: string };
683
912
  if (!body.text?.trim()) return c.json({ error: "消息是空的" }, 400);
684
913
  try {
685
- await jobs.reply(c.req.param("id"), body.text.trim());
914
+ await jobs.reply(c.req.param("id"), body.text.trim(), "user");
686
915
  return c.json({ ok: true });
687
916
  } catch (error) {
688
917
  return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
@@ -694,6 +923,85 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
694
923
  return c.json({ ok: true });
695
924
  });
696
925
 
926
+ app.get("/api/knowledge", async (c) => {
927
+ const limit = Number(c.req.query("limit"));
928
+ return c.json(await runtime.knowledge.list({ origin: c.req.query("origin"), collection: c.req.query("collection"), kind: c.req.query("kind"), assistantId: c.req.query("assistantId"), q: c.req.query("q"), before: c.req.query("before"), ...(Number.isFinite(limit) && limit > 0 ? { limit: Math.floor(limit) } : {}) }));
929
+ });
930
+
931
+ app.post("/api/knowledge/collections", async (c) => {
932
+ const body = await c.req.json().catch(() => ({})) as { name?: unknown };
933
+ if (typeof body.name !== "string" || !body.name.trim()) return c.json({ error: "名称不能为空" }, 400);
934
+ const collection = await runtime.knowledge.createCollection(body.name.trim());
935
+ return c.json({ collection: { ...collection, count: 0 } });
936
+ });
937
+
938
+ app.patch("/api/knowledge/collections/:id", async (c) => {
939
+ const body = await c.req.json().catch(() => ({})) as { name?: unknown };
940
+ if (typeof body.name !== "string" || !body.name.trim()) return c.json({ error: "名称不能为空" }, 400);
941
+ const collection = await runtime.knowledge.renameCollection(c.req.param("id"), body.name.trim());
942
+ if (!collection) return c.json({ error: "知识库不存在" }, 404);
943
+ const count = (await runtime.knowledge.list()).collections.find((item) => item.id === collection.id)?.count ?? 0;
944
+ return c.json({ collection: { ...collection, count } });
945
+ });
946
+
947
+ app.delete("/api/knowledge/collections/:id", async (c) => {
948
+ const deleted = await runtime.knowledge.deleteCollection(c.req.param("id"));
949
+ if (deleted === undefined) return c.json({ error: "知识库不存在" }, 404);
950
+ return c.json({ deleted });
951
+ });
952
+
953
+ app.get("/api/knowledge/entries/:id", async (c) => {
954
+ const raw = c.req.query("chunk");
955
+ if (raw !== undefined && !/^\d+$/.test(raw)) return c.json({ error: "段号无效" }, 400);
956
+ const view = await runtime.knowledge.entry(c.req.param("id"), raw === undefined ? undefined : Number(raw));
957
+ return view ? c.json(view) : c.json({ error: "条目不存在" }, 404);
958
+ });
959
+
960
+ app.post("/api/knowledge/collections/:id/reindex", async (c) => {
961
+ return await runtime.knowledge.reindex(c.req.param("id")) ? c.json({ ok: true }) : c.json({ error: "知识库不存在" }, 404);
962
+ });
963
+
964
+ app.patch("/api/knowledge/entries/:id", async (c) => {
965
+ const body = await c.req.json().catch(() => ({})) as { name?: unknown; collections?: unknown };
966
+ if ((body.name !== undefined && (typeof body.name !== "string" || !body.name.trim())) || (body.collections !== undefined && (!Array.isArray(body.collections) || !body.collections.every((id) => typeof id === "string")))) return c.json({ error: "条目参数无效" }, 400);
967
+ try {
968
+ const entry = await runtime.knowledge.updateEntry(c.req.param("id"), { ...(typeof body.name === "string" ? { name: body.name.trim() } : {}), ...(Array.isArray(body.collections) ? { collections: body.collections as string[] } : {}) });
969
+ return entry ? c.json({ entry }) : c.json({ error: "条目不存在" }, 404);
970
+ } catch (error) {
971
+ if (error instanceof Error && error.message === "知识库不存在") return c.json({ error: error.message }, 404);
972
+ throw error;
973
+ }
974
+ });
975
+
976
+ app.delete("/api/knowledge/entries/:id", async (c) => {
977
+ return await runtime.knowledge.deleteEntry(c.req.param("id")) ? c.json({ ok: true }) : c.json({ error: "条目不存在" }, 404);
978
+ });
979
+
980
+ app.post("/api/knowledge/upload", async (c) => {
981
+ const collectionId = c.req.query("collection");
982
+ if (!collectionId || !(await runtime.knowledge.list()).collections.some((item) => item.id === collectionId)) return c.json({ error: "知识库不存在" }, 404);
983
+ const body = await c.req.parseBody({ all: true });
984
+ const files = Object.values(body).flatMap((value) => Array.isArray(value) ? value : [value]).filter((value): value is File => value instanceof File);
985
+ if (!files.length) return c.json({ error: "没有收到文件" }, 400);
986
+ if (files.some((file) => file.size > 100 * 1024 * 1024)) return c.json({ error: "文件超过 100 MB" }, 400);
987
+ const dir = join(options.home, "knowledge", "incoming");
988
+ mkdirSync(dir, { recursive: true });
989
+ const entries = [];
990
+ for (const file of files) {
991
+ const name = file.name.replace(/[/\\\\]/g, "_") || "file";
992
+ const path = join(dir, `${newId("u-")}-${name}`);
993
+ try {
994
+ writeFileSync(path, Buffer.from(await file.arrayBuffer()), { mode: 0o600 });
995
+ const entry = await runtime.knowledge.upload({ path, name }, collectionId);
996
+ if (entry) entries.push(entry);
997
+ } catch (error) {
998
+ if (error instanceof Error && error.message === "知识库不存在") return c.json({ error: error.message }, 404);
999
+ throw error;
1000
+ } finally { rmSync(path, { force: true }); }
1001
+ }
1002
+ return c.json({ entries });
1003
+ });
1004
+
697
1005
  app.post("/api/uploads", async (c) => {
698
1006
  const body = await c.req.parseBody({ all: true });
699
1007
  const uploads = join(options.home, "uploads");
@@ -713,7 +1021,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
713
1021
 
714
1022
  app.get("/api/files", async (c) => {
715
1023
  const requested = c.req.query("path");
716
- const lookup = requested ? lookupFile(options.home, requested) : null;
1024
+ const lookup = requested ? lookupFile(options.home, requested, workDirs()) : null;
717
1025
  const nosniff = { "x-content-type-options": "nosniff" };
718
1026
  if (!lookup) return c.json({ error: "缺 path" }, 400, nosniff);
719
1027
  if (!lookup.ok) return c.json({ error: lookup.error }, lookup.status, nosniff);
@@ -745,7 +1053,7 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
745
1053
  app.get("/api/files/meta", (c) => {
746
1054
  const requested = c.req.query("path");
747
1055
  if (!requested) return c.json({ error: "缺 path" }, 400);
748
- const lookup = lookupFile(options.home, requested);
1056
+ const lookup = lookupFile(options.home, requested, workDirs());
749
1057
  if (!lookup.ok) return c.json({ error: lookup.error }, lookup.status);
750
1058
  const { info } = lookup;
751
1059
  const meta: FileMeta = {
@@ -761,11 +1069,15 @@ export function createApp(runtime: HobRuntime, options: { webDir?: string; home:
761
1069
  return c.json(meta);
762
1070
  });
763
1071
 
764
- // 任务工作目录里的文件(递归、修改时间倒序、最多 200 个),只在这个任务的目录里找
1072
+ // 任务工作目录,一次只列一层。
765
1073
  app.get("/api/jobs/:id/files", (c) => {
766
1074
  const job = store.job(c.req.param("id"));
767
1075
  if (!job) return c.json({ error: "任务不存在" }, 404);
768
- return c.json(listJobFiles(store.workDir(job.id)));
1076
+ try {
1077
+ return c.json(listJobFiles(job.cwd ?? store.workDir(job.id), c.req.query("path")));
1078
+ } catch (error) {
1079
+ return c.json({ error: error instanceof Error ? error.message : "无法读取工作目录" }, 400);
1080
+ }
769
1081
  });
770
1082
 
771
1083
  app.get("/api/events", (c) => {