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,172 @@
1
+ /**
2
+ * Getting a snapshot off this machine — and keeping the remote tidy.
3
+ *
4
+ * Targets run in parallel (they are independent networks) but the parts
5
+ * of one snapshot go up sequentially, manifest last. That order is the
6
+ * completeness marker: a remote snapshot with no manifest.json is an
7
+ * interrupted upload, and nothing will ever mistake it for a restorable
8
+ * backup.
9
+ *
10
+ * One target failing is not a failed backup. The snapshot is already on
11
+ * local disk by the time we get here, so a target that errors records
12
+ * `failed` with its reason and the run carries on — the next run retries
13
+ * it. The manifest keeps the per-target state alongside the parts, so a
14
+ * snapshot restored onto a new machine still knows where its copies are.
15
+ */
16
+
17
+ import { bus } from "../bus/index.js";
18
+ import { log, logWarn } from "../../util/log.js";
19
+ import { dirs } from "../../util/paths.js";
20
+ import {
21
+ deleteBackupRemote,
22
+ recordBackupRemote,
23
+ } from "../../storage/backup/index.js";
24
+ import {
25
+ partPath,
26
+ reindexSnapshot,
27
+ selectPrunable,
28
+ writeManifest,
29
+ } from "./store.js";
30
+ import type { BackupTarget } from "./targets.js";
31
+ import type { Manifest, RemoteState } from "./types.js";
32
+
33
+ function recordState(id: string, targetId: string, state: RemoteState): void {
34
+ recordBackupRemote({
35
+ backupId: id,
36
+ targetId,
37
+ status: state.status,
38
+ remoteId: state.remoteId,
39
+ uploadedAt: state.uploadedAt,
40
+ error: state.error,
41
+ });
42
+ }
43
+
44
+ /** Push every part, then the manifest. Throws with the target's own words. */
45
+ async function sendSnapshot(
46
+ target: BackupTarget,
47
+ manifest: Manifest,
48
+ home: string,
49
+ ): Promise<{ state: RemoteState; deduplicated: boolean }> {
50
+ let deduplicated = manifest.parts.length > 0;
51
+ for (const part of manifest.parts) {
52
+ const result = await target.upload(
53
+ manifest.id,
54
+ { ...part, path: partPath(manifest.id, part.name, home) },
55
+ manifest,
56
+ );
57
+ if (!result.deduplicated) deduplicated = false;
58
+ }
59
+ const { remoteId } = await target.uploadManifest(manifest.id, manifest);
60
+ return {
61
+ state: { status: "uploaded", remoteId, uploadedAt: Date.now() },
62
+ deduplicated,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Upload one snapshot to every target. Returns the manifest with its
68
+ * `remote` map filled in; it is rewritten on disk and reindexed so the
69
+ * status surfaces can answer without asking the network.
70
+ */
71
+ export async function uploadSnapshot(
72
+ manifest: Manifest,
73
+ targets: readonly BackupTarget[],
74
+ home: string = dirs.root,
75
+ ): Promise<Manifest> {
76
+ if (targets.length === 0) return manifest;
77
+ await Promise.all(
78
+ targets.map(async (target) => {
79
+ if (!target.ready) {
80
+ const state: RemoteState = {
81
+ status: "pending",
82
+ error: target.detail ?? "target not ready",
83
+ };
84
+ manifest.remote[target.id] = state;
85
+ recordState(manifest.id, target.id, state);
86
+ logWarn("backup", `Target ${target.id} not ready: ${state.error}`);
87
+ return;
88
+ }
89
+ try {
90
+ const { state, deduplicated } = await sendSnapshot(
91
+ target,
92
+ manifest,
93
+ home,
94
+ );
95
+ manifest.remote[target.id] = state;
96
+ recordState(manifest.id, target.id, state);
97
+ bus.publish({
98
+ type: "backup.uploaded",
99
+ snapshotId: manifest.id,
100
+ targetId: target.id,
101
+ bytes: manifest.sizeBytes,
102
+ deduplicated,
103
+ });
104
+ log(
105
+ "backup",
106
+ `Uploaded ${manifest.id} to ${target.id}${deduplicated ? " (deduplicated)" : ""}`,
107
+ );
108
+ } catch (err) {
109
+ const state: RemoteState = {
110
+ status: "failed",
111
+ error: err instanceof Error ? err.message : String(err),
112
+ };
113
+ manifest.remote[target.id] = state;
114
+ recordState(manifest.id, target.id, state);
115
+ logWarn("backup", `Upload to ${target.id} failed: ${state.error}`);
116
+ }
117
+ }),
118
+ );
119
+ await writeManifest(manifest, home);
120
+ reindexSnapshot(manifest);
121
+ return manifest;
122
+ }
123
+
124
+ /**
125
+ * Apply the remote retention policy on each target. Same rule as local:
126
+ * the newest `keep` unpinned snapshots survive, pinned ones always do.
127
+ * A target that cannot list is skipped — deleting on a partial listing is
128
+ * how a retention pass turns into data loss.
129
+ */
130
+ export async function pruneRemote(
131
+ targets: readonly BackupTarget[],
132
+ keep: number,
133
+ ): Promise<void> {
134
+ for (const target of targets) {
135
+ if (!target.ready) continue;
136
+ let snapshots;
137
+ try {
138
+ snapshots = await target.list();
139
+ } catch (err) {
140
+ logWarn(
141
+ "backup",
142
+ `Cannot list ${target.id}, skipping remote prune: ${String(err)}`,
143
+ );
144
+ continue;
145
+ }
146
+ const doomed = selectPrunable(
147
+ snapshots.map((entry) => ({
148
+ id: entry.snapshotId,
149
+ createdAt: entry.manifest?.createdAt ?? 0,
150
+ pinned: entry.manifest?.pinned === true,
151
+ })),
152
+ keep,
153
+ );
154
+ for (const victim of doomed) {
155
+ try {
156
+ await target.remove(victim.id);
157
+ deleteBackupRemote(victim.id, target.id);
158
+ } catch (err) {
159
+ logWarn(
160
+ "backup",
161
+ `Could not delete ${victim.id} from ${target.id}: ${String(err)}`,
162
+ );
163
+ }
164
+ }
165
+ if (doomed.length > 0) {
166
+ log(
167
+ "backup",
168
+ `Pruned ${doomed.length} snapshot(s) from ${target.id} (keepRemote=${keep})`,
169
+ );
170
+ }
171
+ }
172
+ }
@@ -15,6 +15,9 @@
15
15
  * `agent.spawned` when an isolated sub-agent run starts, `agent.settled`
16
16
  * when it reaches a terminal state, `agent.message` when a note or a
17
17
  * report crosses between an agent and its parent.
18
+ * - `backup.*` — the snapshot lifecycle, published by `core/backup`:
19
+ * `backup.started` / `backup.completed` / `backup.failed` per run, and
20
+ * `backup.uploaded` once per target a snapshot reaches.
18
21
  * - `turn.*` — the chat-domain moments inside a turn that other
19
22
  * subsystems key off: `turn.started` fires once the warp is bound and
20
23
  * the backend is about to run (never for a no-model refusal);
@@ -91,6 +94,43 @@ interface AgentMessageEvent {
91
94
  readonly kind: "message" | "result";
92
95
  }
93
96
 
97
+ /** A snapshot run began. `trigger` is what asked for it, never content. */
98
+ interface BackupStartedEvent {
99
+ readonly type: "backup.started";
100
+ readonly kind: "backup" | "checkpoint";
101
+ /** "schedule", "manual", "pre-update", "pre-restore", … */
102
+ readonly trigger: string;
103
+ }
104
+
105
+ /** A snapshot was written and indexed. */
106
+ interface BackupCompletedEvent {
107
+ readonly type: "backup.completed";
108
+ /** Not `id`: the bus stamps every published event with its own numeric id. */
109
+ readonly snapshotId: string;
110
+ readonly kind: "backup" | "checkpoint";
111
+ readonly sizeBytes: number;
112
+ readonly parts: number;
113
+ readonly durationMs: number;
114
+ }
115
+
116
+ /** A snapshot run failed. `error` is the reason, never a path's contents. */
117
+ interface BackupFailedEvent {
118
+ readonly type: "backup.failed";
119
+ readonly trigger: string;
120
+ readonly error: string;
121
+ readonly consecutiveFailures: number;
122
+ }
123
+
124
+ /** One snapshot reached one remote target. */
125
+ interface BackupUploadedEvent {
126
+ readonly type: "backup.uploaded";
127
+ readonly snapshotId: string;
128
+ readonly targetId: string;
129
+ readonly bytes: number;
130
+ /** True when the target already held every part (content-addressed reuse). */
131
+ readonly deduplicated: boolean;
132
+ }
133
+
94
134
  export type TalonEvent =
95
135
  | TaskStartedEvent
96
136
  | TaskSettledEvent
@@ -98,7 +138,11 @@ export type TalonEvent =
98
138
  | TurnCompletedEvent
99
139
  | AgentSpawnedEvent
100
140
  | AgentSettledEvent
101
- | AgentMessageEvent;
141
+ | AgentMessageEvent
142
+ | BackupStartedEvent
143
+ | BackupCompletedEvent
144
+ | BackupFailedEvent
145
+ | BackupUploadedEvent;
102
146
 
103
147
  export type TalonEventType = TalonEvent["type"];
104
148
 
@@ -6,6 +6,7 @@ import { hardenTalonPermissions } from "./harden.js";
6
6
  import { setTimezone } from "../../util/time.js";
7
7
  import { BACKEND_IDS } from "../agent-runtime/model-ref.js";
8
8
  import { REASONING_LEVEL_ORDER } from "../models/reasoning-levels.js";
9
+ import { DEFAULT_BACKUP_SETTINGS } from "../backup/plan.js";
9
10
  import {
10
11
  assembleSystemPrompt,
11
12
  joinSystemPromptParts,
@@ -458,6 +459,57 @@ const configSchema = z.object({
458
459
  .default(15 * 60 * 1000),
459
460
  })
460
461
  .optional(),
462
+ /**
463
+ * Backups & checkpoints (docs/backups.md). Talon's only safety net, so
464
+ * it is on by default: every `intervalHours` it writes a snapshot of
465
+ * the identity, state, database and memory under ~/.talon/backups/,
466
+ * keeps `keepLocal` of them, and uploads to whatever remote targets
467
+ * are registered (`targets: []` keeps everything local).
468
+ *
469
+ * - `workspaceInclude` — the workspace is mostly bulk that can be
470
+ * refetched; this is the subset that IS the agent.
471
+ * - `extraPaths` — absolute or `~/…` paths outside ~/.talon worth
472
+ * carrying along (a Claude Code memory directory, say).
473
+ * - `checkpointBeforeUpdate` — pinned checkpoint before `/update`,
474
+ * so a bad update is one restore away from undone.
475
+ * - `notifyChatId` — where failures are reported; falls back to the
476
+ * admin chat.
477
+ */
478
+ backup: z
479
+ .object({
480
+ enabled: z.boolean().default(DEFAULT_BACKUP_SETTINGS.enabled),
481
+ intervalHours: z
482
+ .number()
483
+ .int()
484
+ .min(1)
485
+ .max(168)
486
+ .default(DEFAULT_BACKUP_SETTINGS.intervalHours),
487
+ keepLocal: z
488
+ .number()
489
+ .int()
490
+ .min(1)
491
+ .max(1000)
492
+ .default(DEFAULT_BACKUP_SETTINGS.keepLocal),
493
+ keepRemote: z
494
+ .number()
495
+ .int()
496
+ .min(1)
497
+ .max(1000)
498
+ .default(DEFAULT_BACKUP_SETTINGS.keepRemote),
499
+ includePalace: z.boolean().default(DEFAULT_BACKUP_SETTINGS.includePalace),
500
+ workspaceInclude: z
501
+ .array(z.string().min(1))
502
+ .default([...DEFAULT_BACKUP_SETTINGS.workspaceInclude]),
503
+ extraPaths: z.array(z.string().min(1)).default([]),
504
+ /** Unset = every registered target; `[]` = local only. */
505
+ targets: z.array(z.string().min(1)).optional(),
506
+ checkpointBeforeUpdate: z
507
+ .boolean()
508
+ .default(DEFAULT_BACKUP_SETTINGS.checkpointBeforeUpdate),
509
+ notifyChatId: z.string().optional(),
510
+ })
511
+ .strict()
512
+ .optional(),
461
513
  braveApiKey: z.string().optional(),
462
514
  /**
463
515
  * Codex-specific OpenAI API key. Prefer this, CODEX_API_KEY, or
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Backup actions — the agent tools and the companion app's read surface.
3
+ *
4
+ * Two vocabularies over one subsystem. `create_checkpoint`,
5
+ * `list_checkpoints` and `backup_status` are the model's tools and answer
6
+ * in prose; `backup.status`, `backup.now` and `backup.list` are the
7
+ * companion app's and answer with structured fields beside the text.
8
+ *
9
+ * All of them are chat-free: a snapshot belongs to the daemon, not to a
10
+ * conversation, so the heartbeat and background runs can take one too.
11
+ *
12
+ * There is no restore action. Restoring replaces memory, database and
13
+ * identity underneath a running daemon — that is a decision for a human
14
+ * at a CLI or behind a confirmation button, never a tool call.
15
+ */
16
+
17
+ import {
18
+ collectBackupStatus,
19
+ formatBackupStatus,
20
+ formatSnapshotList,
21
+ listSnapshots,
22
+ runBackup,
23
+ } from "../../../backup/index.js";
24
+ import { log } from "../../../../util/log.js";
25
+ import type { SharedActionHandlers } from "../types.js";
26
+
27
+ const DEFAULT_LIST_LIMIT = 20;
28
+
29
+ function limitOf(
30
+ body: Record<string, unknown>,
31
+ fallback = DEFAULT_LIST_LIMIT,
32
+ ): number {
33
+ const raw = Number(body.limit);
34
+ return Number.isInteger(raw) && raw > 0 ? Math.min(raw, 100) : fallback;
35
+ }
36
+
37
+ async function takeCheckpoint(
38
+ label: string,
39
+ pinned: boolean,
40
+ trigger: string,
41
+ ): Promise<ReturnType<SharedActionHandlers[string]>> {
42
+ try {
43
+ const manifest = await runBackup({
44
+ kind: "checkpoint",
45
+ label,
46
+ pinned,
47
+ trigger,
48
+ });
49
+ log("gateway", `create_checkpoint: ${manifest.id} "${label}"`);
50
+ return {
51
+ ok: true,
52
+ id: manifest.id,
53
+ text:
54
+ `Checkpoint ${manifest.id} taken — "${label}"` +
55
+ (pinned ? " (pinned, never pruned)" : "") +
56
+ `\n${manifest.parts.length} part(s), ${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB.` +
57
+ `\nRestore it with: talon backup restore ${manifest.id}`,
58
+ };
59
+ } catch (err) {
60
+ return {
61
+ ok: false,
62
+ error: `Checkpoint failed: ${err instanceof Error ? err.message : String(err)}`,
63
+ };
64
+ }
65
+ }
66
+
67
+ export const backupHandlers: SharedActionHandlers = {
68
+ create_checkpoint: async (body) => {
69
+ const label = String(body.label ?? "").trim();
70
+ if (!label)
71
+ return { ok: false, error: "create_checkpoint: label is required" };
72
+ return takeCheckpoint(label, body.pin === true, "tool");
73
+ },
74
+
75
+ list_checkpoints: async (body) => {
76
+ const snapshots = await listSnapshots();
77
+ if (snapshots.length === 0) {
78
+ return {
79
+ ok: true,
80
+ text: "No snapshots yet. create_checkpoint takes one now.",
81
+ snapshots: [],
82
+ };
83
+ }
84
+ const limited = snapshots.slice(0, limitOf(body));
85
+ return {
86
+ ok: true,
87
+ text: formatSnapshotList(limited, limited.length),
88
+ snapshots: limited,
89
+ };
90
+ },
91
+
92
+ backup_status: async () => {
93
+ const status = await collectBackupStatus({ withTargets: false });
94
+ return { ok: true, text: formatBackupStatus(status) };
95
+ },
96
+
97
+ "backup.status": async () => {
98
+ const status = await collectBackupStatus();
99
+ return { ok: true, text: formatBackupStatus(status), status };
100
+ },
101
+
102
+ "backup.list": async (body) => {
103
+ const snapshots = (await listSnapshots()).slice(0, limitOf(body, 50));
104
+ return { ok: true, text: formatSnapshotList(snapshots), snapshots };
105
+ },
106
+
107
+ "backup.now": async (body) => {
108
+ const label = String(body.label ?? "").trim();
109
+ if (label) return takeCheckpoint(label, body.pin === true, "manual");
110
+ try {
111
+ const manifest = await runBackup({ kind: "backup", trigger: "manual" });
112
+ return {
113
+ ok: true,
114
+ id: manifest.id,
115
+ text: `Snapshot ${manifest.id} taken (${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB).`,
116
+ };
117
+ } catch (err) {
118
+ return {
119
+ ok: false,
120
+ error: `Backup failed: ${err instanceof Error ? err.message : String(err)}`,
121
+ };
122
+ }
123
+ },
124
+ };
125
+
126
+ /** None of these need a chat — backups belong to the daemon. */
127
+ export const backupChatFreeActions: ReadonlySet<string> = new Set(
128
+ Object.keys(backupHandlers),
129
+ );
@@ -21,6 +21,7 @@
21
21
  * - `models` — model / backend discovery
22
22
  * - `mesh` — companion device mesh (presence + location)
23
23
  * - `cross-send` — explicit-target sends through any enabled frontend
24
+ * - `backup` — snapshots and checkpoints (chat-free; no restore)
24
25
  */
25
26
 
26
27
  import type { ActionResult } from "../../types.js";
@@ -47,6 +48,7 @@ import {
47
48
  whatsappAccountChatFreeActions,
48
49
  } from "./whatsapp-account.js";
49
50
  import { nativeHandlers } from "./native/index.js";
51
+ import { backupChatFreeActions, backupHandlers } from "./backup/index.js";
50
52
 
51
53
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
52
54
  // can't resolve an inherited Object.prototype method — `handlers[action]` only
@@ -67,6 +69,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
67
69
  ...crossSendHandlers,
68
70
  ...whatsappAccountHandlers,
69
71
  ...nativeHandlers,
72
+ ...backupHandlers,
70
73
  });
71
74
 
72
75
  /**
@@ -77,6 +80,7 @@ const chatFreeActions: ReadonlySet<string> = new Set([
77
80
  ...meshChatFreeActions,
78
81
  ...crossSendChatFreeActions,
79
82
  ...whatsappAccountChatFreeActions,
83
+ ...backupChatFreeActions,
80
84
  ]);
81
85
 
82
86
  /**
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { composeTools } from "../tools/index.js";
19
- import { createBridge, textResult } from "../tools/ops/bridge.js";
19
+ import { createBridge, textResult } from "../tools/bridge.js";
20
20
  import type { ToolFrontend, ToolTag } from "../tools/types.js";
21
21
 
22
22
  export const VALID_TOOL_FRONTENDS: ReadonlySet<string> = new Set([
@@ -2,6 +2,13 @@
2
2
  * Action routing — try a gateway action through every loaded plugin in load
3
3
  * order; first non-null result wins. Per-plugin errors are caught and surfaced
4
4
  * as error results rather than cascading.
5
+ *
6
+ * `handlePluginActionIn` is the addressed form: the backup subsystem asks
7
+ * each plugin in turn whether it is a remote target, and must then be able
8
+ * to send the follow-up uploads to THAT plugin. First-non-null routing
9
+ * cannot express "this one", and the wire protocol carries no plugin id —
10
+ * so the addressing lives here, on the core side, and the plugin-facing
11
+ * contract is unchanged.
5
12
  */
6
13
 
7
14
  import { logError } from "../../util/log.js";
@@ -30,3 +37,30 @@ export async function handlePluginAction(
30
37
  }
31
38
  return null;
32
39
  }
