hob-bot 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/bin/hob.mjs +7 -0
  5. package/dist/web/assets/index-D88l35PH.css +1 -0
  6. package/dist/web/assets/index-DUs8nN3E.js +218 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/fonts/inter-latin.woff2 +0 -0
  9. package/dist/web/fonts/jetbrains-mono-latin.woff2 +0 -0
  10. package/dist/web/icons/apple-touch-icon-180.png +0 -0
  11. package/dist/web/icons/favicon-32.png +0 -0
  12. package/dist/web/icons/icon-192.png +0 -0
  13. package/dist/web/icons/icon-512.png +0 -0
  14. package/dist/web/icons/icon-maskable-512.png +0 -0
  15. package/dist/web/index.html +32 -0
  16. package/dist/web/manifest.webmanifest +16 -0
  17. package/dist/web/sw.js +131 -0
  18. package/package.json +62 -4
  19. package/server/src/actions.ts +230 -0
  20. package/server/src/boot.ts +85 -0
  21. package/server/src/bus.ts +21 -0
  22. package/server/src/config.ts +123 -0
  23. package/server/src/cron-text.ts +49 -0
  24. package/server/src/delegations.ts +113 -0
  25. package/server/src/files.ts +142 -0
  26. package/server/src/http.ts +802 -0
  27. package/server/src/ids.ts +19 -0
  28. package/server/src/image.ts +218 -0
  29. package/server/src/imports.ts +302 -0
  30. package/server/src/jobs.ts +730 -0
  31. package/server/src/main.ts +69 -0
  32. package/server/src/memory.ts +272 -0
  33. package/server/src/models.ts +358 -0
  34. package/server/src/overview.ts +116 -0
  35. package/server/src/pages.ts +54 -0
  36. package/server/src/password.ts +23 -0
  37. package/server/src/pi.ts +273 -0
  38. package/server/src/prompt.ts +227 -0
  39. package/server/src/providers.ts +532 -0
  40. package/server/src/push.ts +285 -0
  41. package/server/src/router-tools.ts +549 -0
  42. package/server/src/router.ts +841 -0
  43. package/server/src/routines.ts +129 -0
  44. package/server/src/runtime.ts +104 -0
  45. package/server/src/search.ts +91 -0
  46. package/server/src/secrets.ts +46 -0
  47. package/server/src/settings.ts +452 -0
  48. package/server/src/skills.ts +150 -0
  49. package/server/src/store.ts +324 -0
  50. package/server/src/text.ts +14 -0
  51. package/server/src/time.ts +42 -0
  52. package/server/src/transcript.ts +272 -0
  53. package/server/src/worker-tools.ts +129 -0
  54. package/shared/command.ts +42 -0
  55. package/shared/files.ts +141 -0
  56. package/shared/media.ts +17 -0
  57. package/shared/model-note.ts +32 -0
  58. package/shared/page-shell.ts +108 -0
  59. package/shared/search.ts +19 -0
  60. package/shared/skills.ts +37 -0
  61. package/shared/sprites.ts +422 -0
  62. package/shared/thinking.ts +118 -0
  63. package/shared/types.ts +631 -0
  64. package/vendor/lore-pi-extension/README.md +28 -0
  65. package/vendor/lore-pi-extension/api.ts +140 -0
  66. package/vendor/lore-pi-extension/formatters.ts +204 -0
  67. package/vendor/lore-pi-extension/hooks.ts +195 -0
  68. package/vendor/lore-pi-extension/index.ts +25 -0
  69. package/vendor/lore-pi-extension/package.json +23 -0
  70. package/vendor/lore-pi-extension/skills.ts +447 -0
  71. package/vendor/lore-pi-extension/tools.ts +344 -0
  72. package/vendor/lore-pi-extension/uri.ts +67 -0
  73. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.d.mts +266 -0
  74. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.mjs +1328 -0
  75. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.d.mts +33 -0
  76. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.mjs +202 -0
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,32 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#f6f6f4" />
7
+ <script>
8
+ (() => {
9
+ let theme = "system";
10
+ try { theme = localStorage.getItem("hob.theme") || "system"; } catch {}
11
+ const dark = theme === "dark" || (theme !== "light" && theme !== "dark" && matchMedia("(prefers-color-scheme: dark)").matches);
12
+ document.documentElement.classList.toggle("dark", dark);
13
+ document.documentElement.style.colorScheme = dark ? "dark" : "light";
14
+ // 和 theme.ts 的 statusSurface 一致:手机用 --bg,宽屏用 --side
15
+ const narrow = innerWidth < 768;
16
+ document.querySelector('meta[name="theme-color"]').content = narrow ? (dark ? "#151516" : "#ffffff") : dark ? "#1b1b1d" : "#f6f6f4";
17
+ })();
18
+ </script>
19
+ <link rel="manifest" href="/manifest.webmanifest?v=3" />
20
+ <link rel="icon" href="/icons/favicon-32.png?v=3" sizes="32x32" type="image/png" />
21
+ <link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon-180.png?v=3" />
22
+ <meta name="apple-mobile-web-app-capable" content="yes" />
23
+ <meta name="apple-mobile-web-app-title" content="Hob" />
24
+ <meta name="apple-mobile-web-app-status-bar-style" content="default" />
25
+ <title>Hob</title>
26
+ <script type="module" crossorigin src="/assets/index-DUs8nN3E.js"></script>
27
+ <link rel="stylesheet" crossorigin href="/assets/index-D88l35PH.css">
28
+ </head>
29
+ <body>
30
+ <div id="root"></div>
31
+ </body>
32
+ </html>
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "Hob",
3
+ "short_name": "Hob",
4
+ "description": "常驻助手:任务交由后台执行,需要确认时才会通知你。",
5
+ "lang": "zh-CN",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "background_color": "#fff0e4",
10
+ "theme_color": "#f6f6f4",
11
+ "icons": [
12
+ { "src": "/icons/icon-192.png?v=3", "sizes": "192x192", "type": "image/png", "purpose": "any" },
13
+ { "src": "/icons/icon-512.png?v=3", "sizes": "512x512", "type": "image/png", "purpose": "any" },
14
+ { "src": "/icons/icon-maskable-512.png?v=3", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
15
+ ]
16
+ }
package/dist/web/sw.js ADDED
@@ -0,0 +1,131 @@
1
+ // Hob 的 service worker(手写,无依赖):只缓存界面壳和带哈希的静态文件,/api 一律走网络。
2
+ // 升级不打断用户:新版本装好后等页面提示「有新版本 · 刷新」,点了才 skipWaiting。
3
+ const CACHE = "hob-shell-v4"; // 图标或壳变了就加一,旧缓存在 activate 时删掉
4
+ // 图标和 manifest 不进缓存:系统添加到主屏幕时要拿到的是服务器上最新的那一份
5
+
6
+ self.addEventListener("install", (event) => {
7
+ event.waitUntil(
8
+ precache().then(() => {
9
+ // 第一次装(之前没有 SW 在管)直接接管;已有旧版本就等用户点刷新
10
+ if (!self.registration.active) return self.skipWaiting();
11
+ return undefined;
12
+ }),
13
+ );
14
+ });
15
+
16
+ self.addEventListener("activate", (event) => {
17
+ event.waitUntil(
18
+ caches
19
+ .keys()
20
+ .then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))))
21
+ .then(() => self.clients.claim()),
22
+ );
23
+ });
24
+
25
+ self.addEventListener("message", (event) => {
26
+ if (event.data === "skip-waiting") void self.skipWaiting();
27
+ });
28
+
29
+ // 装的时候就把壳和构建产物一起缓存下来:构建后的文件名带哈希,从 index.html 里抠出来
30
+ async function precache() {
31
+ const cache = await caches.open(CACHE);
32
+ try {
33
+ const response = await fetch("/", { cache: "reload" });
34
+ if (!response.ok) return;
35
+ const html = await response.clone().text();
36
+ await cache.put("/", response.clone());
37
+ const urls = new Set([...html.matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)].map((match) => match[1]));
38
+ await Promise.all(
39
+ [...urls].map(async (url) => {
40
+ const asset = await fetch(url);
41
+ if (asset.ok) await cache.put(url, asset);
42
+ }),
43
+ );
44
+ } catch {
45
+ // 离线时装不上就先有壳,等联网后再缓存
46
+ }
47
+ }
48
+
49
+ function isStatic(pathname) {
50
+ return (
51
+ pathname.startsWith("/assets/") ||
52
+ pathname.startsWith("/fonts/")
53
+ );
54
+ }
55
+
56
+ async function cacheFirst(request) {
57
+ const cache = await caches.open(CACHE);
58
+ const hit = await cache.match(request);
59
+ if (hit) return hit;
60
+ const response = await fetch(request);
61
+ if (response.ok) cache.put(request, response.clone());
62
+ return response;
63
+ }
64
+
65
+ // 导航:先走网络拿最新的 index.html,断网时用缓存里的界面壳
66
+ async function networkFirst(request) {
67
+ const cache = await caches.open(CACHE);
68
+ try {
69
+ const response = await fetch(request);
70
+ if (response.ok) cache.put("/", response.clone());
71
+ return response;
72
+ } catch {
73
+ const hit = (await cache.match(request)) || (await cache.match("/"));
74
+ if (hit) return hit;
75
+ return new Response("连不上 Hob,而且这台设备还没有缓存过界面。", {
76
+ status: 503,
77
+ headers: { "content-type": "text/plain; charset=utf-8" },
78
+ });
79
+ }
80
+ }
81
+
82
+ self.addEventListener("fetch", (event) => {
83
+ const request = event.request;
84
+ if (request.method !== "GET") return;
85
+ const url = new URL(request.url);
86
+ if (url.origin !== self.location.origin) return;
87
+ // 一律不拦:接口要实时的,缓存了就是给用户看假数据
88
+ if (url.pathname.startsWith("/api/")) return;
89
+ if (request.mode === "navigate") {
90
+ event.respondWith(networkFirst(request));
91
+ return;
92
+ }
93
+ if (isStatic(url.pathname)) event.respondWith(cacheFirst(request));
94
+ });
95
+
96
+ self.addEventListener("push", (event) => {
97
+ let payload = {};
98
+ try {
99
+ payload = event.data ? event.data.json() : {};
100
+ } catch {
101
+ payload = {};
102
+ }
103
+ const title = payload.title || "Hob";
104
+ const target = payload.url || "/";
105
+ event.waitUntil(
106
+ self.registration.showNotification(title, {
107
+ body: payload.body || "",
108
+ icon: "/icons/icon-192.png",
109
+ badge: "/icons/favicon-32.png",
110
+ data: { url: target },
111
+ tag: target,
112
+ }),
113
+ );
114
+ });
115
+
116
+ self.addEventListener("notificationclick", (event) => {
117
+ event.notification.close();
118
+ const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href;
119
+ event.waitUntil(
120
+ (async () => {
121
+ const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
122
+ for (const client of windows) {
123
+ if (!client.url.startsWith(self.location.origin)) continue;
124
+ await client.focus();
125
+ if (client.navigate) await client.navigate(url);
126
+ return;
127
+ }
128
+ await self.clients.openWindow(url);
129
+ })(),
130
+ );
131
+ });
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "hob-bot",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.2",
4
+ "description": "A self-hosted personal assistant that runs real work as background jobs.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/FFatTiger/hob.git"
9
+ },
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=22"
13
+ },
14
+ "bin": {
15
+ "hob": "bin/hob.mjs"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "server/src",
20
+ "shared",
21
+ "dist/web",
22
+ "vendor"
23
+ ],
24
+ "scripts": {
25
+ "dev:server": "tsx watch server/src/boot.ts",
26
+ "dev:web": "vite",
27
+ "build": "vite build && tsc -p tsconfig.json --noEmit",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit",
29
+ "start": "tsx server/src/boot.ts",
30
+ "test": "vitest run",
31
+ "prepack": "npm run build"
32
+ },
33
+ "dependencies": {
34
+ "@earendil-works/pi-ai": "1.3.0",
35
+ "@earendil-works/pi-coding-agent": "1.3.0",
36
+ "@hono/node-server": "^2.1.6",
37
+ "@juicesharp/rpiv-config": "2.15.0",
38
+ "@juicesharp/rpiv-web-tools": "2.15.0",
39
+ "croner": "^9.0.0",
40
+ "hono": "^4.6.0",
41
+ "marked": "^15.0.0",
42
+ "pi-memory": "0.4.2",
43
+ "react": "^19.0.0",
44
+ "react-dom": "^19.0.0",
45
+ "tsx": "^4.19.0",
46
+ "web-push": "^3.6.7",
47
+ "ws": "^8.18.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.0.0",
51
+ "@types/react": "^19.0.0",
52
+ "@types/react-dom": "^19.0.0",
53
+ "@types/web-push": "^3.6.4",
54
+ "@types/ws": "^8.5.0",
55
+ "@vitejs/plugin-react": "^4.3.0",
56
+ "typescript": "^5.6.0",
57
+ "vite": "^6.0.0",
58
+ "vitest": "^5.0.5"
59
+ },
60
+ "overrides": {
61
+ "undici": "^8.12.3",
62
+ "minimatch": "^11.0.4"
63
+ }
64
+ }
@@ -0,0 +1,230 @@
1
+ import { distinctSprite } from "../../shared/sprites.ts";
2
+ import type { AnswerApprovalBody, Approval, Assistant, Draft, Rule } from "../../shared/types.ts";
3
+ import { newId, slugify } from "./ids.ts";
4
+ import type { HobRuntime } from "./runtime.ts";
5
+ import { monthDayText } from "./time.ts";
6
+
7
+ export interface ActionResult {
8
+ status: number;
9
+ body: unknown;
10
+ }
11
+
12
+ /** 其他想法的长度上限(字)。 */
13
+ export const MAX_ANSWER_TEXT = 1000;
14
+
15
+ /** 答复审批卡:记答案、写 always 规则、叫醒阻塞的 Worker 或把答案推回 Router。 */
16
+ export async function answerApproval(runtime: HobRuntime, approvalId: string, input: AnswerApprovalBody): Promise<ActionResult> {
17
+ const { store, bus, router, jobs } = runtime;
18
+ const approval = store.approval(approvalId);
19
+ if (!approval) return { status: 404, body: { error: "审批不存在" } };
20
+ if (approval.status !== "pending") return { status: 409, body: { error: "这个审批已经答复过了" } };
21
+ const option = input.key ? approval.options.find((item) => item.key === input.key) : undefined;
22
+ if (input.key && !option) return { status: 400, body: { error: "没有这个选项" } };
23
+ // 没选选项就是想自己写:空白和字数都有上限,不合格的不收
24
+ const text = typeof input.text === "string" ? input.text.trim() : "";
25
+ if (!option) {
26
+ if (!text) return { status: 400, body: { error: "其他想法不能为空" } };
27
+ if (text.length > MAX_ANSWER_TEXT) return { status: 400, body: { error: `其他想法最多 ${MAX_ANSWER_TEXT} 字` } };
28
+ }
29
+
30
+ const answered: Approval = {
31
+ ...approval,
32
+ status: "answered",
33
+ ...(option ? { chosen: option.key } : { answerText: text }),
34
+ answeredAt: new Date().toISOString(),
35
+ };
36
+ store.saveApproval(answered);
37
+ bus.emit({ type: "approval", approval: answered });
38
+
39
+ if (option?.always) {
40
+ const assistant = store.assistant(approval.assistantId);
41
+ const ruleText = approval.ruleText?.trim() || option.label;
42
+ const rules = store.rules(approval.assistantId);
43
+ const existing = rules.find((rule) => rule.text === ruleText);
44
+ if (existing) {
45
+ existing.mode = "allow";
46
+ existing.changedAt = new Date().toISOString();
47
+ } else {
48
+ rules.push({ id: newId("r-"), text: ruleText, mode: "allow", changedAt: new Date().toISOString() });
49
+ }
50
+ store.saveRules(approval.assistantId, rules);
51
+ await router.addItem(approval.assistantId, {
52
+ kind: "note",
53
+ icon: "rule",
54
+ text: `已记入${assistant?.name ?? approval.assistantId}的规则 · ${ruleText}`,
55
+ });
56
+ bus.emit({ type: "profile", assistantId: approval.assistantId });
57
+ }
58
+
59
+ // 回给模型:自己写的那句要说明是用户写的、没选给出的选项
60
+ const answer = option ? `用户选择了:${option.label}` : `用户没有选给出的选项,而是回复:${text}`;
61
+ if (approval.source === "job" && approval.jobId) jobs.resolveAsk(answered, answer);
62
+ else await router.answerEvent(approval.assistantId, option ? `用户选择了 ${option.key}:${option.label}` : answer);
63
+
64
+ return { status: 200, body: { approval: answered } };
65
+ }
66
+
67
+ function uniqueAssistantId(name: string): string {
68
+ const base = slugify(name);
69
+ return base;
70
+ }
71
+
72
+ /** 草稿 → 真正创建助手:带规则、补充说明、定时任务,两边对话各写一行说明。 */
73
+ export async function createFromDraft(runtime: HobRuntime, draftId: string): Promise<ActionResult> {
74
+ const { store, bus, router, routines } = runtime;
75
+ const draft = store.draft(draftId);
76
+ if (!draft) return { status: 404, body: { error: "草稿不存在" } };
77
+ if (draft.status !== "pending") return { status: 409, body: { error: "这个草稿已经处理过了" } };
78
+ if (store.assistant(uniqueAssistantId(draft.name))) {
79
+ return { status: 409, body: { error: `已存在名称为「${draft.name}」的助手` } };
80
+ }
81
+
82
+ const now = new Date().toISOString();
83
+ const assistant: Assistant = {
84
+ id: uniqueAssistantId(draft.name),
85
+ name: draft.name,
86
+ duty: draft.duty,
87
+ dutyLong: draft.dutyLong,
88
+ // 和已有助手撞了头像就换个颜色(自画的图案各不相同,不算撞)
89
+ sprite: distinctSprite(draft.sprite, store.assistants().map((a) => a.sprite)),
90
+ ...(draft.prop ? { prop: draft.prop } : {}),
91
+ primary: false,
92
+ pinned: false,
93
+ hidden: false,
94
+ notify: true,
95
+ instructions: draft.instructions.join("\n"),
96
+ createdAt: now,
97
+ splitFrom: {
98
+ from: draft.assistantId,
99
+ at: now,
100
+ conversations: draft.carry.conversations,
101
+ },
102
+ };
103
+ store.saveAssistant(assistant);
104
+
105
+ store.saveRules(
106
+ assistant.id,
107
+ draft.askFirst.map((text) => ({ id: newId("r-"), text, mode: "ask" as const, changedAt: now })),
108
+ );
109
+ for (const item of draft.routines) {
110
+ try {
111
+ routines.create({
112
+ assistantId: assistant.id,
113
+ name: item.name,
114
+ instruction: item.instruction,
115
+ schedules: [item.schedule],
116
+ });
117
+ } catch {
118
+ // 模型给的 cron 不合法就跳过,不阻塞创建
119
+ }
120
+ }
121
+
122
+ const splitNote = `${monthDayText(runtime.settings.settings.general.timezone)} 由 Hob 创建 · 迁移 ${draft.carry.conversations} 段对话`;
123
+ await router.ensure(assistant.id);
124
+ await router.addItem(assistant.id, { kind: "note", icon: "split", text: splitNote });
125
+ // 新助手自己的开场:固定模板,不过模型
126
+ await router.addItem(assistant.id, {
127
+ kind: "assistant",
128
+ text: [`我是${assistant.name},负责${assistant.duty}。`, ...(draft.routinesText ? [`定时任务:${draft.routinesText}`] : [])].join(
129
+ "\n",
130
+ ),
131
+ });
132
+ await router.addItem(draft.assistantId, { kind: "note", icon: "split", text: `已创建「${assistant.name}」` });
133
+
134
+ const created: Draft = { ...draft, status: "created", createdAssistantId: assistant.id };
135
+ store.saveDraft(created);
136
+ bus.emit({ type: "draft", draft: created });
137
+ bus.emit({ type: "assistant", assistant: router.summary(assistant) });
138
+ return { status: 200, body: { assistant: router.summary(assistant) } };
139
+ }
140
+
141
+ export function dismissDraft(runtime: HobRuntime, draftId: string): ActionResult {
142
+ const draft = runtime.store.draft(draftId);
143
+ if (!draft) return { status: 404, body: { error: "草稿不存在" } };
144
+ if (draft.status !== "pending") return { status: 409, body: { error: "这个草稿已经处理过了" } };
145
+ const next: Draft = { ...draft, status: "dismissed" };
146
+ runtime.store.saveDraft(next);
147
+ runtime.bus.emit({ type: "draft", draft: next });
148
+ return { status: 200, body: { draft: next } };
149
+ }
150
+
151
+ /**
152
+ * 并回 Hob(分出的反方向):定时转给主助手、规则去重后追加、补充说明带上来源、
153
+ * 对话和档案移进 archive/,主助手对话里记一行。历史任务留在 jobs/,只补上助手名。
154
+ */
155
+ export async function mergeAssistant(runtime: HobRuntime, id: string): Promise<ActionResult> {
156
+ const { store, bus, router, routines } = runtime;
157
+ const target = store.assistant(id);
158
+ if (!target) return { status: 404, body: { error: "助手不存在" } };
159
+ if (target.primary) return { status: 400, body: { error: "主助手不能并回自己" } };
160
+ const primary = store.assistants().find((assistant) => assistant.primary);
161
+ if (!primary) return { status: 409, body: { error: "主助手不存在,无法并回" } };
162
+ const live = store
163
+ .jobs()
164
+ .filter((job) => job.assistantId === id && (job.status === "running" || job.status === "queued" || job.status === "waiting"));
165
+ if (live.length) return { status: 409, body: { error: "请等任务结束后再并回" } };
166
+
167
+ // 先算清楚要转多少:规则同一条文字只留一份(主助手原来那条优先)
168
+ const moved = store.routines().filter((routine) => routine.assistantId === id);
169
+ const existing = store.rules(primary.id);
170
+ const now = new Date().toISOString();
171
+ const added: Rule[] = [];
172
+ for (const rule of store.rules(id)) {
173
+ const text = rule.text.trim();
174
+ if (!text || existing.some((item) => item.text.trim() === text)) continue;
175
+ const next: Rule = { id: newId("r-"), text, mode: rule.mode, changedAt: now };
176
+ existing.push(next);
177
+ added.push(next);
178
+ }
179
+
180
+ // 主助手对话里那行提示:模型没配好时开不了会话,这里就停下,别留下改了一半的状态
181
+ try {
182
+ await router.addItem(primary.id, {
183
+ kind: "note",
184
+ icon: "split",
185
+ text: `已并回「${target.name}」:转来 ${moved.length} 个定时任务、${added.length} 条规则`,
186
+ });
187
+ } catch (error) {
188
+ return { status: 409, body: { error: `无法写入 Hob 的对话:${error instanceof Error ? error.message : String(error)}` } };
189
+ }
190
+
191
+ for (const routine of moved) {
192
+ const next = { ...routine, assistantId: primary.id };
193
+ store.saveRoutine(next);
194
+ bus.emit({ type: "routine", routine: next });
195
+ }
196
+ // cron 的归属和时区都在建的时候定死,改完所属助手重排一遍
197
+ if (moved.length) routines.reload();
198
+
199
+ if (added.length) {
200
+ store.saveRules(primary.id, existing);
201
+ bus.emit({ type: "profile", assistantId: primary.id });
202
+ }
203
+
204
+ const extra = target.instructions.trim();
205
+ const instructions = extra
206
+ ? [primary.instructions.trim(), `来自「${target.name}」:`, extra].filter(Boolean).join("\n")
207
+ : primary.instructions;
208
+ store.saveAssistant({ ...primary, instructions });
209
+
210
+ // 这个助手挂着没答的审批:助手没了,卡片一并结束
211
+ for (const approval of store.approvals()) {
212
+ if (approval.assistantId !== id || approval.status !== "pending") continue;
213
+ const expired: Approval = { ...approval, status: "expired" };
214
+ store.saveApproval(expired);
215
+ bus.emit({ type: "approval", approval: expired });
216
+ }
217
+
218
+ // 历史任务留在 jobs/:记下当时的助手名,详情页在助手没了以后照样显示
219
+ for (const job of store.jobs()) {
220
+ if (job.assistantId !== id || job.assistantName) continue;
221
+ store.saveJob({ ...job, assistantName: target.name });
222
+ }
223
+
224
+ router.forget(id);
225
+ store.archiveAssistant(id, `${id}-${new Date().toISOString().replace(/[:.]/g, "-")}`);
226
+ bus.emit({ type: "assistant_removed", id });
227
+ const merged = router.summary(store.assistant(primary.id)!);
228
+ bus.emit({ type: "assistant", assistant: merged });
229
+ return { status: 200, body: { assistant: merged } };
230
+ }
@@ -0,0 +1,85 @@
1
+ // 启动入口:在加载任何引擎模块之前把环境定下来,再交给 main.ts。
2
+ //
3
+ // 引擎里有模块在加载时就算好目录(`utils/tools-manager.js` 的 TOOLS_DIR、`utils/shell.js`
4
+ // 的 PATH 前缀都来自 `getAgentDir()`),靠传参管不到,只能先改环境变量。所以这个文件
5
+ // 只能静态导入 node 内置模块和不碰引擎的小工具,main.ts 必须动态导入。
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { engineDir, loreDir, memoryDir, resolveHome } from "./config.ts";
9
+
10
+ /** 从本机导入时要读的目录(只记路径,不记密钥)。 */
11
+ export interface LocalConfigDirs {
12
+ /** 原 PI_CODING_AGENT_DIR,没有就是 ~/.pi/agent */
13
+ piDir: string;
14
+ /** 原 LORE_HOME,没有就是 ~/.lore */
15
+ loreHome: string;
16
+ }
17
+
18
+ /** 从 shell 继承、但必须由 Hob 自己管的变量:删掉,任务里的 bash 也读不到用户的凭据。 */
19
+ const INHERITED_CONFIG_ENV = [
20
+ "LORE_BASE_URL",
21
+ "LORE_API_TOKEN",
22
+ "API_TOKEN",
23
+ "LORE_CLIENT_TYPE",
24
+ "LORE_SKILLS_ENABLED",
25
+ ];
26
+
27
+ /**
28
+ * 从启动终端继承的模型密钥:Hob 的服务商 Key 只从自己的 secrets.json 来,不读环境变量。
29
+ * 留着的话,任务里的命令能直接拿到用户别的客户端的 Key。
30
+ */
31
+ const INHERITED_MODEL_ENV = [
32
+ "OPENAI_API_KEY",
33
+ "OPENAI_BASE_URL",
34
+ "ANTHROPIC_API_KEY",
35
+ "ANTHROPIC_AUTH_TOKEN",
36
+ "ANTHROPIC_BASE_URL",
37
+ "GEMINI_API_KEY",
38
+ "GOOGLE_API_KEY",
39
+ "DEEPSEEK_API_KEY",
40
+ "OPENROUTER_API_KEY",
41
+ "XAI_API_KEY",
42
+ "GROQ_API_KEY",
43
+ "MISTRAL_API_KEY",
44
+ "CLAUDECODE",
45
+ ];
46
+
47
+ /** 按前缀删:CLAUDE_CODE_OAUTH_TOKEN 这类 Claude Code 自己的变量一个不留。 */
48
+ const INHERITED_ENV_PREFIXES = ["CLAUDE_CODE_"];
49
+
50
+ /**
51
+ * 把引擎和记忆的环境变量指到 HOB_HOME,删掉从 shell 继承的 Lore 凭据、模型密钥和 Claude Code 的变量,
52
+ * 返回本机配置的目录。纯函数:只改传进来的 env,不读 process.env。
53
+ */
54
+ export function applyEngineEnv(env: NodeJS.ProcessEnv, home: string): LocalConfigDirs {
55
+ const piDir = env.HOB_LOCAL_PI_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
56
+ const loreHome = env.HOB_LOCAL_LORE_HOME?.trim() || env.LORE_HOME?.trim() || join(homedir(), ".lore");
57
+ env.HOB_LOCAL_PI_DIR = piDir;
58
+ env.HOB_LOCAL_LORE_HOME = loreHome;
59
+ env.PI_CODING_AGENT_DIR = engineDir(home);
60
+ env.PI_MEMORY_DIR = memoryDir(home);
61
+ env.LORE_HOME = loreDir(home);
62
+ for (const key of [...INHERITED_CONFIG_ENV, ...INHERITED_MODEL_ENV]) delete env[key];
63
+ for (const key of Object.keys(env)) {
64
+ if (INHERITED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) delete env[key];
65
+ }
66
+ return { piDir, loreHome };
67
+ }
68
+
69
+ /** 命令行入口:`tsx server/src/boot.ts` 和 npm 包的 `hob` 命令都走这里。 */
70
+ export function boot(): void {
71
+ const home = resolveHome();
72
+ applyEngineEnv(process.env, home);
73
+ import("./main.ts")
74
+ .then(({ startHob }) => startHob({ home }))
75
+ .then((server) => {
76
+ console.log(`Hob 在 http://${server.runtime.config.host}:${server.port}(数据目录 ${server.runtime.home})`);
77
+ })
78
+ .catch((error: unknown) => {
79
+ console.error(error instanceof Error ? error.message : error);
80
+ process.exit(1);
81
+ });
82
+ }
83
+
84
+ const isMain = process.argv[1]?.endsWith("boot.ts") || process.argv[1]?.endsWith("boot.js");
85
+ if (isMain) boot();
@@ -0,0 +1,21 @@
1
+ import type { HobEvent } from "../../shared/types.ts";
2
+
3
+ /** 进程内事件广播:SSE 订阅者 + 内部关心状态的模块。 */
4
+ export class Bus {
5
+ private listeners = new Set<(event: HobEvent) => void>();
6
+
7
+ subscribe(listener: (event: HobEvent) => void): () => void {
8
+ this.listeners.add(listener);
9
+ return () => this.listeners.delete(listener);
10
+ }
11
+
12
+ emit(event: HobEvent): void {
13
+ for (const listener of [...this.listeners]) {
14
+ try {
15
+ listener(event);
16
+ } catch {
17
+ // 单个订阅者出错不影响其他订阅者
18
+ }
19
+ }
20
+ }
21
+ }