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
@@ -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,22 +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";
28
30
  import { imageContents } from "./jobs.ts";
31
+ import { nowBlock } from "./now.ts";
32
+ import { monthDayTimeText } from "./time.ts";
29
33
 
30
34
  export const ITEM_TYPE = "hob.item";
35
+ export const ITEM_UPDATE_TYPE = "hob.item.update";
31
36
  export const USER_TYPE = "hob.user";
32
37
  export const EVENT_TYPE = "hob.event";
33
38
 
34
39
  /** 会话条目的 id 和 at 由会话文件给出,写卡片时不用传。 */
35
40
  /** 事件附带的信息,落在会话的事件条目上:routine 给回复挂「⏱ 名字」,cont 见 walk。 */
36
41
  export interface EventDetails {
37
- routine?: { id: string; name: string };
42
+ routine?: { id: string; name: string; jobId?: string };
38
43
  cont?: true;
39
44
  delegationId?: string;
45
+ sourceUserId?: string;
40
46
  }
41
47
 
42
48
  export type NewItem = Item extends infer T ? (T extends Item ? Omit<T, "id" | "at"> : never) : never;
@@ -66,6 +72,10 @@ interface RouterState {
66
72
  /** 还在跑的记忆写入工具:参数只在 tool_execution_start 里有,按 toolCallId 存着给 note 用 */
67
73
  memoryWriteArgs: Map<string, Record<string, unknown>>;
68
74
  currentDelegationId?: string;
75
+ currentUserId?: string;
76
+ pendingUsers?: { prompt: string; id: string }[];
77
+ pendingDelivery?: Promise<void>;
78
+ knowledgeNames?: string[];
69
79
  delegationReply?: string[];
70
80
  terminatingTools?: Set<string>;
71
81
  }
