talon-agent 5.2.2 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +78 -0
  3. package/src/backend/codex/mcp-config.ts +1 -1
  4. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  5. package/src/backend/runtime/index.ts +1 -1
  6. package/src/cli/commands/backup.ts +396 -0
  7. package/src/cli/events.ts +14 -0
  8. package/src/cli/index.ts +64 -45
  9. package/src/core/backup/archive/digest.ts +77 -0
  10. package/src/core/backup/archive/tar.ts +567 -0
  11. package/src/core/backup/archive/zstd.ts +31 -0
  12. package/src/core/backup/index.ts +54 -0
  13. package/src/core/backup/plan.ts +273 -0
  14. package/src/core/backup/restore.ts +410 -0
  15. package/src/core/backup/scheduler.ts +357 -0
  16. package/src/core/backup/snapshot.ts +408 -0
  17. package/src/core/backup/status.ts +194 -0
  18. package/src/core/backup/store.ts +312 -0
  19. package/src/core/backup/targets.ts +281 -0
  20. package/src/core/backup/types.ts +96 -0
  21. package/src/core/backup/upload.ts +172 -0
  22. package/src/core/bus/events.ts +45 -1
  23. package/src/core/config/index.ts +52 -0
  24. package/src/core/engine/gateway-actions/backup/index.ts +129 -0
  25. package/src/core/engine/gateway-actions/index.ts +4 -0
  26. package/src/core/mcp-hub/talon-server.ts +1 -1
  27. package/src/core/plugin/actions.ts +34 -0
  28. package/src/core/plugin/index.ts +5 -1
  29. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  30. package/src/core/tools/index.ts +2 -0
  31. package/src/core/tools/ops/backup.ts +67 -0
  32. package/src/core/tools/types.ts +2 -1
  33. package/src/core/update/self-update.ts +3 -0
  34. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  35. package/src/frontend/discord/commands/backup.ts +203 -0
  36. package/src/frontend/discord/commands/definitions.ts +35 -0
  37. package/src/frontend/discord/commands/router.ts +3 -0
  38. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  39. package/src/frontend/telegram/callbacks/index.ts +8 -0
  40. package/src/frontend/telegram/commands/backup.ts +209 -0
  41. package/src/frontend/telegram/commands/definitions.ts +4 -0
  42. package/src/frontend/telegram/commands/index.ts +2 -0
  43. package/src/storage/backup/index.ts +82 -0
  44. package/src/storage/backup/repo.ts +164 -0
  45. package/src/storage/db.ts +20 -0
  46. package/src/storage/sql/backups.sql +46 -0
  47. package/src/storage/sql/db.sql +8 -0
  48. package/src/storage/sql/schema.sql +30 -0
  49. package/src/storage/sql/statements.generated.ts +60 -1
  50. package/src/util/log.ts +1 -0
  51. /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Snapshot-index repository — executes the statements in
