hob-bot 1.3.0 → 1.4.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.
@@ -14,6 +14,7 @@ import type {
14
14
  JobStatus,
15
15
  JobStep,
16
16
  RoutineRun,
17
+ SedimentStatus,
17
18
  } from "../../shared/types.ts";
18
19
  import { IMAGE_TYPES, isImagePath } from "../../shared/media.ts";
19
20
  import { newId } from "./ids.ts";
@@ -83,6 +84,7 @@ export interface StartJobInput {
83
84
  model?: string;
84
85
  thinking?: string;
85
86
  routineId?: string;
87
+ skills?: string[];
86
88
  expectMinutes?: number;
87
89
  delegationId?: string;
88
90
  }
@@ -115,18 +117,32 @@ interface WorkerState {
115
117
  /** 推给助手的任务结果最多带这么多字的报告。 */
116
118
  const EVENT_SUMMARY_MAX = 3000;
117
119
 
118
- export function jobResultEvent(job: Job, prefix = `任务「${job.title}」`): string {
120
+ export function jobResultEvent(job: Job, prefix = `任务「${job.title}」`, skillTitles: string[] = [], toolCount = job.stats.reads + job.stats.commands + job.stats.edits, hint?: string): string {
119
121
  const status = job.status === "done" ? "已完成" : job.status === "stuck" ? "受阻" : job.status === "stopped" ? "已停止" : "失败";
120
122
  const result = job.summary;
121
- const header = job.expect && job.durationMs !== undefined
122
- ? `${prefix}(${job.id})${status},用时 ${duration(job.durationMs)},预计 ${job.expect.minutes} 分钟。`
123
- : `${prefix}(${job.id})${status}。`;
123
+ const header = `${prefix}(${job.id})${status}${job.durationMs !== undefined ? `,用时 ${duration(job.durationMs)}${job.expect ? `,预计 ${job.expect.minutes} 分钟` : ""}` : ""}。\n过程:动手 ${toolCount} 次,${skillTitles.length ? `用了技能${skillTitles.map((title) => `「${title}」`).join("")}` : "没用技能"}。${hint ? `\n${hint}` : ""}`;
124
124
  if (!result) return `${header}\n${job.statusReason ?? "没有结果"}`;
125
125
  return result.length > EVENT_SUMMARY_MAX
126
126
  ? `${header}\n结果(共 ${result.length} 字,以下是前 3,000 字;全文:${job.reportPath}):\n${result.slice(0, EVENT_SUMMARY_MAX)}`
127
127
  : `${header}\n结果:\n${result}`;
128
128
  }
129
129
 
130
+ /** 没带技能的任务做完了,就在结果里提醒助手安排一次沉淀;有从这个任务沉淀出来的技能时,末尾告诉它 into 填哪份。 */
131
+ export function sedimentHint(home: string, job: Job, skills = usableAssistantSkills(home, job.assistantId)): string | undefined {
132
+ if (job.status !== "done" || job.skills?.length) return undefined;
133
+ const last = skills.filter((skill) => skill.fromJobId === job.id).sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0];
134
+ return `这个任务没带技能。用 save_job_as_skill 让它回顾一遍,把能复用的做法沉淀下来,有没有值得沉淀的由它判断;已经有同类的技能,into 填那份。${last ? `上次从这个任务沉淀出了技能「${last.title}」(${last.name}),into 填它。` : ""}`;
135
+ }
136
+
137
+ /** 发给任务所属助手的结果事件。转交结束发给 Hob 的汇总用它不带提醒的那份(delegations.ts)。 */
138
+ export function assistantJobResultEvent(home: string, job: Job, options: { prefix?: string; toolCount?: number } = {}): string {
139
+ const skills = usableAssistantSkills(home, job.assistantId);
140
+ const skillTitles = (job.skillsUsed ?? []).map((name) => skills.find((skill) => skill.name === name)?.title ?? name);
141
+ const filesNote = job.files?.length ? `\n任务交来的文件:${job.files.join("、")}。` : "";
142
+ const text = jobResultEvent(job, options.prefix ?? `任务「${job.title}」`, skillTitles, options.toolCount ?? job.stats.reads + job.stats.commands + job.stats.edits, sedimentHint(home, job, skills));
143
+ return `${text}${filesNote}`;
144
+ }
145
+
130
146
  function trim(value: string, max = 240): string {
131
147
  const oneLine = value.replace(/\s+/g, " ").trim();
132
148
  return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
@@ -161,7 +177,6 @@ export class JobManager {
161
177
  private followUps = new Map<string, string>();
162
178
  private finishing = new Map<string, Promise<void>>();
163
179
  private sediments = new Map<string, { cancelled: boolean; session?: AgentSession; task?: Promise<void> }>();
164
- private replied = new Set<string>();
165
180
  private watchdog?: NodeJS.Timeout;
166
181
 
167
182
  constructor(private readonly runtime: HobRuntime) {}
@@ -262,6 +277,7 @@ export class JobManager {
262
277
  ...(picked.modelReason ? { modelReason: picked.modelReason } : {}),
263
278
  ...(input.files?.length ? { inputs: input.files } : {}),
264
279
  ...(input.routineId ? { routineId: input.routineId } : {}),
280
+ ...(input.skills?.length ? { skills: input.skills } : {}),
265
281
  ...(input.delegationId ? { delegationId: input.delegationId } : {}),
266
282
  };
267
283
  this.store.saveJob(job);
@@ -338,7 +354,8 @@ export class JobManager {
338
354
  if (assistant?.primary) customTools.push(overviewTool(this.runtime));
339
355
  // MCP:配置里有启用中的服务才加载(改完配置,之后新开的任务生效)
340
356
  const mcp = mcpEnabled(this.runtime.home);
341
- const ownSkills = enabledAssistantSkills(this.runtime.home, running.assistantId);
357
+ const allOwnSkills = enabledAssistantSkills(this.runtime.home, running.assistantId);
358
+ const ownSkills = allOwnSkills.filter((skill) => running.skills?.includes(skill.name));
342
359
  const ownNames = new Set(ownSkills.map((skill) => skill.name));
343
360
  const opened = await this.runtime.pi.open({
344
361
  file: this.store.workerFile(running.id),
@@ -349,7 +366,7 @@ export class JobManager {
349
366
  skills: [...ownSkills, ...availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled).filter((skill) => !ownNames.has(skill.name))],
350
367
  disabledSkills: this.runtime.settings.settings.skills?.disabled,
351
368
  systemPrompt: () =>
352
- buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities, knowledgeNames: knowledge.map((collection) => collection.name) }),
369
+ buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities, desktopEnabled: !!desktop, knowledgeNames: knowledge.map((collection) => collection.name) }),
353
370
  tools: [
354
371
  ...workerTools(abilities),
355
372
  ...customTools.map((tool) => tool.name),
@@ -568,7 +585,7 @@ export class JobManager {
568
585
  await this.finish(worker.job.id, "stuck", undefined, "未提交结果");
569
586
  return;
570
587
  }
571
- await session.prompt("你还没有调用 report 提交结果。请现在调用 report(outcome, result)。");
588
+ await session.prompt("这一轮结束了,还没有交结果。做完了就调用 report 交结果;没做完,也用 report 说明做到哪、卡在哪。");
572
589
  }
573
590
 
574
591
  /** report 工具收到结果后由 worker-tools 调用。 */
@@ -600,13 +617,6 @@ export class JobManager {
600
617
  const task = this.finishOnce(jobId, status, summary, reason, files);
601
618
  this.finishing.set(jobId, task);
602
619
  try { await task; } finally { this.finishing.delete(jobId); }
603
- const job = this.store.job(jobId);
604
- const steps = status === "done" && job?.status === "done" ? this.transcript(jobId) : [];
605
- // 自动沉淀:用户中途接着说过话;或者没照着技能做、而且是定时执行或动手 20 次以上。
606
- // 照着技能顺利做完的不再沉淀,免得每天跑完都改一版。
607
- const followed = this.replied.has(jobId) || steps.some((step) => step.from === "user" && step.text !== "沉淀技能");
608
- const fresh = !job?.skillsUsed?.length && (Boolean(job?.routineId) || steps.filter((step) => step.kind === "tool").length >= 20);
609
- if (job?.status === "done" && status === "done" && (followed || fresh)) this.startSediment(jobId);
610
620
  }
611
621
 
612
622
  private async finishOnce(jobId: string, status: JobStatus, summary?: string, reason?: string, files?: string[]): Promise<void> {
@@ -677,7 +687,7 @@ export class JobManager {
677
687
  jobId: job.id,
678
688
  });
679
689
  this.bus.emit({ type: "profile", assistantId: job.assistantId });
680
- const filesNote = next.files?.length ? `\n任务交来的文件:${next.files.join("、")}。要给用户看,用 send_file 发。` : "";
690
+ const toolCount = this.transcript(job.id).filter((step) => step.kind === "tool" && (!next.startedAt || step.at >= next.startedAt)).length;
681
691
 
682
692
  if (job.routineId) {
683
693
  const routine = this.store.routine(job.routineId);
@@ -693,13 +703,13 @@ export class JobManager {
693
703
  this.store.saveRoutine(updated);
694
704
  this.bus.emit({ type: "routine", routine: updated });
695
705
  await this.runtime.router
696
- .event(job.assistantId, `${jobResultEvent(next, `定时任务「${routine.name}」`)}${filesNote}`, {
706
+ .event(job.assistantId, assistantJobResultEvent(this.runtime.home, next, { prefix: `定时任务「${routine.name}」`, toolCount }), {
697
707
  routine: { id: routine.id, name: routine.name, jobId: job.id },
698
708
  })
699
709
  .catch(() => undefined);
700
710
  }
701
711
  } else if (assistant) {
702
- const text = `${jobResultEvent(next)}${filesNote}`;
712
+ const text = assistantJobResultEvent(this.runtime.home, next, { toolCount });
703
713
  if (delegationId) void this.runtime.router.relay(job.assistantId, text, delegationId).catch(() => this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败", true));
704
714
  else await this.runtime.router.event(job.assistantId, text).catch(() => undefined);
705
715
  }
@@ -713,11 +723,10 @@ export class JobManager {
713
723
  await sediment.session?.abort().catch(() => undefined);
714
724
  await sediment.task;
715
725
  }
716
- this.replied.add(jobId);
717
726
  const job = this.store.job(jobId);
718
727
  if (!job) throw new Error("没有这个任务。");
719
728
  if (ENDED.includes(job.status)) {
720
- const { endedAt, statusReason, durationMs, summary, files, reportPath, ...rest } = job;
729
+ const { endedAt, statusReason, durationMs, summary, files, reportPath, sediment, ...rest } = job;
721
730
  const previous = job.rounds ?? [];
722
731
  // 这一轮的报告另存一份:下一轮写报告会覆盖原文件。原文件不在了就不存,不让续做因此失败
723
732
  const savedReport = reportPath && existsSync(reportPath) ? join(dirname(reportPath), `report.round-${previous.length + 1}.md`) : undefined;
@@ -734,7 +743,7 @@ export class JobManager {
734
743
  this.store.saveJob(job);
735
744
  throw error;
736
745
  }
737
- this.followUps.set(jobId, `用户接着说(${monthDayTimeText(this.runtime.settings.settings.general.timezone)}):${text}`);
746
+ this.followUps.set(jobId, `${source === "router" ? `「${this.store.assistant(job.assistantId)?.name ?? job.assistantId}」` : "用户"}接着说(${monthDayTimeText(this.runtime.settings.settings.general.timezone)}):${text}`);
738
747
  this.bus.emit({ type: "job", job: queued });
739
748
  this.publishBusy(job.assistantId);
740
749
  this.queue.push(jobId);
@@ -751,7 +760,7 @@ export class JobManager {
751
760
  : { ...asking, status: "expired" };
752
761
  this.store.saveApproval(answered);
753
762
  this.bus.emit({ type: "approval", approval: answered });
754
- this.resolveAsk(answered, source === "user" ? `用户没有选给出的选项,而是在对话里回复:${text}` : `助手转达:${text}`);
763
+ this.resolveAsk(answered, source === "user" ? `用户没有选给出的选项,而是在对话里回复:${text}` : `「${this.store.assistant(job.assistantId)?.name ?? job.assistantId}」回复:${text}`);
755
764
  return;
756
765
  }
757
766
  // 先显示出来:任务在跑命令时要等这一步做完才读到,界面上标着「等待读取」
@@ -767,45 +776,66 @@ export class JobManager {
767
776
  }
768
777
  }
769
778
 
770
- startSediment(jobId: string, source: "manual" | "auto" = "auto"): void {
779
+ private setSediment(jobId: string, sediment: SedimentStatus): void {
780
+ const job = this.store.job(jobId);
781
+ if (!job) return;
782
+ const next = { ...job, sediment };
783
+ this.store.saveJob(next);
784
+ this.bus.emit({ type: "job", job: next });
785
+ }
786
+
787
+ startSediment(jobId: string, options: { into?: string; note?: string } = {}): void {
771
788
  const job = this.store.job(jobId);
772
789
  if (!job) throw new Error("任务不存在");
773
790
  if (job.status !== "done" || this.finishing.has(jobId) || this.workers.has(jobId)) throw new Error("只有已完成的任务可以沉淀");
774
791
  if (this.sediments.has(jobId)) throw new Error("任务正在沉淀技能");
792
+ const skills = usableAssistantSkills(this.runtime.home, job.assistantId);
793
+ const into = options.into ? skills.find((skill) => skill.name === options.into) : undefined;
794
+ if (options.into && !into) throw new Error(`没有这个技能:${options.into}。现有的:${skills.map((skill) => skill.name).join("、") || "无"}`);
775
795
  const state: { cancelled: boolean; session?: AgentSession; task?: Promise<void> } = { cancelled: false };
776
796
  this.sediments.set(jobId, state);
777
- state.task = this.sedimentRound(job, state, source).catch(async (error) => {
797
+ this.setSediment(jobId, { status: "running" });
798
+ state.task = this.sedimentRound(job, state, into, options.note).catch(async (error) => {
778
799
  if (state.cancelled) return;
779
800
  const message = error instanceof Error ? error.message : String(error);
780
801
  console.error("沉淀技能失败:", error);
802
+ this.setSediment(jobId, { status: "failed", reason: message });
781
803
  await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "info", text: `技能沉淀失败:${message}` }).catch((noteError) => console.error("写入沉淀失败提示失败:", noteError));
782
- }).finally(() => {
783
- this.sediments.delete(jobId);
784
- this.bus.emit({ type: "sediment-finished", jobId });
804
+ }).finally(async () => {
805
+ try {
806
+ const sediment = this.store.job(jobId)?.sediment;
807
+ if (!sediment || sediment.status === "running") return;
808
+ const result = sediment.status === "saved" ? (sediment.updated ? `已更新技能「${sediment.title}」(${sediment.name})· 第 ${sediment.version} 版` : `已沉淀技能「${sediment.title}」(${sediment.name})`) : sediment.status === "empty" ? `没有沉淀,${sediment.reason}` : `沉淀失败,${sediment.reason}`;
809
+ await this.runtime.router.event(job.assistantId, `任务「${job.title}」(${job.id})沉淀完了:${result}。`).catch((error) => console.error("发送沉淀结果失败:", error));
810
+ } finally { this.sediments.delete(jobId); }
785
811
  });
786
812
  }
787
813
 
788
- private async sedimentRound(job: Job, state: { cancelled: boolean; session?: AgentSession }, source: "manual" | "auto"): Promise<void> {
789
- let calledSave = false;
814
+ private async sedimentRound(job: Job, state: { cancelled: boolean; session?: AgentSession }, into: ReturnType<typeof usableAssistantSkills>[number] | undefined, note?: string): Promise<void> {
815
+ let saved: Extract<SedimentStatus, { status: "saved" }> | undefined;
790
816
  const savedSkills = new Set<string>();
791
817
  let lastText = "";
792
818
  const skillTool = {
793
819
  name: "save_skill", label: "保存技能",
794
- description: "保存或修补这个助手的技能。name 只用小写字母、数字和连字符,和已有技能同名就是修补,旧版会保留。content 是 SKILL.md 的正文,不用写开头的 name 和 description。files 放脚本和参考资料,路径以 scripts/ 或 references/ 开头。",
820
+ description: "保存技能。content 是 SKILL.md 的正文,开头的 name 和 description 由 Hob 写。files 放脚本和参考资料,路径以 scripts/ 或 references/ 开头:给 from(工作目录里已有文件的路径,直接复制),或者给 content。",
795
821
  parameters: Type.Object({
796
- name: Type.String({ description: "技能的目录名,如 weekly-report。" }),
822
+ ...(!into ? { name: Type.String({ description: "技能的目录名,只用小写字母、数字和连字符,如 weekly-report。" }) } : {}),
797
823
  title: Type.String({ description: "中文名,如「每周周报」。" }),
798
- description: Type.String({ description: "一句话写什么时候用,120 字以内。" }),
824
+ description: Type.String({ description: "什么时候用,一句话,120 字以内。" }),
799
825
  content: Type.String({ description: "SKILL.md 正文。" }),
800
826
  files: Type.Optional(Type.Array(Type.Object({ path: Type.String(), content: Type.Optional(Type.String()), from: Type.Optional(Type.String()) }), { description: "可选,脚本和参考资料:path 加 content,或 path 加 from(工作目录里已有文件的路径,直接复制)。" })),
801
827
  }),
802
828
  execute: async (_id: string, params: any) => {
803
- calledSave = true;
804
829
  if (state.cancelled || this.store.job(job.id)?.status !== "done") throw new Error("任务已继续,取消沉淀");
830
+ const name = into?.name ?? params.name;
831
+ const overwriteVersion = savedSkills.has(name);
832
+ if (!into && !overwriteVersion && listAssistantSkills(this.runtime.home, job.assistantId).some((skill) => skill.name === name)) {
833
+ return { content: [{ type: "text" as const, text: `「${name}」已经有了,新技能换个名字。` }], details: undefined, isError: true };
834
+ }
805
835
  const files = readSkillFiles(job.cwd ?? this.store.workDir(job.id), params.files);
806
- const overwriteVersion = savedSkills.has(params.name);
807
- const skill = saveAssistantSkill(this.runtime.home, job.assistantId, { ...params, files }, job.id, false, { overwriteVersion });
836
+ const skill = saveAssistantSkill(this.runtime.home, job.assistantId, { ...params, name, files }, job.id, false, { overwriteVersion });
808
837
  savedSkills.add(skill.name);
838
+ saved = { status: "saved", name: skill.name, title: skill.title, version: skill.version, updated: !!into };
809
839
  if (!overwriteVersion) {
810
840
  let text = skill.version === 1 ? `已沉淀技能「${skill.title}」` : `已更新技能「${skill.title}」· 第 ${skill.version} 版`;
811
841
  const routine = job.routineId && this.store.routine(job.routineId);
@@ -819,45 +849,56 @@ export class JobManager {
819
849
  return { content: [{ type: "text" as const, text: "技能已保存。" }], details: undefined };
820
850
  },
821
851
  };
822
- const skills = listAssistantSkills(this.runtime.home, job.assistantId);
823
- const existing = skills.map((skill) => `${skill.name}:${skill.title} —— ${skill.description}`).join("\n") || "无";
824
- const message = `${SEDIMENT_PROMPT_HEAD},下次照着做,更快、更稳。
825
-
826
- 技能写的是这一类事怎么做,不是这一次的经过。下次接到同类的事,照着它就能一次做成用户要的样子。
827
- - 按做事的顺序写步骤,带上要用的命令、查询和判断点;怎么判断结果对不对,写在对应的那一步里。
828
- - 用户在过程中纠正过的做法、对结果提过的要求,写进去。
829
- - 坑写成一条规则加一句原因,放在它影响的那一步。规则要离开这一次也成立:不写事情经过和这一次的数据;会变的情况不写死,写怎么查。
830
- - 同一条经验只写一次。修补时直接改掉写错的那句,不在后面追加更正。
831
- - 助手的规矩、工具说明里已经有的,不再重复。
832
- - 能写成脚本的写成只读脚本,放在 scripts/,一次输出要看的全部结果。写好后用 bash 真跑一遍,输出对得上再保存。偶尔才用得上的细节放 references/,按主题起文件名。
833
- - 会改东西的步骤只写进文档,不写进脚本,也不要执行。
834
-
835
- 不写进技能:
836
- - 临时故障和网络错误。环境没装好、没配好导致的失败,只写怎么装好、配好。
837
- - 「某个工具不能用」这类结论:环境修好以后,它还会一直挡着你。
838
- - 这次没走通的尝试:不能写成推荐做法。
839
-
840
- 下面已有同类技能的,修补它,不要另建;新建时,名字和说明按这一类事来起,不按这一次。没有值得沉淀的,就回复「没有需要沉淀的」,不调用 save_skill。
841
-
842
- 这个助手已有的技能:${existing}`;
852
+ const message = `${SEDIMENT_PROMPT_HEAD},下次接到同类的事,照着它就能一次做成用户要的样子。
853
+
854
+ 下一次的情况会和这次不一样,所以技能写这一类事怎么做,不写这一次的经过和数据。按做事的顺序写步骤,带上要用的命令、查询和判断点,怎么判断结果对不对,写在对应的那一步里。用户在过程中纠正过的做法、对结果提过的要求,最能帮下次少走弯路,要写进去。踩过的坑写成一条规则加一句原因,放在它影响的那一步;会变的情况写怎么查,不写死。每条经验只写一次,修补时直接改掉写错的那句。
855
+
856
+ 技能要照着能跑通。写进去的只读命令和查询,存之前再跑一遍,确认照着写的就能用;能写成脚本的步骤,写成只读脚本放在 scripts/,一次输出要看的全部结果,跑一遍、输出对得上再保存。会改东西的步骤只写在正文里,留给以后的任务确认后再执行。偶尔才用得上的细节放 references/,按主题起文件名。
857
+
858
+ 有几类东西留在技能外面:临时故障和网络错误,环境没装好、没配好导致的失败只写怎么装好、配好;「某个工具不能用」这类结论,环境修好以后它还会一直挡路;这次没走通的尝试。助手的规则和工具说明里已经有的,也不用重复。
859
+
860
+ ${into ? `这次在技能「${into.title}」(${into.name},第 ${into.version} 版)的基础上改,先读它现在的内容。` : "这次新建一个技能,名字和说明按这一类事来起,不按这一次。"}${note ? `\n「${this.store.assistant(job.assistantId)?.name ?? job.assistantId}」交代:${note}` : ""}
861
+ 一次性的事、几步就做完的事,或者照常做就不会走弯路的事,没有值得沉淀的,回复「没有需要沉淀的」就好。`;
843
862
  if (state.cancelled) return;
863
+ // 沉淀时只补上助手明确指定的目标技能,以及交代中点名的技能。
864
+ const mentioned = usableAssistantSkills(this.runtime.home, job.assistantId).filter((skill) => job.skills?.includes(skill.name) || skill.name === into?.name || (!!note && (note.includes(skill.name) || note.includes(skill.title))));
865
+ const own = enabledAssistantSkills(this.runtime.home, job.assistantId).filter((skill) => mentioned.some((item) => item.name === skill.name));
866
+ const ownNames = new Set(own.map((skill) => skill.name));
867
+ const common = availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled).filter((skill) => !ownNames.has(skill.name));
844
868
  const opened = await this.runtime.pi.open({
845
869
  file: this.store.workerFile(job.id), cwd: job.cwd ?? homedir(),
846
870
  model: job.model ?? this.runtime.settings.settings.models.work.ref,
847
871
  thinking: job.thinking ?? this.runtime.settings.settings.models.work.thinking,
848
872
  memory: "off", systemPrompt: () => buildWorkerPrompt(this.store, job.id, this.promptSettings()),
849
873
  tools: ["read", "ls", "grep", "find", "bash", "save_skill"], customTools: [skillTool],
874
+ skills: [...common, ...own],
850
875
  });
851
876
  state.session = opened.session;
877
+ const steps = new Map<string, JobStep>();
852
878
  opened.session.subscribe((event) => {
853
- if (event.type === "message_end" && event.message.role === "assistant") lastText = textOfContent(event.message.content).trim();
879
+ if (event.type === "message_start" && event.message.role === "user") {
880
+ this.bus.emit({ type: "job_step", jobId: job.id, step: { id: `u${event.message.timestamp}`, kind: "text", at: new Date(event.message.timestamp).toISOString(), from: "user", text: "沉淀技能" } });
881
+ } else if (event.type === "tool_execution_start") {
882
+ const step = toolStep({ id: event.toolCallId, name: event.toolName, args: event.args ?? {}, at: new Date().toISOString(), cwd: job.cwd ?? "", running: true });
883
+ steps.set(event.toolCallId, step);
884
+ this.bus.emit({ type: "job_step", jobId: job.id, step });
885
+ } else if (event.type === "tool_execution_update" || event.type === "tool_execution_end") {
886
+ const step = steps.get(event.toolCallId);
887
+ if (step) {
888
+ const text = textOfContent(event.type === "tool_execution_update" ? event.partialResult?.content : event.result?.content);
889
+ const next = withOutput(step, text, { limit: STEP_EVENT_LIMIT, ...(event.type === "tool_execution_end" ? { isError: event.isError, durationMs: event.durationMs } : {}), save: (id, value) => this.store.writeStepOutput(job.id, id, value) });
890
+ this.bus.emit({ type: "job_step", jobId: job.id, step: next });
891
+ if (event.type === "tool_execution_end") steps.delete(event.toolCallId);
892
+ }
893
+ } else if (event.type === "message_end" && event.message.role === "assistant") {
894
+ lastText = textOfContent(event.message.content).trim();
895
+ const worker = { job } as WorkerState;
896
+ this.recordSteps(worker, event.message);
897
+ }
854
898
  });
855
899
  try {
856
900
  if (!state.cancelled) await opened.session.prompt(message);
857
- if (!state.cancelled && source === "manual" && !calledSave) {
858
- const reason = lastText && lastText !== "没有需要沉淀的" ? `:${lastText}` : "";
859
- await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "skill", text: `「${job.title}」没有可沉淀的做法${reason}` });
860
- }
901
+ if (!state.cancelled) this.setSediment(job.id, saved ?? { status: "empty", reason: lastText || "没有需要沉淀的" });
861
902
  } finally { opened.session.dispose(); }
862
903
  }
863
904
 
@@ -906,7 +947,7 @@ export class JobManager {
906
947
  worker.pausedAt = Date.now();
907
948
  this.bus.emit({ type: "job", job: waiting });
908
949
  const sentence = (value: string) => /[。!?.!?]\s*$/.test(value) ? value : `${value}。`;
909
- const text = `任务「${job.title}」在等你回复:${sentence(input.title)}${input.detail ? sentence(input.detail) : ""}选项:${input.options.map((option) => `${option.key} ${option.label}`).join("、")}。自己能答就用 reply_job 回它;要用户定,就用 ask_user 问用户,拿到答复再用 reply_job 回它。`;
950
+ const text = `任务「${job.title}」(${job.id})在等你回复:${sentence(input.title)}${input.detail ? sentence(input.detail) : ""}选项:${input.options.map((option) => `${option.key} ${option.label}`).join("、")}。`;
910
951
  if (delegationId) void this.runtime.router.relay(job.assistantId, text, delegationId).catch(() => this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败", true));
911
952
  else void this.runtime.router.event(job.assistantId, text).catch((error) => console.error("任务提问投递失败:", error));
912
953
  return await answer;
@@ -981,6 +1022,8 @@ export class JobManager {
981
1022
  const next: Job = { ...job, status: "failed", endedAt: now, statusReason: "Hob 重启,任务中断" };
982
1023
  this.store.saveJob(next);
983
1024
  this.bus.emit({ type: "job", job: next });
1025
+ } else if (job.sediment?.status === "running") {
1026
+ this.setSediment(job.id, { status: "failed", reason: "Hob 重启,沉淀中断" });
984
1027
  }
985
1028
  }
986
1029
  for (const approval of this.store.approvals()) {
@@ -38,7 +38,7 @@ export function searchKnowledgeTool(runtime: HobRuntime, assistantId: string): T
38
38
  return {
39
39
  name: "search_knowledge",
40
40
  label: "查知识库",
41
- description: "在绑定给你的知识库里查内容,找出和问题最相关的几段原文,每段带出处链接。回答用到时照抄出处链接标明来源;查不到就直说知识库里没有,不要编。",
41
+ description: "在绑定给你的知识库里查内容,返回和问题最相关的几段原文,每段带出处链接。",
42
42
  parameters: Type.Object({
43
43
  query: Type.String({ description: "要查的问题或关键词,写成完整的一句效果更好。" }),
44
44
  collection: Type.Optional(Type.String({ description: "可选,只查这个知识库,填名称。" })),
@@ -41,6 +41,8 @@ export async function startHob(options: StartOptions = {}): Promise<HobServer> {
41
41
  runtime.routines.start();
42
42
  runtime.jobs.startWatchdog();
43
43
  runtime.desktopEvents.restart();
44
+ // 消耗统计第一次要扫一遍会话文件(几百 MB):先异步跑起来,服务不等它
45
+ runtime.usage.warmup();
44
46
 
45
47
  const app = createApp(runtime, { webDir: fileURLToPath(new URL("../../dist/web", import.meta.url)), home });
46
48
  const port = options.port ?? Number(env.HOB_PORT ?? config.port);
@@ -238,13 +238,13 @@ export function memoryPromptLines(provider: MemoryProvider): string[] {
238
238
  if (provider === "local") {
239
239
  return [
240
240
  "## 记忆",
241
- "你和用户是长期合作。涉及过去的偏好、决定和约定时,先用 memory_read 查看记忆;遇到值得长期记住的新事实,用 memory_write 记下来。记忆要精炼,过时的内容及时更新或删除。",
241
+ "你和用户是长期合作。用户过去的偏好、决定和约定,先用 memory_read 查,免得让用户再说一遍;新出现的、以后还用得上的事实,用 memory_write 记下来。记忆写短一些,过时的及时改掉或删掉。",
242
242
  ];
243
243
  }
244
244
  if (provider === "lore") {
245
245
  return [
246
246
  "## 记忆",
247
- "长期记忆在 Lore:过去的偏好、决定和约定先查 Lore;值得长期记住的新事实记进 Lore。读到的记忆和你刚核实的事实对不上(过时或有错),当场改对;整条已经不成立或没用了,就删掉。",
247
+ "长期记忆在 Lore。用户过去的偏好、决定和约定,先去 Lore 查,免得让用户再说一遍;新出现的、以后还用得上的事实,记进 Lore。读到的记忆和你刚核实的情况对不上,当场改对;已经不成立的,删掉,免得以后被它误导。",
248
248
  ];
249
249
  }
250
250
  return [];
package/server/src/now.ts CHANGED
@@ -13,7 +13,7 @@ export function nowBlock(runtime: HobRuntime, assistantId: string): string {
13
13
  const lines = [`【现在】${nowText(timezone, now)}`];
14
14
  const active = jobs.filter((job) => ["running", "queued", "waiting"].includes(job.status)).map((job) => {
15
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} 分钟`;
16
+ const status = job.status === "queued" ? "排队中" : job.status === "waiting" ? "等你回复" : minutes < 1 ? "刚开始" : `运行 ${minutes} 分钟`;
17
17
  return `${job.id}「${short(job.title)}」,${status}`;
18
18
  });
19
19
  if (active.length) lines.push(`进行中:${active.join(";")}`);
@@ -100,8 +100,7 @@ export function overviewTool(runtime: HobRuntime): ToolDefinition<any, any> {
100
100
  name: "overview",
101
101
  label: "查看这段时间的记录",
102
102
  description:
103
- "查各助手一段时间内的日志和任务,用于早报、日报,或回答「这段时间做了什么」。",
104
- promptSnippet: "写早报/汇总时用 overview 查看各助手这段时间的记录。",
103
+ "查各助手一段时间内的工作日志和任务,写早报、日报,或者回答「这段时间做了什么」时用。",
105
104
  parameters: Type.Object({
106
105
  sinceHours: Type.Optional(Type.Integer({ description: "往回看多少小时,默认 24,最多 168" })),
107
106
  }),
@@ -20,8 +20,8 @@ export async function renderPage(home: string, title: string, summary: string, h
20
20
  return path;
21
21
  }
22
22
 
23
- const description = `当流程、对比、架构、多步骤教程、数据表或长篇调研结论用页面比文字更易理解时使用。简短问答、闲聊或一两段能讲清的内容不要用。调用前只说一句话点题。
24
- html 只写正文的 HTML 片段(不要 <html>、<head>、<body>),标题和摘要由 Hob 放在页头,正文别再写 h1。页面离线显示:不能加载外部脚本、样式和字体,不能联网取数据;图片只能用 https 或 data 地址。
23
+ const description = `把内容做成一页图文页面,作为卡片发到对话里。
24
+ html 只写正文的 HTML 片段(不含 <html>、<head>、<body>),标题和摘要由 Hob 放在页头,正文从 h2 开始。页面离线显示:外部脚本、样式和字体加载不了,也不能联网取数据;图片只能用 https 或 data 地址。
25
25
  已有样式(亮暗自动适配),直接用:
26
26
  - 小节用 <h2>、<h3>;段落、列表、<table>、<pre><code>、<blockquote> 都已排好。
27
27
  - 并列要点:<div class="cards"><div class="card"><h3>…</h3><p>…</p></div>…</div>
@@ -29,8 +29,8 @@ html 只写正文的 HTML 片段(不要 <html>、<head>、<body>),标题
29
29
  - 步骤:<ol class="steps"><li><b>步骤名</b>说明</li>…</ol>
30
30
  - 关键数字:<div class="kpis"><div class="kpi"><b>42%</b><span>说明</span></div>…</div>
31
31
  - 提示框:<div class="note">…</div>;标签:<span class="tag">…</span>;都可加 ok / warn / bad 变色。
32
- - 图(流程、结构、时序、关系)用 <figure> 里的内联 <svg viewBox="…">,不写死宽高,配 <figcaption>。SVG 类:box 普通框、hl 强调框、line 连线、text 加 muted 为次要文字;文字默认 13px。颜色只用变量 --ink --muted --faint --line --soft --accent --accent-soft --ok --warn --bad,写成 style="fill:var(--accent)"。
33
- - 先给结论再展开;能用图、表、卡片说清的就别堆长段落;强调色只点最重要的一两处。
32
+ - 图(流程、结构、时序、关系)用 <figure> 里的内联 <svg viewBox="…">,宽高随容器,配 <figcaption>。SVG 类:box 普通框、hl 强调框、line 连线、text 加 muted 为次要文字;文字默认 13px。颜色只用变量 --ink --muted --faint --line --soft --accent --accent-soft --ok --warn --bad,写成 style="fill:var(--accent)"。
33
+ - 先给结论再展开;能用图、表、卡片说清的,就少用长段落;强调色只点最重要的一两处。
34
34
  - 需要特别的版式可以加 <style>(颜色仍用上面的变量),可以用 <script> 做交互。最多 ${MAX} 字。`;
35
35
 
36
36
  // 只给助手:任务和助手之间用 report 的 Markdown 和文件沟通,给用户怎么展示由助手决定