hob-bot 1.0.3 → 1.1.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 (86) hide show
  1. package/README.md +75 -5
  2. package/README.zh-CN.md +75 -5
  3. package/dist/web/assets/DesktopView-rVxacveF.js +5 -0
  4. package/dist/web/assets/index-B-8ISOr1.js +267 -0
  5. package/dist/web/assets/index-Cu1Pf8yX.css +1 -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/dist/web/mock//347/201/253/346/230/237/346/227/245/350/220/275/347/244/272/346/204/217/345/233/276.md +34 -0
  13. package/package.json +10 -2
  14. package/server/src/actions.ts +24 -57
  15. package/server/src/assistant-skills.ts +201 -0
  16. package/server/src/boot.ts +1 -0
  17. package/server/src/claude-status.ts +47 -0
  18. package/server/src/config.ts +3 -1
  19. package/server/src/delegations.ts +40 -12
  20. package/server/src/desktop-control.ts +152 -0
  21. package/server/src/desktop-events.ts +63 -0
  22. package/server/src/desktop-public.ts +43 -0
  23. package/server/src/desktop-tools.ts +161 -0
  24. package/server/src/desktop.ts +76 -0
  25. package/server/src/embedding.ts +52 -0
  26. package/server/src/files.ts +63 -43
  27. package/server/src/http.ts +334 -15
  28. package/server/src/jobs.ts +330 -59
  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 +1 -1
  38. package/server/src/pages.ts +1 -1
  39. package/server/src/pi.ts +92 -16
  40. package/server/src/prompt.ts +46 -154
  41. package/server/src/providers.ts +3 -2
  42. package/server/src/push.ts +42 -7
  43. package/server/src/router-activity.ts +4 -0
  44. package/server/src/router-tools.ts +132 -38
  45. package/server/src/router.ts +159 -19
  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/thumbs.ts +49 -0
  51. package/server/src/transcript.ts +57 -4
  52. package/server/src/worker-tools.ts +14 -12
  53. package/shared/knowledge.ts +92 -0
  54. package/shared/mcp.ts +91 -0
  55. package/shared/media.ts +6 -0
  56. package/shared/plain-text.ts +31 -0
  57. package/shared/skills.ts +18 -0
  58. package/shared/sprites.ts +198 -0
  59. package/shared/types.ts +105 -20
  60. package/vendor/claude-bridge/HOB-PATCH.md +10 -0
  61. package/vendor/claude-bridge/LICENSE +21 -0
  62. package/vendor/claude-bridge/package.json +62 -0
  63. package/vendor/claude-bridge/src/agents-md.ts +14 -0
  64. package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
  65. package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
  66. package/vendor/claude-bridge/src/attachments.ts +135 -0
  67. package/vendor/claude-bridge/src/config.ts +105 -0
  68. package/vendor/claude-bridge/src/convert.ts +222 -0
  69. package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
  70. package/vendor/claude-bridge/src/index.ts +2503 -0
  71. package/vendor/claude-bridge/src/log-paths.ts +19 -0
  72. package/vendor/claude-bridge/src/mcp-server.ts +169 -0
  73. package/vendor/claude-bridge/src/models.ts +164 -0
  74. package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
  75. package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
  76. package/vendor/claude-bridge/src/query-state.ts +118 -0
  77. package/vendor/claude-bridge/src/rate-limit.ts +48 -0
  78. package/vendor/claude-bridge/src/session-cwd.ts +38 -0
  79. package/vendor/claude-bridge/src/session-verify.ts +38 -0
  80. package/vendor/claude-bridge/src/skills.ts +20 -0
  81. package/vendor/claude-bridge/src/transcript.ts +86 -0
  82. package/vendor/claude-bridge/src/usage.ts +53 -0
  83. package/vendor/lore-pi-extension/package.json +1 -1
  84. package/vendor/lore-pi-extension/tools.ts +8 -5
  85. package/dist/web/assets/index-C72gAwgN.css +0 -1
  86. package/dist/web/assets/index-Dyw5G8Pg.js +0 -230
@@ -23,8 +23,8 @@
23
23
  <meta name="apple-mobile-web-app-title" content="Hob" />
24
24
  <meta name="apple-mobile-web-app-status-bar-style" content="default" />
25
25
  <title>Hob</title>
26
- <script type="module" crossorigin src="/assets/index-Dyw5G8Pg.js"></script>
27
- <link rel="stylesheet" crossorigin href="/assets/index-C72gAwgN.css">
26
+ <script type="module" crossorigin src="/assets/index-B-8ISOr1.js"></script>
27
+ <link rel="stylesheet" crossorigin href="/assets/index-Cu1Pf8yX.css">
28
28
  </head>
