forge-openclaw-plugin 0.2.60 → 0.2.65

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 (59) hide show
  1. package/README.md +121 -51
  2. package/dist/assets/{board-B1V3M__K.js → board-DUwMfZvN.js} +1 -1
  3. package/dist/assets/index-B9VOpR7r.css +1 -0
  4. package/dist/assets/index-DoHjjze2.js +90 -0
  5. package/dist/assets/{motion-CltSTItx.js → motion-Crg3QyXD.js} +1 -1
  6. package/dist/assets/{table-B-VrSFx8.js → table-CTlDeYRs.js} +1 -1
  7. package/dist/assets/{ui-DUqM4jkt.js → ui-CJPaElbj.js} +1 -1
  8. package/dist/assets/{vendor-C0otBhgu.js → vendor-BdrT2htV.js} +217 -207
  9. package/dist/companion-iroh/darwin-arm64/forge-companion-iroh +0 -0
  10. package/dist/companion-iroh/darwin-x64/forge-companion-iroh +0 -0
  11. package/dist/companion-iroh/linux-x64/forge-companion-iroh +0 -0
  12. package/dist/companion-iroh-src/Cargo.lock +4559 -0
  13. package/dist/companion-iroh-src/Cargo.toml +37 -0
  14. package/dist/companion-iroh-src/src/lib.rs +279 -0
  15. package/dist/companion-iroh-src/src/main.rs +478 -0
  16. package/dist/companion-iroh-src/src/protocol.rs +129 -0
  17. package/dist/gamification-previews/dark-fantasy-item-trophy-tasks-anvil-marathon.webp +0 -0
  18. package/dist/gamification-previews/dark-fantasy-item-trophy-xp-levels-the-first-heat.webp +0 -0
  19. package/dist/gamification-previews/dark-fantasy-item-unlock-streaks-molten-crown-fire.webp +0 -0
  20. package/dist/gamification-previews/dark-fantasy-mascot.webp +0 -0
  21. package/dist/gamification-previews/dramatic-smithie-item-trophy-tasks-anvil-marathon.webp +0 -0
  22. package/dist/gamification-previews/dramatic-smithie-item-trophy-xp-levels-the-first-heat.webp +0 -0
  23. package/dist/gamification-previews/dramatic-smithie-item-unlock-streaks-molten-crown-fire.webp +0 -0
  24. package/dist/gamification-previews/dramatic-smithie-mascot.webp +0 -0
  25. package/dist/gamification-previews/mind-locksmith-item-trophy-tasks-anvil-marathon.webp +0 -0
  26. package/dist/gamification-previews/mind-locksmith-item-trophy-xp-levels-the-first-heat.webp +0 -0
  27. package/dist/gamification-previews/mind-locksmith-item-unlock-streaks-molten-crown-fire.webp +0 -0
  28. package/dist/gamification-previews/mind-locksmith-mascot.webp +0 -0
  29. package/dist/index.html +7 -7
  30. package/dist/openclaw/parity.js +27 -0
  31. package/dist/openclaw/plugin-entry-shared.js +2 -2
  32. package/dist/openclaw/plugin-sdk-types.d.ts +2 -1
  33. package/dist/openclaw/routes.d.ts +4 -0
  34. package/dist/openclaw/routes.js +112 -3
  35. package/dist/openclaw/tools.js +32 -4
  36. package/dist/server/server/migrations/059_data_backup_retention.sql +2 -0
  37. package/dist/server/server/src/app.js +288 -61
  38. package/dist/server/server/src/data-management-types.js +2 -0
  39. package/dist/server/server/src/discovery-advertiser.js +13 -0
  40. package/dist/server/server/src/health.js +58 -3
  41. package/dist/server/server/src/movement.js +16 -1
  42. package/dist/server/server/src/openapi.js +410 -9
  43. package/dist/server/server/src/repositories/rewards.js +60 -0
  44. package/dist/server/server/src/services/companion-iroh.js +425 -0
  45. package/dist/server/server/src/services/data-management.js +32 -2
  46. package/dist/server/server/src/services/doctor.js +762 -0
  47. package/dist/server/server/src/services/gamification.js +75 -3
  48. package/dist/server/server/src/services/life-force.js +166 -25
  49. package/dist/server/server/src/web.js +88 -12
  50. package/dist/server/src/lib/api.js +9 -0
  51. package/dist/server/src/lib/gamification-catalog.js +1 -1
  52. package/openclaw.plugin.json +85 -3
  53. package/package.json +10 -6
  54. package/server/migrations/059_data_backup_retention.sql +2 -0
  55. package/skills/forge-openclaw/SKILL.md +80 -19
  56. package/skills/forge-openclaw/entity_conversation_playbooks.md +283 -25
  57. package/skills/forge-openclaw/psyche_entity_playbooks.md +82 -0
  58. package/dist/assets/index-BwKAPo98.css +0 -1
  59. package/dist/assets/index-Dy7c-dRY.js +0 -90
