hob-bot 1.3.0 → 1.5.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 (48) hide show
  1. package/README.md +42 -23
  2. package/README.zh-CN.md +43 -24
  3. package/bin/hob.mjs +8 -2
  4. package/dist/web/assets/{DesktopView-lixZfbYr.js → DesktopView-3QDYo0UU.js} +1 -1
  5. package/dist/web/assets/index-Dzxrt9T3.js +283 -0
  6. package/dist/web/assets/index-FUtuyxyO.css +1 -0
  7. package/dist/web/index.html +2 -2
  8. package/dist/web/mock/readme-cover.svg +13 -0
  9. package/dist/web/mock/readme-page.html +7 -0
  10. package/package.json +2 -1
  11. package/server/src/actions.ts +2 -1
  12. package/server/src/assistant-skills.ts +2 -2
  13. package/server/src/boot.ts +8 -0
  14. package/server/src/config.ts +52 -11
  15. package/server/src/cron-text.ts +14 -1
  16. package/server/src/delete-skill.ts +19 -0
  17. package/server/src/desktop-tools.ts +6 -6
  18. package/server/src/files.ts +27 -7
  19. package/server/src/http.ts +41 -9
  20. package/server/src/job-view.ts +2 -2
  21. package/server/src/jobs.ts +108 -62
  22. package/server/src/knowledge-tools.ts +1 -1
  23. package/server/src/knowledge.ts +9 -4
  24. package/server/src/layout-cli.ts +25 -0
  25. package/server/src/layout.ts +749 -0
  26. package/server/src/main.ts +5 -0
  27. package/server/src/mcp.ts +3 -2
  28. package/server/src/memory.ts +2 -2
  29. package/server/src/now.ts +1 -1
  30. package/server/src/overview.ts +1 -2
  31. package/server/src/pages.ts +9 -7
  32. package/server/src/prompt.ts +47 -110
  33. package/server/src/push.ts +3 -2
  34. package/server/src/router-tools.ts +96 -40
  35. package/server/src/router.ts +5 -4
  36. package/server/src/routines.ts +177 -22
  37. package/server/src/runtime.ts +12 -1
  38. package/server/src/search.ts +1 -1
  39. package/server/src/secrets.ts +3 -2
  40. package/server/src/settings.ts +44 -6
  41. package/server/src/store.ts +123 -40
  42. package/server/src/transcript.ts +25 -7
  43. package/server/src/usage-prices.ts +166 -0
  44. package/server/src/usage.ts +291 -0
  45. package/server/src/worker-tools.ts +6 -9
  46. package/shared/types.ts +110 -3
  47. package/dist/web/assets/index-BZhge0qe.js +0 -267
  48. package/dist/web/assets/index-DIo1ArRa.css +0 -1
