luckytri 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.github/SECURITY.md +4 -4
  2. package/README.en.md +252 -250
  3. package/README.md +252 -250
  4. package/bin/luckytri.js +5 -1
  5. package/docs/en/CHANGELOG.md +8 -0
  6. package/docs/en/connect.md +18 -18
  7. package/docs/en/guide.md +202 -216
  8. package/docs/en/npm-release.md +25 -25
  9. package/docs/zh/CHANGELOG.md +8 -0
  10. package/docs/zh/connect.md +17 -17
  11. package/docs/zh/guide.md +202 -216
  12. package/docs/zh/npm-release.md +33 -33
  13. package/package.json +110 -108
  14. package/plugins/sdk/index.d.ts +155 -155
  15. package/plugins/sdk/testing.js +58 -58
  16. package/plugins/weather/index.js +82 -82
  17. package/plugins/weather/luckytri-plugin.json +29 -29
  18. package/public/app/assets/ChatsPage-BhlJHseG.js +3 -0
  19. package/public/app/assets/Empty-DP6WMGHi.js +2 -0
  20. package/public/app/assets/Field-CtbdqaiB.js +1 -0
  21. package/public/app/assets/HeartPage-32BeTPn2.js +1 -0
  22. package/public/app/assets/LifePage-CxSm0XKI.js +1 -0
  23. package/public/app/assets/MemoryPage-BeKBcPCY.js +1 -0
  24. package/public/app/assets/NaturePage-NEPYl2HS.js +3 -0
  25. package/public/app/assets/PeoplePage-DxtpekwM.js +1 -0
  26. package/public/app/assets/PluginsPage-CVVQwEBI.css +1 -0
  27. package/public/app/assets/PluginsPage-DksEZbUU.js +2 -0
  28. package/public/app/assets/{SystemPage-CAMPoYqM.css → SystemPage-BkRu0Rvx.css} +1 -1
  29. package/public/app/assets/SystemPage-BvzJlHuY.js +1 -0
  30. package/public/app/assets/TimePage-7-awH5XL.js +3 -0
  31. package/public/app/assets/index-4rItIkD-.js +4 -0
  32. package/public/app/assets/{index-YDz1ree1.css → index-DY4e15Kh.css} +1 -1
  33. package/public/app/assets/{knowledge-DG0e0uKw.js → knowledge-C6_HvoyB.js} +1 -1
  34. package/public/app/assets/media-SMfTT-yc.js +1 -0
  35. package/public/app/index.html +3 -3
  36. package/public/guide.en.html +72 -99
  37. package/public/guide.html +44 -86
  38. package/public/tutorial.en.md +25 -39
  39. package/public/tutorial.md +25 -39
  40. package/scripts/backup.js +311 -311
  41. package/scripts/browser.js +41 -41
  42. package/scripts/import-data.js +128 -0
  43. package/scripts/launch.js +101 -94
  44. package/scripts/plugin.js +150 -150
  45. package/scripts/reset-password.js +22 -0
  46. package/scripts/runtime.js +25 -25
  47. package/scripts/service.js +56 -47
  48. package/scripts/setup.js +50 -50
  49. package/scripts/start-server.js +41 -41
  50. package/scripts/stop.js +36 -36
  51. package/server/app.js +112 -96
  52. package/server/auth.js +217 -0
  53. package/server/channels/contract.js +57 -57
  54. package/server/channels/hub.js +165 -165
  55. package/server/channels/index.js +28 -25
  56. package/server/channels/onebot/index.js +335 -336
  57. package/server/channels/onebot/token.js +22 -3
  58. package/server/core/capabilities.js +208 -208
  59. package/server/core/events.js +64 -60
  60. package/server/core/image-preprocessor.js +208 -208
  61. package/server/core/model-manager.js +1213 -1213
  62. package/server/core/net-guard.js +49 -49
  63. package/server/core/orchestrator.js +569 -569
  64. package/server/core/perceivers.js +91 -91
  65. package/server/core/persona-manager.js +243 -243
  66. package/server/core/repository.js +293 -293
  67. package/server/core/response-validator.js +508 -508
  68. package/server/core/turn-processor.js +630 -630
  69. package/server/core/turn.js +236 -236
  70. package/server/core/vision-manager.js +633 -633
  71. package/server/i18n/errors.js +548 -497
  72. package/server/i18n/labels.js +649 -653
  73. package/server/index.js +151 -102
  74. package/server/knowledge/manager.js +386 -386
  75. package/server/knowledge/schema.js +12 -8
  76. package/server/local-access.js +32 -0
  77. package/server/mind/anticipations.js +507 -507
  78. package/server/mind/api.js +703 -703
  79. package/server/mind/budget.js +152 -152
  80. package/server/mind/context-selection.js +61 -61
  81. package/server/mind/day-planner.js +324 -324
  82. package/server/mind/evidence.js +148 -148
  83. package/server/mind/index.js +445 -445
  84. package/server/mind/life/diary.js +786 -786
  85. package/server/mind/life/index.js +1049 -1049
  86. package/server/mind/observations.js +84 -84
  87. package/server/mind/own-day.js +324 -324
  88. package/server/mind/own-voice.js +318 -318
  89. package/server/mind/senses.js +53 -53
  90. package/server/mind/time/activity-clock.js +196 -196
  91. package/server/mind/time/availability.js +69 -69
  92. package/server/mind/time/commitment-review.js +198 -198
  93. package/server/mind/time/executor.js +512 -512
  94. package/server/mind/time/experiences.js +128 -128
  95. package/server/mind/time/games.js +562 -562
  96. package/server/mind/time/index.js +739 -739
  97. package/server/mind/time/intent.js +166 -166
  98. package/server/mind/time/kinds.js +171 -171
  99. package/server/mind/time/plugin-activity.js +207 -207
  100. package/server/mind/time/tasks.js +587 -587
  101. package/server/mind/util.js +110 -110
  102. package/server/mind/view.js +273 -273
  103. package/server/paths.js +102 -102
  104. package/server/plugins/api.js +204 -204
  105. package/server/plugins/host.js +967 -967
  106. package/server/plugins/install.js +295 -295
  107. package/server/plugins/manifest.js +201 -201
  108. package/server/plugins/permissions.js +42 -42
  109. package/server/plugins/runner/guard.mjs +36 -36
  110. package/server/plugins/runner/worker.mjs +250 -250
  111. package/server/plugins/schema.js +91 -91
  112. package/server/plugins/zip.js +112 -112
  113. package/server/startup-lock.js +118 -118
  114. package/server/storage/archive.js +242 -241
  115. package/server/storage/backup-cleanup-worker.js +8 -8
  116. package/server/storage/backup-cleanup.js +415 -414
  117. package/server/storage/backup-lock.js +76 -76
  118. package/server/storage/backup-scheduler.js +131 -131
  119. package/server/storage/data-package.js +298 -0
  120. package/server/storage/data-transfer-api.js +50 -0
  121. package/server/storage/data-transfer-worker.js +18 -0
  122. package/server/storage/data-transfer.js +415 -0
  123. package/server/storage/store.js +202 -199
  124. package/server/studio/readiness.js +181 -190
  125. package/server/studio/settings.js +236 -236
  126. package/public/app/assets/ChatsPage-C-FcRapR.js +0 -3
  127. package/public/app/assets/Empty-CpP5kQHH.js +0 -2
  128. package/public/app/assets/Field-D4gY-INU.js +0 -1
  129. package/public/app/assets/HeartPage-CPy_thQd.js +0 -1
  130. package/public/app/assets/LifePage-CxWvwmSf.js +0 -1
  131. package/public/app/assets/MemoryPage-DC4rhQ68.js +0 -1
  132. package/public/app/assets/NaturePage-IvYw04Zq.js +0 -3
  133. package/public/app/assets/PeoplePage-Cqefm9-q.js +0 -1
  134. package/public/app/assets/PluginsPage-DKSaKvXb.js +0 -2
  135. package/public/app/assets/PluginsPage-HyBe5D8w.css +0 -1
  136. package/public/app/assets/SystemPage-CJmtJkrK.js +0 -1
  137. package/public/app/assets/TimePage-Cw44inM4.js +0 -3
  138. package/public/app/assets/index-BheRW-1t.js +0 -4
  139. package/public/app/assets/media-BzPDBm7e.js +0 -1
