hob-bot 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/bin/hob.mjs +7 -0
  5. package/dist/web/assets/index-D88l35PH.css +1 -0
  6. package/dist/web/assets/index-DUs8nN3E.js +218 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/fonts/inter-latin.woff2 +0 -0
  9. package/dist/web/fonts/jetbrains-mono-latin.woff2 +0 -0
  10. package/dist/web/icons/apple-touch-icon-180.png +0 -0
  11. package/dist/web/icons/favicon-32.png +0 -0
  12. package/dist/web/icons/icon-192.png +0 -0
  13. package/dist/web/icons/icon-512.png +0 -0
  14. package/dist/web/icons/icon-maskable-512.png +0 -0
  15. package/dist/web/index.html +32 -0
  16. package/dist/web/manifest.webmanifest +16 -0
  17. package/dist/web/sw.js +131 -0
  18. package/package.json +62 -4
  19. package/server/src/actions.ts +230 -0
  20. package/server/src/boot.ts +85 -0
  21. package/server/src/bus.ts +21 -0
  22. package/server/src/config.ts +123 -0
  23. package/server/src/cron-text.ts +49 -0
  24. package/server/src/delegations.ts +113 -0
  25. package/server/src/files.ts +142 -0
  26. package/server/src/http.ts +802 -0
  27. package/server/src/ids.ts +19 -0
  28. package/server/src/image.ts +218 -0
  29. package/server/src/imports.ts +302 -0
  30. package/server/src/jobs.ts +730 -0
  31. package/server/src/main.ts +69 -0
  32. package/server/src/memory.ts +272 -0
  33. package/server/src/models.ts +358 -0
  34. package/server/src/overview.ts +116 -0
  35. package/server/src/pages.ts +54 -0
  36. package/server/src/password.ts +23 -0
  37. package/server/src/pi.ts +273 -0
  38. package/server/src/prompt.ts +227 -0
  39. package/server/src/providers.ts +532 -0
  40. package/server/src/push.ts +285 -0
  41. package/server/src/router-tools.ts +549 -0
  42. package/server/src/router.ts +841 -0
  43. package/server/src/routines.ts +129 -0
  44. package/server/src/runtime.ts +104 -0
  45. package/server/src/search.ts +91 -0
  46. package/server/src/secrets.ts +46 -0
  47. package/server/src/settings.ts +452 -0
  48. package/server/src/skills.ts +150 -0
  49. package/server/src/store.ts +324 -0
  50. package/server/src/text.ts +14 -0
  51. package/server/src/time.ts +42 -0
  52. package/server/src/transcript.ts +272 -0
  53. package/server/src/worker-tools.ts +129 -0
  54. package/shared/command.ts +42 -0
  55. package/shared/files.ts +141 -0
  56. package/shared/media.ts +17 -0
  57. package/shared/model-note.ts +32 -0
  58. package/shared/page-shell.ts +108 -0
  59. package/shared/search.ts +19 -0
  60. package/shared/skills.ts +37 -0
  61. package/shared/sprites.ts +422 -0
  62. package/shared/thinking.ts +118 -0
  63. package/shared/types.ts +631 -0
  64. package/vendor/lore-pi-extension/README.md +28 -0
  65. package/vendor/lore-pi-extension/api.ts +140 -0
  66. package/vendor/lore-pi-extension/formatters.ts +204 -0
  67. package/vendor/lore-pi-extension/hooks.ts +195 -0
  68. package/vendor/lore-pi-extension/index.ts +25 -0
  69. package/vendor/lore-pi-extension/package.json +23 -0
  70. package/vendor/lore-pi-extension/skills.ts +447 -0
  71. package/vendor/lore-pi-extension/tools.ts +344 -0
  72. package/vendor/lore-pi-extension/uri.ts +67 -0
  73. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.d.mts +266 -0
  74. package/vendor/lore-pi-extension/vendor/skill-workcopy/index.mjs +1328 -0
  75. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.d.mts +33 -0
  76. package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.mjs +202 -0
