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.
- package/README.md +4 -4
- package/README.zh-CN.md +4 -4
- package/dist/web/assets/{DesktopView-rVxacveF.js → DesktopView-CEBJFAIN.js} +2 -2
- package/dist/web/assets/index-Di5lyTHO.css +1 -0
- package/dist/web/assets/index-y09i1RGd.js +267 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/server/src/assistant-skills.ts +20 -6
- package/server/src/job-view.ts +52 -0
- package/server/src/jobs.ts +55 -12
- package/server/src/overview.ts +3 -3
- package/server/src/prompt.ts +10 -5
- package/server/src/router-activity.ts +2 -0
- package/server/src/router-tools.ts +54 -3
- package/server/src/router.ts +1 -0
- package/server/src/routines.ts +2 -2
- package/shared/types.ts +1 -0
- package/dist/web/assets/index-B-8ISOr1.js +0 -267
- package/dist/web/assets/index-Cu1Pf8yX.css +0 -1
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
27
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
@@ -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
|
-
|
|
76
|
-
|
|
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
|
+
}
|
package/server/src/jobs.ts
CHANGED
|
@@ -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 =
|
|
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",
|
|
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
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
if (
|
|
823
|
-
|
|
824
|
-
|
|
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)} 分钟无响应`);
|
package/server/src/overview.ts
CHANGED
|
@@ -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
|
}
|
package/server/src/prompt.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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));
|
package/server/src/router.ts
CHANGED
|
@@ -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
|
});
|
package/server/src/routines.ts
CHANGED
|
@@ -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 ?
|
|
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; // 完整报告
|