hob-bot 1.4.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.
@@ -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,5 +1,6 @@
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";
@@ -48,8 +49,10 @@ function clipEdit(edits: unknown): { oldText: string; newText: string }[] {
48
49
  }
49
50
 
50
51
  /** 只留详情页要看的那几个参数(write 的正文不整段传,只给长度)。 */
51
- export function toolArgs(name: string, raw: Record<string, unknown>, cwd = ""): Record<string, unknown> {
52
- 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);
53
56
  switch (name) {
54
57
  case "bash":
55
58
  return { command: String(raw.command ?? "") };
@@ -207,11 +210,13 @@ export interface ToolStepInput {
207
210
  running?: boolean;
208
211
  /** MCP 工具的显示名映射(转义后的服务名 → 配置里的原名) */
209
212
  mcpServers?: Record<string, string>;
213
+ /** 会话里的老绝对路径换到新位置(迁移后显示老步骤用) */
214
+ resolvePath?: (path: string) => string;
210
215
  }
211
216
 
212
217
  /** 一次工具调用 → 一个步骤(还没有结果)。实时事件和落盘推导都用它,页面按 id 合并。 */
213
218
  export function toolStep(input: ToolStepInput): JobStep {
214
- const args = toolArgs(input.name, input.args, input.cwd ?? "");
219
+ const args = toolArgs(input.name, input.args, input.cwd ?? "", input.resolvePath);
215
220
  const mcp = mcpToolDisplay(input.name, input.mcpServers);
216
221
  return {
217
222
  id: input.id,
@@ -255,6 +260,8 @@ export interface DeriveOptions {
255
260
  save?: (stepId: string, text: string) => string;
256
261
  /** MCP 工具的显示名映射(转义后的服务名 → 配置里的原名) */
257
262
  mcpServers?: Record<string, string>;
263
+ /** 会话里的老绝对路径换到新位置(迁移后显示老步骤用) */
264
+ resolvePath?: (path: string) => string;
258
265
  }
259
266
 
260
267
  /** 从任务会话文件推导整个过程:说明文字、思考摘要、工具调用(含结果)。 */
@@ -300,6 +307,7 @@ export function deriveSteps(file: string, options: DeriveOptions = {}): JobStep[
300
307
  at,
301
308
  cwd: options.cwd ?? "",
302
309
  mcpServers: options.mcpServers,
310
+ resolvePath: options.resolvePath,
303
311
  running: true,
304
312
  });
305
313
  toolIndex.set(step.id, steps.length);
@@ -1,7 +1,8 @@
1
- // 模型单价:默认从 models.dev 拉,一天最多更新一次;存在 HOB_HOME/usage-prices.json,拉不到就用上次的。
1
+ // 模型单价:默认从 models.dev 拉,一天最多更新一次;存在 HOB_HOME/cache/usage-prices.json,拉不到就用上次的。
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { TokenPrice } from "../../shared/types.ts";
5
+ import { cacheDir } from "./config.ts";
5
6
 
6
7
  const SOURCE = "https://models.dev/api.json";
7
8
  const FILE = "usage-prices.json";
@@ -13,7 +14,7 @@ const TIMEOUT = 10_000;
13
14
  /** 模型厂商自己那一家:同一个模型 id 出现在多个服务商里时优先它。 */
14
15
  const VENDOR_PROVIDERS = new Set(["openai", "anthropic", "deepseek", "google", "xai", "mistral", "zhipuai", "moonshotai"]);
15
16
 
16
- /** HOB_HOME/usage-prices.json:拉到的单价 + 上次更新和上次尝试的时间。 */
17
+ /** HOB_HOME/cache/usage-prices.json:拉到的单价 + 上次更新和上次尝试的时间。 */
17
18
  interface PriceFile {
18
19
  fetchedAt: string | null;
19
20
  attemptAt: string | null;
@@ -24,7 +25,7 @@ interface PriceFile {
24
25
  const EMPTY: PriceFile = { fetchedAt: null, attemptAt: null, error: null, prices: {} };
25
26
 
26
27
  function readFile(home: string): PriceFile {
27
- const path = join(home, FILE);
28
+ const path = join(cacheDir(home), FILE);
28
29
  if (!existsSync(path)) return EMPTY;
29
30
  try {
30
31
  const stored = JSON.parse(readFileSync(path, "utf8")) as Partial<PriceFile>;
@@ -156,7 +157,7 @@ export class UsagePrices {
156
157
  }
157
158
 
158
159
  private write(): void {
159
- const path = join(this.options.home, FILE);
160
+ const path = join(cacheDir(this.options.home), FILE);
160
161
  mkdirSync(dirname(path), { recursive: true });
161
162
  const tmp = `${path}.${process.pid}.tmp`;
162
163
  writeFileSync(tmp, `${JSON.stringify(this.file, null, 2)}\n`);
@@ -1,6 +1,6 @@
1
1
  // 消耗:不另外记账,直接从会话文件里 assistant 消息的 usage 统计(见 docs/architecture.md“消耗”)。
2
2
  import { createReadStream, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import type { TokenPrice, UsageAssistant, UsageModelPrice, UsagePrices, UsageRow, UsageSource, UsageView } from "../../shared/types.ts";
5
5
  import type { SettingsStore } from "./settings.ts";
6
6
  import type { Store } from "./store.ts";
@@ -106,6 +106,17 @@ function readDir(path: string): string[] {
106
106
  return existsSync(path) ? readdirSync(path) : [];
107
107
  }
108
108
 
109
+ /** 所有任务的会话目录:在的助手和归档的助手都算。 */
110
+ function jobSessionDirs(home: string): string[] {
111
+ const out: string[] = [];
112
+ const roots = [
113
+ ...readDir(join(home, "assistants")).map((id) => join(home, "assistants", id, "jobs")),
114
+ ...readDir(join(home, "archive", "assistants")).map((folder) => join(home, "archive", "assistants", folder, "jobs")),
115
+ ];
116
+ for (const root of roots) for (const id of readDir(root)) out.push(join(root, id));
117
+ return out;
118
+ }
119
+
109
120
  function readJson(path: string): Record<string, unknown> | undefined {
110
121
  try {
111
122
  return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
@@ -228,12 +239,11 @@ export class UsageService {
228
239
  if (!assistants.has(id)) assistants.set(id, { id, name, archived: true });
229
240
  await this.sync({ file, assistantId: id, assistantName: name, source: "chat" }, seen);
230
241
  }
231
- for (const jobId of readDir(join(home, "jobs"))) {
232
- const file = join(home, "jobs", jobId, "worker.jsonl");
242
+ for (const jobDir of jobSessionDirs(home)) {
243
+ const file = join(jobDir, "worker.jsonl");
233
244
  if (!existsSync(file)) continue;
234
- // 没有缓存时才读 job.json:任务的所属助手建好就不变
235
245
  const cached = this.files.get(file);
236
- const job = cached ? undefined : this.options.store.job(jobId);
246
+ const job = cached ? undefined : this.options.store.job(basename(jobDir));
237
247
  const assistantId = cached?.assistantId ?? job?.assistantId;
238
248
  if (!assistantId) continue;
239
249
  const live = this.options.store.assistant(assistantId);
package/shared/types.ts CHANGED
@@ -225,6 +225,8 @@ export interface Assistant {
225
225
  prop?: CustomProp;
226
226
  primary: boolean; // 只有 hob 为 true
227
227
  pinned: boolean; // 左栏顶部大头像
228
+ /** 置顶区里的位置:小的在前,拖动头像换位置时改它;没排过的排在排过的后面,再按创建时间 */
229
+ pinOrder?: number;
228
230
  hidden: boolean; // 收起的助手
229
231
  notify: boolean; // 需要确认时通知我
230
232
  instructions: string; // 补充说明:用户可直接编辑该助手需要知道的专属事实
@@ -275,16 +277,40 @@ export interface RoutineRun {
275
277
  tokens?: number; // 技能二期:输入加输出 token
276
278
  }
277
279
 
280
+ export interface RoutineCheck {
281
+ script: string; // 到点先跑的 bash 脚本:没有输出就跳过这一次
282
+ then: "notify" | "job"; // 有输出时:notify 把输出作为提醒发到对话;job 带着输出开始任务
283
+ }
284
+
285
+ export interface RoutineChecks {
286
+ total: number; // 检查了几次
287
+ hits: number; // 触发了几次
288
+ failing?: string; // 最近一次失败的原因,下次成功就清掉
289
+ lastAt?: Iso; // 上次检查的时间
290
+ }
291
+
292
+ /** 定时到点(或点「立即运行」)这一次的结果。 */
293
+ export interface RoutineTick {
294
+ outcome: "job" | "notify" | "quiet" | "failed"; // job 开了任务;notify 已发到对话;quiet 没变化;failed 检查失败
295
+ job?: Job;
296
+ reason?: string; // failed 时的原因
297
+ }
298
+
278
299
  export interface Routine {
279
300
  id: string;
280
301
  assistantId: string;
281
302
  name: string; // 如 "每日简报"
282
303
  instruction: string; // 要做什么
283
- schedules: string[]; // cron 表达式,如 "0 9 * * *";可多个("再加一个")
304
+ schedules: string[]; // cron 表达式(如 "0 9 * * *")或具体时间(如 "2026-11-08T09:00");可多个("再加一个")
284
305
  active: boolean;
285
306
  createdAt: Iso;
286
307
  runs: RoutineRun[]; // 最近 20 次,新的在前
287
308
  skill?: string; // 绑定这个助手自己的技能(AssistantSkill.name):每次执行按它做
309
+ check?: RoutineCheck; // 到点先跑脚本,多数时候没变化就不开任务
310
+ checks?: RoutineChecks; // 检查计数和最近一次失败的原因
311
+ once?: boolean; // 做成一次就停:没有检查脚本的跑完就停,有检查的第一次有输出就停
312
+ stoppedAt?: Iso; // 上次停下的时间
313
+ stopReason?: "hit" | "finished"; // hit=按 once 停了;finished=所有执行时间都跑完了
288
314
  }
289
315
 
290
316
  export interface Delegation {