40
+
41
+ /** Names of the loaded plugins that can answer gateway actions, in load order. */
42
+ export function pluginsWithActions(): string[] {
43
+ return registry.all
44
+ .filter(({ plugin }) => plugin.handleAction !== undefined)
45
+ .map(({ plugin }) => plugin.name);
46
+ }
47
+
48
+ /**
49
+ * Run an action against one named plugin. Returns null when no plugin by
50
+ * that name is loaded or it does not recognise the action.
51
+ */
52
+ export async function handlePluginActionIn(
53
+ name: string,
54
+ body: Record<string, unknown>,
55
+ chatId: string,
56
+ ): Promise<ActionResult | null> {
57
+ const entry = registry.all.find(({ plugin }) => plugin.name === name);
58
+ if (!entry?.plugin.handleAction) return null;
59
+ try {
60
+ return await entry.plugin.handleAction(body, chatId);
61
+ } catch (err) {
62
+ const detail = err instanceof Error ? err.message : String(err);
63
+ logError("plugin", `${name} action error: ${detail}`);
64
+ return { ok: false, error: `Plugin ${name}: ${detail}` };
65
+ }
66
+ }
@@ -23,5 +23,9 @@ export {
23
23
  getPluginPromptAdditions,
24
24
  } from "./loader.js";