@@ -114,7 +124,7 @@ export function briefModelError(raw: string | undefined): string {
114
124
  function walk(entries: SessionEntry[]): { items: Item[]; lead: boolean } {
115
125
  const items: Item[] = [];
116
126
  let assistantIndex = 0;
117
- let routine: { id: string; name: string } | undefined;
127
+ let routine: EventDetails["routine"];
118
128
  let lead = false;
119
129
  const push = (item: Item) => {
120
130
  items.push(lead ? { ...item, lead: true } : item);
@@ -141,6 +151,12 @@ function walk(entries: SessionEntry[]): { items: Item[]; lead: boolean } {
141
151
  push({ ...(entry.data as object), id: entry.id, at: entry.timestamp } as Item);
142
152
  continue;
143
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
+ }
144
160
  if (entry.type === "custom_message" && entry.customType === EVENT_TYPE) {
145
161
  const details = entry.details as EventDetails | undefined;
146
162
  routine = details?.routine;
@@ -203,7 +219,16 @@ export class RouterManager {
203
219
  /** 测试可改小:不给就看设置里的“回复多久无响应视为受阻” */
204
220
  stallOverrideMs?: number;
205
221
 
206
- 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
+ }
207
232
 
208
233
  private get store() {
209
234
  return this.runtime.store;
@@ -245,8 +270,10 @@ export class RouterManager {
245
270
  // 提示词里要写模型的显示名和定位,所以先把注册表拉一遍
246
271
  await this.runtime.models.get();
247
272
  const pick = conversationPick(this.runtime.settings.settings.models, assistant);
273
+ state.knowledgeNames = (await boundKnowledge(this.runtime, assistant.id)).map((collection) => collection.name);
248
274
  const opened = await this.runtime.pi.open({
249
275
  file: this.store.routerFile(assistant.id),
276
+ spillDir: join(dirname(this.store.routerFile(assistant.id)), "spill"),
250
277
  cwd: this.store.home,
251
278
  model: pick.ref,
252
279
  thinking: thinkingForPick(pick, this.runtime.models),
@@ -259,19 +286,50 @@ export class RouterManager {
259
286
  memory: this.runtime.settings.memory().provider,
260
287
  models: this.promptModels(assistant.id),
261
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),
290
+ assistantSkillsDir: resolve(this.runtime.home, "assistants", assistant.id, "skills"),
262
291
  thinkingChoice: this.runtime.settings.settings.models.thinkingChoice,
292
+ knowledgeNames: state.knowledgeNames,
263
293
  });
264
294
  },
265
295
  tools: buildRouterTools(this.runtime, assistant.id).map((tool) => tool.name),
266
296
  customTools: buildRouterTools(this.runtime, assistant.id),
267
297
  });
268
298
  state.session = opened.session;
299
+ if (!state.knowledgeNames.length) refreshSystemPrompt(opened.session, opened.session.getActiveToolNames().filter((name) => name !== "search_knowledge"));
269
300
  state.manager = opened.manager;
270
301
  state.opening = undefined;
271
302
  opened.session.subscribe((event) => this.onSessionEvent(state, assistant.id, event));
272
303
  this.touch(assistant.id);
273
304
  }
274
305
 
306
+ private async refreshTools(state: RouterState): Promise<void> {
307
+ state.knowledgeNames = (await boundKnowledge(this.runtime, state.assistantId)).map((collection) => collection.name);
308
+ const names = state.session!.getActiveToolNames().filter((name) => name !== "search_knowledge");
309
+ if (state.knowledgeNames.length) names.push("search_knowledge");
310
+ refreshSystemPrompt(state.session!, names);
311
+ }
312
+
313
+ private async automaticKnowledge(state: RouterState, query: string): Promise<string> {
314
+ const ids = this.store.assistant(state.assistantId)?.knowledge ?? [];
315
+ if ([...query.trim()].length < 4 || !ids.length || this.runtime.settings.settings.knowledge?.embedding.source !== "openai") return "";
316
+ this.setActivity(state, { state: "tool", label: "正在查知识库" });
317
+ let timer: ReturnType<typeof setTimeout> | undefined;
318
+ try {
319
+ const results = await Promise.race([
320
+ this.runtime.knowledge.search(query, ids, { limit: 3, gate: true }),
321
+ new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("知识库检索超时")), 3000); }),
322
+ ]);
323
+ const text = formatKnowledge(results, 2400 - [...KNOWLEDGE_INTRO].length - 1);
324
+ return text ? `\n\n${KNOWLEDGE_INTRO}\n${text}` : "";
325
+ } catch {
326
+ return "";
327
+ } finally {
328
+ if (timer) clearTimeout(timer);
329
+ if (state.activity?.label === "正在查知识库") this.setActivity(state, null);
330
+ }
331
+ }
332
+
275
333
  /** 这个助手能派、当前可用的模型(提示词里那一节)。 */
276
334
  private promptModels(assistantId: string): PromptModel[] {
277
335
  const assistant = this.store.assistant(assistantId);
@@ -327,8 +385,16 @@ export class RouterManager {
327
385
  }
328
386
  }
329
387
  if (event.type === "agent_end" || (event.type === "message_end" && event.message.role === "assistant" && ["error", "aborted"].includes(event.message.stopReason))) this.setActivity(state, null);
