cortico-world-archivist 0.3.3 → 0.3.4

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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4 — 2026-11-15
4
+
5
+ 修两个会真出事的问题,其中一个来自市场维护者 Pal AI Lab 的反馈。
6
+
7
+ **备份不再把自己打进去(市场反馈)**
8
+
9
+ - 存放位置默认改到**部署目录**下的 `archivist/`,不再放在 `data/archivist`。`data/` 会被「扩展数据」整目录扫描,放在里面等于每份新备份都把之前的 zip 一起打包——zip 本身已经压过,再压一次几乎不变小,单份体积就逐次接近翻倍,最后吃掉整个磁盘或让 `Compress-Archive` 失败。
10
+ - 就算用户自己把存放位置指回 `data/` 里,也不会再自我打包:判定按**绝对路径**做,存放位置和它下面的 `before-restore-*` 一律跳过,也不再进恢复前快照、不被恢复覆盖。
11
+ - 老部署的兼容:如果 `<data>/archivist` 里已经有旧包,继续用老位置。突然换地方会让「保留份数」的轮转在新目录里从零开始,旧包再也清不掉。
12
+
13
+ **恢复:说清范围,且真的能回滚(市场反馈)**
14
+
15
+ - 面板与 README 明写恢复的覆盖面:**所有扩展的数据**、**所有 World 的配置**(`config.json` 里每一个 Worlds 段,不只是本扩展)、**全部密钥**(`.env` 与 `providers/` 下所有渠道)。之前只写了「恢复会覆盖现在的数据」,太含糊。
16
+ - **修掉一个回滚缺口**:`before-restore-<时间>/` 原先只快照 `data/` 的顶层文件,但恢复时 `data/` 的子目录是整个覆盖的。也就是说 `data/stroll`、`data/terrarium`、`data/parlor-history` 这些目录被覆盖后**没有副本**。现在子目录、`providers/`、额外路径都进快照。
17
+ - 额外路径恢复回**原位**:备份时在包里记一张 `extra-map.json`(包内名字 → 原始绝对路径),恢复照它放回。之前只知道 basename,所有额外路径都会被倒在部署目录下。老备份没有这张表,退回旧行为并在结果里说明。
18
+ - 重名的额外路径(如两个 `data`)不再互相覆盖,自动加 `-2`、`-3` 后缀。
19
+
20
+ **备份内容更干净**
21
+
22
+ - 任意层级的浏览器 profile 一律跳过:`stroll/douyin-profile` 这类嵌在扩展自己目录里的 profile 原本整包带走(几十 MB 全是可重建的缓存,打包时还容易撞上 `EBUSY`)。`*‑profile` 后缀名一律算。
23
+ - `data/archivist` 加进跳过名单,作为升级路径上的兜底。
24
+
25
+ **其他**
26
+
27
+ - `package.json` 补上 `repository`(市场建议,便于以后联系)。
28
+ - 测试扩到 193 项,新增覆盖:自我打包、`data/` 子目录快照、额外路径往返、重名、跳过判定、Windows 大小写与路径分隔符。
29
+
3
30
  ## 0.3.3 — 2026-11-05
4
31
 
5
32
  首个公开发布的版本。备份与恢复都在里面。
package/README.md CHANGED
@@ -4,6 +4,9 @@
4
4
 
5
5
  她的长期记忆散在三个地方——`workspace/`(画像、自述、日记、笔记、交接档案)、`data/*.json`(事实库、向量索引、档案、反思、画像卡)、`config.json` + `.env` + `providers/`(配置与密钥)。这个扩展把三处打成一个 zip,滚动保留若干份;要回退时从任意一份放回去。
6
6
 