25
25
  export { loadBuiltinPlugins, reloadPlugins } from "./builtins.js";
26
- export { handlePluginAction } from "./actions.js";
26
+ export {
27
+ handlePluginAction,
28
+ handlePluginActionIn,
29
+ pluginsWithActions,
30
+ } from "./actions.js";
27
31
  export { getPluginMcpServers } from "./mcp.js";
@@ -3,11 +3,16 @@
3
3
  *
4
4
  * Extracted from the old per-backend tools.ts files so there's
5
5
  * exactly one copy of callBridge / textResult.
6
+ *
7
+ * At the tools root, not in `ops/`: the group directories hold tool
8
+ * DOMAINS (one file per family of tool definitions), and this is the
9
+ * transport they all answer over — the same kind of module as
10
+ * `types.ts` and `schemas.ts` beside it.
6
11
  */
7
12
 
8
13
  import { Agent, fetch as undiciFetch } from "undici";
9
- import { isBunRuntime } from "../../../util/runtime.js";
10
- import type { BridgeFunction } from "../types.js";
14
+ import { isBunRuntime } from "../../util/runtime.js";
15
+ import type { BridgeFunction } from "./types.js";
11
16
 
12
17
  /** Default wall-clock budget for a bridge action. */
13
18
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -28,6 +28,7 @@ import { crossSendTools } from "./chat/cross-send.js";
28
28
  import { whatsappTools } from "./chat/whatsapp.js";
