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.
- package/README.md +19 -8
- package/README.zh-CN.md +19 -8
- package/bin/hob.mjs +8 -2
- package/dist/web/assets/{DesktopView-A11jmUwu.js → DesktopView-3QDYo0UU.js} +1 -1
- package/dist/web/assets/index-Dzxrt9T3.js +283 -0
- package/dist/web/assets/index-FUtuyxyO.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/server/src/actions.ts +2 -1
- package/server/src/boot.ts +8 -0
- package/server/src/config.ts +52 -11
- package/server/src/cron-text.ts +14 -1
- package/server/src/desktop-tools.ts +2 -1
- package/server/src/files.ts +27 -7
- package/server/src/http.ts +17 -5
- package/server/src/jobs.ts +3 -0
- package/server/src/knowledge.ts +9 -4
- package/server/src/layout-cli.ts +25 -0
- package/server/src/layout.ts +749 -0
- package/server/src/main.ts +3 -0
- package/server/src/mcp.ts +3 -2
- package/server/src/pages.ts +5 -3
- package/server/src/prompt.ts +4 -3
- package/server/src/push.ts +3 -2
- package/server/src/router-tools.ts +50 -14
- package/server/src/router.ts +3 -3
- package/server/src/routines.ts +171 -21
- package/server/src/search.ts +1 -1
- package/server/src/secrets.ts +3 -2
- package/server/src/settings.ts +7 -6
- package/server/src/store.ts +123 -40
- package/server/src/transcript.ts +11 -3
- package/server/src/usage-prices.ts +5 -4
- package/server/src/usage.ts +15 -5
- package/shared/types.ts +27 -1
- package/dist/web/assets/index-B-YlVbpo.js +0 -270
- package/dist/web/assets/index-DkSQ-ygR.css +0 -1
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// HOB_HOME 的目录布局(v2)和 v1 → v2 的一次性迁移。
|
|
2
|
+
//
|
|
3
|
+
// v2 按归属放:system/ 放配置和凭据,cache/ 放删了能重建的东西,assistants/<id>/ 放这个助手自己的
|
|
4
|
+
// 档案、任务、定时、文件和对话。迁移在启动时自动跑一次,中途断掉也能从备份恢复后重跑;
|
|
5
|
+
// 会话文件一个字节都不改,老路径靠 system/moves.json 在取文件时解析。
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, statfsSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
LAYOUT_VERSION,
|
|
11
|
+
archiveAssistantsDir,
|
|
12
|
+
assistantDir,
|
|
13
|
+
assistantFilesDir,
|
|
14
|
+
cacheDir,
|
|
15
|
+
layoutFile,
|
|
16
|
+
movesFile,
|
|
17
|
+
systemDir,
|
|
18
|
+
} from "./config.ts";
|
|
19
|
+
|
|
20
|
+
/** 迁移前至少要留出的余量:备份之外还要写日志和后续运行。 */
|
|
21
|
+
const SPACE_MARGIN = 64 * 1024 * 1024;
|
|
22
|
+
const BACKUP_PREFIX = "backup-v1-";
|
|
23
|
+
const BACKUP_MARKER = ".hob-backup.json";
|
|
24
|
+
/** 一个人都不认的文件归主助手。 */
|
|
25
|
+
const FALLBACK_OWNER = "hob";
|
|
26
|
+
|
|
27
|
+
/** 只有 v2 布局才有的根条目:根上出现别的东西就说明还是 v1。 */
|
|
28
|
+
const V2_ROOT_ENTRIES = new Set(["layout.json", "system", "cache", "knowledge", "skills", "assistants", "archive"]);
|
|
29
|
+
|
|
30
|
+
type MoveKind = "dir" | "file";
|
|
31
|
+
|
|
32
|
+
export interface PlannedMove {
|
|
33
|
+
from: string;
|
|
34
|
+
to: string;
|
|
35
|
+
kind: MoveKind;
|
|
36
|
+
/** 归属怎么判的 */
|
|
37
|
+
reason: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PlannedSplit {
|
|
41
|
+
/** 根上的老文件 */
|
|
42
|
+
from: string;
|
|
43
|
+
to: string;
|
|
44
|
+
items: { id?: unknown }[];
|
|
45
|
+
/** 目标已有一份时按 id 合并(归档目录里已经有 routines.json) */
|
|
46
|
+
merge?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MigrationPlan {
|
|
50
|
+
moves: PlannedMove[];
|
|
51
|
+
splits: PlannedSplit[];
|
|
52
|
+
orphans: string[];
|
|
53
|
+
/** 页面、图片、上传的归属判定结果 */
|
|
54
|
+
owners: { path: string; assistantId: string; reason: string }[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MigrationSummary {
|
|
58
|
+
status: "current" | "created" | "migrated" | "dry-run";
|
|
59
|
+
backupDir?: string;
|
|
60
|
+
files: number;
|
|
61
|
+
jobs: number;
|
|
62
|
+
orphans: string[];
|
|
63
|
+
logPath?: string;
|
|
64
|
+
durationMs: number;
|
|
65
|
+
plan?: MigrationPlan;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface MigrateOptions {
|
|
69
|
+
/** 只打印计划,不动磁盘 */
|
|
70
|
+
dryRun?: boolean;
|
|
71
|
+
/** 测试用:覆盖检测到的可用空间(字节) */
|
|
72
|
+
availableBytes?: number;
|
|
73
|
+
log?: (line: string) => void;
|
|
74
|
+
/** 测试用的钩子:改坏备份、模拟断电 */
|
|
75
|
+
hooks?: {
|
|
76
|
+
afterBackup?: (backupDir: string) => void;
|
|
77
|
+
afterMove?: (index: number, total: number) => void;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------- 小工具 ----------
|
|
82
|
+
|
|
83
|
+
function readJsonFile<T>(path: string): T | undefined {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeJsonAtomic(path: string, value: unknown): void {
|
|
92
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
93
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
94
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
95
|
+
renameSync(tmp, path);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sha256(path: string): string {
|
|
99
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isDir(path: string): boolean {
|
|
103
|
+
try {
|
|
104
|
+
return lstatSync(path).isDirectory();
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface TreeEntry {
|
|
111
|
+
rel: string;
|
|
112
|
+
kind: "file" | "dir" | "symlink";
|
|
113
|
+
size: number;
|
|
114
|
+
hash?: string;
|
|
115
|
+
link?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 走一遍目录:相对路径、符号链接、每个文件的大小和 sha256。 */
|
|
119
|
+
function manifestTree(root: string, skip: (rel: string) => boolean = () => false): TreeEntry[] {
|
|
120
|
+
const out: TreeEntry[] = [];
|
|
121
|
+
const walk = (dir: string): void => {
|
|
122
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
123
|
+
const path = join(dir, entry.name);
|
|
124
|
+
const rel = relative(root, path);
|
|
125
|
+
if (skip(rel)) continue;
|
|
126
|
+
if (entry.isSymbolicLink()) out.push({ rel, kind: "symlink", size: 0, link: readlinkSync(path) });
|
|
127
|
+
else if (entry.isDirectory()) {
|
|
128
|
+
out.push({ rel, kind: "dir", size: 0 });
|
|
129
|
+
walk(path);
|
|
130
|
+
} else if (entry.isFile()) {
|
|
131
|
+
out.push({ rel, kind: "file", size: statSync(path).size, hash: sha256(path) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
if (existsSync(root)) walk(root);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function countFiles(entries: TreeEntry[]): number {
|
|
140
|
+
return entries.filter((entry) => entry.kind === "file" || entry.kind === "symlink").length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function totalBytes(entries: TreeEntry[]): number {
|
|
144
|
+
return entries.reduce((sum, entry) => sum + entry.size, 0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 复制一项,保权限、时间、相对符号链接(和 cp -a 一样)。 */
|
|
148
|
+
function copyEntry(source: string, target: string): void {
|
|
149
|
+
const stats = lstatSync(source);
|
|
150
|
+
if (stats.isSymbolicLink()) {
|
|
151
|
+
symlinkSync(readlinkSync(source), target);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (stats.isDirectory()) {
|
|
155
|
+
mkdirSync(target, { recursive: true });
|
|
156
|
+
for (const name of readdirSync(source)) copyEntry(join(source, name), join(target, name));
|
|
157
|
+
utimesSync(target, stats.atime, stats.mtime);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
copyFileSync(source, target);
|
|
161
|
+
chmodSync(target, stats.mode & 0o777);
|
|
162
|
+
utimesSync(target, stats.atime, stats.mtime);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 把 from 里的每一项复制进已经存在的 to。 */
|
|
166
|
+
function copyChildren(from: string, to: string, skip: (name: string) => boolean = () => false): void {
|
|
167
|
+
mkdirSync(to, { recursive: true });
|
|
168
|
+
for (const name of readdirSync(from)) {
|
|
169
|
+
if (skip(name)) continue;
|
|
170
|
+
const target = join(to, name);
|
|
171
|
+
if (existsSync(target)) throw new Error(`复制目标已存在:${target}`);
|
|
172
|
+
copyEntry(join(from, name), target);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 和一份清单逐项核对;返回不一致的描述。 */
|
|
177
|
+
function compareTree(root: string, expected: TreeEntry[], skip: (rel: string) => boolean = () => false): string[] {
|
|
178
|
+
const actual = new Map(manifestTree(root, skip).map((entry) => [entry.rel, entry]));
|
|
179
|
+
const problems: string[] = [];
|
|
180
|
+
for (const want of expected) {
|
|
181
|
+
const got = actual.get(want.rel);
|
|
182
|
+
if (!got) {
|
|
183
|
+
problems.push(`缺 ${want.rel}`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
actual.delete(want.rel);
|
|
187
|
+
if (got.kind !== want.kind) problems.push(`类型不一致 ${want.rel}`);
|
|
188
|
+
else if (want.kind === "file" && got.hash !== want.hash) problems.push(`内容不一致 ${want.rel}`);
|
|
189
|
+
else if (want.kind === "symlink" && got.link !== want.link) problems.push(`链接不一致 ${want.rel}`);
|
|
190
|
+
}
|
|
191
|
+
for (const extra of actual.keys()) problems.push(`多出 ${extra}`);
|
|
192
|
+
return problems;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------- 布局 ----------
|
|
196
|
+
|
|
197
|
+
export function readLayoutVersion(home: string): number | undefined {
|
|
198
|
+
const version = readJsonFile<{ version?: unknown }>(layoutFile(home))?.version;
|
|
199
|
+
return typeof version === "number" ? version : undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function isCurrentLayout(home: string): boolean {
|
|
203
|
+
return readLayoutVersion(home) === LAYOUT_VERSION;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createFreshLayout(home: string): void {
|
|
207
|
+
for (const dir of ["system", "cache", "knowledge", "skills", "assistants", "archive/assistants"]) {
|
|
208
|
+
mkdirSync(join(home, dir), { recursive: true });
|
|
209
|
+
}
|
|
210
|
+
writeJsonAtomic(layoutFile(home), { version: LAYOUT_VERSION });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 根上除了 v2 该有的东西以外还有别的:说明是 v1(或者半搬的)数据。 */
|
|
214
|
+
function looksLegacy(home: string): boolean {
|
|
215
|
+
if (!existsSync(home)) return false;
|
|
216
|
+
return readdirSync(home).some((name) => !V2_ROOT_ENTRIES.has(name) && !name.startsWith(BACKUP_PREFIX));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function findBackups(home: string): string[] {
|
|
220
|
+
if (!existsSync(home)) return [];
|
|
221
|
+
return readdirSync(home)
|
|
222
|
+
.filter((name) => name.startsWith(BACKUP_PREFIX) && isDir(join(home, name)))
|
|
223
|
+
.map((name) => join(home, name))
|
|
224
|
+
.sort();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isCompleteBackup(dir: string): boolean {
|
|
228
|
+
return existsSync(join(dir, BACKUP_MARKER));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function latestCompleteBackup(home: string): string | undefined {
|
|
232
|
+
return findBackups(home).filter(isCompleteBackup).at(-1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 遍历数据目录时跳过根上的备份目录(备份里有整份数据,不能算进数据本身)。 */
|
|
236
|
+
function skipBackup(rel: string): boolean {
|
|
237
|
+
return rel.split(sep)[0]!.startsWith(BACKUP_PREFIX);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function dataManifest(home: string): TreeEntry[] {
|
|
241
|
+
return manifestTree(home, skipBackup);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------- 路径映射(moves.json) ----------
|
|
245
|
+
|
|
246
|
+
interface MovesFile {
|
|
247
|
+
version: number;
|
|
248
|
+
home: string;
|
|
249
|
+
movedAt: string;
|
|
250
|
+
entries: { from: string; to: string }[];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export class PathMap {
|
|
254
|
+
private entries: { from: string; to: string }[] = [];
|
|
255
|
+
|
|
256
|
+
constructor(entries: { from: string; to: string }[] = []) {
|
|
257
|
+
for (const entry of entries) this.add(entry.from, entry.to);
|
|
258
|
+
this.sort();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
add(from: string, to: string): void {
|
|
262
|
+
if (from === to) return;
|
|
263
|
+
this.entries.push({ from, to });
|
|
264
|
+
this.sort();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private sort(): void {
|
|
268
|
+
this.entries.sort((a, b) => b.from.length - a.from.length);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 最长前缀匹配:目录按整个前缀换,文件按整条路径换;换不了就原样返回。 */
|
|
272
|
+
map(path: string): string {
|
|
273
|
+
for (const entry of this.entries) {
|
|
274
|
+
if (path === entry.from) return entry.to;
|
|
275
|
+
if (path.startsWith(`${entry.from}${sep}`)) return `${entry.to}${path.slice(entry.from.length)}`;
|
|
276
|
+
}
|
|
277
|
+
return path;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
toJSON(): { from: string; to: string }[] {
|
|
281
|
+
return [...this.entries].sort((a, b) => a.from.localeCompare(b.from));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let movesCache: { file: string; stamp: string; map: PathMap } | undefined;
|
|
286
|
+
|
|
287
|
+
/** 读 system/moves.json;坏文件当成没有(老路径解析不了,但不影响新数据)。 */
|
|
288
|
+
export function movesIndex(home: string): PathMap {
|
|
289
|
+
const file = movesFile(home);
|
|
290
|
+
if (!existsSync(file)) return new PathMap();
|
|
291
|
+
const stats = statSync(file);
|
|
292
|
+
const stamp = `${stats.size}:${stats.mtimeMs}`;
|
|
293
|
+
if (movesCache?.file === file && movesCache.stamp === stamp) return movesCache.map;
|
|
294
|
+
const stored = readJsonFile<MovesFile>(file);
|
|
295
|
+
const map = new PathMap(Array.isArray(stored?.entries) ? stored.entries : []);
|
|
296
|
+
movesCache = { file, stamp, map };
|
|
297
|
+
return map;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 旧路径换到新位置;换不了就原样返回。 */
|
|
301
|
+
export function resolveMovedPath(home: string, path: string): string {
|
|
302
|
+
return movesIndex(home).map(path);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------- 规划 ----------
|
|
306
|
+
|
|
307
|
+
function assistantDirs(home: string): Map<string, string> {
|
|
308
|
+
const root = join(home, "assistants");
|
|
309
|
+
const out = new Map<string, string>();
|
|
310
|
+
if (!existsSync(root)) return out;
|
|
311
|
+
for (const id of readdirSync(root)) {
|
|
312
|
+
const dir = join(root, id);
|
|
313
|
+
if (existsSync(join(dir, "profile.json"))) out.set(id, dir);
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function archivedDirs(home: string): Map<string, string> {
|
|
319
|
+
const root = archiveAssistantsDir(home);
|
|
320
|
+
const out = new Map<string, string>();
|
|
321
|
+
if (!existsSync(root)) return out;
|
|
322
|
+
for (const folder of readdirSync(root)) {
|
|
323
|
+
const dir = join(root, folder);
|
|
324
|
+
if (!isDir(dir)) continue;
|
|
325
|
+
const profile = readJsonFile<{ id?: unknown }>(join(dir, "profile.json"));
|
|
326
|
+
const id = typeof profile?.id === "string" && profile.id ? profile.id : folder;
|
|
327
|
+
if (!out.has(id)) out.set(id, dir);
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
interface JobDir {
|
|
333
|
+
id: string;
|
|
334
|
+
dir: string;
|
|
335
|
+
assistantId: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function jobDirs(home: string): JobDir[] {
|
|
339
|
+
const root = join(home, "jobs");
|
|
340
|
+
if (!existsSync(root)) return [];
|
|
341
|
+
const out: JobDir[] = [];
|
|
342
|
+
for (const id of readdirSync(root)) {
|
|
343
|
+
const dir = join(root, id);
|
|
344
|
+
const stored = readJsonFile<{ assistantId?: unknown }>(join(dir, "job.json"));
|
|
345
|
+
if (!stored) continue;
|
|
346
|
+
out.push({ id, dir, assistantId: typeof stored.assistantId === "string" && stored.assistantId ? stored.assistantId : FALLBACK_OWNER });
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function readText(path: string): string {
|
|
352
|
+
try {
|
|
353
|
+
return readFileSync(path, "utf8");
|
|
354
|
+
} catch {
|
|
355
|
+
return "";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 归属判定用的语料:各助手的对话 + 各任务的 job.json,主助手先、其余按 id 排。 */
|
|
360
|
+
function attributionCorpus(live: Map<string, string>, archived: Map<string, string>, jobs: JobDir[]): { assistantId: string; text: string }[] {
|
|
361
|
+
const out: { assistantId: string; text: string }[] = [];
|
|
362
|
+
for (const [id, dir] of [...live.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
363
|
+
out.push({ assistantId: id, text: readText(join(dir, "router.jsonl")) });
|
|
364
|
+
}
|
|
365
|
+
for (const [id, dir] of [...archived.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
366
|
+
out.push({ assistantId: id, text: readText(join(dir, "router.jsonl")) });
|
|
367
|
+
}
|
|
368
|
+
for (const job of jobs) out.push({ assistantId: job.assistantId, text: readText(join(job.dir, "job.json")) });
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function buildMigrationPlan(home: string): MigrationPlan {
|
|
373
|
+
const live = assistantDirs(home);
|
|
374
|
+
const archived = archivedDirs(home);
|
|
375
|
+
const jobs = jobDirs(home);
|
|
376
|
+
const orphans = new Set<string>();
|
|
377
|
+
const moves: PlannedMove[] = [];
|
|
378
|
+
const splits: PlannedSplit[] = [];
|
|
379
|
+
const owners: MigrationPlan["owners"] = [];
|
|
380
|
+
|
|
381
|
+
const ownerDir = (assistantId: string): string => {
|
|
382
|
+
const dir = live.get(assistantId) ?? archived.get(assistantId);
|
|
383
|
+
if (dir) return dir;
|
|
384
|
+
orphans.add(assistantId);
|
|
385
|
+
return join(archiveAssistantsDir(home), `${assistantId}-orphan`);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const add = (from: string, to: string, kind: MoveKind, reason: string): void => {
|
|
389
|
+
if (existsSync(from)) moves.push({ from, to, kind, reason });
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// 根上的配置、凭据和引擎目录
|
|
393
|
+
for (const name of ["config.json", "settings.json", "secrets.json", "push.json"]) {
|
|
394
|
+
add(join(home, name), join(systemDir(home), name), "file", "系统文件");
|
|
395
|
+
}
|
|
396
|
+
for (const name of ["engine", "lore", "memory"]) {
|
|
397
|
+
add(join(home, name), join(systemDir(home), name), "dir", "系统目录");
|
|
398
|
+
}
|
|
399
|
+
add(join(home, "usage-prices.json"), join(cacheDir(home), "usage-prices.json"), "file", "缓存");
|
|
400
|
+
add(join(home, "github-cache"), join(cacheDir(home), "github"), "dir", "缓存");
|
|
401
|
+
add(join(home, "previews"), join(cacheDir(home), "previews"), "dir", "缓存");
|
|
402
|
+
|
|
403
|
+
// 助手的截图跟着助手走
|
|
404
|
+
for (const [id, dir] of live) {
|
|
405
|
+
add(join(dir, "screenshots"), assistantFilesDir(home, id, "screenshots"), "dir", `${id} 的截图`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 任务跟着所属助手走
|
|
409
|
+
for (const job of jobs) {
|
|
410
|
+
add(job.dir, join(ownerDir(job.assistantId), "jobs", job.id), "dir", `任务属于 ${job.assistantId}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 页面、图片、上传:按对话里第一次提到它的助手
|
|
414
|
+
const corpus = attributionCorpus(live, archived, jobs);
|
|
415
|
+
const flat: { dir: string; kind: "pages" | "images" | "uploads" }[] = [
|
|
416
|
+
{ dir: join(home, "pages"), kind: "pages" },
|
|
417
|
+
{ dir: join(home, "images"), kind: "images" },
|
|
418
|
+
{ dir: join(home, "uploads"), kind: "uploads" },
|
|
419
|
+
];
|
|
420
|
+
for (const group of flat) {
|
|
421
|
+
if (!isDir(group.dir)) continue;
|
|
422
|
+
for (const name of readdirSync(group.dir)) {
|
|
423
|
+
const from = join(group.dir, name);
|
|
424
|
+
if (!lstatSync(from).isFile()) continue;
|
|
425
|
+
const hit = corpus.find((item) => item.text.includes(from));
|
|
426
|
+
const assistantId = hit?.assistantId ?? FALLBACK_OWNER;
|
|
427
|
+
const reason = hit ? `${assistantId} 提过它` : `谁都没提过,归 ${FALLBACK_OWNER}`;
|
|
428
|
+
owners.push({ path: from, assistantId, reason });
|
|
429
|
+
add(from, join(ownerDir(assistantId), "files", group.kind, name), "file", reason);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 定时任务的工作目录
|
|
434
|
+
const routineRoot = join(home, "routines");
|
|
435
|
+
if (isDir(routineRoot)) {
|
|
436
|
+
const defined = readJsonFile<{ id?: unknown; assistantId?: unknown }[]>(join(home, "routines.json")) ?? [];
|
|
437
|
+
for (const rid of readdirSync(routineRoot)) {
|
|
438
|
+
const from = join(routineRoot, rid);
|
|
439
|
+
if (!isDir(from)) continue;
|
|
440
|
+
const routine = defined.find((item) => item.id === rid);
|
|
441
|
+
const assistantId = typeof routine?.assistantId === "string" && routine.assistantId ? routine.assistantId : FALLBACK_OWNER;
|
|
442
|
+
add(from, join(ownerDir(assistantId), "routines", rid), "dir", `定时属于 ${assistantId}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// 审批、定时定义按助手拆开;草稿和委派都是主助手发出来的
|
|
447
|
+
const split = (name: string, targetName: string, merge: boolean): void => {
|
|
448
|
+
const from = join(home, name);
|
|
449
|
+
const list = readJsonFile<{ assistantId?: unknown; id?: unknown }[]>(from);
|
|
450
|
+
if (!Array.isArray(list) || !list.length) return;
|
|
451
|
+
const byOwner = new Map<string, { id?: unknown }[]>();
|
|
452
|
+
for (const item of list) {
|
|
453
|
+
const assistantId = typeof item.assistantId === "string" && item.assistantId ? item.assistantId : FALLBACK_OWNER;
|
|
454
|
+
byOwner.set(assistantId, [...(byOwner.get(assistantId) ?? []), item]);
|
|
455
|
+
}
|
|
456
|
+
for (const [assistantId, items] of byOwner) splits.push({ from, to: join(ownerDir(assistantId), targetName), items, merge });
|
|
457
|
+
};
|
|
458
|
+
split("approvals.json", "approvals.json", false);
|
|
459
|
+
split("routines.json", "routines.json", true);
|
|
460
|
+
for (const [name, target] of [["drafts.json", "drafts.json"], ["delegations.json", "delegations.json"]] as const) {
|
|
461
|
+
const from = join(home, name);
|
|
462
|
+
const items = readJsonFile<{ id?: unknown }[]>(from);
|
|
463
|
+
if (Array.isArray(items)) splits.push({ from, to: join(assistantDir(home, FALLBACK_OWNER), target), items });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return { moves, splits, orphans: [...orphans], owners };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------- 迁移 ----------
|
|
470
|
+
|
|
471
|
+
function availableBytes(home: string): number {
|
|
472
|
+
let probe = resolve(home);
|
|
473
|
+
while (!existsSync(probe)) {
|
|
474
|
+
const parent = dirname(probe);
|
|
475
|
+
if (parent === probe) break;
|
|
476
|
+
probe = parent;
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
const stats = statfsSync(probe);
|
|
480
|
+
return Number(stats.bavail) * Number(stats.bsize);
|
|
481
|
+
} catch {
|
|
482
|
+
return Number.POSITIVE_INFINITY;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
class Log {
|
|
487
|
+
private lines: string[] = [];
|
|
488
|
+
constructor(private path: string | undefined, private echo: (line: string) => void) {}
|
|
489
|
+
write(line: string): void {
|
|
490
|
+
this.lines.push(line);
|
|
491
|
+
this.echo(line);
|
|
492
|
+
if (!this.path) return;
|
|
493
|
+
try {
|
|
494
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
495
|
+
writeFileSync(this.path, `${this.lines.join("\n")}\n`);
|
|
496
|
+
} catch {
|
|
497
|
+
// 日志写不了不影响迁移本身
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function stamp(): string {
|
|
503
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function moveOne(move: PlannedMove): void {
|
|
507
|
+
if (existsSync(move.to)) throw new Error(`迁移目标已存在:${move.to}`);
|
|
508
|
+
mkdirSync(dirname(move.to), { recursive: true });
|
|
509
|
+
try {
|
|
510
|
+
renameSync(move.from, move.to);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
|
|
513
|
+
// 跨设备:复制、核对、再删原件
|
|
514
|
+
const expected = manifestTree(move.from);
|
|
515
|
+
copyEntry(move.from, move.to);
|
|
516
|
+
const problems = compareTree(move.to, expected);
|
|
517
|
+
if (problems.length) {
|
|
518
|
+
rmSync(move.to, { recursive: true, force: true });
|
|
519
|
+
throw new Error(`跨设备搬迁核对不上:${problems.slice(0, 3).join(";")}`);
|
|
520
|
+
}
|
|
521
|
+
rmSync(move.from, { recursive: true, force: true });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** 按 moves 改写结构化 JSON 里的绝对路径。 */
|
|
526
|
+
function rewritePaths(file: string, map: PathMap): string[] {
|
|
527
|
+
const stored = readJsonFile<unknown>(file);
|
|
528
|
+
if (!stored || typeof stored !== "object") return [];
|
|
529
|
+
const changed: string[] = [];
|
|
530
|
+
const walk = (value: unknown, key: string): unknown => {
|
|
531
|
+
if (typeof value === "string" && isAbsolute(value)) {
|
|
532
|
+
const mapped = map.map(value);
|
|
533
|
+
if (mapped === value) return value;
|
|
534
|
+
changed.push(`${key} → ${mapped}`);
|
|
535
|
+
return mapped;
|
|
536
|
+
}
|
|
537
|
+
if (Array.isArray(value)) return value.map((item, index) => walk(item, `${key}[${index}]`));
|
|
538
|
+
if (value && typeof value === "object") {
|
|
539
|
+
const out: Record<string, unknown> = {};
|
|
540
|
+
for (const [name, item] of Object.entries(value)) out[name] = walk(item, key ? `${key}.${name}` : name);
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
return value;
|
|
544
|
+
};
|
|
545
|
+
const next = walk(stored, "");
|
|
546
|
+
if (!changed.length) return [];
|
|
547
|
+
writeJsonAtomic(file, next);
|
|
548
|
+
return changed;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function jobJsonFiles(home: string): string[] {
|
|
552
|
+
const out: string[] = [];
|
|
553
|
+
for (const root of [join(home, "assistants"), archiveAssistantsDir(home)]) {
|
|
554
|
+
if (!existsSync(root)) continue;
|
|
555
|
+
for (const folder of readdirSync(root)) {
|
|
556
|
+
const jobs = join(root, folder, "jobs");
|
|
557
|
+
if (!isDir(jobs)) continue;
|
|
558
|
+
for (const jobId of readdirSync(jobs)) {
|
|
559
|
+
const file = join(jobs, jobId, "job.json");
|
|
560
|
+
if (existsSync(file)) out.push(file);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function rewriteStructuredData(home: string, map: PathMap, log: Log): void {
|
|
568
|
+
for (const file of jobJsonFiles(home)) {
|
|
569
|
+
const changed = rewritePaths(file, map);
|
|
570
|
+
if (changed.length) log.write(` 改写 ${relative(home, file)}:${changed.length} 处路径`);
|
|
571
|
+
}
|
|
572
|
+
const entries = join(home, "knowledge", "entries.json");
|
|
573
|
+
const changed = rewritePaths(entries, map);
|
|
574
|
+
if (changed.length) log.write(` 改写 knowledge/entries.json:${changed.length} 处路径`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** 老布局里已经搬空的平铺目录:连同目录一起删掉,根上只留 layout 里该有的东西。 */
|
|
578
|
+
const LEGACY_FLAT_DIRS = ["jobs", "pages", "images", "uploads", "routines", "github-cache", "previews"];
|
|
579
|
+
|
|
580
|
+
function cleanupLegacyDirs(home: string, log: Log): void {
|
|
581
|
+
for (const name of LEGACY_FLAT_DIRS) {
|
|
582
|
+
const dir = join(home, name);
|
|
583
|
+
if (!existsSync(dir) || !isDir(dir)) continue;
|
|
584
|
+
const left = readdirSync(dir);
|
|
585
|
+
if (!left.length) {
|
|
586
|
+
rmSync(dir, { recursive: true, force: true });
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
// 看不出来归谁的东西不动,留在原地并在日志里说清楚
|
|
590
|
+
log.write(`警告:${name}/ 里还剩 ${left.length} 项没有归属信息(${left.slice(0, 5).join("、")}),原样留着`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function migrateHome(home: string, options: MigrateOptions = {}): MigrationSummary {
|
|
595
|
+
const started = Date.now();
|
|
596
|
+
const echo = options.log ?? ((line: string) => console.log(line));
|
|
597
|
+
const done = (patch: Partial<MigrationSummary> & { status: MigrationSummary["status"] }): MigrationSummary => ({
|
|
598
|
+
files: 0,
|
|
599
|
+
jobs: 0,
|
|
600
|
+
orphans: [],
|
|
601
|
+
durationMs: Date.now() - started,
|
|
602
|
+
...patch,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
if (isCurrentLayout(home)) return done({ status: "current" });
|
|
606
|
+
const version = readLayoutVersion(home);
|
|
607
|
+
if (version !== undefined) throw new Error(`数据目录的布局版本是 ${version},这个版本的 Hob 只认 ${LAYOUT_VERSION}。`);
|
|
608
|
+
|
|
609
|
+
const backups = findBackups(home);
|
|
610
|
+
const complete = backups.filter(isCompleteBackup);
|
|
611
|
+
|
|
612
|
+
if (options.dryRun) {
|
|
613
|
+
if (complete.length) echo(`[dry-run] 上次迁移没做完:会先从 ${complete.at(-1)} 恢复,再迁移。`);
|
|
614
|
+
if (!looksLegacy(home)) {
|
|
615
|
+
echo(`[dry-run] 全新安装:会建好 v2 布局的目录并写 layout.json。`);
|
|
616
|
+
return done({ status: "dry-run", plan: { moves: [], splits: [], orphans: [], owners: [] } });
|
|
617
|
+
}
|
|
618
|
+
const plan = buildMigrationPlan(home);
|
|
619
|
+
echo(`[dry-run] 数据目录 ${home} 还是老布局,迁移会这样做:`);
|
|
620
|
+
for (const move of plan.moves) echo(` ${relative(home, move.from)} → ${relative(home, move.to)}(${move.reason})`);
|
|
621
|
+
for (const split of plan.splits) echo(` ${relative(home, split.from)} 拆出 ${split.items.length} 条 → ${relative(home, split.to)}`);
|
|
622
|
+
for (const orphan of plan.orphans) echo(` 孤儿助手 ${orphan} → ${relative(home, join(archiveAssistantsDir(home), `${orphan}-orphan`))}`);
|
|
623
|
+
return done({ status: "dry-run", jobs: plan.moves.filter((move) => move.kind === "dir" && move.to.includes(`${sep}jobs${sep}`)).length, orphans: plan.orphans, plan });
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// 上次迁移没做完:有完整备份就恢复后重来,只有半份备份就删掉重来(数据还没动过)。
|
|
627
|
+
if (complete.length) {
|
|
628
|
+
restoreHome(home, { log: echo });
|
|
629
|
+
} else if (backups.length) {
|
|
630
|
+
for (const dir of backups) rmSync(dir, { recursive: true, force: true });
|
|
631
|
+
echo(`上次迁移留下的备份没写完,已删掉半份备份(${backups.map((dir) => relative(home, dir)).join("、")}),数据没动。`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (!looksLegacy(home)) {
|
|
635
|
+
createFreshLayout(home);
|
|
636
|
+
return done({ status: "created" });
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const plan = buildMigrationPlan(home);
|
|
640
|
+
const before = dataManifest(home);
|
|
641
|
+
const needed = totalBytes(before) + SPACE_MARGIN;
|
|
642
|
+
const available = options.availableBytes ?? availableBytes(home);
|
|
643
|
+
if (available < needed) {
|
|
644
|
+
const mb = (value: number): string => `${Math.ceil(value / 1024 / 1024)} MB`;
|
|
645
|
+
throw new Error(`空间不够做迁移:需要 ${mb(needed)}(数据 ${mb(totalBytes(before))} + 余量 ${mb(SPACE_MARGIN)}),现在只剩 ${mb(available)}。腾出空间后再启动。`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 完整备份:出问题就从它恢复
|
|
649
|
+
let backupDir = latestCompleteBackup(home);
|
|
650
|
+
const reusedBackup = !!backupDir;
|
|
651
|
+
if (!backupDir) {
|
|
652
|
+
backupDir = join(home, `${BACKUP_PREFIX}${stamp()}`);
|
|
653
|
+
copyChildren(home, backupDir, (name) => name.startsWith(BACKUP_PREFIX));
|
|
654
|
+
options.hooks?.afterBackup?.(backupDir);
|
|
655
|
+
const problems = compareTree(backupDir, before);
|
|
656
|
+
if (problems.length) {
|
|
657
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
658
|
+
throw new Error(`迁移前的备份核对不上(${problems.slice(0, 3).join(";")}${problems.length > 3 ? ` 等 ${problems.length} 处` : ""}),已删掉这份备份,数据没有动。`);
|
|
659
|
+
}
|
|
660
|
+
writeJsonAtomic(join(backupDir, BACKUP_MARKER), { version: 1, createdAt: new Date().toISOString(), files: countFiles(before) });
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const logPath = join(systemDir(home), "migrations", `v2-${stamp()}.log`);
|
|
664
|
+
const log = new Log(logPath, echo);
|
|
665
|
+
log.write(`# HOB_HOME 布局迁移 v${LAYOUT_VERSION}(${new Date().toISOString()})`);
|
|
666
|
+
log.write(`数据目录:${home}`);
|
|
667
|
+
log.write(`备份:${relative(home, backupDir)}${reusedBackup ? "(沿用上次没做完的那份)" : ""},${countFiles(before)} 个文件,${(totalBytes(before) / 1024 / 1024).toFixed(1)} MB,核对通过`);
|
|
668
|
+
log.write(`可用空间 ${(available / 1024 / 1024).toFixed(0)} MB,需要 ${(needed / 1024 / 1024).toFixed(0)} MB`);
|
|
669
|
+
|
|
670
|
+
const map = new PathMap();
|
|
671
|
+
for (let index = 0; index < plan.moves.length; index += 1) {
|
|
672
|
+
const move = plan.moves[index]!;
|
|
673
|
+
log.write(`搬 ${relative(home, move.from)} → ${relative(home, move.to)}(${move.reason})`);
|
|
674
|
+
moveOne(move);
|
|
675
|
+
map.add(move.from, move.to);
|
|
676
|
+
options.hooks?.afterMove?.(index, plan.moves.length);
|
|
677
|
+
}
|
|
678
|
+
const jobCount = plan.moves.filter((move) => move.kind === "dir" && move.to.includes(`${sep}jobs${sep}`)).length;
|
|
679
|
+
|
|
680
|
+
for (const split of plan.splits) {
|
|
681
|
+
const existing = split.merge ? readJsonFile<{ id?: unknown }[]>(split.to) ?? [] : [];
|
|
682
|
+
const merged = [...existing];
|
|
683
|
+
for (const item of split.items) {
|
|
684
|
+
const at = item.id === undefined ? -1 : merged.findIndex((entry) => entry.id === item.id);
|
|
685
|
+
if (at >= 0) merged[at] = item;
|
|
686
|
+
else merged.push(item);
|
|
687
|
+
}
|
|
688
|
+
writeJsonAtomic(split.to, merged);
|
|
689
|
+
log.write(`拆 ${relative(home, split.from)}:${split.items.length} 条 → ${relative(home, split.to)}`);
|
|
690
|
+
rmSync(split.from, { force: true });
|
|
691
|
+
}
|
|
692
|
+
for (const orphan of plan.orphans) {
|
|
693
|
+
log.write(`孤儿助手 ${orphan}:任务、审批、定时搬进 ${relative(home, join(archiveAssistantsDir(home), `${orphan}-orphan`))}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
rewriteStructuredData(home, map, log);
|
|
697
|
+
cleanupLegacyDirs(home, log);
|
|
698
|
+
|
|
699
|
+
writeJsonAtomic(movesFile(home), { version: LAYOUT_VERSION, home, movedAt: new Date().toISOString(), entries: map.toJSON() } satisfies MovesFile);
|
|
700
|
+
for (const dir of ["cache", "knowledge", "skills", "assistants", "archive/assistants"]) mkdirSync(join(home, dir), { recursive: true });
|
|
701
|
+
writeJsonAtomic(layoutFile(home), { version: LAYOUT_VERSION });
|
|
702
|
+
log.write("写完 system/moves.json 和 layout.json,迁移结束");
|
|
703
|
+
|
|
704
|
+
const summary = done({
|
|
705
|
+
status: "migrated",
|
|
706
|
+
backupDir,
|
|
707
|
+
files: countFiles(dataManifest(home)),
|
|
708
|
+
jobs: jobCount,
|
|
709
|
+
orphans: plan.orphans,
|
|
710
|
+
logPath,
|
|
711
|
+
});
|
|
712
|
+
log.write(`合计 ${summary.files} 个文件、${summary.jobs} 个任务,孤儿 ${plan.orphans.length} 个,用了 ${(summary.durationMs / 1000).toFixed(1)} 秒`);
|
|
713
|
+
return summary;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** 从备份恢复成老布局;迁移失败和用坏了想退回去都走这里。 */
|
|
717
|
+
export function restoreHome(home: string, options: { log?: (line: string) => void } = {}): { backupDir: string; files: number } {
|
|
718
|
+
const echo = options.log ?? ((line: string) => console.log(line));
|
|
719
|
+
const backupDir = latestCompleteBackup(home);
|
|
720
|
+
if (!backupDir) throw new Error(`没有找到完整的备份(${BACKUP_PREFIX}*),没法恢复。`);
|
|
721
|
+
const expected = manifestTree(backupDir, (rel) => rel === BACKUP_MARKER);
|
|
722
|
+
const files = countFiles(expected);
|
|
723
|
+
echo(`从备份恢复:${relative(home, backupDir)}(${files} 个文件)`);
|
|
724
|
+
for (const name of readdirSync(home)) {
|
|
725
|
+
if (name.startsWith(BACKUP_PREFIX)) continue;
|
|
726
|
+
rmSync(join(home, name), { recursive: true, force: true });
|
|
727
|
+
}
|
|
728
|
+
copyChildren(backupDir, home, (name) => name === BACKUP_MARKER);
|
|
729
|
+
const problems = compareTree(home, expected, skipBackup);
|
|
730
|
+
if (problems.length) throw new Error(`从备份恢复后核对不上:${problems.slice(0, 3).join(";")}`);
|
|
731
|
+
echo(`恢复完成,${files} 个文件核对一致`);
|
|
732
|
+
return { backupDir, files };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* 启动前调一次:按需迁移,然后把一行总结打到控制台。
|
|
737
|
+
* 返回 false 表示什么都没做(已经是新布局)。
|
|
738
|
+
*/
|
|
739
|
+
export function prepareHome(home: string): MigrationSummary {
|
|
740
|
+
const summary = migrateHome(home);
|
|
741
|
+
if (summary.status === "migrated") {
|
|
742
|
+
console.log(
|
|
743
|
+
`HOB_HOME 已迁移到 v2 布局:搬了 ${summary.files} 个文件、${summary.jobs} 个任务,孤儿 ${summary.orphans.length} 个,备份在 ${summary.backupDir},日志 ${summary.logPath}`,
|
|
744
|
+
);
|
|
745
|
+
} else if (summary.status === "created") {
|
|
746
|
+
console.log(`HOB_HOME(${home})按 v2 布局建好了`);
|
|
747
|
+
}
|
|
748
|
+
return summary;
|
|
749
|
+
}
|