hob-bot 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +42 -23
  2. package/README.zh-CN.md +43 -24
  3. package/bin/hob.mjs +8 -2
  4. package/dist/web/assets/{DesktopView-lixZfbYr.js → DesktopView-3QDYo0UU.js} +1 -1
  5. package/dist/web/assets/index-Dzxrt9T3.js +283 -0
  6. package/dist/web/assets/index-FUtuyxyO.css +1 -0
  7. package/dist/web/index.html +2 -2
  8. package/dist/web/mock/readme-cover.svg +13 -0
  9. package/dist/web/mock/readme-page.html +7 -0
  10. package/package.json +2 -1
  11. package/server/src/actions.ts +2 -1
  12. package/server/src/assistant-skills.ts +2 -2
  13. package/server/src/boot.ts +8 -0
  14. package/server/src/config.ts +52 -11
  15. package/server/src/cron-text.ts +14 -1
  16. package/server/src/delete-skill.ts +19 -0
  17. package/server/src/desktop-tools.ts +6 -6
  18. package/server/src/files.ts +27 -7
  19. package/server/src/http.ts +41 -9
  20. package/server/src/job-view.ts +2 -2
  21. package/server/src/jobs.ts +108 -62
  22. package/server/src/knowledge-tools.ts +1 -1
  23. package/server/src/knowledge.ts +9 -4
  24. package/server/src/layout-cli.ts +25 -0
  25. package/server/src/layout.ts +749 -0
  26. package/server/src/main.ts +5 -0
  27. package/server/src/mcp.ts +3 -2
  28. package/server/src/memory.ts +2 -2
  29. package/server/src/now.ts +1 -1
  30. package/server/src/overview.ts +1 -2
  31. package/server/src/pages.ts +9 -7
  32. package/server/src/prompt.ts +47 -110
  33. package/server/src/push.ts +3 -2
  34. package/server/src/router-tools.ts +96 -40
  35. package/server/src/router.ts +5 -4
  36. package/server/src/routines.ts +177 -22
  37. package/server/src/runtime.ts +12 -1
  38. package/server/src/search.ts +1 -1
  39. package/server/src/secrets.ts +3 -2
  40. package/server/src/settings.ts +44 -6
  41. package/server/src/store.ts +123 -40
  42. package/server/src/transcript.ts +25 -7
  43. package/server/src/usage-prices.ts +166 -0
  44. package/server/src/usage.ts +291 -0
  45. package/server/src/worker-tools.ts +6 -9
  46. package/shared/types.ts +110 -3
  47. package/dist/web/assets/index-BZhge0qe.js +0 -267
  48. package/dist/web/assets/index-DIo1ArRa.css +0 -1
@@ -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 { archiveAssistantsDir, assistantDir, assistantJobsDir } from "./config.ts";
3
4
  import { newId } from "./ids.ts";
