talon-agent 1.0.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 (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
package/src/login.ts ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-time login script for the GramJS user session.
4
+ * Run: npx tsx src/login.ts
5
+ *
6
+ * You'll need:
7
+ * - TALON_API_ID and TALON_API_HASH from https://my.telegram.org
8
+ * - Your phone number (will receive a login code via Telegram)
9
+ */
10
+
11
+ import { TelegramClient } from "telegram";
12
+ import { StringSession } from "telegram/sessions/index.js";
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
+ import { resolve, dirname } from "node:path";
15
+ import { createInterface } from "node:readline";
16
+ import { files } from "./util/paths.js";
17
+
18
+ // Load .env
19
+ const envPath = resolve(process.cwd(), ".env");
20
+ if (existsSync(envPath)) {
21
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed || trimmed.startsWith("#")) continue;
24
+ const eq = trimmed.indexOf("=");
25
+ if (eq <= 0) continue;
26
+ const key = trimmed.slice(0, eq).trim();
27
+ let value = trimmed.slice(eq + 1).trim();
28
+ if (
29
+ (value.startsWith('"') && value.endsWith('"')) ||
30
+ (value.startsWith("'") && value.endsWith("'"))
31
+ ) {
32
+ value = value.slice(1, -1);
33
+ }
34
+ if (!process.env[key]) process.env[key] = value;
35
+ }
36
+ }
37
+
38
+ const SESSION_FILE = files.userSession;
39
+
40
+ const apiId = parseInt(process.env.TALON_API_ID || "", 10);
41
+ const apiHash = process.env.TALON_API_HASH || "";
42
+
43
+ if (!apiId || !apiHash) {
44
+ console.error("Set TALON_API_ID and TALON_API_HASH in .env");
45
+ console.error(
46
+ "Get them from https://my.telegram.org → API development tools",
47
+ );
48
+ process.exit(1);
49
+ }
50
+
51
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
52
+ const ask = (q: string): Promise<string> =>
53
+ new Promise((resolve) => rl.question(q, resolve));
54
+
55
+ async function main() {
56
+ let sessionString = "";
57
+ if (existsSync(SESSION_FILE)) {
58
+ sessionString = readFileSync(SESSION_FILE, "utf-8").trim();
59
+ }
60
+
61
+ const session = new StringSession(sessionString);
62
+ const client = new TelegramClient(session, apiId, apiHash, {
63
+ connectionRetries: 5,
64
+ });
65
+
66
+ await client.start({
67
+ phoneNumber: async () => await ask("Phone number (with country code): "),
68
+ password: async () => await ask("2FA password (if enabled): "),
69
+ phoneCode: async () => await ask("Login code from Telegram: "),
70
+ onError: (err) => console.error("Login error:", err),
71
+ });
72
+
73
+ console.log("\nLogged in successfully!");
74
+
75
+ // Save session
76
+ const newSession = client.session.save() as unknown as string;
77
+ const dir = dirname(SESSION_FILE);
78
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
79
+ writeFileSync(SESSION_FILE, newSession);
80
+ console.log(`Session saved to ${SESSION_FILE}`);
81
+
82
+ await client.disconnect();
83
+ rl.close();
84
+ }
85
+
86
+ main().catch((err) => {
87
+ console.error(err);
88
+ process.exit(1);
89
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Per-chat runtime settings. Overrides global config on a per-chat basis.
3
+ * Persisted alongside sessions.
4
+ */
5
+
6
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
7
+ import writeFileAtomic from "write-file-atomic";
8
+ import { dirname } from "node:path";
9
+ import { log } from "../util/log.js";
10
+ import { files } from "../util/paths.js";
11
+
12
+ export type EffortLevel = "off" | "low" | "medium" | "high" | "max";
13
+
14
+ export type ChatSettings = {
15
+ /** Model override for this chat. */
16
+ model?: string;
17
+ /** Effort level override (maps to SDK thinking + effort options). */
18
+ effort?: EffortLevel;
19
+ /** Whether pulse is enabled for this chat. */
20
+ pulse?: boolean;
21
+ /** Per-chat pulse check interval in milliseconds. */
22
+ pulseIntervalMs?: number;
23
+ /** Last message ID checked by pulse (persisted to avoid reprocessing on restart). */
24
+ pulseLastCheckMsgId?: number;
25
+ };
26
+
27
+ const STORE_FILE = files.chatSettings;
28
+ let store: Record<string, ChatSettings> = {};
29
+ let dirty = false;
30
+
31
+ export function loadChatSettings(): void {
32
+ try {
33
+ if (existsSync(STORE_FILE)) {
34
+ store = JSON.parse(readFileSync(STORE_FILE, "utf-8"));
35
+ }
36
+ } catch {
37
+ // Primary corrupt — try backup
38
+ const bakFile = STORE_FILE + ".bak";
39
+ try {
40
+ if (existsSync(bakFile)) {
41
+ store = JSON.parse(readFileSync(bakFile, "utf-8"));
42
+ log("settings", "Loaded from backup (primary was corrupt)");
43
+ }
44
+ } catch { /* backup also corrupt */ }
45
+ }
46
+ // Migrate legacy maxThinkingTokens → effort
47
+ let migrated = 0;
48
+ for (const [chatId, settings] of Object.entries(store)) {
49
+ const raw = settings as Record<string, unknown>;
50
+ if ("maxThinkingTokens" in raw && !settings.effort) {
51
+ const tokens = Number(raw.maxThinkingTokens);
52
+ let effort: EffortLevel;
53
+ if (tokens === 0) effort = "off";
54
+ else if (tokens <= 2000) effort = "low";
55
+ else if (tokens <= 8000) effort = "medium";
56
+ else if (tokens <= 16000) effort = "high";
57
+ else effort = "max";
58
+ settings.effort = effort;
59
+ delete raw.maxThinkingTokens;
60
+ migrated++;
61
+ log(
62
+ "settings",
63
+ `Migrated chat ${chatId}: maxThinkingTokens=${tokens} to effort=${effort}`,
64
+ );
65
+ } else if ("maxThinkingTokens" in raw) {
66
+ // Has effort already, just clean up the old field
67
+ delete raw.maxThinkingTokens;
68
+ migrated++;
69
+ }
70
+ }
71
+ if (migrated > 0) {
72
+ dirty = true;
73
+ save();
74
+ log(
75
+ "settings",
76
+ `Migrated ${migrated} chat(s) from maxThinkingTokens to effort`,
77
+ );
78
+ }
79
+ }
80
+
81
+ function save(): void {
82
+ if (!dirty) return;
83
+ try {
84
+ const dir = dirname(STORE_FILE);
85
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
86
+ const data = JSON.stringify(store, null, 2) + "\n";
87
+ if (existsSync(STORE_FILE)) {
88
+ try { writeFileAtomic.sync(STORE_FILE + ".bak", readFileSync(STORE_FILE)); } catch { /* best effort */ }
89
+ }
90
+ writeFileAtomic.sync(STORE_FILE, data);
91
+ dirty = false;
92
+ } catch {
93
+ // Non-fatal
94
+ }
95
+ }
96
+
97
+ const autoSaveTimer = setInterval(save, 10_000);
98
+ process.on("exit", save);
99
+
100
+ /** Flush settings to disk and stop the auto-save timer. */
101
+ export function flushChatSettings(): void {
102
+ clearInterval(autoSaveTimer);
103
+ save();
104
+ }
105
+
106
+ export function getChatSettings(chatId: string): ChatSettings {
107
+ return store[chatId] ?? {};
108
+ }
109
+
110
+ function cleanupEmpty(chatId: string): void {
111
+ const s = store[chatId];
112
+ if (s && !s.model && !s.effort && s.pulse === undefined && s.pulseIntervalMs === undefined && s.pulseLastCheckMsgId === undefined) {
113
+ delete store[chatId];
114
+ }
115
+ }
116
+
117
+ export function setPulseLastCheckMsgId(chatId: string, msgId: number | undefined): void {
118
+ if (!store[chatId]) store[chatId] = {};
119
+ if (msgId !== undefined) {
120
+ store[chatId].pulseLastCheckMsgId = msgId;
121
+ } else {
122
+ delete store[chatId].pulseLastCheckMsgId;
123
+ cleanupEmpty(chatId);
124
+ }
125
+ dirty = true;
126
+ // Don't force-save on every pulse check — let the auto-save interval handle it
127
+ }
128
+
129
+ export function setChatModel(chatId: string, model: string | undefined): void {
130
+ if (!store[chatId]) store[chatId] = {};
131
+ if (model) {
132
+ store[chatId].model = model;
133
+ } else {
134
+ delete store[chatId].model;
135
+ cleanupEmpty(chatId);
136
+ }
137
+ dirty = true;
138
+ save();
139
+ }
140
+
141
+ export function setChatEffort(
142
+ chatId: string,
143
+ effort: EffortLevel | undefined,
144
+ ): void {
145
+ if (!store[chatId]) store[chatId] = {};
146
+ if (effort) {
147
+ store[chatId].effort = effort;
148
+ } else {
149
+ delete store[chatId].effort;
150
+ cleanupEmpty(chatId);
151
+ }
152
+ dirty = true;
153
+ save();
154
+ }
155
+
156
+ export function setChatPulse(
157
+ chatId: string,
158
+ enabled: boolean | undefined,
159
+ ): void {
160
+ if (!store[chatId]) store[chatId] = {};
161
+ if (enabled !== undefined) {
162
+ store[chatId].pulse = enabled;
163
+ } else {
164
+ delete store[chatId].pulse;
165
+ cleanupEmpty(chatId);
166
+ }
167
+ dirty = true;
168
+ save();
169
+ }
170
+
171
+ export function setChatPulseInterval(
172
+ chatId: string,
173
+ intervalMs: number | undefined,
174
+ ): void {
175
+ if (!store[chatId]) store[chatId] = {};
176
+ if (intervalMs !== undefined) {
177
+ store[chatId].pulseIntervalMs = intervalMs;
178
+ } else {
179
+ delete store[chatId].pulseIntervalMs;
180
+ cleanupEmpty(chatId);
181
+ }
182
+ dirty = true;
183
+ save();
184
+ }
185
+
186
+ /** Get all chat IDs that have pulse enabled in settings. */
187
+ export function getRegisteredPulseChats(): string[] {
188
+ return Object.entries(store)
189
+ .filter(([, s]) => s.pulse === true)
190
+ .map(([id]) => id);
191
+ }
192
+
193
+ /** Valid effort levels. */
194
+ export const EFFORT_LEVELS: EffortLevel[] = [
195
+ "off",
196
+ "low",
197
+ "medium",
198
+ "high",
199
+ "max",
200
+ ];
201
+
202
+ /** Known model aliases. */
203
+ export const MODEL_ALIASES: Record<string, string> = {
204
+ sonnet: "claude-sonnet-4-6",
205
+ opus: "claude-opus-4-6",
206
+ haiku: "claude-haiku-4-5",
207
+ "sonnet-4.6": "claude-sonnet-4-6",
208
+ "opus-4.6": "claude-opus-4-6",
209
+ "haiku-4.5": "claude-haiku-4-5",
210
+ "sonnet-4-6": "claude-sonnet-4-6",
211
+ "opus-4-6": "claude-opus-4-6",
212
+ "haiku-4-5": "claude-haiku-4-5",
213
+ };
214
+
215
+ export function resolveModelName(input: string): string {
216
+ const lower = input.trim().toLowerCase();
217
+ return Object.hasOwn(MODEL_ALIASES, lower) ? MODEL_ALIASES[lower] : input.trim();
218
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Persistent cron job store. In-memory Map with dirty-flag auto-save to workspace/cron.json.
3
+ * Same pattern as chat-settings.ts.
4
+ */
5
+
6
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
7
+ import writeFileAtomic from "write-file-atomic";
8
+ import { dirname } from "node:path";
9
+ import { Cron } from "croner";
10
+ import { log } from "../util/log.js";
11
+ import { files } from "../util/paths.js";
12
+
13
+ export type CronJobType = "message" | "query";
14
+
15
+ export type CronJob = {
16
+ id: string;
17
+ chatId: string;
18
+ /** Cron expression (5-field: minute hour day month weekday) */
19
+ schedule: string;
20
+ /** "message" sends content as text; "query" runs content as a Claude prompt with tools */
21
+ type: CronJobType;
22
+ /** The message text or query prompt */
23
+ content: string;
24
+ /** Human-readable name for the job */
25
+ name: string;
26
+ enabled: boolean;
27
+ createdAt: number;
28
+ lastRunAt?: number;
29
+ runCount: number;
30
+ /** IANA timezone (e.g. "America/New_York"). Defaults to system timezone. */
31
+ timezone?: string;
32
+ };
33
+
34
+ const STORE_FILE = files.cron;
35
+ let store: Record<string, CronJob> = {};
36
+ let dirty = false;
37
+
38
+ export function loadCronJobs(): void {
39
+ try {
40
+ if (existsSync(STORE_FILE)) {
41
+ const raw = JSON.parse(readFileSync(STORE_FILE, "utf-8"));
42
+ // Support both array (legacy) and object formats
43
+ if (Array.isArray(raw)) {
44
+ for (const job of raw) store[job.id] = job;
45
+ } else {
46
+ store = raw;
47
+ }
48
+ }
49
+ } catch {
50
+ // Primary corrupt — try backup
51
+ const bakFile = STORE_FILE + ".bak";
52
+ try {
53
+ if (existsSync(bakFile)) {
54
+ const raw = JSON.parse(readFileSync(bakFile, "utf-8"));
55
+ store = Array.isArray(raw) ? Object.fromEntries(raw.map((j: CronJob) => [j.id, j])) : raw;
56
+ log("cron", "Loaded from backup (primary was corrupt)");
57
+ }
58
+ } catch { /* backup also corrupt */ }
59
+ }
60
+ const count = Object.keys(store).length;
61
+ if (count > 0) {
62
+ log("cron", `Loaded ${count} cron job(s)`);
63
+ }
64
+ }
65
+
66
+ function save(): void {
67
+ if (!dirty) return;
68
+ try {
69
+ const dir = dirname(STORE_FILE);
70
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
71
+ const data = JSON.stringify(store, null, 2) + "\n";
72
+ if (existsSync(STORE_FILE)) {
73
+ try { writeFileAtomic.sync(STORE_FILE + ".bak", readFileSync(STORE_FILE)); } catch { /* best effort */ }
74
+ }
75
+ writeFileAtomic.sync(STORE_FILE, data);
76
+ dirty = false;
77
+ } catch {
78
+ // Non-fatal
79
+ }
80
+ }
81
+
82
+ const autoSaveTimer = setInterval(save, 10_000);
83
+ process.on("exit", save);
84
+
85
+ /** Flush cron jobs to disk and stop the auto-save timer. */
86
+ export function flushCronJobs(): void {
87
+ clearInterval(autoSaveTimer);
88
+ save();
89
+ }
90
+
91
+ // ── ID generation ───────────────────────────────────────────────────────────
92
+
93
+ export function generateCronId(): string {
94
+ return `cron_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
95
+ }
96
+
97
+ // ── Validation ──────────────────────────────────────────────────────────────
98
+
99
+ export function validateCronExpression(
100
+ expr: string,
101
+ timezone?: string,
102
+ ): { valid: boolean; error?: string; next?: string } {
103
+ try {
104
+ const cron = new Cron(expr, { timezone: timezone ?? undefined });
105
+ const nextDate = cron.nextRun();
106
+ return {
107
+ valid: true,
108
+ next: nextDate ? nextDate.toISOString() : undefined,
109
+ };
110
+ } catch (err) {
111
+ return {
112
+ valid: false,
113
+ error: err instanceof Error ? err.message : String(err),
114
+ };
115
+ }
116
+ }
117
+
118
+ // ── CRUD ────────────────────────────────────────────────────────────────────
119
+
120
+ export function addCronJob(job: CronJob): void {
121
+ store[job.id] = job;
122
+ dirty = true;
123
+ save();
124
+ }
125
+
126
+ export function getCronJob(id: string): CronJob | undefined {
127
+ return store[id];
128
+ }
129
+
130
+ export function getCronJobsForChat(chatId: string): CronJob[] {
131
+ return Object.values(store).filter((j) => j.chatId === chatId);
132
+ }
133
+
134
+ export function getAllCronJobs(): CronJob[] {
135
+ return Object.values(store);
136
+ }
137
+
138
+ export function updateCronJob(
139
+ id: string,
140
+ updates: Partial<Omit<CronJob, "id" | "chatId" | "createdAt">>,
141
+ ): CronJob | undefined {
142
+ const job = store[id];
143
+ if (!job) return undefined;
144
+ Object.assign(job, updates);
145
+ dirty = true;
146
+ save();
147
+ return job;
148
+ }
149
+
150
+ export function deleteCronJob(id: string): boolean {
151
+ if (!store[id]) return false;
152
+ delete store[id];
153
+ dirty = true;
154
+ save();
155
+ return true;
156
+ }
157
+
158
+ export function recordCronRun(id: string): void {
159
+ const job = store[id];
160
+ if (!job) return;
161
+ job.lastRunAt = Date.now();
162
+ job.runCount = (job.runCount || 0) + 1;
163
+ dirty = true;
164
+ // Don't force-save on every run; let the interval handle it
165
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Daily log system — appends brief interaction summaries to workspace/logs/YYYY-MM-DD.md.
3
+ * Claude can reference these via the Read tool for continuity across sessions.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, appendFileSync, readdirSync, unlinkSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { log as logInfo, logError } from "../util/log.js";
9
+ import { dirs } from "../util/paths.js";
10
+
11
+ const LOGS_DIR = dirs.logs;
12
+ const MAX_LOG_DAYS = 30; // Keep last 30 days of logs
13
+
14
+ function ensureLogsDir(): void {
15
+ if (!existsSync(LOGS_DIR)) {
16
+ mkdirSync(LOGS_DIR, { recursive: true });
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Append a user message entry to today's daily log.
22
+ * Format: ## HH:MM -- [chatTitle/userName]\nmessage text\n
23
+ * @param chatName - Display name of the sender (or "System")
24
+ * @param text - Message content
25
+ * @param chatContext - Optional chat context (group title, username, etc.)
26
+ */
27
+ export function appendDailyLog(chatName: string, text: string, chatContext?: { chatTitle?: string; username?: string }): void {
28
+ try {
29
+ ensureLogsDir();
30
+ const now = new Date();
31
+ const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
32
+ const timeStr = now.toTimeString().slice(0, 5); // HH:MM
33
+ const logFile = resolve(LOGS_DIR, `${dateStr}.md`);
34
+
35
+ const label = formatLabel(chatName, chatContext);
36
+ const entry = `## ${timeStr} -- [${label}]\n${text}\n\n`;
37
+ appendFileSync(logFile, entry);
38
+ } catch (err) {
39
+ logError("bot", "Daily log write failed", err);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Append a bot response entry to today's daily log.
45
+ * Format: ## HH:MM -- [botName] in chatTitle\nresponse text\n
46
+ */
47
+ export function appendDailyLogResponse(botName: string, text: string, chatContext?: { chatTitle?: string }): void {
48
+ try {
49
+ ensureLogsDir();
50
+ const now = new Date();
51
+ const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
52
+ const timeStr = now.toTimeString().slice(0, 5); // HH:MM
53
+ const logFile = resolve(LOGS_DIR, `${dateStr}.md`);
54
+
55
+ const label = chatContext?.chatTitle ? `${botName} in ${chatContext.chatTitle}` : botName;
56
+ const entry = `## ${timeStr} -- [${label}]\n${text}\n\n`;
57
+ appendFileSync(logFile, entry);
58
+ } catch (err) {
59
+ logError("bot", "Daily log response write failed", err);
60
+ }
61
+ }
62
+
63
+ /** Format a log label with optional chat title and username. */
64
+ function formatLabel(name: string, ctx?: { chatTitle?: string; username?: string }): string {
65
+ const userPart = ctx?.username ? `${name} (@${ctx.username})` : name;
66
+ if (ctx?.chatTitle) return `${userPart} in ${ctx.chatTitle}`;
67
+ return userPart;
68
+ }
69
+
70
+ /** Get the path to the logs directory (for system prompt reference). */
71
+ export function getLogsDir(): string {
72
+ return LOGS_DIR;
73
+ }
74
+
75
+ /** Remove daily logs older than MAX_LOG_DAYS. Called on startup. */
76
+ export function cleanupOldLogs(): void {
77
+ 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);
82
+
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) {
87
+ try {
88
+ unlinkSync(resolve(LOGS_DIR, file));
89
+ deleted++;
90
+ } catch { /* skip */ }
91
+ }
92
+ }
93
+ if (deleted > 0) {
94
+ logInfo("workspace", `Cleaned up ${deleted} old daily log(s)`);
95
+ }
96
+ } catch { /* skip */ }
97
+ }