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
@@ -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,29 @@ 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";
25
+ import { duration, jobProcessLines } from "./job-view.ts";
21
26
  import { overviewTool } from "./overview.ts";
22
27
  import { pageTool } from "./pages.ts";
23
28
  import { pushApproval, pushJobEnd } from "./push.ts";
24
29
  import { buildWorkerPrompt, type Abilities, type PromptSettings } from "./prompt.ts";
25
30
  import { availableSkills } from "./skills.ts";
31
+ import { enabledAssistantSkills, listAssistantSkills, readSkillFiles, recordAssistantSkillUse, saveAssistantSkill, usableAssistantSkills } from "./assistant-skills.ts";
26
32
  import type { HobRuntime } from "./runtime.ts";
27
33
  import { showPath } from "./text.ts";
28
34
  import { monthDayTimeText } from "./time.ts";
29
- import { STEP_EVENT_LIMIT, deriveSteps, textOfContent, toolStep, withOutput } from "./transcript.ts";
35
+ import { SEDIMENT_PROMPT_HEAD, STEP_EVENT_LIMIT, deriveSteps, textOfContent, toolStep, withOutput } from "./transcript.ts";
30
36
  import { registerAskTool, registerImageTool, registerReportTool } from "./worker-tools.ts";
31
37
  import { searchTools } from "./search.ts";
32
38
 
@@ -78,6 +84,7 @@ export interface StartJobInput {
78
84
  model?: string;
79
85
  thinking?: string;
80
86
  routineId?: string;
87
+ expectMinutes?: number;
81
88
  delegationId?: string;
82
89
  }
83
90
 
@@ -85,22 +92,41 @@ interface WorkerState {
85
92
  job: Job;
86
93
  session?: AgentSession;
87
94
  manager?: SessionManager;
88
- reported?: { outcome: "done" | "stuck" | "need_approval"; summary: string; files?: string[] };
95
+ reported?: { outcome: "done" | "stuck" | "need_approval"; result: string; files?: string[] };
89
96
  lastText?: string;
90
97
  askedFollowUp: boolean;
91
98
  lastActivityAt: number;
99
+ pausedMs: number;
100
+ pausedAt?: number;
101
+ nextReminder: number;
92
102
  finishTimer?: NodeJS.Timeout;
93
103
  ask?: (label: string) => void;
94
104
  /** 用户叫停了:收尾期间这个 worker 的 report/追问都不再算数 */
95
105
  stopped?: boolean;
96
106
  /** 正在跑的工具步骤,按 toolCallId 存:输出更新、跑完都按同一个步骤 id 替换 */
97
107
  steps: Map<string, JobStep>;
108
+ readPaths: Map<string, string>;
98
109
  /** 你补的话已交给任务、还没被读到(任务做完当前这一步才读);读到时换成正式的一行 */
99
110
  queued: JobStep[];
111
+ /** 任务过程里 MCP 工具的显示名(转义后的服务名 → 配置里的原名) */
112
+ mcpServers: Record<string, string>;
113
+ desktop?: DesktopToolSession;
100
114
  }
101
115
 
102
116
  /** 推给助手的任务结果最多带这么多字的报告。 */
103
- const EVENT_SUMMARY_MAX = 6000;
117
+ const EVENT_SUMMARY_MAX = 3000;
118
+
119
+ export function jobResultEvent(job: Job, prefix = `任务「${job.title}」`): string {
120
+ const status = job.status === "done" ? "已完成" : job.status === "stuck" ? "受阻" : job.status === "stopped" ? "已停止" : "失败";
121
+ const result = job.summary;
122
+ const header = job.expect && job.durationMs !== undefined
123
+ ? `${prefix}(${job.id})${status},用时 ${duration(job.durationMs)},预计 ${job.expect.minutes} 分钟。`
124
+ : `${prefix}(${job.id})${status}。`;
125
+ if (!result) return `${header}\n${job.statusReason ?? "没有结果"}`;
126
+ return result.length > EVENT_SUMMARY_MAX
127
+ ? `${header}\n结果(共 ${result.length} 字,以下是前 3,000 字;全文:${job.reportPath}):\n${result.slice(0, EVENT_SUMMARY_MAX)}`
128
+ : `${header}\n结果:\n${result}`;
129
+ }
104
130
 