4
5
  import type {
5
6
  Approval,
@@ -49,14 +50,19 @@ export interface AssistantState {
49
50
 
50
51
  /**
51
52
  * HOB_HOME 下的全部 JSON / JSONL 数据。文件都小,直接同步读写,写入走临时文件 + rename。
53
+ *
54
+ * v2 布局里任务和审批、定时都跟着所属助手放(assistants/<id>/jobs|approvals.json|routines.json),
55
+ * 进不到目录的地方要知道 jobId 落在哪个助手下面,所以这里维护一份内存索引,启动时扫一遍、
56
+ * 新建任务时登记;不存索引文件,免得和实际目录对不上。
52
57
  */
53
58
  export class Store {
59
+ /** jobId → 任务目录(含归档助手目录里的) */
60
+ private jobIndex: Map<string, string> | null = null;
61
+
54
62
  constructor(readonly home: string) {}
55
63
 
56
64
  init(): void {
57
- for (const dir of ["assistants", "jobs", "uploads"]) {
58
- mkdirSync(join(this.home, dir), { recursive: true });
59
- }
65
+ mkdirSync(join(this.home, "assistants"), { recursive: true });
60
66
  if (!existsSync(join(this.home, "rules.json"))) {
61
67
  const changedAt = new Date().toISOString();
62
68
  this.saveGlobalRules([
@@ -80,7 +86,7 @@ export class Store {
80
86
 
81
87
  // ---------- 助手 ----------
82
88
  private assistantDir(id: string): string {
83
- return join(this.home, "assistants", id);
89
+ return assistantDir(this.home, id);
84
90
  }
85
91
 
86
92
  assistants(): Assistant[] {
@@ -110,14 +116,16 @@ export class Store {
110
116
  }
111
117
 
112
118
  /**
113
- * 归档助手:整个助手目录移进 archive/assistants/<id>-<时间>/。同盘 rename,不复制也不删数据,
114
- * 归档里的 profile.json 就是它原来的档案;它的定时存成目录里的 routines.json。
119
+ * 归档助手:整个助手目录(含自己的任务、定时、审批、文件)移进 archive/assistants/<id>-<时间>/。
120
+ * 同盘 rename,不复制也不删数据。
115
121
  */
116
122
  archiveAssistant(id: string, name: string, routines: Routine[] = []): string {
117
123
  if (routines.length) writeJsonAtomic(join(this.assistantDir(id), "routines.json"), routines);
118
- const target = join(this.home, "archive", "assistants", name);
124
+ const target = join(archiveAssistantsDir(this.home), name);
119
125
  mkdirSync(dirname(target), { recursive: true });
120
126
  renameSync(this.assistantDir(id), target);
127
+ // 任务跟着搬走了,索引要重建
128
+ this.jobIndex = null;
121
129
  return target;
122
130
  }
123
131
 
@@ -151,13 +159,45 @@ export class Store {
151
159
  appendFileSync(join(dir, "worklog.jsonl"), `${JSON.stringify(entry)}\n`);
152
160
  }
153
161
 
154
- // ---------- 定时 ----------
155
- private routinesFile(): string {
156
- return join(this.home, "routines.json");
162
+ // ---------- 定时和审批:跟着所属助手 ----------
163
+ /** 每个助手目录下的一份记录文件(含归档目录里的),有就给路径。 */
164
+ private recordFiles(name: string, archived: boolean): string[] {
165
+ const out: string[] = [];
166
+ const live = join(this.home, "assistants");
167
+ if (existsSync(live)) {
168
+ for (const id of readdirSync(live)) {
169
+ const file = join(live, id, name);
170
+ if (existsSync(file)) out.push(file);
171
+ }
172
+ }
173
+ if (!archived) return out;
174
+ const root = archiveAssistantsDir(this.home);
175
+ if (existsSync(root)) {
176
+ for (const folder of readdirSync(root)) {
177
+ const file = join(root, folder, name);
178
+ if (existsSync(file)) out.push(file);
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /** 写这个助手的记录文件:助手归档了就跟到归档目录,不然就地建。 */
185
+ private ownerFile(assistantId: string, name: string): string {
186
+ const live = this.assistantDir(assistantId);
187
+ if (existsSync(join(live, "profile.json"))) return join(live, name);
188
+ const root = archiveAssistantsDir(this.home);
189
+ if (existsSync(root)) {
190
+ for (const folder of readdirSync(root)) {
191
+ const dir = join(root, folder);
192
+ const profile = readJson<{ id?: unknown }>(join(dir, "profile.json"), {});
193
+ if (profile.id === assistantId) return join(dir, name);
194
+ }
195
+ }
196
+ return join(live, name);
157
197
  }
158
198
 
159
199
  routines(): Routine[] {
160
- return readJson<Routine[]>(this.routinesFile(), []);
200
+ return this.recordFiles("routines.json", false).flatMap((file) => readJson<Routine[]>(file, []));
161
201
  }
162
202
 
163
203
  routine(id: string): Routine | undefined {
@@ -165,22 +205,29 @@ export class Store {
165
205
  }
166
206
 
167
207
  saveRoutine(routine: Routine): void {
168
- const all = this.routines().filter((r) => r.id !== routine.id);
169
- all.push(routine);
170
- writeJsonAtomic(this.routinesFile(), all);
208
+ const target = this.ownerFile(routine.assistantId, "routines.json");
209
+ for (const file of this.recordFiles("routines.json", true)) {
210
+ const list = readJson<Routine[]>(file, []);
211
+ const next = list.filter((r) => r.id !== routine.id);
212
+ if (next.length !== list.length) writeJsonAtomic(file, next);
213
+ }
214
+ const list = readJson<Routine[]>(target, []).filter((r) => r.id !== routine.id);
215
+ list.push(routine);
216
+ writeJsonAtomic(target, list);
171
217
  }
172
218
 
219
+ /** 只从还在的助手里拿掉;归档目录里那份是历史,留着。 */
173
220
  deleteRoutine(id: string): void {
174
- writeJsonAtomic(this.routinesFile(), this.routines().filter((r) => r.id !== id));
221
+ for (const file of this.recordFiles("routines.json", false)) {
222
+ const list = readJson<Routine[]>(file, []);
223
+ const next = list.filter((r) => r.id !== id);
224
+ if (next.length !== list.length) writeJsonAtomic(file, next);
225
+ }
175
226
  }
176
227
 
177
228
  // ---------- 审批 ----------
178
- private approvalsFile(): string {
179
- return join(this.home, "approvals.json");
180
- }
181
-
182
229
  approvals(): Approval[] {
183
- return readJson<Approval[]>(this.approvalsFile(), []);
230
+ return this.recordFiles("approvals.json", true).flatMap((file) => readJson<Approval[]>(file, []));
184
231
  }
185
232
 
186
233
  approval(id: string): Approval | undefined {
@@ -188,14 +235,24 @@ export class Store {
188
235
  }
189
236
 
190
237
  saveApproval(approval: Approval): void {
191
- const all = this.approvals().filter((a) => a.id !== approval.id);
192
- all.push(approval);
193
- writeJsonAtomic(this.approvalsFile(), all);
238
+ const target = this.ownerFile(approval.assistantId, "approvals.json");
239
+ for (const file of this.recordFiles("approvals.json", true)) {
240
+ const list = readJson<Approval[]>(file, []);
241
+ const next = list.filter((a) => a.id !== approval.id);
242
+ if (next.length !== list.length) writeJsonAtomic(file, next);
243
+ }
244
+ const list = readJson<Approval[]>(target, []).filter((a) => a.id !== approval.id);
245
+ list.push(approval);
246
+ writeJsonAtomic(target, list);
194
247
  }
195
248
 
196
249
  // ---------- 委派 ----------
250
+ private hobFile(name: string): string {
251
+ return join(this.assistantDir("hob"), name);
252
+ }
253
+
197
254
  delegations(): Delegation[] {
198
- return readJson<Delegation[]>(join(this.home, "delegations.json"), []);
255
+ return readJson<Delegation[]>(this.hobFile("delegations.json"), []);
199
256
  }
200
257
 
201
258
  delegation(id: string): Delegation | undefined {
@@ -205,16 +262,12 @@ export class Store {
205
262
  saveDelegation(delegation: Delegation): void {
206
263
  const all = this.delegations().filter((item) => item.id !== delegation.id);
207
264
  all.push(delegation);
208
- writeJsonAtomic(join(this.home, "delegations.json"), all);
265
+ writeJsonAtomic(this.hobFile("delegations.json"), all);
209
266
  }
210
267
 
211
268
  // ---------- 草稿 ----------
212
- private draftsFile(): string {
213
- return join(this.home, "drafts.json");
214
- }
215
-
216
269
  drafts(): Draft[] {
217
- return readJson<Draft[]>(this.draftsFile(), []);
270
+ return readJson<Draft[]>(this.hobFile("drafts.json"), []);
218
271
  }
219
272
 
220
273
  draft(id: string): Draft | undefined {
@@ -224,31 +277,61 @@ export class Store {
224
277
  saveDraft(draft: Draft): void {
225
278
  const all = this.drafts().filter((d) => d.id !== draft.id);
226
279
  all.push(draft);
227
- writeJsonAtomic(this.draftsFile(), all);
280
+ writeJsonAtomic(this.hobFile("drafts.json"), all);
228
281
  }
229
282
 
230
283
  // ---------- 任务 ----------
284
+ /** 所有任务目录的根:在的助手和归档的助手都算。 */
285
+ private jobRoots(): string[] {
286
+ const out: string[] = [];
287
+ const live = join(this.home, "assistants");
288
+ if (existsSync(live)) for (const id of readdirSync(live)) out.push(assistantJobsDir(this.home, id));
289
+ const archived = archiveAssistantsDir(this.home);
290
+ if (existsSync(archived)) for (const folder of readdirSync(archived)) out.push(join(archived, folder, "jobs"));
291
+ return out.filter((dir) => existsSync(dir));
292
+ }
293
+
294
+ private rebuildJobIndex(): Map<string, string> {
295
+ const index = new Map<string, string>();
296
+ for (const root of this.jobRoots()) {
297
+ for (const id of readdirSync(root).sort()) {
298
+ const dir = join(root, id);
299
+ if (existsSync(join(dir, "job.json"))) index.set(id, dir);
300
+ }
301
+ }
302
+ this.jobIndex = index;
303
+ return index;
304
+ }
305
+
306
+ private findJobDir(id: string): string | undefined {
307
+ const hit = (this.jobIndex ?? this.rebuildJobIndex()).get(id);
308
+ if (hit) return hit;
309
+ return this.rebuildJobIndex().get(id);
310
+ }
311
+
231
312
  jobDir(id: string): string {
232
- return join(this.home, "jobs", id);
313
+ return this.findJobDir(id) ?? join(this.home, "assistants", "hob", "jobs", id);
233
314
  }
234
315
 
235
316
  jobs(): Job[] {
236
- const root = join(this.home, "jobs");
237
- if (!existsSync(root)) return [];
238
317
  const out: Job[] = [];
239
- for (const id of readdirSync(root)) {
240
- const job = readJson<Job | null>(join(root, id, "job.json"), null);
318
+ for (const dir of this.rebuildJobIndex().values()) {
319
+ const job = readJson<Job | null>(join(dir, "job.json"), null);
241
320
  if (job) out.push(job);
242
321
  }
243
322
  return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
244
323
  }
245
324
 
246
325
  job(id: string): Job | undefined {
247
- return readJson<Job | null>(join(this.jobDir(id), "job.json"), null) ?? undefined;
326
+ const dir = this.findJobDir(id);
327
+ return dir ? readJson<Job | null>(join(dir, "job.json"), null) ?? undefined : undefined;
248
328
  }
249
329
 
250
330
  saveJob(job: Job): void {
251
- writeJsonAtomic(join(this.jobDir(job.id), "job.json"), job);
331
+ const dir = this.findJobDir(job.id) ?? join(assistantJobsDir(this.home, job.assistantId), job.id);
332
+ writeJsonAtomic(join(dir, "job.json"), job);
333
+ this.jobIndex ??= new Map();
334
+ this.jobIndex.set(job.id, dir);
252
335
  }
253
336
 
254
337
  writeReport(jobId: string, text: string): string {
@@ -285,10 +368,10 @@ export class Store {
285
368
  */
286
369
  migrateMemories(): void {
287
370
  type OldDraft = Draft & { memories?: string[]; carry: Draft["carry"] & { memories?: number } };
288
- const drafts = readJson<OldDraft[]>(this.draftsFile(), []);
371
+ const drafts = readJson<OldDraft[]>(this.hobFile("drafts.json"), []);
289
372
  if (drafts.some((draft) => !Array.isArray(draft.instructions))) {
290
373
  writeJsonAtomic(
291
- this.draftsFile(),
374
+ this.hobFile("drafts.json"),
292
375
  drafts.map(({ memories, carry: { memories: _carried, ...carry }, ...draft }) => ({
293
376
  ...draft,
294
377
  instructions: draft.instructions ?? memories ?? [],
@@ -1,11 +1,22 @@
1
1
  // 任务过程(transcript):从任务的 worker.jsonl 推导步骤列表,实时事件和落盘推导共用一套拼装。
2
2
  import { existsSync, readFileSync } from "node:fs";
3
+ import { isAbsolute } from "node:path";
3
4
  import { commandSummary } from "../../shared/command.ts";
4
5
  import type { JobStep } from "../../shared/types.ts";
5
6
  import { showPath } from "./text.ts";
6
7
 
7
8
  /** 沉淀技能那一轮发给任务的话的开头:任务详情里这句显示成「沉淀技能」。 */
8
- export const SEDIMENT_PROMPT_HEAD = "这件事以后还会再做。回顾刚才的过程,把能复用的做法沉淀成技能";
9
+ export const SEDIMENT_PROMPT_HEAD = "回顾刚才的过程,看看有没有能复用的做法。这类事以后还会再做的,把做法写成技能";
10
+
11
+ /** 旧版用过的开头:已经存下来的任务记录里还是这些,任务详情照样显示成「沉淀技能」。 */
12
+ const LEGACY_SEDIMENT_HEADS = [
13
+ "这件事以后还会再做。回顾刚才的过程,把能复用的做法写成技能",
14
+ "这件事以后还会再做。回顾刚才的过程,把能复用的做法沉淀成技能",
15
+ ];
16
+
17
+ function isSedimentPrompt(text: string): boolean {
18
+ return text.startsWith(SEDIMENT_PROMPT_HEAD) || LEGACY_SEDIMENT_HEADS.some((head) => text.startsWith(head));
19
+ }
9
20
 
10
21
  /** transcript 里单步输出最多这么多字,超出的整段落盘,页面按路径去拉。 */
11
22
  export const STEP_OUTPUT_LIMIT = 20 * 1024;
@@ -14,7 +25,7 @@ export const STEP_EVENT_LIMIT = 4 * 1024;
14
25
  /** diff 只给看得清的一段,超长的改动不整段塞进接口。 */
15
26
  const EDIT_TEXT_LIMIT = 4_000;
16
27
 
17
- const AUTO_FOLLOW_UP = "你还没有调用 report";
28
+ const AUTO_FOLLOW_UP = "这一轮结束了,还没有交结果。";
18
29
 
19
30
  function oneLine(value: string, max = 120): string {
20
31
  const line = value.replace(/\s+/g, " ").trim();
@@ -38,8 +49,10 @@ function clipEdit(edits: unknown): { oldText: string; newText: string }[] {
38
49
  }
39
50
 
40
51
  /** 只留详情页要看的那几个参数(write 的正文不整段传,只给长度)。 */
41
- export function toolArgs(name: string, raw: Record<string, unknown>, cwd = ""): Record<string, unknown> {
42
- const path = showPath(String(raw.path ?? ""), cwd);
52
+ export function toolArgs(name: string, raw: Record<string, unknown>, cwd = "", resolvePath?: (path: string) => string): Record<string, unknown> {
53
+ const given = String(raw.path ?? "");
54
+ // 迁移前写进会话的绝对路径先按 moves.json 换成新位置再和 cwd 比:老步骤照样显示成短文件名
55
+ const path = showPath(isAbsolute(given) && resolvePath ? resolvePath(given) : given, cwd);
43
56
  switch (name) {
44
57
  case "bash":
45
58
  return { command: String(raw.command ?? "") };
@@ -197,11 +210,13 @@ export interface ToolStepInput {
197
210
  running?: boolean;
198
211
  /** MCP 工具的显示名映射(转义后的服务名 → 配置里的原名) */
199
212
  mcpServers?: Record<string, string>;
213
+ /** 会话里的老绝对路径换到新位置(迁移后显示老步骤用) */
214
+ resolvePath?: (path: string) => string;
200
215
  }
201
216
 
202
217
  /** 一次工具调用 → 一个步骤(还没有结果)。实时事件和落盘推导都用它,页面按 id 合并。 */
203
218
  export function toolStep(input: ToolStepInput): JobStep {
204
- const args = toolArgs(input.name, input.args, input.cwd ?? "");
219
+ const args = toolArgs(input.name, input.args, input.cwd ?? "", input.resolvePath);
205
220
  const mcp = mcpToolDisplay(input.name, input.mcpServers);
206
221
  return {
207
222
  id: input.id,
@@ -245,6 +260,8 @@ export interface DeriveOptions {
245
260
  save?: (stepId: string, text: string) => string;
246
261
  /** MCP 工具的显示名映射(转义后的服务名 → 配置里的原名) */
247
262
  mcpServers?: Record<string, string>;
263
+ /** 会话里的老绝对路径换到新位置(迁移后显示老步骤用) */
264
+ resolvePath?: (path: string) => string;
248
265
  }
249
266
 
250
267
  /** 从任务会话文件推导整个过程:说明文字、思考摘要、工具调用(含结果)。 */
@@ -290,6 +307,7 @@ export function deriveSteps(file: string, options: DeriveOptions = {}): JobStep[
290
307
  at,
291
308
  cwd: options.cwd ?? "",
292
309
  mcpServers: options.mcpServers,
310
+ resolvePath: options.resolvePath,
293
311
  running: true,
294
312
  });
295
313
  toolIndex.set(step.id, steps.length);
@@ -313,12 +331,12 @@ export function deriveSteps(file: string, options: DeriveOptions = {}): JobStep[
313
331
 
314
332
  if (message.role === "user") {
315
333
  const text = textOfContent(message.content).trim();
316
- if (!text || text.startsWith(AUTO_FOLLOW_UP)) continue;
334
+ if (!text || text.startsWith(AUTO_FOLLOW_UP) || text.startsWith("你还没有调用 report")) continue;
317
335
  if (!seenUser) {
318
336
  seenUser = true; // 第一句是任务说明,详情头部已经有
319
337
  continue;
320
338
  }
321
- steps.push({ id: `u${message.timestamp ?? at}`, kind: "text", at, from: "user", text: text.startsWith(SEDIMENT_PROMPT_HEAD) ? "沉淀技能" : text });
339
+ steps.push({ id: `u${message.timestamp ?? at}`, kind: "text", at, from: "user", text: isSedimentPrompt(text) ? "沉淀技能" : text });
322
340
  }
323
341
  }
324
342
  return steps;
@@ -0,0 +1,166 @@
1
+ // 模型单价:默认从 models.dev 拉,一天最多更新一次;存在 HOB_HOME/cache/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
+ import { cacheDir } from "./config.ts";
6
+
7
+ const SOURCE = "https://models.dev/api.json";
8
+ const FILE = "usage-prices.json";
9
+ const DAY = 24 * 3600 * 1000;
10
+ /** 失败后这么久不再试:价格表本来就一天更新一次,失败时别让每次打开页面都等一次超时。 */
11
+ const RETRY = 10 * 60 * 1000;
12
+ const TIMEOUT = 10_000;
13
+
14
+ /** 模型厂商自己那一家:同一个模型 id 出现在多个服务商里时优先它。 */
15
+ const VENDOR_PROVIDERS = new Set(["openai", "anthropic", "deepseek", "google", "xai", "mistral", "zhipuai", "moonshotai"]);
16
+
17
+ /** HOB_HOME/cache/usage-prices.json:拉到的单价 + 上次更新和上次尝试的时间。 */
18
+ interface PriceFile {
19
+ fetchedAt: string | null;
20
+ attemptAt: string | null;
21
+ error: string | null;
22
+ prices: Record<string, TokenPrice>;
23
+ }
24
+
25
+ const EMPTY: PriceFile = { fetchedAt: null, attemptAt: null, error: null, prices: {} };
26
+
27
+ function readFile(home: string): PriceFile {
28
+ const path = join(cacheDir(home), FILE);
29
+ if (!existsSync(path)) return EMPTY;
30
+ try {
31
+ const stored = JSON.parse(readFileSync(path, "utf8")) as Partial<PriceFile>;
32
+ const prices = stored.prices && typeof stored.prices === "object" ? stored.prices : {};
33
+ return {
34
+ fetchedAt: typeof stored.fetchedAt === "string" ? stored.fetchedAt : null,
35
+ attemptAt: typeof stored.attemptAt === "string" ? stored.attemptAt : null,
36
+ error: typeof stored.error === "string" ? stored.error : null,
37
+ prices: Object.fromEntries(Object.entries(prices).filter(([, price]) => isPrice(price))),
38
+ };
39
+ } catch {
40
+ return EMPTY;
41
+ }
42
+ }
43
+
44
+ function isPrice(value: unknown): value is TokenPrice {
45
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
46
+ const price = value as Record<string, unknown>;
47
+ return ["input", "output", "cacheRead", "cacheWrite"].every((key) => typeof price[key] === "number" && Number.isFinite(price[key]) && (price[key] as number) >= 0);
48
+ }
49
+
50
+ /** 用户填的单价:四项都是大于等于 0 的数字,不合法说清哪一项。 */
51
+ export function checkPrice(raw: unknown): TokenPrice {
52
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("单价要填四项:输入、输出、缓存读、缓存写。");
53
+ const input = raw as Record<string, unknown>;
54
+ const names: Record<keyof TokenPrice, string> = { input: "输入", output: "输出", cacheRead: "缓存读", cacheWrite: "缓存写" };
55
+ const out = {} as TokenPrice;
56
+ for (const key of Object.keys(names) as (keyof TokenPrice)[]) {
57
+ const value = input[key];
58
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
59
+ throw new Error(`「${names[key]}」的单价要填大于等于 0 的数字(美元 / 百万 token)。`);
60
+ }
61
+ out[key] = Number(value.toFixed(6));
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function failureReason(error: unknown): string {
67
+ if (error instanceof Error) {
68
+ if (error.name === "TimeoutError" || error.name === "AbortError") return "请求超时";
69
+ return error.message;
70
+ }
71
+ return String(error);
72
+ }
73
+
74
+ /**
75
+ * models.dev 的整份数据 → 模型 id → 单价。
76
+ * 同一个模型 id 在多个服务商里都有时,优先模型厂商自己那家(`canonical_model_id` 指回自己,或 id 在厂商名单里)。
77
+ */
78
+ export function indexPrices(raw: unknown): Record<string, TokenPrice> {
79
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("返回的不是服务商列表");
80
+ const candidates = new Map<string, { vendor: boolean; price: TokenPrice }[]>();
81
+ for (const [providerId, provider] of Object.entries(raw as Record<string, unknown>)) {
82
+ const models = (provider as { models?: unknown } | null)?.models;
83
+ if (!models || typeof models !== "object" || Array.isArray(models)) continue;
84
+ for (const [modelId, model] of Object.entries(models as Record<string, unknown>)) {
85
+ const cost = (model as { cost?: unknown } | null)?.cost as Record<string, unknown> | null | undefined;
86
+ if (!cost || typeof cost.input !== "number" || typeof cost.output !== "number") continue;
87
+ const canonical = (model as { canonical_model_id?: unknown }).canonical_model_id;
88
+ const vendor = (typeof canonical === "string" && canonical.split("/")[0] === providerId) || VENDOR_PROVIDERS.has(providerId);
89
+ const price: TokenPrice = {
90
+ input: cost.input,
91
+ output: cost.output,
92
+ cacheRead: typeof cost.cache_read === "number" ? cost.cache_read : 0,
93
+ cacheWrite: typeof cost.cache_write === "number" ? cost.cache_write : 0,
94
+ };
95
+ const list = candidates.get(modelId) ?? [];
96
+ list.push({ vendor, price });
97
+ candidates.set(modelId, list);
98
+ }
99
+ }
100
+ const prices: Record<string, TokenPrice> = Object.fromEntries([...candidates].map(([modelId, list]) => [modelId, (list.find((item) => item.vendor) ?? list[0]).price]));
101
+ if (!Object.keys(prices).length) throw new Error("返回里没有模型价格");
102
+ return prices;
103
+ }
104
+
105
+ /**
106
+ * 价格表:一天最多更新一次;拉不到就留着上次的,并把原因写下来给页面显示。
107
+ * `fetchImpl` 和 `now` 只为测试注入(不连真实网络)。
108
+ */
109
+ export class UsagePrices {
110
+ private file: PriceFile;
111
+ private loading?: Promise<void>;
112
+
113
+ constructor(private readonly options: { home: string; fetchImpl?: typeof fetch; now?: () => number }) {
114
+ this.file = readFile(options.home);
115
+ }
116
+
117
+ private get now(): number {
118
+ return this.options.now?.() ?? Date.now();
119
+ }
120
+
121
+ /** 需要更新时拉一次(同一时刻只拉一次);要更新时会把结果写进 HOB_HOME。 */
122
+ async ensure(): Promise<void> {
123
+ this.loading ??= this.refresh().finally(() => {
124
+ this.loading = undefined;
125
+ });
126
+ await this.loading;
127
+ }
128
+
129
+ /** 现在用的价格和上次更新的状态。 */
130
+ table(): { fetchedAt: string | null; error: string | null; prices: Record<string, TokenPrice> } {
131
+ return { fetchedAt: this.file.fetchedAt, error: this.file.error, prices: this.file.prices };
132
+ }
133
+
134
+ private async refresh(): Promise<void> {
135
+ const now = this.now;
136
+ const fetchedAt = this.file.fetchedAt ? Date.parse(this.file.fetchedAt) : 0;
137
+ if (now - fetchedAt < DAY) return;
138
+ const attemptAt = this.file.attemptAt ? Date.parse(this.file.attemptAt) : 0;
139
+ if (attemptAt > fetchedAt && now - attemptAt < RETRY) return;
140
+ try {
141
+ this.file = { fetchedAt: new Date(now).toISOString(), attemptAt: new Date(now).toISOString(), error: null, prices: indexPrices(await this.fetchSource()) };
142
+ } catch (error) {
143
+ this.file = { ...this.file, attemptAt: new Date(now).toISOString(), error: failureReason(error) };
144
+ }
145
+ this.write();
146
+ }
147
+
148
+ private async fetchSource(): Promise<unknown> {
149
+ const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
150
+ const response = await fetchImpl(SOURCE, { signal: AbortSignal.timeout(TIMEOUT), headers: { accept: "application/json" } });
151
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
152
+ try {
153
+ return await response.json();
154
+ } catch {
155
+ throw new Error("返回的不是有效 JSON");
156
+ }
157
+ }
158
+
159
+ private write(): void {
160
+ const path = join(cacheDir(this.options.home), FILE);
161
+ mkdirSync(dirname(path), { recursive: true });
162
+ const tmp = `${path}.${process.pid}.tmp`;
163
+ writeFileSync(tmp, `${JSON.stringify(this.file, null, 2)}\n`);
164
+ renameSync(tmp, path);
165
+ }
166
+ }