388
+ if (event.type === "message_start" && event.message.role === "user") {
389
+ const prompt = messageText(event.message.content);
390
+ const index = state.pendingUsers?.findIndex((user) => user.prompt.trim() === prompt) ?? -1;
391
+ if (index >= 0) state.currentUserId = state.pendingUsers!.splice(index, 1)[0].id;
392
+ }
330
393
  if (event.type === "message_start" && event.message.role === "custom" && event.message.customType === EVENT_TYPE) {
331
394
  const details = event.message.details as EventDetails | undefined;
395
+ const users = deriveItems(this.entries(assistantId)).filter((item) => item.kind === "user");
396
+ const source = details?.sourceUserId ? users.find((item) => item.id === details.sourceUserId) : undefined;
397
+ state.currentUserId = (source ?? users.at(-1))?.id;
332
398
  if (details?.delegationId && details.delegationId !== state.currentDelegationId) {
333
399
  state.currentDelegationId = details.delegationId;
334
400
  state.delegationReply = [];
@@ -529,7 +595,7 @@ export class RouterManager {
529
595
  }
530
596
  }
531
597
 
532
- /** 助手没了(并回 Hob):关掉它的会话,内存里也不留 —— 它的目录随后会被移进归档。 */
598
+ /** 助手没了(归档):关掉它的会话,内存里也不留 —— 它的目录随后会被移进归档。 */
533
599
  forget(assistantId: string): void {
534
600
  const state = this.routers.get(assistantId);
535
601
  if (!state) return;
@@ -556,18 +622,67 @@ export class RouterManager {
556
622
  const id = state.manager!.appendCustomEntry(ITEM_TYPE, item);
557
623
  const full = { ...item, ...(lead ? { lead: true } : {}), id, at: this.entryAt(state, id) } as Item;
558
624
  this.bus.emit({ type: "item", assistantId, item: full });
625
+ if (full.kind === "file") {
626
+ const rel = relative(join(this.runtime.home, "pages"), resolve(full.path));
627
+ const page = rel !== ".." && !rel.startsWith(`..${sep}`);
628
+ 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));
629
+ }
559
630
  return full;
560
631
  }
561
632
 
633
+ async updateImage(assistantId: string, id: string, update: { status: "done" | "failed"; path?: string; error?: string }): Promise<void> {
634
+ const state = await this.ensure(assistantId);
635
+ const item = deriveItems(this.entries(assistantId)).find((entry) => entry.id === id && entry.kind === "image");
636
+ if (!item) throw new Error(`图片条目不存在:${id}`);
637
+ state.manager!.appendCustomEntry(ITEM_UPDATE_TYPE, { id, ...update });
638
+ this.bus.emit({ type: "item", assistantId, item: { ...item, ...update } });
639
+ if (item.kind === "image" && update.status === "done" && update.path) {
640
+ void this.runtime.knowledge.collect({ origin: "output", path: update.path, description: item.prompt, assistantId, itemId: id }).catch((error) => console.error("知识库收集失败:", error));
641
+ }
642
+ }
643
+
562
644
  currentDelegation(assistantId: string): string | undefined {
563
645
  return this.routers.get(assistantId)?.currentDelegationId;
564
646
  }
565
647
 