3
+ * sql/backups.sql against the `backups` and `backup_remotes` tables.
4
+ * No SQL text lives here and none lives above: `storage/backup/index.ts`
5
+ * holds the domain API, this module owns statement execution and the
6
+ * row↔domain mapping.
7
+ *
8
+ * The manifest travels as an opaque JSON string. Storage sits below the
9
+ * engine, so it must not know the shape core/backup gives it — it stores
10
+ * the bytes and the few columns the listing surfaces sort and filter on.
11
+ */
12
+
13
+ import { getDatabase } from "../db.js";
14
+ import { backupsSql } from "../sql/statements.generated.js";
15
+
16
+ /** One snapshot as the index holds it. */
17
+ export type BackupRecord = {
18
+ id: string;
19
+ kind: string;
20
+ label?: string;
21
+ pinned: boolean;
22
+ /** Epoch ms. */
23
+ createdAt: number;
24
+ sizeBytes: number;
25
+ /** The manifest.json body, verbatim. */
26
+ manifestJson: string;
27
+ };
28
+
29
+ /** One target's state for one snapshot. */
30
+ export type BackupRemoteRecord = {
31
+ backupId: string;
32
+ targetId: string;
33
+ status: string;
34
+ remoteId?: string;
35
+ uploadedAt?: number;
36
+ error?: string;
37
+ };
38
+
39
+ type Row = {
40
+ id: string;
41
+ kind: string;
42
+ label: string | null;
43
+ pinned: number;
44
+ created_at: number;
45
+ size_bytes: number;
46
+ manifest_json: string;
47
+ };
48
+
49
+ type RemoteRow = {
50
+ backup_id: string;
51
+ target_id: string;
52
+ status: string;
53
+ remote_id: string | null;
54
+ uploaded_at: number | null;
55
+ error: string | null;
56
+ };
57
+
58
+ function toRecord(row: Row): BackupRecord {
59
+ return {
60
+ id: row.id,
61
+ kind: row.kind,
62
+ label: row.label ?? undefined,
63
+ pinned: row.pinned === 1,
64
+ createdAt: row.created_at,
65
+ sizeBytes: row.size_bytes,
66
+ manifestJson: row.manifest_json,
67
+ };
68
+ }
69
+
70
+ function toRemote(row: RemoteRow): BackupRemoteRecord {
71
+ return {
72
+ backupId: row.backup_id,
73
+ targetId: row.target_id,
74
+ status: row.status,
75
+ remoteId: row.remote_id ?? undefined,
76
+ uploadedAt: row.uploaded_at ?? undefined,
77
+ error: row.error ?? undefined,
78
+ };
79
+ }
80
+
81
+ export function upsert(record: BackupRecord): void {
82
+ getDatabase()
83
+ .prepare(backupsSql.upsert)
84
+ .run(
85
+ record.id,
86
+ record.kind,
87
+ record.label ?? null,
88
+ record.pinned ? 1 : 0,
89
+ record.createdAt,
90
+ record.sizeBytes,
91
+ record.manifestJson,
92
+ );
93
+ }
94
+
95
+ export function get(id: string): BackupRecord | undefined {
96
+ const row = getDatabase().prepare(backupsSql.get).get(id) as Row | undefined;
97
+ return row ? toRecord(row) : undefined;
98
+ }
99
+
100
+ /** Newest first. */
101
+ export function all(): BackupRecord[] {
102
+ return (getDatabase().prepare(backupsSql.all).all() as Row[]).map(toRecord);
103
+ }
104
+
105
+ export function ids(): string[] {
106
+ return (getDatabase().prepare(backupsSql.ids).all() as { id: string }[]).map(
107
+ (row) => row.id,
108
+ );
109
+ }
110
+
111
+ export function setPinned(id: string, pinned: boolean): boolean {
112
+ const result = getDatabase()
113
+ .prepare(backupsSql.setPinned)
114
+ .run(pinned ? 1 : 0, id) as { changes?: number };
115
+ return (result.changes ?? 0) > 0;
116
+ }
117
+
118
+ export function setManifest(
119
+ id: string,
120
+ manifestJson: string,
121
+ pinned: boolean,
122
+ sizeBytes: number,
123
+ ): void {
124
+ getDatabase()
125
+ .prepare(backupsSql.setManifest)
126
+ .run(manifestJson, pinned ? 1 : 0, sizeBytes, id);
127
+ }
128
+
129
+ export function remove(id: string): void {
130
+ getDatabase().prepare(backupsSql.remove).run(id);
131
+ }
132
+
133
+ export function upsertRemote(record: BackupRemoteRecord): void {
134
+ getDatabase()
135
+ .prepare(backupsSql.upsertRemote)
136
+ .run(
137
+ record.backupId,
138
+ record.targetId,
139
+ record.status,
140
+ record.remoteId ?? null,
141
+ record.uploadedAt ?? null,
142
+ record.error ?? null,
143
+ );
144
+ }
145
+
146
+ export function remotesAll(): BackupRemoteRecord[] {
147
+ return (
148
+ getDatabase().prepare(backupsSql.remotesAll).all() as RemoteRow[]
149
+ ).map(toRemote);
150
+ }
151
+
152
+ export function remotesFor(backupId: string): BackupRemoteRecord[] {
153
+ return (
154
+ getDatabase().prepare(backupsSql.remotesFor).all(backupId) as RemoteRow[]
155
+ ).map(toRemote);
156
+ }
157
+
158
+ export function removeRemotes(backupId: string): void {
159
+ getDatabase().prepare(backupsSql.removeRemotes).run(backupId);
160
+ }
161
+
162
+ export function removeRemote(backupId: string, targetId: string): void {
163
+ getDatabase().prepare(backupsSql.removeRemote).run(backupId, targetId);
164
+ }
package/src/storage/db.ts CHANGED
@@ -216,6 +216,26 @@ export function flushDatabase(): void {
216
216
  }
