talon-agent 1.0.0 → 1.2.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
@@ -110,7 +110,8 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
110
110
  mediaType: "animation",
111
111
  });
112
112
  } else if ("audio" in ctx.message && ctx.message.audio) {
113
- const title = ctx.message.audio.title || ctx.message.audio.file_name || "audio";
113
+ const title =
114
+ ctx.message.audio.title || ctx.message.audio.file_name || "audio";
114
115
  pushMessage(chatId, {
115
116
  msgId,
116
117
  senderId,
@@ -140,7 +141,12 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
140
141
  timestamp,
141
142
  });
142
143
  } else if ("contact" in ctx.message && ctx.message.contact) {
143
- const name = [ctx.message.contact.first_name, ctx.message.contact.last_name].filter(Boolean).join(" ");
144
+ const name = [
145
+ ctx.message.contact.first_name,
146
+ ctx.message.contact.last_name,
147
+ ]
148
+ .filter(Boolean)
149
+ .join(" ");
144
150
  pushMessage(chatId, {
145
151
  msgId,
146
152
  senderId,
@@ -171,7 +177,11 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
171
177
  bot.on("message:voice", (ctx) => handleVoiceMessage(ctx, bot, config));
172
178
  bot.on("message:sticker", (ctx) => handleStickerMessage(ctx, bot, config));
173
179
  bot.on("message:video", (ctx) => handleVideoMessage(ctx, bot, config));
174
- bot.on("message:animation", (ctx) => handleAnimationMessage(ctx, bot, config));
180
+ bot.on("message:animation", (ctx) =>
181
+ handleAnimationMessage(ctx, bot, config),
182
+ );
175
183
  bot.on("message:audio", (ctx) => handleAudioMessage(ctx, bot, config));
176
- bot.on("message:video_note", (ctx) => handleVideoNoteMessage(ctx, bot, config));
184
+ bot.on("message:video_note", (ctx) =>
185
+ handleVideoNoteMessage(ctx, bot, config),
186
+ );
177
187
  }
@@ -418,7 +418,9 @@ export async function downloadMessageMedia(params: {
418
418
  if (!buffer || buffer.length === 0) return "Download returned empty data.";
419
419
 
420
420
  // Use the original filename if available, otherwise generate one
421
- const doc = (m.media as { document?: { attributes?: Array<{ fileName?: string }> } }).document;
421
+ const doc = (
422
+ m.media as { document?: { attributes?: Array<{ fileName?: string }> } }
423
+ ).document;
422
424
  const originalName = doc?.attributes?.find((a) => a.fileName)?.fileName;
423
425
  const filename = originalName
424
426
  ? `${Date.now()}-${originalName.replace(/[^a-zA-Z0-9._-]/g, "_")}`
@@ -431,7 +433,10 @@ export async function downloadMessageMedia(params: {
431
433
  const filePath = resolve(uploadsDir, filename);
432
434
  writeFileSync(filePath, buffer);
433
435
 
434
- log("userbot", `Downloaded media from msg:${params.messageId} → ${filename} (${buffer.length} bytes)`);
436
+ log(
437
+ "userbot",
438
+ `Downloaded media from msg:${params.messageId} → ${filename} (${buffer.length} bytes)`,
439
+ );
435
440
  return `Downloaded to: ${filePath} (${buffer.length} bytes). Use the Read tool on this path to view the content.`;
436
441
  } catch (err) {
437
442
  return `Download failed: ${err instanceof Error ? err.message : err}`;
@@ -446,7 +451,15 @@ export async function saveStickerPack(params: {
446
451
  bot: unknown;
447
452
  }): Promise<string> {
448
453
  try {
449
- const bot = params.bot as { api: { getStickerSet: (name: string) => Promise<{ title: string; name: string; stickers: Array<{ emoji?: string; file_id: string }> }> } };
454
+ const bot = params.bot as {
455
+ api: {
456
+ getStickerSet: (name: string) => Promise<{
457
+ title: string;
458
+ name: string;
459
+ stickers: Array<{ emoji?: string; file_id: string }>;
460
+ }>;
461
+ };
462
+ };
450
463
  const stickerSet = await bot.api.getStickerSet(params.setName);
451
464
 
452
465
  const stickers = stickerSet.stickers.map((s) => ({
@@ -543,4 +556,3 @@ export async function getOnlineCount(params: {
543
556
  return `Failed: ${err instanceof Error ? err.message : err}`;
544
557
  }
545
558
  }
546
-
@@ -184,10 +184,7 @@ export function createRenderer(cols?: number, displayName = "Talon"): Renderer {
184
184
  tools: number,
185
185
  info: StatusBarInfo,
186
186
  ): void {
187
- const p = [
188
- `${(ms / 1000).toFixed(1)}s`,
189
- info.model,
190
- ];
187
+ const p = [`${(ms / 1000).toFixed(1)}s`, info.model];
191
188
  if (info.sessionName) p.push(`"${info.sessionName}"`);
192
189
  p.push(
193
190
  `${info.turns} turn${info.turns !== 1 ? "s" : ""}`,
package/src/index.ts CHANGED
@@ -15,6 +15,11 @@ import { flushHistory } from "./storage/history.js";
15
15
  import { flushMediaIndex } from "./storage/media-index.js";
16
16
  import { getActiveCount } from "./core/dispatcher.js";
17
17
  import { startPulseTimer, stopPulseTimer } from "./core/pulse.js";
18
+ import {
19
+ startHeartbeatTimer,
20
+ stopHeartbeatTimer,
21
+ awaitCurrentRun as awaitHeartbeat,
22
+ } from "./core/heartbeat.js";
18
23
  import { startCronTimer, stopCronTimer } from "./core/cron.js";
19
24
  import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
20
25
  import { log, logError, logWarn } from "./util/log.js";
@@ -30,7 +35,11 @@ import { files as pathFiles } from "./util/paths.js";
30
35
  const { config } = await bootstrap();
31
36
 
32
37
  // Write PID file for daemon management
33
- try { writeFileSync(pathFiles.pid, String(process.pid)); } catch { /* ok */ }
38
+ try {
39
+ writeFileSync(pathFiles.pid, String(process.pid));
40
+ } catch {
41
+ /* ok */
42
+ }
34
43
 
35
44
  // ── Create gateway + frontend ─────────────────────────────────────────────────
36
45
 
@@ -40,7 +49,8 @@ const selectedFrontend = getFrontends(config)[0]; // use first configured fronte
40
49
  let frontend: Frontend;
41
50
 
42
51
  if (selectedFrontend === "terminal") {
43
- const { createTerminalFrontend } = await import("./frontend/terminal/index.js");
52
+ const { createTerminalFrontend } =
53
+ await import("./frontend/terminal/index.js");
44
54
  frontend = createTerminalFrontend(config, gateway);
45
55
  log("bot", "Frontend: Terminal");
46
56
  } else if (selectedFrontend === "teams") {
@@ -48,7 +58,8 @@ if (selectedFrontend === "terminal") {
48
58
  frontend = createTeamsFrontend(config, gateway);
49
59
  log("bot", "Frontend: Teams");
50
60
  } else {
51
- const { createTelegramFrontend } = await import("./frontend/telegram/index.js");
61
+ const { createTelegramFrontend } =
62
+ await import("./frontend/telegram/index.js");
52
63
  frontend = createTelegramFrontend(config, gateway);
53
64
  log("bot", "Frontend: Telegram");
54
65
  }
@@ -91,6 +102,8 @@ async function gracefulShutdown(signal: string): Promise<void> {
91
102
  await destroyPlugins();
92
103
  }
93
104
  stopPulseTimer();
105
+ stopHeartbeatTimer();
106
+ await awaitHeartbeat();
94
107
  stopCronTimer();
95
108
  stopWatchdog();
96
109
  stopUploadCleanup();
@@ -99,7 +112,11 @@ async function gracefulShutdown(signal: string): Promise<void> {
99
112
  flushCronJobs();
100
113
  flushHistory();
101
114
  flushMediaIndex();
102
- try { unlinkSync(pathFiles.pid); } catch { /* ok */ }
115
+ try {
116
+ unlinkSync(pathFiles.pid);
117
+ } catch {
118
+ /* ok */
119
+ }
103
120
  log("shutdown", "State saved");
104
121
  process.exit(0);
105
122
  }
@@ -108,6 +125,12 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
108
125
  process.on("SIGINT", () => gracefulShutdown("SIGINT"));
109
126
 
110
127
  process.on("uncaughtException", (err) => {
128
+ // EPIPE errors from network sockets (e.g. Telegram MTProto) are transient —
129
+ // gramjs will reconnect; crashing the process here is wrong.
130
+ if ((err as NodeJS.ErrnoException).code === "EPIPE") {
131
+ logWarn("bot", `Suppressed transient EPIPE error: ${err.message}`);
132
+ return;
133
+ }
111
134
  logError("bot", "Uncaught exception", err);
112
135
  flushSessions();
113
136
  flushChatSettings();
@@ -131,6 +154,7 @@ async function main(): Promise<void> {
131
154
  log("bot", "Starting Talon...");
132
155
 
133
156
  if (config.pulse) startPulseTimer(config.pulseIntervalMs);
157
+ if (config.heartbeat) startHeartbeatTimer(config.heartbeatIntervalMinutes);
134
158
  startCronTimer();
135
159
  startWatchdog(config.workspace);
136
160
  startUploadCleanup(config.workspace);
@@ -6,8 +6,10 @@
6
6
  import { existsSync, readFileSync, mkdirSync } from "node:fs";
7
7
  import writeFileAtomic from "write-file-atomic";
8
8
  import { dirname } from "node:path";
9
- import { log } from "../util/log.js";
9
+ import { log, logError } from "../util/log.js";
10
+ import { recordError } from "../util/watchdog.js";
10
11
  import { files } from "../util/paths.js";
12
+ import { registerCleanup } from "../util/cleanup-registry.js";
11
13
 
12
14
  export type EffortLevel = "off" | "low" | "medium" | "high" | "max";
13
15
 
@@ -41,7 +43,9 @@ export function loadChatSettings(): void {
41
43
  store = JSON.parse(readFileSync(bakFile, "utf-8"));
42
44
  log("settings", "Loaded from backup (primary was corrupt)");
43
45
  }
44
- } catch { /* backup also corrupt */ }
46
+ } catch {
47
+ /* backup also corrupt */
48
+ }
45
49
  }
46
50
  // Migrate legacy maxThinkingTokens → effort
47
51
  let migrated = 0;
@@ -85,17 +89,24 @@ function save(): void {
85
89
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
86
90
  const data = JSON.stringify(store, null, 2) + "\n";
87
91
  if (existsSync(STORE_FILE)) {
88
- try { writeFileAtomic.sync(STORE_FILE + ".bak", readFileSync(STORE_FILE)); } catch { /* best effort */ }
92
+ try {
93
+ writeFileAtomic.sync(STORE_FILE + ".bak", readFileSync(STORE_FILE));
94
+ } catch {
95
+ /* best effort */
96
+ }
89
97
  }
90
98
  writeFileAtomic.sync(STORE_FILE, data);
91
99
  dirty = false;
92
- } catch {
93
- // Non-fatal
100
+ } catch (err) {
101
+ logError("settings", "Failed to persist chat settings", err);
102
+ recordError(
103
+ `Settings save failed: ${err instanceof Error ? err.message : err}`,
104
+ );
94
105
  }
95
106
  }
96
107
 
97
108
  const autoSaveTimer = setInterval(save, 10_000);
98
- process.on("exit", save);
109
+ registerCleanup(save);
99
110
 
100
111
  /** Flush settings to disk and stop the auto-save timer. */
101
112
  export function flushChatSettings(): void {
@@ -109,12 +120,22 @@ export function getChatSettings(chatId: string): ChatSettings {
109
120
 
110
121
  function cleanupEmpty(chatId: string): void {
111
122
  const s = store[chatId];
112
- if (s && !s.model && !s.effort && s.pulse === undefined && s.pulseIntervalMs === undefined && s.pulseLastCheckMsgId === undefined) {
123
+ if (
124
+ s &&
125
+ !s.model &&
126
+ !s.effort &&
127
+ s.pulse === undefined &&
128
+ s.pulseIntervalMs === undefined &&
129
+ s.pulseLastCheckMsgId === undefined
130
+ ) {
113
131
  delete store[chatId];
114
132
  }
115
133
  }
116
134
 
117
- export function setPulseLastCheckMsgId(chatId: string, msgId: number | undefined): void {
135
+ export function setPulseLastCheckMsgId(
136
+ chatId: string,
137
+ msgId: number | undefined,
138
+ ): void {
118
139
  if (!store[chatId]) store[chatId] = {};
119
140
  if (msgId !== undefined) {
120
141
  store[chatId].pulseLastCheckMsgId = msgId;
@@ -214,5 +235,7 @@ export const MODEL_ALIASES: Record<string, string> = {
214
235
 
215
236
  export function resolveModelName(input: string): string {
216
237
  const lower = input.trim().toLowerCase();
217
- return Object.hasOwn(MODEL_ALIASES, lower) ? MODEL_ALIASES[lower] : input.trim();
238
+ return Object.hasOwn(MODEL_ALIASES, lower)
239
+ ? MODEL_ALIASES[lower]
240
+ : input.trim();
218
241
  }
@@ -4,11 +4,14 @@
4
4
  */
5
5
 
6
6
  import { existsSync, readFileSync, mkdirSync } from "node:fs";
7
+ import { randomUUID } from "node:crypto";
7
8
  import writeFileAtomic from "write-file-atomic";
8
9
  import { dirname } from "node:path";
9
10
  import { Cron } from "croner";
10
- import { log } from "../util/log.js";
11
+ import { log, logError } from "../util/log.js";
12
+ import { recordError } from "../util/watchdog.js";
11
13
  import { files } from "../util/paths.js";
14
+ import { registerCleanup } from "../util/cleanup-registry.js";
12
15
 
13
16
  export type CronJobType = "message" | "query";
14
17
 
@@ -52,14 +55,45 @@ export function loadCronJobs(): void {
52
55
  try {
53
56
  if (existsSync(bakFile)) {
54
57
  const raw = JSON.parse(readFileSync(bakFile, "utf-8"));
55
- store = Array.isArray(raw) ? Object.fromEntries(raw.map((j: CronJob) => [j.id, j])) : raw;
58
+ store = Array.isArray(raw)
59
+ ? Object.fromEntries(raw.map((j: CronJob) => [j.id, j]))
60
+ : raw;
56
61
  log("cron", "Loaded from backup (primary was corrupt)");
57
62
  }
58
- } catch { /* backup also corrupt */ }
63
+ } catch {
64
+ /* backup also corrupt */
65
+ }
66
+ }
67
+ // Validate and strip invalid IANA timezone strings so Cron() doesn't throw at runtime
68
+ let invalidTz = 0;
69
+ for (const job of Object.values(store)) {
70
+ if (job.timezone && !isValidTimezone(job.timezone)) {
71
+ log(
72
+ "cron",
73
+ `Job "${job.name}" has invalid timezone "${job.timezone}" — clearing`,
74
+ );
75
+ job.timezone = undefined;
76
+ dirty = true;
77
+ invalidTz++;
78
+ }
59
79
  }
80
+
60
81
  const count = Object.keys(store).length;
61
82
  if (count > 0) {
62
- log("cron", `Loaded ${count} cron job(s)`);
83
+ log(
84
+ "cron",
85
+ `Loaded ${count} cron job(s)${invalidTz > 0 ? ` (cleared ${invalidTz} invalid timezone(s))` : ""}`,
86
+ );
87
+ }
88
+ }
89
+
90
+ /** Check if an IANA timezone string is valid using the Intl API. */
91
+ export function isValidTimezone(tz: string): boolean {
92
+ try {
93
+ Intl.DateTimeFormat(undefined, { timeZone: tz });
94
+ return true;
95
+ } catch {
96
+ return false;
63
97
  }
64
98
  }
65
99
 
@@ -70,17 +104,24 @@ function save(): void {
70
104
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
71
105
  const data = JSON.stringify(store, null, 2) + "\n";
72
106
  if (existsSync(STORE_FILE)) {
73
- try { writeFileAtomic.sync(STORE_FILE + ".bak", readFileSync(STORE_FILE)); } catch { /* best effort */ }
107
+ try {
108
+ writeFileAtomic.sync(STORE_FILE + ".bak", readFileSync(STORE_FILE));
109
+ } catch {
110
+ /* best effort */
111
+ }
74
112
  }
75
113
  writeFileAtomic.sync(STORE_FILE, data);
76
114
  dirty = false;
77
- } catch {
78
- // Non-fatal
115
+ } catch (err) {
116
+ logError("cron", "Failed to persist cron jobs", err);
117
+ recordError(
118
+ `Cron save failed: ${err instanceof Error ? err.message : err}`,
119
+ );
79
120
  }
80
121
  }
81
122
 
82
123
  const autoSaveTimer = setInterval(save, 10_000);
83
- process.on("exit", save);
124
+ registerCleanup(save);
84
125
 
85
126
  /** Flush cron jobs to disk and stop the auto-save timer. */
86
127
  export function flushCronJobs(): void {
@@ -90,8 +131,9 @@ export function flushCronJobs(): void {
90
131
 
91
132
  // ── ID generation ───────────────────────────────────────────────────────────
92
133
 
134
+ /** Generate a collision-free cron job ID using the platform's CSPRNG. */
93
135
  export function generateCronId(): string {
94
- return `cron_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
136
+ return `cron_${randomUUID()}`;
95
137
  }
96
138
 
97
139
  // ── Validation ──────────────────────────────────────────────────────────────
@@ -105,12 +147,12 @@ export function validateCronExpression(
105
147
  const nextDate = cron.nextRun();
106
148
  return {
107
149
  valid: true,
108
- next: nextDate ? nextDate.toISOString() : undefined,
150
+ next: (nextDate as Date).toISOString(),
109
151
  };
110
152
  } catch (err) {
111
153
  return {
112
154
  valid: false,
113
- error: err instanceof Error ? err.message : String(err),
155
+ error: (err as Error).message,
114
156
  };
115
157
  }
116
158
  }
@@ -3,10 +3,17 @@
3
3
  * Claude can reference these via the Read tool for continuity across sessions.
4
4
  */
5
5
 
6
- import { existsSync, mkdirSync, appendFileSync, readdirSync, unlinkSync } from "node:fs";
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ appendFileSync,
10
+ readdirSync,
11
+ unlinkSync,
12
+ } from "node:fs";
7
13
  import { resolve } from "node:path";
8
14
  import { log as logInfo, logError } from "../util/log.js";
9
15
  import { dirs } from "../util/paths.js";
16
+ import { toYMD } from "../util/time.js";
10
17
 
11
18
  const LOGS_DIR = dirs.logs;
12
19
  const MAX_LOG_DAYS = 30; // Keep last 30 days of logs
@@ -24,7 +31,11 @@ function ensureLogsDir(): void {
24
31
  * @param text - Message content
25
32
  * @param chatContext - Optional chat context (group title, username, etc.)
26
33
  */
27
- export function appendDailyLog(chatName: string, text: string, chatContext?: { chatTitle?: string; username?: string }): void {
34
+ export function appendDailyLog(
35
+ chatName: string,
36
+ text: string,
37
+ chatContext?: { chatTitle?: string; username?: string },
38
+ ): void {
28
39
  try {
29
40
  ensureLogsDir();
30
41
  const now = new Date();
@@ -44,7 +55,11 @@ export function appendDailyLog(chatName: string, text: string, chatContext?: { c
44
55
  * Append a bot response entry to today's daily log.
45
56
  * Format: ## HH:MM -- [botName] in chatTitle\nresponse text\n
46
57
  */
47
- export function appendDailyLogResponse(botName: string, text: string, chatContext?: { chatTitle?: string }): void {
58
+ export function appendDailyLogResponse(
59
+ botName: string,
60
+ text: string,
61
+ chatContext?: { chatTitle?: string },
62
+ ): void {
48
63
  try {
49
64
  ensureLogsDir();
50
65
  const now = new Date();
@@ -52,7 +67,9 @@ export function appendDailyLogResponse(botName: string, text: string, chatContex
52
67
  const timeStr = now.toTimeString().slice(0, 5); // HH:MM
53
68
  const logFile = resolve(LOGS_DIR, `${dateStr}.md`);
54
69
 
55
- const label = chatContext?.chatTitle ? `${botName} in ${chatContext.chatTitle}` : botName;
70
+ const label = chatContext?.chatTitle
71
+ ? `${botName} in ${chatContext.chatTitle}`
72
+ : botName;
56
73
  const entry = `## ${timeStr} -- [${label}]\n${text}\n\n`;
57
74
  appendFileSync(logFile, entry);
58
75
  } catch (err) {
@@ -61,7 +78,10 @@ export function appendDailyLogResponse(botName: string, text: string, chatContex
61
78
  }
62
79
 
63
80
  /** Format a log label with optional chat title and username. */
64
- function formatLabel(name: string, ctx?: { chatTitle?: string; username?: string }): string {
81
+ function formatLabel(
82
+ name: string,
83
+ ctx?: { chatTitle?: string; username?: string },
84
+ ): string {
65
85
  const userPart = ctx?.username ? `${name} (@${ctx.username})` : name;
66
86
  if (ctx?.chatTitle) return `${userPart} in ${ctx.chatTitle}`;
67
87
  return userPart;
@@ -72,26 +92,59 @@ export function getLogsDir(): string {
72
92
  return LOGS_DIR;
73
93
  }
74
94
 
95
+ /** Matches YYYY-MM-DD.md filenames strictly. */
96
+ const DAILY_FILE_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
97
+
75
98
  /** Remove daily logs older than MAX_LOG_DAYS. Called on startup. */
76
99
  export function cleanupOldLogs(): void {
77
100
  try {
78
- if (!existsSync(LOGS_DIR)) return;
79
- const cutoff = new Date();
80
- cutoff.setDate(cutoff.getDate() - MAX_LOG_DAYS);
81
- const cutoffStr = cutoff.toISOString().slice(0, 10);
101
+ if (existsSync(LOGS_DIR)) {
102
+ const cutoff = new Date();
103
+ cutoff.setDate(cutoff.getDate() - MAX_LOG_DAYS);
104
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
82
105
 
83
- let deleted = 0;
84
- for (const file of readdirSync(LOGS_DIR)) {
85
- // Log files are named YYYY-MM-DD.md
86
- if (file.endsWith(".md") && file < cutoffStr) {
106
+ let deleted = 0;
107
+ for (const file of readdirSync(LOGS_DIR)) {
108
+ if (DAILY_FILE_RE.test(file) && file < cutoffStr) {
109
+ try {
110
+ unlinkSync(resolve(LOGS_DIR, file));
111
+ deleted++;
112
+ } catch {
113
+ /* skip */
114
+ }
115
+ }
116
+ }
117
+ if (deleted > 0) {
118
+ logInfo("workspace", `Cleaned up ${deleted} old daily log(s)`);
119
+ }
120
+ }
121
+ } catch {
122
+ /* skip */
123
+ }
124
+
125
+ // Clean up old daily memory files (independent of logs dir)
126
+ try {
127
+ const dailyMemDir = dirs.dailyMemory;
128
+ if (!existsSync(dailyMemDir)) return;
129
+ const cutoffDate = new Date();
130
+ cutoffDate.setDate(cutoffDate.getDate() - MAX_LOG_DAYS);
131
+ const cutoffMem = toYMD(cutoffDate);
132
+
133
+ let deletedMem = 0;
134
+ for (const file of readdirSync(dailyMemDir)) {
135
+ if (DAILY_FILE_RE.test(file) && file < cutoffMem) {
87
136
  try {
88
- unlinkSync(resolve(LOGS_DIR, file));
89
- deleted++;
90
- } catch { /* skip */ }
137
+ unlinkSync(resolve(dailyMemDir, file));
138
+ deletedMem++;
139
+ } catch {
140
+ /* skip */
141
+ }
91
142
  }
92
143
  }
93
- if (deleted > 0) {
94
- logInfo("workspace", `Cleaned up ${deleted} old daily log(s)`);
144
+ if (deletedMem > 0) {
145
+ logInfo("workspace", `Cleaned up ${deletedMem} old daily memory file(s)`);
95
146
  }
96
- } catch { /* skip */ }
147
+ } catch {
148
+ /* skip */
149
+ }
97
150
  }
@@ -11,8 +11,10 @@ import { existsSync, readFileSync, mkdirSync } from "node:fs";
11
11
  import { dirname } from "node:path";
12
12
  import writeFileAtomic from "write-file-atomic";
13
13
  import { log, logError } from "../util/log.js";
14
+ import { recordError } from "../util/watchdog.js";
14
15
  import { files } from "../util/paths.js";
15
- import { formatSmartTimestamp } from "../util/time.js";
16
+ import { formatSmartTimestamp, formatRelativeAge } from "../util/time.js";
17
+ import { registerCleanup } from "../util/cleanup-registry.js";
16
18
 
17
19
  export type HistoryMessage = {
18
20
  msgId: number;
@@ -61,15 +63,26 @@ export function loadHistory(): void {
61
63
  const bakFile = STORE_FILE + ".bak";
62
64
  try {
63
65
  if (existsSync(bakFile)) {
64
- const raw = JSON.parse(readFileSync(bakFile, "utf-8")) as Record<string, HistoryMessage[]>;
66
+ const raw = JSON.parse(readFileSync(bakFile, "utf-8")) as Record<
67
+ string,
68
+ HistoryMessage[]
69
+ >;
65
70
  for (const [chatId, messages] of Object.entries(raw)) {
66
71
  chatHistories.set(chatId, messages.slice(-MAX_HISTORY_PER_CHAT));
67
72
  }
68
- logError("sessions", "Loaded history from backup (primary was corrupt)");
73
+ logError(
74
+ "sessions",
75
+ "Loaded history from backup (primary was corrupt)",
76
+ );
69
77
  return;
70
78
  }
71
- } catch { /* backup also corrupt */ }
72
- logError("sessions", "History data corrupt and no valid backup — starting fresh");
79
+ } catch {
80
+ /* backup also corrupt */
81
+ }
82
+ logError(
83
+ "sessions",
84
+ "History data corrupt and no valid backup — starting fresh",
85
+ );
73
86
  }
74
87
  }
75
88
 
@@ -85,18 +98,25 @@ function saveHistory(): void {
85
98
  const data = JSON.stringify(obj) + "\n";
86
99
  // Write backup of current file before overwriting
87
100
  if (existsSync(STORE_FILE)) {
88
- try { writeFileAtomic.sync(STORE_FILE + ".bak", readFileSync(STORE_FILE)); } catch { /* best effort */ }
101
+ try {
102
+ writeFileAtomic.sync(STORE_FILE + ".bak", readFileSync(STORE_FILE));
103
+ } catch {
104
+ /* best effort */
105
+ }
89
106
  }
90
107
  writeFileAtomic.sync(STORE_FILE, data);
91
108
  dirty = false;
92
109
  } catch (err) {
93
- logError("sessions", "Failed to persist history", err);
110
+ logError("history", "Failed to persist history", err);
111
+ recordError(
112
+ `History save failed: ${err instanceof Error ? err.message : err}`,
113
+ );
94
114
  }
95
115
  }
96
116
 
97
117
  // Auto-save every 30 seconds (less frequent than sessions since history is larger)
98
118
  const autoSaveTimer = setInterval(saveHistory, 30_000);
99
- process.on("exit", saveHistory);
119
+ registerCleanup(saveHistory);
100
120
 
101
121
  export function flushHistory(): void {
102
122
  clearInterval(autoSaveTimer);
@@ -114,8 +134,7 @@ export function pushMessage(chatId: string, msg: HistoryMessage): void {
114
134
  const iter = chatHistories.keys();
115
135
  for (let i = 0; i < evictCount; i++) {
116
136
  const oldest = iter.next();
117
- if (oldest.done) break;
118
- chatHistories.delete(oldest.value);
137
+ chatHistories.delete(oldest.value as string);
119
138
  }
120
139
  // Mark dirty so evicted chats are removed from disk on next save
121
140
  dirty = true;
@@ -137,11 +156,18 @@ export function getRecentHistory(chatId: string, limit = 50): HistoryMessage[] {
137
156
  }
138
157
 
139
158
  /** Update a message's file path after media download. */
140
- export function setMessageFilePath(chatId: string, msgId: number, filePath: string): void {
159
+ export function setMessageFilePath(
160
+ chatId: string,
161
+ msgId: number,
162
+ filePath: string,
163
+ ): void {
141
164
  const history = chatHistories.get(chatId);
142
165
  if (!history) return;
143
166
  const msg = history.find((m) => m.msgId === msgId);
144
- if (msg) { msg.filePath = filePath; dirty = true; }
167
+ if (msg) {
168
+ msg.filePath = filePath;
169
+ dirty = true;
170
+ }
145
171
  }
146
172
 
147
173
  export function clearHistory(chatId: string): void {
@@ -230,20 +256,12 @@ export function getKnownUsers(chatId: string): string {
230
256
  const lines = [...users.entries()]
231
257
  .sort((a, b) => b[1].lastSeen - a[1].lastSeen)
232
258
  .map(([id, u]) => {
233
- const ago = formatTimeAgo(u.lastSeen);
259
+ const ago = formatRelativeAge(u.lastSeen);
234
260
  return `${u.name} (user_id: ${id}) — ${u.messageCount} msgs, last seen ${ago}`;
235
261
  });
236
262
  return lines.join("\n");
237
263
  }
238
264
 
239
- function formatTimeAgo(ts: number): string {
240
- const diff = Date.now() - ts;
241
- if (diff < 60_000) return "just now";
242
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
243
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
244
- return `${Math.floor(diff / 86_400_000)}d ago`;
245
- }
246
-
247
265
  export function getRecentBySenderId(
248
266
  chatId: string,
249
267
  senderId: number,