talon-agent 5.2.2 → 5.4.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 (74) hide show
  1. package/README.md +10 -6
  2. package/package.json +1 -1
  3. package/src/app.ts +78 -0
  4. package/src/backend/agy/auth.ts +128 -0
  5. package/src/backend/agy/constants.ts +88 -0
  6. package/src/backend/agy/doctor.ts +161 -0
  7. package/src/backend/agy/effort.ts +76 -0
  8. package/src/backend/agy/events.ts +402 -0
  9. package/src/backend/agy/factory.ts +137 -0
  10. package/src/backend/agy/handler/index.ts +9 -0
  11. package/src/backend/agy/handler/message.ts +444 -0
  12. package/src/backend/agy/init.ts +64 -0
  13. package/src/backend/agy/mcp/config.ts +336 -0
  14. package/src/backend/agy/mcp/register.ts +134 -0
  15. package/src/backend/agy/models.ts +335 -0
  16. package/src/backend/agy/one-shot.ts +301 -0
  17. package/src/backend/agy/process/child.ts +378 -0
  18. package/src/backend/agy/process/orphans.ts +106 -0
  19. package/src/backend/agy/sessions.ts +90 -0
  20. package/src/backend/agy/state.ts +77 -0
  21. package/src/backend/builtins.ts +1 -0
  22. package/src/backend/codex/mcp-config.ts +1 -1
  23. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  24. package/src/backend/runtime/index.ts +1 -1
  25. package/src/cli/commands/backup.ts +396 -0
  26. package/src/cli/config-view.ts +5 -0
  27. package/src/cli/config.ts +4 -2
  28. package/src/cli/events.ts +14 -0
  29. package/src/cli/index.ts +64 -45
  30. package/src/cli/setup.ts +49 -0
  31. package/src/core/agent-runtime/model-ref.ts +1 -0
  32. package/src/core/backup/archive/digest.ts +77 -0
  33. package/src/core/backup/archive/tar.ts +567 -0
  34. package/src/core/backup/archive/zstd.ts +31 -0
  35. package/src/core/backup/index.ts +54 -0
  36. package/src/core/backup/plan.ts +273 -0
  37. package/src/core/backup/restore.ts +410 -0
  38. package/src/core/backup/scheduler.ts +357 -0
  39. package/src/core/backup/snapshot.ts +408 -0
  40. package/src/core/backup/status.ts +194 -0
  41. package/src/core/backup/store.ts +312 -0
  42. package/src/core/backup/targets.ts +281 -0
  43. package/src/core/backup/types.ts +96 -0
  44. package/src/core/backup/upload.ts +172 -0
  45. package/src/core/bus/events.ts +45 -1
  46. package/src/core/config/index.ts +53 -0
  47. package/src/core/engine/gateway-actions/backup/index.ts +129 -0
  48. package/src/core/engine/gateway-actions/index.ts +4 -0
  49. package/src/core/mcp-hub/talon-server.ts +1 -1
  50. package/src/core/plugin/actions.ts +34 -0
  51. package/src/core/plugin/index.ts +5 -1
  52. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  53. package/src/core/tools/index.ts +2 -0
  54. package/src/core/tools/ops/backup.ts +67 -0
  55. package/src/core/tools/types.ts +2 -1
  56. package/src/core/update/self-update.ts +3 -0
  57. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  58. package/src/frontend/discord/commands/backup.ts +203 -0
  59. package/src/frontend/discord/commands/definitions.ts +35 -0
  60. package/src/frontend/discord/commands/router.ts +3 -0
  61. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  62. package/src/frontend/telegram/callbacks/index.ts +8 -0
  63. package/src/frontend/telegram/commands/backup.ts +209 -0
  64. package/src/frontend/telegram/commands/definitions.ts +4 -0
  65. package/src/frontend/telegram/commands/index.ts +2 -0
  66. package/src/storage/backup/index.ts +82 -0
  67. package/src/storage/backup/repo.ts +164 -0
  68. package/src/storage/db.ts +20 -0
  69. package/src/storage/sql/backups.sql +46 -0
  70. package/src/storage/sql/db.sql +8 -0
  71. package/src/storage/sql/schema.sql +30 -0
  72. package/src/storage/sql/statements.generated.ts +60 -1
  73. package/src/util/log.ts +1 -0
  74. /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Backups and checkpoints — the subsystem's public surface.