217
217
  }
218
218
 
219
+ /**
220
+ * Write a transactionally consistent copy of the database to `destPath`.
221
+ *
222
+ * The backup subsystem's only way in: a snapshot may not copy
223
+ * `data/talon.db` from disk, because in WAL mode the committed pages
224
+ * live partly in the sidecar and a byte-wise copy of the main file is a
225
+ * corrupt (or silently stale) database. `VACUUM INTO` asks SQLite for
226
+ * the copy instead — one file, no WAL, checkpointed and compacted,
227
+ * consistent as of the moment it runs, with concurrent readers and
228
+ * writers untouched.
229
+ *
230
+ * `destPath` must not exist (SQLite refuses to overwrite); its parent
231
+ * directory is created if needed.
232
+ */
233
+ export function snapshotDatabase(destPath: string): void {
234
+ const database = getDatabase();
235
+ mkdirSync(dirname(destPath), { recursive: true });
236
+ database.prepare(dbSql.vacuumInto).run(destPath);
237
+ }
238
+
219
239
  export function closeDatabase(): void {
220
240
  if (!db) return;
221
241
  try {
@@ -0,0 +1,46 @@
1
+ -- Statements for the snapshot index (see repositories in
2
+ -- storage/backup/repo.ts for parameter order and row↔domain mapping).
3
+
4
+ -- name: upsert
5
+ INSERT OR REPLACE INTO backups
6
+ (id, kind, label, pinned, created_at, size_bytes, manifest_json)
7
+ VALUES (?, ?, ?, ?, ?, ?, ?)
8
+
9
+ -- name: get
10
+ SELECT id, kind, label, pinned, created_at, size_bytes, manifest_json
11
+ FROM backups WHERE id = ?
12
+
13
+ -- name: all
14
+ SELECT id, kind, label, pinned, created_at, size_bytes, manifest_json
15
+ FROM backups ORDER BY created_at DESC
16
+
17
+ -- name: ids
18
+ SELECT id FROM backups
19
+
20
+ -- name: setPinned
21
+ UPDATE backups SET pinned = ? WHERE id = ?
22
+
23
+ -- name: setManifest
24
+ UPDATE backups SET manifest_json = ?, pinned = ?, size_bytes = ? WHERE id = ?
25
+
26
+ -- name: remove
27
+ DELETE FROM backups WHERE id = ?
28
+
29
+ -- name: upsertRemote
30
+ INSERT OR REPLACE INTO backup_remotes
31
+ (backup_id, target_id, status, remote_id, uploaded_at, error)
32
+ VALUES (?, ?, ?, ?, ?, ?)
33
+
34
+ -- name: remotesAll
35
+ SELECT backup_id, target_id, status, remote_id, uploaded_at, error
36
+ FROM backup_remotes
37
+
38
+ -- name: remotesFor
39
+ SELECT backup_id, target_id, status, remote_id, uploaded_at, error
40
+ FROM backup_remotes WHERE backup_id = ?
41
+
42
+ -- name: removeRemotes
43
+ DELETE FROM backup_remotes WHERE backup_id = ?
44
+
45
+ -- name: removeRemote
46
+ DELETE FROM backup_remotes WHERE backup_id = ? AND target_id = ?
@@ -30,3 +30,11 @@ ALTER TABLE history_messages ADD COLUMN attachments TEXT
30
30
  -- Column reconciliation for databases that shipped before the cache-age
31
31
  -- signal existed. Fresh databases get the column via schema.sql.
32
32
  ALTER TABLE sessions ADD COLUMN last_turn_ended_at INTEGER
33
+
34
+ -- name: vacuumInto
35
+ -- Transactionally consistent copy of the whole database into a new file,
36
+ -- produced by SQLite itself (storage/db.ts snapshotDatabase). A backup
37
+ -- must never copy a live .db byte-wise: the WAL holds committed pages the
38
+ -- main file does not, so the copy would be a corrupt database or an old
39
+ -- one. Verified to accept a bound path on node:sqlite and bun:sqlite.
40
+ VACUUM INTO ?
@@ -362,3 +362,33 @@ CREATE TABLE IF NOT EXISTS whatsapp_messages (
362
362
  CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_msg ON whatsapp_messages(msg_id);
363
363
  CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_wa_id ON whatsapp_messages(wa_id);
364
364
  CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_time ON whatsapp_messages(timestamp);
365
+
366
+ -- Snapshot index: the listing/status view over ~/.talon/backups/. The
367
+ -- manifest.json next to the parts on disk stays the source of truth for
368
+ -- a restore (a database that needs restoring cannot also be the record
369
+ -- of how), so these rows are a cache — dropped rows are re-derived from
370
+ -- the directories on the next boot, and a row whose directory is gone is
371
+ -- kept because the snapshot may still exist on a remote target.
372
+ CREATE TABLE IF NOT EXISTS backups (
373
+ id TEXT PRIMARY KEY,
374
+ kind TEXT NOT NULL,
375
+ label TEXT,
376
+ pinned INTEGER NOT NULL DEFAULT 0,
377
+ created_at INTEGER NOT NULL,
378
+ size_bytes INTEGER NOT NULL DEFAULT 0,
379
+ manifest_json TEXT NOT NULL
380
+ );
381
+ CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC);
382
+
383
+ -- Per-target upload state for one snapshot. Separate from the manifest
384
+ -- copy so retention can ask "what is on Drive?" without opening a file
385
+ -- per snapshot, and so a failed upload's error survives a restart.
386
+ CREATE TABLE IF NOT EXISTS backup_remotes (
387
+ backup_id TEXT NOT NULL,
388
+ target_id TEXT NOT NULL,
389
+ status TEXT NOT NULL,
390
+ remote_id TEXT,
391
+ uploaded_at INTEGER,
392
+ error TEXT,
393
+ PRIMARY KEY (backup_id, target_id)
394
+ );
@@ -366,7 +366,60 @@ CREATE TABLE IF NOT EXISTS whatsapp_messages (
366
366
  );
367
367
  CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_msg ON whatsapp_messages(msg_id);
368
368
  CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_wa_id ON whatsapp_messages(wa_id);
369
- CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_time ON whatsapp_messages(timestamp);`;
369
+ CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_time ON whatsapp_messages(timestamp);
370
+
371
+ -- Snapshot index: the listing/status view over ~/.talon/backups/. The
372
+ -- manifest.json next to the parts on disk stays the source of truth for
373
+ -- a restore (a database that needs restoring cannot also be the record
374
+ -- of how), so these rows are a cache — dropped rows are re-derived from
375
+ -- the directories on the next boot, and a row whose directory is gone is
376
+ -- kept because the snapshot may still exist on a remote target.
377
+ CREATE TABLE IF NOT EXISTS backups (
378
+ id TEXT PRIMARY KEY,
379
+ kind TEXT NOT NULL,
380
+ label TEXT,
381
+ pinned INTEGER NOT NULL DEFAULT 0,
382
+ created_at INTEGER NOT NULL,
383
+ size_bytes INTEGER NOT NULL DEFAULT 0,
384
+ manifest_json TEXT NOT NULL
385
+ );
386
+ CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC);
387
+
388
+ -- Per-target upload state for one snapshot. Separate from the manifest
389
+ -- copy so retention can ask "what is on Drive?" without opening a file
390
+ -- per snapshot, and so a failed upload's error survives a restart.
391
+ CREATE TABLE IF NOT EXISTS backup_remotes (
392
+ backup_id TEXT NOT NULL,
393
+ target_id TEXT NOT NULL,
394
+ status TEXT NOT NULL,
395
+ remote_id TEXT,
396
+ uploaded_at INTEGER,
397
+ error TEXT,
398
+ PRIMARY KEY (backup_id, target_id)
399
+ );`;
400
+
401
+ export const backupsSql = {
402
+ upsert: `INSERT OR REPLACE INTO backups
403
+ (id, kind, label, pinned, created_at, size_bytes, manifest_json)
404
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
405
+ get: `SELECT id, kind, label, pinned, created_at, size_bytes, manifest_json
406
+ FROM backups WHERE id = ?`,
407
+ all: `SELECT id, kind, label, pinned, created_at, size_bytes, manifest_json
408
+ FROM backups ORDER BY created_at DESC`,
409
+ ids: `SELECT id FROM backups`,
410
+ setPinned: `UPDATE backups SET pinned = ? WHERE id = ?`,
411
+ setManifest: `UPDATE backups SET manifest_json = ?, pinned = ?, size_bytes = ? WHERE id = ?`,
412
+ remove: `DELETE FROM backups WHERE id = ?`,
413
+ upsertRemote: `INSERT OR REPLACE INTO backup_remotes
414
+ (backup_id, target_id, status, remote_id, uploaded_at, error)
415
+ VALUES (?, ?, ?, ?, ?, ?)`,
416
+ remotesAll: `SELECT backup_id, target_id, status, remote_id, uploaded_at, error
417
+ FROM backup_remotes`,
418
+ remotesFor: `SELECT backup_id, target_id, status, remote_id, uploaded_at, error
419
+ FROM backup_remotes WHERE backup_id = ?`,
420
+ removeRemotes: `DELETE FROM backup_remotes WHERE backup_id = ?`,
421
+ removeRemote: `DELETE FROM backup_remotes WHERE backup_id = ? AND target_id = ?`,
422
+ } as const;
370
423
 
371
424
  export const chatSettingsSql = {
372
425
  upsert: `INSERT OR REPLACE INTO chat_settings (chat_id, settings) VALUES (?, ?)`,
@@ -421,6 +474,12 @@ ALTER TABLE history_messages ADD COLUMN attachments TEXT`,
421
474
  addSessionsLastTurnEndedAtColumn: `-- Column reconciliation for databases that shipped before the cache-age
422
475
  -- signal existed. Fresh databases get the column via schema.sql.
423
476
  ALTER TABLE sessions ADD COLUMN last_turn_ended_at INTEGER`,
477
+ vacuumInto: `-- Transactionally consistent copy of the whole database into a new file,
478
+ -- produced by SQLite itself (storage/db.ts snapshotDatabase). A backup
479
+ -- must never copy a live .db byte-wise: the WAL holds committed pages the
480
+ -- main file does not, so the copy would be a corrupt database or an old
481
+ -- one. Verified to accept a bound path on node:sqlite and bun:sqlite.
482
+ VACUUM INTO ?`,
424
483
  } as const;
425
484
 
426
485
  export const goalsSql = {
package/src/util/log.ts CHANGED
@@ -67,6 +67,7 @@ export type LogComponent =
67
67
  | "playwright"
68
68
  | "memory"
69
69
  | "stickers"
70
+ | "backup"
70
71
  | "backend-controller";
71
72
 
72
73
  const LOG_FILE = files.log;
File without changes