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
@@ -228,6 +228,11 @@ export async function testLoreConnection(
228
228
  return { ok: true, baseUrl: rawBase };
229
229
  }
230
230
 
231
+ /** Hob 自带的 Lore 插件版本:vendor 里放的是 Lore 正式版,换版本用 `npm run update-lore -- <版本号>`。 */
232
+ export const LORE_PLUGIN_VERSION: string = JSON.parse(
233
+ readFileSync(new URL("../../vendor/lore-pi-extension/package.json", import.meta.url), "utf8"),
234
+ ).version;
235
+
231
236
  /** 提示词里「记忆」一节的文案:按当前提供者给,关闭时整节不出。 */
232
237
  export function memoryPromptLines(provider: MemoryProvider): string[] {
233
238
  if (provider === "local") {
@@ -239,7 +244,7 @@ export function memoryPromptLines(provider: MemoryProvider): string[] {
239
244
  if (provider === "lore") {
240
245
  return [
241
246
  "## 记忆",
242
- "你和用户是长期合作。涉及过去的偏好、决定和约定时,先在 Lore 里查;遇到值得长期记住的新事实,记进 Lore。",
247
+ "长期记忆在 Lore:过去的偏好、决定和约定先查 Lore;值得长期记住的新事实记进 Lore。读到的记忆和你刚核实的事实对不上(过时或有错),当场改对;整条已经不成立或没用了,就删掉。",
243
248
  ];
244
249
  }
245
250
  return [];
@@ -254,11 +259,11 @@ export const MEMORY_TOOL_PATTERNS: Record<MemoryProvider, string[]> = {
254
259
 
255
260
  /**
256
261
  * 会写记忆的工具:Router 调成功后在对话里出一行「记忆已更新 · …」。
257
- * 名字取自捆绑插件的实际注册(pi-memory 0.4.2 的 memory_write,Lore 1.12.4 的建/改节点)。
262
+ * 名字取自捆绑插件的实际注册(pi-memory 0.4.2 的 memory_write,Lore 插件的建/改节点)。
258
263
  */
259
264
  export const MEMORY_WRITE_TOOLS = new Set(["memory_write", "lore_create_node", "lore_update_node"]);
260
265
 
261
- /** note 里的摘要:写入内容的第一行,最多 30 个字。 */
266
+ /** note 里的摘要:写入内容的第一行,最多 80 个字符。 */
262
267
  export function memoryNoteText(params: unknown): string | undefined {
263
268
  const record = isRecord(params) ? params : {};
264
269
  for (const key of ["content", "text", "title", "new_text", "old_text"]) {
@@ -266,7 +271,9 @@ export function memoryNoteText(params: unknown): string | undefined {
266
271
  if (typeof value !== "string") continue;
267
272
  const first = value.split("\n").map((line) => line.trim()).find((line) => line !== "");
268
273
  if (!first) continue;
269
- return first.length > 30 ? `${first.slice(0, 30)}…` : first;
274
+ const chars = Array.from(first.replace(/[::,,;;、]+\s*$/u, ""));
275
+ if (!chars.length) continue;
276
+ return chars.length > 80 ? `${chars.slice(0, 80).join("").replace(/[::,,;;、]+\s*$/u, "")}…` : chars.join("");
270
277
  }
271
278
  return undefined;
272
279
  }
@@ -0,0 +1,33 @@
1
+ import type { HobRuntime } from "./runtime.ts";
2
+ import { nowText } from "./time.ts";
3
+
4
+ /** 每条 Router 消息附带的当前状态,不显示在对话里。 */
5
+ export function nowBlock(runtime: HobRuntime, assistantId: string): string {
6
+ const now = new Date();
7
+ const timezone = runtime.settings.settings.general.timezone;
8
+ const clock = (value: string) => new Intl.DateTimeFormat("sv-SE", {
9
+ timeZone: timezone, hour: "2-digit", minute: "2-digit", hour12: false,
10
+ }).format(new Date(value));
11
+ const short = (value: string) => Array.from(value).length > 20 ? `${Array.from(value).slice(0, 20).join("")}…` : value;
12
+ const jobs = runtime.store.jobs().filter((job) => job.assistantId === assistantId);
13
+ const lines = [`【现在】${nowText(timezone, now)}`];
14
+ const active = jobs.filter((job) => ["running", "queued", "waiting"].includes(job.status)).map((job) => {
15
+ const minutes = job.startedAt ? Math.floor((now.getTime() - new Date(job.startedAt).getTime()) / 60_000) : 0;
16
+ const status = job.status === "queued" ? "排队中" : job.status === "waiting" ? "等用户确认" : minutes < 1 ? "刚开始" : `运行 ${minutes} 分钟`;
17
+ return `${job.id}「${short(job.title)}」,${status}`;
18
+ });
19
+ if (active.length) lines.push(`进行中:${active.join(";")}`);
20
+ if (assistantId === "hob") {
21
+ const delegations = runtime.store.delegations().filter((item) => item.from === assistantId && (item.status === "running" || item.status === "waiting"))
22
+ .map((item) => `${runtime.store.assistant(item.to)?.name ?? item.to}「${short(item.text)}」,${clock(item.createdAt)} 起`);
23
+ if (delegations.length) lines.push(`转交中:${delegations.join(";")}`);
24
+ }
25
+ const approvals = runtime.store.approvals().filter((item) => item.assistantId === assistantId && item.status === "pending").map((item) => item.title);
26
+ if (approvals.length) lines.push(`等用户选:${approvals.join(";")}`);
27
+ const names = { done: "已完成", stuck: "受阻", failed: "失败", stopped: "已停止" } as const;
28
+ const recent = jobs.filter((job) => job.endedAt && !job.routineId && job.status in names && new Date(job.endedAt).getTime() >= now.getTime() - 86_400_000 && new Date(job.endedAt).getTime() <= now.getTime())
29
+ .sort((a, b) => b.endedAt!.localeCompare(a.endedAt!)).slice(0, 5)
30
+ .map((job) => `${job.id}「${short(job.title)}」${clock(job.endedAt!)} ${names[job.status as keyof typeof names]}`);
31
+ if (recent.length) lines.push(`最近结束:${recent.join(";")}`);
32
+ return lines.join("\n");
33
+ }
@@ -9,7 +9,7 @@ const LOG_LIMIT = 10;
9
9
  const STATUS_TEXT: Record<Job["status"], string> = {
10
10
  queued: "排队中",
11
11
  running: "进行中",
12
- waiting: "待确认",
12
+ waiting: "等你确认",
13
13
  done: "已完成",
14
14
  stuck: "受阻",
15
15
  failed: "失败",
@@ -83,13 +83,13 @@ export function overviewText(runtime: HobRuntime, sinceHours = 24): string {
83
83
  for (const entry of log) lines.push(`- ${whenText(timezone, entry.at)} ${entry.text}`);
84
84
  }
85
85
  if (asks.length) {
86
- lines.push("待确认:");
86
+ lines.push("等你确认:");
87
87
  for (const approval of asks) lines.push(`- ${approval.title}(${whenText(timezone, approval.createdAt)})`);
88
88
  }
89
89
  blocks.push(lines.join("\n"));
90
90
  }
91
91
 
92
- const head = `最近 ${hours} 小时(${whenText(timezone, since)} 之后):${endedCount} 件结束的任务、${liveCount} 件进行中、${pending.length} 个待确认。`;
92
+ const head = `最近 ${hours} 小时(${whenText(timezone, since)} 之后):${endedCount} 件结束的任务、${liveCount} 件进行中、${pending.length} 个等你确认。`;
93
93
  if (!blocks.length) return `${head}\n\n这段时间没有记录。`;
94
94
  return `${head}\n\n${blocks.join("\n\n")}`;
95
95
  }
@@ -100,7 +100,7 @@ export function overviewTool(runtime: HobRuntime): ToolDefinition<any, any> {
100
100
  name: "overview",
101
101
  label: "查看这段时间的记录",
102
102
  description:
103
- "只读:列出各助手最近一段时间的工作日志、结束的任务(标题、结果、摘要前 100 字)、进行中的任务和待确认的审批。写早报、汇总“昨晚/今天都做了什么”之前先调用它,照实编写。",
103
+ "查各助手一段时间内的日志和任务,用于早报、日报,或回答「这段时间做了什么」。",
104
104
  promptSnippet: "写早报/汇总时用 overview 查看各助手这段时间的记录。",
105
105
  parameters: Type.Object({
106
106
  sinceHours: Type.Optional(Type.Integer({ description: "往回看多少小时,默认 24,最多 168" })),
@@ -45,7 +45,7 @@ export function pageTool(runtime: HobRuntime, assistantId: string, router: boole
45
45
  if (!title || !summary) throw new Error("标题和摘要不能为空。");
46
46
  const path = await renderPage(runtime.home, title, summary, String(params.html ?? ""));
47
47
  await runtime.router.addItem(assistantId, { kind: "file", path, name: title, meta: summary });
48
- return { content: [{ type: "text" as const, text: `页面已展示:${path}${router ? "。本轮到此结束。" : ""}` }], details: { path }, ...(router ? { terminate: true } : {}) };
48
+ return { content: [{ type: "text" as const, text: `页面已展示:${path}` }], details: { path }, ...(router ? { terminate: true } : {}) };
49
49
  } catch (error) {
50
50
  return { content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }], details: undefined, isError: true };
51
51
  }
package/server/src/pi.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  createAgentSession,
3
3
  createExtensionRuntime,
4
+ createMcpExtension,
5
+ createToolSearchExtension,
4
6
  DefaultResourceLoader,
5
7
  ModelRuntime,
6
8
  SessionManager,
@@ -9,12 +11,13 @@ import {
9
11
  type ResourceLoader,
10
12
  } from "@earendil-works/pi-coding-agent";
11
13
  import type { Model, Provider } from "@earendil-works/pi-ai";
12
- import type { AgentSession, Skill, ToolDefinition } from "@earendil-works/pi-coding-agent";
13
- import { mkdirSync, readFileSync } from "node:fs";
14
+ import type { AgentSession, ExtensionFactory, Skill, ToolDefinition } from "@earendil-works/pi-coding-agent";
15
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
16
  import { createRequire } from "node:module";
15
- import { dirname, join } from "node:path";
17
+ import { dirname, join, resolve } from "node:path";
16
18
  import { fileURLToPath, pathToFileURL } from "node:url";
17
- import type { MemoryProvider } from "../../shared/types.ts";
19
+ import type { ClaudeCodeSettings, MemoryProvider } from "../../shared/types.ts";
20
+ import { claudeStatus } from "./claude-status.ts";
18
21
  import type { ThinkingLevel } from "./config.ts";
19
22
  import { MEMORY_TOOL_PATTERNS } from "./memory.ts";
20
23
 
@@ -39,9 +42,13 @@ export interface OpenSessionOptions {
39
42
  customTools: ToolDefinition<any, any>[];
40
43
  /** 这一轮用哪个记忆提供者:决定加载哪个捆绑插件(off = 两个都不加载) */
41
44
  memory?: MemoryProvider;
45
+ /** 任务会话加载 MCP 服务(引擎目录的 mcp.json);对话会话不加载。 */
46
+ mcp?: boolean;
42
47
  /** 任务使用的技能;对话会话不加载。 */
43
48
  skills?: Skill[];
44
49
  disabledSkills?: string[];
50
+ /** Router 大结果落盘目录;任务不传。 */
51
+ spillDir?: string;
45
52
  }
46
53
 
47
54
  export interface OpenedSession {
@@ -53,8 +60,8 @@ export interface OpenedSession {
53
60
  * pi 的 AgentSession 在构建时就把系统提示词算进缓存,之后 ResourceLoader 现读 store 也用不上,
54
61
  * 所以要每轮开始前调一次:重设一遍当前工具集会让它按 loader 重算提示词,下一轮生效。
55
62
  */
56
- export function refreshSystemPrompt(session: AgentSession): void {
57
- session.setActiveToolsByName(session.getActiveToolNames());
63
+ export function refreshSystemPrompt(session: AgentSession, tools = session.getActiveToolNames()): void {
64
+ session.setActiveToolsByName(tools);
58
65
  }
59
66
 
60
67
  const requireFromHere = createRequire(import.meta.url);
@@ -100,7 +107,8 @@ function emptyLoader(systemPrompt: () => string): ResourceLoader {
100
107
  }
101
108
 
102
109
  /**
103
- * 记忆插件互斥断言 + 本地记忆的工具修剪。
110
+ * 记忆插件互斥断言 + 本地记忆的工具修剪。只看捆绑插件(additionalExtensionPaths):
111
+ * 内联工厂(MCP、按需搜索)不是记忆插件,直接放行。
104
112
  *
105
113
  * 一次只加载一家的插件:万一哪天两条路径同时挂上,这里立刻报错,而不是把用户的记忆
106
114
  * 分裂成两份。pi-memory 没带 qmd,`memory_search` 只会报错,所以在注册前就去掉。
@@ -109,9 +117,11 @@ export function filterMemoryExtensions(
109
117
  result: LoadExtensionsResult,
110
118
  provider: MemoryProvider,
111
119
  ): LoadExtensionsResult {
112
- if (result.extensions.length > 1) throw new Error("记忆插件互斥:一次只能加载一个记忆插件。");
120
+ const isInline = (extension: LoadExtensionsResult["extensions"][number]) => extension.path.startsWith("<inline:");
121
+ const bundled = result.extensions.filter((extension) => !isInline(extension));
122
+ if (bundled.length > 1) throw new Error("记忆插件互斥:一次只能加载一个记忆插件。");
113
123
  const families = new Set<MemoryProvider>();
114
- for (const extension of result.extensions) {
124
+ for (const extension of bundled) {
115
125
  for (const name of extension.tools?.keys() ?? []) {
116
126
  if (PI_MEMORY_TOOLS.test(name)) families.add("local");
117
127
  else if (name.startsWith("lore_")) families.add("lore");
@@ -122,13 +132,13 @@ export function filterMemoryExtensions(
122
132
  if (provider === "off" ? loaded !== undefined : loaded !== provider) {
123
133
  throw new Error(`记忆插件与当前记忆来源不一致:期望 ${provider},实际 ${loaded ?? "无"}。`);
124
134
  }
125
- const extensions = result.extensions.map((extension) => {
135
+ const extensions = bundled.map((extension) => {
126
136
  if (!extension.tools?.has("memory_search")) return extension;
127
137
  const tools = new Map(extension.tools);
128
138
  tools.delete("memory_search");
129
139
  return { ...extension, tools };
130
140
  });
131
- return { ...result, extensions };
141
+ return { ...result, extensions: [...result.extensions.filter(isInline), ...extensions] };
132
142
  }
133
143
 
134
144
  /**
@@ -143,6 +153,7 @@ export class PiHost {
143
153
  /** 服务商套进引擎(设 Key、注册兼容接口);引擎建好、返回之前调一次。 */
144
154
  configure?: (runtime: ModelRuntime) => Promise<void>;
145
155
  private runtimePromise?: Promise<ModelRuntime>;
156
+ private claudeFactory?: ExtensionFactory;
146
157
  /** Lore 插件在 resources_discover 中注册的目录。 */
147
158
  loreSkillPaths: string[] = [];
148
159
 
@@ -167,6 +178,35 @@ export class PiHost {
167
178
  return this.runtimePromise;
168
179
  }
169
180
 
181
+ /** 会话尚未打开时先注册桥的模型目录,之后会话复用同一扩展模块。 */
182
+ async configureClaude(runtime: ModelRuntime, home: string, config: ClaudeCodeSettings | undefined): Promise<void> {
183
+ const status = config?.enabled ? await claudeStatus(home, config.executable || undefined) : undefined;
184
+ if (!config?.enabled || !status?.found || !status.loggedIn || !status.executable) {
185
+ this.claudeFactory = undefined;
186
+ runtime.unregisterProvider("claude-bridge");
187
+ return;
188
+ }
189
+ mkdirSync(this.engineDir, { recursive: true });
190
+ writeFileSync(join(this.engineDir, "claude-bridge.json"), JSON.stringify({
191
+ provider: { plan: config.plan, strictMcpConfig: true, autoMemoryEnabled: false, pathToClaudeCodeExecutable: status.executable },
192
+ askClaude: { enabled: false },
193
+ }, null, 2) + "\n", { mode: 0o600 });
194
+ const path = new URL("../../vendor/claude-bridge/src/index.ts", import.meta.url).href;
195
+ const factory = (await import(path)).default as ExtensionFactory;
196
+ const loader = new DefaultResourceLoader({
197
+ cwd: home, agentDir: this.engineDir, settingsManager: SettingsManager.inMemory(),
198
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
199
+ extensionFactories: [factory],
200
+ });
201
+ await loader.reload();
202
+ const loaded = loader.getExtensions();
203
+ if (loaded.errors.length) throw new Error(`Claude Code 扩展加载失败:${loaded.errors[0].error}`);
204
+ const registration = loaded.runtime.pendingProviderRegistrations.find((entry) => entry.name === "claude-bridge");
205
+ if (!registration) throw new Error("Claude Code 扩展未注册模型。");
206
+ runtime.registerProvider(registration.name, registration.config);
207
+ this.claudeFactory = factory;
208
+ }
209
+
170
210
  /** 设置页先于任何会话打开时,也通过插件自己的发现流程取得目录。 */
171
211
  async discoverLoreSkills(): Promise<void> {
172
212
  const pluginDir = dirname(bundledPluginEntry(MEMORY_PACKAGES.lore));
@@ -200,7 +240,7 @@ export class PiHost {
200
240
  settingsManager: SettingsManager,
201
241
  ): Promise<ResourceLoader> {
202
242
  const provider = options.memory ?? "off";
203
- const pluginPath = provider === "off" ? undefined : bundledPluginEntry(MEMORY_PACKAGES[provider]);
243
+ const pluginPath = provider === "off" ? undefined : provider === "lore" ? fileURLToPath(new URL("./lore-entry.ts", import.meta.url)) : bundledPluginEntry(MEMORY_PACKAGES.local);
204
244
  if (provider === "local") {
205
245
  // 会话被频繁回收,退出总结只会白花一次模型调用(和参考产品一致)
206
246
  process.env.PI_MEMORY_EXIT_SUMMARY = "0";
@@ -215,6 +255,36 @@ export class PiHost {
215
255
  noThemes: true,
216
256
  noContextFiles: true,
217
257
  additionalExtensionPaths: pluginPath ? [pluginPath] : [],
258
+ // 任务会话的 MCP:读引擎目录的 mcp.json,会话启动(bindExtensions)时连服务。
259
+ // 按需搜索(deferred)的服务靠 tool_search 加载,codemode 不提供:不加载那个扩展。
260
+ extensionFactories: [
261
+ ...(this.claudeFactory ? [this.claudeFactory] : []),
262
+ ...(options.mcp ? [createMcpExtension(), createToolSearchExtension()] : []),
263
+ ...(options.spillDir ? [((pi) => {
264
+ pi.on("tool_result", async (event) => {
265
+ if (event.toolName === "read") return;
266
+ const full = event.content.filter((block) => block.type === "text").map((block) => block.text).join("");
267
+ if (full.length <= 4000) return;
268
+ const dir = resolve(options.spillDir!);
269
+ const file = join(dir, `${event.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "") || "tool"}.txt`);
270
+ try {
271
+ mkdirSync(dir, { recursive: true });
272
+ writeFileSync(file, full);
273
+ } catch (error) {
274
+ console.error(`工具结果落盘失败:${file}`, error);
275
+ return;
276
+ }
277
+ let first = true;
278
+ const content = event.content.flatMap<(typeof event.content)[number]>((block) => {
279
+ if (block.type !== "text") return [block];
280
+ if (!first) return [];
281
+ first = false;
282
+ return [{ type: "text" as const, text: `${full.slice(0, 4000)}\n\n(共 ${full.length} 字,这里是前 4,000 字;全文:${file})` }];
283
+ });
284
+ return { content };
285
+ });
286
+ }) satisfies ExtensionFactory] : []),
287
+ ],
218
288
  extensionsOverride: (result) => filterMemoryExtensions(result, provider),
219
289
  });
220
290
  await base.reload();
@@ -225,10 +295,11 @@ export class PiHost {
225
295
  getSkills: () => {
226
296
  if (!options.skills) return { skills: [], diagnostics: [] };
227
297
  const discovered = base.getSkills();
298
+ const assistant = options.skills.filter((skill) => skill.sourceInfo.source === "assistant");
228
299
  const own = options.skills.filter((skill) => skill.sourceInfo.source === "hob");
229
- const common = options.skills.filter((skill) => skill.sourceInfo.source !== "hob");
300
+ const common = options.skills.filter((skill) => skill.sourceInfo.source !== "assistant" && skill.sourceInfo.source !== "hob");
230
301
  const seen = new Set<string>();
231
- const skills = [...own, ...discovered.skills, ...common].filter((skill) => {
302
+ const skills = [...assistant, ...own, ...discovered.skills, ...common].filter((skill) => {
232
303
  if (discovered.skills.includes(skill) && options.disabledSkills?.includes(`lore:${skill.name}`)) return false;
233
304
  if (seen.has(skill.name)) return false;
234
305
  seen.add(skill.name);
@@ -248,7 +319,10 @@ export class PiHost {
248
319
  mkdirSync(dirname(options.file), { recursive: true });
249
320
  const model = await this.model(options.model);
250
321
  const manager = SessionManager.open(options.file, dirname(options.file), options.cwd);
251
- const settingsManager = SettingsManager.inMemory({ compaction: { enabled: true }, retry: { enabled: true } });
322
+ const modelOverrides = options.spillDir ? Object.fromEntries((await this.runtime()).getAllModels().filter((item) => "contextWindow" in item).map((item) => [
323
+ `${item.provider}/${item.id}`, { reserveTokens: Math.max(16_384, item.contextWindow - 100_000) },
324
+ ])) : undefined;
325
+ const settingsManager = SettingsManager.inMemory({ compaction: { enabled: true, ...(modelOverrides ? { modelOverrides } : {}) }, retry: { enabled: true } });
252
326
  const resourceLoader = await this.resourceLoader(options, settingsManager);
253
327
  const memoryTools = MEMORY_TOOL_PATTERNS[options.memory ?? "off"];
254
328
  const { session } = await createAgentSession({
@@ -263,10 +337,12 @@ export class PiHost {
263
337
  tools: [...options.tools, ...memoryTools],
264
338
  customTools: options.customTools,
265
339
  });
266
- // resources_discover 由 bindExtensions 触发;仅注册技能目录,不加载对话层技能。
340
+ // resources_discover 由 bindExtensions 触发;Lore 注册技能目录,MCP 连服务(都在后台)
267
341
  if (options.memory === "lore") {
268
342
  this.loreSkillPaths = [];
269
343
  await session.bindExtensions({ mode: "print" });
344
+ } else if (options.mcp || this.claudeFactory) {
345
+ await session.bindExtensions({ mode: "print" });
270
346
  }
271
347
  return { session, manager };
272
348
  }
@@ -1,128 +1,83 @@
1
1
  import { homedir } from "node:os";
2
- import { PROP_IDS, PROPS } from "../../shared/sprites.ts";
3
2
  import type { GeneralSettings, MemoryProvider, ThinkingLevel } from "../../shared/types.ts";
4
3
  import type { Store } from "./store.ts";
5
4
  import { cronToChinese } from "./cron-text.ts";
6
5
  import { memoryPromptLines } from "./memory.ts";
7
- import { nowText } from "./time.ts";
8
6
 
9
- /** 提示词里列的模型:助手能派其中一个。 */
10
7
  export interface PromptModel {
11
8
  name: string;
12
9
  ref: string;
13
- note: string; // 用途说明
14
- thinking: ThinkingLevel; // 默认思考档
15
- levels?: ThinkingLevel[]; // 模型可用的思考档
16
- isDefault: boolean; // 不指定模型时用它
10
+ note: string;
11
+ thinking: ThinkingLevel;
12
+ levels?: ThinkingLevel[];
13
+ isDefault: boolean;
17
14
  }
18
15
 
19
- /** 派出去的活能做什么(助手设置 → 能力)。 */
20
16
  export interface Abilities {
21
17
  exec: boolean;
22
18
  write: boolean;
23
19
  image: boolean;
24
20
  }
25
21
 
26
- /** 提示词里要用的设置:时区、「关于你」、记忆提供者、模型、能力。 */
27
22
  export interface PromptSettings {
28
23
  timezone: string;
29
24
  aboutYou: GeneralSettings["aboutYou"];
30
25
  memory: MemoryProvider;
31
- /** 这个助手能派的模型(Router 用) */
32
26
  models?: PromptModel[];
33
27
  skills?: { name: string; description: string }[];
28
+ assistantSkills?: { name: string; title: string; description: string; version: number; updatedAt: string }[];
29
+ assistantSkillsDir?: string;
30
+ knowledgeNames?: string[];
34
31
  thinkingChoice?: "auto" | "fixed";
35
- /** 任务能做什么(Worker 用) */
36
32
  abilities?: Abilities;
37
33
  }
38
34
 
39
- /** 「关于你」一节:三行里有哪行填了才出哪行,全空就整节不出。 */
40
- function aboutLines(aboutYou: GeneralSettings["aboutYou"]): string[] {
35
+ function aboutSection(aboutYou: GeneralSettings["aboutYou"]): string[] {
41
36
  const lines: string[] = [];
42
37
  if (aboutYou.name) lines.push(`- 称呼:${aboutYou.name}`);
43
38
  if (aboutYou.language) lines.push(`- 语言:${aboutYou.language}`);
44
39
  if (aboutYou.notes) lines.push(`- 其他:${aboutYou.notes}`);
45
- return lines;
46
- }
47
-
48
- function aboutSection(aboutYou: GeneralSettings["aboutYou"]): string[] {
49
- const lines = aboutLines(aboutYou);
50
- return lines.length ? ["", "## 关于用户", ...lines] : [];
51
- }
52
-
53
- /** 「可以派的模型」:只有一个时整节只一行。 */
54
- function modelsSection(models: PromptModel[], fixed: boolean): string[] {
55
- if (!models.length) return [];
56
- const lines = ["", "## 可以派的模型", "聪明、成本、速度都是 0–100:越高越聪明、越贵、越快。按任务难度、可接受的花费和要多快来挑;简单的活别派给又贵又慢的模型。"];
57
- if (fixed) {
58
- for (const model of models) {
59
- lines.push(`- ${model.ref}${model.isDefault ? "(默认)" : ""}${model.note ? ` · ${model.note}` : ""} · 思考档 ${model.thinking}`);
60
- }
61
- if (models.length > 1) lines.push("按任务难度和类型选择:start_job 的 model 填括号里的 ref;不确定就不填,使用默认值。");
62
- return lines;
63
- }
64
- if (models.length === 1) {
65
- const only = models[0];
66
- lines.push(
67
- `- ${only.ref}${only.isDefault ? "(默认)" : ""}${only.note ? ` · ${only.note}` : ""} · 思考档默认 ${only.thinking},可选 ${(only.levels ?? [only.thinking]).join("、")}:start_job 不用填 model,需要更强推理时填 thinking。`,
68
- );
69
- return lines;
70
- }
71
- for (const model of models) {
72
- lines.push(
73
- `- ${model.ref}${model.isDefault ? "(默认)" : ""}${model.note ? ` · ${model.note}` : ""} · 思考档默认 ${model.thinking},可选 ${(model.levels ?? [model.thinking]).join("、")}`,
74
- );
75
- }
76
- lines.push("按任务难度和类型选择:start_job 的 model 填括号里的 ref,thinking 按需要填;不确定就不填,使用默认值。");
77
- return lines;
40
+ return lines.length ? ["", "## 用户", ...lines] : [];
78
41
  }
79
42
 
80
- /** 「你能做什么」:把助手开关关掉的能力明确写成不能做。 */
81
- function abilitiesSection(abilities: Abilities): string[] {
82
- const can: string[] = ["读文件(read、grep、find、ls)"];
83
- const cannot: string[] = [];
84
- (abilities.exec ? can : cannot).push("执行命令(bash)");
85
- (abilities.write ? can : cannot).push("改文件(edit、write)");
86
- (abilities.image ? can : cannot).push("生图(generate_image)");
87
- const lines = ["", "## 你能做什么", `- 能做:${can.join("、")}`];
88
- if (cannot.length) {
89
- lines.push(`- 不能做:${cannot.join("、")}。确需该能力时用 report(stuck) 说明缺少什么,不要勉强完成。`);
90
- }
91
- return lines;
92
- }
93
-
94
- /** Router 的系统提示词:每轮现读 store,所以改了规矩/补充说明下一轮就生效。 */
95
43
  export function buildSystemPrompt(store: Store, assistantId: string, settings: PromptSettings): string {
96
44
  const assistant = store.assistant(assistantId);
97
45
  if (!assistant) throw new Error(`助手不存在:${assistantId}`);
98
46
  const rules = store.rules(assistantId);
99
47
  const routines = store.routines().filter((routine) => routine.assistantId === assistantId);
100
48
  const others = store.assistants().filter((item) => item.id !== assistantId && !item.hidden);
101
-
102
- const lines: string[] = [
103
- `你是「${assistant.name}」,用户的${assistant.primary ? "主助手" : "助手"}。`,
104
- `职责:${assistant.duty}`,
49
+ const lines = assistant.primary ? [
50
+ `你是「${assistant.name}」,用户的主助手。`,
51
+ "你和用户在一对一的对话里合作,用户的每条消息都是对你说的。",
52
+ ] : [
53
+ `你是「${assistant.name}」,用户的助手,负责${assistant.duty}。`,
105
54
  ...(assistant.dutyLong?.trim() ? [`详细职责:${assistant.dutyLong.trim()}`] : []),
106
- `现在时间:${nowText(settings.timezone)}`,
55
+ "你和用户在一对一的对话里合作。主助手 Hob 会把归你管的事转给你:转来的消息带着用户的原话,等同用户本人的要求,结果会自动回到 Hob。",
107
56
  ];
108
-
109
- lines.push(...aboutSection(settings.aboutYou));
110
- lines.push(...modelsSection(settings.models ?? [], settings.thinkingChoice === "fixed"));
111
- if (settings.skills?.length) {
112
- lines.push("", "## 任务可用的技能", ...settings.skills.map((skill) => `- ${skill.name}:${skill.description}`));
57
+ lines.push(
58
+ "用简洁、规范的书面中文回复,先给结论。",
59
+ "", "## 你手里有什么",
60
+ "你能直接看:文件和图片、任务的过程和结果、产出、网页、记忆。",
61
+ "跑命令、改文件、操作设备这些动手的事交给后台任务。任务是你手上的活,在自己的会话和目录里做,做完把完整结果交给你;对用户负责的是你。",
62
+ "任务做完还能接着做,补充和纠正交给原来那个任务,它接得上之前的上下文。",
63
+ );
64
+ if (settings.assistantSkills?.length) {
65
+ lines.push(`你的技能是做同类事的做法,派活时可以让任务照着做。文件在 ${settings.assistantSkillsDir}/<名字>/SKILL.md:`);
66
+ const format = new Intl.DateTimeFormat("sv-SE", { timeZone: settings.timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
67
+ for (const skill of settings.assistantSkills) lines.push(`- ${skill.name}「${skill.title}」· 第 ${skill.version} 版 · ${format.format(new Date(skill.updatedAt)).slice(5)} 更新:${skill.description}`);
113
68
  }
114
-
69
+ if (settings.knowledgeNames?.length) lines.push(`能查的知识库:${settings.knowledgeNames.join("、")}。问到可能写在这些库里的内容,先用 search_knowledge 查。`);
70
+ if (settings.skills?.length) lines.push(`任务能用的技能:${settings.skills.map((skill) => skill.name).join("、")}`);
71
+ lines.push(...aboutSection(settings.aboutYou));
115
72
  if (rules.length) {
116
- lines.push("", "## 你的规则");
73
+ lines.push("", "## 用户定的规则");
117
74
  for (const rule of rules) lines.push(`- ${rule.mode === "ask" ? "执行前确认" : "直接执行"}:${rule.text}`);
118
75
  }
119
- if (assistant.instructions.trim()) {
120
- lines.push("", "## 补充说明", assistant.instructions.trim());
121
- }
76
+ if (assistant.instructions.trim()) lines.push("", "## 补充说明", assistant.instructions.trim());
122
77
  const memory = memoryPromptLines(settings.memory);
123
78
  if (memory.length) lines.push("", ...memory);
124
79
  if (routines.length) {
125
- lines.push("", "## 你的定时");
80
+ lines.push("", "## 定时");
126
81
  for (const routine of routines) {
127
82
  const when = routine.schedules.map(cronToChinese).join("、");
128
83
  lines.push(`- ${routine.name} · ${when}${routine.active ? "" : "(暂停)"}:${routine.instruction}`);
@@ -130,100 +85,40 @@ export function buildSystemPrompt(store: Store, assistantId: string, settings: P
130
85
  }
131
86
  if (assistant.primary && others.length) {
132
87
  lines.push("", "## 其他助手");
133
- for (const other of others) {
134
- const ask = store.rules(other.id).filter((rule) => rule.mode === "ask").map((rule) => rule.text);
135
- lines.push(`- ${other.name}(${other.id}):${other.duty}${other.dutyLong?.trim() ? `。详细职责:${other.dutyLong.trim()}` : ""}${ask.length ? `。执行前要确认:${ask.join(";")}` : ""}`);
136
- }
137
- lines.push("转交前先对照详细职责挑最合适的助手;没有合适的就自己派任务,或者提议创建新助手。");
138
- }
139
-
140
- if (!assistant.primary) {
141
- lines.push("", "## 转来的事", "带「转自 Hob」的消息是主助手替用户转来的,等同于用户本人的要求:照着去做,需要执行就直接 start_job,不要只回复计划;做完不用另外给 Hob 发消息,结果会自动回到 Hob。", "", "## 不归你管的事", "主助手 Hob 负责在各助手之间分派。用户的要求明显超出你的职责时,调用 hand_back_to_hob 交回 Hob,不要硬做;在你职责内的事照常自己做。");
142
- }
143
-
144
- lines.push(
145
- "",
146
- "## 工作方式",
147
- "- 说要做的事,就在同一条消息里调用对应工具去做;不要只描述计划然后停下等用户催。只有规则要求确认或操作不可逆时才停下来问。",
148
- "- 用简洁、规范的书面中文回复,直接给出结论,不要复述步骤、不要罗列清单。",
149
- "- 你不能亲自执行任何操作:没有 bash、read、edit、write 这些工具(联网搜索除外)。需要执行任务时调用 start_job 交给后台任务,任务完成后会有事件回到这里。",
150
- "- 需要最新信息或核对事实时,直接用 web_search / web_fetch 查,不必为此派任务;要多步调研、写文件或跑命令时再用 start_job。",
151
- "- 任务本身就是带完整工具的编码 agent:写代码、改项目、查文件都是普通任务,用 start_job 的 cwd 指定任务的工作目录。",
152
- "- 只有规则标为「执行前确认」,或者操作不可逆(发送、删除、付费)时,才用 ask_user 请用户确认。",
153
- "- 会产生卡片的工具(start_job、ask_user、propose_assistant):先在同一条消息里写一句话(要做什么、等用户确认什么),再调用工具。这些工具会返回“本轮到此结束”:卡片已经出现在对话里,内容齐全,调用完立即结束这一轮,不要再补任何文字(“草稿卡在上面了”“点创建就成”这类话都是重复的废话)。",
154
- "- 用 ask_user 时,如果同类操作以后很可能还会遇到(日常的常规操作;发布、删除、付费这类不可逆的事除外),第三个选项写“以后同类操作直接执行,无需确认”(always),并填一句很短的 ruleText,如“清理临时目录”。",
155
- "- 用户说“以后”“别再”这类要求时,用 set_rule 记入规则,不要只做口头承诺。",
156
- ...(assistant.primary
157
- ? [
158
- "- 用户要早报、日报,或者问“昨晚/今天都做了什么”时,先调 overview 看各助手这段时间的日志和任务,再照实写一两句汇总,不要凭印象编造。",
159
- ]
160
- : []),
161
- );
162
-
163
- if (assistant.primary) {
164
- lines.push(
165
- "- 同类任务在对话中反复出现(同一件事被要求 3 次以上),就用 propose_assistant 提议创建专属助手。",
166
- "- propose_assistant 的 duty 是左栏头像下面那行小字:最多 8 个字,如「线上和机器」「模型和 agent」;更详细的职责写在 dutyLong(草稿卡里的「职责」行)。",
167
- `- propose_assistant 的 sprite 按职责选:${PROP_IDS.filter((id) => id !== "hob")
168
- .map((id) => `${id} ${PROPS[id].label}`)
169
- .join(",")};hob 那个火苗是主助手专用的,不要用。`,
170
- "- 库里没有贴合职责的道具时,可以自己画一件:sprite 填 custom,prop_art 给出 art、pal、dx 画一件 16×16 的像素道具;画得不合适会返回原因,照着改好重发就行。",
171
- "- 有更合适的助手时用 message_assistant 把任务转过去。",
172
- "- 转交时把用户原话转过去,只补充对方需要的背景。不要替用户加验收标准、测试方案或额外要求,把小事写成大工程。",
173
- "- 转交出去的事由对方负责到底。结果回来之前,不要自己 start_job 做同一件事。结果回来后如果没办完,转回同一个助手接着办,或者如实告诉用户。",
174
- );
88
+ for (const other of others) lines.push(`- ${other.name}(${other.id}):${other.duty}。${other.dutyLong?.trim() ?? ""}`);
175
89
  }
176
- lines.push("- 用户问你在做什么、有什么在运行,可依据任务事件回答,不要编造进度。");
177
- lines.push(
178
- "- 最后重申:start_job、ask_user、propose_assistant 返回“本轮到此结束”后,你说的下一个字都是多余的,直接结束回合。",
179
- );
180
90
  return lines.join("\n");
181
91
  }
182
92
 
183
- /** Worker 的系统提示词:所属助手、要问的事、补充说明、任务说明、必须 report 收尾。 */
184
93
  export function buildWorkerPrompt(store: Store, jobId: string, settings: PromptSettings): string {
185
94
  const job = store.job(jobId);
186
95
  if (!job) throw new Error(`任务不存在:${jobId}`);
187
96
  const assistant = store.assistant(job.assistantId);
97
+ const name = assistant?.name ?? job.assistantId;
188
98
  const askFirst = store.rules(job.assistantId).filter((rule) => rule.mode === "ask");
189
- const lines: string[] = [
190
- `你在后台执行「${assistant?.name ?? job.assistantId}」的任务。`,
191
- `职责:${assistant?.duty ?? ""}`,
192
- `现在时间:${nowText(settings.timezone)}`,
99
+ const lines = [
100
+ `你是「${name}」助手派出的后台任务,在自己的会话里把交给你的事做完。`,
101
+ `工作目录:${job.cwd ?? homedir()}。产出放在这里;读取不限目录。`,
102
+ `交给你的消息里有用户的原话、最近的对话、附件和「${name}」的备注。`,
103
+ `做完调用 report:result 写完整结果,包括结论、数据、链接和文件路径。它会交给「${name}」,由它告诉用户。`,
104
+ "之后用户可能还会找你补充、纠正或追问,这些都会发到这个会话里;接着做完,再调用 report。",
193
105
  ];
106
+ if (settings.knowledgeNames?.length) lines.push(`能查的知识库:${settings.knowledgeNames.join("、")}。问到可能写在这些库里的内容,先用 search_knowledge 查。`);
107
+ if (settings.abilities) {
108
+ const cannot = [
109
+ ...(!settings.abilities.exec ? ["执行命令(bash)"] : []),
110
+ ...(!settings.abilities.write ? ["改文件(edit、write)"] : []),
111
+ ...(!settings.abilities.image ? ["生图(generate_image)"] : []),
112
+ ];
113
+ if (cannot.length) lines.push(`不能做:${cannot.join("、")}。`);
114
+ }
194
115
  lines.push(...aboutSection(settings.aboutYou));
195
- if (settings.abilities) lines.push(...abilitiesSection(settings.abilities));
196
116
  if (askFirst.length) {
197
- lines.push("", "## 这些事执行前必须用 ask_user 询问用户");
117
+ lines.push("", "## 这些事执行前用 ask_user 问用户");
198
118
  for (const rule of askFirst) lines.push(`- ${rule.text}`);
199
119
  }
200
- if (assistant?.instructions.trim()) {
201
- lines.push("", "## 补充说明", assistant.instructions.trim());
202
- }
120
+ if (assistant?.instructions.trim()) lines.push("", "## 补充说明", assistant.instructions.trim());
203
121
  const memory = memoryPromptLines(settings.memory);
204
122
  if (memory.length) lines.push("", ...memory);
205
- if (assistant?.primary) {
206
- lines.push(
207
- "",
208
- "## 早报",
209
- "- 用户要早报、日报或汇总“昨晚/今天都做了什么”时,先调 overview(sinceHours) 拿各助手这段时间的日志和任务,再照实写汇总。",
210
- );
211
- }
212
- lines.push(
213
- "",
214
- `## 任务:${job.title}`,
215
- job.instruction,
216
- ...(job.inputs?.length ? ["", "## 你收到的文件", ...job.inputs.map((file) => `- ${file}`)] : []),
217
- "",
218
- `工作目录:${job.cwd ?? homedir()}`,
219
- "",
220
- "## 要求",
221
- "- 产出文件只写在工作目录里:新建的文件、下载的内容、导出的结果都放在这个目录,不要写到用户主目录或其他位置。",
222
- "- 读取不受目录限制:可读取任何位置的文件作参考,只是不要写入这些位置。",
223
- "- 要给用户看的文件放进 report 的 files(绝对路径,文件本身在工作目录里)。",
224
- "- 完成后必须调用 report 提交结果(outcome:已完成 / 受阻 / 待确认),不调用视为未完成。",
225
- "- summary 用中文写给用户:一两句结论 + 关键数字,不要罗列过程。",
226
- "- 受阻时调用 report(stuck) 说明受阻原因;要做不该做的、无法撤回的事就调用 ask_user。",
227
- );
228
123
  return lines.join("\n");
229
124
  }