@@ -0,0 +1,425 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { logForgeDebug } from "../debug.js";
7
+ import { getEffectiveDataRoot } from "../db.js";
8
+ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
9
+ const companionIrohManifestPath = path.join(projectRoot, "companion-iroh", "Cargo.toml");
10
+ const DEFAULT_IROH_START_TIMEOUT_MS = 25_000;
11
+ const COMPANION_IROH_ALPN = "forge-companion/1";
12
+ const FORGE_IROH_AGENT = "forge";
13
+ let irohHostState = emptyIrohHostState();
14
+ export async function buildCompanionPairingTransport(input) {
15
+ const requestApiBaseUrl = normalizeApiBaseUrl(input.requestApiBaseUrl);
16
+ const requestUiBaseUrl = normalizeUiBaseUrl(input.requestUiBaseUrl) ??
17
+ deriveUiBaseUrlFromApiBaseUrl(requestApiBaseUrl);
18
+ if (input.requestedMode === "manual-http") {
19
+ return manualHttpTransport(requestApiBaseUrl, requestUiBaseUrl, [
20
+ "Manual HTTP/TCP pairing was explicitly requested."
21
+ ]);
22
+ }
23
+ if (!shouldAutoStartIrohHost()) {
24
+ return manualHttpTransport(requestApiBaseUrl, requestUiBaseUrl, [
25
+ "Forge Iroh companion transport is unavailable in this runtime, so Forge fell back to direct HTTP."
26
+ ]);
27
+ }
28
+ const snapshot = await ensureCompanionIrohHost(localForgeBaseUrl());
29
+ if (snapshot.status === "ready" && snapshot.pairPayload) {
30
+ return irohTransport({
31
+ pairPayload: snapshot.pairPayload,
32
+ alpn: snapshot.alpn ?? COMPANION_IROH_ALPN,
33
+ localBaseUrl: snapshot.localBaseUrl,
34
+ recreateCommand: snapshot.recreateCommand ?? undefined,
35
+ startedAt: snapshot.startedAt ?? undefined,
36
+ notes: [
37
+ "Default pairing uses Forge's Rust Iroh transport over QUIC.",
38
+ "The QR payload carries the Iroh node id, host token, optional relay, and ALPN forge-companion/1.",
39
+ "Manual HTTP/TCP pairing remains available with --manual-http for advanced local setups."
40
+ ]
41
+ });
42
+ }
43
+ return manualHttpTransport(requestApiBaseUrl, requestUiBaseUrl, [
44
+ snapshot.lastError ??
45
+ "No Forge Iroh companion host could be started, so Forge fell back to direct HTTP."
46
+ ]);
47
+ }
48
+ export function getCompanionIrohStatus() {
49
+ return snapshotFor(irohHostState.pairPayload && irohHostState.child ? "ready" : "unavailable");
50
+ }
51
+ export async function stopCompanionIroh() {
52
+ if (irohHostState.child && !irohHostState.child.killed) {
53
+ irohHostState.child.kill("SIGTERM");
54
+ }
55
+ irohHostState = emptyIrohHostState();
56
+ }
57
+ export function companionIrohApiBaseUrlFromNodeId(nodeId) {
58
+ return `forge-iroh://${nodeId.trim()}/api/v1`;
59
+ }
60
+ export function companionIrohUiBaseUrlFromNodeId(nodeId) {
61
+ return `forge-iroh://${nodeId.trim()}/forge/`;
62
+ }
63
+ async function ensureCompanionIrohHost(localBaseUrl) {
64
+ if (irohHostState.child &&
65
+ !irohHostState.child.killed &&
66
+ irohHostState.pairPayload &&
67
+ irohHostState.localBaseUrl === localBaseUrl) {
68
+ return snapshotFor("ready", localBaseUrl);
69
+ }
70
+ if (irohHostState.starting) {
71
+ return irohHostState.starting;
72
+ }
73
+ irohHostState.starting = startCompanionIrohHost(localBaseUrl).finally(() => {
74
+ irohHostState.starting = null;
75
+ });
76
+ return irohHostState.starting;
77
+ }
78
+ async function startCompanionIrohHost(localBaseUrl) {
79
+ const stateDir = path.join(getEffectiveDataRoot(), "companion-iroh");
80
+ await mkdir(stateDir, { recursive: true });
81
+ const hostCommand = resolveIrohHostCommand({
82
+ stateDir,
83
+ localBaseUrl
84
+ });
85
+ if (!hostCommand) {
86
+ irohHostState.lastError =
87
+ "Forge companion Iroh host is unavailable. Build companion-iroh, install cargo, or set FORGE_COMPANION_IROH_BIN.";
88
+ return snapshotFor("unavailable", localBaseUrl, stateDir);
89
+ }
90
+ if (irohHostState.child && !irohHostState.child.killed) {
91
+ irohHostState.child.kill("SIGTERM");
92
+ }
93
+ const child = spawn(hostCommand.command, hostCommand.args, {
94
+ env: process.env,
95
+ stdio: ["ignore", "pipe", "pipe"]
96
+ });
97
+ irohHostState.child = child;
98
+ irohHostState.pairPayload = null;
99
+ irohHostState.alpn = null;
100
+ irohHostState.localBaseUrl = localBaseUrl;
101
+ irohHostState.stateDir = stateDir;
102
+ irohHostState.recreateCommand = hostCommand.displayCommand;
103
+ irohHostState.startedAt = new Date().toISOString();
104
+ irohHostState.lastError = null;
105
+ const seenLogs = [];
106
+ let stdoutBuffer = "";
107
+ const rememberLog = (chunk) => {
108
+ const text = chunk.toString("utf8");
109
+ seenLogs.push(text);
110
+ if (seenLogs.length > 20) {
111
+ seenLogs.shift();
112
+ }
113
+ };
114
+ const parseReadyLines = (chunk) => {
115
+ stdoutBuffer += chunk.toString("utf8");
116
+ let lineEnd = stdoutBuffer.indexOf("\n");
117
+ while (lineEnd >= 0) {
118
+ const line = stdoutBuffer.slice(0, lineEnd).trim();
119
+ stdoutBuffer = stdoutBuffer.slice(lineEnd + 1);
120
+ applyIrohHostReadyLine(line);
121
+ lineEnd = stdoutBuffer.indexOf("\n");
122
+ }
123
+ };
124
+ child.stdout?.on("data", (chunk) => {
125
+ rememberLog(chunk);
126
+ parseReadyLines(chunk);
127
+ });
128
+ child.stderr?.on("data", rememberLog);
129
+ child.once("error", (error) => {
130
+ irohHostState.lastError = error.message;
131
+ });
132
+ child.once("exit", (code, signal) => {
133
+ if (irohHostState.child === child) {
134
+ irohHostState.child = null;
135
+ irohHostState.pairPayload = null;
136
+ irohHostState.alpn = null;
137
+ irohHostState.lastError =
138
+ code === 0
139
+ ? "Forge companion Iroh host stopped."
140
+ : `Forge companion Iroh host exited with ${signal ?? `code ${code}`}.`;
141
+ }
142
+ });
143
+ const deadline = Date.now() + readIrohStartTimeoutMs();
144
+ while (Date.now() < deadline) {
145
+ if (irohHostState.pairPayload) {
146
+ return snapshotFor("ready", localBaseUrl, stateDir);
147
+ }
148
+ if (!irohHostState.child) {
149
+ break;
150
+ }
151
+ await delay(200);
152
+ }
153
+ if (!irohHostState.pairPayload) {
154
+ irohHostState.lastError =
155
+ irohHostState.lastError ??
156
+ `Forge companion Iroh host did not report a ready pair payload. Recent output: ${seenLogs
157
+ .join("")
158
+ .trim()
159
+ .slice(-800)}`;
160
+ if (irohHostState.child && !irohHostState.child.killed) {
161
+ irohHostState.child.kill("SIGTERM");
162
+ }
163
+ return snapshotFor("error", localBaseUrl, stateDir);
164
+ }
165
+ return snapshotFor("ready", localBaseUrl, stateDir);
166
+ }
167
+ function applyIrohHostReadyLine(line) {
168
+ if (!line) {
169
+ return;
170
+ }
171
+ try {
172
+ const parsed = JSON.parse(line);
173
+ if (parsed.event !== "ready" || !isIrohPairPayload(parsed.pairPayload)) {
174
+ return;
175
+ }
176
+ if (parsed.alpn !== undefined && parsed.alpn !== COMPANION_IROH_ALPN) {
177
+ irohHostState.lastError = `Unsupported companion Iroh ALPN: ${String(parsed.alpn)}`;
178
+ return;
179
+ }
180
+ irohHostState.pairPayload = parsed.pairPayload;
181
+ irohHostState.alpn = COMPANION_IROH_ALPN;
182
+ }
183
+ catch {
184
+ // Non-JSON stdout is treated as diagnostic output from cargo or the host.
185
+ }
186
+ }
187
+ function isIrohPairPayload(value) {
188
+ if (!value || typeof value !== "object") {
189
+ return false;
190
+ }
191
+ const payload = value;
192
+ return (payload.v === 1 &&
193
+ typeof payload.node_id === "string" &&
194
+ payload.node_id.trim().length > 0 &&
195
+ typeof payload.token === "string" &&
196
+ payload.token.trim().length > 0 &&
197
+ (payload.host_name === undefined || typeof payload.host_name === "string") &&
198
+ (payload.relay === undefined || typeof payload.relay === "string"));
199
+ }
200
+ function irohTransport(input) {
201
+ const nodeId = input.pairPayload.node_id;
202
+ return {
203
+ transportMode: "iroh",
204
+ apiBaseUrl: companionIrohApiBaseUrlFromNodeId(nodeId),
205
+ uiBaseUrl: companionIrohUiBaseUrlFromNodeId(nodeId),
206
+ transport: {
207
+ protocol: "iroh",
208
+ provider: "forge-companion-iroh",
209
+ status: "ready",
210
+ localBaseUrl: input.localBaseUrl,
211
+ nodeId,
212
+ relay: input.pairPayload.relay,
213
+ alpn: input.alpn,
214
+ agent: FORGE_IROH_AGENT,
215
+ pairPayload: input.pairPayload,
216
+ recreateCommand: input.recreateCommand,
217
+ startedAt: input.startedAt,
218
+ notes: input.notes
219
+ }
220
+ };
221
+ }
222
+ function manualHttpTransport(apiBaseUrl, uiBaseUrl, notes) {
223
+ return {
224
+ transportMode: "manual-http",
225
+ apiBaseUrl,
226
+ uiBaseUrl,
227
+ transport: {
228
+ protocol: "http",
229
+ provider: "manual-http",
230
+ status: "ready",
231
+ localBaseUrl: apiBaseUrl.replace(/\/api\/v1\/?$/u, ""),
232
+ notes
233
+ }
234
+ };
235
+ }
236
+ function snapshotFor(status, localBaseUrl = localForgeBaseUrl(), stateDir = irohHostState.stateDir) {
237
+ return {
238
+ status,
239
+ pairPayload: irohHostState.pairPayload,
240
+ alpn: irohHostState.alpn,
241
+ localBaseUrl,
242
+ stateDir,
243
+ recreateCommand: irohHostState.recreateCommand,
244
+ startedAt: irohHostState.startedAt,
245
+ lastError: irohHostState.lastError
246
+ };
247
+ }
248
+ function resolveIrohHostCommand(input) {
249
+ const hostArgs = [
250
+ "host",
251
+ "--state-dir",
252
+ input.stateDir,
253
+ "--local-base-url",
254
+ input.localBaseUrl
255
+ ];
256
+ const explicitBin = process.env.FORGE_COMPANION_IROH_BIN?.trim();
257
+ if (explicitBin) {
258
+ const command = path.isAbsolute(explicitBin)
259
+ ? explicitBin
260
+ : path.resolve(projectRoot, explicitBin);
261
+ return {
262
+ command,
263
+ args: hostArgs,
264
+ displayCommand: `${shellQuote(command)} ${hostArgs.map(shellQuote).join(" ")}`
265
+ };
266
+ }
267
+ for (const candidate of candidateIrohBinaries()) {
268
+ if (existsSync(candidate)) {
269
+ return {
270
+ command: candidate,
271
+ args: hostArgs,
272
+ displayCommand: `${shellQuote(candidate)} ${hostArgs.map(shellQuote).join(" ")}`
273
+ };
274
+ }
275
+ }
276
+ const cargoPath = resolveCommand("cargo");
277
+ const manifestPath = resolveCompanionIrohManifestPath();
278
+ if (!cargoPath || !manifestPath) {
279
+ return null;
280
+ }
281
+ const args = [
282
+ "run",
283
+ "--manifest-path",
284
+ manifestPath,
285
+ "--quiet",
286
+ "--",
287
+ ...hostArgs
288
+ ];
289
+ return {
290
+ command: cargoPath,
291
+ args,
292
+ displayCommand: `${shellQuote(cargoPath)} ${args.map(shellQuote).join(" ")}`
293
+ };
294
+ }
295
+ function candidateIrohBinaries() {
296
+ const binaryName = process.platform === "win32" ? "forge-companion-iroh.exe" : "forge-companion-iroh";
297
+ const platformKey = `${process.platform}-${process.arch}`;
298
+ return [
299
+ path.join(projectRoot, "companion-iroh", "target", "release", binaryName),
300
+ path.join(projectRoot, "companion-iroh", "target", "debug", binaryName),
301
+ path.join(projectRoot, "openclaw-plugin", "dist", "companion-iroh", platformKey, binaryName),
302
+ path.join(projectRoot, "companion-iroh", platformKey, binaryName),
303
+ path.join(projectRoot, "companion-iroh", binaryName)
304
+ ];
305
+ }
306
+ function resolveCompanionIrohManifestPath() {
307
+ const candidates = [
308
+ companionIrohManifestPath,
309
+ path.join(projectRoot, "companion-iroh-src", "Cargo.toml")
310
+ ];
311
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
312
+ }
313
+ function shouldAutoStartIrohHost() {
314
+ if (process.env.FORGE_COMPANION_IROH_DISABLED === "1") {
315
+ return false;
316
+ }
317
+ if (process.env.FORGE_COMPANION_IROH_AUTOSTART === "0") {
318
+ return false;
319
+ }
320
+ if (isTestRuntime() && !process.env.FORGE_COMPANION_IROH_BIN?.trim()) {
321
+ return false;
322
+ }
323
+ return true;
324
+ }
325
+ function isTestRuntime() {
326
+ return (process.env.NODE_ENV === "test" ||
327
+ process.env.VITEST === "true" ||
328
+ process.argv.some((arg) => arg === "--test" || arg.includes("vitest")));
329
+ }
330
+ function localForgeBaseUrl() {
331
+ const configured = process.env.FORGE_COMPANION_IROH_LOCAL_BASE_URL?.trim();
332
+ if (configured) {
333
+ return configured.replace(/\/+$/u, "");
334
+ }
335
+ const port = Number(process.env.PORT ?? 4317);
336
+ const safePort = Number.isInteger(port) && port > 0 ? port : 4317;
337
+ return `http://127.0.0.1:${safePort}`;
338
+ }
339
+ function normalizeApiBaseUrl(value) {
340
+ const trimmed = value.trim();
341
+ try {
342
+ const url = new URL(trimmed);
343
+ url.pathname = url.pathname.replace(/\/+$/u, "");
344
+ if (!url.pathname.endsWith("/api/v1")) {
345
+ url.pathname = `${url.pathname}/api/v1`.replace(/\/{2,}/gu, "/");
346
+ }
347
+ url.search = "";
348
+ url.hash = "";
349
+ return url.toString().replace(/\/$/u, "");
350
+ }
351
+ catch {
352
+ return trimmed;
353
+ }
354
+ }
355
+ function normalizeUiBaseUrl(value) {
356
+ if (!value?.trim()) {
357
+ return null;
358
+ }
359
+ try {
360
+ const url = new URL(value);
361
+ url.pathname = "/forge/";
362
+ url.search = "";
363
+ url.hash = "";
364
+ return url.toString();
365
+ }
366
+ catch {
367
+ return null;
368
+ }
369
+ }
370
+ function deriveUiBaseUrlFromApiBaseUrl(apiBaseUrl) {
371
+ try {
372
+ const url = new URL(apiBaseUrl);
373
+ url.pathname = "/forge/";
374
+ url.search = "";
375
+ url.hash = "";
376
+ return url.toString();
377
+ }
378
+ catch {
379
+ return apiBaseUrl;
380
+ }
381
+ }
382
+ function readIrohStartTimeoutMs() {
383
+ const parsed = Number(process.env.FORGE_COMPANION_IROH_START_TIMEOUT_MS);
384
+ if (Number.isFinite(parsed) && parsed > 0) {
385
+ return Math.round(parsed);
386
+ }
387
+ return DEFAULT_IROH_START_TIMEOUT_MS;
388
+ }
389
+ function emptyIrohHostState() {
390
+ return {
391
+ child: null,
392
+ pairPayload: null,
393
+ alpn: null,
394
+ localBaseUrl: null,
395
+ stateDir: null,
396
+ recreateCommand: null,
397
+ startedAt: null,
398
+ lastError: null,
399
+ starting: null
400
+ };
401
+ }
402
+ function resolveCommand(command) {
403
+ const result = spawnSync(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
404
+ encoding: "utf8",
405
+ shell: process.platform !== "win32"
406
+ });
407
+ if (result.status !== 0) {
408
+ return null;
409
+ }
410
+ const resolved = result.stdout.split(/\r?\n/u)[0]?.trim();
411
+ if (!resolved) {
412
+ return null;
413
+ }
414
+ logForgeDebug(`[companion-iroh] resolved ${command} at ${resolved}`);
415
+ return resolved;
416
+ }
417
+ function shellQuote(value) {
418
+ if (/^[a-zA-Z0-9_./:=+-]+$/u.test(value)) {
419
+ return value;
420
+ }
421
+ return `'${value.replaceAll("'", "'\\''")}'`;
422
+ }
423
+ function delay(ms) {
424
+ return new Promise((resolve) => setTimeout(resolve, ms));
425
+ }
@@ -84,12 +84,13 @@ function ensureDataManagementSettingsRow() {
84
84
  preferred_data_root,
85
85
  backup_directory,
86
86
  backup_frequency_hours,
87
+ backup_retention_days,
87
88
  auto_repair_enabled,
88
89
  last_auto_backup_at,
89
90
  last_manual_backup_at,
90
91
  created_at,
91
92
  updated_at
92
- ) VALUES (1, ?, ?, NULL, 1, NULL, NULL, ?, ?)`)
93
+ ) VALUES (1, ?, ?, NULL, 30, 1, NULL, NULL, ?, ?)`)
93
94
  .run(dataRoot, backupDirectory, now, now);