29
29
  <body>
30
30
  <div id="root"></div>
Binary file
Binary file
Binary file
@@ -0,0 +1,90 @@
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">
6
+ <title>如果在地球和火星同时看日落</title>
7
+ <style>
8
+ :root { --bg: #faf9f6; --fg: #1d1d1f; --mute: #6e6e73; --card: #fff; --line: #e8e6e1; }
9
+ :root.dark { --bg: #161616; --fg: #f2f2f2; --mute: #9a9a9f; --card: #1f1f1f; --line: #2c2c2c; }
10
+ * { box-sizing: border-box; }
11
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.6 -apple-system, "PingFang SC", "Noto Sans SC", sans-serif; }
12
+ main { max-width: 760px; margin: 0 auto; padding: 32px 22px 48px; }
13
+ h1 { font-size: 26px; line-height: 1.3; margin: 0 0 6px; letter-spacing: -.01em; }
14
+ .lead { color: var(--mute); margin: 0 0 26px; }
15
+ .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
16
+ figure { margin: 0; background: var(--card); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; }
17
+ svg { display: block; width: 100%; height: auto; }
18
+ figcaption { padding: 12px 14px 14px; }
19
+ figcaption b { display: block; font-size: 15px; }
20
+ figcaption span { color: var(--mute); font-size: 13px; }
21
+ h2 { font-size: 17px; margin: 30px 0 10px; }
22
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
23
+ th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); }
24
+ th { color: var(--mute); font-weight: 500; }
25
+ .why { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
26
+ .why div { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; }
27
+ .why b { display: block; margin-bottom: 4px; }
28
+ .why p { margin: 0; color: var(--mute); font-size: 13.5px; }
29
+ @media (max-width: 560px) { .pair, .why { grid-template-columns: 1fr; } }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <main>
34
+ <h1>如果在地球和火星同时看日落</h1>
35
+ <p class="lead">同一个视角:太阳刚落到地平线。看太阳周围,再看远离太阳的天空。</p>
36
+
37
+ <div class="pair">
38
+ <figure>
39
+ <svg viewBox="0 0 400 260">
40
+ <defs>
41
+ <linearGradient id="es" x1="0" y1="0" x2="0" y2="1">
42
+ <stop offset="0" stop-color="#3b5d9a"/><stop offset=".45" stop-color="#c9718a"/><stop offset=".78" stop-color="#f29a4a"/><stop offset="1" stop-color="#ffcf73"/>
43
+ </linearGradient>
44
+ <radialGradient id="eg" cx=".5" cy="1" r=".55"><stop offset="0" stop-color="#fff2b8"/><stop offset=".35" stop-color="#ffb35c" stop-opacity=".8"/><stop offset="1" stop-color="#ff8a3d" stop-opacity="0"/></radialGradient>
45
+ </defs>
46
+ <rect width="400" height="260" fill="url(#es)"/>
47
+ <ellipse cx="200" cy="205" rx="230" ry="120" fill="url(#eg)"/>
48
+ <circle cx="200" cy="203" r="17" fill="#fff6d6"/>
49
+ <path d="M0 205 L60 192 L120 200 L190 186 L260 198 L330 188 L400 200 L400 260 L0 260Z" fill="#2b2233"/>
50
+ </svg>
51
+ <figcaption><b>地球</b><span>太阳周围橙红,头顶仍是蓝色</span></figcaption>
52
+ </figure>
53
+ <figure>
54
+ <svg viewBox="0 0 400 260">
55
+ <defs>
56
+ <linearGradient id="ms" x1="0" y1="0" x2="0" y2="1">
57
+ <stop offset="0" stop-color="#5a4334"/><stop offset=".5" stop-color="#a0785a"/><stop offset="1" stop-color="#c79c74"/>
58
+ </linearGradient>
59
+ <radialGradient id="mg" cx=".5" cy="1" r=".42"><stop offset="0" stop-color="#e6f2ff"/><stop offset=".3" stop-color="#8fb6e8" stop-opacity=".9"/><stop offset="1" stop-color="#5f86c4" stop-opacity="0"/></radialGradient>
60
+ </defs>
61
+ <rect width="400" height="260" fill="url(#ms)"/>
62
+ <ellipse cx="200" cy="208" rx="170" ry="110" fill="url(#mg)"/>
63
+ <circle cx="200" cy="206" r="11" fill="#f4f8ff"/>
64
+ <path d="M0 208 L70 198 L140 204 L210 194 L280 203 L350 196 L400 202 L400 260 L0 260Z" fill="#4a2c22"/>
65
+ </svg>
66
+ <figcaption><b>火星</b><span>太阳周围泛蓝,远处是黄褐色</span></figcaption>
67
+ </figure>
68
+ </div>
69
+
70
+ <h2>为什么正好相反</h2>
71
+ <div class="why">
72
+ <div><b>地球:气体分子</b><p>空气分子更容易把蓝光散射到四面八方。日落时光路变长,蓝光在路上散尽,只剩橙红照过来。</p></div>
73
+ <div><b>火星:尘埃颗粒</b><p>尘埃颗粒约 1.5 µm,把蓝光集中朝前方散射,所以蓝色只聚在太阳附近,其余天空被尘埃染成黄褐。</p></div>
74
+ </div>
75
+
76
+ <h2>对比</h2>
77
+ <table>
78
+ <tr><th>项目</th><th>地球</th><th>火星</th></tr>
79
+ <tr><td>大气压</td><td>1013 hPa</td><td>约 6 hPa</td></tr>
80
+ <tr><td>主要散射体</td><td>气体分子</td><td>尘埃颗粒</td></tr>
81
+ <tr><td>白天天空</td><td>蓝色</td><td>黄褐色</td></tr>
82
+ <tr><td>日落颜色</td><td>红橙</td><td>蓝色</td></tr>
83
+ </table>
84
+ </main>
85
+ <script>
86
+ const apply = () => document.documentElement.classList.toggle("dark", location.hash === "#dark");
87
+ apply(); addEventListener("hashchange", apply);
88
+ </script>
89
+ </body>
90
+ </html>
@@ -0,0 +1,34 @@
1
+ # 火星日落为什么是蓝色的
2
+
3
+ 火星大气很稀薄,但飘着大量**细小的尘埃**。尘埃颗粒的大小和蓝光波长相近,蓝光会被集中散射到太阳周围,所以日落时太阳附近是一圈蓝。
4
+
5
+ ## 和地球对比
6
+
7
+ | 项目 | 地球 | 火星 |
8
+ | --- | --- | --- |
9
+ | 大气压 | 1013 hPa | 约 6 hPa |
10
+ | 主要散射体 | 气体分子 | 尘埃颗粒 |
11
+ | 白天天空 | 蓝色 | 黄褐色 |
12
+ | 日落颜色 | 红橙 | **蓝色** |
13
+
14
+ ## 要点
15
+
16
+ - 尘埃颗粒直径约 1.5 μm,对蓝光的**前向散射**最强
17
+ - 太阳越低,光穿过的尘埃越多,蓝晕越明显
18
+ - 好奇号在 2015 年拍到过完整过程
19
+
20
+ 1. 白天:尘埃把红光散到全天空,天空发黄
21
+ 2. 日落:前向散射的蓝光留在太阳周围
22
+
23
+ > 数据来自 NASA 好奇号任务公开资料。
24
+
25
+ ### 示意图源码
26
+
27
+ ```mermaid
28
+ graph LR
29
+ 太阳 --> 尘埃 --> 蓝晕
30
+ ```
31
+
32
+ ---
33
+
34
+ 验证状态:**明确 token 被拒**,`401 Unauthorized`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hob-bot",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "A self-hosted personal assistant that runs real work as background jobs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,22 +28,30 @@
28
28
  "typecheck": "tsc -p tsconfig.json --noEmit",
29
29
  "start": "tsx server/src/boot.ts",
30
30
  "test": "vitest run",
31
+ "update-lore": "node scripts/update-lore.mjs",
31
32
  "prepack": "npm run build"
32
33
  },
