hob-bot 1.2.0 → 1.4.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.
Files changed (38) hide show
  1. package/README.md +32 -24
  2. package/README.zh-CN.md +33 -25
  3. package/dist/web/assets/DesktopView-A11jmUwu.js +5 -0
  4. package/dist/web/assets/index-B-YlVbpo.js +270 -0
  5. package/dist/web/assets/index-DkSQ-ygR.css +1 -0
  6. package/dist/web/index.html +2 -2
  7. package/dist/web/mock/readme-cover.svg +13 -0
  8. package/dist/web/mock/readme-page.html +7 -0
  9. package/package.json +1 -1
  10. package/server/src/assistant-skills.ts +2 -2
  11. package/server/src/delete-skill.ts +19 -0
  12. package/server/src/desktop-tools.ts +48 -14
  13. package/server/src/http.ts +56 -6
  14. package/server/src/job-view.ts +2 -2
  15. package/server/src/jobs.ts +120 -108
  16. package/server/src/knowledge-tools.ts +1 -1
  17. package/server/src/main.ts +2 -0
  18. package/server/src/memory.ts +2 -2
  19. package/server/src/now.ts +2 -2
  20. package/server/src/overview.ts +2 -3
  21. package/server/src/pages.ts +7 -6
  22. package/server/src/prompt.ts +57 -58
  23. package/server/src/router-activity.ts +7 -0
  24. package/server/src/router-tools.ts +65 -36
  25. package/server/src/router.ts +92 -40
  26. package/server/src/routines.ts +8 -3
  27. package/server/src/runtime.ts +12 -1
  28. package/server/src/settings.ts +37 -0
  29. package/server/src/store.ts +20 -0
  30. package/server/src/transcript.ts +14 -4
  31. package/server/src/usage-prices.ts +165 -0
  32. package/server/src/usage.ts +281 -0
  33. package/server/src/worker-tools.ts +7 -17
  34. package/shared/skills.ts +3 -1
  35. package/shared/types.ts +85 -2
  36. package/dist/web/assets/DesktopView-CEBJFAIN.js +0 -5
  37. package/dist/web/assets/index-Di5lyTHO.css +0 -1
  38. package/dist/web/assets/index-y09i1RGd.js +0 -267
