hob-bot 1.3.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.
@@ -15,6 +15,7 @@ import type {
15
15
  Theme,
16
16
  ThinkingLevel,
17
17
  ThinkingLevelMapSetting,
18
+ TokenPrice,
18
19
  } from "../../shared/types.ts";
19
20
  import { defaultSearch, normalizeSearch } from "../../shared/search.ts";
20
21
  import { checkImageSettings, normalizeImageSettings } from "./image.ts";
@@ -25,6 +26,7 @@ import type { ModelScores } from "../../shared/types.ts";
25
26
  import { isMemoryProvider, LORE_PLUGIN_VERSION, loreConnection, resolveMemoryProvider } from "./memory.ts";
26
27
  import { checkProvider } from "./providers.ts";
27
28
  import { checkTimezone } from "./time.ts";
29
+ import { checkPrice } from "./usage-prices.ts";
28
30
 
29
31
  const FILE = "settings.json";
30
32
  const THEMES: Theme[] = ["system", "light", "dark"];
@@ -192,6 +194,22 @@ function normalizeProviders(raw: unknown): ProviderSettings[] {
192
194
  return providers;
193
195
  }
194
196
 
197
+ /** 每百万 token 的单价的四项,键名固定。 */
198
+ function normalizePrices(raw: unknown): Record<string, TokenPrice> {
199
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
200
+ const prices: Record<string, TokenPrice> = {};
201
+ for (const [model, price] of Object.entries(raw as Record<string, unknown>)) {
202
+ const id = model.trim();
203
+ if (!id) continue;
204
+ try {
205
+ prices[id] = checkPrice(price);
206
+ } catch {
207
+ // 这一项坏了:跳过,不影响其他模型
208
+ }
209
+ }
210
+ return prices;
211
+ }
212
+
195
213
  /** 把读到的(或补丁里的)内容规整成合法设置:不合法的项退回默认值,时区不合法直接报错。 */