29
29
  import { moderationTools } from "./chat/moderation.js";
30
30
  import { nativeTools } from "./ops/native.js";
31
+ import { backupTools } from "./ops/backup.js";
31
32
 
32
33
  /** All built-in tool definitions. */
33
34
  export const ALL_TOOLS: readonly ToolDefinition[] = [
@@ -54,6 +55,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
54
55
  // prefix, so a new family goes on the end or every live chat re-bills its
55
56
  // system prompt (see compose-tools.test.ts).
56
57
  ...agentTools,
58
+ ...backupTools,
57
59
  ];
58
60
 
59
61
  /**
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Backup tools — the agent's own access to its safety net.
3
+ *
4
+ * Three tools, and deliberately no fourth: there is no restore tool.
5
+ * Restoring replaces the agent's memory, database and identity while it
6
+ * is running, which is a decision for a human at a CLI or behind a
7
+ * confirmation button, not something a model should be able to reach for
8
+ * mid-turn. Taking a checkpoint, on the other hand, is cheap and always
9
+ * safe — so the agent is encouraged to do it before it changes itself.
10
+ */
11
+
12
+ import { z } from "zod";
13
+ import type { ToolDefinition } from "../types.js";
14
+
15
+ export const backupTools: ToolDefinition[] = [
16
+ {
17
+ name: "create_checkpoint",
18
+ description: `Take a labelled snapshot of your whole state right now — config, prompts, keys, sessions, the database, your memory and skills, and the memory palace.
19
+
20
+ Reach for this BEFORE you change yourself in a way you might want undone: editing identity.md, a big memory rewrite, a risky config change, installing or reconfiguring a plugin. It takes seconds and costs no tokens.
21
+
22
+ Pin a checkpoint you want kept past the retention window (12 scheduled backups by default). Restoring is a human operation — 'talon backup restore <id>' or /backup restore <id> — so say the id in your reply if the checkpoint is the point of the turn.`,
23
+ schema: {
24
+ label: z
25
+ .string()
26
+ .min(1)
27
+ .max(80)
28
+ .describe(
29
+ "Why this checkpoint exists, in a few words ('before identity rewrite'). Shown in every listing.",
30
+ ),
31
+ pin: z
32
+ .boolean()
33
+ .optional()
34
+ .describe(
35
+ "Keep it forever — pinned checkpoints are never pruned by retention. Default false.",
36
+ ),
37
+ },
38
+ execute: (params, bridge) => bridge("create_checkpoint", params),
39
+ tag: "backup",
40
+ },
41
+
42
+ {
43
+ name: "list_checkpoints",
44
+ description:
45
+ "List recent snapshots — id, kind, label, size, whether pinned, and which remote targets hold a copy. Newest first. Use it to find the id of something you or the schedule took earlier.",
46
+ schema: {
47
+ limit: z
48
+ .number()
49
+ .int()
50
+ .positive()
51
+ .max(100)
52
+ .optional()
53
+ .describe("How many to return (default 20, max 100)."),
54
+ },
55
+ execute: (params, bridge) => bridge("list_checkpoints", params),
56
+ tag: "backup",
57
+ },
58
+
59
+ {
60
+ name: "backup_status",
61
+ description:
62
+ "Health of the backup subsystem: when the last snapshot ran and when the next one is due, how many snapshots exist locally and how big they are, which remote targets are registered and whether they are ready, and whether recent runs have been failing.",
63
+ schema: {},
64
+ execute: (_params, bridge) => bridge("backup_status", {}),
65
+ tag: "backup",
66
+ },
67
+ ];
@@ -31,7 +31,8 @@ export type ToolTag =
31
31
  | "models"