package/scripts/backup.js CHANGED
@@ -1,311 +1,311 @@
1
- import { DatabaseSync } from "node:sqlite";
2
- import {
3
- existsSync,
4
- lstatSync,
5
- mkdirSync,
6
- readdirSync,
7
- readFileSync,
8
- realpathSync,
9
- renameSync,
10
- rmSync,
11
- statSync,
12
- } from "node:fs";
13
- import { resolve, join, basename, dirname } from "node:path";
14
- import {
15
- copyDatabaseFile,
16
- archiveIdentity,
17
- writeArchiveManifest,
18
- ensureArchiveIdentity,
19
- } from "../server/storage/archive.js";
20
- import { withBackupLock } from "../server/storage/backup-lock.js";
21
- import { pathToFileURL } from "node:url";
22
- import { randomUUID } from "node:crypto";
23
- import { runtimePaths } from "../server/paths.js";
24
-
25
- const HOUR = 3600000;
26
- const DAY = 24 * HOUR;
27
- export const AUTO_PREFIX = "luckytri-auto-";
28
- const MISSING = "数据库不存在,请先启动一次 LuckyTri";
29
-
30
- export function backupDirectory(
31
- source = runtimePaths().database,
32
- env = process.env,
33
- ) {
34
- return env.BACKUP_DIR
35
- ? resolve(runtimePaths(env).home, env.BACKUP_DIR)
36
- : join(dirname(resolve(source)), "backups");
37
- }
38
-
39
- function copyDatabase(source, target) {
40
- copyDatabaseFile(source, target);
41
- }
42
-
43
- export function backupDatabase(
44
- source = runtimePaths().database,
45
- directory = backupDirectory(source),
46
- ) {
47
- if (!existsSync(source)) throw new Error(MISSING);
48
- return withBackupLock(directory, () => fullBackup(source, directory));
49
- }
50
-
51
- function fullBackup(source, directory) {
52
- if (!existsSync(source)) throw new Error(MISSING);
53
- ensureArchiveIdentity(source);
54
- mkdirSync(directory, { recursive: true });
55
- const target = resolve(
56
- join(
57
- directory,
58
- `luckytri-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}.db`,
59
- ),
60
- );
61
- const partial = `${target}.partial`;
62
- try {
63
- copyDatabase(source, partial);
64
- writeArchiveManifest(partial, { filename: basename(target) });
65
- renameSync(partial, target);
66
- renameSync(`${partial}.json`, `${target}.json`);
67
- } catch (error) {
68
- rmSync(partial, { force: true });
69
- rmSync(`${partial}.json`, { force: true });
70
- throw error;
71
- }
72
- return target;
73
- }
74
-
75
- // Automatic backups exist to keep her continuity: what she remembers, how she
76
- // has changed and the days she has lived. Model call traces are megabytes each
77
- // and can be rebuilt from nothing that matters, so old ones are left out; that
78
- // keeps a daily copy small enough to actually keep a week of them.
79
- // When a backup was made comes from its name, so copying the folder to another
80
- // disk does not make old backups look new.
81
- function stampTime(name, file) {
82
- const match = /(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/.exec(
83
- name,
84
- );
85
- const parsed = match
86
- ? Date.parse(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`)
87
- : NaN;
88
- return Number.isFinite(parsed) ? parsed : statSync(file).mtimeMs;
89
- }
90
-
91
- function autoBackups(directory, { source, databaseId } = {}) {
92
- if (!existsSync(directory)) return [];
93
- const scoped = !!source || databaseId !== undefined;
94
- const sourceStat = source && existsSync(source) ? statSync(source) : null;
95
- const sourcePath = source
96
- ? sourceStat
97
- ? realpathSync(source)
98
- : resolve(source)
99
- : null;
100
- const identity = databaseId ?? (sourceStat ? archiveIdentity(source) : null);
101
- const normalize = (file) =>
102
- process.platform === "win32" ? file.toLowerCase() : file;
103
- const entries = [];
104
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
105
- if (
106
- !entry.isFile() ||
107
- !entry.name.startsWith(AUTO_PREFIX) ||
108
- !entry.name.endsWith(".db")
109
- )
110
- continue;
111
- const file = join(directory, entry.name);
112
- const stat = lstatSync(file);
113
- if (
114
- !stat.isFile() ||
115
- (sourcePath && normalize(realpathSync(file)) === normalize(sourcePath)) ||
116
- (sourceStat?.ino &&
117
- stat.ino === sourceStat.ino &&
118
- stat.dev === sourceStat.dev)
119
- )
120
- continue;
121
- if (scoped) {
122
- if (!identity) continue;
123
- try {
124
- const manifestPath = `${file}.json`;
125
- if (!lstatSync(manifestPath).isFile()) continue;
126
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
127
- if (
128
- manifest.format !== 1 ||
129
- manifest.app !== "luckytri" ||
130
- manifest.kind !== "auto" ||
131
- manifest.databaseId !== identity ||
132
- archiveIdentity(file) !== identity
133
- )
134
- continue;
135
- } catch {
136
- // Legacy, damaged and unidentifiable copies require a manual decision.
137
- continue;
138
- }
139
- }
140
- entries.push({ name: entry.name, file, time: stampTime(entry.name, file) });
141
- }
142
- return entries.sort(
143
- (a, b) => b.time - a.time || b.name.localeCompare(a.name),
144
- );
145
- }
146
-
147
- // Newest first, with the size the management page shows.
148
- export function listAutoBackups(directory, options = {}) {
149
- return autoBackups(directory, options).map(({ name, file, time }) => ({
150
- name,
151
- time,
152
- bytes: statSync(file).size,
153
- }));
154
- }
155
-
156
- export function autoBackupDue(
157
- directory,
158
- { now = Date.now(), intervalMs = DAY, source, databaseId } = {},
159
- ) {
160
- const latest = autoBackups(directory, { source, databaseId })[0];
161
- return !latest || now - latest.time >= intervalMs;
162
- }
163
-
164
- // Trim a fresh copy down to what continuity needs, then prove it is readable.
165
- function slimAndVerify(file, traceCutoff) {
166
- const db = new DatabaseSync(file);
167
- try {
168
- const hasTraces = db
169
- .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
170
- .get("core_traces");
171
- if (hasTraces) {
172
- db.prepare("DELETE FROM core_traces WHERE time<?").run(traceCutoff);
173
- db.exec("VACUUM");
174
- }
175
- const check = db.prepare("PRAGMA integrity_check").all();
176
- if (check.length !== 1 || check[0].integrity_check !== "ok")
177
- throw new Error("备份未通过完整性检查,已丢弃");
178
- const hasEvents = db
179
- .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
180
- .get("core_events");
181
- return {
182
- events: hasEvents
183
- ? Number(db.prepare("SELECT COUNT(*) n FROM core_events").get().n)
184
- : 0,
185
- };
186
- } finally {
187
- db.close();
188
- }
189
- }
190
-
191
- export function autoBackup(options = {}) {
192
- const source = options.source || runtimePaths().database;
193
- const directory = options.directory || backupDirectory(source);
194
- if (!existsSync(source)) throw new Error(MISSING);
195
- return withBackupLock(directory, () =>
196
- runAutoBackup({ ...options, source, directory }),
197
- );
198
- }
199
-
200
- function runAutoBackup({
201
- source = runtimePaths().database,
202
- directory = backupDirectory(source),
203
- now = Date.now(),
204
- intervalMs = DAY,
205
- keep = 7,
206
- keepTraceDays = 1,
207
- force = false,
208
- } = {}) {
209
- if (!existsSync(source)) throw new Error(MISSING);
210
- if (!force && !autoBackupDue(directory, { now, intervalMs, source }))
211
- return { status: "skipped" };
212
- const databaseId = ensureArchiveIdentity(source);
213
- mkdirSync(directory, { recursive: true });
214
- const sourceStat = statSync(source);
215
- const sourcePath = realpathSync(source);
216
- const normalize = (file) =>
217
- process.platform === "win32" ? file.toLowerCase() : file;
218
- // A run that was cut off leaves a partial file; it is never a backup.
219
- for (const name of readdirSync(directory)) {
220
- if (!name.startsWith(AUTO_PREFIX) || !name.endsWith(".partial")) continue;
221
- const file = join(directory, name);
222
- const stat = lstatSync(file);
223
- if (
224
- stat.isFile() &&
225
- now - stat.mtimeMs > 6 * HOUR &&
226
- normalize(realpathSync(file)) !== normalize(sourcePath) &&
227
- !(
228
- sourceStat.ino &&
229
- stat.ino === sourceStat.ino &&
230
- stat.dev === sourceStat.dev
231
- )
232
- )
233
- rmSync(file, { force: true });
234
- }
235
- let stampTimeMs = now;
236
- let final;
237
- do {
238
- const stamp = new Date(stampTimeMs++).toISOString().replace(/[:.]/g, "-");
239
- final = resolve(join(directory, `${AUTO_PREFIX}${stamp}.db`));
240
- } while (existsSync(final) || existsSync(`${final}.json`));
241
- const partial = `${final}.${randomUUID().slice(0, 8)}.partial`;
242
- let info;
243
- try {
244
- copyDatabase(source, partial);
245
- info = slimAndVerify(partial, now - keepTraceDays * DAY);
246
- writeArchiveManifest(partial, { kind: "auto", filename: basename(final) });
247
- renameSync(partial, final);
248
- renameSync(`${partial}.json`, `${final}.json`);
249
- } catch (error) {
250
- rmSync(partial, { force: true });
251
- rmSync(`${partial}.json`, { force: true });
252
- throw error;
253
- }
254
- const removed = [];
255
- for (const old of autoBackups(directory, { source, databaseId }).slice(
256
- Math.max(1, keep),
257
- )) {
258
- if (existsSync(`${old.file}-wal`) || existsSync(`${old.file}-shm`))
259
- continue;
260
- rmSync(old.file, { force: true });
261
- rmSync(`${old.file}.json`, { force: true });
262
- removed.push(old.name);
263
- }
264
- return {
265
- status: "created",
266
- file: final,
267
- bytes: statSync(final).size,
268
- events: info.events,
269
- removed,
270
- };
271
- }
272
-
273
- // Reads the schedule from the environment; 0 turns automatic backups off.
274
- export function autoBackupOptions(env = process.env) {
275
- const hours = Number(env.BACKUP_INTERVAL_HOURS ?? 24);
276
- const keep = Number(env.BACKUP_KEEP ?? 7);
277
- const traceDays = Number(env.BACKUP_TRACE_DAYS ?? 1);
278
- return {
279
- enabled: Number.isFinite(hours) && hours > 0,
280
- intervalMs: (Number.isFinite(hours) && hours > 0 ? hours : 24) * HOUR,
281
- keep: Number.isFinite(keep) && keep >= 1 ? Math.floor(keep) : 7,
282
- keepTraceDays: Number.isFinite(traceDays) && traceDays >= 0 ? traceDays : 1,
283
- };
284
- }
285
-
286
- if (
287
- process.argv[1] &&
288
- import.meta.url === pathToFileURL(resolve(process.argv[1])).href
289
- ) {
290
- try {
291
- const { prepareRuntime } = await import("./runtime.js");
292
- prepareRuntime({ initialize: false });
293
- if (process.argv.includes("--auto")) {
294
- const options = autoBackupOptions();
295
- const result = autoBackup({
296
- intervalMs: options.intervalMs,
297
- keep: options.keep,
298
- keepTraceDays: options.keepTraceDays,
299
- force: process.argv.includes("--force"),
300
- });
301
- console.log(
302
- result.status === "created"
303
- ? `自动备份完成:${result.file}(${Math.round(result.bytes / 1048576)} MB,${result.events} 条事件${result.removed.length ? `,清理旧备份 ${result.removed.length} 份` : ""})`
304
- : "自动备份未到时间,已跳过",
305
- );
306
- } else console.log("备份完成:" + backupDatabase());
307
- } catch (error) {
308
- console.error(error.message);
309
- process.exitCode = 1;
310
- }
311
- }
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ renameSync,
10
+ rmSync,
11
+ statSync,
12
+ } from "node:fs";
13
+ import { resolve, join, basename, dirname } from "node:path";
14
+ import {
15
+ copyDatabaseFile,
16
+ archiveIdentity,
17
+ writeArchiveManifest,
18
+ ensureArchiveIdentity,
19
+ } from "../server/storage/archive.js";
20
+ import { withBackupLock } from "../server/storage/backup-lock.js";
21
+ import { pathToFileURL } from "node:url";
22
+ import { randomUUID } from "node:crypto";
23
+ import { runtimePaths } from "../server/paths.js";
24
+
25
+ const HOUR = 3600000;
26
+ const DAY = 24 * HOUR;
27
+ export const AUTO_PREFIX = "luckytri-auto-";
28
+ const MISSING = "数据库不存在,请先启动一次 LuckyTri";
29
+
30
+ export function backupDirectory(
31
+ source = runtimePaths().database,
32
+ env = process.env,
33
+ ) {
34
+ return env.BACKUP_DIR
35
+ ? resolve(runtimePaths(env).home, env.BACKUP_DIR)
36
+ : join(dirname(resolve(source)), "backups");
37
+ }
38
+
39
+ function copyDatabase(source, target) {
40
+ copyDatabaseFile(source, target);
41
+ }
42
+
43
+ export function backupDatabase(
44
+ source = runtimePaths().database,
45
+ directory = backupDirectory(source),
46
+ ) {
47
+ if (!existsSync(source)) throw new Error(MISSING);
48
+ return withBackupLock(directory, () => fullBackup(source, directory));
49
+ }
50
+
51
+ function fullBackup(source, directory) {
52
+ if (!existsSync(source)) throw new Error(MISSING);
53
+ ensureArchiveIdentity(source);
54
+ mkdirSync(directory, { recursive: true });
55
+ const target = resolve(
56
+ join(
57
+ directory,
58
+ `luckytri-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}.db`,
59
+ ),
60
+ );
61
+ const partial = `${target}.partial`;
62
+ try {
63
+ copyDatabase(source, partial);
64
+ writeArchiveManifest(partial, { filename: basename(target) });
65
+ renameSync(partial, target);
66
+ renameSync(`${partial}.json`, `${target}.json`);
67
+ } catch (error) {
68
+ rmSync(partial, { force: true });
69
+ rmSync(`${partial}.json`, { force: true });
70
+ throw error;
71
+ }
72
+ return target;
73
+ }
74
+
75
+ // Automatic backups exist to keep her continuity: what she remembers, how she
76
+ // has changed and the days she has lived. Model call traces are megabytes each
77
+ // and can be rebuilt from nothing that matters, so old ones are left out; that
78
+ // keeps a daily copy small enough to actually keep a week of them.
79
+ // When a backup was made comes from its name, so copying the folder to another
80
+ // disk does not make old backups look new.
81
+ function stampTime(name, file) {
82
+ const match = /(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/.exec(
83
+ name,
84
+ );
85
+ const parsed = match
86
+ ? Date.parse(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`)
87
+ : NaN;
88
+ return Number.isFinite(parsed) ? parsed : statSync(file).mtimeMs;
89
+ }
90
+
91
+ function autoBackups(directory, { source, databaseId } = {}) {
92
+ if (!existsSync(directory)) return [];
93
+ const scoped = !!source || databaseId !== undefined;
94
+ const sourceStat = source && existsSync(source) ? statSync(source) : null;
95
+ const sourcePath = source
96
+ ? sourceStat
97
+ ? realpathSync(source)
98
+ : resolve(source)
99
+ : null;
100
+ const identity = databaseId ?? (sourceStat ? archiveIdentity(source) : null);
101
+ const normalize = (file) =>
102
+ process.platform === "win32" ? file.toLowerCase() : file;
103
+ const entries = [];
104
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
105
+ if (
106
+ !entry.isFile() ||
107
+ !entry.name.startsWith(AUTO_PREFIX) ||
108
+ !entry.name.endsWith(".db")
109
+ )
110
+ continue;
111
+ const file = join(directory, entry.name);
112
+ const stat = lstatSync(file);
113
+ if (
114
+ !stat.isFile() ||
115
+ (sourcePath && normalize(realpathSync(file)) === normalize(sourcePath)) ||
116
+ (sourceStat?.ino &&
117
+ stat.ino === sourceStat.ino &&
118
+ stat.dev === sourceStat.dev)
119
+ )
120
+ continue;
121
+ if (scoped) {
122
+ if (!identity) continue;
123
+ try {
124
+ const manifestPath = `${file}.json`;
125
+ if (!lstatSync(manifestPath).isFile()) continue;
126
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
127
+ if (
128
+ manifest.format !== 1 ||
129
+ manifest.app !== "luckytri" ||
130
+ manifest.kind !== "auto" ||
131
+ manifest.databaseId !== identity ||
132
+ archiveIdentity(file) !== identity
133
+ )
134
+ continue;
135
+ } catch {
136
+ // Legacy, damaged and unidentifiable copies require a manual decision.
137
+ continue;
138
+ }
139
+ }
140
+ entries.push({ name: entry.name, file, time: stampTime(entry.name, file) });
141
+ }
142
+ return entries.sort(
143
+ (a, b) => b.time - a.time || b.name.localeCompare(a.name),
144
+ );
145
+ }
146
+
147
+ // Newest first, with the size the management page shows.
148
+ export function listAutoBackups(directory, options = {}) {
149
+ return autoBackups(directory, options).map(({ name, file, time }) => ({
150
+ name,
151
+ time,
152
+ bytes: statSync(file).size,
153
+ }));
154
+ }
155
+
156
+ export function autoBackupDue(
157
+ directory,
158
+ { now = Date.now(), intervalMs = DAY, source, databaseId } = {},
159
+ ) {
160
+ const latest = autoBackups(directory, { source, databaseId })[0];
161
+ return !latest || now - latest.time >= intervalMs;
162
+ }
163
+
164
+ // Trim a fresh copy down to what continuity needs, then prove it is readable.
165
+ function slimAndVerify(file, traceCutoff) {
166
+ const db = new DatabaseSync(file);
167
+ try {
168
+ const hasTraces = db
169
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
170
+ .get("core_traces");
171
+ if (hasTraces) {
172
+ db.prepare("DELETE FROM core_traces WHERE time<?").run(traceCutoff);
173
+ db.exec("VACUUM");
174
+ }
175
+ const check = db.prepare("PRAGMA integrity_check").all();
176
+ if (check.length !== 1 || check[0].integrity_check !== "ok")
177
+ throw new Error("备份未通过完整性检查,已丢弃");
178
+ const hasEvents = db
179
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
180
+ .get("core_events");
181
+ return {
182
+ events: hasEvents
183
+ ? Number(db.prepare("SELECT COUNT(*) n FROM core_events").get().n)
184
+ : 0,
185
+ };
186
+ } finally {
187
+ db.close();
188
+ }
189
+ }
190
+
191
+ export function autoBackup(options = {}) {
192
+ const source = options.source || runtimePaths().database;
193
+ const directory = options.directory || backupDirectory(source);
194
+ if (!existsSync(source)) throw new Error(MISSING);
195
+ return withBackupLock(directory, () =>
196
+ runAutoBackup({ ...options, source, directory }),
197
+ );
198
+ }
199
+
200
+ function runAutoBackup({
201
+ source = runtimePaths().database,
202
+ directory = backupDirectory(source),
203
+ now = Date.now(),
204
+ intervalMs = DAY,
205
+ keep = 7,
206
+ keepTraceDays = 1,
207
+ force = false,
208
+ } = {}) {
209
+ if (!existsSync(source)) throw new Error(MISSING);
210
+ if (!force && !autoBackupDue(directory, { now, intervalMs, source }))
211
+ return { status: "skipped" };
212
+ const databaseId = ensureArchiveIdentity(source);
213
+ mkdirSync(directory, { recursive: true });
214
+ const sourceStat = statSync(source);
215
+ const sourcePath = realpathSync(source);
216
+ const normalize = (file) =>
217
+ process.platform === "win32" ? file.toLowerCase() : file;
218
+ // A run that was cut off leaves a partial file; it is never a backup.
219
+ for (const name of readdirSync(directory)) {
220
+ if (!name.startsWith(AUTO_PREFIX) || !name.endsWith(".partial")) continue;
221
+ const file = join(directory, name);
222
+ const stat = lstatSync(file);
223
+ if (
224
+ stat.isFile() &&
225
+ now - stat.mtimeMs > 6 * HOUR &&
226
+ normalize(realpathSync(file)) !== normalize(sourcePath) &&
227
+ !(
228
+ sourceStat.ino &&
229
+ stat.ino === sourceStat.ino &&
230
+ stat.dev === sourceStat.dev
231
+ )
232
+ )
233
+ rmSync(file, { force: true });
234
+ }
235
+ let stampTimeMs = now;
236
+ let final;
237
+ do {
238
+ const stamp = new Date(stampTimeMs++).toISOString().replace(/[:.]/g, "-");
239
+ final = resolve(join(directory, `${AUTO_PREFIX}${stamp}.db`));
240
+ } while (existsSync(final) || existsSync(`${final}.json`));
241
+ const partial = `${final}.${randomUUID().slice(0, 8)}.partial`;
242
+ let info;
243
+ try {
244
+ copyDatabase(source, partial);
245
+ info = slimAndVerify(partial, now - keepTraceDays * DAY);
246
+ writeArchiveManifest(partial, { kind: "auto", filename: basename(final) });
247
+ renameSync(partial, final);
248
+ renameSync(`${partial}.json`, `${final}.json`);
249
+ } catch (error) {
250
+ rmSync(partial, { force: true });
251
+ rmSync(`${partial}.json`, { force: true });
252
+ throw error;
253
+ }
254
+ const removed = [];
255
+ for (const old of autoBackups(directory, { source, databaseId }).slice(
256
+ Math.max(1, keep),
257
+ )) {
258
+ if (existsSync(`${old.file}-wal`) || existsSync(`${old.file}-shm`))
259
+ continue;
260
+ rmSync(old.file, { force: true });
261
+ rmSync(`${old.file}.json`, { force: true });
262
+ removed.push(old.name);
263
+ }
264
+ return {
265
+ status: "created",
266
+ file: final,
267
+ bytes: statSync(final).size,
268
+ events: info.events,
269
+ removed,
270
+ };
271
+ }
272
+
273
+ // Reads the schedule from the environment; 0 turns automatic backups off.
274
+ export function autoBackupOptions(env = process.env) {
275
+ const hours = Number(env.BACKUP_INTERVAL_HOURS ?? 24);
276
+ const keep = Number(env.BACKUP_KEEP ?? 7);
277
+ const traceDays = Number(env.BACKUP_TRACE_DAYS ?? 1);
278
+ return {
279
+ enabled: Number.isFinite(hours) && hours > 0,
280
+ intervalMs: (Number.isFinite(hours) && hours > 0 ? hours : 24) * HOUR,
281
+ keep: Number.isFinite(keep) && keep >= 1 ? Math.floor(keep) : 7,
282
+ keepTraceDays: Number.isFinite(traceDays) && traceDays >= 0 ? traceDays : 1,
283
+ };
284
+ }
285
+
286
+ if (
287
+ process.argv[1] &&
288
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
289
+ ) {
290
+ try {
291
+ const { prepareRuntime } = await import("./runtime.js");
292
+ prepareRuntime({ initialize: false });
293
+ if (process.argv.includes("--auto")) {
294
+ const options = autoBackupOptions();
295
+ const result = autoBackup({
296
+ intervalMs: options.intervalMs,
297
+ keep: options.keep,
298
+ keepTraceDays: options.keepTraceDays,
299
+ force: process.argv.includes("--force"),
300
+ });
301
+ console.log(
302
+ result.status === "created"
303
+ ? `自动备份完成:${result.file}(${Math.round(result.bytes / 1048576)} MB,${result.events} 条事件${result.removed.length ? `,清理旧备份 ${result.removed.length} 份` : ""})`
304
+ : "自动备份未到时间,已跳过",
305
+ );
306
+ } else console.log("备份完成:" + backupDatabase());
307
+ } catch (error) {
308
+ console.error(error.message);
309
+ process.exitCode = 1;
310
+ }
311
+ }