talon-agent 5.25.0 → 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.
Files changed (66) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/backend/agy/one-shot.ts +34 -3
  4. package/src/backend/codex/auth.ts +31 -2
  5. package/src/backend/codex/constants.ts +21 -7
  6. package/src/backend/codex/discovery.ts +32 -1
  7. package/src/backend/codex/factory.ts +3 -5
  8. package/src/backend/codex/handler/message.ts +7 -7
  9. package/src/backend/codex/models.ts +53 -11
  10. package/src/backend/codex/one-shot.ts +139 -37
  11. package/src/backend/codex/state.ts +11 -0
  12. package/src/core/agents/abort-reason.ts +46 -0
  13. package/src/core/agents/registry.ts +3 -2
  14. package/src/core/background/dream/index.ts +2 -2
  15. package/src/core/background/heartbeat/agent.ts +1 -1
  16. package/src/core/background/isolated-agent.ts +1 -1
  17. package/src/core/backup/index.ts +1 -0
  18. package/src/core/backup/status.ts +30 -1
  19. package/src/core/config/index.ts +8 -0
  20. package/src/core/engine/gateway.ts +5 -0
  21. package/src/core/mesh/credentials/store.ts +60 -9
  22. package/src/core/tools/bridge.ts +58 -4
  23. package/src/frontend/discord/callbacks/components/effort.ts +4 -4
  24. package/src/frontend/discord/callbacks/components/index.ts +2 -0
  25. package/src/frontend/discord/commands/backup-panel.ts +219 -0
  26. package/src/frontend/discord/commands/backup.ts +25 -34
  27. package/src/frontend/discord/commands/info.ts +17 -5
  28. package/src/frontend/discord/commands/settings.ts +12 -9
  29. package/src/frontend/discord/render.ts +1 -1
  30. package/src/frontend/native/bridge/credentials/claims.ts +5 -5
  31. package/src/frontend/native/bridge/routes/chats.ts +40 -20
  32. package/src/frontend/native/bridge/routes/host.ts +15 -2
  33. package/src/frontend/native/bridge/routes/table.ts +4 -0
  34. package/src/frontend/native/commands/admin.ts +64 -0
  35. package/src/frontend/native/commands/backup.ts +191 -0
  36. package/src/frontend/native/commands/definitions.ts +113 -0
  37. package/src/frontend/native/commands/format.ts +24 -0
  38. package/src/frontend/native/commands/index.ts +106 -0
  39. package/src/frontend/native/commands/info.ts +97 -0
  40. package/src/frontend/native/commands/session.ts +130 -0
  41. package/src/frontend/native/commands/types.ts +26 -0
  42. package/src/frontend/native/protocol.ts +18 -0
  43. package/src/frontend/native/surface/handlers.ts +14 -1
  44. package/src/frontend/native/surface/status.ts +9 -1
  45. package/src/frontend/native/turn/emit.ts +32 -1
  46. package/src/frontend/presentation/backup-panel.ts +425 -0
  47. package/src/frontend/presentation/memory-report.ts +109 -0
  48. package/src/frontend/presentation/text-commands.ts +243 -0
  49. package/src/frontend/telegram/admin/sessions.ts +20 -5
  50. package/src/frontend/telegram/admin.ts +9 -2
  51. package/src/frontend/telegram/callbacks/backup.ts +153 -22
  52. package/src/frontend/telegram/callbacks/effort.ts +5 -5
  53. package/src/frontend/telegram/callbacks/index.ts +2 -2
  54. package/src/frontend/telegram/callbacks/settings.ts +3 -40
  55. package/src/frontend/telegram/commands/admin.ts +8 -7
  56. package/src/frontend/telegram/commands/backup.ts +56 -46
  57. package/src/frontend/telegram/commands/index.ts +3 -2
  58. package/src/frontend/telegram/commands/info.ts +45 -26
  59. package/src/frontend/telegram/commands/memory.ts +8 -92
  60. package/src/frontend/telegram/commands/settings.ts +13 -45
  61. package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
  62. package/src/frontend/telegram/render/backup-panel.ts +47 -0
  63. package/src/frontend/telegram/render/menu.ts +21 -30
  64. package/src/frontend/terminal/builtins/model.ts +20 -11
  65. package/src/frontend/whatsapp/commands.ts +16 -216
  66. package/src/frontend/whatsapp/messages/inbound.ts +33 -2