105
131
  function trim(value: string, max = 240): string {
106
132
  const oneLine = value.replace(/\s+/g, " ").trim();
@@ -133,6 +159,10 @@ function activityFor(toolName: string, args: Record<string, unknown>, cwd = ""):
133
159
  export class JobManager {
134
160
  private workers = new Map<string, WorkerState>();
135
161
  private queue: string[] = [];
162
+ private followUps = new Map<string, string>();
163
+ private finishing = new Map<string, Promise<void>>();
164
+ private sediments = new Map<string, { cancelled: boolean; session?: AgentSession; task?: Promise<void> }>();
165
+ private replied = new Set<string>();
136
166
  private watchdog?: NodeJS.Timeout;
137
167
 
138
168
  constructor(private readonly runtime: HobRuntime) {}
@@ -175,6 +205,7 @@ export class JobManager {
175
205
  const steps = deriveSteps(this.store.workerFile(jobId), {
176
206
  cwd: job.cwd ?? "",
177
207
  save: (stepId, text) => this.store.writeStepOutput(jobId, stepId, text),
208
+ mcpServers: mcpServerDisplay(this.runtime.home),
178
209
  });
179
210
  return [...steps, ...(this.workers.get(jobId)?.queued ?? [])];
180
211
  }
@@ -182,6 +213,18 @@ export class JobManager {
182
213
  async start(assistantId: string, input: StartJobInput): Promise<Job> {
183
214
  const assistant = this.store.assistant(assistantId);
184
215
  if (!assistant) throw new Error(`助手不存在:${assistantId}`);
216
+ if (input.cwd?.trim()) {
217
+ const canonical = (path: string): string => {
218
+ const absolute = resolve(path);
219
+ let parent = absolute;
220
+ while (!existsSync(parent) && dirname(parent) !== parent) parent = dirname(parent);
221
+ return resolve(realpathSync(parent), relative(parent, absolute));
222
+ };
223
+ const path = relative(canonical(this.runtime.home), canonical(input.cwd));
224
+ if (!path || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path))) {
225
+ throw new Error("不能在 Hob 的数据目录里干活;不填 cwd 就在任务自己的目录里做。");
226
+ }
227
+ }
185
228
  await this.runtime.models.get();
186
229
  const requested =
187
230
  input.model || input.thinking
@@ -195,6 +238,14 @@ export class JobManager {
195
238
  ...(hasImageFiles(input.files) ? { hasImages: true } : {}),
196
239
  });
197
240
  const id = this.nextJobId();
241
+ const durations = input.routineId ? (this.store.routine(input.routineId)?.runs ?? [])
242
+ .filter((run) => run.status === "done" && run.durationMs !== undefined && Number.isFinite(run.durationMs) && run.durationMs >= 0)
243
+ .slice(0, 5).map((run) => run.durationMs!).sort((a, b) => a - b) : [];
244
+ const middle = Math.floor(durations.length / 2);
245
+ const median = durations.length % 2 ? durations[middle] : (durations[middle - 1] + durations[middle]) / 2;
246
+ const expect: NonNullable<Job["expect"]> = input.routineId && durations.length
247
+ ? { minutes: Math.max(1, Math.ceil(median / 60_000)), source: "routine", runs: durations.length }
248
+ : input.expectMinutes !== undefined ? { minutes: input.expectMinutes, source: "assistant" } : { minutes: 10, source: "default" };
198
249
  const job: Job = {
199
250
  id,
200
251
  assistantId,
@@ -202,6 +253,7 @@ export class JobManager {
202
253
  title: input.title,
203
254
  instruction: input.instruction,
204
255
  status: "queued",
256
+ expect,
205
257
  createdAt: new Date().toISOString(),
206
258
  stats: { reads: 0, commands: 0, edits: 0 },
207
259
  activity: [],
@@ -215,8 +267,7 @@ export class JobManager {
215
267
  };
216
268
  this.store.saveJob(job);
217
269
  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 });
270
+ await this.runtime.router.addItem(assistantId, { kind: "job", jobId: job.id });
220
271
  this.bus.emit({ type: "job", job });
221
272
  this.publishBusy(assistantId);
222
273
  this.queue.push(job.id);
@@ -249,7 +300,9 @@ export class JobManager {
249
300
  const id = this.queue.shift()!;
250
301
  const job = this.store.job(id);
251
302
  if (!job || job.status !== "queued") continue;
252
- void this.launch(job).catch((error) => this.failLaunch(job, error));
303
+ const followUp = this.followUps.get(id);
304
+ this.followUps.delete(id);
305
+ void this.launch(job, followUp).catch((error) => this.failLaunch(job, error));
253
306
  }
254
307
  }
255
308
 
@@ -258,7 +311,7 @@ export class JobManager {
258
311
  await this.finish(job.id, "failed", undefined, message);
259
312
  }
260
313
 
