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.
- package/README.md +42 -23
- package/README.zh-CN.md +43 -24
- package/bin/hob.mjs +8 -2
- package/dist/web/assets/{DesktopView-lixZfbYr.js → DesktopView-3QDYo0UU.js} +1 -1
- package/dist/web/assets/index-Dzxrt9T3.js +283 -0
- package/dist/web/assets/index-FUtuyxyO.css +1 -0
- package/dist/web/index.html +2 -2
- package/dist/web/mock/readme-cover.svg +13 -0
- package/dist/web/mock/readme-page.html +7 -0
- package/package.json +2 -1
- package/server/src/actions.ts +2 -1
- package/server/src/assistant-skills.ts +2 -2
- package/server/src/boot.ts +8 -0
- package/server/src/config.ts +52 -11
- package/server/src/cron-text.ts +14 -1
- package/server/src/delete-skill.ts +19 -0
- package/server/src/desktop-tools.ts +6 -6
- package/server/src/files.ts +27 -7
- package/server/src/http.ts +41 -9
- package/server/src/job-view.ts +2 -2
- package/server/src/jobs.ts +108 -62
- package/server/src/knowledge-tools.ts +1 -1
- package/server/src/knowledge.ts +9 -4
- package/server/src/layout-cli.ts +25 -0
- package/server/src/layout.ts +749 -0
- package/server/src/main.ts +5 -0
- package/server/src/mcp.ts +3 -2
- package/server/src/memory.ts +2 -2
- package/server/src/now.ts +1 -1
- package/server/src/overview.ts +1 -2
- package/server/src/pages.ts +9 -7
- package/server/src/prompt.ts +47 -110
- package/server/src/push.ts +3 -2
- package/server/src/router-tools.ts +96 -40
- package/server/src/router.ts +5 -4
- package/server/src/routines.ts +177 -22
- package/server/src/runtime.ts +12 -1
- package/server/src/search.ts +1 -1
- package/server/src/secrets.ts +3 -2
- package/server/src/settings.ts +44 -6
- package/server/src/store.ts +123 -40
- package/server/src/transcript.ts +25 -7
- package/server/src/usage-prices.ts +166 -0
- package/server/src/usage.ts +291 -0
- package/server/src/worker-tools.ts +6 -9
- package/shared/types.ts +110 -3
- package/dist/web/assets/index-BZhge0qe.js +0 -267
- package/dist/web/assets/index-DIo1ArRa.css +0 -1
package/server/src/jobs.ts
CHANGED
|
@@ -14,9 +14,11 @@ import type {
|
|
|
14
14
|
JobStatus,
|
|
15
15
|
JobStep,
|
|
16
16
|
RoutineRun,
|
|
17
|
+
SedimentStatus,
|
|
17
18
|
} from "../../shared/types.ts";
|
|
18
19
|
import { IMAGE_TYPES, isImagePath } from "../../shared/media.ts";
|
|
19
20
|
import { newId } from "./ids.ts";
|
|
21
|
+
import { resolveMovedPath } from "./layout.ts";
|
|
20
22
|
import { availableDesktopTools, type DesktopToolSession } from "./desktop-tools.ts";
|
|
21
23
|
import { imageConfigured } from "./image.ts";
|
|
22
24
|
import { boundKnowledge, findKnowledgeTool, searchKnowledgeTool } from "./knowledge-tools.ts";
|
|
@@ -83,6 +85,7 @@ export interface StartJobInput {
|
|
|
83
85
|
model?: string;
|
|
84
86
|
thinking?: string;
|
|
85
87
|
routineId?: string;
|
|
88
|
+
skills?: string[];
|
|
86
89
|
expectMinutes?: number;
|
|
87
90
|
delegationId?: string;
|
|
88
91
|
}
|
|
@@ -115,18 +118,32 @@ interface WorkerState {
|
|
|
115
118
|
/** 推给助手的任务结果最多带这么多字的报告。 */
|
|
116
119
|
const EVENT_SUMMARY_MAX = 3000;
|
|
117
120
|
|
|
118
|
-
export function jobResultEvent(job: Job, prefix = `任务「${job.title}
|
|
121
|
+
export function jobResultEvent(job: Job, prefix = `任务「${job.title}」`, skillTitles: string[] = [], toolCount = job.stats.reads + job.stats.commands + job.stats.edits, hint?: string): string {
|
|
119
122
|
const status = job.status === "done" ? "已完成" : job.status === "stuck" ? "受阻" : job.status === "stopped" ? "已停止" : "失败";
|
|
120
123
|
const result = job.summary;
|
|
121
|
-
const header = job.
|
|
122
|
-
? `${prefix}(${job.id})${status},用时 ${duration(job.durationMs)},预计 ${job.expect.minutes} 分钟。`
|
|
123
|
-
: `${prefix}(${job.id})${status}。`;
|
|
124
|
+
const header = `${prefix}(${job.id})${status}${job.durationMs !== undefined ? `,用时 ${duration(job.durationMs)}${job.expect ? `,预计 ${job.expect.minutes} 分钟` : ""}` : ""}。\n过程:动手 ${toolCount} 次,${skillTitles.length ? `用了技能${skillTitles.map((title) => `「${title}」`).join("")}` : "没用技能"}。${hint ? `\n${hint}` : ""}`;
|
|
124
125
|
if (!result) return `${header}\n${job.statusReason ?? "没有结果"}`;
|
|
125
126
|
return result.length > EVENT_SUMMARY_MAX
|
|
126
127
|
? `${header}\n结果(共 ${result.length} 字,以下是前 3,000 字;全文:${job.reportPath}):\n${result.slice(0, EVENT_SUMMARY_MAX)}`
|
|
127
128
|
: `${header}\n结果:\n${result}`;
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
/** 没带技能的任务做完了,就在结果里提醒助手安排一次沉淀;有从这个任务沉淀出来的技能时,末尾告诉它 into 填哪份。 */
|
|
132
|
+
export function sedimentHint(home: string, job: Job, skills = usableAssistantSkills(home, job.assistantId)): string | undefined {
|
|
133
|
+
if (job.status !== "done" || job.skills?.length) return undefined;
|
|
134
|
+
const last = skills.filter((skill) => skill.fromJobId === job.id).sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0];
|
|
135
|
+
return `这个任务没带技能。用 save_job_as_skill 让它回顾一遍,把能复用的做法沉淀下来,有没有值得沉淀的由它判断;已经有同类的技能,into 填那份。${last ? `上次从这个任务沉淀出了技能「${last.title}」(${last.name}),into 填它。` : ""}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 发给任务所属助手的结果事件。转交结束发给 Hob 的汇总用它不带提醒的那份(delegations.ts)。 */
|
|
139
|
+
export function assistantJobResultEvent(home: string, job: Job, options: { prefix?: string; toolCount?: number } = {}): string {
|
|
140
|
+
const skills = usableAssistantSkills(home, job.assistantId);
|
|
141
|
+
const skillTitles = (job.skillsUsed ?? []).map((name) => skills.find((skill) => skill.name === name)?.title ?? name);
|
|
142
|
+
const filesNote = job.files?.length ? `\n任务交来的文件:${job.files.join("、")}。` : "";
|
|
143
|
+
const text = jobResultEvent(job, options.prefix ?? `任务「${job.title}」`, skillTitles, options.toolCount ?? job.stats.reads + job.stats.commands + job.stats.edits, sedimentHint(home, job, skills));
|
|
144
|
+
return `${text}${filesNote}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
function trim(value: string, max = 240): string {
|
|
131
148
|
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
132
149
|
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
@@ -161,7 +178,6 @@ export class JobManager {
|
|
|
161
178
|
private followUps = new Map<string, string>();
|
|
162
179
|
private finishing = new Map<string, Promise<void>>();
|
|
163
180
|
private sediments = new Map<string, { cancelled: boolean; session?: AgentSession; task?: Promise<void> }>();
|
|
164
|
-
private replied = new Set<string>();
|
|
165
181
|
private watchdog?: NodeJS.Timeout;
|
|
166
182
|
|
|
167
183
|
constructor(private readonly runtime: HobRuntime) {}
|
|
@@ -205,6 +221,8 @@ export class JobManager {
|
|
|
205
221
|
cwd: job.cwd ?? "",
|
|
206
222
|
save: (stepId, text) => this.store.writeStepOutput(jobId, stepId, text),
|
|
207
223
|
mcpServers: mcpServerDisplay(this.runtime.home),
|
|
224
|
+
// 迁移前写进会话的绝对路径:换成新位置后,详情页里还是短文件名
|
|
225
|
+
resolvePath: (path) => resolveMovedPath(this.runtime.home, path),
|
|
208
226
|
});
|
|
209
227
|
return [...steps, ...(this.workers.get(jobId)?.queued ?? [])];
|
|
210
228
|
}
|
|
@@ -262,6 +280,7 @@ export class JobManager {
|
|
|
262
280
|
...(picked.modelReason ? { modelReason: picked.modelReason } : {}),
|
|
263
281
|
...(input.files?.length ? { inputs: input.files } : {}),
|
|
264
282
|
...(input.routineId ? { routineId: input.routineId } : {}),
|
|
283
|
+
...(input.skills?.length ? { skills: input.skills } : {}),
|
|
265
284
|
...(input.delegationId ? { delegationId: input.delegationId } : {}),
|
|
266
285
|
};
|
|
267
286
|
this.store.saveJob(job);
|
|
@@ -338,7 +357,8 @@ export class JobManager {
|
|
|
338
357
|
if (assistant?.primary) customTools.push(overviewTool(this.runtime));
|
|
339
358
|
// MCP:配置里有启用中的服务才加载(改完配置,之后新开的任务生效)
|
|
340
359
|
const mcp = mcpEnabled(this.runtime.home);
|
|
341
|
-
const
|
|
360
|
+
const allOwnSkills = enabledAssistantSkills(this.runtime.home, running.assistantId);
|
|
361
|
+
const ownSkills = allOwnSkills.filter((skill) => running.skills?.includes(skill.name));
|
|
342
362
|
const ownNames = new Set(ownSkills.map((skill) => skill.name));
|
|
343
363
|
const opened = await this.runtime.pi.open({
|
|
344
364
|
file: this.store.workerFile(running.id),
|
|
@@ -349,7 +369,7 @@ export class JobManager {
|
|
|
349
369
|
skills: [...ownSkills, ...availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled).filter((skill) => !ownNames.has(skill.name))],
|
|
350
370
|
disabledSkills: this.runtime.settings.settings.skills?.disabled,
|
|
351
371
|
systemPrompt: () =>
|
|
352
|
-
buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities, knowledgeNames: knowledge.map((collection) => collection.name) }),
|
|
372
|
+
buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities, desktopEnabled: !!desktop, knowledgeNames: knowledge.map((collection) => collection.name) }),
|
|
353
373
|
tools: [
|
|
354
374
|
...workerTools(abilities),
|
|
355
375
|
...customTools.map((tool) => tool.name),
|
|
@@ -568,7 +588,7 @@ export class JobManager {
|
|
|
568
588
|
await this.finish(worker.job.id, "stuck", undefined, "未提交结果");
|
|
569
589
|
return;
|
|
570
590
|
}
|
|
571
|
-
await session.prompt("
|
|
591
|
+
await session.prompt("这一轮结束了,还没有交结果。做完了就调用 report 交结果;没做完,也用 report 说明做到哪、卡在哪。");
|
|
572
592
|
}
|
|
573
593
|
|
|
574
594
|
/** report 工具收到结果后由 worker-tools 调用。 */
|
|
@@ -600,13 +620,6 @@ export class JobManager {
|
|
|
600
620
|
const task = this.finishOnce(jobId, status, summary, reason, files);
|
|
601
621
|
this.finishing.set(jobId, task);
|
|
602
622
|
try { await task; } finally { this.finishing.delete(jobId); }
|
|
603
|
-
const job = this.store.job(jobId);
|
|
604
|
-
const steps = status === "done" && job?.status === "done" ? this.transcript(jobId) : [];
|
|
605
|
-
// 自动沉淀:用户中途接着说过话;或者没照着技能做、而且是定时执行或动手 20 次以上。
|
|
606
|
-
// 照着技能顺利做完的不再沉淀,免得每天跑完都改一版。
|
|
607
|
-
const followed = this.replied.has(jobId) || steps.some((step) => step.from === "user" && step.text !== "沉淀技能");
|
|
608
|
-
const fresh = !job?.skillsUsed?.length && (Boolean(job?.routineId) || steps.filter((step) => step.kind === "tool").length >= 20);
|
|
609
|
-
if (job?.status === "done" && status === "done" && (followed || fresh)) this.startSediment(jobId);
|
|
610
623
|
}
|
|
611
624
|
|
|
612
625
|
private async finishOnce(jobId: string, status: JobStatus, summary?: string, reason?: string, files?: string[]): Promise<void> {
|
|
@@ -677,7 +690,7 @@ export class JobManager {
|
|
|
677
690
|
jobId: job.id,
|
|
678
691
|
});
|
|
679
692
|
this.bus.emit({ type: "profile", assistantId: job.assistantId });
|
|
680
|
-
const
|
|
693
|
+
const toolCount = this.transcript(job.id).filter((step) => step.kind === "tool" && (!next.startedAt || step.at >= next.startedAt)).length;
|
|
681
694
|
|
|
682
695
|
if (job.routineId) {
|
|
683
696
|
const routine = this.store.routine(job.routineId);
|
|
@@ -693,13 +706,13 @@ export class JobManager {
|
|
|
693
706
|
this.store.saveRoutine(updated);
|
|
694
707
|
this.bus.emit({ type: "routine", routine: updated });
|
|
695
708
|
await this.runtime.router
|
|
696
|
-
.event(job.assistantId,
|
|
709
|
+
.event(job.assistantId, assistantJobResultEvent(this.runtime.home, next, { prefix: `定时任务「${routine.name}」`, toolCount }), {
|
|
697
710
|
routine: { id: routine.id, name: routine.name, jobId: job.id },
|
|
698
711
|
})
|
|
699
712
|
.catch(() => undefined);
|
|
700
713
|
}
|
|
701
714
|
} else if (assistant) {
|
|
702
|
-
const text =
|
|
715
|
+
const text = assistantJobResultEvent(this.runtime.home, next, { toolCount });
|
|
703
716
|
if (delegationId) void this.runtime.router.relay(job.assistantId, text, delegationId).catch(() => this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败", true));
|
|
704
717
|
else await this.runtime.router.event(job.assistantId, text).catch(() => undefined);
|
|
705
718
|
}
|
|
@@ -713,11 +726,10 @@ export class JobManager {
|
|
|
713
726
|
await sediment.session?.abort().catch(() => undefined);
|
|
714
727
|
await sediment.task;
|
|
715
728
|
}
|
|
716
|
-
this.replied.add(jobId);
|
|
717
729
|
const job = this.store.job(jobId);
|
|
718
730
|
if (!job) throw new Error("没有这个任务。");
|
|
719
731
|
if (ENDED.includes(job.status)) {
|
|
720
|
-
const { endedAt, statusReason, durationMs, summary, files, reportPath, ...rest } = job;
|
|
732
|
+
const { endedAt, statusReason, durationMs, summary, files, reportPath, sediment, ...rest } = job;
|
|
721
733
|
const previous = job.rounds ?? [];
|
|
722
734
|
// 这一轮的报告另存一份:下一轮写报告会覆盖原文件。原文件不在了就不存,不让续做因此失败
|
|
723
735
|
const savedReport = reportPath && existsSync(reportPath) ? join(dirname(reportPath), `report.round-${previous.length + 1}.md`) : undefined;
|
|
@@ -734,7 +746,7 @@ export class JobManager {
|
|
|
734
746
|
this.store.saveJob(job);
|
|
735
747
|
throw error;
|
|
736
748
|
}
|
|
737
|
-
this.followUps.set(jobId,
|
|
749
|
+
this.followUps.set(jobId, `${source === "router" ? `「${this.store.assistant(job.assistantId)?.name ?? job.assistantId}」` : "用户"}接着说(${monthDayTimeText(this.runtime.settings.settings.general.timezone)}):${text}`);
|
|
738
750
|
this.bus.emit({ type: "job", job: queued });
|
|
739
751
|
this.publishBusy(job.assistantId);
|
|
740
752
|
this.queue.push(jobId);
|
|
@@ -751,7 +763,7 @@ export class JobManager {
|
|
|
751
763
|
: { ...asking, status: "expired" };
|
|
752
764
|
this.store.saveApproval(answered);
|
|
753
765
|
this.bus.emit({ type: "approval", approval: answered });
|
|
754
|
-
this.resolveAsk(answered, source === "user" ? `用户没有选给出的选项,而是在对话里回复:${text}` :
|
|
766
|
+
this.resolveAsk(answered, source === "user" ? `用户没有选给出的选项,而是在对话里回复:${text}` : `「${this.store.assistant(job.assistantId)?.name ?? job.assistantId}」回复:${text}`);
|
|
755
767
|
return;
|
|
756
768
|
}
|
|
757
769
|
// 先显示出来:任务在跑命令时要等这一步做完才读到,界面上标着「等待读取」
|
|
@@ -767,45 +779,66 @@ export class JobManager {
|
|
|
767
779
|
}
|
|
768
780
|
}
|
|
769
781
|
|
|
770
|
-
|
|
782
|
+
private setSediment(jobId: string, sediment: SedimentStatus): void {
|
|
783
|
+
const job = this.store.job(jobId);
|
|
784
|
+
if (!job) return;
|
|
785
|
+
const next = { ...job, sediment };
|
|
786
|
+
this.store.saveJob(next);
|
|
787
|
+
this.bus.emit({ type: "job", job: next });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
startSediment(jobId: string, options: { into?: string; note?: string } = {}): void {
|
|
771
791
|
const job = this.store.job(jobId);
|
|
772
792
|
if (!job) throw new Error("任务不存在");
|
|
773
793
|
if (job.status !== "done" || this.finishing.has(jobId) || this.workers.has(jobId)) throw new Error("只有已完成的任务可以沉淀");
|
|
774
794
|
if (this.sediments.has(jobId)) throw new Error("任务正在沉淀技能");
|
|
795
|
+
const skills = usableAssistantSkills(this.runtime.home, job.assistantId);
|
|
796
|
+
const into = options.into ? skills.find((skill) => skill.name === options.into) : undefined;
|
|
797
|
+
if (options.into && !into) throw new Error(`没有这个技能:${options.into}。现有的:${skills.map((skill) => skill.name).join("、") || "无"}`);
|
|
775
798
|
const state: { cancelled: boolean; session?: AgentSession; task?: Promise<void> } = { cancelled: false };
|
|
776
799
|
this.sediments.set(jobId, state);
|
|
777
|
-
|
|
800
|
+
this.setSediment(jobId, { status: "running" });
|
|
801
|
+
state.task = this.sedimentRound(job, state, into, options.note).catch(async (error) => {
|
|
778
802
|
if (state.cancelled) return;
|
|
779
803
|
const message = error instanceof Error ? error.message : String(error);
|
|
780
804
|
console.error("沉淀技能失败:", error);
|
|
805
|
+
this.setSediment(jobId, { status: "failed", reason: message });
|
|
781
806
|
await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "info", text: `技能沉淀失败:${message}` }).catch((noteError) => console.error("写入沉淀失败提示失败:", noteError));
|
|
782
|
-
}).finally(() => {
|
|
783
|
-
|
|
784
|
-
|
|
807
|
+
}).finally(async () => {
|
|
808
|
+
try {
|
|
809
|
+
const sediment = this.store.job(jobId)?.sediment;
|
|
810
|
+
if (!sediment || sediment.status === "running") return;
|
|
811
|
+
const result = sediment.status === "saved" ? (sediment.updated ? `已更新技能「${sediment.title}」(${sediment.name})· 第 ${sediment.version} 版` : `已沉淀技能「${sediment.title}」(${sediment.name})`) : sediment.status === "empty" ? `没有沉淀,${sediment.reason}` : `沉淀失败,${sediment.reason}`;
|
|
812
|
+
await this.runtime.router.event(job.assistantId, `任务「${job.title}」(${job.id})沉淀完了:${result}。`).catch((error) => console.error("发送沉淀结果失败:", error));
|
|
813
|
+
} finally { this.sediments.delete(jobId); }
|
|
785
814
|
});
|
|
786
815
|
}
|
|
787
816
|
|
|
788
|
-
private async sedimentRound(job: Job, state: { cancelled: boolean; session?: AgentSession },
|
|
789
|
-
let
|
|
817
|
+
private async sedimentRound(job: Job, state: { cancelled: boolean; session?: AgentSession }, into: ReturnType<typeof usableAssistantSkills>[number] | undefined, note?: string): Promise<void> {
|
|
818
|
+
let saved: Extract<SedimentStatus, { status: "saved" }> | undefined;
|
|
790
819
|
const savedSkills = new Set<string>();
|
|
791
820
|
let lastText = "";
|
|
792
821
|
const skillTool = {
|
|
793
822
|
name: "save_skill", label: "保存技能",
|
|
794
|
-
description: "
|
|
823
|
+
description: "保存技能。content 是 SKILL.md 的正文,开头的 name 和 description 由 Hob 写。files 放脚本和参考资料,路径以 scripts/ 或 references/ 开头:给 from(工作目录里已有文件的路径,直接复制),或者给 content。",
|
|
795
824
|
parameters: Type.Object({
|
|
796
|
-
name: Type.String({ description: "
|
|
825
|
+
...(!into ? { name: Type.String({ description: "技能的目录名,只用小写字母、数字和连字符,如 weekly-report。" }) } : {}),
|
|
797
826
|
title: Type.String({ description: "中文名,如「每周周报」。" }),
|
|
798
|
-
description: Type.String({ description: "
|
|
827
|
+
description: Type.String({ description: "什么时候用,一句话,120 字以内。" }),
|
|
799
828
|
content: Type.String({ description: "SKILL.md 正文。" }),
|
|
800
829
|
files: Type.Optional(Type.Array(Type.Object({ path: Type.String(), content: Type.Optional(Type.String()), from: Type.Optional(Type.String()) }), { description: "可选,脚本和参考资料:path 加 content,或 path 加 from(工作目录里已有文件的路径,直接复制)。" })),
|
|
801
830
|
}),
|
|
802
831
|
execute: async (_id: string, params: any) => {
|
|
803
|
-
calledSave = true;
|
|
804
832
|
if (state.cancelled || this.store.job(job.id)?.status !== "done") throw new Error("任务已继续,取消沉淀");
|
|
833
|
+
const name = into?.name ?? params.name;
|
|
834
|
+
const overwriteVersion = savedSkills.has(name);
|
|
835
|
+
if (!into && !overwriteVersion && listAssistantSkills(this.runtime.home, job.assistantId).some((skill) => skill.name === name)) {
|
|
836
|
+
return { content: [{ type: "text" as const, text: `「${name}」已经有了,新技能换个名字。` }], details: undefined, isError: true };
|
|
837
|
+
}
|
|
805
838
|
const files = readSkillFiles(job.cwd ?? this.store.workDir(job.id), params.files);
|
|
806
|
-
const
|
|
807
|
-
const skill = saveAssistantSkill(this.runtime.home, job.assistantId, { ...params, files }, job.id, false, { overwriteVersion });
|
|
839
|
+
const skill = saveAssistantSkill(this.runtime.home, job.assistantId, { ...params, name, files }, job.id, false, { overwriteVersion });
|
|
808
840
|
savedSkills.add(skill.name);
|
|
841
|
+
saved = { status: "saved", name: skill.name, title: skill.title, version: skill.version, updated: !!into };
|
|
809
842
|
if (!overwriteVersion) {
|
|
810
843
|
let text = skill.version === 1 ? `已沉淀技能「${skill.title}」` : `已更新技能「${skill.title}」· 第 ${skill.version} 版`;
|
|
811
844
|
const routine = job.routineId && this.store.routine(job.routineId);
|
|
@@ -819,45 +852,56 @@ export class JobManager {
|
|
|
819
852
|
return { content: [{ type: "text" as const, text: "技能已保存。" }], details: undefined };
|
|
820
853
|
},
|
|
821
854
|
};
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
- 能写成脚本的写成只读脚本,放在 scripts/,一次输出要看的全部结果。写好后用 bash 真跑一遍,输出对得上再保存。偶尔才用得上的细节放 references/,按主题起文件名。
|
|
833
|
-
- 会改东西的步骤只写进文档,不写进脚本,也不要执行。
|
|
834
|
-
|
|
835
|
-
不写进技能:
|
|
836
|
-
- 临时故障和网络错误。环境没装好、没配好导致的失败,只写怎么装好、配好。
|
|
837
|
-
- 「某个工具不能用」这类结论:环境修好以后,它还会一直挡着你。
|
|
838
|
-
- 这次没走通的尝试:不能写成推荐做法。
|
|
839
|
-
|
|
840
|
-
下面已有同类技能的,修补它,不要另建;新建时,名字和说明按这一类事来起,不按这一次。没有值得沉淀的,就回复「没有需要沉淀的」,不调用 save_skill。
|
|
841
|
-
|
|
842
|
-
这个助手已有的技能:${existing}`;
|
|
855
|
+
const message = `${SEDIMENT_PROMPT_HEAD},下次接到同类的事,照着它就能一次做成用户要的样子。
|
|
856
|
+
|
|
857
|
+
下一次的情况会和这次不一样,所以技能写这一类事怎么做,不写这一次的经过和数据。按做事的顺序写步骤,带上要用的命令、查询和判断点,怎么判断结果对不对,写在对应的那一步里。用户在过程中纠正过的做法、对结果提过的要求,最能帮下次少走弯路,要写进去。踩过的坑写成一条规则加一句原因,放在它影响的那一步;会变的情况写怎么查,不写死。每条经验只写一次,修补时直接改掉写错的那句。
|
|
858
|
+
|
|
859
|
+
技能要照着能跑通。写进去的只读命令和查询,存之前再跑一遍,确认照着写的就能用;能写成脚本的步骤,写成只读脚本放在 scripts/,一次输出要看的全部结果,跑一遍、输出对得上再保存。会改东西的步骤只写在正文里,留给以后的任务确认后再执行。偶尔才用得上的细节放 references/,按主题起文件名。
|
|
860
|
+
|
|
861
|
+
有几类东西留在技能外面:临时故障和网络错误,环境没装好、没配好导致的失败只写怎么装好、配好;「某个工具不能用」这类结论,环境修好以后它还会一直挡路;这次没走通的尝试。助手的规则和工具说明里已经有的,也不用重复。
|
|
862
|
+
|
|
863
|
+
${into ? `这次在技能「${into.title}」(${into.name},第 ${into.version} 版)的基础上改,先读它现在的内容。` : "这次新建一个技能,名字和说明按这一类事来起,不按这一次。"}${note ? `\n「${this.store.assistant(job.assistantId)?.name ?? job.assistantId}」交代:${note}` : ""}
|
|
864
|
+
一次性的事、几步就做完的事,或者照常做就不会走弯路的事,没有值得沉淀的,回复「没有需要沉淀的」就好。`;
|
|
843
865
|
if (state.cancelled) return;
|
|
866
|
+
// 沉淀时只补上助手明确指定的目标技能,以及交代中点名的技能。
|
|
867
|
+
const mentioned = usableAssistantSkills(this.runtime.home, job.assistantId).filter((skill) => job.skills?.includes(skill.name) || skill.name === into?.name || (!!note && (note.includes(skill.name) || note.includes(skill.title))));
|
|
868
|
+
const own = enabledAssistantSkills(this.runtime.home, job.assistantId).filter((skill) => mentioned.some((item) => item.name === skill.name));
|
|
869
|
+
const ownNames = new Set(own.map((skill) => skill.name));
|
|
870
|
+
const common = availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled).filter((skill) => !ownNames.has(skill.name));
|
|
844
871
|
const opened = await this.runtime.pi.open({
|
|
845
872
|
file: this.store.workerFile(job.id), cwd: job.cwd ?? homedir(),
|
|
846
873
|
model: job.model ?? this.runtime.settings.settings.models.work.ref,
|
|
847
874
|
thinking: job.thinking ?? this.runtime.settings.settings.models.work.thinking,
|
|
848
875
|
memory: "off", systemPrompt: () => buildWorkerPrompt(this.store, job.id, this.promptSettings()),
|
|
849
876
|
tools: ["read", "ls", "grep", "find", "bash", "save_skill"], customTools: [skillTool],
|
|
877
|
+
skills: [...common, ...own],
|
|
850
878
|
});
|
|
851
879
|
state.session = opened.session;
|
|
880
|
+
const steps = new Map<string, JobStep>();
|
|
852
881
|
opened.session.subscribe((event) => {
|
|
853
|
-
if (event.type === "
|
|
882
|
+
if (event.type === "message_start" && event.message.role === "user") {
|
|
883
|
+
this.bus.emit({ type: "job_step", jobId: job.id, step: { id: `u${event.message.timestamp}`, kind: "text", at: new Date(event.message.timestamp).toISOString(), from: "user", text: "沉淀技能" } });
|
|
884
|
+
} else if (event.type === "tool_execution_start") {
|
|
885
|
+
const step = toolStep({ id: event.toolCallId, name: event.toolName, args: event.args ?? {}, at: new Date().toISOString(), cwd: job.cwd ?? "", running: true });
|
|
886
|
+
steps.set(event.toolCallId, step);
|
|
887
|
+
this.bus.emit({ type: "job_step", jobId: job.id, step });
|
|
888
|
+
} else if (event.type === "tool_execution_update" || event.type === "tool_execution_end") {
|
|
889
|
+
const step = steps.get(event.toolCallId);
|
|
890
|
+
if (step) {
|
|
891
|
+
const text = textOfContent(event.type === "tool_execution_update" ? event.partialResult?.content : event.result?.content);
|
|
892
|
+
const next = withOutput(step, text, { limit: STEP_EVENT_LIMIT, ...(event.type === "tool_execution_end" ? { isError: event.isError, durationMs: event.durationMs } : {}), save: (id, value) => this.store.writeStepOutput(job.id, id, value) });
|
|
893
|
+
this.bus.emit({ type: "job_step", jobId: job.id, step: next });
|
|
894
|
+
if (event.type === "tool_execution_end") steps.delete(event.toolCallId);
|
|
895
|
+
}
|
|
896
|
+
} else if (event.type === "message_end" && event.message.role === "assistant") {
|
|
897
|
+
lastText = textOfContent(event.message.content).trim();
|
|
898
|
+
const worker = { job } as WorkerState;
|
|
899
|
+
this.recordSteps(worker, event.message);
|
|
900
|
+
}
|
|
854
901
|
});
|
|
855
902
|
try {
|
|
856
903
|
if (!state.cancelled) await opened.session.prompt(message);
|
|
857
|
-
if (!state.cancelled
|
|
858
|
-
const reason = lastText && lastText !== "没有需要沉淀的" ? `:${lastText}` : "";
|
|
859
|
-
await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "skill", text: `「${job.title}」没有可沉淀的做法${reason}` });
|
|
860
|
-
}
|
|
904
|
+
if (!state.cancelled) this.setSediment(job.id, saved ?? { status: "empty", reason: lastText || "没有需要沉淀的" });
|
|
861
905
|
} finally { opened.session.dispose(); }
|
|
862
906
|
}
|
|
863
907
|
|
|
@@ -906,7 +950,7 @@ export class JobManager {
|
|
|
906
950
|
worker.pausedAt = Date.now();
|
|
907
951
|
this.bus.emit({ type: "job", job: waiting });
|
|
908
952
|
const sentence = (value: string) => /[。!?.!?]\s*$/.test(value) ? value : `${value}。`;
|
|
909
|
-
const text = `任务「${job.title}
|
|
953
|
+
const text = `任务「${job.title}」(${job.id})在等你回复:${sentence(input.title)}${input.detail ? sentence(input.detail) : ""}选项:${input.options.map((option) => `${option.key} ${option.label}`).join("、")}。`;
|
|
910
954
|
if (delegationId) void this.runtime.router.relay(job.assistantId, text, delegationId).catch(() => this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败", true));
|
|
911
955
|
else void this.runtime.router.event(job.assistantId, text).catch((error) => console.error("任务提问投递失败:", error));
|
|
912
956
|
return await answer;
|
|
@@ -981,6 +1025,8 @@ export class JobManager {
|
|
|
981
1025
|
const next: Job = { ...job, status: "failed", endedAt: now, statusReason: "Hob 重启,任务中断" };
|
|
982
1026
|
this.store.saveJob(next);
|
|
983
1027
|
this.bus.emit({ type: "job", job: next });
|
|
1028
|
+
} else if (job.sediment?.status === "running") {
|
|
1029
|
+
this.setSediment(job.id, { status: "failed", reason: "Hob 重启,沉淀中断" });
|
|
984
1030
|
}
|
|
985
1031
|
}
|
|
986
1032
|
for (const approval of this.store.approvals()) {
|
|
@@ -38,7 +38,7 @@ export function searchKnowledgeTool(runtime: HobRuntime, assistantId: string): T
|
|
|
38
38
|
return {
|
|
39
39
|
name: "search_knowledge",
|
|
40
40
|
label: "查知识库",
|
|
41
|
-
description: "
|
|
41
|
+
description: "在绑定给你的知识库里查内容,返回和问题最相关的几段原文,每段带出处链接。",
|
|
42
42
|
parameters: Type.Object({
|
|
43
43
|
query: Type.String({ description: "要查的问题或关键词,写成完整的一句效果更好。" }),
|
|
44
44
|
collection: Type.Optional(Type.String({ description: "可选,只查这个知识库,填名称。" })),
|
package/server/src/knowledge.ts
CHANGED
|
@@ -7,10 +7,15 @@ import type { EmbeddingSettings } from "../../shared/types.ts";
|
|
|
7
7
|
import { embed, embeddingKey, embeddingModel } from "./embedding.ts";
|
|
8
8
|
import { extractChunks, UnsupportedIndex, type IndexedChunk } from "./knowledge-index.ts";
|
|
9
9
|
import type { Bus } from "./bus.ts";
|
|
10
|
-
import { looksText } from "./files.ts";
|
|
10
|
+
import { looksText, isInPagesDir } from "./files.ts";
|
|
11
11
|
import { newId } from "./ids.ts";
|
|
12
12
|
import type { Store } from "./store.ts";
|
|
13
13
|
|
|
14
|
+
/** 从数据目录内的相对路径推任务 id:老布局 jobs/<jid>/…,新布局 assistants/<aid>/jobs/<jid>/… */
|
|
15
|
+
function jobIdFromPath(rel: string): string | undefined {
|
|
16
|
+
return /^(?:assistants[/\\][^/\\]+[/\\])?jobs[/\\]([^/\\]+)[/\\]/.exec(rel)?.[1];
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
type Entry = KnowledgeEntry & { sourceKey?: string };
|
|
15
20
|
type CollectInput = { origin: "output" | "chat"; path: string; name?: string; description?: string; assistantId: string; itemId?: string; jobId?: string };
|
|
16
21
|
type Query = { origin?: string; collection?: string; kind?: string; assistantId?: string; q?: string; before?: string; limit?: number };
|
|
@@ -188,7 +193,7 @@ export class KnowledgeStore {
|
|
|
188
193
|
const originalName = basename(input.path);
|
|
189
194
|
const name = input.name || (input.origin === "chat" ? originalName.replace(/^u-[^-]+-/, "") : originalName);
|
|
190
195
|
const kind = fileKind(originalName) ?? (looksText(data) ? "text" : "other");
|
|
191
|
-
const inPages =
|
|
196
|
+
const inPages = isInPagesDir(relative(this.home, resolve(input.path)));
|
|
192
197
|
const ext = extname(originalName).toLowerCase().replace(/[^.a-z0-9]/g, "");
|
|
193
198
|
const path = join(this.root, inPages ? "pages" : "blobs", `${hash}${inPages ? ".html" : ext}`);
|
|
194
199
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -201,8 +206,8 @@ export class KnowledgeStore {
|
|
|
201
206
|
: content;
|
|
202
207
|
writeFileSync(join(this.root, "text", `${hash}.txt`), Buffer.from(text).subarray(0, 64 * 1024), { mode: 0o600 });
|
|
203
208
|
}
|
|
204
|
-
const rel = relative(
|
|
205
|
-
const jobId = input.jobId || (
|
|
209
|
+
const rel = relative(this.home, resolve(input.path));
|
|
210
|
+
const jobId = input.jobId || jobIdFromPath(rel);
|
|
206
211
|
const description = input.description || (jobId ? this.store.job(jobId)?.title : undefined);
|
|
207
212
|
const now = this.timestamp(entries);
|
|
208
213
|
if (previous) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// HOB_HOME 布局迁移的命令行入口:npm run layout -- --dry-run | --restore
|
|
2
|
+
//
|
|
3
|
+
// 正常情况下启动时自动迁移;这两个开关给试跑和出事后的恢复用。
|
|
4
|
+
// --dry-run 只打印计划,什么都不动;--restore 从 backup-v1-* 恢复成老布局。
|
|
5
|
+
import { resolveHome } from "./config.ts";
|
|
6
|
+
import { migrateHome, restoreHome } from "./layout.ts";
|
|
7
|
+
|
|
8
|
+
const args = new Set(process.argv.slice(2));
|
|
9
|
+
const home = resolveHome();
|
|
10
|
+
const usage = "用法:npm run layout -- [--dry-run | --restore](npm 安装的用 hob layout [--dry-run | --restore])";
|
|
11
|
+
|
|
12
|
+
if (args.has("--help") || args.has("-h")) {
|
|
13
|
+
console.log(usage);
|
|
14
|
+
} else if (args.has("--dry-run")) {
|
|
15
|
+
console.log(`数据目录:${home}`);
|
|
16
|
+
const summary = migrateHome(home, { dryRun: true });
|
|
17
|
+
console.log(summary.status === "dry-run" ? "(dry-run 没有改动任何文件)" : `当前状态:${summary.status}`);
|
|
18
|
+
} else if (args.has("--restore")) {
|
|
19
|
+
console.log(`数据目录:${home}`);
|
|
20
|
+
const result = restoreHome(home);
|
|
21
|
+
console.log(`已恢复到老布局:${result.files} 个文件,备份在 ${result.backupDir}。下次启动会重新迁移。`);
|
|
22
|
+
} else {
|
|
23
|
+
console.error(usage);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|