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
@@ -2,7 +2,7 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
2
2
  import { parseStreamingJson } from "@earendil-works/pi-ai/utils/json-parse";
3
3
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { mkdirSync } from "node:fs";
5
- import { dirname } from "node:path";
5
+ import { dirname, join, relative, resolve, sep } from "node:path";
6
6
  import type { AgentSession, AgentSessionEvent, SessionEntry, SessionManager } from "@earendil-works/pi-coding-agent";
7
7
  import { SessionManager as SessionManagerImpl } from "@earendil-works/pi-coding-agent";
8
8
  import type {
@@ -21,21 +21,28 @@ import type { HobRuntime } from "./runtime.ts";
21
21
  import { refreshSystemPrompt } from "./pi.ts";
22
22
  import { conversationPick, defaultWorkRef, jobModel, nameOf, noteOf, thinkingForPick, thinkingLevelsOf, fitThinking, usableRefs, NO_MODEL_NOTE, NoModelError } from "./models.ts";
23
23
  import { buildRouterTools } from "./router-tools.ts";
24
+ import { boundKnowledge, formatKnowledge, KNOWLEDGE_INTRO } from "./knowledge-tools.ts";
24
25
  import { MEMORY_WRITE_TOOLS, memoryNoteText } from "./memory.ts";
25
26
  import { buildSystemPrompt, type PromptModel } from "./prompt.ts";
26
27
  import { availableSkills } from "./skills.ts";
28
+ import { usableAssistantSkills } from "./assistant-skills.ts";
27
29
  import { activityLabel, thinkingLine } from "./router-activity.ts";
30
+ import { imageContents } from "./jobs.ts";
31
+ import { nowBlock } from "./now.ts";
32
+ import { monthDayTimeText } from "./time.ts";
28
33
 
29
34
  export const ITEM_TYPE = "hob.item";
35
+ export const ITEM_UPDATE_TYPE = "hob.item.update";
30
36
  export const USER_TYPE = "hob.user";
31
37
  export const EVENT_TYPE = "hob.event";
32
38
 
33
39
  /** 会话条目的 id 和 at 由会话文件给出,写卡片时不用传。 */
34
40
  /** 事件附带的信息,落在会话的事件条目上:routine 给回复挂「⏱ 名字」,cont 见 walk。 */
35
41
  export interface EventDetails {
36
- routine?: { id: string; name: string };
42
+ routine?: { id: string; name: string; jobId?: string };
37
43
  cont?: true;
38
44
  delegationId?: string;
45
+ sourceUserId?: string;
39
46
  }
40
47
 
41
48
  export type NewItem = Item extends infer T ? (T extends Item ? Omit<T, "id" | "at"> : never) : never;
@@ -65,6 +72,10 @@ interface RouterState {
65
72
  /** 还在跑的记忆写入工具:参数只在 tool_execution_start 里有,按 toolCallId 存着给 note 用 */
66
73
  memoryWriteArgs: Map<string, Record<string, unknown>>;
67
74
  currentDelegationId?: string;
75
+ currentUserId?: string;
76
+ pendingUsers?: { prompt: string; id: string }[];
77
+ pendingDelivery?: Promise<void>;
78
+ knowledgeNames?: string[];
68
79
  delegationReply?: string[];
69
80
  terminatingTools?: Set<string>;
70
81
  }
@@ -113,7 +124,7 @@ export function briefModelError(raw: string | undefined): string {
113
124
  function walk(entries: SessionEntry[]): { items: Item[]; lead: boolean } {
114
125
  const items: Item[] = [];
115
126
  let assistantIndex = 0;
116
- let routine: { id: string; name: string } | undefined;
127
+ let routine: EventDetails["routine"];
117
128
  let lead = false;
118
129
  const push = (item: Item) => {
119
130
  items.push(lead ? { ...item, lead: true } : item);
@@ -140,6 +151,12 @@ function walk(entries: SessionEntry[]): { items: Item[]; lead: boolean } {
140
151
  push({ ...(entry.data as object), id: entry.id, at: entry.timestamp } as Item);
141
152
  continue;
142
153
  }
154
+ if (entry.type === "custom" && entry.customType === ITEM_UPDATE_TYPE) {
155
+ const update = entry.data as { id: string; status: "done" | "failed"; path?: string; error?: string };
156
+ const index = items.findIndex((item) => item.id === update.id && item.kind === "image");
157
+ if (index !== -1) items[index] = { ...items[index], ...update } as Item;
158
+ continue;
159
+ }
143
160
  if (entry.type === "custom_message" && entry.customType === EVENT_TYPE) {
144
161
  const details = entry.details as EventDetails | undefined;
145
162
  routine = details?.routine;
@@ -202,7 +219,16 @@ export class RouterManager {
202
219
  /** 测试可改小:不给就看设置里的“回复多久无响应视为受阻” */
203
220
  stallOverrideMs?: number;
204
221
 
205
- constructor(private readonly runtime: HobRuntime) {}
222
+ constructor(private readonly runtime: HobRuntime) {
223
+ for (const assistant of runtime.store.assistants()) {
224
+ const file = runtime.store.routerFile(assistant.id);
225
+ if (!existsSync(file)) continue;
226
+ const pending = deriveItems(readEntries(file)).filter((item) => item.kind === "image" && item.status === "pending");
227
+ if (!pending.length) continue;
228
+ const manager = SessionManagerImpl.open(file, dirname(file), runtime.home);
229
+ for (const item of pending) manager.appendCustomEntry(ITEM_UPDATE_TYPE, { id: item.id, status: "failed", error: "生成被中断" });
230
+ }
231
+ }
206
232
 
207
233
  private get store() {
208
234
  return this.runtime.store;
@@ -244,8 +270,10 @@ export class RouterManager {
244
270
  // 提示词里要写模型的显示名和定位,所以先把注册表拉一遍
245
271
  await this.runtime.models.get();
246
272
  const pick = conversationPick(this.runtime.settings.settings.models, assistant);
273
+ state.knowledgeNames = (await boundKnowledge(this.runtime, assistant.id)).map((collection) => collection.name);
247
274
  const opened = await this.runtime.pi.open({
248
275
  file: this.store.routerFile(assistant.id),
276
+ spillDir: join(dirname(this.store.routerFile(assistant.id)), "spill"),
249
277
  cwd: this.store.home,
250
278
  model: pick.ref,
251
279
  thinking: thinkingForPick(pick, this.runtime.models),
@@ -258,19 +286,49 @@ export class RouterManager {
258
286
  memory: this.runtime.settings.memory().provider,
259
287
  models: this.promptModels(assistant.id),
260
288
  skills: availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled, this.runtime.settings.memory().provider === "lore" ? this.runtime.pi.loreSkillPaths : []),
289
+ assistantSkills: usableAssistantSkills(this.runtime.home, assistant.id),
261
290
  thinkingChoice: this.runtime.settings.settings.models.thinkingChoice,
291
+ knowledgeNames: state.knowledgeNames,
262
292
  });
263
293
  },
264
294
  tools: buildRouterTools(this.runtime, assistant.id).map((tool) => tool.name),
265
295
  customTools: buildRouterTools(this.runtime, assistant.id),
266
296
  });
267
297
  state.session = opened.session;
298
+ if (!state.knowledgeNames.length) refreshSystemPrompt(opened.session, opened.session.getActiveToolNames().filter((name) => name !== "search_knowledge"));
268
299
  state.manager = opened.manager;
269
300
  state.opening = undefined;
270
301
  opened.session.subscribe((event) => this.onSessionEvent(state, assistant.id, event));
271
302
  this.touch(assistant.id);
272
303
  }
273
304
 
305
+ private async refreshTools(state: RouterState): Promise<void> {
306
+ state.knowledgeNames = (await boundKnowledge(this.runtime, state.assistantId)).map((collection) => collection.name);
307
+ const names = state.session!.getActiveToolNames().filter((name) => name !== "search_knowledge");
308
+ if (state.knowledgeNames.length) names.push("search_knowledge");
309
+ refreshSystemPrompt(state.session!, names);
310
+ }
311
+
312
+ private async automaticKnowledge(state: RouterState, query: string): Promise<string> {
313
+ const ids = this.store.assistant(state.assistantId)?.knowledge ?? [];
314
+ if ([...query.trim()].length < 4 || !ids.length || this.runtime.settings.settings.knowledge?.embedding.source !== "openai") return "";
315
+ this.setActivity(state, { state: "tool", label: "正在查知识库" });
316
+ let timer: ReturnType<typeof setTimeout> | undefined;
317
+ try {
318
+ const results = await Promise.race([
319
+ this.runtime.knowledge.search(query, ids, { limit: 3, gate: true }),
320
+ new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("知识库检索超时")), 3000); }),
321
+ ]);
322
+ const text = formatKnowledge(results, 2400 - [...KNOWLEDGE_INTRO].length - 1);
323
+ return text ? `\n\n${KNOWLEDGE_INTRO}\n${text}` : "";
324
+ } catch {
325
+ return "";
326
+ } finally {
327
+ if (timer) clearTimeout(timer);
328
+ if (state.activity?.label === "正在查知识库") this.setActivity(state, null);
329
+ }
330
+ }
331
+
274
332
  /** 这个助手能派、当前可用的模型(提示词里那一节)。 */
