hob-bot 1.1.0 → 1.3.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 +8 -8
- package/README.zh-CN.md +8 -8
- package/dist/web/assets/DesktopView-lixZfbYr.js +5 -0
- package/dist/web/assets/index-BZhge0qe.js +267 -0
- package/dist/web/assets/index-DIo1ArRa.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/server/src/assistant-skills.ts +20 -6
- package/server/src/desktop-tools.ts +45 -10
- package/server/src/http.ts +32 -2
- package/server/src/job-view.ts +52 -0
- package/server/src/jobs.ts +72 -60
- package/server/src/now.ts +1 -1
- package/server/src/overview.ts +4 -4
- package/server/src/pages.ts +3 -2
- package/server/src/prompt.ts +77 -9
- package/server/src/router-activity.ts +9 -0
- package/server/src/router-tools.ts +74 -14
- package/server/src/router.ts +92 -40
- package/server/src/routines.ts +2 -2
- package/server/src/store.ts +20 -0
- package/server/src/worker-tools.ts +3 -10
- package/shared/skills.ts +3 -1
- package/shared/types.ts +3 -0
- package/dist/web/assets/DesktopView-rVxacveF.js +0 -5
- package/dist/web/assets/index-B-8ISOr1.js +0 -267
- package/dist/web/assets/index-Cu1Pf8yX.css +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { createReadToolDefinition, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
-
import { basename, extname, join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
6
|
import { IMAGE_TYPES } from "../../shared/media.ts";
|
|
7
7
|
import { lookupFile } from "./files.ts";
|
|
@@ -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));
|
|
@@ -381,13 +432,21 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
|
|
|
381
432
|
{
|
|
382
433
|
name: "send_file",
|
|
383
434
|
label: "发送文件",
|
|
384
|
-
description: "
|
|
435
|
+
description: "把一个文件(任务产出、截图、生成的图)作为卡片发到对话里,图片直接显示。",
|
|
385
436
|
parameters: Type.Object({ path: Type.String({ description: "文件的绝对路径" }) }),
|
|
386
437
|
execute: async (_id, params: any) => {
|
|
387
438
|
const path = String(params.path);
|
|
388
439
|
if (!existsSync(path)) return fail(`文件不存在:${path}`);
|
|
389
|
-
|
|
390
|
-
|
|
440
|
+
let name = basename(path);
|
|
441
|
+
let meta = fileMeta(path);
|
|
442
|
+
if (dirname(resolve(path)) === resolve(runtime.home, "pages") && extname(path).toLowerCase() === ".html") {
|
|
443
|
+
const html = await readFile(path, "utf8");
|
|
444
|
+
const title = html.match(/<title>([\s\S]*?)<\/title>/)?.[1];
|
|
445
|
+
const summary = html.match(/<header class="hd"><h1>[\s\S]*?<\/h1><p>([\s\S]*?)<\/p><\/header>/)?.[1];
|
|
446
|
+
const unescape = (value: string) => value.replace(/&(amp|lt|gt|quot);/g, (_, key: string) => ({ amp: "&", lt: "<", gt: ">", quot: '"' })[key]!);
|
|
447
|
+
if (title !== undefined && summary !== undefined) { name = unescape(title); meta = unescape(summary); }
|
|
448
|
+
}
|
|
449
|
+
await router.addItem(assistantId, { kind: "file", path, name, ...(meta ? { meta } : {}) });
|
|
391
450
|
return ok("已发送。");
|
|
392
451
|
},
|
|
393
452
|
},
|
|
@@ -494,7 +553,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
|
|
|
494
553
|
});
|
|
495
554
|
}
|
|
496
555
|
|
|
497
|
-
if (runtime.settings.settings.general.showPage !== false) tools.push(pageTool(runtime, assistantId
|
|
556
|
+
if (runtime.settings.settings.general.showPage !== false) tools.push(pageTool(runtime, assistantId));
|
|
498
557
|
|
|
499
558
|
if (assistant().primary) {
|
|
500
559
|
tools.push(
|
|
@@ -502,7 +561,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
|
|
|
502
561
|
{
|
|
503
562
|
name: "propose_assistant",
|
|
504
563
|
label: "提议创建专属助手",
|
|
505
|
-
description: "
|
|
564
|
+
description: "同类事情反复出现、又没有合适的助手时,提议新建一个专属助手。生成草稿卡,用户点「创建」才会建。怎么做事是所有助手共用的模板和全局规则定的,草稿里只写这个助手特有的东西。",
|
|
506
565
|
parameters: Type.Object({
|
|
507
566
|
name: Type.String({ description: "助手名字:职责加一个亲切的昵称,四五个字,如「运维小子」「写稿搭子」「代理管家」;不要只写「代理」「运维」这种光秃秃的词" }),
|
|
508
567
|
duty: Type.String({ description: "左栏头像下面那行小字,最多 8 个字,如「线上和机器」「模型和 agent」" }),
|
|
@@ -535,10 +594,9 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
|
|
|
535
594
|
{ description: "可选。根据上下文判断这个助手需不需要定期做的事,不是每个助手都要有。拿不准就先问用户,问清楚再加。" },
|
|
536
595
|
),
|
|
537
596
|
),
|
|
538
|
-
askFirst: Type.Optional(Type.Array(Type.String(), { description: "
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
),
|
|
597
|
+
askFirst: Type.Optional(Type.Array(Type.String(), { description: "这个领域特有、做之前要先问用户的事,每项一条;全局规则已经列的不重复。" })),
|
|
598
|
+
// 必填:可选时模型第一次总是漏掉,被打回再补,白走一轮(评测 G5 三次都这样)
|
|
599
|
+
facts: Type.Array(Type.String(), { description: "这个助手特有的事实,每项一行:管什么、不管什么(归谁管)、东西在哪(目录、服务)。只写事实,不写做法和规矩。例:「博客源码在 ~/sites/blog,用 Hugo 构建」「报销归「财务」助手管,这里不碰」" }),
|
|
542
600
|
conversations: Type.Optional(Type.Integer({ description: "需要迁移的对话段数,默认 0" })),
|
|
543
601
|
}),
|
|
544
602
|
execute: async (_id, params: any) => {
|
|
@@ -552,6 +610,8 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
|
|
|
552
610
|
if (given?.prop === "custom" && !checked?.ok) {
|
|
553
611
|
return fail("sprite 是 custom 时要同时给 prop_art(art、pal、dx)画一件。");
|
|
554
612
|
}
|
|
613
|
+
const facts = Array.isArray(params.facts) ? params.facts.filter((fact: unknown): fact is string => typeof fact === "string").map((fact: string) => fact.trim()).filter(Boolean) : [];
|
|
614
|
+
if (!facts.length) return fail("facts 至少写一条:这个助手管什么、不管什么。");
|
|
555
615
|
const routinesInput: { name: string; instruction: string; schedule: string }[] =
|
|
556
616
|
params.routines ?? [];
|
|
557
617
|
const taken = store.assistants().map((a) => a.sprite);
|
|
@@ -570,7 +630,7 @@ export function buildRouterTools(runtime: HobRuntime, assistantId: string): Tool
|
|
|
570
630
|
routines: routinesInput,
|
|
571
631
|
routinesText: routinesInput.map((item) => `${item.name} · ${cronToChinese(item.schedule)}`).join("、"),
|
|
572
632
|
askFirst: (params.askFirst as string[]) ?? [],
|
|
573
|
-
instructions:
|
|
633
|
+
instructions: facts,
|
|
574
634
|
carry: { conversations: Number(params.conversations ?? 0) },
|
|
575
635
|
createdAt: new Date().toISOString(),
|
|
576
636
|
};
|
package/server/src/router.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
2
2
|
import { parseStreamingJson } from "@earendil-works/pi-ai/utils/json-parse";
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { mkdirSync } from "node:fs";
|
|
5
5
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import type { AgentSession, AgentSessionEvent, SessionEntry, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
@@ -30,6 +30,7 @@ import { activityLabel, thinkingLine } from "./router-activity.ts";
|
|
|
30
30
|
import { imageContents } from "./jobs.ts";
|
|
31
31
|
import { nowBlock } from "./now.ts";
|
|
32
32
|
import { monthDayTimeText } from "./time.ts";
|
|
33
|
+
import { DesktopToolSession } from "./desktop-tools.ts";
|
|
33
34
|
|
|
34
35
|
export const ITEM_TYPE = "hob.item";
|
|
35
36
|
export const ITEM_UPDATE_TYPE = "hob.item.update";
|
|
@@ -65,7 +66,7 @@ interface RouterState {
|
|
|
65
66
|
/** 这一轮最后一次有动静的时间,看门狗用它判断“没响应” */
|
|
66
67
|
lastEventAt: number;
|
|
67
68
|
stallTimer?: NodeJS.Timeout;
|
|
68
|
-
/**
|
|
69
|
+
/** 这一轮已经被叫停(没响应,或用户按了停止):之后的报错不再写「回复失败」 */
|
|
69
70
|
stalled: boolean;
|
|
70
71
|
/** 记忆提供者换了:跑着的这一轮结束就释放会话,下一轮按新提供者重新打开 */
|
|
71
72
|
releaseAfterTurn?: boolean;
|
|
@@ -78,6 +79,7 @@ interface RouterState {
|
|
|
78
79
|
knowledgeNames?: string[];
|
|
79
80
|
delegationReply?: string[];
|
|
80
81
|
terminatingTools?: Set<string>;
|
|
82
|
+
desktop?: DesktopToolSession;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
function readEntries(file: string): SessionEntry[] {
|
|
@@ -186,6 +188,7 @@ export function deriveItems(entries: SessionEntry[]): Item[] {
|
|
|
186
188
|
|
|
187
189
|
export class RouterManager {
|
|
188
190
|
private routers = new Map<string, RouterState>();
|
|
191
|
+
private itemCache = new Map<string, { manager?: SessionManager; version: string; result: ReturnType<typeof walk>; positions: Map<string, number>; assistantTimes: string[]; last?: { text: string; at: Iso } }>();
|
|
189
192
|
private livePages = new Map<string, Map<string, { page: PageLive; timer?: NodeJS.Timeout; last: number }>>();
|
|
190
193
|
private liveJson = new Map<string, string>();
|
|
191
194
|
|
|
@@ -250,6 +253,37 @@ export class RouterManager {
|
|
|
250
253
|
return readEntries(this.store.routerFile(assistantId));
|
|
251
254
|
}
|
|
252
255
|
|
|
256
|
+
private derived(assistantId: string) {
|
|
257
|
+
const manager = this.routers.get(assistantId)?.manager;
|
|
258
|
+
const file = this.store.routerFile(assistantId);
|
|
259
|
+
let version: string;
|
|
260
|
+
if (manager) version = String(manager.getEntryCount());
|
|
261
|
+
else {
|
|
262
|
+
try {
|
|
263
|
+
const stat = statSync(file);
|
|
264
|
+
version = `${stat.size}:${stat.mtimeMs}`;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
267
|
+
version = "missing";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const cached = this.itemCache.get(assistantId);
|
|
271
|
+
if (cached && cached.manager === manager && cached.version === version) return cached;
|
|
272
|
+
const result = walk(manager ? manager.getEntries() : readEntries(file));
|
|
273
|
+
const positions = new Map<string, number>();
|
|
274
|
+
const assistantTimes: string[] = [];
|
|
275
|
+
let last: { text: string; at: Iso } | undefined;
|
|
276
|
+
result.items.forEach((item, index) => {
|
|
277
|
+
positions.set(item.id, index);
|
|
278
|
+
if (item.kind === "assistant") assistantTimes.push(item.at);
|
|
279
|
+
if (item.kind === "assistant" || item.kind === "user" || item.kind === "note") last = { text: item.text, at: item.at };
|
|
280
|
+
});
|
|
281
|
+
assistantTimes.sort();
|
|
282
|
+
const next = { manager, version, result, positions, assistantTimes, last };
|
|
283
|
+
this.itemCache.set(assistantId, next);
|
|
284
|
+
return next;
|
|
285
|
+
}
|
|
286
|
+
|
|
253
287
|
async ensure(assistantId: string): Promise<RouterState> {
|
|
254
288
|
const assistant = this.assistant(assistantId);
|
|
255
289
|
let state = this.routers.get(assistantId);
|
|
@@ -271,6 +305,10 @@ export class RouterManager {
|
|
|
271
305
|
await this.runtime.models.get();
|
|
272
306
|
const pick = conversationPick(this.runtime.settings.settings.models, assistant);
|
|
273
307
|
state.knowledgeNames = (await boundKnowledge(this.runtime, assistant.id)).map((collection) => collection.name);
|
|
308
|
+
state.desktop = new DesktopToolSession(this.runtime, assistant.id, () => !!state.session?.model?.input.includes("image"), undefined, true);
|
|
309
|
+
const tools = [...buildRouterTools(this.runtime, assistant.id), ...state.desktop.tools().filter((tool) => tool.name.startsWith("browser_"))];
|
|
310
|
+
const desktop = this.runtime.desktop.view();
|
|
311
|
+
const enabled = desktop.enabled && !!desktop.url && desktop.tokenConfigured;
|
|
274
312
|
const opened = await this.runtime.pi.open({
|
|
275
313
|
file: this.store.routerFile(assistant.id),
|
|
276
314
|
spillDir: join(dirname(this.store.routerFile(assistant.id)), "spill"),
|
|
@@ -280,19 +318,25 @@ export class RouterManager {
|
|
|
280
318
|
memory: this.runtime.settings.memory().provider,
|
|
281
319
|
systemPrompt: () => {
|
|
282
320
|
const { timezone, aboutYou } = this.runtime.settings.settings.general;
|
|
321
|
+
const desktop = this.runtime.desktop.view();
|
|
283
322
|
return buildSystemPrompt(this.store, assistant.id, {
|
|
284
323
|
timezone,
|
|
285
324
|
aboutYou,
|
|
286
325
|
memory: this.runtime.settings.memory().provider,
|
|
326
|
+
searchEnabled: this.runtime.settings.settings.search.some((provider) => provider.enabled),
|
|
327
|
+
imageEnabled: this.runtime.settings.settings.image.source !== "off",
|
|
328
|
+
desktopEnabled: desktop.enabled && !!desktop.url && desktop.tokenConfigured,
|
|
329
|
+
publicDesktop: desktop.publicAssistants.includes(assistant.id),
|
|
287
330
|
models: this.promptModels(assistant.id),
|
|
288
331
|
skills: availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled, this.runtime.settings.memory().provider === "lore" ? this.runtime.pi.loreSkillPaths : []),
|
|
289
332
|
assistantSkills: usableAssistantSkills(this.runtime.home, assistant.id),
|
|
333
|
+
assistantSkillsDir: resolve(this.runtime.home, "assistants", assistant.id, "skills"),
|
|
290
334
|
thinkingChoice: this.runtime.settings.settings.models.thinkingChoice,
|
|
291
335
|
knowledgeNames: state.knowledgeNames,
|
|
292
336
|
});
|
|
293
337
|
},
|
|
294
|
-
tools:
|
|
295
|
-
customTools:
|
|
338
|
+
tools: tools.filter((tool) => enabled || !tool.name.startsWith("browser_")).map((tool) => tool.name),
|
|
339
|
+
customTools: tools,
|
|
296
340
|
});
|
|
297
341
|
state.session = opened.session;
|
|
298
342
|
if (!state.knowledgeNames.length) refreshSystemPrompt(opened.session, opened.session.getActiveToolNames().filter((name) => name !== "search_knowledge"));
|
|
@@ -304,8 +348,10 @@ export class RouterManager {
|
|
|
304
348
|
|
|
305
349
|
private async refreshTools(state: RouterState): Promise<void> {
|
|
306
350
|
state.knowledgeNames = (await boundKnowledge(this.runtime, state.assistantId)).map((collection) => collection.name);
|
|
307
|
-
const names = state.session!.getActiveToolNames().filter((name) => name !== "search_knowledge");
|
|
351
|
+
const names = state.session!.getActiveToolNames().filter((name) => name !== "search_knowledge" && !name.startsWith("browser_"));
|
|
308
352
|
if (state.knowledgeNames.length) names.push("search_knowledge");
|
|
353
|
+
const desktop = this.runtime.desktop.view();
|
|
354
|
+
if (desktop.enabled && desktop.url && desktop.tokenConfigured) names.push(...state.desktop!.tools().filter((tool) => tool.name.startsWith("browser_")).map((tool) => tool.name));
|
|
309
355
|
refreshSystemPrompt(state.session!, names);
|
|
310
356
|
}
|
|
311
357
|
|
|
@@ -391,7 +437,7 @@ export class RouterManager {
|
|
|
391
437
|
}
|
|
392
438
|
if (event.type === "message_start" && event.message.role === "custom" && event.message.customType === EVENT_TYPE) {
|
|
393
439
|
const details = event.message.details as EventDetails | undefined;
|
|
394
|
-
const users =
|
|
440
|
+
const users = this.derived(assistantId).result.items.filter((item) => item.kind === "user");
|
|
395
441
|
const source = details?.sourceUserId ? users.find((item) => item.id === details.sourceUserId) : undefined;
|
|
396
442
|
state.currentUserId = (source ?? users.at(-1))?.id;
|
|
397
443
|
if (details?.delegationId && details.delegationId !== state.currentDelegationId) {
|
|
@@ -470,7 +516,7 @@ export class RouterManager {
|
|
|
470
516
|
at: new Date().toISOString(),
|
|
471
517
|
text: "",
|
|
472
518
|
streaming: true,
|
|
473
|
-
...(
|
|
519
|
+
...(this.derived(assistantId).result.lead ? { lead: true } : {}),
|
|
474
520
|
},
|
|
475
521
|
});
|
|
476
522
|
}
|
|
@@ -501,7 +547,7 @@ export class RouterManager {
|
|
|
501
547
|
}
|
|
502
548
|
|
|
503
549
|
private emitSettledAssistant(assistantId: string, text: string, expectedId?: string): void {
|
|
504
|
-
const list =
|
|
550
|
+
const list = this.derived(assistantId).result.items.filter((item) => item.kind === "assistant");
|
|
505
551
|
const item = list.find((entry) => entry.id === expectedId) ?? list.filter((entry) => entry.text === text).pop();
|
|
506
552
|
if (item) this.bus.emit({ type: "item", assistantId, item });
|
|
507
553
|
this.touch(assistantId);
|
|
@@ -579,8 +625,11 @@ export class RouterManager {
|
|
|
579
625
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
580
626
|
state.idleTimer = undefined;
|
|
581
627
|
state.session?.dispose();
|
|
628
|
+
void state.desktop?.dispose();
|
|
629
|
+
state.desktop = undefined;
|
|
582
630
|
state.session = undefined;
|
|
583
631
|
state.manager = undefined;
|
|
632
|
+
this.itemCache.delete(state.assistantId);
|
|
584
633
|
}
|
|
585
634
|
|
|
586
635
|
/**
|
|
@@ -596,6 +645,7 @@ export class RouterManager {
|
|
|
596
645
|
|
|
597
646
|
/** 助手没了(归档):关掉它的会话,内存里也不留 —— 它的目录随后会被移进归档。 */
|
|
598
647
|
forget(assistantId: string): void {
|
|
648
|
+
this.itemCache.delete(assistantId);
|
|
599
649
|
const state = this.routers.get(assistantId);
|
|
600
650
|
if (!state) return;
|
|
601
651
|
this.close(state);
|
|
@@ -617,7 +667,7 @@ export class RouterManager {
|
|
|
617
667
|
// ---------- 事件与条目 ----------
|
|
618
668
|
async addItem(assistantId: string, item: NewItem): Promise<Item> {
|
|
619
669
|
const state = await this.ensure(assistantId);
|
|
620
|
-
const lead =
|
|
670
|
+
const lead = this.derived(assistantId).result.lead;
|
|
621
671
|
const id = state.manager!.appendCustomEntry(ITEM_TYPE, item);
|
|
622
672
|
const full = { ...item, ...(lead ? { lead: true } : {}), id, at: this.entryAt(state, id) } as Item;
|
|
623
673
|
this.bus.emit({ type: "item", assistantId, item: full });
|
|
@@ -631,7 +681,7 @@ export class RouterManager {
|
|
|
631
681
|
|
|
632
682
|
async updateImage(assistantId: string, id: string, update: { status: "done" | "failed"; path?: string; error?: string }): Promise<void> {
|
|
633
683
|
const state = await this.ensure(assistantId);
|
|
634
|
-
const item =
|
|
684
|
+
const item = this.derived(assistantId).result.items.find((entry) => entry.id === id && entry.kind === "image");
|
|
635
685
|
if (!item) throw new Error(`图片条目不存在:${id}`);
|
|
636
686
|
state.manager!.appendCustomEntry(ITEM_UPDATE_TYPE, { id, ...update });
|
|
637
687
|
this.bus.emit({ type: "item", assistantId, item: { ...item, ...update } });
|
|
@@ -646,7 +696,7 @@ export class RouterManager {
|
|
|
646
696
|
|
|
647
697
|
/** 取当前回合来源及其之前的可见对话;事件取最近用户消息。 */
|
|
648
698
|
originalWords(assistantId: string, note?: string, recent = false, extraFiles: string[] = []): { text: string; files: string[] } {
|
|
649
|
-
const items =
|
|
699
|
+
const items = this.derived(assistantId).result.items;
|
|
650
700
|
const state = this.routers.get(assistantId);
|
|
651
701
|
const source = items.find((item) => item.kind === "user" && item.id === state?.currentUserId)
|
|
652
702
|
?? items.findLast((item) => item.kind === "user");
|
|
@@ -742,7 +792,8 @@ export class RouterManager {
|
|
|
742
792
|
*/
|
|
743
793
|
private async reportTurnError(state: RouterState): Promise<void> {
|
|
744
794
|
const last = state.session?.messages.findLast((message) => message.role === "assistant");
|
|
745
|
-
|
|
795
|
+
// 停下的那一轮:有的服务商(Claude 订阅桥)把停止报成 error,不是回复失败
|
|
796
|
+
if (state.stalled || !last || last.role !== "assistant" || last.stopReason !== "error") return;
|
|
746
797
|
await this.addItem(state.assistantId, { kind: "note", icon: "info", text: `回复失败:${briefModelError(last.errorMessage)}。发送任意消息即可重试。` });
|
|
747
798
|
}
|
|
748
799
|
|
|
@@ -875,7 +926,10 @@ export class RouterManager {
|
|
|
875
926
|
|
|
876
927
|
async stop(assistantId: string): Promise<void> {
|
|
877
928
|
const state = this.routers.get(assistantId);
|
|
878
|
-
if (state)
|
|
929
|
+
if (state) {
|
|
930
|
+
state.stalled = true;
|
|
931
|
+
this.setActivity(state, null);
|
|
932
|
+
}
|
|
879
933
|
if (state?.session) await state.session.abort();
|
|
880
934
|
}
|
|
881
935
|
|
|
@@ -907,14 +961,12 @@ export class RouterManager {
|
|
|
907
961
|
}
|
|
908
962
|
|
|
909
963
|
items(assistantId: string, options: { before?: string; limit?: number } = {}): ItemsPage {
|
|
910
|
-
const
|
|
964
|
+
const derived = this.derived(assistantId);
|
|
965
|
+
const all = derived.result.items;
|
|
911
966
|
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
if (index !== -1) list = all.slice(0, index);
|
|
916
|
-
}
|
|
917
|
-
const page = list.slice(-limit);
|
|
967
|
+
const end = options.before ? derived.positions.get(options.before) ?? all.length : all.length;
|
|
968
|
+
const start = Math.max(0, end - limit);
|
|
969
|
+
const page = all.slice(start, end);
|
|
918
970
|
const jobIds = new Set(page.filter((item) => item.kind === "job").map((item) => (item as { jobId: string }).jobId));
|
|
919
971
|
const draftIds = new Set(
|
|
920
972
|
page.filter((item) => item.kind === "draft").map((item) => (item as { draftId: string }).draftId),
|
|
@@ -928,15 +980,14 @@ export class RouterManager {
|
|
|
928
980
|
for (const id of delegation.jobIds) jobIds.add(id);
|
|
929
981
|
for (const id of delegation.approvalIds ?? []) approvalIds.add(id);
|
|
930
982
|
}
|
|
931
|
-
for (const approval of this.store.approvals()) if (approval.jobId && jobIds.has(approval.jobId)) approvalIds.add(approval.id);
|
|
932
983
|
return {
|
|
933
984
|
...(!options.before ? { pageLive: [...(this.livePages.get(assistantId)?.values() ?? [])].map((live) => live.page) } : {}),
|
|
934
985
|
items: page,
|
|
935
986
|
approvals: this.store.approvals().filter((approval) => approvalIds.has(approval.id)),
|
|
936
|
-
jobs:
|
|
987
|
+
jobs: [...jobIds].flatMap((id) => { const job = this.store.job(id); return job ? [job] : []; }),
|
|
937
988
|
drafts: this.store.drafts().filter((draft) => draftIds.has(draft.id)),
|
|
938
989
|
delegations,
|
|
939
|
-
hasMore:
|
|
990
|
+
hasMore: start > 0,
|
|
940
991
|
};
|
|
941
992
|
}
|
|
942
993
|
|
|
@@ -946,21 +997,23 @@ export class RouterManager {
|
|
|
946
997
|
|
|
947
998
|
unread(assistantId: string): number {
|
|
948
999
|
const readAt = this.store.state(assistantId).readAt;
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
(
|
|
952
|
-
|
|
1000
|
+
// 从没记过已读:从现在开始算,以前的都当读过。不记的话未读永远是 0,前端也就永远不会来标已读
|
|
1001
|
+
if (!readAt) {
|
|
1002
|
+
this.read(assistantId);
|
|
1003
|
+
return 0;
|
|
1004
|
+
}
|
|
1005
|
+
const times = this.derived(assistantId).assistantTimes;
|
|
1006
|
+
let lo = 0, hi = times.length;
|
|
1007
|
+
while (lo < hi) {
|
|
1008
|
+
const mid = (lo + hi) >>> 1;
|
|
1009
|
+
if (times[mid] <= readAt) lo = mid + 1;
|
|
1010
|
+
else hi = mid;
|
|
1011
|
+
}
|
|
1012
|
+
return times.length - lo;
|
|
953
1013
|
}
|
|
954
1014
|
|
|
955
1015
|
last(assistantId: string): { text: string; at: Iso } | undefined {
|
|
956
|
-
|
|
957
|
-
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
958
|
-
const item = items[i];
|
|
959
|
-
if (item.kind === "user" || item.kind === "assistant" || item.kind === "note") {
|
|
960
|
-
return { text: item.text, at: item.at };
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
return undefined;
|
|
1016
|
+
return this.derived(assistantId).last;
|
|
964
1017
|
}
|
|
965
1018
|
|
|
966
1019
|
summary(assistant: Assistant): AssistantSummary {
|
|
@@ -969,6 +1022,7 @@ export class RouterManager {
|
|
|
969
1022
|
...assistant,
|
|
970
1023
|
...(last ? { last } : {}),
|
|
971
1024
|
unread: this.unread(assistant.id),
|
|
1025
|
+
...(this.store.state(assistant.id).readAt ? { readAt: this.store.state(assistant.id).readAt } : {}),
|
|
972
1026
|
busy: this.isBusy(assistant.id),
|
|
973
1027
|
activity: this.routers.get(assistant.id)?.activity ?? null,
|
|
974
1028
|
};
|
|
@@ -992,7 +1046,7 @@ export class RouterManager {
|
|
|
992
1046
|
.jobs()
|
|
993
1047
|
.filter((job) => job.status === "running" || job.status === "queued" || job.status === "waiting" || (job.endedAt ?? job.createdAt) > jobCutoff),
|
|
994
1048
|
routines: this.store.routines(),
|
|
995
|
-
approvals: this.store.approvals().filter((approval) => approval.status === "pending"),
|
|
1049
|
+
approvals: this.store.approvals().filter((approval) => approval.status === "pending" && approval.source !== "job"),
|
|
996
1050
|
delegations: this.store.delegations(),
|
|
997
1051
|
settings: this.runtime.settings.get(),
|
|
998
1052
|
};
|
|
@@ -1000,11 +1054,9 @@ export class RouterManager {
|
|
|
1000
1054
|
|
|
1001
1055
|
/** 进程退出或测试收尾:释放所有 Router 会话。 */
|
|
1002
1056
|
disposeAll(): void {
|
|
1003
|
-
for (const state of this.routers.values())
|
|
1004
|
-
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
1005
|
-
state.session?.dispose();
|
|
1006
|
-
}
|
|
1057
|
+
for (const state of this.routers.values()) this.close(state);
|
|
1007
1058
|
this.routers.clear();
|
|
1059
|
+
this.itemCache.clear();
|
|
1008
1060
|
}
|
|
1009
1061
|
|
|
1010
1062
|
/** 直接走事件的 followUp 队列,实际收到这条消息时才开始关联任务。 */
|
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/server/src/store.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, appendFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { newId } from "./ids.ts";
|
|
3
4
|
import type {
|
|
4
5
|
Approval,
|
|
5
6
|
Assistant,
|
|
@@ -56,6 +57,25 @@ export class Store {
|
|
|
56
57
|
for (const dir of ["assistants", "jobs", "uploads"]) {
|
|
57
58
|
mkdirSync(join(this.home, dir), { recursive: true });
|
|
58
59
|
}
|
|
60
|
+
if (!existsSync(join(this.home, "rules.json"))) {
|
|
61
|
+
const changedAt = new Date().toISOString();
|
|
62
|
+
this.saveGlobalRules([
|
|
63
|
+
...["删除或覆盖文件、数据和备份", "对外发送(消息、邮件、发布)、付费、充值、改计费", "停止或重启别人正在使用的服务和设备", "修改可能让你失去远程访问的设置(网络、防火墙、登录方式)"].map((text) => ({ id: newId("r-"), text, mode: "ask" as const, changedAt })),
|
|
64
|
+
...["过去的记录是线索不是现状:动手前先核实实际状态。", "改配置、改数据前先留好退路(备份或记下原值);改完用实际效果验证,不只看命令返回。", "凭据(密码、密钥、令牌、Cookie)只在用的时候读取,不回显,不写进文件和报告,也不存起来。"].map((text) => ({ id: newId("r-"), text, mode: "allow" as const, changedAt })),
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
globalRules(): Rule[] {
|
|
70
|
+
const path = join(this.home, "rules.json");
|
|
71
|
+
if (!existsSync(path)) return [];
|
|
72
|
+
const rules = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
73
|
+
if (!Array.isArray(rules)) throw new Error("全局规则文件格式无效");
|
|
74
|
+
return rules as Rule[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
saveGlobalRules(rules: Rule[]): void {
|
|
78
|
+
writeJsonAtomic(join(this.home, "rules.json"), rules);
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
// ---------- 助手 ----------
|
|
@@ -19,8 +19,8 @@ export function registerReportTool(runtime: HobRuntime, jobId: string): ToolDefi
|
|
|
19
19
|
outcome: Type.Unsafe<"done" | "stuck" | "need_approval">(
|
|
20
20
|
Type.String({ description: "done 做完 / stuck 做不下去(写明缺什么)/ need_approval 需要用户拍板" }),
|
|
21
21
|
),
|
|
22
|
-
result: Type.String({ description: "
|
|
23
|
-
files: Type.Optional(Type.Array(Type.String(), { description: "
|
|
22
|
+
result: Type.String({ description: "完整结果,按这个顺序写:结论;做了什么;验证了什么、怎么验证的;没验证或不确定的;需要用户决定的。带上数据、链接和文件路径,写给派活的助手。" }),
|
|
23
|
+
files: Type.Optional(Type.Array(Type.String(), { description: "交给助手的文件(绝对路径),比如要给用户看的图" })),
|
|
24
24
|
}),
|
|
25
25
|
execute: async (_id, params: any) => {
|
|
26
26
|
// 不认识的结果不能当成已完成:退回让模型重新调用
|
|
@@ -46,7 +46,7 @@ export function registerAskTool(runtime: HobRuntime, jobId: string): ToolDefinit
|
|
|
46
46
|
return {
|
|
47
47
|
name: "ask_user",
|
|
48
48
|
label: "询问用户",
|
|
49
|
-
description: "
|
|
49
|
+
description: "问你的助手,等它回复:它会自己答,或者去问用户。用于用户定的规则要求先确认的事,或发送、删除、付费这类撤不回的事。其他拿不准的不问,按合理的假设做下去,把假设写进 result。",
|
|
50
50
|
promptSnippet: "规则要求执行前确认的事,执行前必须调用 ask_user。",
|
|
51
51
|
parameters: Type.Object({
|
|
52
52
|
title: Type.String({ description: "问题,一句话" }),
|
|
@@ -57,27 +57,20 @@ export function registerAskTool(runtime: HobRuntime, jobId: string): ToolDefinit
|
|
|
57
57
|
}),
|
|
58
58
|
{ description: "2 到 3 个选项", minItems: 2, maxItems: 3 },
|
|
59
59
|
),
|
|
60
|
-
remember: Type.Optional(Type.String({ description: "同类事以后可以不再问时,填这类事的简称(如「清理临时目录」);卡片会多一个「以后直接做」的选项。" })),
|
|
61
60
|
}),
|
|
62
61
|
execute: async (_id, params: any) => {
|
|
63
62
|
const labels: { label: string }[] = Array.isArray(params.options) ? params.options : [];
|
|
64
63
|
if (labels.length < 2 || labels.length > 3) {
|
|
65
64
|
return { content: [{ type: "text" as const, text: "选项要 2 到 3 个。" }], details: undefined, isError: true };
|
|
66
65
|
}
|
|
67
|
-
const remember = typeof params.remember === "string" ? params.remember.trim() : "";
|
|
68
|
-
if (remember && labels.length > 2) {
|
|
69
|
-
return { content: [{ type: "text" as const, text: "带 remember 时最多给 2 个选项。" }], details: undefined, isError: true };
|
|
70
|
-
}
|
|
71
66
|
const options: ApprovalOption[] = labels.map((option, index) => ({
|
|
72
67
|
key: (["A", "B", "C"] as const)[index],
|
|
73
68
|
label: String(option.label),
|
|
74
69
|
}));
|
|
75
|
-
if (remember) options.push({ key: "C", label: `以后「${remember}」直接做`, always: true });
|
|
76
70
|
const answer = await runtime.jobs.askQuestion(jobId, {
|
|
77
71
|
title: String(params.title),
|
|
78
72
|
...(params.detail ? { detail: String(params.detail) } : {}),
|
|
79
73
|
options,
|
|
80
|
-
...(remember ? { ruleText: remember } : {}),
|
|
81
74
|
});
|
|
82
75
|
// 用户可能没选给出的选项,而是自己写了一段:answer 已经是回给模型的整句
|
|
83
76
|
return ok(answer);
|
package/shared/skills.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface SkillsView {
|
|
|
15
15
|
skills: SkillView[];
|
|
16
16
|
loreActive: boolean;
|
|
17
17
|
loreUrl: string | null;
|
|
18
|
+
/** Lore 目录没取到的原因;其他技能照常列出。 */
|
|
19
|
+
loreError?: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export interface ImportSkill {
|
|
@@ -42,7 +44,7 @@ export interface SkillImportResult {
|
|
|
42
44
|
*/
|
|
43
45
|
export interface AssistantSkill {
|
|
44
46
|
name: string; // 目录名:小写字母、数字、连字符
|
|
45
|
-
title: string; //
|
|
47
|
+
title: string; // 界面上显示的名字,如「每周周报」
|
|
46
48
|
description: string; // 一句话:什么时候用
|
|
47
49
|
path: string; // SKILL.md 的绝对路径(预览用)
|
|
48
50
|
version: number; // 每次修补 +1,旧版在 skills/<name>/.history/<版本>/
|