@@ -1,10 +1,11 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { createReadToolDefinition, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
3
  import { existsSync, readFileSync, statSync } from "node:fs";
4
- import { basename, dirname, extname, join, resolve } from "node:path";
4
+ import { basename, extname, join, relative, resolve } from "node:path";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { IMAGE_TYPES } from "../../shared/media.ts";
7
- import { lookupFile } from "./files.ts";
7
+ import { lookupFile, isInPagesDir } from "./files.ts";
8
+ import { resolveMovedPath } from "./layout.ts";
8
9
  import {
9
10
  COLORS,
10
11
  COLOR_IDS,
@@ -17,8 +18,8 @@ import {
17
18
  validateCustomProp,
18
19
  } from "../../shared/sprites.ts";
19
20
  import type { ColorId } from "../../shared/sprites.ts";
20
- import type { Approval, ApprovalOption, CustomProp, Draft, Routine } from "../../shared/types.ts";
21
- import { cronToChinese } from "./cron-text.ts";
21
+ import type { Approval, ApprovalOption, CustomProp, Draft, Routine, RoutineCheck } from "../../shared/types.ts";
22
+ import { cronToChinese, timePointText } from "./cron-text.ts";
22
23
  import { newId } from "./ids.ts";
23
24
  import { findKnowledgeTool, searchKnowledgeTool } from "./knowledge-tools.ts";
24
25
  import { generateImage, imageConfigured } from "./image.ts";
@@ -31,9 +32,35 @@ import type { HobRuntime } from "./runtime.ts";
31
32
  import { clampDuty } from "./text.ts";
32
33
  import { listAssistantSkills, readAssistantSkillBody, saveAssistantSkill, skillInstruction, usableAssistantSkills } from "./assistant-skills.ts";
33
34
  import { jobView } from "./job-view.ts";
35
+ import { deleteSkill } from "./delete-skill.ts";
34
36
 
35
37
  type Tool = ToolDefinition<any, any>;
36
38
 
39
+ /** 执行时间的参数说明:cron 表达式和具体时间都收。 */
40
+ const SCHEDULES_DESCRIPTION = "一个或多个;可以是 cron 表达式,也可以是具体时间(如 2026-11-08T09:00),到点只跑一次。";
41
+
42
+ /** once 的参数说明。 */
43
+ const ONCE_DESCRIPTION = "可选。为 true 时做成一次就停:没有检查脚本的,跑完一次就停;有检查脚本的,第一次有输出就停。停了的定时任务还在,可以再打开。";
44
+
45
+ /** 检查脚本参数:create_routine 和 update_routine 共用。 */
46
+ const CHECK_DESCRIPTION = "可选。到点先跑的 bash 脚本,填脚本全文:没有输出就这次什么都不做;有输出时,then 为 notify 就把输出作为提醒发到对话,为 job 就带着输出开始任务。脚本在这个定时任务自己的目录里运行,状态文件存在当前目录,记住上次看到哪里;第一次跑还没有状态时只记下现状,不输出。非零退出算检查失败。脚本用到的其他文件也放在这个目录,跟着定时任务走。";
47
+
48
+ const CHECK_PARAM = Type.Object({
49
+ script: Type.String({ description: "bash 脚本全文" }),
50
+ then: Type.Union([Type.Literal("notify"), Type.Literal("job")], { description: "有输出时:notify 把输出作为提醒发到对话,job 带着输出开始任务" }),
51
+ }, { description: CHECK_DESCRIPTION });
52
+
53
+ /** 工具返回里说的「每 10 分钟」这类:具体时间只说那一刻,有检查脚本的说明有新内容会怎样,开了 once 的说明会停。 */
54
+ function routineFact(schedules: string[], check: RoutineCheck | undefined, once: boolean | undefined): string {
55
+ const points = schedules.map((schedule) => timePointText(schedule));
56
+ const labels = schedules.map((schedule, index) => points[index] ?? cronToChinese(schedule));
57
+ const facts: string[] = [];
58
+ if (points.every(Boolean)) facts.push(`${labels.join("、")}跑一次`);
59
+ if (check) facts.push(`${labels.join("、")}检查一次${check.then === "notify" ? ",有新内容就提醒你" : ",有新内容就开任务"}`);
60
+ if (once) facts.push("触发一次就停");
61
+ return facts.length ? `,${facts.join(";")}` : "";
62
+ }
63
+
37
64
  function ok(text: string) {
38
65
  return { content: [{ type: "text" as const, text }], details: undefined };
39
66
  }
@@ -99,6 +126,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
99
126
  readFile: async (path) => readFile(checkFile(path)),
100
127
  detectImageMimeType: async (path) => IMAGE_TYPES[extname(checkFile(path)).toLowerCase()],
101
128
  } }) as unknown as Tool;