261
- private async launch(job: Job): Promise<void> {
314
+ private async launch(job: Job, followUp?: string): Promise<void> {
262
315
  const running: Job = {
263
316
  ...job,
264
317
  status: "running",
@@ -274,28 +327,41 @@ export class JobManager {
274
327
  ...baseAbilities,
275
328
  image: baseAbilities.image && imageConfigured(this.runtime.settings.settings.image),
276
329
  };
277
- const customTools = [registerReportTool(this.runtime, running.id), registerAskTool(this.runtime, running.id)];
330
+ const knowledge = await boundKnowledge(this.runtime, running.assistantId);
331
+ const customTools = [registerReportTool(this.runtime, running.id), registerAskTool(this.runtime, running.id), findKnowledgeTool(this.runtime)];
332
+ if (knowledge.length) customTools.push(searchKnowledgeTool(this.runtime, running.assistantId));
278
333
  if (abilities.image) customTools.push(registerImageTool(this.runtime, running.id));
279
334
  if (this.runtime.settings.settings.general.showPage !== false) customTools.push(pageTool(this.runtime, running.assistantId, false));
280
335
  customTools.push(...searchTools(this.runtime.home, () => this.runtime.settings.settings.search));
336
+ const desktop = await availableDesktopTools(this.runtime, running.assistantId, !!this.runtime.models.find(running.model ?? this.runtime.settings.settings.models.work.ref)?.input.includes("image"));
337
+ if (this.store.job(running.id)?.status !== "running") { await desktop?.dispose(); return; }
338
+ if (desktop) customTools.push(...desktop.tools());
281
339
  // 定时跑的就是 Worker:主助手的任务要看得到各助这段时间的动静才能写早报
282
340
  if (assistant?.primary) customTools.push(overviewTool(this.runtime));
341
+ // MCP:配置里有启用中的服务才加载(改完配置,之后新开的任务生效)
342
+ const mcp = mcpEnabled(this.runtime.home);
343
+ const ownSkills = enabledAssistantSkills(this.runtime.home, running.assistantId);
344
+ const ownNames = new Set(ownSkills.map((skill) => skill.name));
283
345
  const opened = await this.runtime.pi.open({
284
346
  file: this.store.workerFile(running.id),
285
347
  cwd: running.cwd ?? homedir(),
286
348
  model: running.model ?? this.runtime.settings.settings.models.work.ref,
287
349
  thinking: running.thinking ?? this.runtime.settings.settings.models.work.thinking,
288
350
  memory: this.runtime.settings.memory().provider,
289
- skills: availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled),
351
+ skills: [...ownSkills, ...availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled).filter((skill) => !ownNames.has(skill.name))],
290
352
  disabledSkills: this.runtime.settings.settings.skills?.disabled,
291
353
  systemPrompt: () =>
292
- buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities }),
354
+ buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities, knowledgeNames: knowledge.map((collection) => collection.name) }),
293
355
  tools: [
294
356
  ...workerTools(abilities),
295
357
  ...customTools.map((tool) => tool.name),
358
+ ...(mcp ? MCP_SESSION_TOOLS : []),
296
359
  ],
297
360
  customTools,
361
+ mcp,
298
362
  });
363
+ if (this.store.job(running.id)?.status !== "running") { opened.session.dispose(); await desktop?.dispose(); return; }
364
+ const mcpServers = mcpServerDisplay(this.runtime.home);
299
365
 