@@ -0,0 +1,165 @@
1
+ // 模型单价:默认从 models.dev 拉,一天最多更新一次;存在 HOB_HOME/usage-prices.json,拉不到就用上次的。
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import type { TokenPrice } from "../../shared/types.ts";
5
+
6
+ const SOURCE = "https://models.dev/api.json";
7
+ const FILE = "usage-prices.json";
8
+ const DAY = 24 * 3600 * 1000;
9
+ /** 失败后这么久不再试:价格表本来就一天更新一次,失败时别让每次打开页面都等一次超时。 */
10
+ const RETRY = 10 * 60 * 1000;
11
+ const TIMEOUT = 10_000;
12
+
13
+ /** 模型厂商自己那一家:同一个模型 id 出现在多个服务商里时优先它。 */
14
+ const VENDOR_PROVIDERS = new Set(["openai", "anthropic", "deepseek", "google", "xai", "mistral", "zhipuai", "moonshotai"]);
15
+
16
+ /** HOB_HOME/usage-prices.json:拉到的单价 + 上次更新和上次尝试的时间。 */
17
+ interface PriceFile {
18
+ fetchedAt: string | null;
19
+ attemptAt: string | null;
20
+ error: string | null;
21
+ prices: Record<string, TokenPrice>;
22
+ }
23
+
24
+ const EMPTY: PriceFile = { fetchedAt: null, attemptAt: null, error: null, prices: {} };
25
+
26
+ function readFile(home: string): PriceFile {
27
+ const path = join(home, FILE);
28
+ if (!existsSync(path)) return EMPTY;
29
+ try {
30
+ const stored = JSON.parse(readFileSync(path, "utf8")) as Partial<PriceFile>;
31
+ const prices = stored.prices && typeof stored.prices === "object" ? stored.prices : {};
32
+ return {
33
+ fetchedAt: typeof stored.fetchedAt === "string" ? stored.fetchedAt : null,
34
+ attemptAt: typeof stored.attemptAt === "string" ? stored.attemptAt : null,
35
+ error: typeof stored.error === "string" ? stored.error : null,
36
+ prices: Object.fromEntries(Object.entries(prices).filter(([, price]) => isPrice(price))),
37
+ };
38
+ } catch {
39
+ return EMPTY;
40
+ }
41
+ }
42
+
43
+ function isPrice(value: unknown): value is TokenPrice {
44
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
45
+ const price = value as Record<string, unknown>;
46
+ return ["input", "output", "cacheRead", "cacheWrite"].every((key) => typeof price[key] === "number" && Number.isFinite(price[key]) && (price[key] as number) >= 0);
47
+ }
48
+
49
+ /** 用户填的单价:四项都是大于等于 0 的数字,不合法说清哪一项。 */
50
+ export function checkPrice(raw: unknown): TokenPrice {
51
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("单价要填四项:输入、输出、缓存读、缓存写。");
52
+ const input = raw as Record<string, unknown>;
53
+ const names: Record<keyof TokenPrice, string> = { input: "输入", output: "输出", cacheRead: "缓存读", cacheWrite: "缓存写" };
54
+ const out = {} as TokenPrice;
55
+ for (const key of Object.keys(names) as (keyof TokenPrice)[]) {
56
+ const value = input[key];
57
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
58
+ throw new Error(`「${names[key]}」的单价要填大于等于 0 的数字(美元 / 百万 token)。`);
59
+ }
60
+ out[key] = Number(value.toFixed(6));
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function failureReason(error: unknown): string {
66
+ if (error instanceof Error) {
67
+ if (error.name === "TimeoutError" || error.name === "AbortError") return "请求超时";
68
+ return error.message;
69
+ }
70
+ return String(error);
71
+ }
72
+
73
+ /**
74
+ * models.dev 的整份数据 → 模型 id → 单价。
75
+ * 同一个模型 id 在多个服务商里都有时,优先模型厂商自己那家(`canonical_model_id` 指回自己,或 id 在厂商名单里)。
76
+ */
77
+ export function indexPrices(raw: unknown): Record<string, TokenPrice> {
78
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("返回的不是服务商列表");
79
+ const candidates = new Map<string, { vendor: boolean; price: TokenPrice }[]>();
80
+ for (const [providerId, provider] of Object.entries(raw as Record<string, unknown>)) {
81
+ const models = (provider as { models?: unknown } | null)?.models;
82
+ if (!models || typeof models !== "object" || Array.isArray(models)) continue;
83
+ for (const [modelId, model] of Object.entries(models as Record<string, unknown>)) {
84
+ const cost = (model as { cost?: unknown } | null)?.cost as Record<string, unknown> | null | undefined;
85
+ if (!cost || typeof cost.input !== "number" || typeof cost.output !== "number") continue;
86
+ const canonical = (model as { canonical_model_id?: unknown }).canonical_model_id;
87
+ const vendor = (typeof canonical === "string" && canonical.split("/")[0] === providerId) || VENDOR_PROVIDERS.has(providerId);
88
+ const price: TokenPrice = {
89
+ input: cost.input,
90
+ output: cost.output,
91
+ cacheRead: typeof cost.cache_read === "number" ? cost.cache_read : 0,
92
+ cacheWrite: typeof cost.cache_write === "number" ? cost.cache_write : 0,
93
+ };
94
+ const list = candidates.get(modelId) ?? [];
95
+ list.push({ vendor, price });
96
+ candidates.set(modelId, list);
97
+ }
98
+ }
99
+ const prices: Record<string, TokenPrice> = Object.fromEntries([...candidates].map(([modelId, list]) => [modelId, (list.find((item) => item.vendor) ?? list[0]).price]));
100
+ if (!Object.keys(prices).length) throw new Error("返回里没有模型价格");
101
+ return prices;
102
+ }
103
+
104
+ /**
105
+ * 价格表:一天最多更新一次;拉不到就留着上次的,并把原因写下来给页面显示。
106
+ * `fetchImpl` 和 `now` 只为测试注入(不连真实网络)。
107
+ */
108
+ export class UsagePrices {
109
+ private file: PriceFile;
110
+ private loading?: Promise<void>;
111
+
112
+ constructor(private readonly options: { home: string; fetchImpl?: typeof fetch; now?: () => number }) {
113
+ this.file = readFile(options.home);
114
+ }
115
+
116
+ private get now(): number {
117
+ return this.options.now?.() ?? Date.now();
118
+ }
119
+
120
+ /** 需要更新时拉一次(同一时刻只拉一次);要更新时会把结果写进 HOB_HOME。 */
121
+ async ensure(): Promise<void> {
122
+ this.loading ??= this.refresh().finally(() => {
123
+ this.loading = undefined;
124
+ });
125
+ await this.loading;
126
+ }
127
+
128
+ /** 现在用的价格和上次更新的状态。 */
129
+ table(): { fetchedAt: string | null; error: string | null; prices: Record<string, TokenPrice> } {
130
+ return { fetchedAt: this.file.fetchedAt, error: this.file.error, prices: this.file.prices };
131
+ }
132
+
133
+ private async refresh(): Promise<void> {
134
+ const now = this.now;
135
+ const fetchedAt = this.file.fetchedAt ? Date.parse(this.file.fetchedAt) : 0;
136
+ if (now - fetchedAt < DAY) return;
137
+ const attemptAt = this.file.attemptAt ? Date.parse(this.file.attemptAt) : 0;
138
+ if (attemptAt > fetchedAt && now - attemptAt < RETRY) return;
139
+ try {
140
+ this.file = { fetchedAt: new Date(now).toISOString(), attemptAt: new Date(now).toISOString(), error: null, prices: indexPrices(await this.fetchSource()) };
141
+ } catch (error) {
142
+ this.file = { ...this.file, attemptAt: new Date(now).toISOString(), error: failureReason(error) };
143
+ }
144
+ this.write();
145
+ }
146
+
147
+ private async fetchSource(): Promise<unknown> {
148
+ const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
149
+ const response = await fetchImpl(SOURCE, { signal: AbortSignal.timeout(TIMEOUT), headers: { accept: "application/json" } });
150
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
151
+ try {
152
+ return await response.json();
153
+ } catch {
154
+ throw new Error("返回的不是有效 JSON");
155
+ }
156
+ }
157
+
158
+ private write(): void {
159
+ const path = join(this.options.home, FILE);
160
+ mkdirSync(dirname(path), { recursive: true });
161
+ const tmp = `${path}.${process.pid}.tmp`;
162
+ writeFileSync(tmp, `${JSON.stringify(this.file, null, 2)}\n`);
163
+ renameSync(tmp, path);
164
+ }
165
+ }
@@ -0,0 +1,281 @@
1
+ // 消耗:不另外记账,直接从会话文件里 assistant 消息的 usage 统计(见 docs/architecture.md“消耗”)。
2
+ import { createReadStream, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type { TokenPrice, UsageAssistant, UsageModelPrice, UsagePrices, UsageRow, UsageSource, UsageView } from "../../shared/types.ts";
5
+ import type { SettingsStore } from "./settings.ts";
6
+ import type { Store } from "./store.ts";
7
+ import type { UsagePrices as PriceTable } from "./usage-prices.ts";
8
+
9
+ /** 一条 assistant 消息的用量;日志按毫秒记,归到哪一天按设置的时区算。 */
10
+ interface UsageRecord {
11
+ at: number;
12
+ provider: string;
13
+ model: string;
14
+ input: number;
15
+ output: number;
16
+ cacheRead: number;
17
+ cacheWrite: number;
18
+ }
19
+
20
+ /** 一个会话文件:大小和修改时间没变就不重读,变长时只读追加的那一段。 */
21
+ interface CachedFile {
22
+ size: number;
23
+ mtimeMs: number;
24
+ offset: number; // 已读到的字节位置(只到最后一个完整换行,半行留着下次读)
25
+ records: UsageRecord[];
26
+ assistantId: string;
27
+ assistantName: string;
28
+ source: UsageSource;
29
+ }
30
+
31
+ interface Target {
32
+ file: string;
33
+ assistantId: string;
34
+ assistantName: string;
35
+ source: UsageSource;
36
+ }
37
+
38
+ function num(value: unknown): number {
39
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
40
+ }
41
+
42
+ /** 一行会话记录 → 用量;不是带用量字段的 assistant 消息就返回 undefined。 */
43
+ function recordOf(line: string): UsageRecord | undefined {
44
+ let entry: { type?: unknown; timestamp?: unknown; message?: any };
45
+ try {
46
+ entry = JSON.parse(line) as typeof entry;
47
+ } catch {
48
+ return undefined; // 半行(进程被杀)跳过
49
+ }
50
+ if (entry?.type !== "message") return undefined;
51
+ const message = entry.message;
52
+ if (message?.role !== "assistant") return undefined;
53
+ const provider = typeof message.provider === "string" ? message.provider : "";
54
+ const model = typeof message.model === "string" ? message.model : "";
55
+ if (!provider || !model) return undefined;
56
+ const at = typeof message.timestamp === "number" ? message.timestamp : Date.parse(String(message.timestamp ?? entry.timestamp ?? ""));
57
+ if (!Number.isFinite(at)) return undefined;
58
+ const usage = message.usage ?? {};
59
+ return {
60
+ at,
61
+ provider,
62
+ model,
63
+ input: num(usage.input),
64
+ output: num(usage.output),
65
+ cacheRead: num(usage.cacheRead),
66
+ cacheWrite: num(usage.cacheWrite),
67
+ };
68
+ }
69
+
70
+ /** 从 start 字节起读完整的行,返回下一次从这里接着读的字节位置和用量。 */
71
+ async function readUsage(file: string, start: number): Promise<{ offset: number; records: UsageRecord[] }> {
72
+ const records: UsageRecord[] = [];
73
+ let pending: Buffer = Buffer.alloc(0);
74
+ let read = start;
75
+ for await (const chunk of createReadStream(file, { start })) {
76
+ const buffer = chunk as Buffer;
77
+ read += buffer.length;
78
+ pending = pending.length ? Buffer.concat([pending, buffer]) : buffer;
79
+ let from = 0;
80
+ for (let at = pending.indexOf(0x0a); at !== -1; at = pending.indexOf(0x0a, from)) {
81
+ const line = pending.toString("utf8", from, at);
82
+ from = at + 1;
83
+ const record = recordOf(line);
84
+ if (record) records.push(record);
85
+ }
86
+ pending = pending.subarray(from);
87
+ }
88
+ return { offset: read - pending.length, records };
89
+ }
90
+
91
+ /** 时间戳 → 设置时区下的日期(同一小时里的本地日期一样,按时段记一下省掉重复的时区换算)。 */
92
+ function dayKeyer(timezone: string): (at: number) => string {
93
+ const format = new Intl.DateTimeFormat("sv-SE", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" });
94
+ const memo = new Map<number, string>();
95
+ return (at) => {
96
+ const hour = Math.floor(at / 3_600_000);
97
+ const hit = memo.get(hour);
98
+ if (hit) return hit;
99
+ const day = format.format(new Date(at));
100
+ memo.set(hour, day);
101
+ return day;
102
+ };
103
+ }
104
+
105
+ function readDir(path: string): string[] {
106
+ return existsSync(path) ? readdirSync(path) : [];
107
+ }
108
+
109
+ function readJson(path: string): Record<string, unknown> | undefined {
110
+ try {
111
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ function modelPrice(id: string, table: Record<string, TokenPrice>, overrides: Record<string, TokenPrice>): UsageModelPrice | null {
118
+ const custom = Object.hasOwn(overrides, id) ? overrides[id] : undefined;
119
+ const base = Object.hasOwn(table, id) ? table[id] : undefined;
120
+ if (custom && base) return { price: custom, custom: true, default: base };
121
+ if (custom) return { price: custom, custom: true };
122
+ return base ? { price: base, custom: false } : null;
123
+ }
124
+
125
+ /**
126
+ * 消耗统计:扫出全部会话文件(助手对话、归档助手的对话、任务的会话),按文件缓存解析结果。
127
+ * 第一次扫描是异步流式读,不阻塞其他请求。
128
+ */
129
+ export class UsageService {
130
+ private files = new Map<string, CachedFile>();
131
+ private assistants = new Map<string, UsageAssistant>();
132
+ private scanning?: Promise<void>;
133
+ /** 读过几次会话文件(文件没变时不该再读,测试用它看这条) */
134
+ reads = 0;
135
+
136
+ constructor(
137
+ private readonly options: {
138
+ home: string;
139
+ store: Store;
140
+ settings: SettingsStore;
141
+ prices: PriceTable;
142
+ },
143
+ ) {}
144
+
145
+ /** 启动后先扫一遍;服务不等它。 */
146
+ warmup(): void {
147
+ void this.scan().catch(() => {});
148
+ }
149
+
150
+ async view(): Promise<UsageView> {
151
+ await this.scan();
152
+ const timezone = this.options.settings.settings.general.timezone;
153
+ const dayOf = dayKeyer(timezone);
154
+ const rows = new Map<string, UsageRow>();
155
+ for (const file of this.files.values()) {
156
+ for (const record of file.records) {
157
+ const day = dayOf(record.at);
158
+ const key = [day, file.assistantId, file.source, record.provider, record.model].join("\u0000");
159
+ let row = rows.get(key);
160
+ if (!row) {
161
+ row = { day, assistantId: file.assistantId, source: file.source, provider: record.provider, model: record.model, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
162
+ rows.set(key, row);
163
+ }
164
+ row.requests += 1;
165
+ row.input += record.input;
166
+ row.output += record.output;
167
+ row.cacheRead += record.cacheRead;
168
+ row.cacheWrite += record.cacheWrite;
169
+ }
170
+ }
171
+ return {
172
+ timezone,
173
+ rows: [...rows.values()].sort((a, b) => a.day.localeCompare(b.day) || a.assistantId.localeCompare(b.assistantId) || a.source.localeCompare(b.source) || a.model.localeCompare(b.model)),
174
+ assistants: [...this.assistants.values()],
175
+ prices: await this.prices(),
176
+ };
177
+ }
178
+
179
+ /** 一个模型现在的单价(`PUT /api/usage/price` 的返回用)。 */
180
+ async priceOf(model: string): Promise<UsageModelPrice | null> {
181
+ await this.options.prices.ensure();
182
+ return modelPrice(model, this.options.prices.table().prices, this.options.settings.settings.usage?.prices ?? {});
183
+ }
184
+
185
+ private async priceTable(): Promise<Record<string, UsageModelPrice>> {
186
+ const table = this.options.prices.table().prices;
187
+ const overrides = this.options.settings.settings.usage?.prices ?? {};
188
+ const models: Record<string, UsageModelPrice> = {};
189
+ for (const id of new Set([...Object.keys(table), ...Object.keys(overrides)])) {
190
+ const price = modelPrice(id, table, overrides);
191
+ if (price) models[id] = price;
192
+ }
193
+ return models;
194
+ }
195
+
196
+ private async prices(): Promise<UsagePrices> {
197
+ await this.options.prices.ensure();
198
+ const { fetchedAt, error } = this.options.prices.table();
199
+ return { updatedAt: fetchedAt, error, models: await this.priceTable() };
200
+ }
201
+
202
+ private async scan(): Promise<void> {
203
+ this.scanning ??= this.doScan().finally(() => {
204
+ this.scanning = undefined;
205
+ });
206
+ await this.scanning;
207
+ }
208
+
209
+ private async doScan(): Promise<void> {
210
+ const home = this.options.home;
211
+ const seen = new Set<string>();
212
+ const assistants = new Map<string, UsageAssistant>();
213
+ for (const id of readDir(join(home, "assistants"))) {
214
+ const file = join(home, "assistants", id, "router.jsonl");
215
+ if (!existsSync(file)) continue;
216
+ const name = this.options.store.assistant(id)?.name ?? id;
217
+ assistants.set(id, { id, name, archived: false });
218
+ await this.sync({ file, assistantId: id, assistantName: name, source: "chat" }, seen);
219
+ }
220
+ const archiveRoot = join(home, "archive", "assistants");
221
+ for (const folder of readDir(archiveRoot)) {
222
+ const file = join(archiveRoot, folder, "router.jsonl");
223
+ if (!existsSync(file)) continue;
224
+ // 归档目录名是 <助手 id>-<归档时间>,id 从归档里的档案读
225
+ const profile = readJson(join(archiveRoot, folder, "profile.json"));
226
+ const id = typeof profile?.id === "string" && profile.id ? profile.id : folder;
227
+ const name = typeof profile?.name === "string" && profile.name ? profile.name : id;
228
+ if (!assistants.has(id)) assistants.set(id, { id, name, archived: true });
229
+ await this.sync({ file, assistantId: id, assistantName: name, source: "chat" }, seen);
230
+ }
231
+ for (const jobId of readDir(join(home, "jobs"))) {
232
+ const file = join(home, "jobs", jobId, "worker.jsonl");
233
+ if (!existsSync(file)) continue;
234
+ // 没有缓存时才读 job.json:任务的所属助手建好就不变
235
+ const cached = this.files.get(file);
236
+ const job = cached ? undefined : this.options.store.job(jobId);
237
+ const assistantId = cached?.assistantId ?? job?.assistantId;
238
+ if (!assistantId) continue;
239
+ const live = this.options.store.assistant(assistantId);
240
+ const name = cached?.assistantName ?? job?.assistantName ?? live?.name ?? assistantId;
241
+ if (!assistants.has(assistantId)) assistants.set(assistantId, { id: assistantId, name, archived: !live });
242
+ await this.sync({ file, assistantId, assistantName: name, source: "job" }, seen);
243
+ }
244
+ for (const file of [...this.files.keys()]) if (!seen.has(file)) this.files.delete(file);
245
+ this.assistants = assistants;
246
+ }
247
+
248
+ private async sync(target: Target, seen: Set<string>): Promise<void> {
249
+ seen.add(target.file);
250
+ let stat;
251
+ try {
252
+ stat = statSync(target.file);
253
+ } catch {
254
+ this.files.delete(target.file);
255
+ return;
256
+ }
257
+ const cached = this.files.get(target.file);
258
+ const usable = cached && cached.assistantId === target.assistantId && cached.source === target.source;
259
+ if (usable && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return;
260
+ // 会话文件只往后写:变长时接着上次的位置读,否则整份重读
261
+ const appendFrom = usable && stat.size > cached.size ? cached.offset : 0;
262
+ let parsed;
263
+ try {
264
+ parsed = await readUsage(target.file, appendFrom);
265
+ } catch {
266
+ return; // 读不了就留着上次的记录,不当成 0
267
+ }
268
+ this.reads += 1;
269
+ let records = parsed.records;
270
+ if (appendFrom && cached) records = [...cached.records, ...parsed.records];
271
+ this.files.set(target.file, {
272
+ size: stat.size,
273
+ mtimeMs: stat.mtimeMs,
274
+ offset: parsed.offset,
275
+ records,
276
+ assistantId: target.assistantId,
277
+ assistantName: target.assistantName,
278
+ source: target.source,
279
+ });
280
+ }
281
+ }
@@ -13,14 +13,13 @@ export function registerReportTool(runtime: HobRuntime, jobId: string): ToolDefi
13
13
  return {
14
14
  name: "report",
15
15
  label: "提交结果",
16
- description: "任务结束时调用一次,提交结果。",
17
- promptSnippet: "任务结束必须调用 report 提交结果。",
16
+ description: "交结果。做完了、做不下去了,或者要用户拍板时调用。",
18
17
  parameters: Type.Object({
19
18
  outcome: Type.Unsafe<"done" | "stuck" | "need_approval">(
20
- Type.String({ description: "done 做完 / stuck 做不下去(写明缺什么)/ need_approval 需要用户拍板" }),
19
+ Type.String({ description: "done 要的结果拿到了;stuck 要的结果没拿到(做不下去或没做完),写明做到哪、缺什么;need_approval 要用户拍板才能往下做,写明要定什么。" }),
21
20
  ),
22
- result: Type.String({ description: "完整结果:结论、数据、链接、文件路径;写给派活的助手。" }),
23
- files: Type.Optional(Type.Array(Type.String(), { description: "要给用户看的文件(绝对路径)" })),
21
+ result: Type.String({ description: "完整结果,按这个顺序写:结论;做了什么;验证了什么、怎么验证的;没验证或不确定的;需要用户决定的。带上数据、链接和文件路径,写给派活的助手。" }),
22
+ files: Type.Optional(Type.Array(Type.String(), { description: "交给助手的文件(绝对路径),比如要给用户看的图。" })),
24
23
  }),
25
24
  execute: async (_id, params: any) => {
26
25
  // 不认识的结果不能当成已完成:退回让模型重新调用
@@ -46,38 +45,30 @@ export function registerAskTool(runtime: HobRuntime, jobId: string): ToolDefinit
46
45
  return {
47
46
  name: "ask_user",
48
47
  label: "询问用户",
49
- description: "在对话里出选择题卡片(2 到 3 个选项),等用户选,返回用户的选择。用于用户定的规则要求先确认的事,或发送、删除、付费这类撤不回的事。",
50
- promptSnippet: "规则要求执行前确认的事,执行前必须调用 ask_user。",
48
+ description: "问你的助手,等它回复。它能答的会直接答,要用户定的会去问用户。",
51
49
  parameters: Type.Object({
52
50
  title: Type.String({ description: "问题,一句话" }),
53
- detail: Type.Optional(Type.String({ description: "补充说明,写清后果" })),
51
+ detail: Type.Optional(Type.String({ description: "补充说明,写清后果。" })),
54
52
  options: Type.Array(
55
53
  Type.Object({
56
54
  label: Type.String({ description: "选项文字" }),
57
55
  }),
58
56
  { description: "2 到 3 个选项", minItems: 2, maxItems: 3 },
59
57
  ),
60
- remember: Type.Optional(Type.String({ description: "同类事以后可以不再问时,填这类事的简称(如「清理临时目录」);卡片会多一个「以后直接做」的选项。" })),
61
58
  }),
62
59
  execute: async (_id, params: any) => {
63
60
  const labels: { label: string }[] = Array.isArray(params.options) ? params.options : [];
64
61
  if (labels.length < 2 || labels.length > 3) {
65
62
  return { content: [{ type: "text" as const, text: "选项要 2 到 3 个。" }], details: undefined, isError: true };
66
63
  }
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
64
  const options: ApprovalOption[] = labels.map((option, index) => ({
72
65
  key: (["A", "B", "C"] as const)[index],
73
66
  label: String(option.label),
74
67
  }));
75
- if (remember) options.push({ key: "C", label: `以后「${remember}」直接做`, always: true });
76
68
  const answer = await runtime.jobs.askQuestion(jobId, {
77
69
  title: String(params.title),
78
70
  ...(params.detail ? { detail: String(params.detail) } : {}),
79
71
  options,
80
- ...(remember ? { ruleText: remember } : {}),
81
72
  });
82
73
  // 用户可能没选给出的选项,而是自己写了一段:answer 已经是回给模型的整句
83
74
  return ok(answer);
@@ -91,8 +82,7 @@ export function registerImageTool(runtime: HobRuntime, jobId: string): ToolDefin
91
82
  name: "generate_image",
92
83
  label: "生成图片",
93
84
  description:
94
- "按文字描述生成一张图片,返回图片的绝对路径(存在任务工作目录)。要把图给用户看,就把这个路径放进 report 的 files。",
95
- promptSnippet: "生成图片用 generate_image;要给用户看就把它放进 report 的 files。",
85
+ "按文字描述生成一张图片,返回图片的绝对路径(存在任务工作目录)。要给用户看,就把路径放进 report 的 files。",
96
86
  parameters: Type.Object({
97
87
  prompt: Type.String({ description: "要画什么,越具体越好" }),
98
88
  size: Type.Optional(
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/<版本>/
package/shared/types.ts CHANGED
@@ -227,7 +227,7 @@ export interface Assistant {
227
227
  pinned: boolean; // 左栏顶部大头像
228
228
  hidden: boolean; // 收起的助手
229
229
  notify: boolean; // 需要确认时通知我
230
- instructions: string; // 补充说明:用户可直接编辑的自定义指令,这个助手特有的做法写这里
230
+ instructions: string; // 补充说明:用户可直接编辑该助手需要知道的专属事实
231
231
  /** 这个助手用哪些模型;缺省全部跟随设置 → 模型 */
232
232
  models?: {
233
233
  conversation?: ModelPick; // 不填 = 跟随全局的对话模型
@@ -247,6 +247,7 @@ export type RouterActivity = { state: "thinking" | "tool"; label: string };
247
247
  export interface AssistantSummary extends Assistant {
248
248
  last?: { text: string; at: Iso }; // 对话最后一条(列表第二行)
249
249
  unread: number; // 未读条数(>0 时大头像右上角橙点)
250
+ readAt?: Iso; // 上次看这个助手的时间:之前结束的任务不再挂完成/受阻标记
250
251
  busy: boolean; // Router 正在回复(在跑的任务不算,任务状态看任务卡)
251
252
  activity?: RouterActivity | null;
252
253
  }
@@ -367,6 +368,12 @@ export interface JobsPage {
367
368
  hasMore: boolean;
368
369
  }
369
370
 
371
+ export type SedimentStatus =
372
+ | { status: "running" }
373
+ | { status: "saved"; name: string; title: string; version: number; updated: boolean }
374
+ | { status: "empty"; reason: string }
375
+ | { status: "failed"; reason: string };
376
+
370
377
  export interface Job {
371
378
  id: string; // "t-" + 4 位以上
372
379
  assistantId: string;
@@ -391,6 +398,8 @@ export interface Job {
391
398
  modelReason?: string; // 换了模型的原因,如「需要看图,换成了 X」
392
399
  inputs?: string[]; // 交给任务的附件(绝对路径)
393
400
  routineId?: string; // 由定时触发
401
+ sediment?: SedimentStatus; // 在原任务中沉淀技能,不改变任务状态
402
+ skills?: string[]; // 派活时交给任务的本助手技能
394
403
  skillsUsed?: string[]; // 这次读过的本助手技能(AssistantSkill.name)
395
404
  delegationId?: string; // 转交回合创建的任务
396
405
  /** 续做前每一轮结束时的样子(第 0 轮起)。对话里前面几张任务卡照它显示,最后一张跟着任务实时变 */
@@ -548,6 +557,78 @@ export interface PushSettings {
548
557
  email: string;
549
558
  }
550
559
 
560
+ /** 每百万 token 的单价(美元),四项。 */
561
+ export interface TokenPrice {
562
+ input: number;
563
+ output: number;
564
+ cacheRead: number;
565
+ cacheWrite: number;
566
+ }
567
+
568
+ /** 设置 → 消耗:按模型改过的单价,键是模型 id(服务商 id 是用户自己起的,价格按模型 id 对)。 */
569
+ export interface UsageSettings {
570
+ prices: Record<string, TokenPrice>;
571
+ }
572
+
573
+ /** 用量的来源:助手自己的对话 / 任务的会话。 */
574
+ export type UsageSource = "chat" | "job";
575
+
576
+ /** GET /api/usage 的一行:按天 × 助手 × 来源 × 服务商 × 模型汇总;day 是设置时区下的日期。 */
577
+ export interface UsageRow {
578
+ day: string; // 2026-11-06
579
+ assistantId: string;
580
+ source: UsageSource;
581
+ provider: string;
582
+ model: string;
583
+ requests: number; // assistant 消息条数
584
+ input: number; // 没命中缓存的输入 token
585
+ output: number;
586
+ cacheRead: number;
587
+ cacheWrite: number;
588
+ }
589
+
590
+ /** 有会话文件的助手(归档的也算),「按助手」一节用它的名字。 */
591
+ export interface UsageAssistant {
592
+ id: string;
593
+ name: string;
594
+ archived: boolean;
595
+ }
596
+
597
+ /** 一个模型现在的单价:改过的是自定,default 是价格表里的默认值(恢复默认和对比用)。 */
598
+ export interface UsageModelPrice {
599
+ price: TokenPrice;
600
+ custom: boolean;
601
+ default?: TokenPrice;
602
+ }
603
+
604
+ /** 价格表:默认单价从 models.dev 拉,一天最多更新一次。 */
605
+ export interface UsagePrices {
606
+ updatedAt: Iso | null; // 价格表拉到的时间;从没拉到过为 null
607
+ error: string | null; // 这次更新价格表失败的原因;成功为 null
608
+ models: Record<string, UsageModelPrice>; // 键是模型 id
609
+ }
610
+
611
+ /** GET /api/usage:按天、助手、来源、服务商、模型分组的明细 + 价格;前端自己汇总。 */
612
+ export interface UsageView {
613
+ timezone: string;
614
+ rows: UsageRow[];
615
+ assistants: UsageAssistant[];
616
+ prices: UsagePrices;
617
+ }
618
+
619
+ /** PUT /api/usage/price:改一个模型的单价,price 为 null 就恢复默认。 */
620
+ export interface UsagePricePatch {
621
+ revision: number;
622
+ model: string;
623
+ price: TokenPrice | null;
624
+ }
625
+
626
+ /** PUT /api/usage/price:新 revision 的整份设置 + 这个模型现在的单价(既没价格表也没自定时为 null)。 */
627
+ export interface UsagePriceSaved {
628
+ settings: SettingsFile;
629
+ price: UsageModelPrice | null;
630
+ }
631
+
551
632
  export interface Settings {
552
633
  general: GeneralSettings;
553
634
  /** 服务商(Key 在 secrets.json,这里只放不敏感的) */
@@ -564,6 +645,8 @@ export interface Settings {
564
645
  push: PushSettings;
565
646
  /** 知识库二期:向量模型(Key 在 secrets.json 的 embedding);缺省 = 关闭,只按关键词查 */
566
647
  knowledge?: { embedding: EmbeddingSettings };
648
+ /** 消耗:按模型改过的单价;没改过的模型用 models.dev 的价格 */
649
+ usage?: UsageSettings;
567
650
  }
568
651
 
569
652
  /** 设置 → 知识库:向量模型,照图像生成的做法(兼容接口的地址 + 模型,Key 单独存)。 */
@@ -688,6 +771,7 @@ export interface Bootstrap {
688
771
  }
689
772
 
690
773
  export type HobEvent =
774
+ | { type: "rules" }
691
775
  | { type: "desktop-control"; screen: string; taken: boolean }
692
776
  | { type: "desktop-screens"; screens: DesktopScreen[] }
693
777
  | ({ type: "page-live" } & PageLive)
@@ -696,7 +780,6 @@ export type HobEvent =
696
780
  | { type: "item"; assistantId: string; item: Item } // 新条目,或按 id 替换
697
781
  | { type: "delta"; assistantId: string; itemId: string; text: string } // 流式追加到 assistant 条目
698
782
  | { type: "job"; job: Job }
699
- | { type: "sediment-finished"; jobId: string }
700
783
  | { type: "delegation"; delegation: Delegation }
701
784
  | { type: "job_step"; jobId: string; step: JobStep } // 新的一步,或按 id 替换(输出流式更新、跑完补结果)
702
785
  | { type: "router-activity"; assistantId: string; activity: RouterActivity | null }