hob-bot 1.0.4 → 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 (81) 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/package.json +7 -1
  13. package/server/src/actions.ts +16 -56
  14. package/server/src/assistant-skills.ts +201 -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/jobs.ts +280 -41
  28. package/server/src/knowledge-index.ts +123 -0
  29. package/server/src/knowledge-tools.ts +94 -0
  30. package/server/src/knowledge.ts +451 -0
  31. package/server/src/lore-entry.ts +10 -0
  32. package/server/src/main.ts +6 -0
  33. package/server/src/mcp.ts +745 -0
  34. package/server/src/memory.ts +11 -4
  35. package/server/src/now.ts +33 -0
  36. package/server/src/overview.ts +1 -1
  37. package/server/src/pages.ts +1 -1
  38. package/server/src/pi.ts +92 -16
  39. package/server/src/prompt.ts +46 -156
  40. package/server/src/providers.ts +3 -2
  41. package/server/src/push.ts +39 -5
  42. package/server/src/router-activity.ts +4 -0
  43. package/server/src/router-tools.ts +132 -38
  44. package/server/src/router.ts +153 -17
  45. package/server/src/routines.ts +14 -4
  46. package/server/src/runtime.ts +15 -0
  47. package/server/src/settings.ts +43 -1
  48. package/server/src/store.ts +4 -3
  49. package/server/src/transcript.ts +57 -4
  50. package/server/src/worker-tools.ts +14 -12
  51. package/shared/knowledge.ts +92 -0
  52. package/shared/mcp.ts +91 -0
  53. package/shared/skills.ts +18 -0
  54. package/shared/types.ts +103 -20
  55. package/vendor/claude-bridge/HOB-PATCH.md +10 -0
  56. package/vendor/claude-bridge/LICENSE +21 -0
  57. package/vendor/claude-bridge/package.json +62 -0
  58. package/vendor/claude-bridge/src/agents-md.ts +14 -0
  59. package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
  60. package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
  61. package/vendor/claude-bridge/src/attachments.ts +135 -0
  62. package/vendor/claude-bridge/src/config.ts +105 -0
  63. package/vendor/claude-bridge/src/convert.ts +222 -0
  64. package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
  65. package/vendor/claude-bridge/src/index.ts +2503 -0
  66. package/vendor/claude-bridge/src/log-paths.ts +19 -0
  67. package/vendor/claude-bridge/src/mcp-server.ts +169 -0
  68. package/vendor/claude-bridge/src/models.ts +164 -0
  69. package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
  70. package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
  71. package/vendor/claude-bridge/src/query-state.ts +118 -0
  72. package/vendor/claude-bridge/src/rate-limit.ts +48 -0
  73. package/vendor/claude-bridge/src/session-cwd.ts +38 -0
  74. package/vendor/claude-bridge/src/session-verify.ts +38 -0
  75. package/vendor/claude-bridge/src/skills.ts +20 -0
  76. package/vendor/claude-bridge/src/transcript.ts +86 -0
  77. package/vendor/claude-bridge/src/usage.ts +53 -0
  78. package/vendor/lore-pi-extension/package.json +1 -1
  79. package/vendor/lore-pi-extension/tools.ts +8 -5
  80. package/dist/web/assets/index-BCGEZvgg.css +0 -1
  81. package/dist/web/assets/index-BqkIAlk9.js +0 -258
@@ -1,8 +1,8 @@
1
1
  import { homedir } from "node:os";
