talon-agent 5.25.1 → 5.26.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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/backend/agy/one-shot.ts +34 -3
- package/src/backend/codex/auth.ts +31 -2
- package/src/backend/codex/constants.ts +21 -7
- package/src/backend/codex/discovery.ts +32 -1
- package/src/backend/codex/factory.ts +3 -5
- package/src/backend/codex/handler/message.ts +7 -7
- package/src/backend/codex/models.ts +53 -11
- package/src/backend/codex/one-shot.ts +139 -37
- package/src/backend/codex/state.ts +11 -0
- package/src/core/agents/abort-reason.ts +46 -0
- package/src/core/agents/registry.ts +3 -2
- package/src/core/background/dream/index.ts +2 -2
- package/src/core/background/heartbeat/agent.ts +1 -1
- package/src/core/background/isolated-agent.ts +1 -1
- package/src/core/backup/index.ts +1 -0
- package/src/core/backup/status.ts +30 -1
- package/src/core/config/index.ts +8 -0
- package/src/core/engine/gateway.ts +5 -0
- package/src/core/mesh/credentials/store.ts +60 -9
- package/src/core/tools/bridge.ts +58 -4
- package/src/frontend/discord/callbacks/components/effort.ts +4 -4
- package/src/frontend/discord/callbacks/components/index.ts +2 -0
- package/src/frontend/discord/commands/backup-panel.ts +219 -0
- package/src/frontend/discord/commands/backup.ts +25 -34
- package/src/frontend/discord/commands/info.ts +17 -5
- package/src/frontend/discord/commands/settings.ts +12 -9
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/native/bridge/credentials/claims.ts +5 -5
- package/src/frontend/native/bridge/routes/chats.ts +40 -20
- package/src/frontend/native/bridge/routes/host.ts +15 -2
- package/src/frontend/native/bridge/routes/table.ts +4 -0
- package/src/frontend/native/commands/admin.ts +64 -0
- package/src/frontend/native/commands/backup.ts +191 -0
- package/src/frontend/native/commands/definitions.ts +113 -0
- package/src/frontend/native/commands/format.ts +24 -0
- package/src/frontend/native/commands/index.ts +106 -0
- package/src/frontend/native/commands/info.ts +97 -0
- package/src/frontend/native/commands/session.ts +130 -0
- package/src/frontend/native/commands/types.ts +26 -0
- package/src/frontend/native/protocol.ts +18 -0
- package/src/frontend/native/surface/handlers.ts +14 -1
- package/src/frontend/native/surface/status.ts +9 -1
- package/src/frontend/native/turn/emit.ts +32 -1
- package/src/frontend/presentation/backup-panel.ts +425 -0
- package/src/frontend/presentation/memory-report.ts +109 -0
- package/src/frontend/presentation/text-commands.ts +243 -0
- package/src/frontend/telegram/admin/sessions.ts +20 -5
- package/src/frontend/telegram/admin.ts +9 -2
- package/src/frontend/telegram/callbacks/backup.ts +153 -22
- package/src/frontend/telegram/callbacks/effort.ts +5 -5
- package/src/frontend/telegram/callbacks/index.ts +2 -2
- package/src/frontend/telegram/callbacks/settings.ts +3 -40
- package/src/frontend/telegram/commands/admin.ts +8 -7
- package/src/frontend/telegram/commands/backup.ts +56 -46
- package/src/frontend/telegram/commands/index.ts +3 -2
- package/src/frontend/telegram/commands/info.ts +45 -26
- package/src/frontend/telegram/commands/memory.ts +8 -92
- package/src/frontend/telegram/commands/settings.ts +13 -45
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
- package/src/frontend/telegram/render/backup-panel.ts +47 -0
- package/src/frontend/telegram/render/menu.ts +21 -30
- package/src/frontend/terminal/builtins/model.ts +20 -11
- package/src/frontend/whatsapp/commands.ts +16 -216
- package/src/frontend/whatsapp/messages/inbound.ts +33 -2
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* /backup — snapshots and checkpoints (admin only), the Discord half.
|
|
3
3
|
*
|
|
4
|
-
* Same surface as Telegram's /backup:
|
|
5
|
-
*
|
|
4
|
+
* Same surface as Telegram's /backup: the panel (bare `/backup` — status
|
|
5
|
+
* with Back up now / Snapshots / How restore works / Refresh buttons, see
|
|
6
|
+
* backup-panel.ts), now, checkpoint, list, pin/unpin, restore. Restore is behind a button (`backup:restore:<id>`)
|
|
6
7
|
* because it replaces the database, memory and identity of a running
|
|
7
8
|
* agent — and even then it does not restore in place: the request is
|
|
8
9
|
* staged to ~/.talon/restore-pending.json and applied by the next boot,
|
|
@@ -21,16 +22,23 @@ import {
|
|
|
21
22
|
type ChatInputCommandInteraction,
|
|
22
23
|
} from "discord.js";
|
|
23
24
|
import {
|
|
24
|
-
collectBackupStatus,
|
|
25
|
-
formatBackupStatus,
|
|
26
25
|
formatSnapshotList,
|
|
27
26
|
isSnapshotId,
|
|
28
27
|
listSnapshots,
|
|
29
28
|
readManifest,
|
|
30
|
-
runBackup,
|
|
31
29
|
setSnapshotPinned,
|
|
32
30
|
writeRestorePending,
|
|
33
31
|
} from "../../../core/backup/index.js";
|
|
32
|
+
import {
|
|
33
|
+
parseBackupAction,
|
|
34
|
+
renderRestoreConfirm,
|
|
35
|
+
} from "../../presentation/backup-panel.js";
|
|
36
|
+
import { DISCORD_REPORTS } from "../render.js";
|
|
37
|
+
import {
|
|
38
|
+
handleBackupPanelAction,
|
|
39
|
+
replyWithPanel,
|
|
40
|
+
runSnapshotLine,
|
|
41
|
+
} from "./backup-panel.js";
|
|
34
42
|
import { respawnSelf } from "../../../core/daemon/respawn.js";
|
|
35
43
|
import { logError } from "../../../util/log.js";
|
|
36
44
|
import { escapeForCodeBlock } from "../formatting.js";
|
|
@@ -60,24 +68,7 @@ async function takeSnapshot(
|
|
|
60
68
|
label?: string,
|
|
61
69
|
): Promise<void> {
|
|
62
70
|
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
63
|
-
|
|
64
|
-
const manifest = await runBackup({
|
|
65
|
-
kind: label ? "checkpoint" : "backup",
|
|
66
|
-
label,
|
|
67
|
-
pinned: Boolean(label),
|
|
68
|
-
trigger: "command",
|
|
69
|
-
});
|
|
70
|
-
await i.editReply(
|
|
71
|
-
`✅ \`${manifest.id}\` — ${manifest.parts.length} part(s), ` +
|
|
72
|
-
`${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB` +
|
|
73
|
-
(label ? " (pinned)" : ""),
|
|
74
|
-
);
|
|
75
|
-
} catch (err) {
|
|
76
|
-
logError("backup", "/backup now failed", err);
|
|
77
|
-
await i.editReply(
|
|
78
|
-
`⚠️ Backup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
71
|
+
await i.editReply(await runSnapshotLine(label));
|
|
81
72
|
}
|
|
82
73
|
|
|
83
74
|
async function askToRestore(
|
|
@@ -94,12 +85,7 @@ async function askToRestore(
|
|
|
94
85
|
return;
|
|
95
86
|
}
|
|
96
87
|
await i.reply({
|
|
97
|
-
content:
|
|
98
|
-
`♻️ **Restore \`${id}\`?**\n` +
|
|
99
|
-
(manifest.label ? `“${manifest.label}”\n` : "") +
|
|
100
|
-
`Taken ${new Date(manifest.createdAt).toISOString()}\n\n` +
|
|
101
|
-
"This replaces config, prompts, keys, sessions, the database and memory, " +
|
|
102
|
-
"then restarts. A pinned checkpoint of the current state is taken first.",
|
|
88
|
+
content: renderRestoreConfirm(DISCORD_REPORTS, manifest),
|
|
103
89
|
components: [confirmRow(id).toJSON()],
|
|
104
90
|
flags: MessageFlags.Ephemeral,
|
|
105
91
|
});
|
|
@@ -149,11 +135,7 @@ export async function handleBackup(
|
|
|
149
135
|
await askToRestore(i, argument);
|
|
150
136
|
return;
|
|
151
137
|
default:
|
|
152
|
-
await
|
|
153
|
-
i,
|
|
154
|
-
block(formatBackupStatus(await collectBackupStatus())),
|
|
155
|
-
true,
|
|
156
|
-
);
|
|
138
|
+
await replyWithPanel(i);
|
|
157
139
|
}
|
|
158
140
|
}
|
|
159
141
|
|
|
@@ -174,6 +156,15 @@ export async function handleBackupComponent(
|
|
|
174
156
|
await interaction.update({ content: "Restore cancelled.", components: [] });
|
|
175
157
|
return true;
|
|
176
158
|
}
|
|
159
|
+
const panelAction = parseBackupAction(interaction.customId);
|
|
160
|
+
if (
|
|
161
|
+
panelAction &&
|
|
162
|
+
panelAction.kind !== "restore" &&
|
|
163
|
+
panelAction.kind !== "cancel"
|
|
164
|
+
) {
|
|
165
|
+
await handleBackupPanelAction(interaction, panelAction);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
177
168
|
if (action !== "restore" || !id || !isSnapshotId(id)) return false;
|
|
178
169
|
await interaction.update({
|
|
179
170
|
content:
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type ChatInputCommandInteraction,
|
|
7
7
|
type Client,
|
|
8
8
|
MessageFlags,
|
|
9
|
+
Status,
|
|
9
10
|
} from "discord.js";
|
|
10
11
|
import {
|
|
11
12
|
formatDuration,
|
|
@@ -111,12 +112,23 @@ export async function handlePing(
|
|
|
111
112
|
const start = Date.now();
|
|
112
113
|
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
113
114
|
const apiLatency = Date.now() - start;
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
await i.editReply(renderPingReply(i.client.ws, apiLatency));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The /ping body. `ws.ping` is the gateway heartbeat RTT (-1 until the
|
|
120
|
+
* first heartbeat is acknowledged); `ws.status` is the live connection
|
|
121
|
+
* state, reported by name when it isn't Ready.
|
|
122
|
+
*/
|
|
123
|
+
export function renderPingReply(
|
|
124
|
+
ws: { ping: number; status: Status },
|
|
125
|
+
restLatencyMs: number,
|
|
126
|
+
): string {
|
|
127
|
+
const wsPing = ws.ping >= 0 ? `${Math.round(ws.ping)}ms` : "n/a";
|
|
128
|
+
const gateway =
|
|
129
|
+
ws.status === Status.Ready ? "✓" : `✗ (${Status[ws.status] ?? ws.status})`;
|
|
116
130
|
const uptime = formatDuration(process.uptime() * 1000);
|
|
117
|
-
|
|
118
|
-
`Pong! WS: ${wsPing}ms · REST: ${apiLatency}ms\nGateway: ✓ | Uptime: ${uptime}`,
|
|
119
|
-
);
|
|
131
|
+
return `Pong! WS: ${wsPing} · REST: ${restLatencyMs}ms\nGateway: ${gateway} | Uptime: ${uptime}`;
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
export async function handlePlugins(
|
|
@@ -169,6 +169,18 @@ export async function handleEffort(
|
|
|
169
169
|
config,
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
// Reset first: adaptive needs no model levels, so it must stay
|
|
173
|
+
// reachable on a model that registers none.
|
|
174
|
+
if (level === "adaptive" || level === "reset" || level === "default") {
|
|
175
|
+
setChatEffort(chatId, undefined);
|
|
176
|
+
await reply(
|
|
177
|
+
i,
|
|
178
|
+
"Effort reset to **adaptive** (model decides when to think)",
|
|
179
|
+
true,
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
172
184
|
if (reasoning.levels.length === 0) {
|
|
173
185
|
await reply(
|
|
174
186
|
i,
|
|
@@ -202,15 +214,6 @@ export async function handleEffort(
|
|
|
202
214
|
return;
|
|
203
215
|
}
|
|
204
216
|
|
|
205
|
-
if (level === "adaptive") {
|
|
206
|
-
setChatEffort(chatId, undefined);
|
|
207
|
-
await reply(
|
|
208
|
-
i,
|
|
209
|
-
"Effort reset to **adaptive** (model decides when to think)",
|
|
210
|
-
true,
|
|
211
|
-
);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
217
|
if (supportsReasoningLevel(level, reasoning.levels)) {
|
|
215
218
|
setChatEffort(chatId, level as EffortLevel);
|
|
216
219
|
await reply(i, `Effort set to **${level}**`, true);
|
|
@@ -46,7 +46,7 @@ export {
|
|
|
46
46
|
const DEFAULT_METRICS_MESSAGE_MAX = DISCORD_MAX_TEXT - DISCORD_SAFE_RESERVE;
|
|
47
47
|
|
|
48
48
|
/** Discord markdown: bold/italic/code markers, no escaping, 2000-char messages. */
|
|
49
|
-
const DISCORD_REPORTS: ReportFormatter = {
|
|
49
|
+
export const DISCORD_REPORTS: ReportFormatter = {
|
|
50
50
|
bold: (s) => `**${s}**`,
|
|
51
51
|
italic: (s) => `_${s}_`,
|
|
52
52
|
emphasis: (s) => `*${s}*`,
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* `id` on /devices/register, `deviceId` on /location and
|
|
6
6
|
* /devices/command-result. A per-device credential may only ever name its
|
|
7
7
|
* own device; an unbound pairing/installer credential is bound by the first
|
|
8
|
-
* id it names (
|
|
8
|
+
* id it names — re-pairing (revoking) a device that already holds one when
|
|
9
|
+
* the store allows it, see DeviceCredentialStore.bind. The
|
|
9
10
|
* shared token and an open bridge name whatever they like — that is the
|
|
10
11
|
* legacy trust model — but a remote shared-token claim is recorded so the
|
|
11
12
|
* operator can see which devices still need upgrading.
|
|
@@ -44,10 +45,9 @@ export function claimDevice(
|
|
|
44
45
|
const bind = credentials?.authority.bind(principal.credentialId, claimed);
|
|
45
46
|
if (!bind || !bind.ok) {
|
|
46
47
|
const error = bind?.error ?? "Credential cannot be bound";
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// leaves no trace on the daemon side.
|
|
48
|
+
// Callers answer 403 with this error (never 401: the token itself is
|
|
49
|
+
// fine). Without this line a pairing that authenticated but could not
|
|
50
|
+
// bind leaves no trace on the daemon side.
|
|
51
51
|
logWarn(
|
|
52
52
|
"native",
|
|
53
53
|
`bridge.auth event=bind_refused credential=${principal.credentialId} device=${claimed}: ${error}`,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RouteHost } from "./host.js";
|
|
2
2
|
import { claimDevice } from "../credentials/claims.js";
|
|
3
|
+
import { hasScope } from "../credentials/principal.js";
|
|
3
4
|
import type { BridgeRoutes, RouteContext } from "./table.js";
|
|
4
5
|
import {
|
|
5
6
|
asAttachmentRefs,
|
|
@@ -26,6 +27,41 @@ function openEvents(host: RouteHost, ctx: RouteContext): void {
|
|
|
26
27
|
host.openStream(res, claim.deviceId, principal);
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* POST /send. Text naming one of the daemon's slash commands is answered
|
|
32
|
+
* by the daemon; the operator-only ones need the same scope as
|
|
33
|
+
* POST /control, so the caller's scope rides along.
|
|
34
|
+
*/
|
|
35
|
+
async function postSend(host: RouteHost, ctx: RouteContext): Promise<void> {
|
|
36
|
+
const { req, res, principal } = ctx;
|
|
37
|
+
const body = await host.readJson(req);
|
|
38
|
+
const id = asString(body.chatId) ?? "";
|
|
39
|
+
const text = asString(body.text) ?? "";
|
|
40
|
+
// Multi-file clients send `attachments`; the single-image shape older
|
|
41
|
+
// clients send is folded into the same list by the handler.
|
|
42
|
+
const attachments = asAttachmentRefs(body.attachments);
|
|
43
|
+
const imagePath = asString(body.imagePath);
|
|
44
|
+
const attachmentPath = asString(body.attachmentPath);
|
|
45
|
+
const hasAttachment =
|
|
46
|
+
attachments.length > 0 || Boolean(attachmentPath || imagePath);
|
|
47
|
+
// Text may be empty when a file is attached; require one or the other.
|
|
48
|
+
if (!id || (!text.trim() && !hasAttachment)) {
|
|
49
|
+
host.json(res, 400, {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: "chatId and text (or an attachment) required",
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const operator = principal !== null && hasScope(principal, "operator");
|
|
56
|
+
host.handlers.send(
|
|
57
|
+
id,
|
|
58
|
+
text,
|
|
59
|
+
{ attachments, imagePath, attachmentPath },
|
|
60
|
+
{ operator },
|
|
61
|
+
);
|
|
62
|
+
host.json(res, 202, { ok: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
29
65
|
export function chatRoutes(
|
|
30
66
|
host: RouteHost,
|
|
31
67
|
): Pick<
|
|
@@ -42,6 +78,7 @@ export function chatRoutes(
|
|
|
42
78
|
| "GET /history"
|
|
43
79
|
| "GET /search"
|
|
44
80
|
| "POST /send"
|
|
81
|
+
| "GET /commands"
|
|
45
82
|
| "POST /upload"
|
|
46
83
|
| "GET /media"
|
|
47
84
|
> {
|
|
@@ -105,26 +142,9 @@ export function chatRoutes(
|
|
|
105
142
|
const chatId = url.searchParams.get("chatId") ?? undefined;
|
|
106
143
|
json(res, 200, { results: h.search(q, chatId) });
|
|
107
144
|
},
|
|
108
|
-
"POST /send":
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const text = asString(body.text) ?? "";
|
|
112
|
-
// Multi-file clients send `attachments`; the single-image shape older
|
|
113
|
-
// clients send is folded into the same list by the handler.
|
|
114
|
-
const attachments = asAttachmentRefs(body.attachments);
|
|
115
|
-
const imagePath = asString(body.imagePath);
|
|
116
|
-
const attachmentPath = asString(body.attachmentPath);
|
|
117
|
-
const hasAttachment =
|
|
118
|
-
attachments.length > 0 || Boolean(attachmentPath || imagePath);
|
|
119
|
-
// Text may be empty when a file is attached; require one or the other.
|
|
120
|
-
if (!id || (!text.trim() && !hasAttachment))
|
|
121
|
-
return json(res, 400, {
|
|
122
|
-
ok: false,
|
|
123
|
-
error: "chatId and text (or an attachment) required",
|
|
124
|
-
});
|
|
125
|
-
h.send(id, text, { attachments, imagePath, attachmentPath });
|
|
126
|
-
json(res, 202, { ok: true });
|
|
127
|
-
},
|
|
145
|
+
"POST /send": (ctx) => postSend(host, ctx),
|
|
146
|
+
"GET /commands": ({ res }) =>
|
|
147
|
+
json(res, 200, { commands: h.listCommands() }),
|
|
128
148
|
"POST /upload": async ({ req, res, url }) => {
|
|
129
149
|
const filename = url.searchParams.get("filename") ?? "upload";
|
|
130
150
|
const contentType =
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
BridgeEvent,
|
|
12
12
|
BridgeStatus,
|
|
13
13
|
ClientChat,
|
|
14
|
+
ClientCommand,
|
|
14
15
|
ClientMessage,
|
|
15
16
|
DeviceInfo,
|
|
16
17
|
DeviceLocation,
|
|
@@ -64,8 +65,18 @@ export type BridgeServerHandlers = {
|
|
|
64
65
|
listMemory(query: MemoryListQuery): MemoryListResult;
|
|
65
66
|
/** One memory row plus its audit trail, or null when no such id. */
|
|
66
67
|
memoryWhy(id: number): MemoryWhyWire | null;
|
|
67
|
-
/**
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Fire-and-forget: streams its results back through `broadcast`. Text
|
|
70
|
+
* naming one of the daemon's slash commands is answered by the daemon
|
|
71
|
+
* instead of the model; `caller` says whether the credential may run
|
|
72
|
+
* the operator-only ones (absent = it may not).
|
|
73
|
+
*/
|
|
74
|
+
send(
|
|
75
|
+
id: string,
|
|
76
|
+
text: string,
|
|
77
|
+
opts?: SendOptions,
|
|
78
|
+
caller?: { operator: boolean },
|
|
79
|
+
): void;
|
|
69
80
|
/**
|
|
70
81
|
* Stream an uploaded file to disk and return its wire description. The body
|
|
71
82
|
* is consumed as it arrives (never buffered whole), so the size ceiling is
|
|
@@ -112,6 +123,8 @@ export type BridgeServerHandlers = {
|
|
|
112
123
|
setSkillEnabled(name: string, enabled: boolean): ToggleResult;
|
|
113
124
|
/** Fire a daemon-level control action (e.g. "restart", "dream"). */
|
|
114
125
|
control(action: string): Promise<{ ok: boolean; message: string }>;
|
|
126
|
+
/** The slash commands `/send` answers itself, for client autocomplete. */
|
|
127
|
+
listCommands(): ClientCommand[];
|
|
115
128
|
/** Newest daemon log entries (for the client's log viewer). */
|
|
116
129
|
logs(opts: {
|
|
117
130
|
lines: number;
|
|
@@ -67,6 +67,10 @@ export const BRIDGE_ROUTE_AUTH = {
|
|
|
67
67
|
"GET /memory/why": "client",
|
|
68
68
|
|
|
69
69
|
"POST /send": "client",
|
|
70
|
+
// The slash commands /send answers itself — names and one-liners for
|
|
71
|
+
// autocomplete. Operator-only ones are listed (flagged `admin`) and
|
|
72
|
+
// refused at /send time for a credential without the scope.
|
|
73
|
+
"GET /commands": "client",
|
|
70
74
|
"POST /upload": "client",
|
|
71
75
|
"GET /media": "client",
|
|
72
76
|
"GET /models": "client",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator commands — `/metrics`, `/doctor`, `/dream`, `/restart`.
|
|
3
|
+
*
|
|
4
|
+
* Gated on the `operator` scope by the dispatcher (see definitions.ts).
|
|
5
|
+
* `/restart` and `/dream` are the same actions the app's Settings screen
|
|
6
|
+
* fires through `POST /control`, so a typed command and a tap take the
|
|
7
|
+
* one code path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { collectDoctorReport } from "../../../core/doctor/index.js";
|
|
11
|
+
import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
|
|
12
|
+
import {
|
|
13
|
+
renderDoctorReport,
|
|
14
|
+
renderMetricsMessages,
|
|
15
|
+
} from "../../presentation/reports.js";
|
|
16
|
+
import { control } from "../surface/control.js";
|
|
17
|
+
import { NATIVE_REPORTS } from "./format.js";
|
|
18
|
+
import type { NativeCommandContext, NativeCommandHandler } from "./types.js";
|
|
19
|
+
|
|
20
|
+
async function metrics(ctx: NativeCommandContext): Promise<void> {
|
|
21
|
+
const all = ctx.arg.toLowerCase() === "all";
|
|
22
|
+
const parts = renderMetricsMessages(
|
|
23
|
+
all ? getMetrics() : getTodayMetrics(),
|
|
24
|
+
NATIVE_REPORTS,
|
|
25
|
+
undefined,
|
|
26
|
+
all ? "📊 Metrics — all time" : "📊 Metrics — today (UTC)",
|
|
27
|
+
);
|
|
28
|
+
ctx.reply(
|
|
29
|
+
parts.join("\n\n") +
|
|
30
|
+
(all ? "" : "\n\n`/metrics all` for the all-time view."),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function doctor(ctx: NativeCommandContext): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
const report = await collectDoctorReport({
|
|
37
|
+
config: ctx.runtime.config,
|
|
38
|
+
hasConfigFile: true,
|
|
39
|
+
});
|
|
40
|
+
ctx.reply(renderDoctorReport(report, NATIVE_REPORTS));
|
|
41
|
+
} catch (err) {
|
|
42
|
+
ctx.reply(
|
|
43
|
+
`🩺 Doctor failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function dream(ctx: NativeCommandContext): Promise<void> {
|
|
49
|
+
const result = await control("dream");
|
|
50
|
+
ctx.reply(result.ok ? `🌙 ${result.message}` : `⚠️ ${result.message}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function restart(ctx: NativeCommandContext): Promise<void> {
|
|
54
|
+
// Reply first: the restart takes this process down with it.
|
|
55
|
+
ctx.reply("♻️ Restarting Talon — back online in a few seconds.");
|
|
56
|
+
await control("restart");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const adminCommands = {
|
|
60
|
+
metrics,
|
|
61
|
+
doctor,
|
|
62
|
+
dream,
|
|
63
|
+
restart,
|
|
64
|
+
} satisfies Record<string, NativeCommandHandler>;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /backup — snapshots and checkpoints (operator only), the native half.
|
|
3
|
+
*
|
|
4
|
+
* /backup status: schedule, sizes, targets
|
|
5
|
+
* /backup now take a snapshot now
|
|
6
|
+
* /backup checkpoint <label> labelled, pinned snapshot
|
|
7
|
+
* /backup list recent snapshots
|
|
8
|
+
* /backup show <id> one snapshot's manifest summary
|
|
9
|
+
* /backup pin|unpin <id> keep past retention, or release
|
|
10
|
+
* /backup restore <id> describe it and ask to confirm
|
|
11
|
+
* /backup restore <id> confirm staged restore + restart
|
|
12
|
+
*
|
|
13
|
+
* Same surface as Telegram's and Discord's /backup, with the confirmation
|
|
14
|
+
* button replaced by a typed `confirm` — the bridge has no callback route
|
|
15
|
+
* for button data. Restore does not happen in place either: the request
|
|
16
|
+
* is staged to ~/.talon/restore-pending.json and applied by the next boot
|
|
17
|
+
* before anything opens the database (core/backup/restore.ts, and
|
|
18
|
+
* `applyStagedRestore` in app.ts), exactly as the other frontends do it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
collectBackupStatus,
|
|
23
|
+
formatBackupStatus,
|
|
24
|
+
formatBytes,
|
|
25
|
+
formatSnapshotList,
|
|
26
|
+
isSnapshotId,
|
|
27
|
+
listSnapshots,
|
|
28
|
+
readManifest,
|
|
29
|
+
runBackup,
|
|
30
|
+
setSnapshotPinned,
|
|
31
|
+
writeRestorePending,
|
|
32
|
+
} from "../../../core/backup/index.js";
|
|
33
|
+
import { respawnSelf } from "../../../core/daemon/respawn.js";
|
|
34
|
+
import { logError } from "../../../util/log.js";
|
|
35
|
+
import { fence } from "./format.js";
|
|
36
|
+
import type { NativeCommandContext } from "./types.js";
|
|
37
|
+
|
|
38
|
+
const USAGE = [
|
|
39
|
+
"**/backup** — snapshots and checkpoints",
|
|
40
|
+
"",
|
|
41
|
+
"`/backup` — status",
|
|
42
|
+
"`/backup now` — take a snapshot",
|
|
43
|
+
"`/backup checkpoint <label>` — labelled, pinned checkpoint",
|
|
44
|
+
"`/backup list` — recent snapshots",
|
|
45
|
+
"`/backup show <id>` — one snapshot",
|
|
46
|
+
"`/backup pin <id>` · `/backup unpin <id>`",
|
|
47
|
+
"`/backup restore <id>` — restore (asks to confirm, then restarts)",
|
|
48
|
+
].join("\n");
|
|
49
|
+
|
|
50
|
+
const NOT_AN_ID = "That is not a snapshot id — `/backup list` shows them.";
|
|
51
|
+
|
|
52
|
+
function errorText(err: unknown): string {
|
|
53
|
+
return err instanceof Error ? err.message : String(err);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function takeSnapshot(
|
|
57
|
+
ctx: NativeCommandContext,
|
|
58
|
+
label?: string,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
ctx.reply(
|
|
61
|
+
label ? `📸 Taking checkpoint “${label}”…` : "📸 Taking a snapshot…",
|
|
62
|
+
);
|
|
63
|
+
try {
|
|
64
|
+
const manifest = await runBackup({
|
|
65
|
+
kind: label ? "checkpoint" : "backup",
|
|
66
|
+
label,
|
|
67
|
+
pinned: Boolean(label),
|
|
68
|
+
trigger: "command",
|
|
69
|
+
});
|
|
70
|
+
ctx.reply(
|
|
71
|
+
`✅ \`${manifest.id}\` — ${manifest.parts.length} part(s), ` +
|
|
72
|
+
`${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB` +
|
|
73
|
+
(label ? " (pinned)" : ""),
|
|
74
|
+
);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
logError("backup", "/backup now failed", err);
|
|
77
|
+
ctx.reply(`⚠️ Backup failed: ${errorText(err)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function show(ctx: NativeCommandContext, id: string): Promise<void> {
|
|
82
|
+
if (!isSnapshotId(id)) return ctx.reply(NOT_AN_ID);
|
|
83
|
+
const manifest = await readManifest(id);
|
|
84
|
+
if (!manifest) return ctx.reply(`No snapshot \`${id}\` on this machine.`);
|
|
85
|
+
const remotes = Object.entries(manifest.remote ?? {});
|
|
86
|
+
ctx.reply(
|
|
87
|
+
[
|
|
88
|
+
`**Snapshot \`${manifest.id}\`**${manifest.pinned ? " 📌" : ""}`,
|
|
89
|
+
...(manifest.label ? [`“${manifest.label}”`] : []),
|
|
90
|
+
`Kind: ${manifest.kind} · taken ${new Date(manifest.createdAt).toISOString()}`,
|
|
91
|
+
`Host: ${manifest.host} · Talon ${manifest.talonVersion}`,
|
|
92
|
+
`Size: ${formatBytes(manifest.sizeBytes)} in ${manifest.parts.length} part(s)`,
|
|
93
|
+
`Covers: ${manifest.includes.join(", ") || "—"}`,
|
|
94
|
+
...(remotes.length
|
|
95
|
+
? [
|
|
96
|
+
`Remotes: ${remotes.map(([name, r]) => `${name} (${r.status})`).join(", ")}`,
|
|
97
|
+
]
|
|
98
|
+
: []),
|
|
99
|
+
"",
|
|
100
|
+
`\`/backup restore ${manifest.id}\` restores it.`,
|
|
101
|
+
].join("\n"),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function setPinned(
|
|
106
|
+
ctx: NativeCommandContext,
|
|
107
|
+
id: string,
|
|
108
|
+
pinned: boolean,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
if (!isSnapshotId(id)) return ctx.reply(NOT_AN_ID);
|
|
111
|
+
const ok = await setSnapshotPinned(id, pinned);
|
|
112
|
+
ctx.reply(
|
|
113
|
+
ok
|
|
114
|
+
? `${pinned ? "📌 Pinned" : "Unpinned"} \`${id}\``
|
|
115
|
+
: `No snapshot \`${id}\``,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `/backup restore <id>` describes the snapshot and asks for the typed
|
|
121
|
+
* confirmation; `/backup restore <id> confirm` stages it and hands off to
|
|
122
|
+
* the successor, which applies it during boot.
|
|
123
|
+
*/
|
|
124
|
+
async function restore(
|
|
125
|
+
ctx: NativeCommandContext,
|
|
126
|
+
id: string,
|
|
127
|
+
confirmed: boolean,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (!isSnapshotId(id)) return ctx.reply(NOT_AN_ID);
|
|
130
|
+
const manifest = await readManifest(id);
|
|
131
|
+
if (!manifest) return ctx.reply(`No snapshot \`${id}\` on this machine.`);
|
|
132
|
+
if (!confirmed) {
|
|
133
|
+
ctx.reply(
|
|
134
|
+
`♻️ **Restore \`${id}\`?**\n` +
|
|
135
|
+
(manifest.label ? `“${manifest.label}”\n` : "") +
|
|
136
|
+
`Taken ${new Date(manifest.createdAt).toISOString()}\n\n` +
|
|
137
|
+
"This replaces config, prompts, keys, sessions, the database and memory, " +
|
|
138
|
+
"then restarts. A pinned checkpoint of the current state is taken first.\n\n" +
|
|
139
|
+
`Send \`/backup restore ${id} confirm\` to go ahead.`,
|
|
140
|
+
);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await writeRestorePending({
|
|
145
|
+
id,
|
|
146
|
+
requestedAt: Date.now(),
|
|
147
|
+
requestedBy: ctx.entry.id,
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
logError("backup", "Staging the restore failed", err);
|
|
151
|
+
ctx.reply(`⚠️ Could not stage the restore: ${errorText(err)}`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
ctx.reply(
|
|
155
|
+
`♻️ Restoring \`${id}\` — restarting now. The restore is applied during ` +
|
|
156
|
+
"boot; the result is reported once Talon is back up.",
|
|
157
|
+
);
|
|
158
|
+
respawnSelf(`native /backup restore ${id}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `/backup <subcommand> [arg]`. */
|
|
162
|
+
export async function backupCommand(ctx: NativeCommandContext): Promise<void> {
|
|
163
|
+
const [subcommand, ...rest] = ctx.arg.split(/\s+/).filter(Boolean);
|
|
164
|
+
const target = rest[0] ?? "";
|
|
165
|
+
switch (subcommand?.toLowerCase()) {
|
|
166
|
+
case undefined:
|
|
167
|
+
case "status":
|
|
168
|
+
return ctx.reply(fence(formatBackupStatus(await collectBackupStatus())));
|
|
169
|
+
case "now":
|
|
170
|
+
return takeSnapshot(ctx);
|
|
171
|
+
case "checkpoint": {
|
|
172
|
+
const label = rest.join(" ").trim();
|
|
173
|
+
if (!label)
|
|
174
|
+
return ctx.reply(
|
|
175
|
+
"Give the checkpoint a label: `/backup checkpoint before the rewrite`",
|
|
176
|
+
);
|
|
177
|
+
return takeSnapshot(ctx, label);
|
|
178
|
+
}
|
|
179
|
+
case "list":
|
|
180
|
+
return ctx.reply(fence(formatSnapshotList(await listSnapshots())));
|
|
181
|
+
case "show":
|
|
182
|
+
return show(ctx, target);
|
|
183
|
+
case "pin":
|
|
184
|
+
case "unpin":
|
|
185
|
+
return setPinned(ctx, target, subcommand.toLowerCase() === "pin");
|
|
186
|
+
case "restore":
|
|
187
|
+
return restore(ctx, target, rest[1]?.toLowerCase() === "confirm");
|
|
188
|
+
default:
|
|
189
|
+
return ctx.reply(USAGE);
|
|
190
|
+
}
|
|
191
|
+
}
|