talon-agent 5.27.0 → 5.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.27.0",
3
+ "version": "5.28.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
package/src/app.ts CHANGED
@@ -128,10 +128,18 @@ stampDaemonOwner();
128
128
  *
129
129
  * Never throws: a failed restore still boots the daemon (with the reason
130
130
  * in the log and the request deleted, so the next boot is normal).
131
+ * Returns the confirmation line and who asked for it, so it can be sent
132
+ * back to that chat once the frontends are up.
131
133
  */
132
- async function applyStagedRestore(): Promise<string | null> {
134
+ async function applyStagedRestore(): Promise<{
135
+ text: string;
136
+ requestedBy?: string;
137
+ frontend?: string;
138
+ } | null> {
133
139
  const { applyPendingRestore, readRestorePending } =
134
140
  await import("./core/backup/index.js");
141
+ const { formatRestoreNotice } =
142
+ await import("./core/backup/restore/notice.js");
135
143
  if (!(await readRestorePending())) return null;
136
144
  const { loadConfig } = await import("./core/config/index.js");
137
145
  const { resolveBackupSettings } = await import("./core/backup/plan.js");
@@ -141,12 +149,11 @@ async function applyStagedRestore(): Promise<string | null> {
141
149
  beforeApply: closeDatabase,
142
150
  });
143
151
  if (!report) return null;
144
- return (
145
- `♻️ Restored snapshot ${report.id}` +
146
- (report.checkpointId
147
- ? ` (previous state saved as checkpoint ${report.checkpointId})`
148
- : "")
149
- );
152
+ return {
153
+ text: formatRestoreNotice(report),
154
+ requestedBy: report.requestedBy,
155
+ frontend: report.frontend,
156
+ };
150
157
  }
151
158
 
152
159
  /**
@@ -546,12 +553,21 @@ async function main(): Promise<void> {
546
553
  // Phase 0 accounting (docs/ts-migration-plan.md): the boot is over the
547
554
  // moment the frontends are listening, so the totals are folded into the
548
555
  // metrics store here, from the same uptime figure the log line prints.
549
- // A restore applied at boot happened before any frontend existed, so the
550
- // operator hears about it here, on the first channel that can carry it.
556
+ // A restore applied at boot happened before any frontend existed, so it
557
+ // is reported here, on the first channels that can carry it: the chat
558
+ // that asked for it, or the admin's primary chat when that one can't be
559
+ // reached. Not awaited — delivery may retry while a frontend finishes
560
+ // connecting, and the boot shouldn't wait on a courtesy message.
551
561
  if (restoreReport) {
552
562
  const { notifyAdmin } =
553
563
  await import("./core/frontend-runtime/admin-notify.js");
554
- await notifyAdmin(restoreReport);
564
+ const { deliverRestoreNotice } =
565
+ await import("./core/backup/restore/notice.js");
566
+ void deliverRestoreNotice({
567
+ text: restoreReport.text,
568
+ requester: restoreReport,
569
+ notifyAdmin,
570
+ });
555
571
  }
556
572
  // Same reasoning for a crash: the process that died couldn't say so,
557
573
  // so the marker it left is announced now. The probes start here too —
@@ -22,11 +22,11 @@
22
22
  */
23
23
 
24
24
  import { chmod } from "node:fs/promises";
25
- import { logWarn } from "../../util/log.js";
26
- import { TalonError } from "../errors.js";
27
- import { verifyManifest } from "./archive/manifest-auth.js";
28
- import { resolvePassphrase } from "./passphrase.js";
29
- import type { BackupSettings, Manifest } from "./types.js";
25
+ import { logWarn } from "../../../util/log.js";
26
+ import { TalonError } from "../../errors.js";
27
+ import { verifyManifest } from "../archive/manifest-auth.js";
28
+ import { resolvePassphrase } from "../passphrase.js";
29
+ import type { BackupSettings, Manifest } from "../types.js";
30
30
 