275
333
  private promptModels(assistantId: string): PromptModel[] {
276
334
  const assistant = this.store.assistant(assistantId);
@@ -326,9 +384,17 @@ export class RouterManager {
326
384
  }
327
385
  }
328
386
  if (event.type === "agent_end" || (event.type === "message_end" && event.message.role === "assistant" && ["error", "aborted"].includes(event.message.stopReason))) this.setActivity(state, null);
387
+ if (event.type === "message_start" && event.message.role === "user") {
388
+ const prompt = messageText(event.message.content);
389
+ const index = state.pendingUsers?.findIndex((user) => user.prompt.trim() === prompt) ?? -1;
390
+ if (index >= 0) state.currentUserId = state.pendingUsers!.splice(index, 1)[0].id;
391
+ }
329
392
  if (event.type === "message_start" && event.message.role === "custom" && event.message.customType === EVENT_TYPE) {
330
393
  const details = event.message.details as EventDetails | undefined;
331
- if (details?.delegationId) {
394
+ const users = deriveItems(this.entries(assistantId)).filter((item) => item.kind === "user");
395
+ const source = details?.sourceUserId ? users.find((item) => item.id === details.sourceUserId) : undefined;
396
+ state.currentUserId = (source ?? users.at(-1))?.id;
397
+ if (details?.delegationId && details.delegationId !== state.currentDelegationId) {
332
398
  state.currentDelegationId = details.delegationId;
333
399
  state.delegationReply = [];
334
400
  state.terminatingTools = new Set();
@@ -340,7 +406,7 @@ export class RouterManager {
340
406
  const failed = event.message.stopReason === "error" || event.message.stopReason === "aborted";
341
407
  if (failed || !event.toolResults.length || event.toolResults.every((tool) => state.terminatingTools?.has(tool.toolCallId))) {
342
408
  const id = state.currentDelegationId;
343
- const reply = state.delegationReply?.join("\n") || (failed ? event.message.errorMessage || "目标助手处理失败" : "(没有回复)");
409
+ const reply = state.delegationReply?.join("\n") || (failed ? event.message.errorMessage || "目标助手处理失败" : "");
344
410
  state.currentDelegationId = undefined;
345
411
  state.delegationReply = undefined;
346
412
  state.terminatingTools = undefined;
@@ -528,7 +594,7 @@ export class RouterManager {
528
594
  }
529
595
  }
530
596
 
531
- /** 助手没了(并回 Hob):关掉它的会话,内存里也不留 —— 它的目录随后会被移进归档。 */
597
+ /** 助手没了(归档):关掉它的会话,内存里也不留 —— 它的目录随后会被移进归档。 */
532
598
  forget(assistantId: string): void {
533
599
  const state = this.routers.get(assistantId);
534
600
  if (!state) return;
@@ -555,18 +621,67 @@ export class RouterManager {
555
621
  const id = state.manager!.appendCustomEntry(ITEM_TYPE, item);
556
622
  const full = { ...item, ...(lead ? { lead: true } : {}), id, at: this.entryAt(state, id) } as Item;
557
623
  this.bus.emit({ type: "item", assistantId, item: full });
624
+ if (full.kind === "file") {
625
+ const rel = relative(join(this.runtime.home, "pages"), resolve(full.path));
626
+ const page = rel !== ".." && !rel.startsWith(`..${sep}`);
627
+ void this.runtime.knowledge.collect({ origin: "output", path: full.path, assistantId, itemId: id, ...(page ? { name: full.name, description: full.meta } : {}) }).catch((error) => console.error("知识库收集失败:", error));
628
+ }
558
629
  return full;
559
630
  }
560
631
 
632
+ async updateImage(assistantId: string, id: string, update: { status: "done" | "failed"; path?: string; error?: string }): Promise<void> {
633
+ const state = await this.ensure(assistantId);
634
+ const item = deriveItems(this.entries(assistantId)).find((entry) => entry.id === id && entry.kind === "image");
635
+ if (!item) throw new Error(`图片条目不存在:${id}`);
636
+ state.manager!.appendCustomEntry(ITEM_UPDATE_TYPE, { id, ...update });
637
+ this.bus.emit({ type: "item", assistantId, item: { ...item, ...update } });
638
+ if (item.kind === "image" && update.status === "done" && update.path) {
639
+ void this.runtime.knowledge.collect({ origin: "output", path: update.path, description: item.prompt, assistantId, itemId: id }).catch((error) => console.error("知识库收集失败:", error));
640
+ }
641
+ }
642
+
561
643
  currentDelegation(assistantId: string): string | undefined {
562
644
  return this.routers.get(assistantId)?.currentDelegationId;
563
645
  }
564
646
 
565
- async addForwarded(assistantId: string, text: string, delegationId: string, sourceId = "hob"): Promise<Item> {
647
+ /** 取当前回合来源及其之前的可见对话;事件取最近用户消息。 */
648
+ originalWords(assistantId: string, note?: string, recent = false, extraFiles: string[] = []): { text: string; files: string[] } {
649
+ const items = deriveItems(this.entries(assistantId));
650
+ const state = this.routers.get(assistantId);
651
+ const source = items.find((item) => item.kind === "user" && item.id === state?.currentUserId)
652
+ ?? items.findLast((item) => item.kind === "user");
653
+ const user = source?.kind === "user" ? source : undefined;
654
+ const files = [...new Set([...(user?.files ?? []), ...extraFiles])];
655
+ const timezone = this.runtime.settings.settings.general.timezone;
656
+ const name = this.assistant(assistantId).name;
657
+ const sections: string[] = [];
658
+ if (user) {
659
+ const header = user.from ? `${user.from.name} 转来` : "用户原话";
660
+ const lines = [`${header}(${monthDayTimeText(timezone, new Date(user.at))}):`, user.text];
661
+ if (user.quote) lines.push(`(引用:「${user.quote.text}」)`);
662
+ sections.push(lines.filter(Boolean).join("\n"));
663
+ if (recent) {
664
+ const previous = items.slice(0, items.indexOf(user)).filter((item) => item.kind === "user" || item.kind === "assistant").slice(-6);
665
+ if (previous.length) sections.push(`最近的对话:\n${previous.map((item) => {
666
+ if (item.kind !== "user" && item.kind !== "assistant") return "";
667
+ const clock = new Intl.DateTimeFormat("sv-SE", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(item.at));
668
+ const characters = [...item.text];
669
+ const text = characters.length > 1500 ? `${characters.slice(0, 1500).join("")}…` : item.text;
670
+ return `${item.kind === "user" ? "用户" : name}(${clock}):${text}`;
671
+ }).join("\n")}`);
672
+ }
673
+ }
674
+ if (files.length) sections.push(`附件:${files.join("、")}`);
675
+ if (note) sections.push(`${name} 的备注:${note}`);
676
+ return { text: sections.join("\n\n"), files };
677
+ }
678
+
679
+ async addForwarded(assistantId: string, text: string, delegationId: string, sourceId = "hob", files: string[] = []): Promise<Item> {
566
680
  const state = await this.ensure(assistantId);
567
681
  const from = { assistantId: sourceId, name: this.store.assistant(sourceId)?.name ?? sourceId, delegationId };
568
- const id = state.manager!.appendCustomEntry(USER_TYPE, { text, from });
569
- const item: Item = { kind: "user", id, at: this.entryAt(state, id), text, from };
682
+ const data = { text, from, ...(files.length ? { files } : {}) };
683
+ const id = state.manager!.appendCustomEntry(USER_TYPE, data);
684
+ const item: Item = { kind: "user", id, at: this.entryAt(state, id), ...data };
570
685
  this.bus.emit({ type: "item", assistantId, item });
571
686
  return item;
572
687
  }
@@ -581,16 +696,18 @@ export class RouterManager {
581
696
  assistantId: string,
582
697
  text: string,
583
698
  details: EventDetails | undefined,
699
+ knowledgeQuery?: string,
584
700
  ): Promise<boolean> {
585
701
  const state = await this.ensure(assistantId);
586
702
  const session = state.session!;
587
- const message = { customType: EVENT_TYPE, content: `[hob-event] ${text}`, display: false, details };
588
703
  this.touch(assistantId);
589
704
  state.running += 1;
590
705
  this.armStall(state);
591
706
  this.publishBusy(state);
592
- refreshSystemPrompt(session);
593
707
  try {
708
+ const knowledge = knowledgeQuery === undefined ? "" : await this.automaticKnowledge(state, knowledgeQuery);
709
+ const message = { customType: EVENT_TYPE, content: `${nowBlock(this.runtime, assistantId)}\n\n[hob-event] ${text}${knowledge}`, display: false, details };
710
+ await this.refreshTools(state);
594
711
  await this.applyConversationModel(state);
595
712
  if (session.isStreaming) {
596
713
  await session.sendCustomMessage(message, { deliverAs: "followUp" });
@@ -672,6 +789,7 @@ export class RouterManager {
672
789
  };
673
790
  const id = manager.appendCustomEntry(USER_TYPE, data);
674
791
  const item: Item = { kind: "user", id, at: new Date().toISOString(), ...data };
792
+ for (const path of body.files ?? []) void this.runtime.knowledge.collect({ origin: "chat", path, assistantId, itemId: id }).catch((error) => console.error("知识库收集失败:", error));
675
793
  this.bus.emit({ type: "item", assistantId, item });
676
794
  const noteId = manager.appendCustomEntry(ITEM_TYPE, { kind: "note", icon: "info", text: note });
677
795
  this.bus.emit({
@@ -683,6 +801,13 @@ export class RouterManager {
683
801
  }
684
802
 
685
803
  async send(assistantId: string, body: SendMessageBody): Promise<Item> {
804
+ this.runtime.delegations.takeOver(assistantId);
805
+ for (const approval of this.store.approvals()) {
806
+ if (approval.assistantId !== assistantId || approval.source !== "router" || approval.status !== "pending") continue;
807
+ const expired = { ...approval, status: "expired" as const };
808
+ this.store.saveApproval(expired);
809
+ this.bus.emit({ type: "approval", approval: expired });
810
+ }
686
811
  const gap = await this.modelGap(assistantId);
687
812
  if (gap) return this.sendWithoutModel(assistantId, body, gap);
688
813
  const state = await this.ensure(assistantId);
@@ -701,11 +826,16 @@ export class RouterManager {
701
826
  ...(body.files?.length ? { files: body.files } : {}),
702
827
  };
703
828
  this.bus.emit({ type: "item", assistantId, item });
829
+ for (const path of body.files ?? []) void this.runtime.knowledge.collect({ origin: "chat", path, assistantId, itemId: id }).catch((error) => console.error("知识库收集失败:", error));
704
830
 
705
831
  const lines = [body.text];
706
832
  if (body.quote) lines.unshift(`(引用我之前的话:「${body.quote.text}」)`);
707
833
  if (body.files?.length) lines.push(`附件:${body.files.join("、")}`);
708
- const promptText = lines.join("\n");
834
+ const basePrompt = `${nowBlock(this.runtime, assistantId)}\n\n${lines.filter(Boolean).join("\n")}`;
835
+ const previousDelivery = state.pendingDelivery;
836
+ let releaseDelivery!: () => void;
837
+ const delivery = new Promise<void>((resolve) => { releaseDelivery = resolve; });
838
+ state.pendingDelivery = delivery;
709
839
 
710
840
  this.touch(assistantId);
711
841
  state.running += 1;
@@ -713,16 +843,25 @@ export class RouterManager {
713
843
  this.publishBusy(state);
714
844
  void (async () => {
715
845
  try {
846
+ const promptText = basePrompt + await this.automaticKnowledge(state, body.text);
847
+ await previousDelivery;
848
+ (state.pendingUsers ??= []).push({ prompt: promptText, id });
716
849
  await this.applyConversationModel(state);
717
- refreshSystemPrompt(state.session!);
718
- if (state.session!.isStreaming) await state.session!.steer(promptText);
719
- else {
720
- await state.session!.prompt(promptText);
850
+ await this.refreshTools(state);
851
+ // 对话模型能看图就把图片直接给它(路径照样写在「附件」里,派任务时还能交出去);看不了图就只有路径
852
+ const images = state.session!.model?.input.includes("image") ? imageContents(body.files) : [];
853
+ if (state.session!.isStreaming) {
854
+ await state.session!.steer(promptText, images.length ? images : undefined);
855
+ releaseDelivery();
856
+ } else {
857
+ await state.session!.prompt(promptText, { ...(images.length ? { images } : {}), preflightResult: releaseDelivery });
721
858
  await this.reportTurnError(state);
722
859
  }
723
860
  } catch (error) {
724
861
  await this.fail(state, error);
725
862
  } finally {
863
+ releaseDelivery();
864
+ if (state.pendingDelivery === delivery) state.pendingDelivery = undefined;
726
865
  state.running = Math.max(0, state.running - 1);
727
866
  if (!state.running) this.setActivity(state, null);
728
867
  this.clearStall(state);
@@ -869,8 +1008,9 @@ export class RouterManager {
869
1008
  }
870
1009
 
871
1010
  /** 直接走事件的 followUp 队列,实际收到这条消息时才开始关联任务。 */
872
- async relay(targetAssistantId: string, text: string, delegationId: string): Promise<void> {
873
- const success = await this.pushEvent(targetAssistantId, `转自 Hob:${text}`, { delegationId });
1011
+ /** 把事件当作转交的一轮送给目标助手;这一轮没正常收尾就把转交记为受阻,不会一直挂着。 */
1012
+ async relay(targetAssistantId: string, text: string, delegationId: string, details?: EventDetails, knowledgeQuery?: string): Promise<void> {
1013
+ const success = await this.pushEvent(targetAssistantId, text, { ...details, delegationId }, knowledgeQuery);
874
1014
  if (!success || !this.store.delegation(delegationId)?.turnFinished) {
875
1015
  this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败或转交被中断", true);
876
1016
  }
@@ -2,6 +2,7 @@ import { Cron } from "croner";
2
2
  import type { Job, Routine } from "../../shared/types.ts";
3
3
  import type { HobRuntime } from "./runtime.ts";
4
4
  import { newId } from "./ids.ts";
5
+ import { listAssistantSkills, usableAssistantSkills } from "./assistant-skills.ts";
5
6
 
6
7
  export interface RoutineInput {
7
8
  assistantId: string;
@@ -55,13 +56,18 @@ export class RoutineScheduler {
55
56
  this.timers.set(routine.id, list);
56
57
  }
57
58
 
59
+ private instruction(routine: Routine): string {
60
+ const skill = routine.skill && usableAssistantSkills(this.runtime.home, routine.assistantId).find((item) => item.name === routine.skill);
61
+ return skill ? `按技能「${skill.title}」做(先读它的 SKILL.md)。\n${routine.instruction}` : routine.instruction;
62
+ }
63
+
58
64
  private async fire(routineId: string): Promise<void> {
59
65
  const routine = this.runtime.store.routine(routineId);
60
66
  if (!routine || !routine.active) return;
61
67
  try {
62
68
  await this.runtime.jobs.start(routine.assistantId, {
63
69
  title: routine.name,
64
- instruction: routine.instruction,
70
+ instruction: this.instruction(routine),
65
71
  routineId: routine.id,
66
72
  });
67
73
  } catch (error) {
@@ -91,11 +97,15 @@ export class RoutineScheduler {
91
97
  return routine;
92
98
  }
93
99
 
94
- update(id: string, patch: Partial<Pick<Routine, "name" | "instruction" | "schedules" | "active">>): Routine {
100
+ update(id: string, patch: Partial<Pick<Routine, "name" | "instruction" | "schedules" | "active">> & { skill?: string | null }): Routine {
95
101
  const routine = this.runtime.store.routine(id);
96
102
  if (!routine) throw new Error(`定时任务不存在:${id}`);
97
103
  if (patch.schedules) validate(patch.schedules, this.runtime.settings.settings.general.timezone);
98
- const next: Routine = { ...routine, ...patch };
104
+ if (patch.skill !== undefined && patch.skill !== null && (typeof patch.skill !== "string" || !listAssistantSkills(this.runtime.home, routine.assistantId).some((skill) => skill.name === patch.skill))) throw new Error("绑定的技能无效");
105
+ const { skill, ...rest } = patch;
106
+ const next: Routine = { ...routine, ...rest };
107
+ if (skill === null) delete next.skill;
108
+ else if (skill !== undefined) next.skill = skill;
99
109
  this.runtime.store.saveRoutine(next);
100
110
  this.apply(next);
101
111
  this.runtime.bus.emit({ type: "routine", routine: next });
@@ -122,7 +132,7 @@ export class RoutineScheduler {
122
132
  if (!routine) throw new Error(`定时任务不存在:${id}`);
123
133
  return await this.runtime.jobs.start(routine.assistantId, {
124
134
  title: routine.name,
125
- instruction: routine.instruction,
135
+ instruction: this.instruction(routine),
126
136
  routineId: routine.id,
127
137
  });
128
138
  }
@@ -1,4 +1,9 @@
1
1
  import type { Bus } from "./bus.ts";
2
+ import { DesktopService } from "./desktop.ts";
3
+ import { KnowledgeStore } from "./knowledge.ts";
4
+ import { DesktopControl } from "./desktop-control.ts";
5
+ import { DesktopEvents } from "./desktop-events.ts";
6
+ import { DesktopPublicLock } from "./desktop-public.ts";
2
7
  import { DelegationManager } from "./delegations.ts";
3
8
  import type { HobConfig } from "./config.ts";
4
9
  import { memoryDir } from "./config.ts";
@@ -30,9 +35,14 @@ export interface HobRuntime {
30
35
  config: HobConfig;
31
36
  store: Store;
32
37
  bus: Bus;
38
+ knowledge: KnowledgeStore;
33
39
  pi: PiHost;
34
40
  /** 界面可改的设置(settings.json),每次现读所以改完立即生效 */
35
41
  settings: SettingsStore;
42
+ desktop: DesktopService;
43
+ desktopControl: DesktopControl;
44
+ desktopEvents: DesktopEvents;
45
+ desktopPublic: DesktopPublicLock;
36
46
  /** 服务商:设置里存不敏感的,Key 存 secrets.json,变动套进引擎 */
37
47
  providers: ProviderService;
38
48
  /** 引擎注册表里现在能用的模型(设置页、派任务时看) */
@@ -86,11 +96,16 @@ export function createRuntime(options: CreateRuntimeOptions): HobRuntime {
86
96
  config: options.config,
87
97
  store,
88
98
  bus,
99
+ knowledge: new KnowledgeStore(options.home, bus, store, undefined, () => settings.settings.knowledge?.embedding ?? { source: "off" }),
89
100
  pi: options.pi,
90
101
  settings,
102
+ desktop: new DesktopService(options.home, settings),
91
103
  providers,
92
104
  models,
93
105
  } as HobRuntime;
106
+ runtime.desktopControl = new DesktopControl(runtime);
107
+ runtime.desktopEvents = new DesktopEvents(runtime);
108
+ runtime.desktopPublic = new DesktopPublicLock();
94
109
  runtime.router = new RouterManagerImpl(runtime);
95
110
  runtime.jobs = new JobManagerImpl(runtime);
96
111
  runtime.delegations = new DelegationManager(runtime);
@@ -2,6 +2,7 @@
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import type {
5
+ ClaudeCodeSettings,
5
6
  MemoryConfig,
6
7
  MemoryProvider,
7
8
  ModelPick,
@@ -17,10 +18,11 @@ import type {
17
18
  } from "../../shared/types.ts";
18
19
  import { defaultSearch, normalizeSearch } from "../../shared/search.ts";
19
20
  import { checkImageSettings, normalizeImageSettings } from "./image.ts";
21
+ import { checkEmbeddingSettings, normalizeEmbeddingSettings } from "./embedding.ts";
20
22
  import { checkThinkingMap, isThinkingLevel } from "./models.ts";
21
23
  import { normalizeThinkingMap } from "../../shared/thinking.ts";
22
24
  import type { ModelScores } from "../../shared/types.ts";
23
- import { isMemoryProvider, loreConnection, resolveMemoryProvider } from "./memory.ts";
25
+ import { isMemoryProvider, LORE_PLUGIN_VERSION, loreConnection, resolveMemoryProvider } from "./memory.ts";
24
26
  import { checkProvider } from "./providers.ts";
25
27
  import { checkTimezone } from "./time.ts";
26
28
 
@@ -89,6 +91,8 @@ export function defaultSettings(timezone = systemTimezone(), models: ModelSettin
89
91
  models,
90
92
  image: { source: "off" },
91
93
  search: defaultSearch(),
94
+ desktop: { url: "", enabled: false, publicAssistants: [] },
95
+ claude: { enabled: false, plan: "pro", executable: "" },
92
96
  work: { maxJobs: 2, jobStallMinutes: 15, routerStallMinutes: 6 },
93
97
  push: { email: "" },
94
98
  };
@@ -211,7 +215,18 @@ function normalize(raw: unknown, base: Settings): Settings {
211
215
  models: normalizeModels(input.models, base.models),
212
216
  providers: normalizeProviders(input.providers ?? base.providers),
213
217
  image: normalizeImageSettings(input.image, base.image),
218
+ knowledge: { embedding: normalizeEmbeddingSettings(input.knowledge?.embedding, base.knowledge?.embedding ?? { source: "off" }) },
214
219
  search: normalizeSearch(input.search ?? base.search),
220
+ desktop: {
221
+ url: typeof input.desktop?.url === "string" ? input.desktop.url : (base.desktop?.url ?? ""),
222
+ enabled: typeof input.desktop?.enabled === "boolean" ? input.desktop.enabled : (base.desktop?.enabled ?? false),
223
+ publicAssistants: Array.isArray(input.desktop?.publicAssistants) ? input.desktop.publicAssistants.filter((id): id is string => typeof id === "string") : (base.desktop?.publicAssistants ?? []),
224
+ },
225
+ claude: {
226
+ enabled: input.claude?.enabled === true,
227
+ plan: input.claude?.plan === "max" ? "max" : "pro",
228
+ executable: typeof input.claude?.executable === "string" ? input.claude.executable.trim() : "",
229
+ },
215
230
  skills: { disabled: Array.isArray(input.skills?.disabled) ? input.skills.disabled.filter((v): v is string => typeof v === "string") : (base.skills?.disabled ?? []) },
216
231
  work: {
217
232
  maxJobs: int(work.maxJobs, base.work.maxJobs, 1, 8),
@@ -317,6 +332,7 @@ export class SettingsStore {
317
332
  provider: resolveMemoryProvider(this.memoryProvider, lore.configured),
318
333
  providerExplicit: this.memoryProvider !== undefined,
319
334
  lore,
335
+ lorePlugin: LORE_PLUGIN_VERSION,
320
336
  };
321
337
  }
322
338
 
@@ -368,7 +384,10 @@ export class SettingsStore {
368
384
  },
369
385
  // image / providers 不在这份补丁里(各走自己的接口),别把它们冲回默认
370
386
  image: current.image,
387
+ knowledge: current.knowledge,
371
388
  search: current.search,
389
+ desktop: current.desktop,
390
+ claude: current.claude,
372
391
  skills: patch.skills ?? current.skills,
373
392
  providers: current.providers,
374
393
  work: { ...current.work, ...(patch.work ?? {}) },
@@ -392,6 +411,29 @@ export class SettingsStore {
392
411
  return this.get();
393
412
  }
394
413
 
414
+ setEmbedding(revision: number, embedding: unknown): SettingsFile | undefined {
415
+ if (revision !== this.file.revision) return undefined;
416
+ const next = checkEmbeddingSettings(embedding);
417
+ this.file = { revision: this.file.revision + 1, settings: { ...this.get().settings, knowledge: { embedding: next } } };
418
+ this.write();
419
+ return this.get();
420
+ }
421
+
422
+ setClaude(revision: number, claude: ClaudeCodeSettings): SettingsFile | undefined {
423
+ if (revision !== this.file.revision) return undefined;
424
+ if (typeof claude.enabled !== "boolean" || (claude.plan !== "pro" && claude.plan !== "max") || typeof claude.executable !== "string") throw new Error("Claude Code 设置无效。");
425
+ this.file = { revision: this.file.revision + 1, settings: { ...this.get().settings, claude: { ...claude, executable: claude.executable.trim() } } };
426
+ this.write();
427
+ return this.get();
428
+ }
429
+
430
+ setDesktop(revision: number, desktop: { url: string; enabled: boolean; publicAssistants?: string[] }): SettingsFile | undefined {
431
+ if (revision !== this.file.revision) return undefined;
432
+ this.file = { revision: this.file.revision + 1, settings: { ...this.get().settings, desktop: { ...this.get().settings.desktop, ...desktop } } };
433
+ this.write();
434
+ return this.get();
435
+ }
436
+
395
437
  setSearch(revision: number, search: unknown): SettingsFile | undefined {
396
438
  if (revision !== this.file.revision) return undefined;
397
439
  if (!Array.isArray(search)) throw new Error("联网搜索配置无效。");
@@ -90,10 +90,11 @@ export class Store {
90
90
  }
91
91
 
92
92
  /**
93
- * 并回 Hob:整个助手目录移进 archive/assistants/<id>-<时间>/。同盘 rename,不复制也不删数据,
94
- * 归档里的 profile.json 就是它原来的档案。
93
+ * 归档助手:整个助手目录移进 archive/assistants/<id>-<时间>/。同盘 rename,不复制也不删数据,
94
+ * 归档里的 profile.json 就是它原来的档案;它的定时存成目录里的 routines.json。
95
95
  */
96
- archiveAssistant(id: string, name: string): string {
96
+ archiveAssistant(id: string, name: string, routines: Routine[] = []): string {
97
+ if (routines.length) writeJsonAtomic(join(this.assistantDir(id), "routines.json"), routines);
97
98
  const target = join(this.home, "archive", "assistants", name);
98
99
  mkdirSync(dirname(target), { recursive: true });
99
100
  renameSync(this.assistantDir(id), target);
@@ -0,0 +1,49 @@
1
+ // 对话里的图片缩略图:长边 720px 的 JPEG/PNG,按(路径、大小、修改时间)缓存在 HOB_HOME/cache/thumbs。
2
+ // 对话列表只拉缩略图,点开预览才拉原图。原图本来就小、GIF(可能是动图)、缩不了的,都直接给原图。
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { resizeImage } from "@earendil-works/pi-coding-agent";
7
+
8
+ const THUMB_EDGE = 720;
9
+ const RESIZABLE = new Set(["image/jpeg", "image/png", "image/webp"]);
10
+ const inflight = new Map<string, Promise<Thumb | null>>();
11
+
12
+ export interface Thumb {
13
+ data: Uint8Array;
14
+ mime: string;
15
+ }
16
+
17
+ export async function thumbnail(home: string, file: { path: string; size: number; modifiedAt: string; mime: string }): Promise<Thumb | null> {
18
+ if (!RESIZABLE.has(file.mime)) return null;
19
+ const key = createHash("sha1").update(`${file.path}\0${file.size}\0${file.modifiedAt}`).digest("hex");
20
+ const dir = join(home, "cache", "thumbs");
21
+ for (const [ext, mime] of [["jpg", "image/jpeg"], ["png", "image/png"]] as const) {
22
+ const cached = join(dir, `${key}.${ext}`);
23
+ if (existsSync(cached)) return { data: readFileSync(cached), mime };
24
+ }
25
+ // 原图大小就不值得缩:记一个空标记,下次直接给原图
26
+ if (existsSync(join(dir, `${key}.orig`))) return null;
27
+ let job = inflight.get(key);
28
+ if (!job) {
29
+ job = make(dir, key, file).finally(() => inflight.delete(key));
30
+ inflight.set(key, job);
31
+ }
32
+ return job;
33
+ }
34
+
35
+ async function make(dir: string, key: string, file: { path: string; mime: string }): Promise<Thumb | null> {
36
+ const resized = await resizeImage(readFileSync(file.path), file.mime, { maxWidth: THUMB_EDGE, maxHeight: THUMB_EDGE, maxBytes: 400_000, jpegQuality: 82 }).catch(() => null);
37
+ mkdirSync(dir, { recursive: true });
38
+ if (!resized || !resized.wasResized) {
39
+ writeFileSync(join(dir, `${key}.orig`), "");
40
+ return null;
41
+ }
42
+ const data = Buffer.from(resized.data, "base64");
43
+ const target = join(dir, `${key}.${resized.mimeType === "image/png" ? "png" : "jpg"}`);
44
+ // 先写临时文件再改名:并发读到的不会是半截图
45
+ const tmp = `${target}.${process.pid}.tmp`;
46
+ writeFileSync(tmp, data);
47
+ renameSync(tmp, target);
48
+ return { data, mime: resized.mimeType };
49
+ }