94
95
  }
95
96
  function readDataManagementSettingsRow() {
@@ -99,6 +100,7 @@ function readDataManagementSettingsRow() {
99
100
  preferred_data_root,
100
101
  backup_directory,
101
102
  backup_frequency_hours,
103
+ backup_retention_days,
102
104
  auto_repair_enabled,
103
105
  last_auto_backup_at,
104
106
  last_manual_backup_at,
@@ -124,12 +126,13 @@ function writeDataManagementSettingsRow(patch) {
124
126
  SET preferred_data_root = ?,
125
127
  backup_directory = ?,
126
128
  backup_frequency_hours = ?,
129
+ backup_retention_days = ?,
127
130
  auto_repair_enabled = ?,
128
131
  last_auto_backup_at = ?,
129
132
  last_manual_backup_at = ?,
130
133
  updated_at = ?
131
134
  WHERE id = 1`)
132
- .run(next.preferred_data_root, next.backup_directory, next.backup_frequency_hours, next.auto_repair_enabled, next.last_auto_backup_at, next.last_manual_backup_at, next.updated_at);
135
+ .run(next.preferred_data_root, next.backup_directory, next.backup_frequency_hours, next.backup_retention_days, next.auto_repair_enabled, next.last_auto_backup_at, next.last_manual_backup_at, next.updated_at);
133
136
  }
134
137
  function resolveCurrentDataManagementSettings() {
135
138
  const row = readDataManagementSettingsRow();
@@ -139,6 +142,7 @@ function resolveCurrentDataManagementSettings() {
139
142
  preferredDataRoot,
140
143
  backupDirectory,
141
144
  backupFrequencyHours: row.backup_frequency_hours,
145
+ backupRetentionDays: row.backup_retention_days,
142
146
  autoRepairEnabled: row.auto_repair_enabled === 1,
143
147
  lastAutoBackupAt: row.last_auto_backup_at,
144
148
  lastManualBackupAt: row.last_manual_backup_at
@@ -461,6 +465,7 @@ export async function createDataBackup(input = { note: "" }, options = {}) {
461
465
  }
462
466
  if (mode === "automatic") {
463
467
  writeDataManagementSettingsRow({ last_auto_backup_at: createdAt });
468
+ await pruneExpiredAutomaticBackups(settings.backupDirectory, settings.backupRetentionDays);
464
469
  }
465
470
  return backup;
466
471
  }
@@ -468,6 +473,27 @@ export async function createDataBackup(input = { note: "" }, options = {}) {
468
473
  await rm(sqliteSnapshot.tempDir, { recursive: true, force: true });
469
474
  }
470
475
  }
476
+ async function pruneExpiredAutomaticBackups(backupDirectory, retentionDays) {
477
+ if (!retentionDays) {
478
+ return;
479
+ }
480
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
481
+ const backups = await listDataBackups();
482
+ for (const backup of backups) {
483
+ if (backup.mode !== "automatic") {
484
+ continue;
485
+ }
486
+ if (path.resolve(backup.backupDirectory) !== path.resolve(backupDirectory)) {
487
+ continue;
488
+ }
489
+ const createdAtMs = new Date(backup.createdAt).getTime();
490
+ if (!Number.isFinite(createdAtMs) || createdAtMs >= cutoff) {
491
+ continue;
492
+ }
493
+ await rm(backup.archivePath, { force: true });
494
+ await rm(backup.manifestPath, { force: true });
495
+ }
496
+ }
471
497
  async function openDatabaseSnapshot(databasePath) {
472
498
  const database = new DatabaseSync(databasePath);
473
499
  database.exec("PRAGMA busy_timeout = 250;");
@@ -643,6 +669,7 @@ export async function switchDataRoot(input, options = {}) {
643
669
  preferred_data_root: targetDataRoot,
644
670
  backup_directory: nextBackupDirectory,
645
671
  backup_frequency_hours: previousSettings.backupFrequencyHours,
672
+ backup_retention_days: previousSettings.backupRetentionDays,
646
673
  auto_repair_enabled: previousSettings.autoRepairEnabled ? 1 : 0
647
674
  });
648
675
  await (options.persistPreferredDataRoot ?? writeMonorepoPreferredDataRoot)(targetDataRoot);
@@ -698,6 +725,9 @@ export async function updateDataManagementSettings(input) {
698
725
  backup_frequency_hours: parsed.backupFrequencyHours !== undefined
699
726
  ? parsed.backupFrequencyHours
700
727
  : undefined,
728
+ backup_retention_days: parsed.backupRetentionDays !== undefined
729
+ ? parsed.backupRetentionDays
730
+ : undefined,
701
731
  auto_repair_enabled: parsed.autoRepairEnabled !== undefined
702
732
  ? parsed.autoRepairEnabled
703
733
  ? 1