3
+ *
4
+ * A snapshot is everything that makes this deployment itself: config,
5
+ * prompts, keys, sessions, the database (via `VACUUM INTO`, never a
6
+ * byte-wise copy), the agent's memory and skills, and the memory palace
7
+ * as its own content-addressed part. Scheduled snapshots are pruned by
8
+ * retention; checkpoints are labelled, optionally pinned, and taken
9
+ * before anything risky (a self-update, a restore).
10
+ *
11
+ * This barrel is what the surfaces above use — the CLI, the `/backup`
12
+ * commands, the gateway actions, bootstrap and app. Inside the
13
+ * subsystem the modules import each other directly.
14
+ *
15
+ * Read them in this order: `plan` (what goes in), `archive/` (how it is
16
+ * written), `snapshot` (the build), `store` (the local store and its
17
+ * index), `targets` + `upload` (getting it off the machine), `scheduler`
18
+ * (when), `restore` (getting it back), `status` (what every surface
19
+ * renders). docs/backups.md has the operator's view and the plugin
20
+ * protocol.
21
+ */
22
+
23
+ export {
24
+ initBackup,
25
+ runBackup,
26
+ stopBackupScheduler,
27
+ checkpointBeforeUpdate,
28
+ } from "./scheduler.js";
29
+
30
+ export {
31
+ isSnapshotId,
32
+ listSnapshots,
33
+ readManifest,
34
+ setSnapshotPinned,
35
+ } from "./store.js";
36
+
37
+ export {
38
+ applyPendingRestore,
39
+ readRestorePending,
40
+ restoreSnapshot,
41
+ writeRestorePending,
42
+ } from "./restore.js";
43
+
44
+ export { discoverTargets, type BackupTarget } from "./targets.js";
45
+
46
+ export {
47
+ collectBackupStatus,
48
+ formatBackupStatus,
49
+ formatBytes,
50
+ formatRelative,
51
+ formatSnapshotList,
52
+ } from "./status.js";
53
+
54
+ export type { SnapshotSummary } from "./types.js";
@@ -0,0 +1,273 @@
1
+ /**
2
+ * What goes into a snapshot — the include/exclude rules and the walker
3
+ * that turns them into a list of archive members.
4
+ *
5
+ * The rules answer one question: if this machine died, what would we need
6
+ * to rebuild the same agent? Identity (config, prompts, keys, sessions),
7
+ * everything the agent wrote about itself (memory, skills, scripts), and
8
+ * nothing that can be re-fetched or re-derived (node_modules, venvs,
9
+ * browser downloads, logs, traces). The database is deliberately absent
10
+ * here: a live SQLite file copied byte-wise is a corrupt SQLite file, so
11
+ * the builder adds it via `VACUUM INTO` instead (see snapshot.ts).
12
+ *
13
+ * `~/.talon/ns` is excluded by name and never stat()ed. It is a FUSE
14
+ * mount that can be dead ("Transport endpoint is not connected"), and on
15
+ * a dead mount a stat blocks or throws — a backup must not be the thing
16
+ * that hangs on it.
17
+ *
18
+ * Pure except for the walker: the rules are plain string predicates so
19
+ * they can be tested without a filesystem.
20
+ */
21
+
22
+ import { lstat, readdir, readlink } from "node:fs/promises";
23
+ import { isAbsolute, join, resolve, sep } from "node:path";
24
+ import { homedir } from "node:os";
25
+ import type { BackupSettings } from "./types.js";
26
+
27
+ /**
28
+ * The workspace is mostly machine-generated bulk (uploads, media, build
29
+ * output, project checkouts). These are the parts that are the agent:
30
+ * what it knows, what it learned to do, and who it decided to be.
31
+ */
32
+ export const DEFAULT_WORKSPACE_INCLUDE: readonly string[] = [
33
+ "identity.md",
34
+ "memory.md",
35
+ "state.md",
36
+ "heartbeat-instructions.md",
37
+ "memory/**",
38
+ "skills/**",
39
+ "scripts/**",
40
+ "secrets/**",
41
+ "stickers/**",
42
+ ];
43
+
44
+ /**
45
+ * The policy defaults — the single source of truth the zod schema in
46
+ * core/config defers to, so `config.backup` and an absent `config.backup`
47
+ * mean exactly the same thing.
48
+ */
49
+ export const DEFAULT_BACKUP_SETTINGS = {
50
+ enabled: true,
51
+ intervalHours: 6,
52
+ keepLocal: 12,
53
+ keepRemote: 30,
54
+ includePalace: true,
55
+ workspaceInclude: DEFAULT_WORKSPACE_INCLUDE,
56
+ extraPaths: [] as readonly string[],
57
+ checkpointBeforeUpdate: true,
58
+ } as const;
59
+
60
+ /** Fill in whatever `config.backup` left out (or was entirely absent). */
61
+ export function resolveBackupSettings(
62
+ partial?: Partial<BackupSettings>,
63
+ ): BackupSettings {
64
+ return { ...DEFAULT_BACKUP_SETTINGS, ...partial };
65
+ }
66
+
67
+ /** Roots under ~/.talon that a snapshot always carries, in archive order. */
68
+ export const HOME_INCLUDES: readonly string[] = [
69
+ "config.json",
70
+ "prompts",
71
+ "data",
72
+ "keys",
73
+ "whatsapp-auth",
74
+ ".user-session",
75
+ "mesh-devices.json",
76
+ "mesh-history.json",
77
+ "teleport-state.json",
78
+ "agent-workspace",
79
+ ];
80
+
81
+ /** The exclusion rules, in the words the manifest records them by. */
82
+ export const EXCLUDE_RULES: readonly string[] = [
83
+ "talon.log*",
84
+ "errors.log",
85
+ "node-bin/",
86
+ "*venv*/",
87
+ "ns/ (FUSE mount — never stat()ed)",
88
+ "backups/",
89
+ "data/traces/**",
90
+ "data/talon.db* (the database is added via VACUUM INTO)",
91
+ "*.tmp-*",
92
+ "workspace/palace/** (its own part)",
93
+ ];
94
+
95
+ /**
96
+ * True when an archive path must not be captured. `path` is the path the
97
+ * member would have INSIDE the archive, which for the ~/.talon roots is
98
+ * also its path relative to the Talon home.
99
+ */
100
+ export function isExcluded(path: string): boolean {
101
+ const segments = path.split("/").filter(Boolean);
102
+ if (segments.length === 0) return true;
103
+ const first = segments[0];
104
+ const last = segments[segments.length - 1];
105
+ // Anchored rules — only at the root of the archive.
106
+ if (first === "ns" || first === "backups") return true;
107
+ if (
108
+ first === "data" &&
109
+ (segments[1] === "traces" || segments[1]?.startsWith("talon.db"))
110
+ ) {
111
+ return true;
112
+ }
113
+ if (
114
+ segments.length === 1 &&
115
+ (first.startsWith("talon.log") || first === "errors.log")
116
+ ) {
117
+ return true;
118
+ }
119
+ if (path === "workspace/palace" || path.startsWith("workspace/palace/"))
120
+ return true;
121
+ // Rules that hold at any depth: build output, virtualenvs, half-written
122
+ // files from an atomic write that never landed.
123
+ if (
124
+ segments.some(
125
+ (s) => s === "node-bin" || s === "node_modules" || s.includes("venv"),
126
+ )
127
+ ) {
128
+ return true;
129
+ }
130
+ if (last.includes(".tmp-")) return true;
131
+ return false;
132
+ }
133
+
134
+ /**
135
+ * Match one workspace-relative path against the `workspaceInclude` list.
136
+ * The pattern language is deliberately two rules wide — an exact relative
137
+ * path, or a `dir/**` prefix — because that is all the config needs and a
138
+ * glob engine is a dependency plus a surprise.
139
+ */
140
+ export function matchesWorkspaceInclude(
141
+ relative: string,
142
+ patterns: readonly string[],
143
+ ): boolean {
144
+ for (const pattern of patterns) {
145
+ if (pattern.endsWith("/**")) {
146
+ const prefix = pattern.slice(0, -3);
147
+ if (relative === prefix || relative.startsWith(`${prefix}/`)) return true;
148
+ } else if (pattern === relative) {
149
+ return true;
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+
155
+ /** The workspace subtrees to walk, derived from the include patterns. */
156
+ export function workspaceRoots(patterns: readonly string[]): string[] {
157
+ return [
158
+ ...new Set(patterns.map((p) => (p.endsWith("/**") ? p.slice(0, -3) : p))),
159
+ ];
160
+ }
161
+
162
+ /** Expand a leading `~` and resolve against the home directory. */
163
+ export function expandUserPath(path: string): string {
164
+ const trimmed = path.trim();
165
+ if (trimmed === "~") return homedir();
166
+ if (trimmed.startsWith("~/")) return resolve(homedir(), trimmed.slice(2));
167
+ return isAbsolute(trimmed) ? resolve(trimmed) : resolve(homedir(), trimmed);
168
+ }
169
+
170
+ /** One member the builder will hand to the tar writer. */
171
+ export type SourceEntry = {
172
+ /** Path inside the archive (POSIX separators). */
173
+ archivePath: string;
174
+ /** Absolute path on disk. */
175
+ source: string;
176
+ type: "file" | "dir" | "symlink";
177
+ mode: number;
178
+ /** Epoch seconds. */
179
+ mtime: number;
180
+ size: number;
181
+ linkTarget?: string;
182
+ };
183
+
184
+ /**
185
+ * Walk one root into archive members. Missing roots are skipped silently —
186
+ * a fresh install has no whatsapp-auth/, and that is not an error. An
187
+ * unreadable entry is skipped too: a backup that aborts because one file
188
+ * lost its permissions is a backup that never runs.
189
+ */
190
+ export async function collectTree(
191
+ absRoot: string,
192
+ archiveRoot: string,
193
+ opts: {
194
+ /** Defaults to {@link isExcluded}; the palace part overrides it. */
195
+ exclude?: (archivePath: string) => boolean;
196
+ onSkip?: (path: string, err: unknown) => void;
197
+ } = {},
198
+ ): Promise<SourceEntry[]> {
199
+ const { exclude = isExcluded, onSkip } = opts;
200
+ const entries: SourceEntry[] = [];
201
+ const visit = async (abs: string, archivePath: string): Promise<void> => {
202
+ if (exclude(archivePath)) return;
203
+ let stats;
204
+ try {
205
+ stats = await lstat(abs);
206
+ } catch (err) {
207
+ // A root that does not exist is the normal case on a fresh install
208
+ // (no whatsapp-auth/, no agent-workspace/) — only real failures
209
+ // (permissions, I/O) are worth a line in the log.
210
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") onSkip?.(abs, err);
211
+ return;
212
+ }
213
+ const mtime = Math.floor(stats.mtimeMs / 1000);
214
+ const mode = stats.mode & 0o7777;
215
+ if (stats.isSymbolicLink()) {
216
+ try {
217
+ entries.push({
218
+ archivePath,
219
+ source: abs,
220
+ type: "symlink",
221
+ mode,
222
+ mtime,
223
+ size: 0,
224
+ linkTarget: await readlink(abs),
225
+ });
226
+ } catch (err) {
227
+ onSkip?.(abs, err);
228
+ }
229
+ return;
230
+ }
231
+ if (stats.isDirectory()) {
232
+ entries.push({
233
+ archivePath,
234
+ source: abs,
235
+ type: "dir",
236
+ mode,
237
+ mtime,
238
+ size: 0,
239
+ });
240
+ let children: string[] = [];
241
+ try {
242
+ children = (await readdir(abs)).sort();
243
+ } catch (err) {
244
+ onSkip?.(abs, err);
245
+ return;
246
+ }
247
+ for (const child of children) {
248
+ await visit(join(abs, child), `${archivePath}/${child}`);
249
+ }
250
+ return;
251
+ }
252
+ if (!stats.isFile()) return; // sockets, fifos, devices: not agent state
253
+ entries.push({
254
+ archivePath,
255
+ source: abs,
256
+ type: "file",
257
+ mode,
258
+ mtime,
259
+ size: stats.size,
260
+ });
261
+ };
262
+ await visit(absRoot, archiveRoot);
263
+ return entries;
264
+ }
265
+
266
+ /** True when `path` is inside `root` (or is `root`). */
267
+ export function isInside(root: string, path: string): boolean {
268
+ const normalizedRoot = resolve(root);
269
+ const normalized = resolve(path);
270
+ return (
271
+ normalized === normalizedRoot || normalized.startsWith(normalizedRoot + sep)
272
+ );
273
+ }