7
+ > ⚠️ **这三处说的是「备份打些什么」,不是「恢复碰些什么」。**
8
+ > 恢复的覆盖面大得多,见下面[恢复](#恢复)一节——它会覆盖**所有扩展的数据**、**所有 World 的配置**和**全部密钥**。
9
+
7
10
  **零上下文成本**:不进系统前缀(`envPromptVars()` 返回 `null`)、不注册任何工具(`tools()` 返回 `[]`)。它只在后台跑,不吃 token。
8
11
 
9
12
  ## 装
@@ -20,18 +23,34 @@ pnpm add cortico-world-archivist # 或在扩展市场里装
20
23
  | --- | --- |
21
24
  | 备份间隔 | 多久一次(小时)。进程重启后会检查是否超期,超了就立刻补一次 |
22
25
  | 保留份数 | 滚动保留。超过就删最老的 |
23
- | 存放位置 | zip 放哪。留空 = `data/archivist/`。**建议放到另一块硬盘或网盘同步目录** |
26
+ | 存放位置 | zip 放哪。留空 = **部署目录**下的 `archivist/`(在 `data/` 之外,不会被下一次备份打包进去)。**建议放到另一块硬盘或网盘同步目录** |
24
27
  | 备份内容 | 五项可勾:工作区全部 / git 历史 / blobs 附件 / 扩展数据 / 配置与密钥 |
25
28
  | 连带扩展包 | 把 `extensions/` 也打包。几十 MB,一般不用 |
26
29
  | 额外要备份的路径 | 一行一个。给那些把数据放在子目录、或装在别处的扩展补漏 |
27
30
  | 立即备份一次 | 拨到开 → 一分钟内开始,做完自动关回去 |
28
31
 
29
- 「扩展数据」是**自动扫描** `data/` 目录的:装了什么扩展就备份什么,不用手动配。只跳过明确可重建的东西(运行态 JSON、缓存、`session-main.jsonl` 这类流水,以及 `media/`、`figures/`、`emoji/` 这些几十上百 MB 的素材目录)。判定标准写在 `src/config.js` 的 `DATA_SKIP` / `DATA_SKIP_DIRS` 里,附了每一条的理由。
32
+ **存放位置绝不能落在 `data/` 里。** 「扩展数据」会整目录扫描 `data/`,把备份目录放在那里等于每一份新 zip 都把之前的 zip 一起打包——zip 已经压过,再压一次几乎不变小,单份体积逐次接近翻倍,几天就能吃掉整块盘,或者让 `Compress-Archive` 因为单文件过大而失败。
33
+
34
+ 即使你手动把它指回 `data/` 里,扩展也不会自我打包:判定按绝对路径做,存放位置与它下面的 `before-restore-*` 在备份、快照、恢复三处都被排除。老版本升级上来的部署如果 `<data>/archivist` 里已经有旧包,会继续用老位置(否则轮转在新目录里从零开始,旧包再也清不掉)。
35
+
36
+ 「扩展数据」是**自动扫描** `data/` 目录的:装了什么扩展就备份什么,不用手动配。只跳过明确可重建的东西(运行态 JSON、缓存、`session-main.jsonl` 这类流水、任意层级的浏览器 profile,以及 `media/`、`figures/`、`emoji/` 这些几十上百 MB 的素材目录)。判定标准写在 `src/config.js` 的 `DATA_SKIP` / `DATA_SKIP_DIRS` / `DATA_SKIP_ANY_DIR` 里,附了每一条的理由。
30
37
 
31
38
  一份真实部署(9 MB 工作区 + 13 MB 记忆数据)打包后约 **15 MB,4~5 秒**。
32
39
 
33
40
  ## 恢复
34
41
 
42
+ > ### ⚠️ 恢复的覆盖面
43
+ >
44
+ > 恢复**不只是把「她的记忆」放回去**。它按备份里的内容原样写回原位,所以实际覆盖的是:
45
+ >
46
+ > | 覆盖什么 | 具体是 |
47
+ > | --- | --- |
48
+ > | **所有扩展的数据** | `data/` 下**每一个**扩展存的东西:记忆库、事实库、向量索引、档案、卡片、表情包索引、词汇本……不只是本扩展或记忆类扩展 |
49
+ > | **所有 World 的配置** | `config.json` 里的**每一个 Worlds 段**(桌面宠物、QQ、语音、社交、做梦、画画……),不只是 `worlds.archivist` |
50
+ > | **全部密钥** | `.env` 与 `providers/` 下所有模型渠道的 Key 和地址 |
51
+ >
52
+ > 备份之后的**新文件不会被删除**(只覆盖、不删除),但**已有文件会被换成备份时的版本**。文件被覆盖前会先存到 `before-restore-<时间>/`,见下面第 3 步。
53
+
35
54
  恢复默认**只用于灾难恢复**,所以设了两道闸:
36
55
 
37
56
  1. **确认口令**:必须先输入 `RESTORE`(或「恢复」),否则「立即恢复」开关不动。
@@ -46,6 +65,8 @@ pnpm add cortico-world-archivist # 或在扩展市场里装
46
65
  ④ 才复制覆盖
47
66
  ```
48
67
 
68
+ 第 ③ 步快照的范围与第 ④ 步覆盖的范围**一一对应**:`workspace/` 的各个子项、`data/` 的顶层数据文件**和每个会被整包覆盖的子目录**、`config.json` / `.env` / `providers/`、以及额外路径(存在快照的 `extra/` 下,按包内名字对应)。所以任何一个被恢复覆盖的位置都拿得回来。
69
+
49
70
  **恢复完必须重启 Coopanion。** 记忆类扩展(如 memory-extra)把数据缓存在内存里、每 2 秒全量 flush 一次,不重启会被旧内存写回去。`workspace/*.md` 是每次现读的,会立刻生效。
50
71
 
51
72
  所以恢复完会再等 3 秒复查一遍:如果 `data/` 里的文件在恢复后立刻被改写(内容变了,不只是时间戳),说明进程正把旧数据写回磁盘,这时结果里会直接说「这次恢复对它们无效,请完全退出 Coopanion 后重新恢复」,而不是留下一个看起来成功的假象。
@@ -77,6 +98,8 @@ pnpm add cortico-world-archivist # 或在扩展市场里装
77
98
 
78
99
  - zip 打包和解压调系统能力(Windows 用 `Compress-Archive` / `Expand-Archive`,macOS/Linux 用 `zip` / `unzip`),不引第三方依赖,所以打包时源文件被并发改写可能进包失败——这也是先复制到临时目录再压缩的原因。
79
100
  - 恢复 `config.json` 会连 `worlds.archivist` 这一段一起还原,包括当时的「存放位置」。如果你改过存放位置,恢复后它也会变回去。
101
+ - 快照与备份都会跳过浏览器 profile(`*‑profile`),所以这些缓存不在回滚范围内——它们本来也能重建。
102
+ - 恢复**只覆盖、不删除**:备份之后新写出来的文件会留在原地,不会因为你回退到旧备份而消失。
80
103
  - 不做增量备份,每次都是全量。这样单份 zip 自包含,恢复时不用找链条。
81
104
 
82
105
  ## 开发
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "cortico-world-archivist",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "g_zone"
7
7
  },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/z-o-n-e/cortico-world-archivist.git"
11
+ },
8
12
  "description": "备份与恢复 —— 定期把她的工作区、记忆数据和配置打包成 zip,滚动保留若干份;需要时一键恢复(恢复前自动留存当前状态)。零上下文成本:不进系统前缀、不占工具表。",
9
13
  "license": "MIT",
10
14
  "os": [
package/src/backup.js CHANGED
@@ -8,15 +8,50 @@
8
8
  * - 任何一步失败都清理临时目录,不留下垃圾
9
9
  */
10
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';
11
+ import { copyFile, rm, mkdir, readdir, stat, writeFile } from 'node:fs/promises';
12
+ import { join, basename, dirname, resolve, sep } from 'node:path';
13
13
  import { tmpdir } from 'node:os';
14
14
  import { spawn } from 'node:child_process';
15
15
  import { randomUUID } from 'node:crypto';
16
- import { CONFIG_FILES, CONFIG_DIRS, DATA_SKIP, DATA_SKIP_DIRS } from './config.js';
16
+ import { CONFIG_FILES, CONFIG_DIRS, DATA_SKIP, DATA_SKIP_DIRS, skipAnyDir } from './config.js';
17
+
18
+ /** 绝对路径统一形态(Windows 不分大小写),用来比对「在不在某个目录里」。 */
19
+ function norm(p) {
20
+ const abs = resolve(p);
21
+ return process.platform === 'win32' ? abs.toLowerCase() : abs;
22
+ }
23
+
24
+ /** abs 是否就是 root 或落在 root 里面。 */
25
+ export function isInside(abs, root) {
26
+ const a = norm(abs);
27
+ const r = norm(root);
28
+ return a === r || a.startsWith(r.endsWith(sep) ? r : r + sep);
29
+ }
30
+
31
+ /**
32
+ * 配置没填存放位置时的默认目录。
33
+ *
34
+ * 默认放部署目录下(botDir),**不放** data/ 里:data/ 会被「扩展数据」整目录扫描,
35
+ * 放在里面等于每份备份都把之前的 zip 一起打包——zip 已经压过,再打一次几乎不变小,
36
+ * 单份体积就逐次接近翻倍,直到占满磁盘或压缩失败。
37
+ *
38
+ * 但老部署升级上来时 `<dataDir>/archivist` 里可能已经有旧包。继续用它,
39
+ * 否则「保留份数」的轮转会在新目录里从零开始,旧包再也清不掉。
40
+ *
41
+ * @param {{botDir: string, dataDir: string}} dirs
42
+ */
43
+ export function defaultDestDir({ botDir, dataDir }) {
44
+ const legacy = join(dataDir, 'archivist');
45
+ try {
46
+ if (existsSync(legacy) && readdirSync(legacy).some((f) => f.endsWith('.zip'))) return legacy;
47
+ } catch {
48
+ // 读不了就按新默认走
49
+ }
50
+ return join(botDir, 'archivist');
51
+ }
17
52
 
18
53
  /** 递归复制目录;onError 收集单个文件的失败,不中断整体。 */
19
- async function copyDir(src, dest, onError) {
54
+ async function copyDir(src, dest, onError, skip) {
20
55
  await mkdir(dest, { recursive: true });
21
56
  let entries;
22
57
  try {
@@ -28,8 +63,9 @@ async function copyDir(src, dest, onError) {
28
63
  for (const entry of entries) {
29
64
  const s = join(src, entry.name);
30
65
  const d = join(dest, entry.name);
66
+ if (skip?.(s, entry.name, entry.isDirectory())) continue;
31
67
  if (entry.isDirectory()) {
32
- await copyDir(s, d, onError);
68
+ await copyDir(s, d, onError, skip);
33
69
  } else if (entry.isFile()) {
34
70
  try {
35
71
  await copyFile(s, d);
@@ -100,6 +136,35 @@ function stampOf(name) {
100
136
  return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime();
101
137
  }
102
138
 
139
+ /**
140
+ * 把 `extraPaths` 配置解析成「包里的名字 → 原始绝对路径」。
141
+ *
142
+ * 单独抽出来是为了备份与恢复用同一份映射:恢复时才知道该放回哪里,
143
+ * 快照时也知道该给哪些路径留副本。重名就加 `-2`、`-3` 后缀。
144
+ *
145
+ * @param {string} botDir
146
+ * @param {object} cfg
147
+ * @returns {{name: string, abs: string}[]}
148
+ */
149
+ export function extraTargets(botDir, cfg) {
150
+ const raw = String(cfg?.extraPaths ?? '')
151
+ .split(/\r?\n/)
152
+ .map((s) => s.trim())
153
+ .filter(Boolean)
154
+ .filter((s) => !s.startsWith('#'));
155
+ const out = [];
156
+ const used = new Set();
157
+ for (const item of raw) {
158
+ const abs = resolve(botDir, item);
159
+ if (!existsSync(abs)) continue;
160
+ let name = basename(abs);
161
+ for (let i = 2; used.has(name); i++) name = `${basename(abs)}-${i}`;
162
+ used.add(name);
163
+ out.push({ name, abs });
164
+ }
165
+ return out;
166
+ }
167
+
103
168
  /**
104
169
  * 执行一次备份。
105
170
  * @param {object} opts
@@ -115,7 +180,9 @@ export async function runBackup({ botDir, dataDir, cfg, log }) {
115
180
  const onError = (msg) => { errors.push(msg); if (errors.length <= 20) log(`跳过 ${msg}`); };
116
181
 
117
182
  // ---- 决定目标目录 ----
118
- const destDir = cfg.destDir?.trim() ? resolve(cfg.destDir.trim()) : join(dataDir, 'archivist');
183
+ // 与 world.js 的 destDir() 保持一致:默认放在部署目录(botDir)下,不在 data/ 里面。
184
+ // 老部署如果已经在 <dataDir>/archivist 里攒了旧包,就继续用那里。
185
+ const destDir = cfg.destDir?.trim() ? resolve(cfg.destDir.trim()) : defaultDestDir({ botDir, dataDir });
119
186
  try {
120
187
  mkdirSync(destDir, { recursive: true });
121
188
  } catch (e) {
@@ -193,12 +260,24 @@ export async function runBackup({ botDir, dataDir, cfg, log }) {
193
260
  onError(`读不了数据目录 ${dataDir}: ${e.code ?? e.message}`);
194
261
  }
195
262
 
263
+ // 存放位置可能就在 data/ 里面(老版本的默认位置)。绝不能把它打进包:
264
+ // zip 本身已经压过,再打一次几乎不变小,体积会逐份接近翻倍,直到占满磁盘。
265
+ // 这里按绝对路径判定,比只比名字更稳——用户也可能把存放位置指到 data/ 的某个子目录。
266
+ const isSelfDir = (abs) => isInside(abs, destDir);
267
+
196
268
  for (const entry of entries) {
197
269
  // 跳过不需要备份的目录(venv、浏览器 profile、运行日志等)
198
270
  if (entry.isDirectory()) {
199
271
  if (DATA_SKIP_DIRS.has(entry.name)) continue;
200
- // 其它子目录整包带走(有些扩展把数据放在自己的子目录里)
201
- await copyDir(join(dataDir, entry.name), join(target, entry.name), onError);
272
+ const abs = join(dataDir, entry.name);
273
+ if (isSelfDir(abs)) {
274
+ log(`跳过自己的存放位置 ${abs}(否则备份会把自己打进去)`);
275
+ continue;
276
+ }
277
+ // 其它子目录整包带走(有些扩展把数据放在自己的子目录里),
278
+ // 但任意层级上的浏览器 profile 一律跳过。
279
+ await copyDir(abs, join(target, entry.name), onError,
280
+ (p, name, isDir) => isDir && (skipAnyDir(name) || isSelfDir(p)));
202
281
  nDirs++;
203
282
  continue;
204
283
  }
@@ -217,22 +296,19 @@ export async function runBackup({ botDir, dataDir, cfg, log }) {
217
296
  }
218
297
 
219
298
  // ---- 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) {
299
+ const extraList = extraTargets(botDir, cfg);
300
+ // 逐条报出「配了但不存在」的路径,别让它静默消失
301
+ for (const raw of String(cfg.extraPaths ?? '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)) {
302
+ if (raw.startsWith('#')) continue;
303
+ if (!extraList.some((e) => e.abs === resolve(botDir, raw))) onError(`额外路径不存在: ${raw}`);
304
+ }
305
+ if (extraList.length) {
226
306
  const target = join(stage, 'extra');
307
+ const mapping = {};
227
308
  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
- }
309
+ for (const { name, abs } of extraList) {
310
+ mapping[name] = abs;
234
311
  const st = statSync(abs);
235
- const name = basename(abs);
236
312
  if (st.isDirectory()) {
237
313
  await copyDir(abs, join(target, name), onError);
238
314
  n++;
@@ -240,7 +316,14 @@ export async function runBackup({ botDir, dataDir, cfg, log }) {
240
316
  if (await copyOne(abs, join(target, name), onError)) n++;
241
317
  }
242
318
  }
243
- if (n) done.push(`额外路径(${n})`);
319
+ // 记下「包里的名字 → 原始绝对路径」,恢复时才能放回原处
320
+ // (光看名字只知道 basename,会一股脑倒在 botDir 下)
321
+ if (n) {
322
+ await writeFile(
323
+ join(target, 'extra-map.json'), JSON.stringify(mapping, null, 2), 'utf8',
324
+ );
325
+ done.push(`额外路径(${n})`);
326
+ }
244
327
  }
245
328
 
246
329
  // ---- 3. 配置与密钥 ----
@@ -305,7 +388,6 @@ export async function runBackup({ botDir, dataDir, cfg, log }) {
305
388
  `恢复后扩展会在下次启动时自行重建它们。`,
306
389
  ``,
307
390
  ].join('\n');
308
- const { writeFile } = await import('node:fs/promises');
309
391
  await writeFile(join(stage, 'MANIFEST.md'), manifest, 'utf8');
310
392
 
311
393
  // ---- 6. 压缩 ----
package/src/config.js CHANGED
@@ -17,7 +17,7 @@ export const TITLES_ZH = {
17
17
  keepCountNote: '备份是滚动的:超过这个数量就自动删掉最老的一份。',
18
18
  copies: '份',
19
19
  destDir: '存放位置',
20
- destDirNote: '备份 zip 存到哪个文件夹。留空 = 数据目录下的 archivist 文件夹。建议放到另一块硬盘或网盘同步目录。',
20
+ destDirNote: '备份 zip 存到哪个文件夹。留空 = 部署目录下的 archivist 文件夹(在 data/ 之外,不会被下一次备份打包进去)。建议放到另一块硬盘或网盘同步目录。',
21
21
  extraPaths: '额外要备份的路径',
22
22
  extraPathsNote: '一行一个,绝对路径或相对部署目录均可。给那些把数据放在子目录、或装在别处的扩展补漏。一般不用填。',
23
23
  includeExtensions: '连带扩展包',
@@ -30,11 +30,11 @@ export const TITLES_ZH = {
30
30
  lastResult: '上次备份结果',
31
31
  // ---- 恢复 ----
32
32
  restoreTitle: '恢复(危险)',
33
- restoreNote: '把备份里的文件放回原位。恢复前会先把当前状态存一份到存放位置下的 before-restore-<时间>/,万一后悔还能拿回来。恢复完成后必须重启 Coopanion。',
33
+ restoreNote: '把备份里的文件原样放回原位。⚠️ 覆盖面比「她的记忆」大得多:恢复会覆盖【所有扩展的数据目录与数据文件】(data/ 下各扩展存的记忆库、档案、卡片、表情包索引、词汇本等)、【所有 World 的配置】(config.json 里的每一个 Worlds 段,不只是本扩展)、以及【全部密钥】(.env、providers/ 选择器里的所有模型渠道 Key)。恢复前会先把当前状态存一份到存放位置下的 before-restore-<时间>/,万一后悔还能拿回来。恢复完成后必须重启 Coopanion。',
34
34
  restoreFile: '要恢复的备份',
35
35
  restoreFileNote: '选一个 .zip 备份文件。留空 = 用存放位置里最新的那一份。',
36
36
  restoreConfirm: '确认口令',
37
- restoreConfirmNote: '恢复会覆盖现在的数据。确实要恢复,请在这里原样输入 RESTORE(或「恢复」两个字)。口令不对,下面的开关不会动作。',
37
+ restoreConfirmNote: '恢复会覆盖上面说的全部范围。确实要恢复,请在这里原样输入 RESTORE(或「恢复」两个字)。口令不对,下面的开关不会动作。',
38
38
  restoreNow: '立即恢复',
39
39
  restoreNowNote: '口令填对之后拨到开 → 一分钟内开始恢复,做完自动关回去。恢复期间请勿关闭 Coopanion。',
40
40
  lastRestoreAt: '上次恢复时间',
@@ -52,7 +52,7 @@ export const TITLES_EN = {
52
52
  keepCountNote: 'Rolling: the oldest archive is deleted once this many exist.',
53
53
  copies: 'files',
54
54
  destDir: 'Destination',
55
- destDirNote: 'Folder for the zip files. Empty = the archivist folder inside the data directory. Prefer another disk or a synced folder.',
55
+ destDirNote: 'Folder for the zip files. Empty = the archivist folder in the deployment directory, outside data/, so backups never pack themselves. Prefer another disk or a synced folder.',
56
56
  extraPaths: 'Extra paths to back up',
57
57
  extraPathsNote: 'One per line; absolute, or relative to the deployment directory. For extensions that keep data in a subfolder or elsewhere. Usually unnecessary.',
58
58
  includeExtensions: 'Include extensions',
@@ -65,11 +65,11 @@ export const TITLES_EN = {
65
65
  lastResult: 'Backup result',
66
66
  // ---- restore ----
67
67
  restoreTitle: 'Restore (dangerous)',
68
- restoreNote: 'Puts archived files back where they belong. The current state is first copied to before-restore-<time>/ inside the destination folder, so a mistake is recoverable. Coopanion must be restarted afterwards.',
68
+ restoreNote: 'Puts archived files back where they belong. ⚠️ This covers far more than her memories: it overwrites ALL extension data (the memory stores, archives, cards, sticker index and vocabulary that every extension keeps under data/), ALL World configuration (every Worlds section in config.json, not just this extension), and ALL API keys (.env plus every model channel under providers/). The current state is first copied to before-restore-<time>/ inside the destination folder, so a mistake is recoverable. Coopanion must be restarted afterwards.',
69
69
  restoreFile: 'Archive to restore',
70
70
  restoreFileNote: 'Pick a .zip archive. Empty = the newest one in the destination folder.',
71
71
  restoreConfirm: 'Confirmation code',
72
- restoreConfirmNote: 'Restoring overwrites current data. To proceed, type RESTORE here exactly. The switch below does nothing without it.',
72
+ restoreConfirmNote: 'Restoring overwrites everything listed above. To proceed, type RESTORE here exactly. The switch below does nothing without it.',
73
73
  restoreNow: 'Restore now',
74
74
  restoreNowNote: 'With the code in place, switch on → the restore starts within a minute and the switch flips back. Do not quit Coopanion while it runs.',
75
75
  lastRestoreAt: 'Last restore',
@@ -127,9 +127,9 @@ export const SOURCES = [
127
127
  root: 'data',
128
128
  defaultOn: true,
129
129
  title: '扩展数据(data/ 下的文件)',
130
- note: '自动扫描 data 目录里所有扩展存的数据:记忆库、档案、卡片、表情包索引、词汇本……装了什么扩展就备份什么,不用手动配。仅跳过可重建的临时状态与缓存。',
130
+ note: '自动扫描 data 目录里所有扩展存的数据:记忆库、档案、卡片、表情包索引、词汇本……装了什么扩展就备份什么,不用手动配。仅跳过可重建的临时状态、缓存与浏览器 profile,以及备份自己的存放位置。',
131
131
  enTitle: 'Extension data (files under data/)',
132
- enNote: 'Scans the data folder for whatever the installed extensions store: memory databases, archives, cards, sticker index, vocabulary… Nothing to configure — only rebuildable runtime state and caches are skipped.',
132
+ enNote: 'Scans the data folder for whatever the installed extensions store: memory databases, archives, cards, sticker index, vocabulary… Nothing to configure — only rebuildable runtime state, caches, browser profiles and the backup folder itself are skipped.',
133
133
  },
134
134
  // ---- 配置 ----
135
135
  {
@@ -138,9 +138,9 @@ export const SOURCES = [
138
138
  root: 'bot',
139
139
  defaultOn: true,
140
140
  title: '配置与密钥',
141
- note: 'config.json、.env、providers/ 下的模型渠道配置',
141
+ note: 'config.json、.env、providers/ 下的模型渠道配置。注意这是整套配置:恢复时 config.json 里【所有 World 的配置】和【全部渠道密钥】都会一起回到备份时的样子',
142
142
  enTitle: 'Config and secrets',
143
- enNote: 'config.json, .env, and the model channel configs under providers/.',
143
+ enNote: 'config.json, .env, and the model channel configs under providers/. Note that this is the whole thing: restoring puts back EVERY World section in config.json and EVERY channel key as of the backup.',
144
144
  },
145
145
  ];
146
146
 
@@ -188,8 +188,33 @@ export const DATA_SKIP_DIRS = new Set([
188
188
  'qq-video', // 视频缓存
189
189
  'emote-pics-popup', // 弹窗临时资源
190
190
  'bilibili-video', // 视频素材缓存
191
+ 'archivist', // 老版本的默认存放位置。留着这一条,免得升级后把旧包又打进去
191
192
  ]);
192
193
 
194
+ /**
195
+ * 目录名命中就算跳过 —— 在任意层级的子目录上都生效。
196
+ *
197
+ * `DATA_SKIP_DIRS` 只比对 data/ 的顶层,所以像 `stroll/douyin-profile`
198
+ * 这种嵌在扩展自己的目录里的浏览器 profile 会整包被带走(几十 MB,
199
+ * 全是可以重建的缓存,打包时还容易撞上 EBUSY)。
200
+ */
201
+ export const DATA_SKIP_ANY_DIR = new Set([
202
+ 'douyin-profile',
203
+ 'browser-profile',
204
+ 'chrome-profile',
205
+ '*-profile',
206
+ ]);
207
+
208
+ /**
209
+ * 判断某个目录名(任意层级)是否该跳过。
210
+ * 支持 `*-profile` 这种后缀式通配。
211
+ * @param {string} name
212
+ */
213
+ export function skipAnyDir(name) {
214
+ if (DATA_SKIP_ANY_DIR.has(name)) return true;
215
+ return name.endsWith('-profile');
216
+ }
217
+
193
218
  /** config 源要打包的具体文件(相对 botDir)。 */
194
219
  export const CONFIG_FILES = [
195
220
  'config.json',
package/src/restore.js CHANGED
@@ -12,12 +12,13 @@
12
12
  * 恢复完成会等几秒复查一遍:真被写回去了就当场喊出来,别让人以为恢复成功了。
13
13
  */
14
14
  import { existsSync } from 'node:fs';
15
- import { copyFile, rm, mkdir, readdir, readFile, rename } from 'node:fs/promises';
15
+ import { copyFile, rm, mkdir, readdir, readFile, rename, stat } from 'node:fs/promises';
16
16
  import { join, basename, dirname, resolve } from 'node:path';
17
17
  import { tmpdir } from 'node:os';
18
18
  import { spawn } from 'node:child_process';
19
19
  import { createHash, randomUUID } from 'node:crypto';
20
- import { CONFIG_FILES, CONFIG_DIRS } from './config.js';
20
+ import { CONFIG_FILES, CONFIG_DIRS, DATA_SKIP, DATA_SKIP_DIRS, skipAnyDir } from './config.js';
21
+ import { defaultDestDir, isInside, extraTargets } from './backup.js';
21
22
 
22
23
  /** 一个文件的 sha1;读不到给 null。 */
23
24
  async function hashFile(path) {
@@ -111,8 +112,8 @@ function decompress(zipPath, outDir) {
111
112
  });
112
113
  }
113
114
 
114
- /** 递归复制目录并覆盖。 */
115
- async function overwriteDir(src, dest, onError, stats) {
115
+ /** 递归复制目录并覆盖。`skip(path, name, isDir)` 返回 true 的条目直接不碰。 */
116
+ async function overwriteDir(src, dest, onError, stats, skip) {
116
117
  await mkdir(dest, { recursive: true });
117
118
  let entries;
118
119
  try {
@@ -124,7 +125,8 @@ async function overwriteDir(src, dest, onError, stats) {
124
125
  for (const entry of entries) {
125
126
  const s = join(src, entry.name);
126
127
  const d = join(dest, entry.name);
127
- if (entry.isDirectory()) await overwriteDir(s, d, onError, stats);
128
+ if (skip?.(s, entry.name, entry.isDirectory())) continue;
129
+ if (entry.isDirectory()) await overwriteDir(s, d, onError, stats, skip);
128
130
  else if (entry.isFile()) {
129
131
  try {
130
132
  await copyFile(s, d);
@@ -189,10 +191,21 @@ async function snapshotCurrent({ botDir, dataDir, cfg, destDir, onError, stats }
189
191
  if (wantData && existsSync(dataDir)) {
190
192
  const dst = join(target, 'data');
191
193
  await mkdir(dst, { recursive: true });
192
- // 只快照备份包里会恢复的那些顶层文件(与 backup.js 的扫描规则一致)
194
+ // 和 backup.js 的扫描规则保持一致:顶层数据文件 + 每个整包带走的子目录。
195
+ // 子目录也必须快照——恢复时它们是整个覆盖的(data/stroll、data/terrarium 之类),
196
+ // 只存顶层文件的话,这些目录被覆盖掉就再也拿不回原样了。
193
197
  for (const entry of await readdir(dataDir, { withFileTypes: true }).catch(() => [])) {
194
- if (entry.isFile() && /\.(json|jsonl|db|sqlite|yaml|yml|txt|md)$/i.test(entry.name)) {
195
- await overwriteOne(join(dataDir, entry.name), join(dst, entry.name), onError, stats);
198
+ const src = join(dataDir, entry.name);
199
+ const out = join(dst, entry.name);
200
+ if (entry.isDirectory()) {
201
+ if (DATA_SKIP_DIRS.has(entry.name)) continue;
202
+ if (isInside(src, destDir)) continue; // 自己的存放位置,别把 zip 拷进快照
203
+ await overwriteDir(src, out, onError, stats,
204
+ (p, name, isDir) => isDir && (skipAnyDir(name) || isInside(p, destDir)));
205
+ } else if (entry.isFile()) {
206
+ if (DATA_SKIP.has(entry.name)) continue;
207
+ if (!/\.(json|jsonl|db|sqlite|yaml|yml|txt|md)$/i.test(entry.name)) continue;
208
+ await overwriteOne(src, out, onError, stats);
196
209
  }
197
210
  }
198
211
  }
@@ -203,6 +216,31 @@ async function snapshotCurrent({ botDir, dataDir, cfg, destDir, onError, stats }
203
216
  for (const f of CONFIG_FILES) {
204
217
  await overwriteOne(join(botDir, f), join(dst, f), onError, stats);
205
218
  }
219
+ for (const d of CONFIG_DIRS) {
220
+ const src = join(botDir, d);
221
+ if (existsSync(src)) await overwriteDir(src, join(dst, d), onError, stats);
222
+ }
223
+ }
224
+
225
+ // 额外路径同样会被恢复覆盖,同样得留副本。按「包里的名字」存,
226
+ // 和 zip 里的 extra/ 结构一致,回滚时照着放回去就行。
227
+ const extras = extraTargets(botDir, cfg);
228
+ if (extras.length) {
229
+ const dst = join(target, 'extra');
230
+ await mkdir(dst, { recursive: true });
231
+ for (const { name, abs } of extras) {
232
+ if (isInside(abs, destDir)) continue;
233
+ try {
234
+ if ((await stat(abs)).isDirectory()) {
235
+ await overwriteDir(abs, join(dst, name), onError, stats,
236
+ (p, n2, isDir) => isDir && skipAnyDir(n2));
237
+ } else {
238
+ await overwriteOne(abs, join(dst, name), onError, stats);
239
+ }
240
+ } catch {
241
+ // 单条路径读不到就算了,不拖累整体
242
+ }
243
+ }
206
244
  }
207
245
 
208
246
  return target;
@@ -252,7 +290,9 @@ export async function runRestore({ botDir, dataDir, cfg, zipPath, log }) {
252
290
  return { ok: false, detail: '备份里没有可恢复的内容(workspace/data/config 都不存在)。' };
253
291
  }
254
292
 
255
- const destDir = cfg.destDir?.trim() ? resolve(cfg.destDir.trim()) : join(dataDir, 'archivist');
293
+ const destDir = cfg.destDir?.trim()
294
+ ? resolve(cfg.destDir.trim())
295
+ : defaultDestDir({ botDir, dataDir });
256
296
 
257
297
  // ---- ③ 先备份当前状态 ----
258
298
  const pre = { files: 0 };
@@ -303,9 +343,17 @@ export async function runRestore({ botDir, dataDir, cfg, zipPath, log }) {
303
343
  for (const entry of await readdir(src, { withFileTypes: true }).catch(() => [])) {
304
344
  const s = join(src, entry.name);
305
345
  const t = join(dst, entry.name);
346
+ // 存放位置若在 data/ 内,恢复它就是把备份目录本身覆盖成旧版本。
347
+ // 跳过:这一步没有意义,还会把刚拿到的包弄坏。
348
+ if (isInside(t, destDir)) {
349
+ log(`跳过 ${t}(这是自己的存放位置,不能覆盖)`);
350
+ continue;
351
+ }
352
+ const skip = (p, name, isDir) => isDir && (skipAnyDir(name) || isInside(p, destDir));
306
353
  if (entry.isDirectory()) {
307
- await overwriteDir(s, t, onError, stats);
354
+ await overwriteDir(s, t, onError, stats, skip);
308
355
  } else if (entry.isFile()) {
356
+ if (DATA_SKIP.has(entry.name)) continue;
309
357
  if (await overwriteOne(s, t, onError, stats)) dataFiles.push(t);
310
358
  }
311
359
  n++;
@@ -331,13 +379,24 @@ export async function runRestore({ botDir, dataDir, cfg, zipPath, log }) {
331
379
 
332
380
  if (hasExtra) {
333
381
  const src = join(stage, 'extra');
382
+ // 包里记了「名字 → 原始绝对路径」就照它放回原处;
383
+ // 老备份没有这张表,只能退回旧行为(放进 botDir),并在结果里说清楚。
384
+ let mapping = null;
385
+ try {
386
+ mapping = JSON.parse(await readFile(join(src, 'extra-map.json'), 'utf8'));
387
+ } catch {
388
+ mapping = null;
389
+ }
390
+ let n = 0;
334
391
  for (const entry of await readdir(src, { withFileTypes: true }).catch(() => [])) {
392
+ if (entry.isFile() && entry.name === 'extra-map.json') continue;
335
393
  const s = join(src, entry.name);
336
- const t = join(botDir, entry.name);
337
- if (entry.isDirectory()) await overwriteDir(s, t, onError, stats);
394
+ const t = mapping?.[entry.name] ? resolve(mapping[entry.name]) : join(botDir, entry.name);
395
+ if (entry.isDirectory()) await overwriteDir(s, t, onError, stats, (p, name, isDir) => isDir && skipAnyDir(name));
338
396
  else if (entry.isFile()) await overwriteOne(s, t, onError, stats);
397
+ n++;
339
398
  }
340
- done.push('额外路径');
399
+ if (n) done.push(mapping ? '额外路径' : '额外路径(老备份没有路径表,放到部署目录下)');
341
400
  }
342
401
 
343
402
  if (!done.length) {
package/src/world.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * - 只做后台定时备份,外加面板上的手动备份 / 恢复
8
8
  */
9
9
  import { join, resolve } from 'node:path';
10
- import { runBackup, listBackups } from './backup.js';
10
+ import { runBackup, listBackups, defaultDestDir } from './backup.js';
11
11
  import { runRestore } from './restore.js';
12
12
  import { configGroups, normalizeSources, TITLES_ZH, TITLES_EN } from './config.js';
13
13
  import { fileLogger } from './log.js';
@@ -70,10 +70,21 @@ export class ArchivistWorld {
70
70
  return { config: configGroups(lang === 'en' ? TITLES_EN : TITLES_ZH, lang) };
71
71
  }
72
72
 
73
- /** 目标目录(配置留空时落到 dataDir/archivist)。 */
73
+ /**
74
+ * 目标目录(配置留空时的默认存放位置)。
75
+ *
76
+ * 默认放在**部署目录**(botDir)下,不再放在 data/ 里面。
77
+ * 原因:`data/` 会被「扩展数据」整目录扫描,放在里面等于让每一份新备份
78
+ * 把之前的 zip 也打进去——zip 已经压过,再打一次几乎不变小,体积逐份接近翻倍。
79
+ *
80
+ * 老部署升级上来时,`<dataDir>/archivist` 里可能已经有一堆旧包。
81
+ * 这时如果它非空,就继续用它:突然换地方会让「保留份数」的轮转在新目录里
82
+ * 从零开始,旧包再也清不掉。backup.js 的跳过判定按绝对路径做,两个位置都安全。
83
+ */
74
84
  destDir() {
75
85
  const raw = this.ctx.cfg.destDir;
76
- return raw && String(raw).trim() ? String(raw).trim() : join(this.ctx.dataDir, DEFAULT_DEST_SUBDIR);
86
+ if (raw && String(raw).trim()) return String(raw).trim();
87
+ return defaultDestDir({ botDir: this.ctx.botDir, dataDir: this.ctx.dataDir });
77
88
  }
78
89
 
79
90
  /** 下一次该备份的时间(毫秒时间戳);没有历史则立即。 */
@@ -96,7 +107,7 @@ export class ArchivistWorld {
96
107
  try {
97
108
  dest = this.destDir();
98
109
  } catch {
99
- dest = DEFAULT_DEST_SUBDIR;
110
+ dest = join(this.ctx.dataDir, DEFAULT_DEST_SUBDIR);
100
111
  }
101
112
  if (!this._logger || this._loggerDir !== dest) {
102
113
  this._loggerDir = dest;