32
32
  | "mesh"
33
33
  | "moderation"
34
- | "native";
34
+ | "native"
35
+ | "backup";
35
36
 
36
37
  /** The bridge caller signature — injected into execute(). */
37
38
  export type BridgeFunction = (
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import { execFile } from "node:child_process";
33
+ import { checkpointBeforeUpdate } from "../backup/index.js";
33
34
  import { existsSync } from "node:fs";
34
35
  import { dirname, join } from "node:path";
35
36
  import { fileURLToPath } from "node:url";
@@ -248,6 +249,8 @@ export async function runSelfUpdate(
248
249
  return { ok: true, repoRoot, steps, before, after, changed: false };
249
250
  }
250
251
 
252
+ await checkpointBeforeUpdate(before, after);
253
+
251
254
  const install = await record(
252
255
  "npm install",
253
256
  "npm",
@@ -18,6 +18,7 @@
18
18
  * model:nav:* (pager)
19
19
  * model:backend-select (select menu)
20
20
  * metrics:today | metrics:all
21
+ * backup:restore:<id> | backup:cancel (restore confirmation, admin only)
21
22
  * ai:<id> (AI-generated buttons — forwarded to agent)
22
23
  */
23
24
 
@@ -36,6 +37,7 @@ import { handleMetricsComponent } from "./metrics.js";
36
37
  import { handleModelComponent } from "./model.js";
37
38
  import { handlePulseComponent } from "./pulse.js";
38
39
  import { handleSettingsComponent } from "./settings.js";
40
+ import { handleBackupComponent } from "../../commands/backup.js";
39
41
  import type { ComponentHandlers, ComponentInteraction } from "./types.js";
40
42
 
41
43
  // Null-prototype so a custom id of "toString:" / "constructor:" / etc.
@@ -48,6 +50,7 @@ export const COMPONENT_HANDLERS: ComponentHandlers = Object.assign(
48
50
  "effort:": handleEffortComponent,
49
51
  "model:": handleModelComponent,
50
52
  "metrics:": handleMetricsComponent,
53
+ "backup:": handleBackupComponent,
51
54
  "ai:": forwardToAgent,
52
55
  } satisfies ComponentHandlers,
53
56
  );