31
31
  export type ManifestTrust = {
32
32
  /** Operator override for unauthenticated manifests. */
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Reporting a staged restore back to the chat that asked for it.
3
+ *
4
+ * `/backup restore <id>` stages the request and restarts; the restore runs
5
+ * in the next boot before any frontend exists (see `applyStagedRestore` in
6
+ * app.ts). Once the frontends are up, the "♻️ Restored snapshot …" line is
7
+ * delivered here: to the requesting chat, on the frontend it came from,
8
+ * and — when that chat can't be reached (frontend disabled, delivery
9
+ * failing) or the request never said who asked — to the operator's
10
+ * primary chat through the admin notifier, which is where it always went
11
+ * before.
12
+ *
13
+ * Core never imports src/frontend, so delivery goes through the same
14
+ * cross-send broker `send_via` uses: each enabled frontend's action
15
+ * handler, keyed by frontend name.
16
+ */
17
+
18
+ import { log, logWarn } from "../../../util/log.js";
19
+ import {
20
+ isNativeChatId,
21
+ isTelegramChatId,
22
+ numericChatIdFor,
23
+ } from "../../frontend-runtime/chat-id.js";
24
+ import { crossSendTarget } from "../../engine/gateway-actions/cross-send.js";
25
+ import type { RestoreReport } from "../restore.js";
26
+
27
+ /** Who asked for a staged restore, as recorded in restore-pending.json. */
28
+ export type RestoreRequester = {
29
+ /** The requesting chat's key (Telegram id, `d_…`, `discord_…`). */
30
+ requestedBy?: string;
31
+ /** The frontend the request came from. Absent in files staged before it existed. */
32
+ frontend?: string;
33
+ };
34
+
35
+ /**
36
+ * The frontend a requester belongs to: the recorded one when the request
37
+ * carries it, else inferred from the shape of the chat key — Telegram's
38
+ * ids are numeric, native's start `d_`, Discord's `discord_`. Undefined
39
+ * when neither says.
40
+ */
41
+ export function requesterFrontend(
42
+ requester: RestoreRequester,
43
+ ): string | undefined {
44
+ const explicit =
45
+ typeof requester.frontend === "string"
46
+ ? requester.frontend.trim().toLowerCase()
47
+ : "";
48
+ if (explicit) return explicit;
49
+ const key =
50
+ typeof requester.requestedBy === "string" ? requester.requestedBy : "";
51
+ if (!key) return undefined;
52
+ if (isTelegramChatId(key)) return "telegram";
53
+ if (isNativeChatId(key)) return "native";
54
+ if (key.startsWith("discord_")) return "discord";
55
+ return undefined;
56
+ }
57
+
58
+ /** The confirmation line a successful staged restore reports. */
59
+ export function formatRestoreNotice(
60
+ report: Pick<RestoreReport, "id" | "checkpointId">,
61
+ ): string {
62
+ return (
63
+ `♻️ Restored snapshot ${report.id}` +
64
+ (report.checkpointId
65
+ ? ` (previous state saved as checkpoint ${report.checkpointId})`
66
+ : "")
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Send `text` to one chat through its frontend's registered action
72
+ * handler. The chat key rides in `target` so a frontend that has not
73
+ * seen the chat since the restart (a Discord channel nobody has spoken
74
+ * in yet, a native chat the restored database doesn't list) can adopt it.
75
+ * Resolves true only when the frontend reports the message delivered.
76
+ */
77
+ async function sendToRequester(
78
+ frontend: string,
79
+ chatKey: string,
80
+ text: string,
81
+ ): Promise<boolean> {
82
+ const handler = crossSendTarget(frontend);
83
+ if (!handler) return false;
84
+ const result = await handler(
85
+ { action: "send_message", text, target: chatKey },
86
+ numericChatIdFor(chatKey),
87
+ );
88
+ return Boolean(result && result.ok === true);
89
+ }
90
+
91
+ const RESTORE_NOTICE_ATTEMPTS = 6;
92
+ const RESTORE_NOTICE_DELAY_MS = 5_000;
93
+
94
+ export type RestoreNoticeOptions = {
95
+ text: string;
96
+ requester: RestoreRequester;
97
+ /** The operator's primary chat — the fallback. */
98
+ notifyAdmin: (text: string) => Promise<unknown>;
99
+ send?: (frontend: string, chatKey: string, text: string) => Promise<boolean>;
100
+ /** Whether a frontend is enabled at all (no point retrying one that isn't). */
101
+ isEnabled?: (frontend: string) => boolean;
102
+ attempts?: number;
103
+ sleep?: (ms: number) => Promise<void>;
104
+ };
105
+
106
+ /**
107
+ * Deliver the restore notice to the requesting chat, falling back to the
108
+ * admin's primary chat. Retries a while when the requester's frontend is
109
+ * enabled but can't deliver yet — the frontends have just started, and a
110
+ * Discord client may still be logging in. Never throws; resolves with
111
+ * where the notice went.
112
+ */
113
+ export async function deliverRestoreNotice(
114
+ options: RestoreNoticeOptions,
115
+ ): Promise<"requester" | "admin"> {
116
+ const {
117
+ text,
118
+ requester,
119
+ notifyAdmin,
120
+ send = sendToRequester,
121
+ isEnabled = (name) => crossSendTarget(name) !== undefined,
122
+ attempts = RESTORE_NOTICE_ATTEMPTS,
123
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref?.()),
124
+ } = options;
125
+ const frontend = requesterFrontend(requester);
126
+ const chatKey =
127
+ typeof requester.requestedBy === "string" ? requester.requestedBy : "";
128
+
129
+ if (frontend && chatKey && isEnabled(frontend)) {
130
+ for (let attempt = 1; attempt <= attempts; attempt++) {
131
+ try {
132
+ if (await send(frontend, chatKey, text)) {
133
+ log("backup", `Restore reported to ${frontend} chat ${chatKey}`);
134
+ return "requester";
135
+ }
136
+ } catch (err) {
137
+ logWarn(
138
+ "backup",
139
+ `Restore report to ${frontend} chat ${chatKey} failed: ${err instanceof Error ? err.message : String(err)}`,
140
+ );
141
+ }
142
+ if (attempt < attempts) await sleep(RESTORE_NOTICE_DELAY_MS);
143
+ }
144
+ logWarn(
145
+ "backup",
146
+ `Could not reach ${frontend} chat ${chatKey}; reporting the restore to the admin chat instead`,
147
+ );
148
+ } else if (chatKey || frontend) {
149
+ logWarn(
150
+ "backup",
151
+ `Restore requester ${frontend ?? "?"}:${chatKey || "?"} is not reachable here; reporting to the admin chat`,
152
+ );
153
+ }
154
+
155
+ try {
156
+ await notifyAdmin(text);
157
+ } catch (err) {
158
+ logWarn(
159
+ "backup",
160
+ `Admin restore report failed: ${err instanceof Error ? err.message : String(err)}`,
161
+ );
162
+ }
163
+ return "admin";
164
+ }
@@ -4,7 +4,7 @@
4
4
  * The rules that make this safe to run on a live home directory:
5
5
  *
6
6
  * 1. Verify before you touch anything. The manifest's signature is
7
- * checked (restore-guard.ts), then every part's sha256 and — for
7
+ * checked (restore/guard.ts), then every part's sha256 and — for
8
8
  * encrypted parts — every record's tag; a part that fails is a
9
9
  * stopped restore, not a half-applied one.
10
10
  * 2. Stage, then swap. The archive is extracted into a staging
@@ -55,7 +55,7 @@ import {
55
55
  authenticateManifest,
56
56
  makePrivate,
57
57
  type ManifestTrust,
58
- } from "./restore-guard.js";
58
+ } from "./restore/guard.js";
59
59
  import { buildSnapshot } from "./snapshot.js";
60
60
  import {
61
61
  relocateRoot,
@@ -84,6 +84,12 @@ export type RestorePending = {
84
84
  requestedAt: number;
85
85
  /** Chat key that asked, so the boot can report back. */
86
86
  requestedBy?: string;
87
+ /**
88
+ * Frontend the request came from ("telegram", "discord", "native"), so
89
+ * the report goes back the way it came. Absent in files staged before
90
+ * it was recorded — the boot then infers it from `requestedBy`.
91
+ */
92
+ frontend?: string;
87
93
  };
88
94
 
89
95
  export type RestoreReport = {
@@ -507,7 +513,7 @@ export type RestoreOptions = {
507
513
  beforeApply?: () => void | Promise<void>;
508
514
  /** Skip the automatic pre-restore checkpoint (it has already been taken). */
509
515
  skipCheckpoint?: boolean;
510
- /** Restore a manifest that carries no signature (see restore-guard.ts). */
516
+ /** Restore a manifest that carries no signature (see restore/guard.ts). */
511
517
  allowUnauthenticated?: ManifestTrust["allowUnauthenticated"];
512
518
  /**
513
519
  * Restoring onto a different machine: relocate the session stores and
@@ -641,7 +647,9 @@ export async function applyPendingRestore(options: {
641
647
  settings: BackupSettings;
642
648
  home?: string;
643
649
  beforeApply?: () => void | Promise<void>;
644
- }): Promise<(RestoreReport & { requestedBy?: string }) | null> {
650
+ }): Promise<
651
+ (RestoreReport & { requestedBy?: string; frontend?: string }) | null
652
+ > {
645
653
  const home = options.home ?? dirs.root;
646
654
  const pending = await readRestorePending(home);
647
655
  if (!pending) return null;
@@ -654,7 +662,11 @@ export async function applyPendingRestore(options: {
654
662
  beforeApply: options.beforeApply,
655
663
  });
656
664
  await clearRestorePending(home);
657
- return { ...report, requestedBy: pending.requestedBy };
665
+ return {
666
+ ...report,
667
+ requestedBy: pending.requestedBy,
668
+ frontend: pending.frontend,
669
+ };
658
670
  } catch (err) {
659
671
  logWarn("backup", `Staged restore of ${pending.id} failed: ${String(err)}`);
660
672
  await clearRestorePending(home);
@@ -23,7 +23,7 @@ import { resolve } from "node:path";
23
23
  import { dirs } from "../../../util/paths.js";
24
24
  import { log, logWarn } from "../../../util/log.js";
25
25
  import { TalonError } from "../../errors.js";
26
- import { readArray, writePrivateJson } from "../persist.js";
26
+ import { readArray, writePrivateJson, writesSettled } from "../persist.js";
27
27
  import { faultText } from "../../engine/fault-text.js";
28
28
  import { raiseAlert, resolveAlert } from "../../frontend-runtime/alerts.js";
29
29
  import {
@@ -506,6 +506,14 @@ export class DeviceCredentialStore {
506
506
  }
507
507
  }
508
508
 
509
+ /**
510
+ * Wait for every background write queued so far (mintNow, bind, re-pair
511
+ * revocation, lastUsedAt touches) to reach disk or fail.
512
+ */
513
+ flush(): Promise<void> {
514
+ return writesSettled(this.file);
515
+ }
516
+
509
517
  private persistSoon(): void {
510
518
  void this.persist().catch((err: unknown) =>
511
519
  logWarn("mesh", `Could not persist mesh credentials: ${err}`),
@@ -25,6 +25,15 @@ export async function readArray<T>(path: string): Promise<T[]> {
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * Resolve once every write queued for `path` so far has finished (settled,
30
+ * success or failure) — for callers that persisted fire-and-forget and now
31
+ * need the file on disk.
32
+ */
33
+ export function writesSettled(path: string): Promise<void> {
34
+ return writeQueues.get(path) ?? Promise.resolve();
35
+ }
36
+
28
37
  /** Persist JSON atomically with 0600 perms, serialized per path. */
29
38
  export async function writePrivateJson(
30
39
  path: string,
@@ -14,9 +14,12 @@ export async function resolveChannel(
14
14
  const info = lookupDiscordChat(numericChatId);
15
15
  if (!info) return null;
16
16
  try {
17
- const ch = await client.channels.fetch(info.channelId);
18
- if (ch && "send" in ch && (ch as TextBasedChannel).isSendable?.()) {
19
- return ch as TextBasedChannel;
17
+ // A DM rebuilt from its chat key has no channel id — open it by user.
18
+ if (info.channelId) {
19
+ const ch = await client.channels.fetch(info.channelId);
20
+ if (ch && "send" in ch && (ch as TextBasedChannel).isSendable?.()) {
21
+ return ch as TextBasedChannel;
22
+ }
20
23
  }
21
24
  if (info.userId) {
22
25
  const user = await client.users.fetch(info.userId);
@@ -24,6 +24,11 @@ import type { Client } from "discord.js";
24
24
  import type { Gateway } from "../../../core/engine/gateway.js";
25
25
  import type { ActionResult } from "../../../core/types.js";
26
26
  import { resolveChannel } from "./channels.js";
27
+ import {
28
+ lookupDiscordChat,
29
+ parseDiscordChatKey,
30
+ registerDiscordChat,
31
+ } from "../handlers/registry.js";
27
32
  import { messagingHandlers, restoreScheduledMessages } from "./messaging.js";
28
33
  import { mediaHandlers } from "./media.js";
29
34
  import { chatInfoHandlers } from "./chat-info.js";
@@ -43,6 +48,24 @@ const handlers: DiscordActionHandlers = Object.assign(Object.create(null), {
43
48
  ...chatInfoHandlers,
44
49
  });
45
50
 
51
+ /**
52
+ * A plain send addressed to a chat this process hasn't registered — the
53
+ * channel that asked for a staged restore, say, before anyone has spoken
54
+ * in it since the restart. The sender names the chat by key in
55
+ * `body.target`; when that key is the one the numeric id was derived
56
+ * from, register it so the channel resolves. send_message only.
57
+ */
58
+ function adoptAddressedChat(
59
+ action: string,
60
+ body: Record<string, unknown>,
61
+ chatId: number,
62
+ ): void {
63
+ if (action !== "send_message" || lookupDiscordChat(chatId)) return;
64
+ const key = typeof body.target === "string" ? body.target : "";
65
+ const info = key ? parseDiscordChatKey(key) : undefined;
66
+ if (info && info.numericChatId === chatId) registerDiscordChat(info);
67
+ }
68
+
46
69
  export function createDiscordActionHandler(client: Client, gateway: Gateway) {
47
70
  const scheduledMessages = new Map<string, ReturnType<typeof setTimeout>>();
48
71
 
@@ -58,6 +81,7 @@ export function createDiscordActionHandler(client: Client, gateway: Gateway) {
58
81
  const handler = handlers[action];
59
82
  if (!handler) return null; // not a Discord action
60
83
 
84
+ adoptAddressedChat(action, body, chatId);
61
85
  const channel = await resolveChannel(client, chatId);
62
86
 
63
87
  // For non-channel actions (e.g. cancel_scheduled) that don't need a
@@ -180,6 +180,7 @@ export async function handleBackupComponent(
180
180
  id,
181
181
  requestedAt: Date.now(),
182
182
  requestedBy: chatId,
183
+ frontend: "discord",
183
184
  });
184
185
  respawnSelf(`discord /backup restore ${id}`);
185
186
  } catch (err) {
@@ -3,6 +3,7 @@
3
3
  * Discord channel info the action handler needs to post back.
4
4
  */
5
5
 
6
+ import { deriveNumericChatId } from "../../../core/frontend-runtime/chat-id.js";
6
7
  import {
7
8
  chatRegistry,
8
9
  chatRegistryByString,
@@ -21,3 +22,37 @@ export function lookupDiscordChat(
21
22
  ): DiscordChatInfo | undefined {
22
23
  return chatRegistry.get(numericChatId);
23
24
  }
25
+
26
+ /**
27
+ * Rebuild a chat's registry entry from its string key alone
28
+ * (`discord_guild_<guild>_<channel>` or `discord_dm_<user>`), for a chat
29
+ * this process hasn't seen a message from yet — the registry is in-memory,
30
+ * so after a restart only the allowed users' DMs are known until someone
31
+ * speaks. A DM entry carries no channel id; resolveChannel opens the DM
32
+ * from the user id. Undefined for anything that isn't a Discord chat key.
33
+ */
34
+ export function parseDiscordChatKey(
35
+ chatKey: string,
36
+ ): DiscordChatInfo | undefined {
37
+ const guild = /^discord_guild_(\d+)_(\d+)$/.exec(chatKey);
38
+ if (guild) {
39
+ return {
40
+ channelId: guild[2]!,
41
+ guildId: guild[1]!,
42
+ userId: null,
43
+ numericChatId: deriveNumericChatId(chatKey),
44
+ chatId: chatKey,
45
+ };
46
+ }
47
+ const dm = /^discord_dm_(\d+)$/.exec(chatKey);
48
+ if (dm) {
49
+ return {
50
+ channelId: "",
51
+ guildId: null,
52
+ userId: dm[1]!,
53
+ numericChatId: deriveNumericChatId(chatKey),
54
+ chatId: chatKey,
55
+ };
56
+ }
57
+ return undefined;
58
+ }
@@ -145,6 +145,7 @@ async function restore(
145
145
  id,
146
146
  requestedAt: Date.now(),
147
147
  requestedBy: ctx.entry.id,
148
+ frontend: "native",
148
149
  });
149
150
  } catch (err) {
150
151
  logError("backup", "Staging the restore failed", err);
@@ -15,6 +15,10 @@ import type {
15
15
  import type { Gateway } from "../../../core/engine/gateway.js";
16
16
  import type { NativeChats, ChatEntry } from "../chats/chats.js";
17
17
  import type { BridgeEvent, ClientButton } from "../protocol.js";
18
+ import {
19
+ deriveNumericChatId,
20
+ isNativeChatId,
21
+ } from "../../../core/frontend-runtime/chat-id.js";
18
22
 
19
23
  export type NativeActionDeps = {
20
24
  chats: NativeChats;
@@ -69,6 +73,28 @@ const NATIVE_ACTIONS = new Set([
69
73
  "send_chat_action",
70
74
  ]);
71
75
 
76
+ /**
77
+ * A plain send addressed to a native chat this daemon doesn't list — the
78
+ * chat that asked for a staged restore, say, when the restored database
79
+ * predates it. The sender names the chat by key in `body.target`; the
80
+ * chat is adopted (as a deep link would) so the message lands in its
81
+ * history and the app shows it. Only when the key really is the chat the
82
+ * numeric id was derived from, and only for send_message: edits,
83
+ * reactions and deletes address messages, which an unknown chat has none of.
84
+ */
85
+ function adoptAddressedChat(
86
+ chats: NativeChats,
87
+ action: string,
88
+ body: Record<string, unknown>,
89
+ chatId: number,
90
+ ): ChatEntry | undefined {
91
+ if (action !== "send_message") return undefined;
92
+ const key = typeof body.target === "string" ? body.target : "";
93
+ if (!key || !isNativeChatId(key) || deriveNumericChatId(key) !== chatId)
94
+ return undefined;
95
+ return chats.ensure(key);
96
+ }
97
+
72
98
  export function createNativeActionHandler(
73
99
  deps: NativeActionDeps,
74
100
  ): FrontendActionHandler {
@@ -77,7 +103,9 @@ export function createNativeActionHandler(
77
103
  return async (body, chatId): Promise<ActionResult | null> => {
78
104
  const action = typeof body.action === "string" ? body.action : "";
79
105
  if (!NATIVE_ACTIONS.has(action)) return null;
80
- const entry = chats.byNumeric(chatId);
106
+ const entry =
107
+ chats.byNumeric(chatId) ??
108
+ adoptAddressedChat(chats, action, body, chatId);
81
109
  if (!entry) return { ok: false, error: "No active native chat" };
82
110
 
83
111
  switch (action) {
@@ -166,6 +166,7 @@ export async function stageRestore(chatId: string, id: string): Promise<void> {
166
166
  id,
167
167
  requestedAt: Date.now(),
168
168
  requestedBy: chatId,
169
+ frontend: "telegram",
169
170
  });
170
171
  respawnSelf(`telegram /backup restore ${id}`);
171
172
  }