cortico-world-archivist 0.0.0-stage → 0.3.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## 0.3.3 — 2026-11-05
4
+
5
+ 首个公开发布的版本。备份与恢复都在里面。
6
+
7
+ **备份**
8
+
9
+ - 按间隔自动备份(默认 24 小时),启动时若已超期立刻补一次
10
+ - 滚动保留(默认 14 份),超出自动删最老的
11
+ - 五项可勾:工作区全部 / 工作区 git 历史 / 工作区附件(blobs) / 扩展数据 / 配置与密钥
12
+ - 「扩展数据」自动扫描 `data/`,装了什么扩展就备份什么,只跳过可重建的运行态与缓存
13
+ - 额外路径补漏、可选连带扩展包
14
+ - 打包前先复制到临时目录再压缩,避免打到写了一半的文件
15
+ - 每份 zip 带 `MANIFEST.md`,写明内容与手工恢复步骤
16
+
17
+ **恢复**
18
+
19
+ - 解压到临时目录 → 校验 `MANIFEST.md` → 快照当前状态到 `before-restore-<时间>/` → 才覆盖
20
+ - 恢复范围复用备份那份勾选
21
+ - 需要输入确认口令 `RESTORE`(或「恢复」)才会动作,输错则一个文件都不碰
22
+ - 恢复后等 3 秒复查:若有扩展把内存里的旧数据写回磁盘,结果里直接说明「这次恢复对它们无效,请完全退出 Coopanion 后重做」
23
+ - 跨平台:Windows 的 `Compress-Archive` 把条目名写成 `\`,解开后就地修正为目录结构(`..` 一并过滤)
24
+
25
+ **其他**
26
+
27
+ - 零上下文成本:`envPromptVars()` 返回 `null`、`tools()` 返回 `[]`,不占前缀、不进工具表
28
+ - 日志写到 `<存放位置>/archivist.log`(`WorldContext` 没有 `log` 通道,所以自己写)
29
+ - 配置项的源 id 改名后自动迁移,缺的源按默认值补齐
30
+ - 中英双语界面文案
31
+
32
+ ## 0.1.0 – 0.3.2
33
+
34
+ 未公开的本地迭代版本。0.3.3 之前的编号只在作者本机存在。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cortico-world-archivist contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,92 @@
1
- # Temporary Holding Version
1
+ # 备份与恢复 / Backup & Restore
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 给 [Coopanion](https://github.com/Pal-AI-Lab) 用的 World 扩展(包名 `cortico-world-archivist`,配置段 `worlds.archivist`):**定期备份 + 一键恢复**。
4
+
5
+ 她的长期记忆散在三个地方——`workspace/`(画像、自述、日记、笔记、交接档案)、`data/*.json`(事实库、向量索引、档案、反思、画像卡)、`config.json` + `.env` + `providers/`(配置与密钥)。这个扩展把三处打成一个 zip,滚动保留若干份;要回退时从任意一份放回去。
6
+
7
+ **零上下文成本**:不进系统前缀(`envPromptVars()` 返回 `null`)、不注册任何工具(`tools()` 返回 `[]`)。它只在后台跑,不吃 token。
8
+
9
+ ## 装
10
+
11
+ ```
12
+ pnpm add cortico-world-archivist # 或在扩展市场里装
13
+ ```
14
+
15
+ 装完重启 Coopanion,在「世界」页启用「归档」,再打开它的配置页。
16
+
17
+ ## 备份
18
+
19
+ | 配置项 | 说明 |
20
+ | --- | --- |
21
+ | 备份间隔 | 多久一次(小时)。进程重启后会检查是否超期,超了就立刻补一次 |
22
+ | 保留份数 | 滚动保留。超过就删最老的 |
23
+ | 存放位置 | zip 放哪。留空 = `data/archivist/`。**建议放到另一块硬盘或网盘同步目录** |
24
+ | 备份内容 | 五项可勾:工作区全部 / git 历史 / blobs 附件 / 扩展数据 / 配置与密钥 |
25
+ | 连带扩展包 | 把 `extensions/` 也打包。几十 MB,一般不用 |
26
+ | 额外要备份的路径 | 一行一个。给那些把数据放在子目录、或装在别处的扩展补漏 |
27
+ | 立即备份一次 | 拨到开 → 一分钟内开始,做完自动关回去 |
28
+
29
+ 「扩展数据」是**自动扫描** `data/` 目录的:装了什么扩展就备份什么,不用手动配。只跳过明确可重建的东西(运行态 JSON、缓存、`session-main.jsonl` 这类流水,以及 `media/`、`figures/`、`emoji/` 这些几十上百 MB 的素材目录)。判定标准写在 `src/config.js` 的 `DATA_SKIP` / `DATA_SKIP_DIRS` 里,附了每一条的理由。
30
+
31
+ 一份真实部署(9 MB 工作区 + 13 MB 记忆数据)打包后约 **15 MB,4~5 秒**。
32
+
33
+ ## 恢复
34
+
35
+ 恢复默认**只用于灾难恢复**,所以设了两道闸:
36
+
37
+ 1. **确认口令**:必须先输入 `RESTORE`(或「恢复」),否则「立即恢复」开关不动。
38
+ 2. **先留存再覆盖**:恢复前把当前状态复制到 `<存放位置>/before-restore-<时间>/`,后悔了还能拿回来。
39
+
40
+ 流程是固定的四步,顺序不能颠倒:
41
+
42
+ ```
43
+ ① 解压到临时目录 —— 不碰真实数据
44
+ ② 校验 MANIFEST.md —— 不是本扩展的包直接拒绝
45
+ ③ 快照当前状态 —— before-restore-<时间>/
46
+ ④ 才复制覆盖
47
+ ```
48
+
49
+ **恢复完必须重启 Coopanion。** 记忆类扩展(如 memory-extra)把数据缓存在内存里、每 2 秒全量 flush 一次,不重启会被旧内存写回去。`workspace/*.md` 是每次现读的,会立刻生效。
50
+
51
+ 所以恢复完会再等 3 秒复查一遍:如果 `data/` 里的文件在恢复后立刻被改写(内容变了,不只是时间戳),说明进程正把旧数据写回磁盘,这时结果里会直接说「这次恢复对它们无效,请完全退出 Coopanion 后重新恢复」,而不是留下一个看起来成功的假象。
52
+
53
+ 恢复与备份共用「备份内容」那份勾选:备份时打包哪些,恢复时就放回哪些。
54
+
55
+ 只覆盖、不删除:备份之后新写出来的文件不会被删掉。
56
+
57
+ 跨平台:Windows 的 `Compress-Archive` 会把条目名写成 `data\fact-store.json`(zip 规范要求 `/`)。Windows 自己解压没问题,但拿到 macOS/Linux 上会变成一堆名字里带反斜杠的平铺文件。所以解压后会就地修正这类条目名,`..` 一并过滤掉。
58
+
59
+ ### 老配置自动迁移
60
+
61
+ 配置里的源 id 改过名(早期版本叫 `memory`,现在是 `data`)。构造时会自动改名、补齐缺的源、删掉多余的键,并写回一次配置——否则面板读到 `undefined` 会把勾选框画成「没勾」,一保存就真的把那一类数据关掉了。迁移只在真的需要时才写盘。
62
+
63
+ ## 日志
64
+
65
+ `<存放位置>/archivist.log`,一行一条,带 ISO 时间和级别:
66
+
67
+ ```
68
+ 2026-11-05T10:01:24.742Z INFO 开始备份(启动补备份)
69
+ 2026-11-05T10:01:28.733Z INFO 备份完成 E:\Coopanion-Archive\archivist-2026-11-05T18-01-21.zip(15.4 MB,3960 ms)
70
+ ```
71
+
72
+ 和 zip 放在一起,出了问题直接把这个文件发出来就行。超过 512 KB 会截断到后一半(这不是审计日志)。
73
+
74
+ `WorldContext` 没有 `log` 字段(api 6 只给 `cfg` / `botDir` / `dataDir` / `persist` 这些),所以在扩展里写 `ctx.log?.info?.(...)` 是**空转**——这也是为什么它自己写文件,而不是指望框架的日志。
75
+
76
+ ## 已知限制
77
+
78
+ - zip 打包和解压调系统能力(Windows 用 `Compress-Archive` / `Expand-Archive`,macOS/Linux 用 `zip` / `unzip`),不引第三方依赖,所以打包时源文件被并发改写可能进包失败——这也是先复制到临时目录再压缩的原因。
79
+ - 恢复 `config.json` 会连 `worlds.archivist` 这一段一起还原,包括当时的「存放位置」。如果你改过存放位置,恢复后它也会变回去。
80
+ - 不做增量备份,每次都是全量。这样单份 zip 自包含,恢复时不用找链条。
81
+
82
+ ## 开发
83
+
84
+ ```
85
+ node --check src/index.js # 语法
86
+ ```
87
+
88
+ `src/` 四个文件:`index.js`(导出 World 定义)、`world.js`(生命周期、定时器、面板开关兑现)、`config.js`(默认值 + JSON Schema 表单 + 跳过名单)、`backup.js` / `restore.js`(两个执行器)。
89
+
90
+ 配置表单走 `webUi().config`(纯 JSON Schema,WebUI 直接渲染),不走 `webUi().panels`——后者需要预先构建 `cortico.webUiClient` 产物。
91
+
92
+ MIT。
package/describe.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "tools": [],
3
+ "events": []
4
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "cortico-world-archivist",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.3",
4
+ "type": "module",
5
+ "author": {
6
+ "name": "g_zone"
7
+ },
8
+ "description": "备份与恢复 —— 定期把她的工作区、记忆数据和配置打包成 zip,滚动保留若干份;需要时一键恢复(恢复前自动留存当前状态)。零上下文成本:不进系统前缀、不占工具表。",
9
+ "license": "MIT",
10
+ "os": [
11
+ "win32",
12
+ "darwin",
13
+ "linux"
14
+ ],
15
+ "main": "./src/index.js",
16
+ "files": [
17
+ "src",
18
+ "describe.json",
19
+ "README.md",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "keywords": [
23
+ "cortico-world",
24
+ "cortico",
25
+ "coopanion",
26
+ "backup",
27
+ "restore",
28
+ "archive"
29
+ ],
30
+ "cortico": {
31
+ "kind": "world",
32
+ "api": 6,
33
+ "displayName": "备份与恢复",
34
+ "tags": [
35
+ "diagnostics"
36
+ ],
37
+ "locales": {
38
+ "zh": {
39
+ "displayName": "备份与恢复",
40
+ "description": "定期把她的一切打包成 zip:工作区(画像/自述/日记/笔记/交接档案)、记忆数据(事实库/向量索引/档案/反思)、配置与密钥。滚动保留若干份,自动清理旧的;需要时从任意一份恢复,恢复前先给当前状态留备份。不占系统前缀,不进工具表。"
41
+ },
42
+ "en": {
43
+ "displayName": "Backup & Restore",
44
+ "description": "Zip everything about her on a schedule: workspace (persona, diary, notes, handoffs), memory data, and config. Rolling retention with automatic cleanup, plus one-click restore that snapshots the current state first. Costs no prefix space and adds no tools."
45
+ }
46
+ },
47
+ "describe": "describe.json"
48
+ }
49
+ }
package/src/backup.js ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * 备份执行器:把勾选的源复制到临时目录 → 打包成 zip → 轮转清理旧包。
3
+ *
4
+ * 关键设计:
5
+ * - 先复制到临时目录再打包,避免打包过程中源文件被改写(如 vector-memory.json 正在写)
6
+ * - 临时目录放在系统 temp,不污染她的工作区
7
+ * - 用系统自带能力压缩(Windows: Compress-Archive / macOS: zip),不加第三方依赖
8
+ * - 任何一步失败都清理临时目录,不留下垃圾
9
+ */
10
+ import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
11
+ import { copyFile, rm, mkdir, readdir, stat } from 'node:fs/promises';
12
+ import { join, basename, dirname, resolve } from 'node:path';
13
+ import { tmpdir } from 'node:os';
14
+ import { spawn } from 'node:child_process';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { CONFIG_FILES, CONFIG_DIRS, DATA_SKIP, DATA_SKIP_DIRS } from './config.js';
17
+
18
+ /** 递归复制目录;onError 收集单个文件的失败,不中断整体。 */
19
+ async function copyDir(src, dest, onError) {
20
+ await mkdir(dest, { recursive: true });
21
+ let entries;
22
+ try {
23
+ entries = await readdir(src, { withFileTypes: true });
24
+ } catch (e) {
25
+ onError(`${src}: ${e.code ?? e.message}`);
26
+ return;
27
+ }
28
+ for (const entry of entries) {
29
+ const s = join(src, entry.name);
30
+ const d = join(dest, entry.name);
31
+ if (entry.isDirectory()) {
32
+ await copyDir(s, d, onError);
33
+ } else if (entry.isFile()) {
34
+ try {
35
+ await copyFile(s, d);
36
+ } catch (e) {
37
+ onError(`${s}: ${e.code ?? e.message}`);
38
+ }
39
+ }
40
+ // 符号链接与特殊文件跳过
41
+ }
42
+ }
43
+
44
+ /** 复制单个文件;不存在时静默跳过。 */
45
+ async function copyOne(src, dest, onError) {
46
+ if (!existsSync(src)) return false;
47
+ try {
48
+ await mkdir(dirname(dest), { recursive: true });
49
+ await copyFile(src, dest);
50
+ return true;
51
+ } catch (e) {
52
+ onError(`${src}: ${e.code ?? e.message}`);
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /** 调系统压缩命令。返回 { ok, detail }。 */
58
+ function compress(stageDir, zipPath) {
59
+ return new Promise((done) => {
60
+ const isWin = process.platform === 'win32';
61
+ let cmd, args;
62
+ if (isWin) {
63
+ // Compress-Archive 会把 stageDir 的内容打成 zip(不含 stageDir 这层)
64
+ cmd = 'powershell.exe';
65
+ args = [
66
+ '-NoProfile', '-NonInteractive', '-Command',
67
+ `Compress-Archive -Path '${join(stageDir, '*')}' -DestinationPath '${zipPath}' -CompressionLevel Optimal -Force`,
68
+ ];
69
+ } else {
70
+ cmd = 'zip';
71
+ args = ['-r', '-q', zipPath, '.'];
72
+ }
73
+ const child = spawn(cmd, args, {
74
+ cwd: isWin ? undefined : stageDir,
75
+ windowsHide: true,
76
+ stdio: ['ignore', 'pipe', 'pipe'],
77
+ });
78
+ let err = '';
79
+ child.stderr?.on('data', (c) => { err += c.toString(); });
80
+ child.on('error', (e) => done({ ok: false, detail: `压缩失败: ${e.message}` }));
81
+ child.on('close', (code) => {
82
+ if (code === 0 && existsSync(zipPath)) done({ ok: true, detail: 'ok' });
83
+ else done({ ok: false, detail: `压缩退出码 ${code}: ${err.slice(0, 300)}` });
84
+ });
85
+ // 10 分钟上限
86
+ setTimeout(() => { child.kill(); done({ ok: false, detail: '压缩超时' }); }, 10 * 60 * 1000).unref?.();
87
+ });
88
+ }
89
+
90
+ /** 时间戳文件名:2026-11-05T17-44-00 */
91
+ function stamp(now = new Date()) {
92
+ const p = (n) => String(n).padStart(2, '0');
93
+ return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}T${p(now.getHours())}-${p(now.getMinutes())}-${p(now.getSeconds())}`;
94
+ }
95
+
96
+ /** 从备份文件名解析时间(用于轮转排序)。解析不出返回 0。 */
97
+ function stampOf(name) {
98
+ const m = /archivist-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/.exec(name);
99
+ if (!m) return 0;
100
+ return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime();
101
+ }
102
+
103
+ /**
104
+ * 执行一次备份。
105
+ * @param {object} opts
106
+ * @param {string} opts.botDir
107
+ * @param {string} opts.dataDir
108
+ * @param {object} opts.cfg worlds.archivist 的活引用
109
+ * @param {(msg: string, data?: object) => void} opts.log
110
+ * @returns {Promise<{ok: boolean, file?: string, bytes?: number, ms?: number, detail: string}>}
111
+ */
112
+ export async function runBackup({ botDir, dataDir, cfg, log }) {
113
+ const started = Date.now();
114
+ const errors = [];
115
+ const onError = (msg) => { errors.push(msg); if (errors.length <= 20) log(`跳过 ${msg}`); };
116
+
117
+ // ---- 决定目标目录 ----
118
+ const destDir = cfg.destDir?.trim() ? resolve(cfg.destDir.trim()) : join(dataDir, 'archivist');
119
+ try {
120
+ mkdirSync(destDir, { recursive: true });
121
+ } catch (e) {
122
+ return { ok: false, detail: `无法创建备份目录 ${destDir}: ${e.message}` };
123
+ }
124
+
125
+ // ---- 暂存目录 ----
126
+ const stage = join(tmpdir(), `coopanion-archivist-${randomUUID()}`);
127
+ await mkdir(stage, { recursive: true });
128
+
129
+ try {
130
+ const done = [];
131
+
132
+ // ---- 1. 工作区(workspace 主体 / .git / blobs 三个独立开关) ----
133
+ const wantWorkspace = cfg.sources?.workspace ?? true;
134
+ const wantGit = cfg.sources?.['workspace-git'] ?? false;
135
+ const wantBlobs = cfg.sources?.['workspace-blobs'] ?? true;
136
+ if (wantWorkspace || wantGit || wantBlobs) {
137
+ const wsSrc = join(botDir, 'workspace');
138
+ if (existsSync(wsSrc)) {
139
+ const target = join(stage, 'workspace');
140
+ await mkdir(target, { recursive: true });
141
+ const skipped = new Set();
142
+ if (!wantBlobs) skipped.add('blobs');
143
+ if (!wantGit) skipped.add('.git');
144
+
145
+ if (wantWorkspace && skipped.size === 0) {
146
+ // 全量:一次性复制,最快
147
+ await copyDir(wsSrc, target, onError);
148
+ done.push('workspace(全量)');
149
+ } else if (wantWorkspace) {
150
+ // 主体:复制 workspace 的子项,跳过未勾选的
151
+ for (const entry of await readdir(wsSrc, { withFileTypes: true }).catch(() => [])) {
152
+ if (skipped.has(entry.name)) continue;
153
+ const s = join(wsSrc, entry.name);
154
+ const d = join(target, entry.name);
155
+ if (entry.isDirectory()) await copyDir(s, d, onError);
156
+ else if (entry.isFile()) await copyOne(s, d, onError);
157
+ }
158
+ const label = `workspace(${skipped.size ? '不含 ' + [...skipped].join('/') : '全量'})`;
159
+ done.push(label);
160
+ } else {
161
+ // 不需要主体,只按勾选补 .git / blobs
162
+ if (wantGit) {
163
+ const gSrc = join(wsSrc, '.git');
164
+ if (existsSync(gSrc)) {
165
+ await copyDir(gSrc, join(target, '.git'), onError);
166
+ done.push('git历史');
167
+ }
168
+ }
169
+ if (wantBlobs) {
170
+ const bSrc = join(wsSrc, 'blobs');
171
+ if (existsSync(bSrc)) {
172
+ await copyDir(bSrc, join(target, 'blobs'), onError);
173
+ done.push('blobs');
174
+ }
175
+ }
176
+ }
177
+ } else {
178
+ onError(`工作区不存在: ${wsSrc}`);
179
+ }
180
+ }
181
+
182
+ // ---- 2. 扩展数据(自动扫描 data 目录,跳过可重建的临时状态)----
183
+ if (cfg.sources?.data ?? true) {
184
+ const target = join(stage, 'data');
185
+ await mkdir(target, { recursive: true });
186
+ let nFiles = 0;
187
+ let nDirs = 0;
188
+
189
+ let entries = [];
190
+ try {
191
+ entries = await readdir(dataDir, { withFileTypes: true });
192
+ } catch (e) {
193
+ onError(`读不了数据目录 ${dataDir}: ${e.code ?? e.message}`);
194
+ }
195
+
196
+ for (const entry of entries) {
197
+ // 跳过不需要备份的目录(venv、浏览器 profile、运行日志等)
198
+ if (entry.isDirectory()) {
199
+ if (DATA_SKIP_DIRS.has(entry.name)) continue;
200
+ // 其它子目录整包带走(有些扩展把数据放在自己的子目录里)
201
+ await copyDir(join(dataDir, entry.name), join(target, entry.name), onError);
202
+ nDirs++;
203
+ continue;
204
+ }
205
+ if (!entry.isFile()) continue;
206
+ // 跳过可重建的状态/缓存文件
207
+ if (DATA_SKIP.has(entry.name)) continue;
208
+ // 只收数据类文件,跳过归档产物与日志
209
+ if (!/\.(json|jsonl|db|sqlite|yaml|yml|txt|md)$/i.test(entry.name)) continue;
210
+ if (await copyOne(join(dataDir, entry.name), join(target, entry.name), onError)) nFiles++;
211
+ }
212
+
213
+ const parts = [];
214
+ if (nFiles) parts.push(`${nFiles} 个文件`);
215
+ if (nDirs) parts.push(`${nDirs} 个目录`);
216
+ if (parts.length) done.push(`扩展数据(${parts.join(' + ')})`);
217
+ }
218
+
219
+ // ---- 2b. 用户指定的额外路径 ----
220
+ const extra = String(cfg.extraPaths ?? '')
221
+ .split(/\r?\n/)
222
+ .map((s) => s.trim())
223
+ .filter(Boolean)
224
+ .filter((s) => !s.startsWith('#'));
225
+ if (extra.length) {
226
+ const target = join(stage, 'extra');
227
+ let n = 0;
228
+ for (const raw of extra) {
229
+ const abs = resolve(botDir, raw);
230
+ if (!existsSync(abs)) {
231
+ onError(`额外路径不存在: ${raw}`);
232
+ continue;
233
+ }
234
+ const st = statSync(abs);
235
+ const name = basename(abs);
236
+ if (st.isDirectory()) {
237
+ await copyDir(abs, join(target, name), onError);
238
+ n++;
239
+ } else if (st.isFile()) {
240
+ if (await copyOne(abs, join(target, name), onError)) n++;
241
+ }
242
+ }
243
+ if (n) done.push(`额外路径(${n})`);
244
+ }
245
+
246
+ // ---- 3. 配置与密钥 ----
247
+ if (cfg.sources?.config ?? true) {
248
+ const target = join(stage, 'config');
249
+ await mkdir(target, { recursive: true });
250
+ let n = 0;
251
+ for (const f of CONFIG_FILES) {
252
+ if (await copyOne(join(botDir, f), join(target, f), onError)) n++;
253
+ }
254
+ for (const d of CONFIG_DIRS) {
255
+ const src = join(botDir, d);
256
+ if (existsSync(src)) {
257
+ await copyDir(src, join(target, d), onError);
258
+ n++;
259
+ }
260
+ }
261
+ if (n) done.push(`配置(${n}项)`);
262
+ }
263
+
264
+ // ---- 4. 扩展包(默认关,体积大) ----
265
+ if (cfg.includeExtensions) {
266
+ // 扩展在部署的上一级:<data>\extensions
267
+ const extSrc = join(dirname(botDir), '..', 'extensions');
268
+ const extAbs = resolve(extSrc);
269
+ if (existsSync(extAbs)) {
270
+ await copyDir(extAbs, join(stage, 'extensions'), onError);
271
+ done.push('extensions');
272
+ } else {
273
+ onError(`扩展目录不存在: ${extAbs}`);
274
+ }
275
+ }
276
+
277
+ if (!done.length) {
278
+ return { ok: false, detail: `没有可备份的内容(勾选项都没匹配到文件)` };
279
+ }
280
+
281
+ // ---- 5. 写一份清单 ----
282
+ const manifest = [
283
+ `# Coopanion 归档清单`,
284
+ ``,
285
+ `- 时间: ${new Date().toISOString()}`,
286
+ `- 主机: ${process.env.COMPUTERNAME ?? process.env.HOSTNAME ?? 'unknown'}`,
287
+ `- botDir: ${botDir}`,
288
+ `- dataDir: ${dataDir}`,
289
+ `- 内容: ${done.join('、')}`,
290
+ `- 跳过: ${errors.length} 个文件(详见日志)`,
291
+ ``,
292
+ `## 恢复方式`,
293
+ ``,
294
+ `1. 解压本 zip`,
295
+ `2. workspace/ → 复制回 <botDir>/workspace/`,
296
+ `3. data/ → 复制回 <dataDir>/(各扩展的数据文件)`,
297
+ `4. config/config.json → 复制回 <botDir>/config.json`,
298
+ `5. config/.env → 复制回 <botDir>/.env`,
299
+ `6. config/providers/ → 复制回 <botDir>/providers/`,
300
+ `7. extra/ → 按原路径放回(用户指定的额外路径)`,
301
+ `8. 重启 Coopanion`,
302
+ ``,
303
+ `注意:data/ 里不含体积大且可重建的运行时目录`,
304
+ `(fish-sing 的 venv、socialmedia 的浏览器 profile、runs 日志等)。`,
305
+ `恢复后扩展会在下次启动时自行重建它们。`,
306
+ ``,
307
+ ].join('\n');
308
+ const { writeFile } = await import('node:fs/promises');
309
+ await writeFile(join(stage, 'MANIFEST.md'), manifest, 'utf8');
310
+
311
+ // ---- 6. 压缩 ----
312
+ const zipPath = join(destDir, `archivist-${stamp()}.zip`);
313
+ const z = await compress(stage, zipPath);
314
+ if (!z.ok) return { ok: false, detail: z.detail };
315
+
316
+ const bytes = statSync(zipPath).size;
317
+
318
+ // ---- 7. 轮转 ----
319
+ const removed = await rotate(destDir, cfg.keepCount ?? 14, log);
320
+
321
+ const ms = Date.now() - started;
322
+ return {
323
+ ok: true,
324
+ file: zipPath,
325
+ bytes,
326
+ ms,
327
+ detail: `已备份 ${done.join('、')}${errors.length ? `,跳过 ${errors.length} 个文件` : ''}${removed ? `,清理 ${removed} 份旧包` : ''}`,
328
+ };
329
+ } catch (e) {
330
+ return { ok: false, detail: `备份异常: ${e.message}` };
331
+ } finally {
332
+ // 无论成败都清暂存目录
333
+ await rm(stage, { recursive: true, force: true }).catch(() => {});
334
+ }
335
+ }
336
+
337
+ /** 只保留最近 keep 份,返回删除数量。 */
338
+ async function rotate(destDir, keep, log) {
339
+ let files;
340
+ try {
341
+ files = readdirSync(destDir).filter((f) => /^archivist-.*\.zip$/.test(f));
342
+ } catch {
343
+ return 0;
344
+ }
345
+ if (files.length <= keep) return 0;
346
+ const sorted = files.map((f) => ({ f, t: stampOf(f) })).sort((a, b) => a.t - b.t);
347
+ const drop = sorted.slice(0, sorted.length - keep);
348
+ let n = 0;
349
+ for (const { f } of drop) {
350
+ try {
351
+ await rm(join(destDir, f), { force: true });
352
+ n++;
353
+ log(`清理旧备份 ${f}`);
354
+ } catch {}
355
+ }
356
+ return n;
357
+ }
358
+
359
+ /** 列出已有备份(给 WebUI / 工具用)。 */
360
+ export function listBackups(destDir) {
361
+ if (!destDir) return [];
362
+ try {
363
+ return readdirSync(destDir)
364
+ .filter((f) => /^archivist-.*\.zip$/.test(f))
365
+ .map((f) => {
366
+ const p = join(destDir, f);
367
+ let bytes = 0;
368
+ let at = 0;
369
+ try { const st = statSync(p); bytes = st.size; at = st.mtimeMs; } catch {}
370
+ return { name: f, path: p, bytes, at };
371
+ })
372
+ .sort((a, b) => b.at - a.at);
373
+ } catch {
374
+ return [];
375
+ }
376
+ }