196
214
  function normalize(raw: unknown, base: Settings): Settings {
197
215
  const input = (raw ?? {}) as Partial<Settings>;
@@ -200,6 +218,7 @@ function normalize(raw: unknown, base: Settings): Settings {
200
218
  const work = (input.work ?? {}) as Partial<Settings["work"]>;
201
219
  const push = (input.push ?? {}) as Partial<Settings["push"]>;
202
220
  const theme = text(general.theme, base.general.theme) as Theme;
221
+ const prices = normalizePrices(input.usage?.prices);
203
222
  return {
204
223
  general: {
205
224
  theme: THEMES.includes(theme) ? theme : base.general.theme,
@@ -234,6 +253,7 @@ function normalize(raw: unknown, base: Settings): Settings {
234
253
  routerStallMinutes: int(work.routerStallMinutes, base.work.routerStallMinutes, 1, 24 * 60),
235
254
  },
236
255
  push: { email: text(push.email, base.push.email) },
256
+ ...(Object.keys(prices).length ? { usage: { prices } } : {}),
237
257
  };
238
258
  }
239
259
 
@@ -392,6 +412,7 @@ export class SettingsStore {
392
412
  providers: current.providers,
393
413
  work: { ...current.work, ...(patch.work ?? {}) },
394
414
  push: { ...current.push, ...(patch.push ?? {}) },
415
+ usage: current.usage,
395
416
  },
396
417
  current,
397
418
  );
@@ -466,6 +487,22 @@ export class SettingsStore {
466
487
  return this.get();
467
488
  }
468
489
 
490
+ /** 存一个模型的自定单价(price 为 null = 恢复默认)。不合法直接抛错,界面显示原因。 */
491
+ setUsagePrice(revision: number, model: string, price: TokenPrice | null): SettingsFile | undefined {
492
+ if (revision !== this.file.revision) return undefined;
493
+ const id = model.trim();
494
+ if (!id) throw new Error("未指定模型。");
495
+ const prices = { ...(this.settings.usage?.prices ?? {}) };
496
+ if (price === null) delete prices[id];
497
+ else prices[id] = checkPrice(price);
498
+ const settings = { ...this.settings };
499
+ if (Object.keys(prices).length) settings.usage = { prices };
500
+ else delete settings.usage;
501
+ this.file = { revision: this.file.revision + 1, settings };
502
+ this.write();
503
+ return this.get();
504
+ }
505
+
469
506
  /** 新建或改一个服务商(Key 不在这里,见 secrets.json)。 */
470
507
  setProvider(provider: ProviderSettings): SettingsFile {
471
508
  // 改已有的原地替换(列表顺序不跳),新的排到最后
@@ -5,7 +5,17 @@ import type { JobStep } from "../../shared/types.ts";
5
5
  import { showPath } from "./text.ts";
6
6
 
7
7
  /** 沉淀技能那一轮发给任务的话的开头:任务详情里这句显示成「沉淀技能」。 */
8
- export const SEDIMENT_PROMPT_HEAD = "这件事以后还会再做。回顾刚才的过程,把能复用的做法沉淀成技能";
8
+ export const SEDIMENT_PROMPT_HEAD = "回顾刚才的过程,看看有没有能复用的做法。这类事以后还会再做的,把做法写成技能";
9
+
10
+ /** 旧版用过的开头:已经存下来的任务记录里还是这些,任务详情照样显示成「沉淀技能」。 */
11
+ const LEGACY_SEDIMENT_HEADS = [
12
+ "这件事以后还会再做。回顾刚才的过程,把能复用的做法写成技能",
13
+ "这件事以后还会再做。回顾刚才的过程,把能复用的做法沉淀成技能",
14
+ ];
15
+
16
+ function isSedimentPrompt(text: string): boolean {
17
+ return text.startsWith(SEDIMENT_PROMPT_HEAD) || LEGACY_SEDIMENT_HEADS.some((head) => text.startsWith(head));
18
+ }
9
19
 
10
20
  /** transcript 里单步输出最多这么多字,超出的整段落盘,页面按路径去拉。 */
11
21
  export const STEP_OUTPUT_LIMIT = 20 * 1024;
@@ -14,7 +24,7 @@ export const STEP_EVENT_LIMIT = 4 * 1024;
14
24
  /** diff 只给看得清的一段,超长的改动不整段塞进接口。 */
15
25
  const EDIT_TEXT_LIMIT = 4_000;
16
26
 
17
- const AUTO_FOLLOW_UP = "你还没有调用 report";
27
+ const AUTO_FOLLOW_UP = "这一轮结束了,还没有交结果。";
18
28
 
19
29
  function oneLine(value: string, max = 120): string {
20
30
  const line = value.replace(/\s+/g, " ").trim();
@@ -313,12 +323,12 @@ export function deriveSteps(file: string, options: DeriveOptions = {}): JobStep[
313
323
 
314
324
  if (message.role === "user") {
315
325
  const text = textOfContent(message.content).trim();
316
- if (!text || text.startsWith(AUTO_FOLLOW_UP)) continue;
326
+ if (!text || text.startsWith(AUTO_FOLLOW_UP) || text.startsWith("你还没有调用 report")) continue;
317
327
  if (!seenUser) {
318
328
  seenUser = true; // 第一句是任务说明,详情头部已经有
319
329
  continue;
320
330
  }
321
- steps.push({ id: `u${message.timestamp ?? at}`, kind: "text", at, from: "user", text: text.startsWith(SEDIMENT_PROMPT_HEAD) ? "沉淀技能" : text });
331
+ steps.push({ id: `u${message.timestamp ?? at}`, kind: "text", at, from: "user", text: isSedimentPrompt(text) ? "沉淀技能" : text });
322
332
  }
323
333
  }
324
334
  return steps;
@@ -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
21
  result: Type.String({ description: "完整结果,按这个顺序写:结论;做了什么;验证了什么、怎么验证的;没验证或不确定的;需要用户决定的。带上数据、链接和文件路径,写给派活的助手。" }),
23
- files: Type.Optional(Type.Array(Type.String(), { description: "交给助手的文件(绝对路径),比如要给用户看的图" })),
22
+ files: Type.Optional(Type.Array(Type.String(), { description: "交给助手的文件(绝对路径),比如要给用户看的图。" })),
24
23
  }),
25
24
  execute: async (_id, params: any) => {
26
25
  // 不认识的结果不能当成已完成:退回让模型重新调用
@@ -46,11 +45,10 @@ export function registerAskTool(runtime: HobRuntime, jobId: string): ToolDefinit
46
45
  return {
47
46
  name: "ask_user",
48
47
  label: "询问用户",
49
- description: "问你的助手,等它回复:它会自己答,或者去问用户。用于用户定的规则要求先确认的事,或发送、删除、付费这类撤不回的事。其他拿不准的不问,按合理的假设做下去,把假设写进 result。",
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: "选项文字" }),
@@ -84,8 +82,7 @@ export function registerImageTool(runtime: HobRuntime, jobId: string): ToolDefin
84
82
  name: "generate_image",
85
83
  label: "生成图片",
86
84
  description:
87
- "按文字描述生成一张图片,返回图片的绝对路径(存在任务工作目录)。要把图给用户看,就把这个路径放进 report 的 files。",
88
- promptSnippet: "生成图片用 generate_image;要给用户看就把它放进 report 的 files。",
85
+ "按文字描述生成一张图片,返回图片的绝对路径(存在任务工作目录)。要给用户看,就把路径放进 report 的 files。",
89
86
  parameters: Type.Object({
90
87
  prompt: Type.String({ description: "要画什么,越具体越好" }),
91
88
  size: Type.Optional(