33
34
  "dependencies": {
35
+ "@anthropic-ai/claude-agent-sdk": "0.3.293",
34
36
  "@earendil-works/pi-ai": "1.3.0",
35
37
  "@earendil-works/pi-coding-agent": "1.3.0",
36
38
  "@hono/node-server": "^2.1.6",
37
39
  "@juicesharp/rpiv-config": "2.15.0",
38
40
  "@juicesharp/rpiv-web-tools": "2.15.0",
41
+ "@novnc/novnc": "^1.7.0",
39
42
  "croner": "^9.0.0",
43
+ "github-markdown-css": "^5.9.0",
40
44
  "hono": "^4.6.0",
41
45
  "katex": "^0.19.1",
42
- "marked": "^15.0.0",
46
+ "marked": "^18.1.3",
47
+ "marked-cjk-friendly": "^0.1.3",
48
+ "pi-claude-bridge": "0.9.3",
43
49
  "pi-memory": "0.4.2",
50
+ "playwright-core": "^1.64.1",
44
51
  "react": "^19.0.0",
45
52
  "react-dom": "^19.0.0",
46
53
  "tsx": "^4.19.0",
54
+ "unpdf": "^1.9.1",
47
55
  "web-push": "^3.6.7",
48
56
  "ws": "^8.18.0"
49
57
  },
