hob-bot 1.1.0 → 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.
@@ -23,8 +23,8 @@
23
23
  <meta name="apple-mobile-web-app-title" content="Hob" />
24
24
  <meta name="apple-mobile-web-app-status-bar-style" content="default" />
25
25
  <title>Hob</title>
26
- <script type="module" crossorigin src="/assets/index-B-8ISOr1.js"></script>
27
- <link rel="stylesheet" crossorigin href="/assets/index-Cu1Pf8yX.css">
26
+ <script type="module" crossorigin src="/assets/index-y09i1RGd.js"></script>
27
+ <link rel="stylesheet" crossorigin href="/assets/index-Di5lyTHO.css">
28
28
  </head>
29
29
  <body>
30
30
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hob-bot",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A self-hosted personal assistant that runs real work as background jobs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,6 +46,17 @@ function description(file: string): string {
46
46
  if (!line) throw new Error("技能说明缺失");
47
47
  return JSON.parse(line.slice("description: ".length));
48
48
  }
49
+ export function readAssistantSkillBody(path: string): string {
50
+ const text = readFileSync(path, "utf8");
51
+ const end = text.indexOf("\n---\n");
52
+ if (!text.startsWith("---\n") || end < 0) throw new Error("技能 frontmatter 无效");
53
+ return text.slice(end + 5).replace(/^\n/, "");
54
+ }
55
+
56
+ export function skillInstruction(title: string, instruction: string): string {
57
+ return `按技能「${title}」做(先读它的 SKILL.md)。\n${instruction}`;
58
+ }
59
+
49
60
  export function listAssistantSkills(home: string, assistantId: string): AssistantSkill[] {
50
61
  const dir = checkedDir(home, assistantId);
51
62
  return Object.entries(metadata(dir)).map(([name, data]) => {
@@ -72,8 +83,11 @@ export function readSkillFiles(cwd: string, files: unknown): SkillInput["files"]
72
83
  let root: string | undefined;
73
84
  return files.map((file) => {
74
85
  const { path, content, from } = (file ?? {}) as { path?: unknown; content?: unknown; from?: unknown };
75
- if ((content === undefined) === (from === undefined)) throw new Error("附件要给 content 或 from,只给一个");
76
- if (from === undefined) return { path, content } as { path: string; content: string };
86
+ // 有的模型会把每个字段都填上,没用的那个给空串:空串当没给
87
+ const hasFrom = from !== undefined && from !== "";
88
+ const hasContent = content !== undefined && !(hasFrom && content === "");
89
+ if (hasContent === hasFrom) throw new Error("附件要给 content 或 from,只给一个");
90
+ if (!hasFrom) return { path, content } as { path: string; content: string };
77
91
  if (typeof from !== "string" || !from.trim()) throw new Error("附件的 from 无效");
78
92
  let real: string;
79
93
  try { root ??= realpathSync(cwd); real = realpathSync(resolve(root, from)); }
@@ -86,7 +100,7 @@ export function readSkillFiles(cwd: string, files: unknown): SkillInput["files"]
86
100
  return { path, content: readFileSync(real, "utf8") } as { path: string; content: string };
87
101
  });
88
102
  }
89
- export function saveAssistantSkill(home: string, assistantId: string, input: SkillInput, jobId?: string, replaceFiles = false): AssistantSkill {
103
+ export function saveAssistantSkill(home: string, assistantId: string, input: SkillInput, jobId?: string, replaceFiles = false, options?: { overwriteVersion?: boolean }): AssistantSkill {
90
104
  validName(input.name);
91
105
  if (typeof input.title !== "string" || !input.title.trim() || typeof input.description !== "string" || !input.description.trim() || [...input.description].length > 120 || typeof input.content !== "string") throw new Error("技能内容无效");
92
106
  const body = `---\nname: ${JSON.stringify(input.name)}\ndescription: ${JSON.stringify(input.description)}\nmetadata:\n title: ${JSON.stringify(input.title)}\n---\n\n${input.content}`;
@@ -125,7 +139,7 @@ export function saveAssistantSkill(home: string, assistantId: string, input: Ski
125
139
  const parts = path.split("/");
126
140
  for (let i = 2; i < parts.length; i++) if (combined.has(parts.slice(0, i).join("/"))) throw new Error("附件路径冲突");
127
141
  }
128
- if (old) {
142
+ if (old && !options?.overwriteVersion) {
129
143
  const history = join(target, ".history");
130
144
  mkdirSync(history, { recursive: true });
131
145
  const snapshot = join(history, String(old.version));
@@ -133,7 +147,7 @@ export function saveAssistantSkill(home: string, assistantId: string, input: Ski
133
147
  for (const entry of readdirSync(target)) if (entry !== ".history") cpSync(join(target, entry), join(snapshot, entry), { recursive: true });
134
148
  const versions = readdirSync(history).filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => b - a);
135
149
  for (const version of versions.slice(5)) rmSync(join(history, String(version)), { recursive: true, force: true });
136
- } else mkdirSync(target);
150
+ } else if (!old) mkdirSync(target);
137
151
  if (replaceFiles) for (const folder of ["scripts", "references"]) rmSync(join(target, folder), { recursive: true, force: true });
138
152
  writeFileSync(join(target, "SKILL.md"), body);
139
153
  for (const file of files) {
@@ -142,7 +156,7 @@ export function saveAssistantSkill(home: string, assistantId: string, input: Ski
142
156
  writeFileSync(dest, file.content);
143
157
  }
144
158
  const now = new Date().toISOString();
145
- all[input.name] = { title: input.title, version: (old?.version ?? 0) + 1, createdAt: old?.createdAt ?? now, updatedAt: now, ...(jobId ? { fromJobId: jobId } : old?.fromJobId ? { fromJobId: old.fromJobId } : {}), uses: old?.uses ?? 0, ...(old?.lastUsedAt ? { lastUsedAt: old.lastUsedAt } : {}), enabled: old?.enabled ?? true };
159
+ all[input.name] = { title: input.title, version: old && options?.overwriteVersion ? old.version : (old?.version ?? 0) + 1, createdAt: old?.createdAt ?? now, updatedAt: now, ...(jobId ? { fromJobId: jobId } : old?.fromJobId ? { fromJobId: old.fromJobId } : {}), uses: old?.uses ?? 0, ...(old?.lastUsedAt ? { lastUsedAt: old.lastUsedAt } : {}), enabled: old?.enabled ?? true };
146
160
  saveMetadata(dir, all);
147
161
  return listAssistantSkills(home, assistantId).find((skill) => skill.name === input.name)!;
148
162
  }
@@ -0,0 +1,52 @@
1
+ import type { Approval, Job, JobStatus, JobStep } from "../../shared/types.ts";
2
+ import type { AssistantSkill } from "../../shared/skills.ts";
3
+
4
+ const statusText: Record<JobStatus, string> = { queued: "排队中", running: "进行中", waiting: "等用户确认", done: "已完成", stuck: "受阻", failed: "失败", stopped: "已停止" };
5
+
6
+ export function duration(ms: number): string {
7
+ const seconds = Math.max(0, Math.floor(ms / 1000));
8
+ if (seconds < 60) return `${seconds} 秒`;
9
+ const minutes = Math.floor(seconds / 60);
10
+ return minutes < 60 ? `${minutes} 分钟` : `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟`;
11
+ }
12
+
13
+ /** 任务过程按时间倒序,供任务查看和超时通知共用。 */
14
+ export function jobProcessLines(steps: JobStep[], timezone: string, now = new Date()): string[] {
15
+ const visible = steps.filter((step) => step.kind === "tool" || (step.kind === "text" && (step.from === "assistant" || step.from === "user")));
16
+ const format = new Intl.DateTimeFormat("sv-SE", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
17
+ const today = format.format(now).slice(0, 10);
18
+ return visible.reverse().map((step) => {
19
+ const time = format.format(new Date(step.at));
20
+ const clock = time.slice(0, 10) === today ? time.slice(11) : time.slice(5);
21
+ const detail = step.kind === "tool"
22
+ ? `${step.title ?? step.name ?? "工具"}${step.running ? ` · 还在跑,${Math.max(0, Math.floor((now.getTime() - Date.parse(step.at)) / 60000))} 分钟` : ""}${step.isError ? " · 出错" : ""}`
23
+ : `${step.from === "user" ? "收到" : "说"}:${step.text ?? ""}`;
24
+ const line = `${clock} ${detail}`.replace(/\s+/g, " ").trim();
25
+ const chars = [...line];
26
+ return chars.length > 160 ? `${chars.slice(0, 159).join("")}…` : line;
27
+ });
28
+ }
29
+
30
+ export function jobView(job: Job, steps: JobStep[], approvals: Approval[], skills: AssistantSkill[], timezone: string, now = new Date()): string {
31
+ const ended = !["queued", "running", "waiting"].includes(job.status);
32
+ const elapsed = Math.max(0, now.getTime() - Date.parse(job.startedAt ?? job.createdAt));
33
+ const state = job.status === "queued" ? "排队中" : ended
34
+ ? `${statusText[job.status]},用时 ${duration(job.durationMs ?? (job.endedAt ? Date.parse(job.endedAt) - Date.parse(job.startedAt ?? job.createdAt) : 0))}`
35
+ : `${statusText[job.status]},已运行 ${elapsed < 60000 ? "不到 1 分钟" : `${Math.floor(elapsed / 60000)} 分钟`}`;
36
+ const lines = [`任务「${job.title}」(${job.id}):${state}${job.rounds?.length ? `(第 ${job.rounds.length + 1} 轮)` : ""}`];
37
+ if (job.statusReason) lines.push(`原因:${job.statusReason}`);
38
+ for (const approval of approvals.filter((item) => item.jobId === job.id && item.status === "pending")) lines.push(`在等用户回答:${approval.title}`);
39
+ if (job.skillsUsed?.length) lines.push(`用到的技能:${job.skillsUsed.map((name) => { const skill = skills.find((item) => item.name === name); return skill ? `${skill.title}(第 ${skill.version} 版)` : name; }).join("、")}`);
40
+ if (ended) {
41
+ if (job.summary) {
42
+ const chars = [...job.summary];
43
+ lines.push(chars.length > 1000 ? `结果(共 ${chars.length} 字,全文:${job.reportPath ?? "无"}):${chars.slice(0, 1000).join("")}` : `结果:${job.summary}`);
44
+ }
45
+ if (job.reportPath) lines.push(`报告:${job.reportPath}`);
46
+ }
47
+ if (job.rounds?.length) lines.push(`之前的轮次:${job.rounds.map((round, i) => `第 ${i + 1} 轮 ${statusText[round.status]},用时 ${duration(round.durationMs ?? (round.startedAt && round.endedAt ? Date.parse(round.endedAt) - Date.parse(round.startedAt) : 0))}`).join(";")}`);
48
+ lines.push(`工作目录:${job.cwd ?? "未指定"}`);
49
+ const process = jobProcessLines(steps, timezone, now);
50
+ lines.push(`过程(新的在前,共 ${process.length} 步):`, ...process);
51
+ return lines.join("\n");
52
+ }
@@ -22,6 +22,7 @@ import { imageConfigured } from "./image.ts";
22
22
  import { boundKnowledge, findKnowledgeTool, searchKnowledgeTool } from "./knowledge-tools.ts";
23
23
  import { MCP_SESSION_TOOLS, mcpEnabled, mcpServerDisplay } from "./mcp.ts";
24
24
  import { jobModel } from "./models.ts";
25
+ import { duration, jobProcessLines } from "./job-view.ts";
25
26
  import { overviewTool } from "./overview.ts";
26
27
  import { pageTool } from "./pages.ts";
27
28
  import { pushApproval, pushJobEnd } from "./push.ts";
@@ -83,6 +84,7 @@ export interface StartJobInput {
83
84
  model?: string;
84
85
  thinking?: string;
85
86
  routineId?: string;
87
+ expectMinutes?: number;
86
88
  delegationId?: string;
87
89
  }
88
90
 
@@ -94,6 +96,9 @@ interface WorkerState {
94
96
  lastText?: string;
95
97
  askedFollowUp: boolean;
96
98
  lastActivityAt: number;
99
+ pausedMs: number;
100
+ pausedAt?: number;
101
+ nextReminder: number;
97
102
  finishTimer?: NodeJS.Timeout;
98
103
  ask?: (label: string) => void;
99
104
  /** 用户叫停了:收尾期间这个 worker 的 report/追问都不再算数 */
@@ -114,7 +119,9 @@ const EVENT_SUMMARY_MAX = 3000;
114
119
  export function jobResultEvent(job: Job, prefix = `任务「${job.title}」`): string {
115
120
  const status = job.status === "done" ? "已完成" : job.status === "stuck" ? "受阻" : job.status === "stopped" ? "已停止" : "失败";
116
121
  const result = job.summary;
117
- const header = `${prefix}(${job.id})${status}。`;
122
+ const header = job.expect && job.durationMs !== undefined
123
+ ? `${prefix}(${job.id})${status},用时 ${duration(job.durationMs)},预计 ${job.expect.minutes} 分钟。`
124
+ : `${prefix}(${job.id})${status}。`;
118
125
  if (!result) return `${header}\n${job.statusReason ?? "没有结果"}`;
119
126
  return result.length > EVENT_SUMMARY_MAX
120
127
  ? `${header}\n结果(共 ${result.length} 字,以下是前 3,000 字;全文:${job.reportPath}):\n${result.slice(0, EVENT_SUMMARY_MAX)}`
@@ -231,6 +238,14 @@ export class JobManager {
231
238
  ...(hasImageFiles(input.files) ? { hasImages: true } : {}),
232
239
  });
233
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" };
234
249
  const job: Job = {
235
250
  id,
236
251
  assistantId,
@@ -238,6 +253,7 @@ export class JobManager {
238
253
  title: input.title,
239
254
  instruction: input.instruction,
240
255
  status: "queued",
256
+ expect,
241
257
  createdAt: new Date().toISOString(),
242
258
  stats: { reads: 0, commands: 0, edits: 0 },
243
259
  activity: [],
@@ -353,6 +369,8 @@ export class JobManager {
353
369
  manager: opened.manager,
354
370
  askedFollowUp: false,
355
371
  lastActivityAt: Date.now(),
372
+ pausedMs: 0,
373
+ nextReminder: running.expect?.source === "routine" ? 1.5 : 1,
356
374
  steps: new Map(),
357
375
  readPaths: new Map(),
358
376
  queued: [],
@@ -740,7 +758,7 @@ export class JobManager {
740
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;
741
759
  const ended: JobRound = { status: job.status, statusReason, startedAt: job.startedAt, endedAt, durationMs, summary, files, usage, reportPath: savedReport, followUp: text };
742
760
  const rounds = [...previous, ended];
743
- const queued: Job = { ...rest, rounds, status: "queued", startedAt: new Date().toISOString() };
761
+ const queued: Job = { ...rest, rounds, status: "queued", expect: { minutes: 10, source: "default" }, startedAt: undefined };
744
762
  this.store.saveJob(queued);
745
763
  try {
746
764
  await this.runtime.router.addItem(job.assistantId, { kind: "job", jobId, round: rounds.length });
@@ -801,6 +819,7 @@ export class JobManager {
801
819
 
802
820
  private async sedimentRound(job: Job, state: { cancelled: boolean; session?: AgentSession }, source: "manual" | "auto"): Promise<void> {
803
821
  let calledSave = false;
822
+ const savedSkills = new Set<string>();
804
823
  let lastText = "";
805
824
  const skillTool = {
806
825
  name: "save_skill", label: "保存技能",
@@ -816,14 +835,18 @@ export class JobManager {
816
835
  calledSave = true;
817
836
  if (state.cancelled || this.store.job(job.id)?.status !== "done") throw new Error("任务已继续,取消沉淀");
818
837
  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}」,以后定时执行按它做`;
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 });
825
849
  }
826
- await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "skill", text, path: skill.path });
827
850
  this.bus.emit({ type: "skills", assistantId: job.assistantId });
828
851
  return { content: [{ type: "text" as const, text: "技能已保存。" }], details: undefined };
829
852
  },
@@ -870,7 +893,7 @@ export class JobManager {
870
893
  } finally { opened.session.dispose(); }
871
894
  }
872
895
 
873
- async stop(jobId: string): Promise<void> {
896
+ async stop(jobId: string, reason = "你叫停了"): Promise<void> {
874
897
  const job = this.store.job(jobId);
875
898
  if (!job) throw new Error("没有这个任务。");
876
899
  const worker = this.workers.get(jobId);
@@ -882,7 +905,7 @@ export class JobManager {
882
905
  // abort 会把正在执行的工具(bash 子进程)一起终止
883
906
  await worker.session?.abort().catch(() => undefined);
884
907
  }
885
- await this.finish(jobId, "stopped", undefined, "你叫停了");
908
+ await this.finish(jobId, "stopped", undefined, reason);
886
909
  }
887
910
 
888
911
  /** Worker 的 ask_user:出审批卡、把任务标成待确认,阻塞到用户答复。 */
@@ -913,7 +936,7 @@ export class JobManager {
913
936
  const waiting: Job = { ...job, status: "waiting" };
914
937
  this.store.saveJob(waiting);
915
938
  this.bus.emit({ type: "job", job: waiting });
916
- if (worker) worker.job = waiting;
939
+ if (worker) { worker.job = waiting; worker.pausedAt = Date.now(); }
917
940
  return await new Promise<string>((resolve) => {
918
941
  if (worker) worker.ask = resolve;
919
942
  else resolve("(任务未在运行)");
@@ -933,6 +956,8 @@ export class JobManager {
933
956
  worker?.ask?.(answer);
934
957
  if (worker) {
935
958
  worker.ask = undefined;
959
+ if (worker.pausedAt !== undefined) worker.pausedMs += Date.now() - worker.pausedAt;
960
+ worker.pausedAt = undefined;
936
961
  worker.lastActivityAt = Date.now();
937
962
  }
938
963
  }
@@ -949,6 +974,24 @@ export class JobManager {
949
974
  checkStalls(now = Date.now()): void {
950
975
  const idleMs = this.runtime.settings.settings.work.jobStallMinutes * 60_000;
951
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
+ }
952
995
  // 在等用户答提问不算没响应:等多久都是用户的事
953
996
  if (!worker.ask && now - worker.lastActivityAt > idleMs) {
954
997
  void this.finish(jobId, "stuck", undefined, `${Math.round(idleMs / 60_000)} 分钟无响应`);
@@ -9,7 +9,7 @@ const LOG_LIMIT = 10;
9
9
  const STATUS_TEXT: Record<Job["status"], string> = {
10
10
  queued: "排队中",
11
11
  running: "进行中",
12
- waiting: "待确认",
12
+ waiting: "等你确认",
13
13
  done: "已完成",
14
14
  stuck: "受阻",
15
15
  failed: "失败",
@@ -83,13 +83,13 @@ export function overviewText(runtime: HobRuntime, sinceHours = 24): string {
83
83
  for (const entry of log) lines.push(`- ${whenText(timezone, entry.at)} ${entry.text}`);
84
84
  }
85
85
  if (asks.length) {
86
- lines.push("待确认:");
86
+ lines.push("等你确认:");
87
87
  for (const approval of asks) lines.push(`- ${approval.title}(${whenText(timezone, approval.createdAt)})`);
88
88
  }
89
89
  blocks.push(lines.join("\n"));
90
90
  }
91
91
 
92
- const head = `最近 ${hours} 小时(${whenText(timezone, since)} 之后):${endedCount} 件结束的任务、${liveCount} 件进行中、${pending.length} 个待确认。`;
92
+ const head = `最近 ${hours} 小时(${whenText(timezone, since)} 之后):${endedCount} 件结束的任务、${liveCount} 件进行中、${pending.length} 个等你确认。`;
93
93
  if (!blocks.length) return `${head}\n\n这段时间没有记录。`;
94
94
  return `${head}\n\n${blocks.join("\n\n")}`;
95
95
  }
@@ -25,7 +25,8 @@ export interface PromptSettings {
25
25
  memory: MemoryProvider;
26
26
  models?: PromptModel[];
27
27
  skills?: { name: string; description: string }[];
28
- assistantSkills?: { title: string; description: string }[];
28
+ assistantSkills?: { name: string; title: string; description: string; version: number; updatedAt: string }[];
29
+ assistantSkillsDir?: string;
29
30
  knowledgeNames?: string[];
30
31
  thinkingChoice?: "auto" | "fixed";
31
32
  abilities?: Abilities;
@@ -56,11 +57,15 @@ export function buildSystemPrompt(store: Store, assistantId: string, settings: P
56
57
  lines.push(
57
58
  "用简洁、规范的书面中文回复,先给结论。",
58
59
  "", "## 你手里有什么",
59
- "你能直接看:文件和图片、任务的结果和产出、网页、记忆。",
60
- "跑命令、改文件、操作设备这些动手的事在后台任务手里:任务在自己的会话和目录里做,做完把完整结果发回给你。",
61
- "任务结束后还能接着做。一件事交给任务或其他助手后由它负责到底,跟进也交给它。",
60
+ "你能直接看:文件和图片、任务的过程和结果、产出、网页、记忆。",
61
+ "跑命令、改文件、操作设备这些动手的事交给后台任务。任务是你手上的活,在自己的会话和目录里做,做完把完整结果交给你;对用户负责的是你。",
62
+ "任务做完还能接着做,补充和纠正交给原来那个任务,它接得上之前的上下文。",
62
63
  );
63
- if (settings.assistantSkills?.length) lines.push(`你沉淀过的技能:${settings.assistantSkills.map((skill) => `${skill.title}:${skill.description}`).join("、")}。派同类的活时在指令里写明按哪个技能做。`);
64
+ if (settings.assistantSkills?.length) {
65
+ lines.push(`你的技能是做同类事的做法,派活时可以让任务照着做。文件在 ${settings.assistantSkillsDir}/<名字>/SKILL.md:`);
66
+ const format = new Intl.DateTimeFormat("sv-SE", { timeZone: settings.timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
67
+ for (const skill of settings.assistantSkills) lines.push(`- ${skill.name}「${skill.title}」· 第 ${skill.version} 版 · ${format.format(new Date(skill.updatedAt)).slice(5)} 更新:${skill.description}`);
68
+ }
64
69
  if (settings.knowledgeNames?.length) lines.push(`能查的知识库:${settings.knowledgeNames.join("、")}。问到可能写在这些库里的内容,先用 search_knowledge 查。`);
65
70
  if (settings.skills?.length) lines.push(`任务能用的技能:${settings.skills.map((skill) => skill.name).join("、")}`);
66
71
  lines.push(...aboutSection(settings.aboutYou));
@@ -8,6 +8,8 @@ export function activityLabel(name: string, args: Record<string, unknown> = {},
8
8
  switch (name) {
9
9
  case "read": label = withValue("正在读", basename(value("path"))); break;
10
10
  case "start_job": label = withValue("派任务", value("title")); break;
11
+ case "check_job": label = "看任务"; break;
12
+ case "edit_skill": label = "改技能"; break;
11
13
  case "reply_job": label = "补充任务要求"; break;
12
14
  case "stop_job": label = "停止任务"; break;
13
15
  case "ask_user": label = "准备问你"; break;
@@ -29,6 +29,8 @@ import { pushApproval } from "./push.ts";
29
29
  import { searchTools } from "./search.ts";
30
30
  import type { HobRuntime } from "./runtime.ts";
31
31
  import { clampDuty } from "./text.ts";
32
+ import { listAssistantSkills, readAssistantSkillBody, saveAssistantSkill, skillInstruction, usableAssistantSkills } from "./assistant-skills.ts";
33
+ import { jobView } from "./job-view.ts";
32
34
 
33
35
  type Tool = ToolDefinition<any, any>;
34
36
 
@@ -98,6 +100,46 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
98
100
  detectImageMimeType: async (path) => IMAGE_TYPES[extname(checkFile(path)).toLowerCase()],
99
101
  } }) as unknown as Tool;
100
102
  const tools: Tool[] = [
103
+ {
104
+ name: "check_job",
105
+ label: "看任务",
106
+ description: "看一个任务现在的状态和过程:做到哪一步、跑过哪些命令、说过什么、用了哪个技能、在等什么。",
107
+ parameters: Type.Object({ jobId: Type.String({ description: "任务 id" }) }),
108
+ execute: async (_id, params: { jobId: string }) => {
109
+ const job = store.job(params.jobId);
110
+ if (!job || (!assistant().primary && job.assistantId !== assistantId)) return fail(`没有这个任务:${params.jobId}`);
111
+ try {
112
+ return ok(jobView({ ...job, cwd: job.cwd ?? store.workDir(job.id) }, jobs.transcript(job.id), store.approvals(), listAssistantSkills(runtime.home, job.assistantId), runtime.settings.settings.general.timezone));
113
+ } catch (error) { return fail(error instanceof Error ? error.message : String(error)); }
114
+ },
115
+ },
116
+ {
117
+ name: "edit_skill",
118
+ label: "改技能",
119
+ description: "改这个助手某个技能的正文(SKILL.md):把一段原文换成新的文字,旧版会保留。要改脚本的,交给任务去改。",
120
+ parameters: Type.Object({
121
+ name: Type.String({ description: "技能名" }),
122
+ edits: Type.Array(Type.Object({ old: Type.String(), new: Type.String() }), { description: "一处或几处修改:old 是正文里的原文,要一字不差、只出现一次;new 是换成的文字" }),
123
+ }),
124
+ execute: async (_id, params: { name: string; edits: { old: string; new: string }[] }) => {
125
+ try {
126
+ const skill = listAssistantSkills(runtime.home, assistantId).find((item) => item.name === params.name);
127
+ if (!skill) return fail(`没有这个技能:${params.name}`);
128
+ let body = readAssistantSkillBody(skill.path);
129
+ for (const edit of params.edits) {
130
+ if (!edit.old) return fail("原文不能为空");
131
+ const count = body.split(edit.old).length - 1;
132
+ if (!count) return fail(`正文里找不到这段原文:${[...edit.old].slice(0, 30).join("")}`);
133
+ if (count > 1) return fail(`这段原文出现了 ${count} 次,多带几个字`);
134
+ body = body.replace(edit.old, () => edit.new);
135
+ }
136
+ const saved = saveAssistantSkill(runtime.home, assistantId, { name: skill.name, title: skill.title, description: skill.description, content: body });
137
+ await router.addItem(assistantId, { kind: "note", icon: "skill", text: `已更新技能「${saved.title}」· 第 ${saved.version} 版`, path: saved.path });
138
+ bus.emit({ type: "skills", assistantId });
139
+ return ok(`已更新技能「${saved.title}」,第 ${saved.version} 版。`);
140
+ } catch (error) { return fail(error instanceof Error ? error.message : String(error)); }
141
+ },
142
+ },
101
143
  {
102
144
  name: "save_job_as_skill",
103
145
  label: "沉淀技能",
@@ -160,6 +202,8 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
160
202
  description: "要交给任务的文件路径(用户发来的附件);图片会直接给模型看",
161
203
  }),
162
204
  ),
205
+ skill: Type.Optional(Type.String({ description: "可选,按这个助手的哪个技能做,填技能名。" })),
206
+ expect_minutes: Type.Optional(Type.Number({ description: "可选,预计多少分钟能交结果。超过了会通知你;不填按 10 分钟。" })),
163
207
  model: Type.Optional(Type.String({ description: `可选,不填用默认。聪明、成本、速度 0–100,越高越聪明、越贵、越快:\n${modelText}` })),
164
208
  thinking: Type.Optional(
165
209
  Type.String({ description: modelSettings.thinkingChoice === "fixed" ? "当前设置为固定思考档,此参数会被忽略,不要填。" : "可选:思考档以可分派模型列表里每个模型标的为准。不填用模型的默认思考档。" }),
@@ -167,12 +211,19 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
167
211
  }),
168
212
  execute: async (_id, params: any) => {
169
213
  try {
214
+ if (params.expect_minutes != null && (!Number.isInteger(params.expect_minutes) || params.expect_minutes < 1 || params.expect_minutes > 10080)) return fail("expect_minutes 要在 1 到 10080 之间");
170
215
  const note = params.note == null ? "" : String(params.note);
171
216
  if ([...note].length > 200) return fail("备注最多 200 字;用户原话和最近的对话会自动带上,备注只写从原话里看不出的背景。");
217
+ // 有的模型会把没用的可选参数填成空串:空串当没填
218
+ const wanted = params.skill ? String(params.skill) : "";
219
+ 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("、") || "无"}`);
172
222
  const original = router.originalWords(assistantId, note, true, Array.isArray(params.files) ? params.files.map(String) : []);
173
223
  const job = await jobs.start(assistantId, {
174
224
  title: String(params.title),
175
- instruction: original.text,
225
+ ...(params.expect_minutes != null ? { expectMinutes: params.expect_minutes } : {}),
226
+ instruction: skill ? skillInstruction(skill.title, original.text) : original.text,
176
227
  ...(params.cwd ? { cwd: String(params.cwd) } : {}),
177
228
  ...(original.files.length ? { files: original.files } : {}),
178
229
  ...(params.model ? { model: String(params.model) } : {}),
@@ -181,7 +232,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
181
232
  });
182
233
  if (job.status === "failed") return fail(`任务启动失败:${job.statusReason ?? "原因不明"}`);
183
234
  return endTurn(
184
- `任务「${job.title}」(${job.id})已开始。`,
235
+ `任务「${job.title}」(${job.id})已开始,预计 ${job.expect?.minutes ?? 10} 分钟${params.expect_minutes == null ? "(默认)" : ""}。`,
185
236
  );
186
237
  } catch (error) {
187
238
  return fail(error instanceof Error ? error.message : String(error));
@@ -214,7 +265,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
214
265
  parameters: Type.Object({ jobId: Type.String({ description: "任务 id" }) }),
215
266
  execute: async (_id, params: any) => {
216
267
  try {
217
- await jobs.stop(String(params.jobId));
268
+ await jobs.stop(String(params.jobId), `${assistant().name}叫停了`);
218
269
  return ok("已停止。");
219
270
  } catch (error) {
220
271
  return fail(error instanceof Error ? error.message : String(error));
@@ -287,6 +287,7 @@ export class RouterManager {
287
287
  models: this.promptModels(assistant.id),
288
288
  skills: availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled, this.runtime.settings.memory().provider === "lore" ? this.runtime.pi.loreSkillPaths : []),
289
289
  assistantSkills: usableAssistantSkills(this.runtime.home, assistant.id),
290
+ assistantSkillsDir: resolve(this.runtime.home, "assistants", assistant.id, "skills"),
290
291
  thinkingChoice: this.runtime.settings.settings.models.thinkingChoice,
291
292
  knowledgeNames: state.knowledgeNames,
292
293
  });
@@ -2,7 +2,7 @@ import { Cron } from "croner";
2
2
  import type { Job, Routine } from "../../shared/types.ts";
3
3
  import type { HobRuntime } from "./runtime.ts";
4
4
  import { newId } from "./ids.ts";
5
- import { listAssistantSkills, usableAssistantSkills } from "./assistant-skills.ts";
5
+ import { listAssistantSkills, skillInstruction, usableAssistantSkills } from "./assistant-skills.ts";
6
6
 
7
7
  export interface RoutineInput {
8
8
  assistantId: string;
@@ -58,7 +58,7 @@ export class RoutineScheduler {
58
58
 
59
59
  private instruction(routine: Routine): string {
60
60
  const skill = routine.skill && usableAssistantSkills(this.runtime.home, routine.assistantId).find((item) => item.name === routine.skill);
61
- return skill ? `按技能「${skill.title}」做(先读它的 SKILL.md)。\n${routine.instruction}` : routine.instruction;
61
+ return skill ? skillInstruction(skill.title, routine.instruction) : routine.instruction;
62
62
  }
63
63
 
64
64
  private async fire(routineId: string): Promise<void> {
package/shared/types.ts CHANGED
@@ -380,6 +380,7 @@ export interface Job {
380
380
  endedAt?: Iso;
381
381
  usage?: JobUsage; // 输入/输出 token 和花费
382
382
  durationMs?: number; // 用时(结束时间 − 开始时间)
383
+ expect?: { minutes: number; source: "assistant" | "routine" | "default"; runs?: number };
383
384
  stats: { reads: number; commands: number; edits: number };
384
385
  summary?: string; // report 的完整结果
385
386
  reportPath?: string; // 完整报告