129
+ delete (read as { promptSnippet?: unknown }).promptSnippet; // 用了自定义系统提示词,引擎自带的这段不进提示词
102
130
  const tools: Tool[] = [
103
131
  {
104
132
  name: "check_job",
@@ -116,7 +144,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
116
144
  {
117
145
  name: "edit_skill",
118
146
  label: "改技能",
119
- description: "改这个助手某个技能的正文(SKILL.md):把一段原文换成新的文字,旧版会保留。要改脚本的,交给任务去改。",
147
+ description: "改一个技能的正文(SKILL.md):把一段原文换成新的文字,旧版会保留。脚本要改的,交给任务改。",
120
148
  parameters: Type.Object({
121
149
  name: Type.String({ description: "技能名" }),
122
150
  edits: Type.Array(Type.Object({ old: Type.String(), new: Type.String() }), { description: "一处或几处修改:old 是正文里的原文,要一字不差、只出现一次;new 是换成的文字" }),
@@ -140,15 +168,28 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
140
168
  } catch (error) { return fail(error instanceof Error ? error.message : String(error)); }
141
169
  },
142
170
  },
171
+ {
172
+ name: "delete_skill",
173
+ label: "删除技能",
174
+ description: "删除一个技能。技能会移到归档,不会彻底删掉。",
175
+ parameters: Type.Object({ name: Type.String({ description: "技能名。" }), replaced_by: Type.Optional(Type.String({ description: "可选,被哪个技能接替,填技能名。用到它的定时任务会改用接替的技能。" })) }),
176
+ execute: async (_id, params: { name: string; replaced_by?: string }) => {
177
+ try {
178
+ const text = deleteSkill(runtime, assistantId, params.name, params.replaced_by || undefined);
179
+ await router.addItem(assistantId, { kind: "note", icon: "skill", text });
180
+ return ok(text);
181
+ } catch (error) { return fail(error instanceof Error ? error.message : String(error)); }
182
+ },
183
+ },
143
184
  {
144
185
  name: "save_job_as_skill",
145
186
  label: "沉淀技能",
146
- description: "把一个已完成的任务的做法沉淀成这个助手的技能,在任务自己的会话里做,做完对话里会有提示。",
147
- parameters: Type.Object({ jobId: Type.String() }),
148
- execute: async (_id, params: { jobId: string }) => {
187
+ description: "让一个已完成的任务把它的做法写成技能。它在自己的会话里写,能把脚本和命令真跑一遍验证;进度显示在任务卡片上,写好以后结果会回到这里。",
188
+ parameters: Type.Object({ jobId: Type.String(), into: Type.Optional(Type.String({ description: "可选,在哪个已有技能的基础上改,填技能名;不填就新建。" })), note: Type.Optional(Type.String({ description: "可选,要交代给任务的话,比如用户的反馈和纠正、要一起并进来的技能。" })) }),
189
+ execute: async (_id, params: { jobId: string; into?: string; note?: string }) => {
149
190
  const job = store.job(params.jobId);
150
191
  if (!job || job.assistantId !== assistantId) return fail("任务不存在");
151
- try { jobs.startSediment(job.id, "manual"); return ok("已开始沉淀技能。"); }
192
+ try { jobs.startSediment(job.id, { into: params.into || undefined, note: params.note || undefined }); return ok("已开始沉淀,进度显示在任务卡片上。"); }
152
193
  catch (error) { return fail(error instanceof Error ? error.message : String(error)); }
153
194
  },
154
195
  },
@@ -186,8 +227,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
186
227
  {
187
228
  name: "start_job",
188
229
  label: "创建任务",
189
- description: "把要动手的事交给后台任务:跑命令、改文件、操作设备、多步调研。用户这次的原话、引用、附件和最近几条对话会自动带给任务;任务随时可以停。",
190
- promptSnippet: "把需要执行的任务交给后台任务。",
230
+ description: "把事情交给后台任务去做。用户这次的原话、引用、附件和最近几条对话会自动带给任务,note 只补原话里看不出的背景。",
191
231
  parameters: Type.Object({
192
232
  title: Type.String({ description: "任务标题,短,如「查磁盘占用」" }),
193
233
  note: Type.Optional(Type.String({ description: "任务从原话里看不出、又需要知道的背景,最多 200 字。" })),
@@ -202,7 +242,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
202
242
  description: "要交给任务的文件路径(用户发来的附件);图片会直接给模型看",
203
243
  }),
204
244
  ),
205
- skill: Type.Optional(Type.String({ description: "可选,按这个助手的哪个技能做,填技能名。" })),
245
+ skills: Type.Optional(Type.Array(Type.String(), { description: "给任务用的技能,填技能名。你的技能,任务只看得到你给的这些。" })),
206
246
  expect_minutes: Type.Optional(Type.Number({ description: "可选,预计多少分钟能交结果。超过了会通知你;不填按 10 分钟。" })),
207
247
  model: Type.Optional(Type.String({ description: `可选,不填用默认。聪明、成本、速度 0–100,越高越聪明、越贵、越快:\n${modelText}` })),
208
248
  thinking: Type.Optional(
@@ -215,15 +255,17 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
215
255
  const note = params.note == null ? "" : String(params.note);
216
256
  if ([...note].length > 200) return fail("备注最多 200 字;用户原话和最近的对话会自动带上,备注只写从原话里看不出的背景。");
217
257
  // 有的模型会把没用的可选参数填成空串:空串当没填
218
- const wanted = params.skill ? String(params.skill) : "";
258
+ const wanted: string[] = Array.isArray(params.skills) ? [...new Set<string>(params.skills.map(String))] : [];
219
259
  const skills = usableAssistantSkills(runtime.home, assistantId);
220
- const skill = wanted ? skills.find((item) => item.name === wanted) : undefined;
221
- if (wanted && !skill) return fail(`没有这个技能:${wanted}。能用的有:${skills.map((item) => item.name).join("、") || "无"}`);
260
+ const missing = wanted.find((name) => !skills.some((item) => item.name === name));
261
+ if (missing) return fail(`没有这个技能:${missing}。能用的有:${skills.map((item) => item.name).join("、") || "无"}`);
262
+ const selected = wanted.map((name) => skills.find((item) => item.name === name)!);
222
263
  const original = router.originalWords(assistantId, note, true, Array.isArray(params.files) ? params.files.map(String) : []);
223
264
  const job = await jobs.start(assistantId, {
224
265
  title: String(params.title),
225
266
  ...(params.expect_minutes != null ? { expectMinutes: params.expect_minutes } : {}),
226
- instruction: skill ? skillInstruction(skill.title, original.text) : original.text,
267
+ instruction: selected.length ? skillInstruction(selected.map((skill) => skill.title), original.text) : original.text,
268
+ ...(wanted.length ? { skills: wanted } : {}),
227
269
  ...(params.cwd ? { cwd: String(params.cwd) } : {}),
228
270
  ...(original.files.length ? { files: original.files } : {}),
229
271
  ...(params.model ? { model: String(params.model) } : {}),
@@ -242,7 +284,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
242
284
  {
243
285
  name: "reply_job",
244
286
  label: "补充任务说明",
245
- description: "把补充、纠正或追问交给已有的任务,它带着原来的上下文和目录接着做;已经结束的任务也可以。",
287
+ description: "把话交给一个已有的任务:补充、纠正、追问,或者回答它的提问。它带着原来的上下文和目录接着做,已经结束的任务也能接着做。",
246
288
  parameters: Type.Object({
247
289
  jobId: Type.String({ description: "任务 id" }),
248
290
  text: Type.String({ description: "要补充的话" }),
@@ -275,17 +317,17 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
275
317
  {
276
318
  name: "ask_user",
277
319
  label: "询问用户",
278
- description: "在对话里出选择题卡片(2 到 3 个选项),等用户选,答案会回到对话。用于用户定的规则要求先确认的事,或发送、删除、付费这类撤不回的事。",
320
+ description: "在对话里出一张选择题卡片(2 到 3 个选项),用户选完,答案会回到对话。",
279
321
  parameters: Type.Object({
280
322
  title: Type.String({ description: "问题,一句话" }),
281
- detail: Type.Optional(Type.String({ description: "补充说明,写清后果" })),
323
+ detail: Type.Optional(Type.String({ description: "补充说明,写清后果。" })),
282
324
  options: Type.Array(
283
325
  Type.Object({
284
326
  label: Type.String({ description: "选项文字,如「暂不发布」" }),
285
327
  }),
286
328
  { description: "2 到 3 个选项", minItems: 2, maxItems: 3 },
287
329
  ),
288
- remember: Type.Optional(Type.String({ description: "同类事以后可以不再问时,填这类事的简称(如「清理临时目录」);卡片会多一个「以后直接做」的选项。" })),
330
+ remember: Type.Optional(Type.String({ description: "这类事以后可以不再问时,填这类事的简称(如「清理临时文件」);卡片上会多一个「以后直接做」的选项。" })),
289
331
  }),
290
332
  execute: async (_id, params: any) => {
291
333
  const labels: { label: string }[] = Array.isArray(params.options) ? params.options : [];
@@ -320,7 +362,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
320
362
  {
321
363
  name: "set_rule",
322
364
  label: "记录规则",
323
- description: "记下用户对某类事的确认方式:ask 先问,allow 直接做。",
365
+ description: "记下用户对某类事的处理方式:ask 是做之前先问,allow 是直接做。",
324
366
  parameters: Type.Object({
325
367
  text: Type.String({ description: "规则内容,如「发布公众号」" }),
326
368
  mode: Type.Unsafe<"ask" | "allow">(Type.String({ description: "ask 或 allow" })),
@@ -364,11 +406,14 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
364
406
  {
365
407
  name: "create_routine",
366
408
  label: "新增定时任务",
367
- description: "新增一个定时任务。schedule 用 cron 表达式,如「0 9 * * *」每天 9 点;多个执行时间就传多个。",
409
+ description: "新增一个定时任务。执行时间用 cron 表达式,如「0 9 * * *」每天 9 点;只跑一次就填具体时间;多个执行时间就传多个。",
368
410
  parameters: Type.Object({
369
411
  name: Type.String({ description: "定时任务名称,如「每日简报」" }),
370
412
  instruction: Type.String({ description: "到点要执行的内容" }),
371
- schedules: Type.Array(Type.String({ description: "cron 表达式" }), { description: "一个或多个" }),
413
+ schedules: Type.Array(Type.String({ description: "cron 表达式或具体时间" }), { description: SCHEDULES_DESCRIPTION }),
414
+ skill: Type.Optional(Type.String({ description: "可选,按哪个技能做,填技能名。" })),
415
+ check: Type.Optional(CHECK_PARAM),
416
+ once: Type.Optional(Type.Boolean({ description: ONCE_DESCRIPTION })),
372
417
  }),
373
418
  execute: async (_id, params: any) => {
374
419
  try {
@@ -377,10 +422,13 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
377
422
  name: String(params.name),
378
423
  instruction: String(params.instruction),
379
424
  schedules: (params.schedules as string[]) ?? [],
425
+ ...(params.skill ? { skill: String(params.skill) } : {}),
426
+ ...(params.check ? { check: params.check as RoutineCheck } : {}),
427
+ ...(params.once ? { once: true } : {}),
380
428
  });
381
429
  await note("routine", `新增定时任务 · ${routine.name} · ${routine.schedules.map(cronToChinese).join("、")}`);
382
430
  profileChanged();
383
- return ok(`定时任务「${routine.name}」已创建。`);
431
+ return ok(`定时任务「${routine.name}」已创建${routineFact(routine.schedules, routine.check, routine.once)}。它的目录是 ${routines.dir(routine)},检查脚本在这里运行;脚本要用的其他文件,可以派任务写到这个目录。`);
384
432
  } catch (error) {
385
433
  return fail(error instanceof Error ? error.message : String(error));
386
434
  }
@@ -389,13 +437,16 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
389
437
  {
390
438
  name: "update_routine",
391
439
  label: "修改定时任务",
392
- description: "修改一个定时任务:名称、内容、执行时间(schedules 会整体替换)、启用状态。",
440
+ description: "修改一个定时任务:名称、内容、执行时间(schedules 会整体替换)、按哪个技能做、检查脚本、启用状态。",
393
441
  parameters: Type.Object({
394
442
  routineId: Type.String(),
395
443
  name: Type.Optional(Type.String()),
396
444
  instruction: Type.Optional(Type.String()),
397
- schedules: Type.Optional(Type.Array(Type.String())),
445
+ schedules: Type.Optional(Type.Array(Type.String({ description: "cron 表达式或具体时间" }), { description: SCHEDULES_DESCRIPTION })),
398
446
  active: Type.Optional(Type.Boolean()),
447
+ skill: Type.Optional(Type.String({ description: "按哪个技能做,填技能名;填空字符串表示不用技能。" })),
448
+ check: Type.Optional(Type.Union([CHECK_PARAM, Type.Null()], { description: `${CHECK_DESCRIPTION}传 null 去掉检查。` })),
449
+ once: Type.Optional(Type.Boolean({ description: ONCE_DESCRIPTION })),
399
450
  }),
400
451
  execute: async (_id, params: any) => {
401
452
  try {
@@ -404,10 +455,13 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
404
455
  ...(params.instruction ? { instruction: String(params.instruction) } : {}),
405
456
  ...(params.schedules ? { schedules: params.schedules as string[] } : {}),
406
457
  ...(typeof params.active === "boolean" ? { active: params.active } : {}),
458
+ ...(typeof params.skill === "string" ? { skill: params.skill } : {}),
459
+ ...(params.check === null || params.check ? { check: params.check as RoutineCheck | null } : {}),
460
+ ...(typeof params.once === "boolean" ? { once: params.once } : {}),
407
461
  });
408
462
  await note("routine", `修改定时任务 · ${routine.name} · ${routine.schedules.map(cronToChinese).join("、")}`);
409
463
  profileChanged();
410
- return ok("已修改。");
464
+ return ok(`已修改${routineFact(routine.schedules, routine.check, routine.once)}。目录是 ${routines.dir(routine)}。`);
411
465
  } catch (error) {
412
466
  return fail(error instanceof Error ? error.message : String(error));
413
467
  }
@@ -432,14 +486,16 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
432
486
  {
433
487
  name: "send_file",
434
488
  label: "发送文件",
435
- description: "把一个文件(任务产出、截图、生成的图)作为卡片发到对话里,图片直接显示。",
489
+ description: "把一个文件(任务交来的文件、截图、任务生成的图)作为卡片发到对话里,图片直接显示。",
436
490
  parameters: Type.Object({ path: Type.String({ description: "文件的绝对路径" }) }),
437
491
  execute: async (_id, params: any) => {
438
- const path = String(params.path);
439
- if (!existsSync(path)) return fail(`文件不存在:${path}`);
492
+ const requested = String(params.path);
493
+ // 迁移后的老路径直接换到新位置(不给模型提示),卡片里存新路径
494
+ const path = existsSync(requested) ? requested : resolveMovedPath(runtime.home, requested);
495
+ if (!existsSync(path)) return fail(`文件不存在:${requested}`);
440
496
  let name = basename(path);
441
497
  let meta = fileMeta(path);
442
- if (dirname(resolve(path)) === resolve(runtime.home, "pages") && extname(path).toLowerCase() === ".html") {
498
+ if (isInPagesDir(relative(runtime.home, resolve(path))) && extname(path).toLowerCase() === ".html") {
443
499
  const html = await readFile(path, "utf8");
444
500
  const title = html.match(/<title>([\s\S]*?)<\/title>/)?.[1];
445
501
  const summary = html.match(/<header class="hd"><h1>[\s\S]*?<\/h1><p>([\s\S]*?)<\/p><\/header>/)?.[1];
@@ -525,7 +581,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
525
581
  tools.push({
526
582
  name: "generate_image",
527
583
  label: "生成图片",
528
- description: "直接在对话里按文字生成或修改图片,生成时显示卡片,完成后图片自动显示,不需要派任务或用 send_file 发送。返回的路径可作为后续改图的参考图。",
584
+ description: "按文字生成或修改图片,直接显示在对话里:生成时有进度卡片,画好自动显示。返回的路径可以作为之后改图的参考图。",
529
585
  parameters: Type.Object({
530
586
  prompt: Type.String({ description: "要画什么,越具体越好" }),
531
587
  size: Type.Optional(Type.String({ description: "尺寸,如 1024x1024;不填用设置里的默认" })),
@@ -537,13 +593,13 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
537
593
  const size = params.size ? String(params.size) : config.source === "openai" ? config.size : undefined;
538
594
  const item = await router.addItem(assistantId, { kind: "image", status: "pending", prompt, ...(size ? { size } : {}), startedAt: new Date().toISOString() });
539
595
  try {
540
- const path = await generateImage({ home: runtime.home, dir: join(runtime.home, "images"), config }, {
596
+ const path = await generateImage({ home: runtime.home, dir: join(runtime.home, "assistants", assistantId, "files", "images"), config }, {
541
597
  prompt,
542
598
  ...(params.size ? { size: String(params.size) } : {}),
543
599
  ...(Array.isArray(params.refs) && params.refs.length ? { refs: params.refs.map(String) } : {}),
544
600
  });
545
601
  await router.updateImage(assistantId, item.id, { status: "done", path });
546
- return ok(`图片已经显示在对话里,不要再用 send_file 发一遍。图片路径:${path}`);
602
+ return ok(`图片已经显示在对话里。路径:${path},之后改图可以作为参考图。`);
547
603
  } catch (error) {
548
604
  const message = error instanceof Error ? error.message : String(error);
549
605
  await router.updateImage(assistantId, item.id, { status: "failed", error: message });
@@ -561,10 +617,10 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
561
617
  {
562
618
  name: "propose_assistant",
563
619
  label: "提议创建专属助手",
564
- description: "同类事情反复出现、又没有合适的助手时,提议新建一个专属助手。生成草稿卡,用户点「创建」才会建。怎么做事是所有助手共用的模板和全局规则定的,草稿里只写这个助手特有的东西。",
620
+ description: "提议新建一个专属助手:对话里出一张草稿卡,用户点「创建」才会建。做事的方法由所有助手共用的模板和全局规则决定,草稿里只写这个助手特有的东西。",
565
621
  parameters: Type.Object({
566
- name: Type.String({ description: "助手名字:职责加一个亲切的昵称,四五个字,如「运维小子」「写稿搭子」「代理管家」;不要只写「代理」「运维」这种光秃秃的词" }),
567
- duty: Type.String({ description: "左栏头像下面那行小字,最多 8 个字,如「线上和机器」「模型和 agent」" }),
622
+ name: Type.String({ description: "助手名字,职责加一个亲切的昵称,四五个字,如「写稿搭子」「记账管家」。" }),
623
+ duty: Type.String({ description: "左栏头像下面那行小字,最多 8 个字,如「周报和文章」「账单和预算」。" }),
568
624
  dutyLong: Type.String({ description: "草稿卡里的职责说明,两三句话,写完整一些" }),
569
625
  sprite: Type.String({
570
626
  description: `内置道具:${PROP_IDS.filter((id) => id !== "hob").map((id) => `${id} ${PROPS[id].label}`).join(",")};或 custom(同时填 prop_art,自己画一件)`,
@@ -591,7 +647,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
591
647
  routines: Type.Optional(
592
648
  Type.Array(
593
649
  Type.Object({ name: Type.String(), instruction: Type.String(), schedule: Type.String() }),
594
- { description: "可选。根据上下文判断这个助手需不需要定期做的事,不是每个助手都要有。拿不准就先问用户,问清楚再加。" },
650
+ { description: "可选。这个助手要定期做的事,不是每个助手都需要;拿不准就先问用户,问清楚再加。" },
595
651
  ),
596
652
  ),
597
653
  askFirst: Type.Optional(Type.Array(Type.String(), { description: "这个领域特有、做之前要先问用户的事,每项一条;全局规则已经列的不重复。" })),
@@ -645,7 +701,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
645
701
  {
646
702
  name: "message_assistant",
647
703
  label: "转交其他助手",
648
- description: "把一件事交给更合适的助手(见「其他助手」),由它负责到底;用户的原话和附件会自动转过去,对方的回复和结果会回到这里。",
704
+ description: "把一件事转给另一个助手(见「助手」一节),由它负责到底。用户的原话和附件会自动转过去,对方的回复和结果会回到这里。",
649
705
  parameters: Type.Object({
650
706
  assistantId: Type.String({ description: "目标助手 id" }),
651
707
  note: Type.Optional(Type.String({ description: "可选,对方需要知道的背景,最多 200 字。" })),
@@ -671,7 +727,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
671
727
  tools.push({
672
728
  name: "hand_back_to_hob",
673
729
  label: "交回 Hob",
674
- description: "这件事不归你管时交回 Hob,由它转给合适的助手。",
730
+ description: "把不归你管的事交回 Hob,由它转给合适的助手。",
675
731
  parameters: Type.Object({ text: Type.String({ description: "用户要什么" }), reason: Type.String({ description: "为什么不归你,一句话" }) }),
676
732
  execute: async (_id, params: any) => {
677
733
  const text = String(params.text ?? "").trim();
@@ -2,7 +2,7 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
2
2
  import { parseStreamingJson } from "@earendil-works/pi-ai/utils/json-parse";
3
3
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
4
  import { mkdirSync } from "node:fs";
5
- import { dirname, join, relative, resolve, sep } from "node:path";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
6
  import type { AgentSession, AgentSessionEvent, SessionEntry, SessionManager } from "@earendil-works/pi-coding-agent";
7
7
  import { SessionManager as SessionManagerImpl } from "@earendil-works/pi-coding-agent";
8
8
  import type {
@@ -21,6 +21,7 @@ import type { HobRuntime } from "./runtime.ts";
21
21
  import { refreshSystemPrompt } from "./pi.ts";
22
22
  import { conversationPick, defaultWorkRef, jobModel, nameOf, noteOf, thinkingForPick, thinkingLevelsOf, fitThinking, usableRefs, NO_MODEL_NOTE, NoModelError } from "./models.ts";
23
23
  import { buildRouterTools } from "./router-tools.ts";
24
+ import { isInPagesDir } from "./files.ts";
24
25
  import { boundKnowledge, formatKnowledge, KNOWLEDGE_INTRO } from "./knowledge-tools.ts";
25
26
  import { MEMORY_WRITE_TOOLS, memoryNoteText } from "./memory.ts";
26
27
  import { buildSystemPrompt, type PromptModel } from "./prompt.ts";
@@ -324,7 +325,8 @@ export class RouterManager {
324
325
  aboutYou,
325
326
  memory: this.runtime.settings.memory().provider,
326
327
  searchEnabled: this.runtime.settings.settings.search.some((provider) => provider.enabled),
327
- imageEnabled: this.runtime.settings.settings.image.source !== "off",
328
+ imageEnabled: tools.some((tool) => tool.name === "generate_image"),
329
+ showPageEnabled: tools.some((tool) => tool.name === "show_page"),
328
330
  desktopEnabled: desktop.enabled && !!desktop.url && desktop.tokenConfigured,
329
331
  publicDesktop: desktop.publicAssistants.includes(assistant.id),
330
332
  models: this.promptModels(assistant.id),
@@ -672,8 +674,7 @@ export class RouterManager {
672
674
  const full = { ...item, ...(lead ? { lead: true } : {}), id, at: this.entryAt(state, id) } as Item;
673
675
  this.bus.emit({ type: "item", assistantId, item: full });
674
676
  if (full.kind === "file") {
675
- const rel = relative(join(this.runtime.home, "pages"), resolve(full.path));
676
- const page = rel !== ".." && !rel.startsWith(`..${sep}`);
677
+ const page = isInPagesDir(relative(this.runtime.home, resolve(full.path)));
677
678
  void this.runtime.knowledge.collect({ origin: "output", path: full.path, assistantId, itemId: id, ...(page ? { name: full.name, description: full.meta } : {}) }).catch((error) => console.error("知识库收集失败:", error));
678
679
  }
679
680
  return full;