@@ -1,5 +1,5 @@
1
1
  import { distinctSprite } from "../../shared/sprites.ts";
2
- import type { AnswerApprovalBody, Approval, Assistant, Draft, Rule } from "../../shared/types.ts";
2
+ import type { AnswerApprovalBody, Approval, Assistant, Draft } from "../../shared/types.ts";
3
3
  import { newId, slugify } from "./ids.ts";
4
4
  import type { HobRuntime } from "./runtime.ts";
5
5
  import { monthDayText } from "./time.ts";
@@ -34,6 +34,9 @@ export async function answerApproval(runtime: HobRuntime, approvalId: string, in
34
34
  answeredAt: new Date().toISOString(),
35
35
  };
36
36
  store.saveApproval(answered);
37
+ // 转交里 Router 问的:答案回到目标助手接着办,办完才算转交结束(任务里的提问由任务自己接着跑)
38
+ const delegation = approval.source === "job" ? undefined : store.delegations().find((item) => item.approvalIds?.includes(approval.id));
39
+ const delegationId = delegation && runtime.delegations.resume(delegation.id) ? delegation.id : undefined;
37
40
  bus.emit({ type: "approval", approval: answered });
38
41
 
39
42
  if (option?.always) {
@@ -59,7 +62,11 @@ export async function answerApproval(runtime: HobRuntime, approvalId: string, in
59
62
  // 回给模型:自己写的那句要说明是用户写的、没选给出的选项
60
63
  const answer = option ? `用户选择了:${option.label}` : `用户没有选给出的选项,而是回复:${text}`;
61
64
  if (approval.source === "job" && approval.jobId) jobs.resolveAsk(answered, answer);
62
- else await router.answerEvent(approval.assistantId, option ? `用户选择了 ${option.key}:${option.label}` : answer);
65
+ else {
66
+ const reply = option ? `用户选择了 ${option.key}:${option.label}` : answer;
67
+ if (delegationId) void router.relay(approval.assistantId, reply, delegationId).catch(() => runtime.delegations.turnEnded(delegationId, "目标助手处理失败", true));
68
+ else await router.answerEvent(approval.assistantId, reply);
69
+ }
63
70
 
64
71
  return { status: 200, body: { approval: answered } };
65
72
  }
@@ -149,63 +156,18 @@ export function dismissDraft(runtime: HobRuntime, draftId: string): ActionResult
149
156
  }
150
157
 
151
158
  /**
152
- * 并回 Hob(分出的反方向):定时转给主助手、规则去重后追加、补充说明带上来源、
153
- * 对话和档案移进 archive/,主助手对话里记一行。历史任务留在 jobs/,只补上助手名。
159
+ * 归档助手:整个目录(档案、对话、规则)移进 archive/,它的定时一并存进去再停掉。
160
+ * 什么都不转给主助手。历史任务留在 jobs/,只补上助手名。
154
161
  */
155
- export async function mergeAssistant(runtime: HobRuntime, id: string): Promise<ActionResult> {
156
- const { store, bus, router, routines } = runtime;
162
+ export async function archiveAssistant(runtime: HobRuntime, id: string): Promise<ActionResult> {
163
+ const { store, bus, router } = runtime;
157
164
  const target = store.assistant(id);
158
165
  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: "主助手不存在,无法并回" } };
166
+ if (target.primary) return { status: 400, body: { error: "主助手不能归档" } };
162
167
  const live = store
163
168
  .jobs()
164
169
  .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 });
170
+ if (live.length) return { status: 409, body: { error: "请等任务结束后再归档" } };
209
171
 
210
172
  // 这个助手挂着没答的审批:助手没了,卡片一并结束
211
173
  for (const approval of store.approvals()) {
@@ -221,10 +183,15 @@ export async function mergeAssistant(runtime: HobRuntime, id: string): Promise<A
221
183
  store.saveJob({ ...job, assistantName: target.name });
222
184
  }
223
185
 