@@ -0,0 +1,358 @@
1
+ // 模型名单:从引擎的模型注册表拿可用的聊天模型,解析每个角色 / 每个任务用哪个模型。
2
+ import type { Model } from "@earendil-works/pi-ai";
3
+ import type {
4
+ Assistant,
5
+ ModelInfo,
6
+ ModelPick,
7
+ ModelSettings,
8
+ RosterEntry,
9
+ SettingsPatch,
10
+ ThinkingLevel,
11
+ ThinkingLevelMapSetting,
12
+ } from "../../shared/types.ts";
13
+ import { contextText, guessModelScores, scoreSummary } from "../../shared/model-note.ts";
14
+ import {
15
+ THINKING_LEVELS,
16
+ THINKING_TEXT,
17
+ THINKING_TEXT_MAX,
18
+ isThinkingLevel,
19
+ normalizeThinkingMap,
20
+ thinkingPlan,
21
+ } from "../../shared/thinking.ts";
22
+ import type { PiHost } from "./pi.ts";
23
+
24
+ // 思考档的规则(档位、默认范围、默认字符、规整)在 shared/thinking.ts:前后端和假后端共用一份。
25
+ export { THINKING_LEVELS, isThinkingLevel };
26
+
27
+ /** 保存自定义思考档前的校验:不合法就报错(http 回 400),界面原样显示原因。 */
28
+ export function checkThinkingMap(raw: unknown): ThinkingLevelMapSetting {
29
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
30
+ throw new Error("思考档格式无效:应为「档位: 字符」的对象。");
31
+ }
32
+ const input = raw as Record<string, unknown>;
33
+ const map: ThinkingLevelMapSetting = {};
34
+ for (const [key, value] of Object.entries(input)) {
35
+ if (!isThinkingLevel(key)) throw new Error(`思考档无效:可选值为 ${THINKING_LEVELS.join("、")}。`);
36
+ if (value === null) {
37
+ map[key] = null;
38
+ continue;
39
+ }
40
+ if (typeof value !== "string") throw new Error(`「${THINKING_TEXT[key]}」这一档的字符无效。`);
41
+ // 空串 = 按服务商协议的默认,不算错
42
+ const text = value.trim();
43
+ if (text.length > THINKING_TEXT_MAX) {
44
+ throw new Error(`「${THINKING_TEXT[key]}」这一档的字符最多 ${THINKING_TEXT_MAX} 个字。`);
45
+ }
46
+ map[key] = text;
47
+ }
48
+ if (map.off === null) throw new Error("「关闭」这一档不能删。");
49
+ return map;
50
+ }
51
+
52
+ /** 这个模型能用哪些思考档(Hob 按设置算出来的):不会推理的模型只有 off。 */
53
+ export function thinkingLevelsOf(model: ModelInfo | undefined): ThinkingLevel[] {
54
+ return model?.thinkingLevels ?? ["off"];
55
+ }
56
+
57
+ /** 想要的档模型没有时往下取最近的一档;一档都没有才是 off。 */
58
+ export function fitThinking(levels: ThinkingLevel[], wants: ThinkingLevel): ThinkingLevel {
59
+ const rank = THINKING_LEVELS.indexOf(wants);
60
+ return [...levels].reverse().find((level) => THINKING_LEVELS.indexOf(level) <= rank) ?? "off";
61
+ }
62
+
63
+ /** 这一轮(对话或任务)的思考档:设置里的档这个模型没有,就往下取最近的一档。 */
64
+ export function thinkingForPick(pick: ModelPick, catalog: ModelCatalog): ThinkingLevel {
65
+ return fitThinking(thinkingLevelsOf(catalog.find(pick.ref)), pick.thinking);
66
+ }
67
+
68
+ function infoOf(model: Model<any>, settings: ModelSettings): ModelInfo {
69
+ const ref = `${model.provider}/${model.id}`;
70
+ const reasoning = Boolean(model.reasoning);
71
+ const plan = thinkingPlan(model.id, reasoning, settings.thinking?.[ref], model.api);
72
+ return {
73
+ ref,
74
+ provider: model.provider,
75
+ id: model.id,
76
+ name: model.name || model.id,
77
+ reasoning,
78
+ thinkingLevels: plan.levels,
79
+ input: model.input.filter((kind): kind is "text" | "image" => kind === "text" || kind === "image"),
80
+ contextWindow: model.contextWindow,
81
+ maxTokens: model.maxTokens,
82
+ cost: { input: model.cost?.input ?? 0, output: model.cost?.output ?? 0 },
83
+ };
84
+ }
85
+
86
+ /** 有凭据可用的聊天模型,缓一小会儿(设置页和每次分派任务都要看它)。 */
87
+ export class ModelCatalog {
88
+ private list: ModelInfo[] = [];
89
+ private loadedAt = 0;
90
+ private loading?: Promise<void>;
91
+
92
+ constructor(
93
+ private readonly pi: PiHost,
94
+ /** 每次现读设置:思考档只看 Hob 自己的设置,不看引擎注册表里自带的映射 */
95
+ private readonly settings: () => ModelSettings,
96
+ ) {}
97
+
98
+ /** 上一次拿到的名单(同步,提示词用;还没拉过就是空)。 */
99
+ snapshot(): ModelInfo[] {
100
+ return this.list;
101
+ }
102
+
103
+ /** 服务商变了:下一次 get() 重新拉。 */
104
+ reset(): void {
105
+ this.list = [];
106
+ this.loadedAt = 0;
107
+ }
108
+
109
+ find(ref: string): ModelInfo | undefined {
110
+ return this.list.find((model) => model.ref === ref);
111
+ }
112
+
113
+ async get(maxAgeMs = 30_000): Promise<ModelInfo[]> {
114
+ if (this.list.length && Date.now() - this.loadedAt < maxAgeMs) return this.list;
115
+ this.loading ??= this.load().finally(() => {
116
+ this.loading = undefined;
117
+ });
118
+ await this.loading;
119
+ return this.list;
120
+ }
121
+
122
+ private async load(): Promise<void> {
123
+ const runtime = await this.pi.runtime();
124
+ const models = await runtime.getAvailableOfType("chat");
125
+ this.list = models.map((model) => infoOf(model, this.settings())).sort((a, b) => a.ref.localeCompare(b.ref));
126
+ this.loadedAt = Date.now();
127
+ }
128
+ }
129
+
130
+ /** 旧名单没有分数时按模型名称推测,保留用户的补充说明。 */
131
+ export function noteOf(entry: RosterEntry, catalog: ModelCatalog): string {
132
+ const info = catalog.find(entry.ref);
133
+ const scores = entry.scores ?? guessModelScores(info ?? { id: entry.ref.split("/").at(-1) ?? entry.ref, name: "", reasoning: false });
134
+ return [scoreSummary(scores), ...(info?.input.includes("image") ? ["能看图"] : []), ...(info ? [`上下文 ${contextText(info.contextWindow)}`] : []), ...(entry.note.trim() ? [`补充:${entry.note.trim()}`] : [])].join(" · ");
135
+ }
136
+
137
+ /** 助手的名字:注册表里有名字就用,没有就用 ref。 */
138
+ export function nameOf(ref: string, catalog: ModelCatalog): string {
139
+ return catalog.find(ref)?.name ?? ref;
140
+ }
141
+
142
+ /** 这个助手能派的模型 ref:缺省名单全部。 */
143
+ export function allowedRefs(roster: RosterEntry[], assistant: Assistant): string[] {
144
+ const inRoster = new Set(roster.map((entry) => entry.ref));
145
+ const allowed = assistant.models?.allowed?.length ? assistant.models.allowed : roster.map((entry) => entry.ref);
146
+ return allowed.filter((ref) => inRoster.has(ref));
147
+ }
148
+
149
+ /** 这个助手能派、而且现在可用的模型 ref。 */
150
+ export function usableRefs(settings: ModelSettings, assistant: Assistant, catalog: ModelCatalog): string[] {
151
+ return allowedRefs(settings.roster, assistant).filter((ref) => catalog.find(ref));
152
+ }
153
+
154
+ /** 不指定模型时用哪个:助手的默认任务模型 → 全局默认任务模型 → 名单里第一个可用的。 */
155
+ export function defaultWorkRef(
156
+ settings: ModelSettings,
157
+ assistant: Assistant,
158
+ catalog: ModelCatalog,
159
+ ): string | undefined {
160
+ const usable = usableRefs(settings, assistant, catalog);
161
+ const wanted = [assistant.models?.work?.ref, settings.work.ref];
162
+ return wanted.find((ref): ref is string => !!ref && usable.includes(ref)) ?? usable[0];
163
+ }
164
+
165
+ export class NoModelError extends Error {}
166
+
167
+ /** 一个模型都用不了时的说法:指到 设置 → 服务商 和 设置 → 模型。 */
168
+ export const NO_MODEL_NOTE =
169
+ "尚未配置模型。请在「设置 → 服务商」中添加服务商,再到「设置 → 模型」选择模型";
170
+
171
+ function missingModel(): NoModelError {
172
+ return new NoModelError(NO_MODEL_NOTE);
173
+ }
174
+
175
+ /** Router 这一轮用哪个模型:助手自己的对话模型,没有就全局。 */
176
+ export function conversationPick(settings: ModelSettings, assistant: Assistant): ModelPick {
177
+ const pick = assistant.models?.conversation ?? settings.conversation;
178
+ if (!pick.ref) throw missingModel();
179
+ return pick;
180
+ }
181
+
182
+ export interface JobModelInput {
183
+ settings: ModelSettings;
184
+ assistant: Assistant;
185
+ catalog: ModelCatalog;
186
+ /** 助手分派任务时填的 model / thinking */
187
+ requested?: { model?: string; thinking?: string };
188
+ /** 任务带图片:所选模型不能看图就换一个能看的 */
189
+ hasImages?: boolean;
190
+ }
191
+
192
+ export interface JobModelPick {
193
+ model: ModelPick;
194
+ modelReason?: string;
195
+ }
196
+
197
+ function thinkingFor(
198
+ ref: string,
199
+ settings: ModelSettings,
200
+ role: { ref: string; thinking: ThinkingLevel },
201
+ wants: ThinkingLevel | undefined,
202
+ catalog: ModelCatalog,
203
+ ): ThinkingLevel {
204
+ const levels = thinkingLevelsOf(catalog.find(ref));
205
+ // 角色指定的模型用角色的思考档;另选的模型用它自己在名单里的默认思考档
206
+ const fallback =
207
+ ref === role.ref
208
+ ? role.thinking
209
+ : (settings.roster.find((entry) => entry.ref === ref)?.thinking ?? role.thinking);
210
+ // 填了模型没有的档不报错,往下取最近的一档(和设置里的默认档一样)
211
+ return fitThinking(levels, wants ?? fallback);
212
+ }
213
+
214
+ /**
215
+ * 分派任务时定模型:助手指定的优先,其次助手的默认任务模型,最后全局的。
216
+ * 都要落在助手允许的范围里,并且当前可用。
217
+ */
218
+ export function jobModel(input: JobModelInput): JobModelPick {
219
+ const { settings, assistant, catalog, requested } = input;
220
+ const allowed = allowedRefs(settings.roster, assistant);
221
+ if (!allowed.length) {
222
+ throw new Error("该助手暂无可用模型。请在「设置 → 服务商」中添加服务商,再到「设置 → 模型」选择模型。");
223
+ }
224
+ const usable = allowed.filter((ref) => catalog.find(ref));
225
+ const listText = usable.length ? usable.join("、") : "(均不可用:服务商已删除或未配置 Key)";
226
+
227
+ const wantedModel = requested?.model?.trim();
228
+ if (wantedModel && !allowed.includes(wantedModel)) {
229
+ throw new Error(`无法分派 ${wantedModel}:该助手可分派的模型为 ${listText}。`);
230
+ }
231
+ if (wantedModel && !catalog.find(wantedModel)) {
232
+ throw new Error(
233
+ `${wantedModel} 当前不可用(该模型的服务商已删除或未配置 Key)。可分派的模型为 ${listText}。`,
234
+ );
235
+ }
236
+
237
+ const role = {
238
+ ref: assistant.models?.work?.ref ?? settings.work.ref,
239
+ thinking: assistant.models?.work?.thinking ?? settings.work.thinking,
240
+ };
241
+ let ref = wantedModel ?? defaultWorkRef(settings, assistant, catalog);
242
+ if (!ref || !usable.includes(ref)) {
243
+ if (!usable.length) throw new Error(`可分派的模型当前均不可用:${listText}。`);
244
+ ref = usable[0];
245
+ }
246
+
247
+ const wants = settings.thinkingChoice === "fixed" ? undefined : requested?.thinking;
248
+ if (wants !== undefined && !isThinkingLevel(wants)) {
249
+ throw new Error(`思考档无效:可选值为 ${THINKING_LEVELS.join("、")}。`);
250
+ }
251
+ let thinking = thinkingFor(ref, settings, role, wants, catalog);
252
+
253
+ let modelReason: string | undefined;
254
+ if (input.hasImages) {
255
+ const info = catalog.find(ref);
256
+ if (!info?.input.includes("image")) {
257
+ const withImage = usable.find((candidate) => catalog.find(candidate)?.input.includes("image"));
258
+ if (!withImage) {
259
+ throw new Error(
260
+ `任务包含图片,但可分派的模型均不支持图片输入(${listText})。请在「设置 → 模型」中添加支持图片输入的模型。`,
261
+ );
262
+ }
263
+ modelReason = `任务包含图片,已更换为 ${nameOf(withImage, catalog)}`;
264
+ ref = withImage;
265
+ thinking = thinkingFor(ref, settings, role, wants, catalog);
266
+ }
267
+ }
268
+
269
+ return { model: { ref, thinking }, ...(modelReason ? { modelReason } : {}) };
270
+ }
271
+
272
+ /** 设置里填的思考档只校验档位本身合法:模型没有这一档时不拒绝,用的时候往下收(见 fitThinking)。 */
273
+ function checkThinking(thinking: unknown): void {
274
+ if (thinking === undefined) return;
275
+ if (!isThinkingLevel(thinking)) throw new Error(`思考档无效:可选值为 ${THINKING_LEVELS.join("、")}。`);
276
+ }
277
+
278
+ /** PUT /api/settings 里 models 这一节的结构校验(可用性要现查,所以是异步的)。 */
279
+ export async function checkModelsPatch(
280
+ patch: SettingsPatch,
281
+ current: ModelSettings,
282
+ catalog: ModelCatalog,
283
+ ): Promise<void> {
284
+ const next = patch.models;
285
+ if (!next) return;
286
+ await catalog.get();
287
+ const currentRefs = new Set(current.roster.map((entry) => entry.ref));
288
+ if (next.roster) {
289
+ const seen = new Set<string>();
290
+ for (const entry of next.roster) {
291
+ const ref = typeof entry?.ref === "string" ? entry.ref.trim() : "";
292
+ if (!ref) throw new Error("模型列表中存在空项。");
293
+ if (seen.has(ref)) throw new Error(`模型列表中重复:${ref}。`);
294
+ seen.add(ref);
295
+ if (!catalog.find(ref) && !currentRefs.has(ref)) {
296
+ throw new Error(`${ref} 当前不可用(该模型的服务商已删除或未配置 Key)。`);
297
+ }
298
+ checkThinking(entry.thinking);
299
+ }
300
+ }
301
+ const rosterRefs = new Set((next.roster ?? current.roster).map((entry) => entry.ref));
302
+ // 自定义思考档:走设置接口时也照规矩校验(界面用的是 /api/models/thinking)
303
+ for (const [ref, levels] of Object.entries(next.thinking ?? {})) {
304
+ if (!ref.trim()) throw new Error("思考档里存在空的模型项。");
305
+ checkThinkingMap(levels);
306
+ }
307
+ // 只校补丁里真的改了的角色;只删名单里的模型时,角色会自动退到名单里的其他模型
308
+ for (const [label, pick] of [
309
+ ["对话", next.conversation],
310
+ ["任务执行", next.work],
311
+ ] as const) {
312
+ if (!pick) continue;
313
+ if (!pick.ref) throw new Error(`未选择${label}模型。`);
314
+ if (!rosterRefs.has(pick.ref)) throw new Error(`${label}模型 ${pick.ref} 不在模型列表中。`);
315
+ checkThinking(pick.thinking);
316
+ }
317
+ }
318
+
319
+ /**
320
+ * PATCH /api/assistants/:id 里的 models / abilities:不合法就抛错(http 回 400)。
321
+ * models.allowed 为空数组 = 名单全部,等于没限制。
322
+ */
323
+ export function checkAssistantModels(
324
+ raw: { models?: Assistant["models"]; abilities?: Assistant["abilities"] },
325
+ roster: RosterEntry[],
326
+ catalog: ModelCatalog,
327
+ ): { models?: Assistant["models"]; abilities?: Assistant["abilities"] } {
328
+ const out: { models?: Assistant["models"]; abilities?: Assistant["abilities"] } = {};
329
+ const rosterRefs = new Set(roster.map((entry) => entry.ref));
330
+ if (raw.models) {
331
+ const models: NonNullable<Assistant["models"]> = {};
332
+ for (const key of ["conversation", "work"] as const) {
333
+ const pick = raw.models[key];
334
+ if (!pick) continue;
335
+ const ref = typeof pick.ref === "string" ? pick.ref.trim() : "";
336
+ if (!ref) continue; // 空 = 跟随全局
337
+ if (!rosterRefs.has(ref)) throw new Error(`模型 ${ref} 不在模型列表中。`);
338
+ if (!catalog.find(ref)) throw new Error(`模型 ${ref} 当前不可用(该模型的服务商已删除或未配置 Key)。`);
339
+ checkThinking(pick.thinking);
340
+ models[key] = { ref, thinking: pick.thinking };
341
+ }
342
+ if (raw.models.allowed) {
343
+ const allowed = raw.models.allowed.map((ref) => String(ref));
344
+ const unknown = allowed.filter((ref) => !rosterRefs.has(ref));
345
+ if (unknown.length) throw new Error(`以下模型不在模型列表中:${unknown.join("、")}。`);
346
+ models.allowed = allowed;
347
+ }
348
+ out.models = models;
349
+ }
350
+ if (raw.abilities) {
351
+ out.abilities = {
352
+ exec: raw.abilities.exec !== false,
353
+ write: raw.abilities.write !== false,
354
+ image: raw.abilities.image !== false,
355
+ };
356
+ }
357
+ return out;
358
+ }
@@ -0,0 +1,116 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import type { Job } from "../../shared/types.ts";
4
+ import type { HobRuntime } from "./runtime.ts";
5
+
6
+ const SUMMARY_CHARS = 100;
7
+ const LOG_LIMIT = 10;
8
+
9
+ const STATUS_TEXT: Record<Job["status"], string> = {
10
+ queued: "排队中",
11
+ running: "进行中",
12
+ waiting: "待确认",
13
+ done: "已完成",
14
+ stuck: "受阻",
15
+ failed: "失败",
16
+ stopped: "已停止",
17
+ };
18
+
19
+ function whenText(timezone: string, iso: string): string {
20
+ return new Intl.DateTimeFormat("zh-CN", {
21
+ timeZone: timezone,
22
+ month: "long",
23
+ day: "numeric",
24
+ hour: "2-digit",
25
+ minute: "2-digit",
26
+ hour12: false,
27
+ }).format(new Date(iso));
28
+ }
29
+
30
+ function oneLine(text: string, max: number): string {
31
+ const flat = text.replace(/\s+/g, " ").trim();
32
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
33
+ }
34
+
35
+ function isLive(job: Job): boolean {
36
+ return job.status === "queued" || job.status === "running" || job.status === "waiting";
37
+ }
38
+
39
+ /** 各助手最近这段时间的记录:工作日志、结束的任务、进行中的任务、待确认的审批。 */
40
+ export function overviewText(runtime: HobRuntime, sinceHours = 24): string {
41
+ const hours = Math.min(Math.max(Math.round(sinceHours), 1), 24 * 7);
42
+ const since = new Date(Date.now() - hours * 3600 * 1000).toISOString();
43
+ const timezone = runtime.settings.settings.general.timezone;
44
+ const jobs = runtime.store.jobs();
45
+ const pending = runtime.store.approvals().filter((approval) => approval.status === "pending");
46
+
47
+ const blocks: string[] = [];
48
+ let endedCount = 0;
49
+ let liveCount = 0;
50
+
51
+ for (const assistant of runtime.store.assistants()) {
52
+ const mine = jobs.filter((job) => job.assistantId === assistant.id);
53
+ const ended = mine
54
+ .filter((job) => job.endedAt && job.endedAt >= since)
55
+ .sort((a, b) => (b.endedAt ?? "").localeCompare(a.endedAt ?? ""));
56
+ const live = mine.filter(isLive);
57
+ const asks = pending.filter((approval) => approval.assistantId === assistant.id);
58
+ const log = runtime.store
59
+ .worklog(assistant.id, 100)
60
+ .filter((entry) => entry.at >= since)
61
+ .slice(0, LOG_LIMIT);
62
+ if (!ended.length && !live.length && !asks.length && !log.length) continue;
63
+ endedCount += ended.length;
64
+ liveCount += live.length;
65
+
66
+ const lines = [`## ${assistant.name}(${assistant.id})`];
67
+ if (live.length) {
68
+ lines.push("进行中:");
69
+ for (const job of live) {
70
+ const started = job.startedAt ? `,${whenText(timezone, job.startedAt)} 开始` : "";
71
+ lines.push(`- ${job.title} · ${STATUS_TEXT[job.status]}${started}`);
72
+ }
73
+ }
74
+ if (ended.length) {
75
+ lines.push("结束的任务:");
76
+ for (const job of ended) {
77
+ const summary = job.summary ? oneLine(job.summary, SUMMARY_CHARS) : job.statusReason ?? "没有摘要";
78
+ lines.push(`- ${job.title} · ${STATUS_TEXT[job.status]} · ${summary}`);
79
+ }
80
+ }
81
+ if (log.length) {
82
+ lines.push("工作日志:");
83
+ for (const entry of log) lines.push(`- ${whenText(timezone, entry.at)} ${entry.text}`);
84
+ }
85
+ if (asks.length) {
86
+ lines.push("待确认:");
87
+ for (const approval of asks) lines.push(`- ${approval.title}(${whenText(timezone, approval.createdAt)})`);
88
+ }
89
+ blocks.push(lines.join("\n"));
90
+ }
91
+
92
+ const head = `最近 ${hours} 小时(${whenText(timezone, since)} 之后):${endedCount} 件结束的任务、${liveCount} 件进行中、${pending.length} 个待确认。`;
93
+ if (!blocks.length) return `${head}\n\n这段时间没有记录。`;
94
+ return `${head}\n\n${blocks.join("\n\n")}`;
95
+ }
96
+
97
+ /** 只读的 overview 工具:只给主助手(Router)和主助手的任务(Worker)。 */
98
+ export function overviewTool(runtime: HobRuntime): ToolDefinition<any, any> {
99
+ return {
100
+ name: "overview",
101
+ label: "查看这段时间的记录",
102
+ description:
103
+ "只读:列出各助手最近一段时间的工作日志、结束的任务(标题、结果、摘要前 100 字)、进行中的任务和待确认的审批。写早报、汇总“昨晚/今天都做了什么”之前先调用它,照实编写。",
104
+ promptSnippet: "写早报/汇总时用 overview 查看各助手这段时间的记录。",
105
+ parameters: Type.Object({
106
+ sinceHours: Type.Optional(Type.Integer({ description: "往回看多少小时,默认 24,最多 168" })),
107
+ }),
108
+ execute: async (_id, params: any) => {
109
+ const hours = Number(params.sinceHours ?? 24);
110
+ return {
111
+ content: [{ type: "text" as const, text: overviewText(runtime, Number.isFinite(hours) ? hours : 24) }],
112
+ details: undefined,
113
+ };
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,54 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { Type } from "@earendil-works/pi-ai";
5
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
6
+ import type { HobRuntime } from "./runtime.ts";
7
+ import { pageHtml } from "../../shared/page-shell.ts";
8
+ export { pageHtml } from "../../shared/page-shell.ts";
9
+
10
+ const MAX = 80_000;
11
+
12
+ export async function renderPage(home: string, title: string, summary: string, html: string): Promise<string> {
13
+ if (!html.trim()) throw new Error("页面内容不能为空。");
14
+ if (html.length > MAX) throw new Error(`页面内容不能超过 ${MAX} 字。`);
15
+ if (/<\s*(!doctype|html|head|body)\b/i.test(html)) throw new Error("html 只写正文片段,不要包含 <!doctype>、<html>、<head> 或 <body>;标题和摘要由 Hob 放在页头。");
16
+ const dir = join(home, "pages");
17
+ await mkdir(dir, { recursive: true });
18
+ const path = join(dir, `${randomUUID()}.html`);
19
+ await writeFile(path, pageHtml(title, summary, html), "utf8");
20
+ return path;
21
+ }
22
+
23
+ const description = `当流程、对比、架构、多步骤教程、数据表或长篇调研结论用页面比文字更易理解时使用。简短问答、闲聊或一两段能讲清的内容不要用。调用前只说一句话点题。
24
+ html 只写正文的 HTML 片段(不要 <html>、<head>、<body>),标题和摘要由 Hob 放在页头,正文别再写 h1。页面离线显示:不能加载外部脚本、样式和字体,不能联网取数据;图片只能用 https 或 data 地址。
25
+ 已有样式(亮暗自动适配),直接用:
26
+ - 小节用 <h2>、<h3>;段落、列表、<table>、<pre><code>、<blockquote> 都已排好。
27
+ - 并列要点:<div class="cards"><div class="card"><h3>…</h3><p>…</p></div>…</div>
28
+ - 左右对比:<div class="cols"><div>…</div><div>…</div></div>
29
+ - 步骤:<ol class="steps"><li><b>步骤名</b>说明</li>…</ol>
30
+ - 关键数字:<div class="kpis"><div class="kpi"><b>42%</b><span>说明</span></div>…</div>
31
+ - 提示框:<div class="note">…</div>;标签:<span class="tag">…</span>;都可加 ok / warn / bad 变色。
32
+ - 图(流程、结构、时序、关系)用 <figure> 里的内联 <svg viewBox="…">,不写死宽高,配 <figcaption>。SVG 类:box 普通框、hl 强调框、line 连线、text 加 muted 为次要文字;文字默认 13px。颜色只用变量 --ink --muted --faint --line --soft --accent --accent-soft --ok --warn --bad,写成 style="fill:var(--accent)"。
33
+ - 先给结论再展开;能用图、表、卡片说清的就别堆长段落;强调色只点最重要的一两处。
34
+ - 需要特别的版式可以加 <style>(颜色仍用上面的变量),可以用 <script> 做交互。最多 ${MAX} 字。`;
35
+
36
+ export function pageTool(runtime: HobRuntime, assistantId: string, router: boolean): ToolDefinition<any, any> {
37
+ return {
38
+ name: "show_page", label: "展示页面", description,
39
+ parameters: Type.Object({ title: Type.String(), summary: Type.String(), html: Type.String({ maxLength: MAX }) }),
40
+ execute: async (_id, params: any) => {
41
+ try {
42
+ if (runtime.settings.settings.general.showPage === false) throw new Error("展示页面已关闭。");
43
+ const title = String(params.title ?? "").trim();
44
+ const summary = String(params.summary ?? "").trim();
45
+ if (!title || !summary) throw new Error("标题和摘要不能为空。");
46
+ const path = await renderPage(runtime.home, title, summary, String(params.html ?? ""));
47
+ await runtime.router.addItem(assistantId, { kind: "file", path, name: title, meta: summary });
48
+ return { content: [{ type: "text" as const, text: `页面已展示:${path}${router ? "。本轮到此结束。" : ""}` }], details: { path }, ...(router ? { terminate: true } : {}) };
49
+ } catch (error) {
50
+ return { content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }], details: undefined, isError: true };
51
+ }
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,23 @@
1
+ // 登录密码:config.json 只存哈希(scrypt + 每组随机盐),不存明文。
2
+ import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
3
+
4
+ const KEYLEN = 64;
5
+
6
+ /** "scrypt$salt$hash",salt 和 hash 都是 hex。 */
7
+ export function hashPassword(password: string): string {
8
+ const salt = randomBytes(16).toString("hex");
9
+ const hash = scryptSync(password, salt, KEYLEN).toString("hex");
10
+ return `scrypt$${salt}$${hash}`;
11
+ }
12
+
13
+ export function isPasswordHash(value: string): boolean {
14
+ return /^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/.test(value);
15
+ }
16
+
17
+ export function verifyPassword(stored: string, password: string): boolean {
18
+ if (!isPasswordHash(stored)) return false;
19
+ const [, salt, hash] = stored.split("$");
20
+ const expected = Buffer.from(hash, "hex");
21
+ const given = scryptSync(password, salt, KEYLEN);
22
+ return expected.length === given.length && timingSafeEqual(expected, given);
23
+ }