300
366
  const worker: WorkerState = {
301
367
  job: running,
@@ -303,14 +369,26 @@ export class JobManager {
303
369
  manager: opened.manager,
304
370
  askedFollowUp: false,
305
371
  lastActivityAt: Date.now(),
372
+ pausedMs: 0,
373
+ nextReminder: running.expect?.source === "routine" ? 1.5 : 1,
306
374
  steps: new Map(),
375
+ readPaths: new Map(),
307
376
  queued: [],
377
+ mcpServers,
378
+ desktop,
308
379
  };
309
380
  this.workers.set(running.id, worker);
310
- const images = imageContents(running.inputs);
381
+ if (followUp !== undefined) {
382
+ const step: JobStep = { id: `q${Date.now()}-0`, kind: "text", at: new Date().toISOString(), from: "user", text: followUp, queued: true };
383
+ worker.queued.push(step);
384
+ this.emitStep(worker, step);
385
+ }
386
+ const images = followUp === undefined ? imageContents(running.inputs) : [];
311
387
 
312
388
  opened.session.subscribe((event) => {
389
+ if (worker.stopped) return;
313
390
  if (event.type === "tool_execution_start") {
391
+ if (event.toolName === "read" && typeof event.args?.path === "string") worker.readPaths.set(event.toolCallId, event.args.path);
314
392
  this.recordActivity(worker, event.toolName, event.args ?? {});
315
393
  const step = toolStep({
316
394
  id: event.toolCallId,
@@ -318,6 +396,7 @@ export class JobManager {
318
396
  args: event.args ?? {},
319
397
  at: new Date().toISOString(),
320
398
  cwd: worker.job.cwd ?? "",
399
+ mcpServers: worker.mcpServers,
321
400
  running: true,
322
401
  });
323
402
  worker.steps.set(event.toolCallId, step);
@@ -330,9 +409,17 @@ export class JobManager {
330
409
  return;
331
410
  }
332
411
  if (event.type === "tool_execution_end") {
412
+ if (event.toolName === "read" && !event.isError) {
413
+ const readPath = worker.readPaths.get(event.toolCallId);
414
+ if (readPath) {
415
+ try { this.recordSkillUse(worker, readPath); }
416
+ catch (error) { console.error("记录技能用量失败:", error); }
417
+ }
418
+ }
419
+ worker.readPaths.delete(event.toolCallId);
333
420
  const step =
334
421
  worker.steps.get(event.toolCallId) ??
335
- toolStep({ id: event.toolCallId, name: event.toolName, args: {}, cwd: worker.job.cwd ?? "" });
422
+ toolStep({ id: event.toolCallId, name: event.toolName, args: {}, cwd: worker.job.cwd ?? "", mcpServers: worker.mcpServers });
336
423
  worker.steps.delete(event.toolCallId);
337
424
  this.emitStep(
338
425
  worker,
@@ -368,12 +455,28 @@ export class JobManager {
368
455
  });
369
456
 
370
457
  void opened.session
371
- .prompt(running.instruction, images.length ? { images } : undefined)
458
+ .prompt(followUp ?? running.instruction, images.length ? { images } : undefined)
372
459
  .catch(async (error: unknown) => {
373
- await this.finish(running.id, "failed", undefined, error instanceof Error ? error.message : String(error));
460
+ if (!worker.stopped) await this.finish(running.id, "failed", undefined, error instanceof Error ? error.message : String(error));
374
461
  });
375
462
  }
376
463
 
464
+ private recordSkillUse(worker: WorkerState, path: string): void {
465
+ const job = this.store.job(worker.job.id);
466
+ if (!job) return;
467
+ let absolute: string;
468
+ try { absolute = realpathSync(resolve(job.cwd ?? homedir(), path)); }
469
+ catch { return; }
470
+ const skill = usableAssistantSkills(this.runtime.home, job.assistantId).find((item) => existsSync(item.path) && realpathSync(item.path) === absolute);
471
+ if (!skill || job.skillsUsed?.includes(skill.name)) return;
472
+ recordAssistantSkillUse(this.runtime.home, job.assistantId, skill.name);
473
+ const next = { ...job, skillsUsed: [...(job.skillsUsed ?? []), skill.name] };
474
+ this.store.saveJob(next);
475
+ worker.job = next;
476
+ this.bus.emit({ type: "job", job: next });
477
+ this.bus.emit({ type: "skills", assistantId: job.assistantId });
478
+ }
479
+
377
480
  private recordActivity(worker: WorkerState, toolName: string, args: Record<string, unknown>): void {
378
481
  worker.lastActivityAt = Date.now();
379
482
  const line = activityFor(toolName, args);
@@ -467,7 +570,7 @@ export class JobManager {
467
570
  await this.finish(worker.job.id, "stuck", undefined, "未提交结果");
468
571
  return;
469
572
  }
470
- await session.prompt("你还没有调用 report 提交结果。请现在调用 report(outcome, summary)。");
573
+ await session.prompt("你还没有调用 report 提交结果。请现在调用 report(outcome, result)。");
471
574
  }
472
575
 
473
576
  /** report 工具收到结果后由 worker-tools 调用。 */
@@ -477,10 +580,10 @@ export class JobManager {
477
580
  const status: JobStatus = worker.reported.outcome === "done" ? "done" : "stuck";
478
581
  // 不写原因:outcome 只说明模型自己认为没做完,状态词(受阻、待确认)卡片上已经写过一遍,
479
582
  // 当原因存下来就会重复。真正的原因由看门狗和停止操作给。
480
- await this.finish(workerId, status, worker.reported.summary, undefined, worker.reported.files);
583
+ await this.finish(workerId, status, worker.reported.result, undefined, worker.reported.files);
481
584
  }
482
585
 
483
- reportFromTool(jobId: string, data: { outcome: "done" | "stuck" | "need_approval"; summary: string; files?: string[] }): void {
586
+ reportFromTool(jobId: string, data: { outcome: "done" | "stuck" | "need_approval"; result: string; files?: string[] }): void {
484
587
  const worker = this.workers.get(jobId);
485
588
  if (!worker) return;
486
589
  worker.reported = data;
@@ -494,10 +597,28 @@ export class JobManager {
494
597
  reason?: string,
495
598
  files?: string[],
496
599
  ): Promise<void> {
600
+ const pending = this.finishing.get(jobId);
601
+ if (pending) return pending;
602
+ const task = this.finishOnce(jobId, status, summary, reason, files);
603
+ this.finishing.set(jobId, task);
604
+ try { await task; } finally { this.finishing.delete(jobId); }
605
+ const job = this.store.job(jobId);
606
+ const steps = status === "done" && job?.status === "done" ? this.transcript(jobId) : [];
607
+ // 自动沉淀:用户中途接着说过话;或者没照着技能做、而且是定时执行或动手 20 次以上。
608
+ // 照着技能顺利做完的不再沉淀,免得每天跑完都改一版。
609
+ const followed = this.replied.has(jobId) || steps.some((step) => step.from === "user" && step.text !== "沉淀技能");
610
+ const fresh = !job?.skillsUsed?.length && (Boolean(job?.routineId) || steps.filter((step) => step.kind === "tool").length >= 20);
611
+ if (job?.status === "done" && status === "done" && (followed || fresh)) this.startSediment(jobId);
612
+ }
613
+
614
+ private async finishOnce(jobId: string, status: JobStatus, summary?: string, reason?: string, files?: string[]): Promise<void> {
497
615
  const job = this.store.job(jobId);
498
616
  if (!job || ENDED.includes(job.status)) return;
499
617
  const worker = this.workers.get(jobId);
618
+ if (worker) worker.stopped = true;
500
619
  if (worker?.finishTimer) clearTimeout(worker.finishTimer);
620
+ this.followUps.delete(jobId);
621
+ await worker?.desktop?.dispose();
501
622
  await this.settle(job, status, { summary, reason, files, lastText: worker?.lastText });
502
623
 
503
624
  if (worker?.session) {
@@ -515,10 +636,7 @@ export class JobManager {
515
636
  ): Promise<void> {
516
637
  const endedAt = new Date().toISOString();
517
638
  // 报告原样保留(不压成一行、不截断):任务详情和 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;
639
+ const cleanSummary = options.summary?.trim() ? options.summary : undefined;
522
640
  const statusText = status === "done" ? "已完成" : status === "stuck" ? "受阻" : status === "stopped" ? "已停止" : "失败";
523
641
  const reportPath = this.store.writeReport(
524
642
  job.id,
@@ -528,13 +646,9 @@ export class JobManager {
528
646
  `状态:${statusText}${options.reason ? `(${options.reason})` : ""}`,
529
647
  `结束时间:${monthDayTimeText(this.runtime.settings.settings.general.timezone)}`,
530
648
  "",
531
- "## 摘要",
649
+ "## 结果",
532
650
  "",
533
- cleanSummary ?? "(没有摘要)",
534
- "",
535
- "## 任务最后一条回复",
536
- "",
537
- options.lastText ?? "(没有回复)",
651
+ cleanSummary ?? options.lastText ?? "(没有回复)",
538
652
  "",
539
653
  "## 任务说明",
540
654
  "",
@@ -547,11 +661,12 @@ export class JobManager {
547
661
  endedAt,
548
662
  reportPath,
549
663
  ...(job.startedAt ? { durationMs: Date.parse(endedAt) - Date.parse(job.startedAt) } : {}),
550
- ...(cleanSummary ? { summary: cleanSummary } : {}),
664
+ ...(options.summary !== undefined ? { summary: cleanSummary } : {}),
551
665
  ...(options.reason ? { statusReason: options.reason } : {}),
552
- ...(options.files?.length ? { files: options.files } : {}),
666
+ ...(options.summary !== undefined ? { files: options.files ?? [] } : {}),
553
667
  };
554
668
  this.store.saveJob(next);
669
+ 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
670
  // 转交里的任务:先把转交重新打开,结果由目标助手接着处理完才回流 Hob(见 DelegationManager.resume)
556
671
  const delegationId = !job.routineId && job.delegationId && this.runtime.delegations.resume(job.delegationId) ? job.delegationId : undefined;
557
672
  this.bus.emit({ type: "job", job: next });
@@ -571,20 +686,25 @@ export class JobManager {
571
686
  if (job.routineId) {
572
687
  const routine = this.store.routine(job.routineId);
573
688
  if (routine) {
574
- const run: RoutineRun = { jobId: job.id, at: endedAt, status };
689
+ const run: RoutineRun = {
690
+ jobId: job.id, at: endedAt, status,
691
+ ...(next.durationMs !== undefined ? { durationMs: next.durationMs } : {}),
692
+ tools: next.stats.reads + next.stats.commands + next.stats.edits,
693
+ ...(next.usage ? { tokens: next.usage.input + next.usage.output } : {}),
694
+ };
575
695
  const runs = [run, ...routine.runs.filter((item) => item.jobId !== job.id)].slice(0, 20);
576
696
  const updated = { ...routine, runs };
577
697
  this.store.saveRoutine(updated);
578
698
  this.bus.emit({ type: "routine", routine: updated });
579
699
  await this.runtime.router
580
- .event(job.assistantId, `定时任务「${routine.name}」结果:${eventSummary ?? options.reason ?? statusText}${filesNote}`, {
581
- routine: { id: routine.id, name: routine.name },
700
+ .event(job.assistantId, `${jobResultEvent(next, `定时任务「${routine.name}」`)}${filesNote}`, {
701
+ routine: { id: routine.id, name: routine.name, jobId: job.id },
582
702
  ...cont,
583
703
  })
584
704
  .catch(() => undefined);
585
705
  }
586
706
  } else if (assistant) {
587
- const text = `任务「${job.title}」${statusText}${eventSummary ? `:${eventSummary}` : options.reason ? `:${options.reason}` : ""}${filesNote}`;
707
+ const text = `${jobResultEvent(next)}${filesNote}`;
588
708
  if (delegationId) void this.runtime.router.relay(job.assistantId, text, delegationId, cont).catch(() => this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败", true));
589
709
  else await this.runtime.router.event(job.assistantId, text, cont).catch(() => undefined);
590
710
  }
@@ -612,14 +732,60 @@ export class JobManager {
612
732
  }
613
733
  }
614
734
  const lines: string[] = [];
615
- if (posted.length) lines.push(`产出的图片已经直接发到对话里了(${posted.join("、")}),用户看得到,不要再发或再派任务去取。`);
616
- if (others.length) lines.push(`产出文件:${others.join("、")}(要给用户就用 send_file)。`);
735
+ if (posted.length) lines.push(`图片已发到对话里:${posted.join("、")}`);
736
+ if (others.length) lines.push(`文件:${others.join("、")}`);
617
737
  return { note: lines.length ? `\n${lines.join("\n")}` : "", posted: posted.length };
618
738
  }
619
739
 
620
- async reply(jobId: string, text: string): Promise<void> {
740
+ async reply(jobId: string, text: string, source: "user" | "router" = "user"): Promise<void> {
741
+ await this.finishing.get(jobId);
742
+ const sediment = this.sediments.get(jobId);
743
+ if (sediment) {
744
+ sediment.cancelled = true;
745
+ await sediment.session?.abort().catch(() => undefined);
746
+ await sediment.task;
747
+ }
748
+ this.replied.add(jobId);
749
+ const job = this.store.job(jobId);
750
+ if (!job) throw new Error("没有这个任务。");
751
+ if (ENDED.includes(job.status)) {
752
+ const { endedAt, statusReason, durationMs, summary, files, reportPath, ...rest } = job;
753
+ const previous = job.rounds ?? [];
754
+ // 这一轮的报告另存一份:下一轮写报告会覆盖原文件。原文件不在了就不存,不让续做因此失败
755
+ const savedReport = reportPath && existsSync(reportPath) ? join(dirname(reportPath), `report.round-${previous.length + 1}.md`) : undefined;
756
+ if (reportPath && savedReport) copyFileSync(reportPath, savedReport);
757
+ 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 });
758
+ 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;
759
+ const ended: JobRound = { status: job.status, statusReason, startedAt: job.startedAt, endedAt, durationMs, summary, files, usage, reportPath: savedReport, followUp: text };
760
+ const rounds = [...previous, ended];
761
+ const queued: Job = { ...rest, rounds, status: "queued", expect: { minutes: 10, source: "default" }, startedAt: undefined };
762
+ this.store.saveJob(queued);
763
+ try {
764
+ await this.runtime.router.addItem(job.assistantId, { kind: "job", jobId, round: rounds.length });
765
+ } catch (error) {
766
+ this.store.saveJob(job);
767
+ throw error;
768
+ }
769
+ this.followUps.set(jobId, `用户接着说(${monthDayTimeText(this.runtime.settings.settings.general.timezone)}):${text}`);
770
+ this.bus.emit({ type: "job", job: queued });
771
+ this.publishBusy(job.assistantId);
772
+ this.queue.push(jobId);
773
+ this.pump();
774
+ return;
775
+ }
621
776
  const worker = this.workers.get(jobId);
622
777
  if (!worker?.session) throw new Error("这个任务当前未在运行。");
778
+ // 任务卡在 ask_user 等答复:用户在对话里回的话就是答复,不然插话要等提问返回才读得到,任务永远等下去
779
+ const asking = worker.ask ? this.store.approvals().find((item) => item.jobId === jobId && item.status === "pending") : undefined;
780
+ if (asking) {
781
+ const answered: Approval = source === "user"
782
+ ? { ...asking, status: "answered", answerText: text, answeredAt: new Date().toISOString() }
783
+ : { ...asking, status: "expired" };
784
+ this.store.saveApproval(answered);
785
+ this.bus.emit({ type: "approval", approval: answered });
786
+ this.resolveAsk(answered, source === "user" ? `用户没有选给出的选项,而是在对话里回复:${text}` : `助手转达:${text}`);
787
+ return;
788
+ }
623
789
  // 先显示出来:任务在跑命令时要等这一步做完才读到,界面上标着「等待读取」
624
790
  const step: JobStep = { id: `q${Date.now()}-${worker.queued.length}`, kind: "text", at: new Date().toISOString(), from: "user", text, queued: true };
625
791
  worker.queued.push(step);
@@ -633,7 +799,101 @@ export class JobManager {
633
799
  }
634
800
  }
635
801
 
636
- async stop(jobId: string): Promise<void> {
802
+ startSediment(jobId: string, source: "manual" | "auto" = "auto"): void {
803
+ const job = this.store.job(jobId);
804
+ if (!job) throw new Error("任务不存在");
805
+ if (job.status !== "done" || this.finishing.has(jobId) || this.workers.has(jobId)) throw new Error("只有已完成的任务可以沉淀");
806
+ if (this.sediments.has(jobId)) throw new Error("任务正在沉淀技能");
807
+ const state: { cancelled: boolean; session?: AgentSession; task?: Promise<void> } = { cancelled: false };
808
+ this.sediments.set(jobId, state);
809
+ state.task = this.sedimentRound(job, state, source).catch(async (error) => {
810
+ if (state.cancelled) return;
811
+ const message = error instanceof Error ? error.message : String(error);
812
+ console.error("沉淀技能失败:", error);
813
+ await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "info", text: `技能沉淀失败:${message}` }).catch((noteError) => console.error("写入沉淀失败提示失败:", noteError));
814
+ }).finally(() => {
815
+ this.sediments.delete(jobId);
816
+ this.bus.emit({ type: "sediment-finished", jobId });
817
+ });
818
+ }
819
+
820
+ private async sedimentRound(job: Job, state: { cancelled: boolean; session?: AgentSession }, source: "manual" | "auto"): Promise<void> {
821
+ let calledSave = false;
822
+ const savedSkills = new Set<string>();
823
+ let lastText = "";
824
+ const skillTool = {
825
+ name: "save_skill", label: "保存技能",
826
+ description: "保存或修补这个助手的技能。name 只用小写字母、数字和连字符,和已有技能同名就是修补,旧版会保留。content 是 SKILL.md 的正文,不用写开头的 name 和 description。files 放脚本和参考资料,路径以 scripts/ 或 references/ 开头。",
827
+ parameters: Type.Object({
828
+ name: Type.String({ description: "技能的目录名,如 gateway-daily-check。" }),
829
+ title: Type.String({ description: "中文名,如「网关每日巡检」。" }),
830
+ description: Type.String({ description: "一句话写什么时候用,120 字以内。" }),
831
+ content: Type.String({ description: "SKILL.md 正文。" }),
832
+ 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(工作目录里已有文件的路径,直接复制)。" })),
833
+ }),
834
+ execute: async (_id: string, params: any) => {
835
+ calledSave = true;
836
+ if (state.cancelled || this.store.job(job.id)?.status !== "done") throw new Error("任务已继续,取消沉淀");
837
+ const files = readSkillFiles(job.cwd ?? this.store.workDir(job.id), params.files);
838
+ const overwriteVersion = savedSkills.has(params.name);
839
+ const skill = saveAssistantSkill(this.runtime.home, job.assistantId, { ...params, files }, job.id, false, { overwriteVersion });
840
+ savedSkills.add(skill.name);
841
+ if (!overwriteVersion) {
842
+ let text = skill.version === 1 ? `已沉淀技能「${skill.title}」` : `已更新技能「${skill.title}」· 第 ${skill.version} 版`;
843
+ const routine = job.routineId && this.store.routine(job.routineId);
844
+ if (routine && !routine.skill) {
845
+ this.runtime.routines.update(routine.id, { skill: skill.name });
846
+ text = `已沉淀技能「${skill.title}」,以后定时执行按它做`;
847
+ }
848
+ await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "skill", text, path: skill.path });
849
+ }
850
+ this.bus.emit({ type: "skills", assistantId: job.assistantId });
851
+ return { content: [{ type: "text" as const, text: "技能已保存。" }], details: undefined };
852
+ },
853
+ };
854
+ const skills = listAssistantSkills(this.runtime.home, job.assistantId);
855
+ const existing = skills.map((skill) => `${skill.name}:${skill.title} —— ${skill.description}`).join("\n") || "无";
856
+ const message = `${SEDIMENT_PROMPT_HEAD},下次照着做,更快、更稳。
857
+
858
+ 技能写的是这一类事怎么做,不是这一次的经过。下次接到同类的事,照着它就能一次做成用户要的样子。
859
+ - 按做事的顺序写步骤,带上要用的命令、查询和判断点;怎么判断结果对不对,写在对应的那一步里。
860
+ - 用户在过程中纠正过的做法、对结果提过的要求,写进去。
861
+ - 坑写成一条规则加一句原因,放在它影响的那一步。规则要离开这一次也成立:不写事情经过和这一次的数据;会变的情况不写死,写怎么查。
862
+ - 同一条经验只写一次。修补时直接改掉写错的那句,不在后面追加更正。
863
+ - 助手的规矩、工具说明里已经有的,不再重复。
864
+ - 能写成脚本的写成只读脚本,放在 scripts/,一次输出要看的全部结果。写好后用 bash 真跑一遍,输出对得上再保存。偶尔才用得上的细节放 references/,按主题起文件名。
865
+ - 会改东西的步骤只写进文档,不写进脚本,也不要执行。
866
+
867
+ 不写进技能:
868
+ - 临时故障和网络错误。环境没装好、没配好导致的失败,只写怎么装好、配好。
869
+ - 「某个工具不能用」这类结论:环境修好以后,它还会一直挡着你。
870
+ - 这次没走通的尝试:不能写成推荐做法。
871
+
872
+ 下面已有同类技能的,修补它,不要另建;新建时,名字和说明按这一类事来起,不按这一次。没有值得沉淀的,就回复「没有需要沉淀的」,不调用 save_skill。
873
+
874
+ 这个助手已有的技能:${existing}`;
875
+ if (state.cancelled) return;
876
+ const opened = await this.runtime.pi.open({
877
+ file: this.store.workerFile(job.id), cwd: job.cwd ?? homedir(),
878
+ model: job.model ?? this.runtime.settings.settings.models.work.ref,
879
+ thinking: job.thinking ?? this.runtime.settings.settings.models.work.thinking,
880
+ memory: "off", systemPrompt: () => buildWorkerPrompt(this.store, job.id, this.promptSettings()),
881
+ tools: ["read", "ls", "grep", "find", "bash", "save_skill"], customTools: [skillTool],
882
+ });
883
+ state.session = opened.session;
884
+ opened.session.subscribe((event) => {
885
+ if (event.type === "message_end" && event.message.role === "assistant") lastText = textOfContent(event.message.content).trim();
886
+ });
887
+ try {
888
+ if (!state.cancelled) await opened.session.prompt(message);
889
+ if (!state.cancelled && source === "manual" && !calledSave) {
890
+ const reason = lastText && lastText !== "没有需要沉淀的" ? `:${lastText}` : "";
891
+ await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "skill", text: `「${job.title}」没有可沉淀的做法${reason}` });
892
+ }
893
+ } finally { opened.session.dispose(); }
894
+ }
895
+
896
+ async stop(jobId: string, reason = "你叫停了"): Promise<void> {
637
897
  const job = this.store.job(jobId);
638
898
  if (!job) throw new Error("没有这个任务。");
639
899
  const worker = this.workers.get(jobId);
@@ -645,7 +905,7 @@ export class JobManager {
645
905
  // abort 会把正在执行的工具(bash 子进程)一起终止
646
906
  await worker.session?.abort().catch(() => undefined);
647
907
  }
648
- await this.finish(jobId, "stopped", undefined, "你叫停了");
908
+ await this.finish(jobId, "stopped", undefined, reason);
649
909
  }
650
910
 
651
911
  /** Worker 的 ask_user:出审批卡、把任务标成待确认,阻塞到用户答复。 */
@@ -676,7 +936,7 @@ export class JobManager {
676
936
  const waiting: Job = { ...job, status: "waiting" };
677
937
  this.store.saveJob(waiting);
678
938
  this.bus.emit({ type: "job", job: waiting });
679
- if (worker) worker.job = waiting;
939
+ if (worker) { worker.job = waiting; worker.pausedAt = Date.now(); }
680
940
  return await new Promise<string>((resolve) => {
681
941
  if (worker) worker.ask = resolve;
682
942
  else resolve("(任务未在运行)");
@@ -696,6 +956,8 @@ export class JobManager {
696
956
  worker?.ask?.(answer);
697
957
  if (worker) {
698
958
  worker.ask = undefined;
959
+ if (worker.pausedAt !== undefined) worker.pausedMs += Date.now() - worker.pausedAt;
960
+ worker.pausedAt = undefined;
699
961
  worker.lastActivityAt = Date.now();
700
962
  }
701
963
  }
@@ -712,6 +974,24 @@ export class JobManager {
712
974
  checkStalls(now = Date.now()): void {
713
975
  const idleMs = this.runtime.settings.settings.work.jobStallMinutes * 60_000;
714
976
  for (const [jobId, worker] of this.workers) {
977
+ const job = this.store.job(jobId);
978
+ if (job?.status === "running" && job.startedAt && job.expect && worker.pausedAt === undefined) {
979
+ const elapsed = Math.max(0, now - Date.parse(job.startedAt) - worker.pausedMs);
980
+ if (elapsed >= job.expect.minutes * 60_000 * worker.nextReminder) {
981
+ while (elapsed >= job.expect.minutes * 60_000 * worker.nextReminder) worker.nextReminder *= 2;
982
+ const source = job.expect.source === "assistant" ? "派活时估的" : job.expect.source === "routine" ? `这个定时任务以前 ${job.expect.runs} 次的中位数` : "默认 10 分钟";
983
+ const routine = job.routineId && this.store.routine(job.routineId);
984
+ const prefix = routine ? `定时任务「${routine.name}」` : `任务「${job.title}」`;
985
+ const steps = this.transcript(jobId);
986
+ for (const step of worker.steps.values()) {
987
+ const index = steps.findIndex((item) => item.id === step.id);
988
+ if (index >= 0) steps[index] = step; else steps.push(step);
989
+ }
990
+ const lines = jobProcessLines(steps, this.runtime.settings.settings.general.timezone, new Date(now)).slice(0, 10);
991
+ const text = `${prefix}(${job.id})已运行 ${Math.floor(elapsed / 60_000)} 分钟,超过预计的 ${job.expect.minutes} 分钟(${source})。\n最近的过程(新的在前):${lines.length ? `\n${lines.join("\n")}` : ""}`;
992
+ void this.runtime.router.event(job.assistantId, text).catch((error) => console.error("任务超时通知失败:", error));
993
+ }
994
+ }
715
995
  // 在等用户答提问不算没响应:等多久都是用户的事
716
996
  if (!worker.ask && now - worker.lastActivityAt > idleMs) {
717
997
  void this.finish(jobId, "stuck", undefined, `${Math.round(idleMs / 60_000)} 分钟无响应`);
@@ -740,11 +1020,13 @@ export class JobManager {
740
1020
  }
741
1021
  }
742
1022
  this.queue = [];
1023
+ this.followUps.clear();
743
1024
  }
744
1025
 
745
1026
  disposeAll(): void {
746
1027
  this.stopWatchdog();
747
- for (const worker of this.workers.values()) worker.session?.dispose();
1028
+ for (const state of this.sediments.values()) { state.cancelled = true; void state.session?.abort(); }
1029
+ for (const worker of this.workers.values()) { worker.session?.dispose(); void worker.desktop?.dispose(); }
748
1030
  this.workers.clear();
749
1031
  }
750
1032
 
@@ -755,7 +1037,7 @@ export class JobManager {
755
1037
  const active = this.store
756
1038
  .jobs()
757
1039
  .filter((job) => job.status === "running" || job.status === "queued" || job.status === "waiting");
758
- if (!active.length) return;
1040
+ if (!active.length && !this.finishing.size && !this.sediments.size) return;
759
1041
  await new Promise((resolve) => setTimeout(resolve, 25));
760
1042
  }
761
1043
  }