186
+ // 公共屏幕授权不随助手 id 的重新使用继承。
187
+ const desktop = runtime.settings.settings.desktop;
188
+ if (desktop?.publicAssistants?.includes(id)) {
189
+ runtime.settings.setDesktop(runtime.settings.get().revision, { ...desktop, publicAssistants: desktop.publicAssistants.filter((assistantId) => assistantId !== id) });
190
+ }
224
191
  router.forget(id);
225
- store.archiveAssistant(id, `${id}-${new Date().toISOString().replace(/[:.]/g, "-")}`);
192
+ const routines = store.routines().filter((routine) => routine.assistantId === id);
193
+ store.archiveAssistant(id, `${id}-${new Date().toISOString().replace(/[:.]/g, "-")}`, routines);
194
+ for (const routine of routines) runtime.routines.remove(routine.id);
226
195
  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 } };
196
+ return { status: 200, body: { ok: true } };
230
197
  }
@@ -0,0 +1,201 @@
1
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { loadSkillsFromDir, type Skill } from "@earendil-works/pi-coding-agent";
4
+ import type { AssistantSkill } from "../../shared/skills.ts";
5
+
6
+ type Metadata = Omit<AssistantSkill, "name" | "description" | "path">;
7
+ export interface SkillInput { name: string; title: string; description: string; content: string; files?: { path: string; content: string }[] }
8
+
9
+ function checkedDir(home: string, assistantId: string): string {
10
+ if (!/^[a-zA-Z0-9_-]+$/.test(assistantId)) throw new Error("助手名称无效");
11
+ const root = resolve(home);
12
+ const dir = join(root, "assistants", assistantId, "skills");
13
+ let current = root;
14
+ for (const part of ["assistants", assistantId, "skills"]) {
15
+ current = join(current, part);
16
+ if (isLink(current)) throw new Error("技能路径不能包含符号链接");
17
+ }
18
+ return dir;
19
+ }
20
+ function validName(name: string): void {
21
+ if (!/^[a-z0-9][a-z0-9-]{1,47}$/.test(name)) throw new Error("技能名称无效");
22
+ }
23
+ function isLink(path: string): boolean {
24
+ try { return lstatSync(path).isSymbolicLink(); }
25
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
26
+ }
27
+ function safeTree(path: string): void {
28
+ if (isLink(path)) throw new Error("技能目录不能包含符号链接");
29
+ if (!existsSync(path)) return;
30
+ const stat = lstatSync(path);
31
+ if (stat.isSymbolicLink()) throw new Error("技能目录不能包含符号链接");
32
+ if (stat.isDirectory()) for (const entry of readdirSync(path)) safeTree(join(path, entry));
33
+ }
34
+ function metadata(dir: string): Record<string, Metadata> {
35
+ const path = join(dir, "skills.json");
36
+ safeTree(path);
37
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
38
+ }
39
+ function saveMetadata(dir: string, data: Record<string, Metadata>): void {
40
+ const temp = join(dir, `.${process.pid}-${Date.now()}.tmp`);
41
+ writeFileSync(temp, JSON.stringify(data, null, 2));
42
+ renameSync(temp, join(dir, "skills.json"));
43
+ }
44
+ function description(file: string): string {
45
+ const line = readFileSync(file, "utf8").split("\n").find((item) => item.startsWith("description: "));
46
+ if (!line) throw new Error("技能说明缺失");
47
+ return JSON.parse(line.slice("description: ".length));
48
+ }
49
+ export function listAssistantSkills(home: string, assistantId: string): AssistantSkill[] {
50
+ const dir = checkedDir(home, assistantId);
51
+ return Object.entries(metadata(dir)).map(([name, data]) => {
52
+ validName(name);
53
+ const skillDir = join(dir, name);
54
+ safeTree(skillDir);
55
+ const path = join(skillDir, "SKILL.md");
56
+ return { name, ...data, description: description(path), path };
57
+ });
58
+ }
59
+ /** 给对话、任务和定时用的已启用技能:读不了就当没有并记日志,一个坏掉的技能不能让整个助手开不了会话。 */
60
+ export function usableAssistantSkills(home: string, assistantId: string): AssistantSkill[] {
61
+ try { return listAssistantSkills(home, assistantId).filter((skill) => skill.enabled); }
62
+ catch (error) { console.error(`读取「${assistantId}」的技能失败:`, error); return []; }
63
+ }
64
+ export function enabledAssistantSkills(home: string, assistantId: string): Skill[] {
65
+ return usableAssistantSkills(home, assistantId).flatMap((skill) =>
66
+ loadSkillsFromDir({ dir: dirname(skill.path), source: "assistant" }).skills.filter((item) => item.filePath === skill.path));
67
+ }
68
+ /** save_skill 的附件可以不抄全文,只给工作目录里的文件(from),这里读出来;不能跳出工作目录。 */
69
+ export function readSkillFiles(cwd: string, files: unknown): SkillInput["files"] {
70
+ if (files === undefined) return undefined;
71
+ if (!Array.isArray(files)) throw new Error("技能附件无效");
72
+ let root: string | undefined;
73
+ return files.map((file) => {
74
+ const { path, content, from } = (file ?? {}) as { path?: unknown; content?: unknown; from?: unknown };
75
+ if ((content === undefined) === (from === undefined)) throw new Error("附件要给 content 或 from,只给一个");
76
+ if (from === undefined) return { path, content } as { path: string; content: string };
77
+ if (typeof from !== "string" || !from.trim()) throw new Error("附件的 from 无效");
78
+ let real: string;
79
+ try { root ??= realpathSync(cwd); real = realpathSync(resolve(root, from)); }
80
+ catch { throw new Error(`找不到 from 指向的文件:${from}`); }
81
+ const rel = relative(root, real);
82
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Error("from 只能是工作目录里的文件");
83
+ const stat = statSync(real);
84
+ if (!stat.isFile()) throw new Error(`from 不是文件:${from}`);
85
+ if (stat.size > 200 * 1024) throw new Error("技能附件超过 200 KB");
86
+ return { path, content: readFileSync(real, "utf8") } as { path: string; content: string };
87
+ });
88
+ }
89
+ export function saveAssistantSkill(home: string, assistantId: string, input: SkillInput, jobId?: string, replaceFiles = false): AssistantSkill {
90
+ validName(input.name);
91
+ if (typeof input.title !== "string" || !input.title.trim() || typeof input.description !== "string" || !input.description.trim() || [...input.description].length > 120 || typeof input.content !== "string") throw new Error("技能内容无效");
92
+ const body = `---\nname: ${JSON.stringify(input.name)}\ndescription: ${JSON.stringify(input.description)}\nmetadata:\n title: ${JSON.stringify(input.title)}\n---\n\n${input.content}`;
93
+ if ([...body].length > 20000) throw new Error("SKILL.md 超过 20000 字");
94
+ if (input.files !== undefined && !Array.isArray(input.files)) throw new Error("技能附件无效");
95
+ const files = input.files ?? [];
96
+ if (files.length > 20) throw new Error("技能附件超过 20 个");
97
+ const names = new Set<string>();
98
+ for (const file of files) {
99
+ if (!file || typeof file.path !== "string" || typeof file.content !== "string" || !/^(scripts|references)\/(?!.*(?:^|\/)\.\.?($|\/))[^\\\0]+$/.test(file.path) || file.path.split("/").some((p) => !p || p === "." || p === "..")) throw new Error("附件路径无效");
100
+ if (Buffer.byteLength(file.content) > 200 * 1024) throw new Error("技能附件超过 200 KB");
101
+ if (names.has(file.path)) throw new Error("附件路径重复");
102
+ names.add(file.path);
103
+ }
104
+ const dir = checkedDir(home, assistantId);
105
+ mkdirSync(dir, { recursive: true });
106
+ const all = metadata(dir);
107
+ const target = join(dir, input.name);
108
+ safeTree(target);
109
+ const old = all[input.name];
110
+ if (!old && existsSync(target)) throw new Error("技能目录已存在");
111
+ const existingFiles = new Set<string>();
112
+ const collectFiles = (dirPath: string, prefix: string): void => {
113
+ if (!existsSync(dirPath)) return;
114
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
115
+ const path = `${prefix}/${entry.name}`;
116
+ if (entry.isDirectory()) collectFiles(join(dirPath, entry.name), path);
117
+ else if (entry.isFile()) existingFiles.add(path);
118
+ }
119
+ };
120
+ collectFiles(join(target, "scripts"), "scripts");
121
+ collectFiles(join(target, "references"), "references");
122
+ const combined = new Set([...(replaceFiles ? [] : existingFiles), ...names]);
123
+ if (combined.size > 20) throw new Error("技能附件超过 20 个");
124
+ for (const path of combined) {
125
+ const parts = path.split("/");
126
+ for (let i = 2; i < parts.length; i++) if (combined.has(parts.slice(0, i).join("/"))) throw new Error("附件路径冲突");
127
+ }
128
+ if (old) {
129
+ const history = join(target, ".history");
130
+ mkdirSync(history, { recursive: true });
131
+ const snapshot = join(history, String(old.version));
132
+ mkdirSync(snapshot);
133
+ for (const entry of readdirSync(target)) if (entry !== ".history") cpSync(join(target, entry), join(snapshot, entry), { recursive: true });
134
+ const versions = readdirSync(history).filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => b - a);
135
+ for (const version of versions.slice(5)) rmSync(join(history, String(version)), { recursive: true, force: true });
136
+ } else mkdirSync(target);
137
+ if (replaceFiles) for (const folder of ["scripts", "references"]) rmSync(join(target, folder), { recursive: true, force: true });
138
+ writeFileSync(join(target, "SKILL.md"), body);
139
+ for (const file of files) {
140
+ const dest = join(target, file.path);
141
+ mkdirSync(dirname(dest), { recursive: true });
142
+ writeFileSync(dest, file.content);
143
+ }
144
+ const now = new Date().toISOString();
145
+ all[input.name] = { title: input.title, version: (old?.version ?? 0) + 1, createdAt: old?.createdAt ?? now, updatedAt: now, ...(jobId ? { fromJobId: jobId } : old?.fromJobId ? { fromJobId: old.fromJobId } : {}), uses: old?.uses ?? 0, ...(old?.lastUsedAt ? { lastUsedAt: old.lastUsedAt } : {}), enabled: old?.enabled ?? true };
146
+ saveMetadata(dir, all);
147
+ return listAssistantSkills(home, assistantId).find((skill) => skill.name === input.name)!;
148
+ }
149
+ export function setAssistantSkillEnabled(home: string, assistantId: string, name: string, enabled: boolean): AssistantSkill {
150
+ validName(name);
151
+ const dir = checkedDir(home, assistantId), all = metadata(dir);
152
+ if (!all[name]) throw new Error("技能不存在");
153
+ safeTree(join(dir, name));
154
+ all[name].enabled = enabled;
155
+ saveMetadata(dir, all);
156
+ return listAssistantSkills(home, assistantId).find((skill) => skill.name === name)!;
157
+ }
158
+ export function archiveAssistantSkill(home: string, assistantId: string, name: string): void {
159
+ validName(name);
160
+ const dir = checkedDir(home, assistantId), all = metadata(dir), target = join(dir, name);
161
+ if (!all[name]) throw new Error("技能不存在");
162
+ safeTree(target);
163
+ const archive = join(dir, ".archive");
164
+ safeTree(archive);
165
+ mkdirSync(archive, { recursive: true });
166
+ renameSync(target, join(archive, `${name}-${Date.now()}`));
167
+ delete all[name];
168
+ saveMetadata(dir, all);
169
+ }
170
+ export function recordAssistantSkillUse(home: string, assistantId: string, name: string): void {
171
+ validName(name);
172
+ const dir = checkedDir(home, assistantId), all = metadata(dir);
173
+ if (!all[name]) throw new Error("技能不存在");
174
+ all[name].uses++;
175
+ all[name].lastUsedAt = new Date().toISOString();
176
+ saveMetadata(dir, all);
177
+ }
178
+ export function rollbackAssistantSkill(home: string, assistantId: string, name: string): AssistantSkill {
179
+ validName(name);
180
+ const dir = checkedDir(home, assistantId), all = metadata(dir), target = join(dir, name);
181
+ if (!all[name]) throw new Error("技能不存在");
182
+ safeTree(target);
183
+ const history = join(target, ".history");
184
+ const versions = existsSync(history) ? readdirSync(history).filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => b - a) : [];
185
+ if (!versions.length) throw new Error("没有上一版");
186
+ const previous = join(history, String(versions[0]));
187
+ const body = readFileSync(join(previous, "SKILL.md"), "utf8");
188
+ const titleLine = body.split("\n").find((line) => line.startsWith(" title: "));
189
+ const title = titleLine ? JSON.parse(titleLine.slice(" title: ".length)) : all[name].title;
190
+ const files: { path: string; content: string }[] = [];
191
+ function collect(dirPath: string, rel: string): void {
192
+ if (!existsSync(dirPath)) return;
193
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
194
+ const path = join(dirPath, entry.name), next = `${rel}/${entry.name}`;
195
+ if (entry.isDirectory()) collect(path, next);
196
+ else if (entry.isFile()) files.push({ path: next, content: readFileSync(path, "utf8") });
197
+ }
198
+ }
199
+ collect(join(previous, "scripts"), "scripts"); collect(join(previous, "references"), "references");
200
+ return saveAssistantSkill(home, assistantId, { name, title, description: description(join(previous, "SKILL.md")), content: body.slice(body.indexOf("\n---\n") + 5).replace(/^\n/, ""), files }, undefined, true);
201
+ }
@@ -42,6 +42,7 @@ const INHERITED_MODEL_ENV = [
42
42
  "GROQ_API_KEY",
43
43
  "MISTRAL_API_KEY",
44
44
  "CLAUDECODE",
45
+ "CLAUDE_CONFIG_DIR",
45
46
  ];