566
- async addForwarded(assistantId: string, text: string, delegationId: string, sourceId = "hob"): Promise<Item> {
648
+ /** 取当前回合来源及其之前的可见对话;事件取最近用户消息。 */
649
+ originalWords(assistantId: string, note?: string, recent = false, extraFiles: string[] = []): { text: string; files: string[] } {
650
+ const items = deriveItems(this.entries(assistantId));
651
+ const state = this.routers.get(assistantId);
652
+ const source = items.find((item) => item.kind === "user" && item.id === state?.currentUserId)
653
+ ?? items.findLast((item) => item.kind === "user");
654
+ const user = source?.kind === "user" ? source : undefined;
655
+ const files = [...new Set([...(user?.files ?? []), ...extraFiles])];
656
+ const timezone = this.runtime.settings.settings.general.timezone;
657
+ const name = this.assistant(assistantId).name;
658
+ const sections: string[] = [];
659
+ if (user) {
660
+ const header = user.from ? `${user.from.name} 转来` : "用户原话";
661
+ const lines = [`${header}(${monthDayTimeText(timezone, new Date(user.at))}):`, user.text];
662
+ if (user.quote) lines.push(`(引用:「${user.quote.text}」)`);
663
+ sections.push(lines.filter(Boolean).join("\n"));
664
+ if (recent) {
665
+ const previous = items.slice(0, items.indexOf(user)).filter((item) => item.kind === "user" || item.kind === "assistant").slice(-6);
666
+ if (previous.length) sections.push(`最近的对话:\n${previous.map((item) => {
667
+ if (item.kind !== "user" && item.kind !== "assistant") return "";
668
+ const clock = new Intl.DateTimeFormat("sv-SE", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(item.at));
669
+ const characters = [...item.text];
670
+ const text = characters.length > 1500 ? `${characters.slice(0, 1500).join("")}…` : item.text;
671
+ return `${item.kind === "user" ? "用户" : name}(${clock}):${text}`;
672
+ }).join("\n")}`);
673
+ }
674
+ }
675
+ if (files.length) sections.push(`附件:${files.join("、")}`);
676
+ if (note) sections.push(`${name} 的备注:${note}`);
677
+ return { text: sections.join("\n\n"), files };
678
+ }
679
+
680
+ async addForwarded(assistantId: string, text: string, delegationId: string, sourceId = "hob", files: string[] = []): Promise<Item> {
567
681
  const state = await this.ensure(assistantId);
568
682
  const from = { assistantId: sourceId, name: this.store.assistant(sourceId)?.name ?? sourceId, delegationId };
569
- const id = state.manager!.appendCustomEntry(USER_TYPE, { text, from });
570
- const item: Item = { kind: "user", id, at: this.entryAt(state, id), text, from };
683
+ const data = { text, from, ...(files.length ? { files } : {}) };
684
+ const id = state.manager!.appendCustomEntry(USER_TYPE, data);
685
+ const item: Item = { kind: "user", id, at: this.entryAt(state, id), ...data };
571
686
  this.bus.emit({ type: "item", assistantId, item });
572
687
  return item;
573
688
  }
@@ -582,16 +697,18 @@ export class RouterManager {
582
697
  assistantId: string,
583
698
  text: string,
584
699
  details: EventDetails | undefined,
700
+ knowledgeQuery?: string,
585
701
  ): Promise<boolean> {
586
702
  const state = await this.ensure(assistantId);
587
703
  const session = state.session!;
588
- const message = { customType: EVENT_TYPE, content: `[hob-event] ${text}`, display: false, details };
589
704
  this.touch(assistantId);
590
705
  state.running += 1;
591
706
  this.armStall(state);
592
707
  this.publishBusy(state);
593
- refreshSystemPrompt(session);
594
708
  try {
709
+ const knowledge = knowledgeQuery === undefined ? "" : await this.automaticKnowledge(state, knowledgeQuery);
710
+ const message = { customType: EVENT_TYPE, content: `${nowBlock(this.runtime, assistantId)}\n\n[hob-event] ${text}${knowledge}`, display: false, details };
711
+ await this.refreshTools(state);
595
712
  await this.applyConversationModel(state);
596
713
  if (session.isStreaming) {
597
714
  await session.sendCustomMessage(message, { deliverAs: "followUp" });
@@ -673,6 +790,7 @@ export class RouterManager {
673
790
  };
674
791
  const id = manager.appendCustomEntry(USER_TYPE, data);
675
792
  const item: Item = { kind: "user", id, at: new Date().toISOString(), ...data };
793
+ for (const path of body.files ?? []) void this.runtime.knowledge.collect({ origin: "chat", path, assistantId, itemId: id }).catch((error) => console.error("知识库收集失败:", error));
676
794
  this.bus.emit({ type: "item", assistantId, item });
677
795
  const noteId = manager.appendCustomEntry(ITEM_TYPE, { kind: "note", icon: "info", text: note });
678
796
  this.bus.emit({
@@ -684,6 +802,13 @@ export class RouterManager {
684
802
  }
685
803
 
686
804
  async send(assistantId: string, body: SendMessageBody): Promise<Item> {
805
+ this.runtime.delegations.takeOver(assistantId);
806
+ for (const approval of this.store.approvals()) {
807
+ if (approval.assistantId !== assistantId || approval.source !== "router" || approval.status !== "pending") continue;
808
+ const expired = { ...approval, status: "expired" as const };
809
+ this.store.saveApproval(expired);
810
+ this.bus.emit({ type: "approval", approval: expired });
811
+ }
687
812
  const gap = await this.modelGap(assistantId);
688
813
  if (gap) return this.sendWithoutModel(assistantId, body, gap);
689
814
  const state = await this.ensure(assistantId);
@@ -702,11 +827,16 @@ export class RouterManager {
702
827
  ...(body.files?.length ? { files: body.files } : {}),
703
828
  };
704
829
  this.bus.emit({ type: "item", assistantId, item });
830
+ for (const path of body.files ?? []) void this.runtime.knowledge.collect({ origin: "chat", path, assistantId, itemId: id }).catch((error) => console.error("知识库收集失败:", error));
705
831
 
706
832
  const lines = [body.text];
707
833
  if (body.quote) lines.unshift(`(引用我之前的话:「${body.quote.text}」)`);
708
834
  if (body.files?.length) lines.push(`附件:${body.files.join("、")}`);
709
- const promptText = lines.filter(Boolean).join("\n");
835
+ const basePrompt = `${nowBlock(this.runtime, assistantId)}\n\n${lines.filter(Boolean).join("\n")}`;
836
+ const previousDelivery = state.pendingDelivery;
837
+ let releaseDelivery!: () => void;
838
+ const delivery = new Promise<void>((resolve) => { releaseDelivery = resolve; });
839
+ state.pendingDelivery = delivery;
710
840
 
711
841
  this.touch(assistantId);
712
842
  state.running += 1;
@@ -714,18 +844,25 @@ export class RouterManager {
714
844
  this.publishBusy(state);
715
845
  void (async () => {
716
846
  try {
847
+ const promptText = basePrompt + await this.automaticKnowledge(state, body.text);
848
+ await previousDelivery;
849
+ (state.pendingUsers ??= []).push({ prompt: promptText, id });
717
850
  await this.applyConversationModel(state);
718
- refreshSystemPrompt(state.session!);
851
+ await this.refreshTools(state);
719
852
  // 对话模型能看图就把图片直接给它(路径照样写在「附件」里,派任务时还能交出去);看不了图就只有路径
720
853
  const images = state.session!.model?.input.includes("image") ? imageContents(body.files) : [];
721
- if (state.session!.isStreaming) await state.session!.steer(promptText, images.length ? images : undefined);
722
- else {
723
- await state.session!.prompt(promptText, images.length ? { images } : undefined);
854
+ if (state.session!.isStreaming) {
855
+ await state.session!.steer(promptText, images.length ? images : undefined);
856
+ releaseDelivery();
857
+ } else {
858
+ await state.session!.prompt(promptText, { ...(images.length ? { images } : {}), preflightResult: releaseDelivery });
724
859
  await this.reportTurnError(state);
725
860
  }
726
861
  } catch (error) {
727
862
  await this.fail(state, error);
728
863
  } finally {
864
+ releaseDelivery();
865
+ if (state.pendingDelivery === delivery) state.pendingDelivery = undefined;
729
866
  state.running = Math.max(0, state.running - 1);
730
867
  if (!state.running) this.setActivity(state, null);
731
868
  this.clearStall(state);
@@ -873,8 +1010,8 @@ export class RouterManager {
873
1010
 
874
1011
  /** 直接走事件的 followUp 队列,实际收到这条消息时才开始关联任务。 */
875
1012
  /** 把事件当作转交的一轮送给目标助手;这一轮没正常收尾就把转交记为受阻,不会一直挂着。 */
876
- async relay(targetAssistantId: string, text: string, delegationId: string, details?: EventDetails): Promise<void> {
877
- const success = await this.pushEvent(targetAssistantId, text, { ...details, delegationId });
1013
+ async relay(targetAssistantId: string, text: string, delegationId: string, details?: EventDetails, knowledgeQuery?: string): Promise<void> {
1014
+ const success = await this.pushEvent(targetAssistantId, text, { ...details, delegationId }, knowledgeQuery);
878
1015
  if (!success || !this.store.delegation(delegationId)?.turnFinished) {
879
1016
  this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败或转交被中断", true);
880
1017
  }
@@ -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, skillInstruction, 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 ? skillInstruction(skill.title, 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);