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.
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +15 -11
- package/prompts/dream.md +7 -3
- package/prompts/heartbeat.md +30 -0
- package/prompts/identity.md +1 -0
- package/prompts/teams.md +3 -0
- package/prompts/telegram.md +1 -0
- package/src/__tests__/chat-settings.test.ts +108 -2
- package/src/__tests__/cleanup-registry.test.ts +58 -0
- package/src/__tests__/config.test.ts +118 -52
- package/src/__tests__/cron-store-extended.test.ts +661 -0
- package/src/__tests__/cron-store.test.ts +145 -11
- package/src/__tests__/daily-log.test.ts +224 -13
- package/src/__tests__/dispatcher.test.ts +424 -23
- package/src/__tests__/dream.test.ts +1028 -0
- package/src/__tests__/errors-extended.test.ts +428 -0
- package/src/__tests__/errors.test.ts +95 -3
- package/src/__tests__/fuzz.test.ts +87 -15
- package/src/__tests__/gateway-actions.test.ts +1174 -433
- package/src/__tests__/gateway-http.test.ts +210 -19
- package/src/__tests__/gateway-retry.test.ts +359 -0
- package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
- package/src/__tests__/graph.test.ts +830 -0
- package/src/__tests__/handlers-stream.test.ts +208 -0
- package/src/__tests__/handlers.test.ts +2539 -70
- package/src/__tests__/heartbeat.test.ts +364 -0
- package/src/__tests__/history-extended.test.ts +775 -0
- package/src/__tests__/history-persistence.test.ts +74 -19
- package/src/__tests__/history.test.ts +113 -79
- package/src/__tests__/integration.test.ts +43 -8
- package/src/__tests__/log-init.test.ts +129 -0
- package/src/__tests__/log.test.ts +23 -5
- package/src/__tests__/media-index.test.ts +317 -35
- package/src/__tests__/plugin.test.ts +314 -0
- package/src/__tests__/prompt-builder-extended.test.ts +296 -0
- package/src/__tests__/prompt-builder.test.ts +44 -9
- package/src/__tests__/sessions.test.ts +258 -4
- package/src/__tests__/storage-save-errors.test.ts +342 -0
- package/src/__tests__/teams-frontend.test.ts +526 -31
- package/src/__tests__/telegram-formatting.test.ts +82 -0
- package/src/__tests__/terminal-commands.test.ts +208 -1
- package/src/__tests__/terminal-renderer.test.ts +223 -0
- package/src/__tests__/time.test.ts +107 -0
- package/src/__tests__/workspace-migrate.test.ts +256 -0
- package/src/__tests__/workspace.test.ts +63 -1
- package/src/backend/claude-sdk/tools.ts +64 -18
- package/src/bootstrap.ts +14 -14
- package/src/cli.ts +440 -125
- package/src/core/cron.ts +20 -5
- package/src/core/dispatcher.ts +27 -9
- package/src/core/dream.ts +79 -24
- package/src/core/errors.ts +12 -2
- package/src/core/gateway-actions.ts +182 -46
- package/src/core/gateway.ts +93 -41
- package/src/core/heartbeat.ts +515 -0
- package/src/core/plugin.ts +1 -1
- package/src/core/prompt-builder.ts +1 -4
- package/src/core/pulse.ts +4 -3
- package/src/frontend/teams/actions.ts +3 -1
- package/src/frontend/teams/formatting.ts +47 -8
- package/src/frontend/teams/graph.ts +35 -11
- package/src/frontend/teams/index.ts +155 -57
- package/src/frontend/teams/tools.ts +4 -6
- package/src/frontend/telegram/actions.ts +358 -82
- package/src/frontend/telegram/admin.ts +162 -72
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +37 -21
- package/src/frontend/telegram/formatting.ts +2 -4
- package/src/frontend/telegram/handlers.ts +262 -66
- package/src/frontend/telegram/index.ts +39 -14
- package/src/frontend/telegram/middleware.ts +14 -4
- package/src/frontend/telegram/userbot.ts +16 -4
- package/src/frontend/terminal/renderer.ts +1 -4
- package/src/index.ts +28 -4
- package/src/storage/chat-settings.ts +32 -9
- package/src/storage/cron-store.ts +53 -11
- package/src/storage/daily-log.ts +72 -19
- package/src/storage/history.ts +39 -21
- package/src/storage/media-index.ts +37 -12
- package/src/storage/sessions.ts +3 -2
- package/src/util/cleanup-registry.ts +34 -0
- package/src/util/config.ts +85 -23
- package/src/util/log.ts +47 -17
- package/src/util/paths.ts +10 -0
- package/src/util/time.ts +29 -6
- package/src/util/watchdog.ts +5 -1
- 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 =
|
|
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 = [
|
|
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) =>
|
|
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) =>
|
|
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 = (
|
|
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(
|
|
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 {
|
|
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 {
|
|
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 } =
|
|
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 } =
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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)
|
|
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)
|
|
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 {
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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_${
|
|
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
|
|
150
|
+
next: (nextDate as Date).toISOString(),
|
|
109
151
|
};
|
|
110
152
|
} catch (err) {
|
|
111
153
|
return {
|
|
112
154
|
valid: false,
|
|
113
|
-
error: err
|
|
155
|
+
error: (err as Error).message,
|
|
114
156
|
};
|
|
115
157
|
}
|
|
116
158
|
}
|
package/src/storage/daily-log.ts
CHANGED
|
@@ -3,10 +3,17 @@
|
|
|
3
3
|
* Claude can reference these via the Read tool for continuity across sessions.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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 (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
89
|
-
|
|
90
|
-
} catch {
|
|
137
|
+
unlinkSync(resolve(dailyMemDir, file));
|
|
138
|
+
deletedMem++;
|
|
139
|
+
} catch {
|
|
140
|
+
/* skip */
|
|
141
|
+
}
|
|
91
142
|
}
|
|
92
143
|
}
|
|
93
|
-
if (
|
|
94
|
-
logInfo("workspace", `Cleaned up ${
|
|
144
|
+
if (deletedMem > 0) {
|
|
145
|
+
logInfo("workspace", `Cleaned up ${deletedMem} old daily memory file(s)`);
|
|
95
146
|
}
|
|
96
|
-
} catch {
|
|
147
|
+
} catch {
|
|
148
|
+
/* skip */
|
|
149
|
+
}
|
|
97
150
|
}
|
package/src/storage/history.ts
CHANGED
|
@@ -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<
|
|
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(
|
|
73
|
+
logError(
|
|
74
|
+
"sessions",
|
|
75
|
+
"Loaded history from backup (primary was corrupt)",
|
|
76
|
+
);
|
|
69
77
|
return;
|
|
70
78
|
}
|
|
71
|
-
} catch {
|
|
72
|
-
|
|
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 {
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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) {
|
|
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 =
|
|
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,
|