46
47
 
47
48
  /** 按前缀删:CLAUDE_CODE_OAUTH_TOKEN 这类 Claude Code 自己的变量一个不留。 */
@@ -0,0 +1,47 @@
1
+ import { execFile } from "node:child_process";
2
+ import { accessSync, constants } from "node:fs";
3
+ import { delimiter, isAbsolute, join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { applyEngineEnv } from "./boot.ts";
6
+ import type { ClaudeCodeStatus } from "../../shared/types.ts";
7
+
8
+ const run = promisify(execFile);
9
+
10
+ /** 找不到或不可执行时不回退到 SDK 自带的程序。 */
11
+ export function findClaude(path: string | undefined, env: NodeJS.ProcessEnv): string | undefined {
12
+ const candidates = path ? [path] : (env.PATH ?? "").split(delimiter).filter(Boolean).map((dir) => join(dir, "claude"));
13
+ return candidates.find((candidate) => {
14
+ if (!isAbsolute(candidate)) return false;
15
+ try { accessSync(candidate, constants.X_OK); return true; } catch { return false; }
16
+ });
17
+ }
18
+
19
+ /** 只保留状态展示需要的字段,不返回程序的原始输出或错误。 */
20
+ export async function claudeStatus(home: string, path?: string, inherited = process.env): Promise<ClaudeCodeStatus> {
21
+ const env = { ...inherited };
22
+ applyEngineEnv(env, home);
23
+ const executable = findClaude(path, env);
24
+ if (!executable) return { found: false, loggedIn: false };
25
+ let version: string | undefined;
26
+ try {
27
+ const result = await run(executable, ["--version"], { env, timeout: 10_000, maxBuffer: 16_384 });
28
+ version = result.stdout.match(/^\s*(\d+\.\d+\.\d+(?:[-.][\w.-]+)?)/)?.[1];
29
+ } catch {
30
+ return { found: true, executable, loggedIn: false, error: "无法运行 Claude Code,请检查程序路径。" };
31
+ }
32
+ let output: string;
33
+ try {
34
+ output = (await run(executable, ["auth", "status", "--json"], { env, timeout: 10_000, maxBuffer: 16_384 })).stdout;
35
+ } catch (error) {
36
+ output = typeof (error as { stdout?: unknown }).stdout === "string" ? (error as { stdout: string }).stdout : "";
37
+ }
38
+ try {
39
+ const value = JSON.parse(output) as Record<string, unknown>;
40
+ if (typeof value.loggedIn !== "boolean") throw new Error("invalid status");
41
+ const email = typeof value.email === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.email) && value.email.length <= 254 ? value.email : undefined;
42
+ const plan = typeof value.subscriptionType === "string" && /^(pro|max|team|enterprise)$/i.test(value.subscriptionType) ? value.subscriptionType : undefined;
43
+ return { found: true, executable, ...(version ? { version } : {}), loggedIn: value.loggedIn === true, ...(email ? { email } : {}), ...(plan ? { plan } : {}) };
44
+ } catch {
45
+ return { found: true, executable, ...(version ? { version } : {}), loggedIn: false, error: "无法检测 Claude Code 登录状态。" };
46
+ }
47
+ }
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { hashPassword, isPasswordHash } from "./password.ts";
@@ -65,6 +65,8 @@ function writeAtomic(path: string, text: string): void {
65
65
  */
66
66
  export function loadConfig(home: string, env: NodeJS.ProcessEnv = process.env): HobConfig {
67
67
  mkdirSync(home, { recursive: true });
68
+ // 对话、任务、引擎日志(MCP 服务发来的日志原样落盘)都在这里:只给本人读
69
+ chmodSync(home, 0o700);
68
70
  const file = join(home, CONFIG_FILE);
69
71
  const stored: Record<string, unknown> = existsSync(file)
70
72
  ? (JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>)