@@ -0,0 +1,425 @@
1
+ /**
2
+ * The /backup panel — status, snapshot pages and the restore guide —
3
+ * written once and spelled per platform through a `ReportFormatter`.
4
+ *
5
+ * Each view is text plus a platform-neutral button grid (`PanelButton`:
6
+ * a label and the callback data it sends back). Telegram maps the grid
7
+ * onto an inline keyboard, Discord onto button rows; the callback data
8
+ * grammar is shared, so both frontends parse taps with the same
9
+ * `parseBackupAction`.
10
+ *
11
+ * backup:panel the status panel (also Refresh)
12
+ * backup:now take a snapshot, editing the panel in place
13
+ * backup:list:<page> one page of snapshots
14
+ * backup:guide how restore works
15
+ * backup:pin:<page>:<id> pin, then re-render that page
16
+ * backup:unpin:<page>:<id> unpin, then re-render that page
17
+ * backup:ask:<page>:<id> the restore confirmation — nothing happens yet
18
+ * backup:restore:<id> the confirmed restore: stage + restart
19
+ * backup:cancel the confirmation's Cancel
20
+ *
21
+ * The longest of these (`backup:unpin:99:<23-char id>`) is 39 bytes,
22
+ * inside Telegram's 64-byte callback_data cap and Discord's 100-char
23
+ * custom_id cap.
24
+ *
25
+ * Restore stays the only destructive action and it is always two taps:
26
+ * a snapshot's Restore button only opens the confirmation (`ask`); the
27
+ * staged restore is `backup:restore:<id>`, which only the confirmation
28
+ * offers.
29
+ */
30
+
31
+ import {
32
+ isSnapshotId,
33
+ type BackupStatus,
34
+ type SnapshotSummary,
35
+ } from "../../core/backup/index.js";
36
+ import { formatBytes } from "./format.js";
37
+ import type { ReportFormatter } from "./reports.js";
38
+
39
+ export type PanelButton = { label: string; data: string };
40
+ export type PanelView = { text: string; buttons: PanelButton[][] };
41
+ /** The manifest fields a restore confirmation names. */
42
+ type RestoreSubject = { id: string; label?: string; createdAt: number };
43
+
44
+ /**
45
+ * Snapshots per page — one button row each, plus navigation and Back.
46
+ * Telegram takes five; Discord allows five action rows in all, so it
47
+ * pages by three.
48
+ */
49
+ export const SNAPSHOT_PAGE_SIZE = 5;
50
+
51
+ export type BackupAction =
52
+ | { kind: "panel" }
53
+ | { kind: "now" }
54
+ | { kind: "guide" }
55
+ | { kind: "cancel" }
56
+ | { kind: "list"; page: number }
57
+ | { kind: "pin" | "unpin" | "ask"; page: number; id: string }
58
+ | { kind: "restore"; id: string };
59
+
60
+ function pageOf(raw: string | undefined): number | null {
61
+ if (raw === undefined || !/^\d{1,3}$/.test(raw)) return null;
62
+ return Number(raw);
63
+ }
64
+
65
+ /** Parse `backup:*` callback data; null for anything malformed. */
66
+ export function parseBackupAction(data: string): BackupAction | null {
67
+ const [prefix, action, a, b, ...extra] = data.split(":");
68
+ if (prefix !== "backup" || extra.length > 0) return null;
69
+ switch (action) {
70
+ case "panel":
71
+ case "now":
72
+ case "guide":
73
+ case "cancel":
74
+ return a === undefined ? { kind: action } : null;
75
+ case "list": {
76
+ const page = pageOf(a);
77
+ return page === null || b !== undefined ? null : { kind: "list", page };
78
+ }
79
+ case "pin":
80
+ case "unpin":
81
+ case "ask": {
82
+ const page = pageOf(a);
83
+ if (page === null || !b || !isSnapshotId(b)) return null;
84
+ return { kind: action, page, id: b };
85
+ }
86
+ case "restore":
87
+ return a && isSnapshotId(a) && b === undefined
88
+ ? { kind: "restore", id: a }
89
+ : null;
90
+ default:
91
+ return null;
92
+ }
93
+ }
94
+
95
+ // ── Time ────────────────────────────────────────────────────────────────────
96
+
97
+ /** `2026-09-30 14:47 UTC` — the absolute half of every timestamp. */
98
+ function formatUtc(at: number): string {
99
+ return `${new Date(at).toISOString().slice(0, 16).replace("T", " ")} UTC`;
100
+ }
101
+
102
+ /** `in 9h 34m`, `2h 27m ago`, `3d 4h ago`, `just now`. */
103
+ function formatAgo(at: number, now: number = Date.now()): string {
104
+ const delta = at - now;
105
+ const minutes = Math.round(Math.abs(delta) / 60_000);
106
+ if (minutes === 0) return "just now";
107
+ const hours = Math.floor(minutes / 60);
108
+ const text =
109
+ hours >= 48
110
+ ? `${Math.floor(hours / 24)}d ${hours % 24}h`
111
+ : hours > 0
112
+ ? `${hours}h ${minutes % 60}m`
113
+ : `${minutes}m`;
114
+ return delta > 0 ? `in ${text}` : `${text} ago`;
115
+ }
116
+
117
+ function when(f: ReportFormatter, at: number, now: number): string {
118
+ return `${formatAgo(at, now)} ${f.italic(`(${formatUtc(at)})`)}`;
119
+ }
120
+
121
+ function plural(n: number, word: string): string {
122
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
123
+ }
124
+
125
+ // ── Status panel ────────────────────────────────────────────────────────────
126
+
127
+ export const PANEL_BUTTONS: PanelButton[][] = [
128
+ [
129
+ { label: "📸 Back up now", data: "backup:now" },
130
+ { label: "📋 Snapshots", data: "backup:list:0" },
131
+ ],
132
+ [
133
+ { label: "ℹ️ How restore works", data: "backup:guide" },
134
+ { label: "🔄 Refresh", data: "backup:panel" },
135
+ ],
136
+ ];
137
+
138
+ function scheduleLines(
139
+ f: ReportFormatter,
140
+ status: BackupStatus,
141
+ now: number,
142
+ ): string[] {
143
+ const { schedule, local } = status;
144
+ const lines: string[] = [];
145
+ if (!schedule.enabled) {
146
+ lines.push(`${f.bold("Schedule:")} off — manual snapshots only`);
147
+ } else {
148
+ lines.push(
149
+ `${f.bold("Schedule:")} every ${schedule.intervalHours}h` +
150
+ (schedule.nextRunAt
151
+ ? ` · next ${when(f, schedule.nextRunAt, now)}`
152
+ : ""),
153
+ );
154
+ }
155
+ const lastAt = schedule.lastRunAt ?? local.newest?.createdAt;
156
+ const lastId = schedule.lastSnapshotId ?? local.newest?.id;
157
+ lines.push(
158
+ lastAt
159
+ ? `${f.bold("Last snapshot:")} ${when(f, lastAt, now)}` +
160
+ (lastId ? ` · ${f.code(f.escape(lastId))}` : "")
161
+ : `${f.bold("Last snapshot:")} none yet`,
162
+ );
163
+ if (schedule.running) lines.push("⏳ A snapshot is running right now.");
164
+ if (schedule.consecutiveFailures > 0) {
165
+ lines.push(
166
+ `⚠️ ${f.bold("Failing:")} ${plural(schedule.consecutiveFailures, "run")} in a row — ` +
167
+ f.escape(schedule.lastError ?? "unknown error"),
168
+ );
169
+ }
170
+ return lines;
171
+ }
172
+
173
+ function storageLines(f: ReportFormatter, status: BackupStatus): string[] {
174
+ const { local, policy } = status;
175
+ const lines = [
176
+ `${f.bold("On this machine:")} ${plural(local.count, "snapshot")} · ` +
177
+ formatBytes(local.sizeBytes) +
178
+ (local.pinned > 0 ? ` · 📌 ${local.pinned} pinned` : ""),
179
+ ];
180
+ if (policy) {
181
+ lines.push(
182
+ `${f.bold("Retention:")} newest ${policy.keepLocal} kept here, ` +
183
+ `${policy.keepRemote} per target · pinned ones are never pruned`,
184
+ );
185
+ lines.push(
186
+ policy.encrypted
187
+ ? `${f.bold("Encryption:")} 🔒 on`
188
+ : `${f.bold("Encryption:")} 🔓 off — snapshots stay on this machine`,
189
+ );
190
+ }
191
+ return lines;
192
+ }
193
+
194
+ function targetLines(f: ReportFormatter, status: BackupStatus): string[] {
195
+ if (status.targets.length === 0) {
196
+ return [`${f.bold("Targets:")} none — local only`];
197
+ }
198
+ const lines = [f.bold("Targets:")];
199
+ for (const target of status.targets) {
200
+ const name = `${f.escape(target.name)} ${f.code(f.escape(target.id))}`;
201
+ const state = !target.ready
202
+ ? `⏸ not ready${target.detail ? ` — ${f.escape(target.detail)}` : ""}`
203
+ : target.error
204
+ ? `❌ unreachable — ${f.escape(target.error)}`
205
+ : `✅ ready · ${plural(target.snapshots ?? 0, "snapshot")}`;
206
+ lines.push(`• ${name} — ${state}`);
207
+ }
208
+ return lines;
209
+ }
210
+
211
+ /** The status panel body (no buttons). */
212
+ function renderBackupStatus(
213
+ f: ReportFormatter,
214
+ status: BackupStatus,
215
+ now: number = Date.now(),
216
+ ): string {
217
+ return [
218
+ `🗄 ${f.bold("Backups")}`,
219
+ "",
220
+ ...scheduleLines(f, status, now),
221
+ "",
222
+ ...storageLines(f, status),
223
+ "",
224
+ ...targetLines(f, status),
225
+ ].join("\n");
226
+ }
227
+
228
+ export function backupPanelView(
229
+ f: ReportFormatter,
230
+ status: BackupStatus,
231
+ now: number = Date.now(),
232
+ notice?: string,
233
+ ): PanelView {
234
+ const body = renderBackupStatus(f, status, now);
235
+ return {
236
+ text: notice ? `${notice}\n\n${body}` : body,
237
+ buttons: PANEL_BUTTONS,
238
+ };
239
+ }
240
+
241
+ // ── Snapshot pages ──────────────────────────────────────────────────────────
242
+
243
+ const REMOTE_ICON: Record<string, string> = {
244
+ uploaded: "✅",
245
+ pending: "⏳",
246
+ failed: "❌",
247
+ };
248
+
249
+ function snapshotBlock(
250
+ f: ReportFormatter,
251
+ snapshot: SnapshotSummary,
252
+ n: number,
253
+ now: number,
254
+ ): string {
255
+ const facts = [
256
+ snapshot.kind,
257
+ formatBytes(snapshot.sizeBytes),
258
+ ...(snapshot.pinned ? ["📌 pinned"] : []),
259
+ ...(snapshot.local ? [] : ["remote only"]),
260
+ ];
261
+ const lines = [
262
+ `${f.bold(`${n}.`)} ${f.code(snapshot.id)} — ${when(f, snapshot.createdAt, now)}`,
263
+ ` ${facts.join(" · ")}`,
264
+ ];
265
+ if (snapshot.label) lines.push(` “${f.escape(snapshot.label)}”`);
266
+ const remotes = Object.entries(snapshot.remote).map(
267
+ ([id, entry]) => `${f.escape(id)} ${REMOTE_ICON[entry.status] ?? "?"}`,
268
+ );
269
+ if (remotes.length > 0) lines.push(` ☁️ ${remotes.join(" · ")}`);
270
+ return lines.join("\n");
271
+ }
272
+
273
+ function snapshotButtons(
274
+ snapshot: SnapshotSummary,
275
+ n: number,
276
+ page: number,
277
+ ): PanelButton[] {
278
+ const row: PanelButton[] = [
279
+ snapshot.pinned
280
+ ? { label: `Unpin #${n}`, data: `backup:unpin:${page}:${snapshot.id}` }
281
+ : { label: `📌 Pin #${n}`, data: `backup:pin:${page}:${snapshot.id}` },
282
+ ];
283
+ // Chat restores read the local copy; a remote-only one is a CLI job.
284
+ if (snapshot.local) {
285
+ row.push({
286
+ label: `♻️ Restore #${n}`,
287
+ data: `backup:ask:${page}:${snapshot.id}`,
288
+ });
289
+ }
290
+ return row;
291
+ }
292
+
293
+ function navRow(page: number, pages: number): PanelButton[] {
294
+ const row: PanelButton[] = [];
295
+ if (page > 0) row.push({ label: "‹ Newer", data: `backup:list:${page - 1}` });
296
+ if (page < pages - 1) {
297
+ row.push({ label: "Older ›", data: `backup:list:${page + 1}` });
298
+ }
299
+ return row;
300
+ }
301
+
302
+ /** One page of snapshots, newest first. Out-of-range pages clamp. */
303
+ export function snapshotPageView(
304
+ f: ReportFormatter,
305
+ snapshots: readonly SnapshotSummary[],
306
+ requestedPage: number,
307
+ now: number = Date.now(),
308
+ pageSize: number = SNAPSHOT_PAGE_SIZE,
309
+ ): PanelView & { page: number; pages: number } {
310
+ const back: PanelButton[] = [{ label: "⬅️ Back", data: "backup:panel" }];
311
+ if (snapshots.length === 0) {
312
+ return {
313
+ text: `📋 ${f.bold("Snapshots")}\n\nNo snapshots yet — tap Back, then 📸 Back up now.`,
314
+ buttons: [back],
315
+ page: 0,
316
+ pages: 1,
317
+ };
318
+ }
319
+ const pages = Math.ceil(snapshots.length / pageSize);
320
+ const page = Math.min(Math.max(0, requestedPage), pages - 1);
321
+ const start = page * pageSize;
322
+ const items = snapshots.slice(start, start + pageSize);
323
+ const header =
324
+ `📋 ${f.bold("Snapshots")} · ${plural(snapshots.length, "snapshot")}` +
325
+ (pages > 1 ? ` · page ${page + 1}/${pages}` : "");
326
+ const blocks = items.map((snapshot, i) =>
327
+ snapshotBlock(f, snapshot, start + i + 1, now),
328
+ );
329
+ const buttons = items.map((snapshot, i) =>
330
+ snapshotButtons(snapshot, start + i + 1, page),
331
+ );
332
+ const nav = navRow(page, pages);
333
+ if (nav.length > 0) buttons.push(nav);
334
+ buttons.push(back);
335
+ return {
336
+ text: [header, "", blocks.join("\n\n")].join("\n"),
337
+ buttons,
338
+ page,
339
+ pages,
340
+ };
341
+ }
342
+
343
+ // ── Restore ─────────────────────────────────────────────────────────────────
344
+
345
+ /** The confirmation prompt — shared by `/backup restore <id>` and the panel. */
346
+ export function renderRestoreConfirm(
347
+ f: ReportFormatter,
348
+ manifest: RestoreSubject,
349
+ ): string {
350
+ return (
351
+ `♻️ ${f.bold(`Restore ${f.code(f.escape(manifest.id))}?`)}\n` +
352
+ (manifest.label ? `“${f.escape(manifest.label)}”\n` : "") +
353
+ `Taken ${new Date(manifest.createdAt).toISOString()}\n\n` +
354
+ "This replaces config, prompts, keys, sessions, the database and memory, " +
355
+ "then restarts. A pinned checkpoint of the current state is taken first."
356
+ );
357
+ }
358
+
359
+ /** The panel's confirmation: Cancel goes back to the page it came from. */
360
+ export function restoreConfirmView(
361
+ f: ReportFormatter,
362
+ manifest: RestoreSubject,
363
+ page: number,
364
+ ): PanelView {
365
+ return {
366
+ text: renderRestoreConfirm(f, manifest),
367
+ buttons: [
368
+ [
369
+ {
370
+ label: "♻️ Restore and restart",
371
+ data: `backup:restore:${manifest.id}`,
372
+ },
373
+ { label: "Cancel", data: `backup:list:${page}` },
374
+ ],
375
+ ],
376
+ };
377
+ }
378
+
379
+ /**
380
+ * How restore works. Every sentence here is something core/backup does —
381
+ * plan.ts (what is captured), snapshot.ts (the parts), restore.ts (verify,
382
+ * checkpoint, replace, the staged boot path) and the CLI's restore flags.
383
+ */
384
+ export function renderRestoreGuide(f: ReportFormatter): string {
385
+ const c = (s: string) => f.code(f.escape(s));
386
+ return [
387
+ `ℹ️ ${f.bold("How restore works")}`,
388
+ "",
389
+ f.bold("What a snapshot holds"),
390
+ `• ${f.bold("State")} — config.json, prompts, keys, plugins, mesh devices, the database ` +
391
+ `(a consistent ${c("VACUUM INTO")} copy) and the workspace files that make the agent ` +
392
+ "(by default identity, memory, skills, scripts, secrets, stickers).",
393
+ `• ${f.bold("Sessions")} — backend transcripts, session databases and traces ` +
394
+ `(unless ${c("backup.includeSessions")} is off).`,
395
+ `• ${f.bold("Palace")} — the memory palace in its own part; an unchanged palace is reused, not recompressed.`,
396
+ `• ${f.bold("Logins")} — WhatsApp pairing and the userbot session. They stay on this machine ` +
397
+ `unless ${c("backup.loginSessions")} is ${c("remote")}.`,
398
+ "Left out: logs, virtualenvs, the ns/ mount, other snapshots and the rest of the workspace " +
399
+ "(uploads, media, checkouts).",
400
+ "",
401
+ f.bold("What a restore does"),
402
+ "• Verifies the snapshot first — every part's checksum, and its signature when encrypted — before touching anything.",
403
+ `• Takes a pinned ${c("pre-restore <id>")} checkpoint of the current state. Restore that one to undo.`,
404
+ "• Puts everything the snapshot covers back exactly as it was — memory, sessions, the database. " +
405
+ "Whatever changed since the snapshot is replaced.",
406
+ `• From chat it is staged (${c("restore-pending.json")}) and Talon restarts; the next boot applies it ` +
407
+ "before the database opens and reports back. A staged request expires after 10 minutes, " +
408
+ "and a failed restore is dropped so Talon still boots (the reason is in the log).",
409
+ "• Chat restores need the snapshot's local copy.",
410
+ "",
411
+ f.bold("From a terminal"),
412
+ `${c("talon stop")}, then ${c("talon backup restore <id>")} (it asks you to type yes).`,
413
+ `${c("--from <target>")} fetches a snapshot this machine does not have; ` +
414
+ `${c("--clone")} restores onto a new machine, relocating session and plugin paths.`,
415
+ `Encrypted snapshots need their passphrase (${c("TALON_BACKUP_PASSPHRASE")} or ` +
416
+ `${c("backup.encryption.passphraseFile")}) — keep a copy off this machine.`,
417
+ ].join("\n");
418
+ }
419
+
420
+ export const GUIDE_BUTTONS: PanelButton[][] = [
421
+ [
422
+ { label: "📋 Snapshots", data: "backup:list:0" },
423
+ { label: "⬅️ Back", data: "backup:panel" },
424
+ ],
425
+ ];
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `/memory` — a read-only window on the typed memory store, rendered in
3
+ * any frontend's markup.
4
+ *
5
+ * Four shapes, all reads: the ranked listing, a full-text search, one
6
+ * row's provenance (`why <id>`) and a per-kind listing. Nothing here
7
+ * writes: asserting, superseding and dropping are the write path's job,
8
+ * so the operator can always ask what Talon remembers without the answer
9
+ * being able to change it.
10
+ *
11
+ * Every line is model- or user-authored text, so it goes through
12
+ * `fmt.escape` before it is joined — for Telegram's HTML parse mode that
13
+ * is what keeps a `<` in a memory from failing the whole send. Who may
14
+ * read memory at all is the calling frontend's decision.
15
+ */
16
+
17
+ import {
18
+ formatMemory,
19
+ getMemory,
20
+ isMemoryKind,
21
+ listMemories,
22
+ memoryHistory,
23
+ searchMemories,
24
+ MEMORY_KINDS,
25
+ type MemoryRow,
26
+ } from "../../storage/memory.js";
27
+ import type { ReportFormatter } from "./reports.js";
28
+
29
+ /** Rows per reply — a chat listing is a glance, not an export. */
30
+ const LIST_LIMIT = 15;
31
+
32
+ type MemoryFormatter = Pick<ReportFormatter, "bold" | "escape">;
33
+
34
+ /** Route the argument to one of the four reads. Returns ready markup. */
35
+ export function renderMemoryReport(arg: string, fmt: MemoryFormatter): string {
36
+ const why = /^why\b\s*(.*)$/is.exec(arg);
37
+ if (why) return renderWhy(why[1]!.trim(), fmt);
38
+ const kind = /^kind\b\s*(.*)$/is.exec(arg);
39
+ if (kind) return renderKind(kind[1]!.trim(), fmt);
40
+ if (!arg)
41
+ return renderRows(
42
+ listMemories({ limit: LIST_LIMIT }),
43
+ "Nothing remembered yet.",
44
+ fmt,
45
+ );
46
+ return renderRows(
47
+ searchMemories(arg, { limit: LIST_LIMIT }),
48
+ `No memories matching "${arg}".`,
49
+ fmt,
50
+ );
51
+ }
52
+
53
+ /** One escaped line per row, or the (escaped) empty-case sentence. */
54
+ function renderRows(
55
+ rows: MemoryRow[],
56
+ empty: string,
57
+ fmt: MemoryFormatter,
58
+ ): string {
59
+ if (rows.length === 0) return fmt.escape(empty);
60
+ return rows.map((row) => fmt.escape(formatMemory(row))).join("\n");
61
+ }
62
+
63
+ function renderKind(kind: string, fmt: MemoryFormatter): string {
64
+ if (!isMemoryKind(kind))
65
+ return fmt.escape(
66
+ `No such kind "${kind}". Valid kinds: ${MEMORY_KINDS.join(", ")}.`,
67
+ );
68
+ return renderRows(
69
+ listMemories({ kind, limit: LIST_LIMIT }),
70
+ `Nothing remembered under ${kind}.`,
71
+ fmt,
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Provenance for one row: the row itself, the numbers that decide where
77
+ * it ranks, and its audit trail. Reads by id rather than by the live
78
+ * listing, so a superseded or dropped row still explains itself.
79
+ */
80
+ function renderWhy(raw: string, fmt: MemoryFormatter): string {
81
+ const id = Number(raw);
82
+ if (!raw || !Number.isInteger(id))
83
+ return fmt.escape(`No memory with id ${raw || "(none given)"}.`);
84
+ const row = getMemory(id);
85
+ if (!row) return fmt.escape(`No memory with id ${id}.`);
86
+ const lines = [
87
+ fmt.escape(formatMemory(row)),
88
+ "",
89
+ fmt.escape(
90
+ `trust ${row.trust} · confidence ${row.confidence} · hits ${row.hitCount} · salience ${row.salience}`,
91
+ ),
92
+ fmt.escape(
93
+ `created ${isoTime(row.createdAt)} · last seen ${isoTime(row.lastSeenAt)}`,
94
+ ),
95
+ ];
96
+ const history = memoryHistory(row.id);
97
+ if (history.length > 0) {
98
+ lines.push("", fmt.bold("History"));
99
+ for (const entry of history) {
100
+ const reason = entry.reason ? ` — ${entry.reason}` : "";
101
+ lines.push(fmt.escape(`${isoTime(entry.at)} ${entry.op}${reason}`));
102
+ }
103
+ }
104
+ return lines.join("\n");
105
+ }
106
+
107
+ function isoTime(ms: number): string {
108
+ return new Date(ms).toISOString();
109
+ }