2
- import { existsSync, readFileSync, statSync } from "node:fs";
3
- import { basename, extname } from "node:path";
2
+ import { copyFileSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import type { AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
5
- import type { ImageContent } from "@earendil-works/pi-ai";
5
+ import { Type, type ImageContent } from "@earendil-works/pi-ai";
6
6
  import { commandSummary } from "../../shared/command.ts";
7
7
  import type {
8
8
  ActivityLine,
@@ -10,23 +10,28 @@ import type {
10
10
  ApprovalOption,
11
11
  Job,
12
12
  JobsPage,
13
+ JobRound,
13
14
  JobStatus,
14
15
  JobStep,
15
16
  RoutineRun,
16
17
  } from "../../shared/types.ts";
17
18
  import { IMAGE_TYPES, isImagePath } from "../../shared/media.ts";
18
19
  import { newId } from "./ids.ts";
20
+ import { availableDesktopTools, type DesktopToolSession } from "./desktop-tools.ts";
19
21
  import { imageConfigured } from "./image.ts";
22
+ import { boundKnowledge, findKnowledgeTool, searchKnowledgeTool } from "./knowledge-tools.ts";
23
+ import { MCP_SESSION_TOOLS, mcpEnabled, mcpServerDisplay } from "./mcp.ts";
20
24
  import { jobModel } from "./models.ts";
21
25
  import { overviewTool } from "./overview.ts";
22
26
  import { pageTool } from "./pages.ts";
23
27
  import { pushApproval, pushJobEnd } from "./push.ts";
24
28
  import { buildWorkerPrompt, type Abilities, type PromptSettings } from "./prompt.ts";
25
29
  import { availableSkills } from "./skills.ts";
30
+ import { enabledAssistantSkills, listAssistantSkills, readSkillFiles, recordAssistantSkillUse, saveAssistantSkill, usableAssistantSkills } from "./assistant-skills.ts";
26
31
  import type { HobRuntime } from "./runtime.ts";
27
32
  import { showPath } from "./text.ts";
28
33
  import { monthDayTimeText } from "./time.ts";
29
- import { STEP_EVENT_LIMIT, deriveSteps, textOfContent, toolStep, withOutput } from "./transcript.ts";
34
+ import { SEDIMENT_PROMPT_HEAD, STEP_EVENT_LIMIT, deriveSteps, textOfContent, toolStep, withOutput } from "./transcript.ts";
30
35
  import { registerAskTool, registerImageTool, registerReportTool } from "./worker-tools.ts";
31
36
  import { searchTools } from "./search.ts";
32
37
 
@@ -85,7 +90,7 @@ interface WorkerState {
85
90
  job: Job;
86
91
  session?: AgentSession;
87
92
  manager?: SessionManager;
88
- reported?: { outcome: "done" | "stuck" | "need_approval"; summary: string; files?: string[] };
93
+ reported?: { outcome: "done" | "stuck" | "need_approval"; result: string; files?: string[] };
89
94
  lastText?: string;
90
95
  askedFollowUp: boolean;
91
96
  lastActivityAt: number;
@@ -95,12 +100,26 @@ interface WorkerState {
95
100
  stopped?: boolean;
96
101
  /** 正在跑的工具步骤,按 toolCallId 存:输出更新、跑完都按同一个步骤 id 替换 */
97
102
  steps: Map<string, JobStep>;
103
+ readPaths: Map<string, string>;
98
104
  /** 你补的话已交给任务、还没被读到(任务做完当前这一步才读);读到时换成正式的一行 */
99
105
  queued: JobStep[];
106
+ /** 任务过程里 MCP 工具的显示名(转义后的服务名 → 配置里的原名) */
107
+ mcpServers: Record<string, string>;
108
+ desktop?: DesktopToolSession;
100
109
  }
101
110
 
102
111
  /** 推给助手的任务结果最多带这么多字的报告。 */
103
- const EVENT_SUMMARY_MAX = 6000;
112
+ const EVENT_SUMMARY_MAX = 3000;
113
+
114
+ export function jobResultEvent(job: Job, prefix = `任务「${job.title}」`): string {
115
+ const status = job.status === "done" ? "已完成" : job.status === "stuck" ? "受阻" : job.status === "stopped" ? "已停止" : "失败";
116
+ const result = job.summary;
117
+ const header = `${prefix}(${job.id})${status}。`;
118
+ if (!result) return `${header}\n${job.statusReason ?? "没有结果"}`;
119
+ return result.length > EVENT_SUMMARY_MAX
120
+ ? `${header}\n结果(共 ${result.length} 字,以下是前 3,000 字;全文:${job.reportPath}):\n${result.slice(0, EVENT_SUMMARY_MAX)}`
121
+ : `${header}\n结果:\n${result}`;
122
+ }
104
123
 
105
124
  function trim(value: string, max = 240): string {
106
125
  const oneLine = value.replace(/\s+/g, " ").trim();
@@ -133,6 +152,10 @@ function activityFor(toolName: string, args: Record<string, unknown>, cwd = ""):
133
152
  export class JobManager {
134
153
  private workers = new Map<string, WorkerState>();
135
154
  private queue: string[] = [];
155
+ private followUps = new Map<string, string>();
156
+ private finishing = new Map<string, Promise<void>>();
157
+ private sediments = new Map<string, { cancelled: boolean; session?: AgentSession; task?: Promise<void> }>();
158
+ private replied = new Set<string>();
136
159
  private watchdog?: NodeJS.Timeout;
137
160
 
138
161
  constructor(private readonly runtime: HobRuntime) {}
@@ -175,6 +198,7 @@ export class JobManager {
175
198
  const steps = deriveSteps(this.store.workerFile(jobId), {
176
199
  cwd: job.cwd ?? "",
177
200
  save: (stepId, text) => this.store.writeStepOutput(jobId, stepId, text),
201
+ mcpServers: mcpServerDisplay(this.runtime.home),
178
202
  });
179
203
  return [...steps, ...(this.workers.get(jobId)?.queued ?? [])];
180
204
  }
@@ -182,6 +206,18 @@ export class JobManager {
182
206
  async start(assistantId: string, input: StartJobInput): Promise<Job> {
183
207
  const assistant = this.store.assistant(assistantId);
184
208
  if (!assistant) throw new Error(`助手不存在:${assistantId}`);
209
+ if (input.cwd?.trim()) {
210
+ const canonical = (path: string): string => {
211
+ const absolute = resolve(path);
212
+ let parent = absolute;
213
+ while (!existsSync(parent) && dirname(parent) !== parent) parent = dirname(parent);
214
+ return resolve(realpathSync(parent), relative(parent, absolute));
215
+ };
216
+ const path = relative(canonical(this.runtime.home), canonical(input.cwd));
217
+ if (!path || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path))) {
218
+ throw new Error("不能在 Hob 的数据目录里干活;不填 cwd 就在任务自己的目录里做。");
219
+ }
220
+ }
185
221
  await this.runtime.models.get();
186
222
  const requested =
187
223
  input.model || input.thinking
@@ -215,8 +251,7 @@ export class JobManager {
215
251
  };
216
252
  this.store.saveJob(job);
217
253
  if (job.delegationId) this.runtime.delegations.addJob(job.delegationId, job.id);
218
- // 定时跑的任务不出卡片:结果以带「⏱ 名字」的回复回到对话,过程在右栏和运行记录里看
219
- if (!input.routineId) await this.runtime.router.addItem(assistantId, { kind: "job", jobId: job.id });
254
+ await this.runtime.router.addItem(assistantId, { kind: "job", jobId: job.id });
220
255
  this.bus.emit({ type: "job", job });
221
256
  this.publishBusy(assistantId);
222
257
  this.queue.push(job.id);
@@ -249,7 +284,9 @@ export class JobManager {
249
284
  const id = this.queue.shift()!;
250
285
  const job = this.store.job(id);
251
286
  if (!job || job.status !== "queued") continue;
252
- void this.launch(job).catch((error) => this.failLaunch(job, error));
287
+ const followUp = this.followUps.get(id);
288
+ this.followUps.delete(id);
289
+ void this.launch(job, followUp).catch((error) => this.failLaunch(job, error));
253
290
  }
254
291
  }
255
292
 
@@ -258,7 +295,7 @@ export class JobManager {
258
295
  await this.finish(job.id, "failed", undefined, message);
259
296
  }
260
297
 
261
- private async launch(job: Job): Promise<void> {
298
+ private async launch(job: Job, followUp?: string): Promise<void> {
262
299
  const running: Job = {
263
300
  ...job,
264
301
  status: "running",
@@ -274,28 +311,41 @@ export class JobManager {
274
311
  ...baseAbilities,
275
312
  image: baseAbilities.image && imageConfigured(this.runtime.settings.settings.image),
276
313
  };
277
- const customTools = [registerReportTool(this.runtime, running.id), registerAskTool(this.runtime, running.id)];
314
+ const knowledge = await boundKnowledge(this.runtime, running.assistantId);
315
+ const customTools = [registerReportTool(this.runtime, running.id), registerAskTool(this.runtime, running.id), findKnowledgeTool(this.runtime)];
316
+ if (knowledge.length) customTools.push(searchKnowledgeTool(this.runtime, running.assistantId));
278
317
  if (abilities.image) customTools.push(registerImageTool(this.runtime, running.id));
279
318
  if (this.runtime.settings.settings.general.showPage !== false) customTools.push(pageTool(this.runtime, running.assistantId, false));
280
319
  customTools.push(...searchTools(this.runtime.home, () => this.runtime.settings.settings.search));
320
+ const desktop = await availableDesktopTools(this.runtime, running.assistantId, !!this.runtime.models.find(running.model ?? this.runtime.settings.settings.models.work.ref)?.input.includes("image"));
321
+ if (this.store.job(running.id)?.status !== "running") { await desktop?.dispose(); return; }
322
+ if (desktop) customTools.push(...desktop.tools());
281
323
  // 定时跑的就是 Worker:主助手的任务要看得到各助这段时间的动静才能写早报
282
324
  if (assistant?.primary) customTools.push(overviewTool(this.runtime));
325
+ // MCP:配置里有启用中的服务才加载(改完配置,之后新开的任务生效)
326
+ const mcp = mcpEnabled(this.runtime.home);
327
+ const ownSkills = enabledAssistantSkills(this.runtime.home, running.assistantId);
328
+ const ownNames = new Set(ownSkills.map((skill) => skill.name));
283
329
  const opened = await this.runtime.pi.open({
284
330
  file: this.store.workerFile(running.id),
285
331
  cwd: running.cwd ?? homedir(),
286
332
  model: running.model ?? this.runtime.settings.settings.models.work.ref,
287
333
  thinking: running.thinking ?? this.runtime.settings.settings.models.work.thinking,
288
334
  memory: this.runtime.settings.memory().provider,
289
- skills: availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled),
335
+ skills: [...ownSkills, ...availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled).filter((skill) => !ownNames.has(skill.name))],
290
336
  disabledSkills: this.runtime.settings.settings.skills?.disabled,
291
337
  systemPrompt: () =>
292
- buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities }),
338
+ buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities, knowledgeNames: knowledge.map((collection) => collection.name) }),
293
339
  tools: [
294
340
  ...workerTools(abilities),
295
341
  ...customTools.map((tool) => tool.name),
342
+ ...(mcp ? MCP_SESSION_TOOLS : []),
296
343
  ],
297
344
  customTools,
345
+ mcp,
298
346
  });
347
+ if (this.store.job(running.id)?.status !== "running") { opened.session.dispose(); await desktop?.dispose(); return; }
348
+ const mcpServers = mcpServerDisplay(this.runtime.home);
299
349
 
300
350
  const worker: WorkerState = {
301
351
  job: running,
@@ -304,13 +354,23 @@ export class JobManager {
304
354
  askedFollowUp: false,
305
355
  lastActivityAt: Date.now(),
306
356
  steps: new Map(),
357
+ readPaths: new Map(),
307
358
  queued: [],
359
+ mcpServers,
360
+ desktop,
308
361
  };
309
362
  this.workers.set(running.id, worker);
310
- const images = imageContents(running.inputs);
363
+ if (followUp !== undefined) {
364
+ const step: JobStep = { id: `q${Date.now()}-0`, kind: "text", at: new Date().toISOString(), from: "user", text: followUp, queued: true };
365
+ worker.queued.push(step);
366
+ this.emitStep(worker, step);
367
+ }
368
+ const images = followUp === undefined ? imageContents(running.inputs) : [];
311
369
 
312
370
  opened.session.subscribe((event) => {
371
+ if (worker.stopped) return;
313
372
  if (event.type === "tool_execution_start") {
373
+ if (event.toolName === "read" && typeof event.args?.path === "string") worker.readPaths.set(event.toolCallId, event.args.path);
314
374
  this.recordActivity(worker, event.toolName, event.args ?? {});
315
375
  const step = toolStep({
316
376
  id: event.toolCallId,
@@ -318,6 +378,7 @@ export class JobManager {
318
378
  args: event.args ?? {},
319
379
  at: new Date().toISOString(),
320
380
  cwd: worker.job.cwd ?? "",
381
+ mcpServers: worker.mcpServers,
321
382
  running: true,
322
383
  });
323
384
  worker.steps.set(event.toolCallId, step);
@@ -330,9 +391,17 @@ export class JobManager {
330
391
  return;
331
392
  }
332
393
  if (event.type === "tool_execution_end") {
394
+ if (event.toolName === "read" && !event.isError) {
395
+ const readPath = worker.readPaths.get(event.toolCallId);
396
+ if (readPath) {
397
+ try { this.recordSkillUse(worker, readPath); }
398
+ catch (error) { console.error("记录技能用量失败:", error); }
399
+ }
400
+ }
401
+ worker.readPaths.delete(event.toolCallId);
333
402
  const step =
334
403
  worker.steps.get(event.toolCallId) ??
335
- toolStep({ id: event.toolCallId, name: event.toolName, args: {}, cwd: worker.job.cwd ?? "" });
404
+ toolStep({ id: event.toolCallId, name: event.toolName, args: {}, cwd: worker.job.cwd ?? "", mcpServers: worker.mcpServers });
336
405
  worker.steps.delete(event.toolCallId);
337
406
  this.emitStep(
338
407
  worker,
@@ -368,12 +437,28 @@ export class JobManager {
368
437
  });
369
438
 
370
439
  void opened.session
371
- .prompt(running.instruction, images.length ? { images } : undefined)
440
+ .prompt(followUp ?? running.instruction, images.length ? { images } : undefined)
372
441
  .catch(async (error: unknown) => {
373
- await this.finish(running.id, "failed", undefined, error instanceof Error ? error.message : String(error));
442
+ if (!worker.stopped) await this.finish(running.id, "failed", undefined, error instanceof Error ? error.message : String(error));
374
443
  });
375
444
  }
376
445
 
446
+ private recordSkillUse(worker: WorkerState, path: string): void {
447
+ const job = this.store.job(worker.job.id);
448
+ if (!job) return;
449
+ let absolute: string;
450
+ try { absolute = realpathSync(resolve(job.cwd ?? homedir(), path)); }
451
+ catch { return; }
452
+ const skill = usableAssistantSkills(this.runtime.home, job.assistantId).find((item) => existsSync(item.path) && realpathSync(item.path) === absolute);
453
+ if (!skill || job.skillsUsed?.includes(skill.name)) return;
454
+ recordAssistantSkillUse(this.runtime.home, job.assistantId, skill.name);
455
+ const next = { ...job, skillsUsed: [...(job.skillsUsed ?? []), skill.name] };
456
+ this.store.saveJob(next);
457
+ worker.job = next;
458
+ this.bus.emit({ type: "job", job: next });
459
+ this.bus.emit({ type: "skills", assistantId: job.assistantId });
460
+ }
461
+
377
462
  private recordActivity(worker: WorkerState, toolName: string, args: Record<string, unknown>): void {
378
463
  worker.lastActivityAt = Date.now();
379
464
  const line = activityFor(toolName, args);
@@ -467,7 +552,7 @@ export class JobManager {
467
552
  await this.finish(worker.job.id, "stuck", undefined, "未提交结果");
468
553
  return;
469
554
  }
470
- await session.prompt("你还没有调用 report 提交结果。请现在调用 report(outcome, summary)。");
555
+ await session.prompt("你还没有调用 report 提交结果。请现在调用 report(outcome, result)。");
471
556
  }
472
557
 
473
558
  /** report 工具收到结果后由 worker-tools 调用。 */
@@ -477,10 +562,10 @@ export class JobManager {
477
562
  const status: JobStatus = worker.reported.outcome === "done" ? "done" : "stuck";
478
563
  // 不写原因:outcome 只说明模型自己认为没做完,状态词(受阻、待确认)卡片上已经写过一遍,
479
564
  // 当原因存下来就会重复。真正的原因由看门狗和停止操作给。
480
- await this.finish(workerId, status, worker.reported.summary, undefined, worker.reported.files);
565
+ await this.finish(workerId, status, worker.reported.result, undefined, worker.reported.files);
481
566
  }
482
567
 
483
- reportFromTool(jobId: string, data: { outcome: "done" | "stuck" | "need_approval"; summary: string; files?: string[] }): void {
568
+ reportFromTool(jobId: string, data: { outcome: "done" | "stuck" | "need_approval"; result: string; files?: string[] }): void {
484
569
  const worker = this.workers.get(jobId);
485
570
  if (!worker) return;
486
571
  worker.reported = data;
@@ -494,10 +579,28 @@ export class JobManager {
494
579
  reason?: string,
495
580
  files?: string[],
496
581
  ): Promise<void> {
582
+ const pending = this.finishing.get(jobId);
583
+ if (pending) return pending;
584
+ const task = this.finishOnce(jobId, status, summary, reason, files);
585
+ this.finishing.set(jobId, task);
586
+ try { await task; } finally { this.finishing.delete(jobId); }
587
+ const job = this.store.job(jobId);
588
+ const steps = status === "done" && job?.status === "done" ? this.transcript(jobId) : [];
589
+ // 自动沉淀:用户中途接着说过话;或者没照着技能做、而且是定时执行或动手 20 次以上。
590
+ // 照着技能顺利做完的不再沉淀,免得每天跑完都改一版。
591
+ const followed = this.replied.has(jobId) || steps.some((step) => step.from === "user" && step.text !== "沉淀技能");
592
+ const fresh = !job?.skillsUsed?.length && (Boolean(job?.routineId) || steps.filter((step) => step.kind === "tool").length >= 20);
593
+ if (job?.status === "done" && status === "done" && (followed || fresh)) this.startSediment(jobId);
594
+ }
595
+
596
+ private async finishOnce(jobId: string, status: JobStatus, summary?: string, reason?: string, files?: string[]): Promise<void> {
497
597
  const job = this.store.job(jobId);
498
598
  if (!job || ENDED.includes(job.status)) return;
499
599
  const worker = this.workers.get(jobId);
600
+ if (worker) worker.stopped = true;
500
601
  if (worker?.finishTimer) clearTimeout(worker.finishTimer);
602
+ this.followUps.delete(jobId);
603
+ await worker?.desktop?.dispose();
501
604
  await this.settle(job, status, { summary, reason, files, lastText: worker?.lastText });
502
605
 
503
606
  if (worker?.session) {
@@ -515,10 +618,7 @@ export class JobManager {
515
618
  ): Promise<void> {
516
619
  const endedAt = new Date().toISOString();
517
620
  // 报告原样保留(不压成一行、不截断):任务详情和 report.md 要完整的;只有推给助手的事件太长时才截
518
- const cleanSummary = options.summary?.trim() || undefined;
519
- const eventSummary = cleanSummary && cleanSummary.length > EVENT_SUMMARY_MAX
520
- ? `${cleanSummary.slice(0, EVENT_SUMMARY_MAX)}…(报告过长已截断,完整内容在任务详情)`
521
- : cleanSummary;
621
+ const cleanSummary = options.summary?.trim() ? options.summary : undefined;
522
622
  const statusText = status === "done" ? "已完成" : status === "stuck" ? "受阻" : status === "stopped" ? "已停止" : "失败";
523
623
  const reportPath = this.store.writeReport(
524
624
  job.id,
@@ -528,13 +628,9 @@ export class JobManager {
528
628
  `状态:${statusText}${options.reason ? `(${options.reason})` : ""}`,
529
629
  `结束时间:${monthDayTimeText(this.runtime.settings.settings.general.timezone)}`,
530
630
  "",
531
- "## 摘要",
631
+ "## 结果",
532
632
  "",
533
- cleanSummary ?? "(没有摘要)",
534
- "",
535
- "## 任务最后一条回复",
536
- "",
537
- options.lastText ?? "(没有回复)",
633
+ cleanSummary ?? options.lastText ?? "(没有回复)",
538
634
  "",
539
635
  "## 任务说明",
540
636
  "",
@@ -547,11 +643,12 @@ export class JobManager {
547
643
  endedAt,
548
644
  reportPath,
549
645
  ...(job.startedAt ? { durationMs: Date.parse(endedAt) - Date.parse(job.startedAt) } : {}),
550
- ...(cleanSummary ? { summary: cleanSummary } : {}),
646
+ ...(options.summary !== undefined ? { summary: cleanSummary } : {}),
551
647
  ...(options.reason ? { statusReason: options.reason } : {}),
552
- ...(options.files?.length ? { files: options.files } : {}),
648
+ ...(options.summary !== undefined ? { files: options.files ?? [] } : {}),
553
649
  };
554
650
  this.store.saveJob(next);
651
+ for (const path of options.files ?? []) void this.runtime.knowledge.collect({ origin: "output", path, description: job.title, assistantId: job.assistantId, jobId: job.id }).catch((error) => console.error("知识库收集失败:", error));
555
652
  // 转交里的任务:先把转交重新打开,结果由目标助手接着处理完才回流 Hob(见 DelegationManager.resume)
556
653
  const delegationId = !job.routineId && job.delegationId && this.runtime.delegations.resume(job.delegationId) ? job.delegationId : undefined;
557
654
  this.bus.emit({ type: "job", job: next });
@@ -571,20 +668,25 @@ export class JobManager {
571
668
  if (job.routineId) {
572
669
  const routine = this.store.routine(job.routineId);
573
670
  if (routine) {
574
- const run: RoutineRun = { jobId: job.id, at: endedAt, status };
671
+ const run: RoutineRun = {
672
+ jobId: job.id, at: endedAt, status,
673
+ ...(next.durationMs !== undefined ? { durationMs: next.durationMs } : {}),
674
+ tools: next.stats.reads + next.stats.commands + next.stats.edits,
675
+ ...(next.usage ? { tokens: next.usage.input + next.usage.output } : {}),
676
+ };
575
677
  const runs = [run, ...routine.runs.filter((item) => item.jobId !== job.id)].slice(0, 20);
576
678
  const updated = { ...routine, runs };
577
679
  this.store.saveRoutine(updated);
578
680
  this.bus.emit({ type: "routine", routine: updated });
579
681
  await this.runtime.router
580
- .event(job.assistantId, `定时任务「${routine.name}」结果:${eventSummary ?? options.reason ?? statusText}${filesNote}`, {
581
- routine: { id: routine.id, name: routine.name },
682
+ .event(job.assistantId, `${jobResultEvent(next, `定时任务「${routine.name}」`)}${filesNote}`, {
683
+ routine: { id: routine.id, name: routine.name, jobId: job.id },
582
684
  ...cont,
583
685
  })
584
686
  .catch(() => undefined);
585
687
  }
586
688
  } else if (assistant) {
587
- const text = `任务「${job.title}」${statusText}${eventSummary ? `:${eventSummary}` : options.reason ? `:${options.reason}` : ""}${filesNote}`;
689
+ const text = `${jobResultEvent(next)}${filesNote}`;
588
690
  if (delegationId) void this.runtime.router.relay(job.assistantId, text, delegationId, cont).catch(() => this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败", true));
589
691
  else await this.runtime.router.event(job.assistantId, text, cont).catch(() => undefined);
590
692
  }
@@ -612,14 +714,60 @@ export class JobManager {
612
714
  }
613
715
  }
614
716
  const lines: string[] = [];
615
- if (posted.length) lines.push(`产出的图片已经直接发到对话里了(${posted.join("、")}),用户看得到,不要再发或再派任务去取。`);
616
- if (others.length) lines.push(`产出文件:${others.join("、")}(要给用户就用 send_file)。`);
717
+ if (posted.length) lines.push(`图片已发到对话里:${posted.join("、")}`);
718
+ if (others.length) lines.push(`文件:${others.join("、")}`);
617
719
  return { note: lines.length ? `\n${lines.join("\n")}` : "", posted: posted.length };
618
720
  }
619
721
 
620
- async reply(jobId: string, text: string): Promise<void> {
722
+ async reply(jobId: string, text: string, source: "user" | "router" = "user"): Promise<void> {
723
+ await this.finishing.get(jobId);
724
+ const sediment = this.sediments.get(jobId);
725
+ if (sediment) {
726
+ sediment.cancelled = true;
727
+ await sediment.session?.abort().catch(() => undefined);
728
+ await sediment.task;
729
+ }
730
+ this.replied.add(jobId);
731
+ const job = this.store.job(jobId);
732
+ if (!job) throw new Error("没有这个任务。");
733
+ if (ENDED.includes(job.status)) {
734
+ const { endedAt, statusReason, durationMs, summary, files, reportPath, ...rest } = job;
735
+ const previous = job.rounds ?? [];
736
+ // 这一轮的报告另存一份:下一轮写报告会覆盖原文件。原文件不在了就不存,不让续做因此失败
737
+ const savedReport = reportPath && existsSync(reportPath) ? join(dirname(reportPath), `report.round-${previous.length + 1}.md`) : undefined;
738
+ if (reportPath && savedReport) copyFileSync(reportPath, savedReport);
739
+ const used = previous.reduce((sum, round) => ({ input: sum.input + (round.usage?.input ?? 0), output: sum.output + (round.usage?.output ?? 0), cost: sum.cost + (round.usage?.cost ?? 0) }), { input: 0, output: 0, cost: 0 });
740
+ const usage = job.usage && previous.every((round) => round.usage) ? { input: job.usage.input - used.input, output: job.usage.output - used.output, cost: Number((job.usage.cost - used.cost).toFixed(6)) } : undefined;
741
+ const ended: JobRound = { status: job.status, statusReason, startedAt: job.startedAt, endedAt, durationMs, summary, files, usage, reportPath: savedReport, followUp: text };
742
+ const rounds = [...previous, ended];
743
+ const queued: Job = { ...rest, rounds, status: "queued", startedAt: new Date().toISOString() };
744
+ this.store.saveJob(queued);
745
+ try {
746
+ await this.runtime.router.addItem(job.assistantId, { kind: "job", jobId, round: rounds.length });
747
+ } catch (error) {
748
+ this.store.saveJob(job);
749
+ throw error;
750
+ }
751
+ this.followUps.set(jobId, `用户接着说(${monthDayTimeText(this.runtime.settings.settings.general.timezone)}):${text}`);
752
+ this.bus.emit({ type: "job", job: queued });
753
+ this.publishBusy(job.assistantId);
754
+ this.queue.push(jobId);
755
+ this.pump();
756
+ return;
757
+ }
621
758
  const worker = this.workers.get(jobId);
622
759
  if (!worker?.session) throw new Error("这个任务当前未在运行。");
760
+ // 任务卡在 ask_user 等答复:用户在对话里回的话就是答复,不然插话要等提问返回才读得到,任务永远等下去
761
+ const asking = worker.ask ? this.store.approvals().find((item) => item.jobId === jobId && item.status === "pending") : undefined;
762
+ if (asking) {
763
+ const answered: Approval = source === "user"
764
+ ? { ...asking, status: "answered", answerText: text, answeredAt: new Date().toISOString() }
765
+ : { ...asking, status: "expired" };
766
+ this.store.saveApproval(answered);
767
+ this.bus.emit({ type: "approval", approval: answered });
768
+ this.resolveAsk(answered, source === "user" ? `用户没有选给出的选项,而是在对话里回复:${text}` : `助手转达:${text}`);
769
+ return;
770
+ }
623
771
  // 先显示出来:任务在跑命令时要等这一步做完才读到,界面上标着「等待读取」
624
772
  const step: JobStep = { id: `q${Date.now()}-${worker.queued.length}`, kind: "text", at: new Date().toISOString(), from: "user", text, queued: true };
625
773
  worker.queued.push(step);
@@ -633,6 +781,95 @@ export class JobManager {
633
781
  }
634
782
  }
635
783
 
784
+ startSediment(jobId: string, source: "manual" | "auto" = "auto"): void {
785
+ const job = this.store.job(jobId);
786
+ if (!job) throw new Error("任务不存在");
787
+ if (job.status !== "done" || this.finishing.has(jobId) || this.workers.has(jobId)) throw new Error("只有已完成的任务可以沉淀");
788
+ if (this.sediments.has(jobId)) throw new Error("任务正在沉淀技能");
789
+ const state: { cancelled: boolean; session?: AgentSession; task?: Promise<void> } = { cancelled: false };
790
+ this.sediments.set(jobId, state);
791
+ state.task = this.sedimentRound(job, state, source).catch(async (error) => {
792
+ if (state.cancelled) return;
793
+ const message = error instanceof Error ? error.message : String(error);
794
+ console.error("沉淀技能失败:", error);
795
+ await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "info", text: `技能沉淀失败:${message}` }).catch((noteError) => console.error("写入沉淀失败提示失败:", noteError));
796
+ }).finally(() => {
797
+ this.sediments.delete(jobId);
798
+ this.bus.emit({ type: "sediment-finished", jobId });
799
+ });
800
+ }
801
+
802
+ private async sedimentRound(job: Job, state: { cancelled: boolean; session?: AgentSession }, source: "manual" | "auto"): Promise<void> {
803
+ let calledSave = false;
804
+ let lastText = "";
805
+ const skillTool = {
806
+ name: "save_skill", label: "保存技能",
807
+ description: "保存或修补这个助手的技能。name 只用小写字母、数字和连字符,和已有技能同名就是修补,旧版会保留。content 是 SKILL.md 的正文,不用写开头的 name 和 description。files 放脚本和参考资料,路径以 scripts/ 或 references/ 开头。",
808
+ parameters: Type.Object({
809
+ name: Type.String({ description: "技能的目录名,如 gateway-daily-check。" }),
810
+ title: Type.String({ description: "中文名,如「网关每日巡检」。" }),
811
+ description: Type.String({ description: "一句话写什么时候用,120 字以内。" }),
812
+ content: Type.String({ description: "SKILL.md 正文。" }),
813
+ 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(工作目录里已有文件的路径,直接复制)。" })),
814
+ }),
815
+ execute: async (_id: string, params: any) => {
816
+ calledSave = true;
817
+ if (state.cancelled || this.store.job(job.id)?.status !== "done") throw new Error("任务已继续,取消沉淀");
818
+ const files = readSkillFiles(job.cwd ?? this.store.workDir(job.id), params.files);
819
+ const skill = saveAssistantSkill(this.runtime.home, job.assistantId, { ...params, files }, job.id);
820
+ let text = skill.version === 1 ? `已沉淀技能「${skill.title}」` : `已更新技能「${skill.title}」· 第 ${skill.version} 版`;
821
+ const routine = job.routineId && this.store.routine(job.routineId);
822
+ if (routine && !routine.skill) {
823
+ this.runtime.routines.update(routine.id, { skill: skill.name });
824
+ text = `已沉淀技能「${skill.title}」,以后定时执行按它做`;
825
+ }
826
+ await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "skill", text, path: skill.path });
827
+ this.bus.emit({ type: "skills", assistantId: job.assistantId });
828
+ return { content: [{ type: "text" as const, text: "技能已保存。" }], details: undefined };
829
+ },
830
+ };
831
+ const skills = listAssistantSkills(this.runtime.home, job.assistantId);
832
+ const existing = skills.map((skill) => `${skill.name}:${skill.title} —— ${skill.description}`).join("\n") || "无";
833
+ const message = `${SEDIMENT_PROMPT_HEAD},下次照着做,更快、更稳。
834
+
835
+ 技能写的是这一类事怎么做,不是这一次的经过。下次接到同类的事,照着它就能一次做成用户要的样子。
836
+ - 按做事的顺序写步骤,带上要用的命令、查询和判断点;怎么判断结果对不对,写在对应的那一步里。
837
+ - 用户在过程中纠正过的做法、对结果提过的要求,写进去。
838
+ - 坑写成一条规则加一句原因,放在它影响的那一步。规则要离开这一次也成立:不写事情经过和这一次的数据;会变的情况不写死,写怎么查。
839
+ - 同一条经验只写一次。修补时直接改掉写错的那句,不在后面追加更正。
840
+ - 助手的规矩、工具说明里已经有的,不再重复。
841
+ - 能写成脚本的写成只读脚本,放在 scripts/,一次输出要看的全部结果。写好后用 bash 真跑一遍,输出对得上再保存。偶尔才用得上的细节放 references/,按主题起文件名。
842
+ - 会改东西的步骤只写进文档,不写进脚本,也不要执行。
843
+
844
+ 不写进技能:
845
+ - 临时故障和网络错误。环境没装好、没配好导致的失败,只写怎么装好、配好。
846
+ - 「某个工具不能用」这类结论:环境修好以后,它还会一直挡着你。
847
+ - 这次没走通的尝试:不能写成推荐做法。
848
+
849
+ 下面已有同类技能的,修补它,不要另建;新建时,名字和说明按这一类事来起,不按这一次。没有值得沉淀的,就回复「没有需要沉淀的」,不调用 save_skill。
850
+
851
+ 这个助手已有的技能:${existing}`;
852
+ if (state.cancelled) return;
853
+ const opened = await this.runtime.pi.open({
854
+ file: this.store.workerFile(job.id), cwd: job.cwd ?? homedir(),
855
+ model: job.model ?? this.runtime.settings.settings.models.work.ref,
856
+ thinking: job.thinking ?? this.runtime.settings.settings.models.work.thinking,
857
+ memory: "off", systemPrompt: () => buildWorkerPrompt(this.store, job.id, this.promptSettings()),
858
+ tools: ["read", "ls", "grep", "find", "bash", "save_skill"], customTools: [skillTool],
859
+ });
860
+ state.session = opened.session;
861
+ opened.session.subscribe((event) => {
862
+ if (event.type === "message_end" && event.message.role === "assistant") lastText = textOfContent(event.message.content).trim();
863
+ });
864
+ try {
865
+ if (!state.cancelled) await opened.session.prompt(message);
866
+ if (!state.cancelled && source === "manual" && !calledSave) {
867
+ const reason = lastText && lastText !== "没有需要沉淀的" ? `:${lastText}` : "";
868
+ await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "skill", text: `「${job.title}」没有可沉淀的做法${reason}` });
869
+ }
870
+ } finally { opened.session.dispose(); }
871
+ }
872
+
636
873
  async stop(jobId: string): Promise<void> {
637
874
  const job = this.store.job(jobId);
638
875
  if (!job) throw new Error("没有这个任务。");
@@ -740,11 +977,13 @@ export class JobManager {
740
977
  }
741
978
  }
742
979
  this.queue = [];
980
+ this.followUps.clear();
743
981
  }
744
982
 
745
983
  disposeAll(): void {
746
984
  this.stopWatchdog();
747
- for (const worker of this.workers.values()) worker.session?.dispose();
985
+ for (const state of this.sediments.values()) { state.cancelled = true; void state.session?.abort(); }
986
+ for (const worker of this.workers.values()) { worker.session?.dispose(); void worker.desktop?.dispose(); }
748
987
  this.workers.clear();
749
988
  }
750
989
 
@@ -755,7 +994,7 @@ export class JobManager {
755
994
  const active = this.store
756
995
  .jobs()
757
996
  .filter((job) => job.status === "running" || job.status === "queued" || job.status === "waiting");
758
- if (!active.length) return;
997
+ if (!active.length && !this.finishing.size && !this.sediments.size) return;
759
998
  await new Promise((resolve) => setTimeout(resolve, 25));
760
999
  }
761
1000
  }