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
@@ -8,16 +8,24 @@
8
8
 
9
9
  import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs";
10
10
  import { dirname } from "node:path";
11
+ import { registerCleanup } from "../util/cleanup-registry.js";
11
12
  import writeFileAtomic from "write-file-atomic";
12
13
  import { log } from "../util/log.js";
13
14
  import { files } from "../util/paths.js";
14
15
 
15
16
  export type MediaEntry = {
16
- id: string; // unique key: chatId:msgId
17
+ id: string; // unique key: chatId:msgId
17
18
  chatId: string;
18
19
  msgId: number;
19
20
  senderName: string;
20
- type: "photo" | "document" | "voice" | "video" | "animation" | "audio" | "sticker";
21
+ type:
22
+ | "photo"
23
+ | "document"
24
+ | "voice"
25
+ | "video"
26
+ | "animation"
27
+ | "audio"
28
+ | "sticker";
21
29
  filePath: string;
22
30
  caption?: string;
23
31
  timestamp: number;
@@ -36,7 +44,9 @@ export function loadMediaIndex(): void {
36
44
  if (existsSync(STORE_FILE)) {
37
45
  entries = JSON.parse(readFileSync(STORE_FILE, "utf-8"));
38
46
  }
39
- } catch { entries = []; }
47
+ } catch {
48
+ entries = [];
49
+ }
40
50
  // Purge expired on load
41
51
  purgeExpired();
42
52
  }
@@ -48,11 +58,13 @@ function save(): void {
48
58
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
49
59
  writeFileAtomic.sync(STORE_FILE, JSON.stringify(entries) + "\n");
50
60
  dirty = false;
51
- } catch { /* non-fatal */ }
61
+ } catch {
62
+ /* non-fatal */
63
+ }
52
64
  }
53
65
 
54
66
  const autoSaveTimer = setInterval(save, 30_000);
55
- process.on("exit", save);
67
+ registerCleanup(save);
56
68
 
57
69
  export function flushMediaIndex(): void {
58
70
  clearInterval(autoSaveTimer);
@@ -80,7 +92,11 @@ export function getRecentMedia(chatId: string, limit = 10): MediaEntry[] {
80
92
  }
81
93
 
82
94
  /** Get all media matching a type in a chat. */
83
- export function getMediaByType(chatId: string, type: MediaEntry["type"], limit = 10): MediaEntry[] {
95
+ export function getMediaByType(
96
+ chatId: string,
97
+ type: MediaEntry["type"],
98
+ limit = 10,
99
+ ): MediaEntry[] {
84
100
  return entries
85
101
  .filter((e) => e.chatId === chatId && e.type === type)
86
102
  .sort((a, b) => b.timestamp - a.timestamp)
@@ -91,11 +107,16 @@ export function getMediaByType(chatId: string, type: MediaEntry["type"], limit =
91
107
  export function formatMediaIndex(chatId: string, limit = 10): string {
92
108
  const media = getRecentMedia(chatId, limit);
93
109
  if (media.length === 0) return "No recent media in this chat.";
94
- return media.map((m) => {
95
- const time = new Date(m.timestamp).toISOString().slice(0, 16).replace("T", " ");
96
- const cap = m.caption ? ` "${m.caption.slice(0, 50)}"` : "";
97
- return `[${m.type}] msg:${m.msgId} by ${m.senderName} at ${time}${cap}\n file: ${m.filePath}`;
98
- }).join("\n");
110
+ return media
111
+ .map((m) => {
112
+ const time = new Date(m.timestamp)
113
+ .toISOString()
114
+ .slice(0, 16)
115
+ .replace("T", " ");
116
+ const cap = m.caption ? ` "${m.caption.slice(0, 50)}"` : "";
117
+ return `[${m.type}] msg:${m.msgId} by ${m.senderName} at ${time}${cap}\n file: ${m.filePath}`;
118
+ })
119
+ .join("\n");
99
120
  }
100
121
 
101
122
  // ── Expiry ──────────────────────────────────────────────────────────────────
@@ -106,7 +127,11 @@ function purgeExpired(): void {
106
127
  entries = entries.filter((e) => {
107
128
  if (e.timestamp >= cutoff) return true;
108
129
  // Delete the file too
109
- try { if (existsSync(e.filePath)) unlinkSync(e.filePath); } catch { /* skip */ }
130
+ try {
131
+ if (existsSync(e.filePath)) unlinkSync(e.filePath);
132
+ } catch {
133
+ /* skip */
134
+ }
110
135
  return false;
111
136
  });
112
137
  if (entries.length < before) {
@@ -3,6 +3,7 @@ import writeFileAtomic from "write-file-atomic";
3
3
  import { log, logError } from "../util/log.js";
4
4
  import { recordError } from "../util/watchdog.js";
5
5
  import { dirs, files } from "../util/paths.js";
6
+ import { registerCleanup } from "../util/cleanup-registry.js";
6
7
 
7
8
  /**
8
9
  * Session manager — maps Telegram chat IDs to Claude SDK session IDs.
@@ -227,7 +228,7 @@ export function recordUsage(
227
228
  session.usage.totalResponseMs =
228
229
  (session.usage.totalResponseMs || 0) + turn.durationMs;
229
230
  session.usage.lastResponseMs = turn.durationMs;
230
- const current = session.usage.fastestResponseMs || Infinity;
231
+ const current = session.usage.fastestResponseMs;
231
232
  if (turn.durationMs < current) {
232
233
  session.usage.fastestResponseMs = turn.durationMs;
233
234
  }
@@ -318,7 +319,7 @@ export function getAllSessions(): Array<{ chatId: string; info: SessionInfo }> {
318
319
  }
319
320
 
320
321
  // Flush on exit (signal handlers are in index.ts for graceful shutdown)
321
- process.on("exit", saveSessions);
322
+ registerCleanup(saveSessions);
322
323
 
323
324
  /** Force-save sessions to disk and stop the auto-save timer. */
324
325
  export function flushSessions(): void {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Centralized process exit handler registry.
3
+ *
4
+ * Each storage module calls registerCleanup() instead of
5
+ * process.on("exit", fn) directly. This keeps exactly ONE
6
+ * "exit" listener on the process regardless of how many modules
7
+ * are loaded — avoiding MaxListenersExceededWarning.
8
+ */
9
+
10
+ const handlers: Array<() => void> = [];
11
+ let registered = false;
12
+
13
+ /**
14
+ * Register a synchronous cleanup function to run on process exit.
15
+ * Safe to call multiple times across multiple modules — only one
16
+ * process "exit" listener is ever registered.
17
+ */
18
+ export function registerCleanup(fn: () => void): void {
19
+ handlers.push(fn);
20
+ if (!registered) {
21
+ registered = true;
22
+ process.on("exit", runAll);
23
+ }
24
+ }
25
+
26
+ function runAll(): void {
27
+ for (const fn of handlers) {
28
+ try {
29
+ fn();
30
+ } catch {
31
+ // Suppress — we're in exit, can't do much about it
32
+ }
33
+ }
34
+ }
@@ -1,12 +1,17 @@
1
- import { existsSync, readFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ statSync,
7
+ } from "node:fs";
2
8
  import { resolve } from "node:path";
3
9
  import writeFileAtomic from "write-file-atomic";
4
10
  import { z } from "zod";
5
11
  import { dirs, files as pathFiles } from "./paths.js";
6
- import { setTimezone, formatFullDatetime } from "./time.js";
12
+ import { setTimezone, formatFullDatetime, todayAndYesterday } from "./time.js";
7
13
  import { log } from "./log.js";
8
14
 
9
-
10
15
  // ── Config schema ───────────────────────────────────────────────────────────
11
16
 
12
17
  const pluginEntrySchema = z.object({
@@ -28,8 +33,12 @@ const configSchema = z.object({
28
33
  apiId: z.number().int().optional(),
29
34
  apiHash: z.string().optional(),
30
35
  adminUserId: z.number().int().optional(),
36
+ allowedUsers: z.array(z.number().int()).optional(), // Whitelist of user IDs allowed to DM the bot
31
37
  pulse: z.boolean().default(true),
32
38
  pulseIntervalMs: z.number().int().min(60000).default(300000),
39
+ heartbeat: z.boolean().default(false),
40
+ heartbeatIntervalMinutes: z.number().int().min(5).default(60),
41
+ heartbeatModel: z.string().optional(), // Model for heartbeat agent (defaults to main model)
33
42
  braveApiKey: z.string().optional(),
34
43
  searxngUrl: z.string().default("http://localhost:8080"),
35
44
  timezone: z.string().optional(),
@@ -77,7 +86,9 @@ function loadConfigFile(): Record<string, unknown> {
77
86
  if (existsSync(CONFIG_FILE)) {
78
87
  return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
79
88
  }
80
- } catch { /* corrupt — will be recreated */ }
89
+ } catch {
90
+ /* corrupt — will be recreated */
91
+ }
81
92
  return {};
82
93
  }
83
94
 
@@ -89,7 +100,10 @@ function ensureConfigFile(): boolean {
89
100
  if (!existsSync(dirs.root)) mkdirSync(dirs.root, { recursive: true });
90
101
  if (!existsSync(dirs.data)) mkdirSync(dirs.data, { recursive: true });
91
102
  if (!existsSync(CONFIG_FILE)) {
92
- writeFileAtomic.sync(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
103
+ writeFileAtomic.sync(
104
+ CONFIG_FILE,
105
+ JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
106
+ );
93
107
  return true;
94
108
  }
95
109
  return false;
@@ -100,13 +114,18 @@ function ensureConfigFile(): boolean {
100
114
  function readOptionalFile(path: string): string {
101
115
  try {
102
116
  if (existsSync(path)) return readFileSync(path, "utf-8").trim();
103
- } catch { /* ignore */ }
117
+ } catch {
118
+ /* ignore */
119
+ }
104
120
  return "";
105
121
  }
106
122
 
107
123
  let lastLoggedPromptKey = "";
108
124
 
109
- function loadSystemPrompt(frontend?: string, pluginPromptAdditions?: string[]): string {
125
+ function loadSystemPrompt(
126
+ frontend?: string,
127
+ pluginPromptAdditions?: string[],
128
+ ): string {
110
129
  const promptDir = dirs.prompts;
111
130
  const parts: string[] = [];
112
131
 
@@ -124,14 +143,21 @@ function loadSystemPrompt(frontend?: string, pluginPromptAdditions?: string[]):
124
143
  // Load base prompt (shared across all frontends)
125
144
  const custom = readOptionalFile(resolve(promptDir, "custom.md"));
126
145
  const basePrompt = readOptionalFile(resolve(promptDir, "base.md"));
127
- if (custom) { parts.push(custom); loaded.push("custom"); }
128
- else if (basePrompt) { parts.push(basePrompt); loaded.push("base"); }
129
- else parts.push("You are a sharp and helpful AI assistant.");
146
+ if (custom) {
147
+ parts.push(custom);
148
+ loaded.push("custom");
149
+ } else if (basePrompt) {
150
+ parts.push(basePrompt);
151
+ loaded.push("base");
152
+ } else parts.push("You are a sharp and helpful AI assistant.");
130
153
 
131
154
  // Load frontend-specific prompt
132
155
  const frontendFile = `${frontend ?? "telegram"}.md`;
133
156
  const frontendPrompt = readOptionalFile(resolve(promptDir, frontendFile));
134
- if (frontendPrompt) { parts.push(frontendPrompt); loaded.push(frontendFile.replace(".md", "")); }
157
+ if (frontendPrompt) {
158
+ parts.push(frontendPrompt);
159
+ loaded.push(frontendFile.replace(".md", ""));
160
+ }
135
161
 
136
162
  const memory = readOptionalFile(pathFiles.memory);
137
163
  if (memory) {
@@ -141,6 +167,12 @@ function loadSystemPrompt(frontend?: string, pluginPromptAdditions?: string[]):
141
167
  loaded.push("memory");
142
168
  }
143
169
 
170
+ // Point the bot at daily memory files (read on demand, not injected)
171
+ const { today } = todayAndYesterday();
172
+ parts.push(
173
+ `## Daily Memory\n\nYour daily notes are stored in \`${dirs.dailyMemory}/\`. Today's file is \`${today}.md\`. Use the Read tool to check recent daily notes when you need context from previous days.`,
174
+ );
175
+
144
176
  const loadedKey = loaded.join(" + ");
145
177
  if (loadedKey && loadedKey !== lastLoggedPromptKey) {
146
178
  log("config", `System prompt: ${loadedKey}`);
@@ -155,28 +187,44 @@ function loadSystemPrompt(frontend?: string, pluginPromptAdditions?: string[]):
155
187
  const entries: string[] = [];
156
188
  try {
157
189
  for (const e of readdirSync(dir, { withFileTypes: true })) {
158
- if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "talon.log") continue;
190
+ if (
191
+ e.name.startsWith(".") ||
192
+ e.name === "node_modules" ||
193
+ e.name === "talon.log"
194
+ )
195
+ continue;
159
196
  const full = resolve(dir, e.name);
160
197
  if (e.isDirectory()) {
161
198
  const sub = listDir(full, `${prefix}${e.name}/`);
162
199
  if (sub.length > 0 && sub.length <= 8) entries.push(...sub);
163
- else if (sub.length > 8) entries.push(`${prefix}${e.name}/ (${sub.length} files)`);
200
+ else if (sub.length > 8)
201
+ entries.push(`${prefix}${e.name}/ (${sub.length} files)`);
164
202
  } else {
165
203
  const sz = statSync(full).size;
166
- entries.push(`${prefix}${e.name} (${sz < 1024 ? sz + "B" : (sz / 1024).toFixed(0) + "KB"})`);
204
+ entries.push(
205
+ `${prefix}${e.name} (${sz < 1024 ? sz + "B" : (sz / 1024).toFixed(0) + "KB"})`,
206
+ );
167
207
  }
168
208
  }
169
- } catch { /* skip */ }
209
+ } catch {
210
+ /* skip */
211
+ }
170
212
  return entries;
171
213
  };
172
214
  const files = listDir(workspaceDir);
173
- if (files.length > 0) workspaceFiles = "\n\nCurrent workspace contents:\n" + files.map((f) => ` ${f}`).join("\n");
174
- } catch { /* no workspace yet */ }
215
+ if (files.length > 0)
216
+ workspaceFiles =
217
+ "\n\nCurrent workspace contents:\n" +
218
+ files.map((f) => ` ${f}`).join("\n");
219
+ } catch {
220
+ /* no workspace yet */
221
+ }
175
222
 
176
223
  parts.push(`## Workspace
177
224
 
178
225
  You have a workspace directory at \`~/.talon/workspace/\`. This is your home — organize it however you want.
179
226
  - \`~/.talon/workspace/memory/memory.md\` is your persistent memory file. Update it when you learn important things.
227
+ - \`~/.talon/workspace/memory/daily/YYYY-MM-DD.md\` is your daily notes file. Write observations, learnings, corrections, and follow-ups here throughout the day. Keep entries concise.
180
228
  - Daily interaction logs are saved to \`~/.talon/workspace/logs/\` automatically.
181
229
  - Files users send you (photos, docs, voice) are saved to \`~/.talon/workspace/uploads/\`.
182
230
  - Persistent cron jobs are managed via the cron tools.
@@ -215,13 +263,19 @@ export function loadConfig(): TalonConfig {
215
263
  setTimezone(parsed.timezone);
216
264
 
217
265
  // Validate per-frontend requirements
218
- const frontends = Array.isArray(parsed.frontend) ? parsed.frontend : [parsed.frontend];
266
+ const frontends = Array.isArray(parsed.frontend)
267
+ ? parsed.frontend
268
+ : [parsed.frontend];
219
269
  for (const fe of frontends) {
220
270
  if (fe === "telegram" && !parsed.botToken) {
221
- throw new Error(`Telegram frontend requires "botToken" in ${CONFIG_FILE}. Run "talon setup" to configure.`);
271
+ throw new Error(
272
+ `Telegram frontend requires "botToken" in ${CONFIG_FILE}. Run "talon setup" to configure.`,
273
+ );
222
274
  }
223
275
  if (fe === "teams" && !parsed.teamsWebhookUrl) {
224
- throw new Error(`Teams frontend requires "teamsWebhookUrl" in ${CONFIG_FILE}. Run "talon setup" to configure.`);
276
+ throw new Error(
277
+ `Teams frontend requires "teamsWebhookUrl" in ${CONFIG_FILE}. Run "talon setup" to configure.`,
278
+ );
225
279
  }
226
280
  }
227
281
 
@@ -238,7 +292,15 @@ export function loadConfig(): TalonConfig {
238
292
  * Rebuild the system prompt with plugin additions.
239
293
  * Called after plugins are loaded to inject their prompt contributions.
240
294
  */
241
- export function rebuildSystemPrompt(config: TalonConfig, pluginAdditions: string[]): void {
242
- const frontends = Array.isArray(config.frontend) ? config.frontend : [config.frontend];
243
- config.systemPrompt = loadSystemPrompt(frontends[0], pluginAdditions.length > 0 ? pluginAdditions : undefined);
295
+ export function rebuildSystemPrompt(
296
+ config: TalonConfig,
297
+ pluginAdditions: string[],
298
+ ): void {
299
+ const frontends = Array.isArray(config.frontend)
300
+ ? config.frontend
301
+ : [config.frontend];
302
+ config.systemPrompt = loadSystemPrompt(
303
+ frontends[0],
304
+ pluginAdditions.length > 0 ? pluginAdditions : undefined,
305
+ );
244
306
  }
package/src/util/log.ts CHANGED
@@ -8,7 +8,14 @@
8
8
  */
9
9
 
10
10
  import pino from "pino";
11
- import { existsSync, readFileSync, mkdirSync, statSync, renameSync, unlinkSync } from "node:fs";
11
+ import {
12
+ existsSync,
13
+ readFileSync,
14
+ mkdirSync,
15
+ statSync,
16
+ renameSync,
17
+ unlinkSync,
18
+ } from "node:fs";
12
19
  import { dirs, files } from "./paths.js";
13
20
 
14
21
  export type LogComponent =
@@ -22,22 +29,29 @@ export type LogComponent =
22
29
  | "workspace"
23
30
  | "shutdown"
24
31
  | "file"
32
+ | "history"
25
33
  | "sessions"
26
34
  | "settings"
27
35
  | "commands"
28
36
  | "cron"
29
37
  | "dream"
38
+ | "heartbeat"
30
39
  | "dispatcher"
31
40
  | "gateway"
32
41
  | "plugin"
33
42
  | "teams"
34
- | "config";
43
+ | "config"
44
+ | "access";
35
45
 
36
46
  const LOG_FILE = files.log;
37
47
 
38
48
  // Ensure .talon dir exists for log file
39
49
  if (!existsSync(dirs.root)) {
40
- try { mkdirSync(dirs.root, { recursive: true }); } catch { /* ignore */ }
50
+ try {
51
+ mkdirSync(dirs.root, { recursive: true });
52
+ } catch {
53
+ /* ignore */
54
+ }
41
55
  }
42
56
 
43
57
  // Rotate log file on startup if it exceeds 10MB
@@ -45,10 +59,21 @@ const MAX_LOG_SIZE = 10 * 1024 * 1024;
45
59
  try {
46
60
  if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > MAX_LOG_SIZE) {
47
61
  const rotated = `${LOG_FILE}.old`;
48
- try { unlinkSync(rotated); } catch { /* ignore */ }
62
+ try {
63
+ unlinkSync(rotated);
64
+ } catch {
65
+ /* ignore */
66
+ }
49
67
  renameSync(LOG_FILE, rotated);
50
68
  }
51
- } catch { /* ignore */ }
69
+ } catch {
70
+ /* ignore */
71
+ }
72
+
73
+ // Detect if running as a bun compiled binary (pino-pretty can't be bundled).
74
+ // import.meta.path is Bun-specific — undefined in Node.js/Vitest, so guard with ?.
75
+ const isBunBinary =
76
+ (import.meta as { path?: string }).path?.startsWith("/$bunfs/") ?? false;
52
77
 
53
78
  // Suppress console output for terminal frontend (stdout belongs to the REPL)
54
79
  let quiet = process.env.TALON_QUIET === "1";
@@ -59,23 +84,29 @@ if (!quiet) {
59
84
  const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
60
85
  if (cfg.frontend === "terminal") quiet = true;
61
86
  }
62
- } catch { /* ignore */ }
87
+ } catch {
88
+ /* ignore */
89
+ }
63
90
  }
64
91
 
65
92
  const logger = pino({
66
93
  level: "trace",
67
94
  transport: {
68
95
  targets: [
69
- // Console output (disabled in quiet mode)
70
- ...(!quiet ? [{
71
- target: "pino-pretty",
72
- level: "trace" as const,
73
- options: {
74
- colorize: true,
75
- ignore: "pid,hostname",
76
- translateTime: "HH:MM:ss",
77
- },
78
- }] : []),
96
+ // Console output (disabled in quiet mode or compiled binary)
97
+ ...(!quiet && !isBunBinary
98
+ ? [
99
+ {
100
+ target: "pino-pretty",
101
+ level: "trace" as const,
102
+ options: {
103
+ colorize: true,
104
+ ignore: "pid,hostname",
105
+ translateTime: "HH:MM:ss",
106
+ },
107
+ },
108
+ ]
109
+ : []),
79
110
  // JSON file output (always active)
80
111
  {
81
112
  target: "pino/file",
@@ -119,4 +150,3 @@ export function logDebug(component: LogComponent, message: string): void {
119
150
  (globalThis as Record<string, unknown>).__talonLog = log;
120
151
  (globalThis as Record<string, unknown>).__talonLogError = logError;
121
152
  (globalThis as Record<string, unknown>).__talonLogWarn = logWarn;
122
-
package/src/util/paths.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * media-index.json
15
15
  * workspace/ User-facing workspace (memory, uploads, logs)
16
16
  * memory/
17
+ * daily/ Per-day memory notes (YYYY-MM-DD.md)
17
18
  * uploads/
18
19
  * stickers/
19
20
  * logs/
@@ -42,6 +43,8 @@ export const dirs = {
42
43
  logs: resolve(TALON_ROOT, "workspace", "logs"),
43
44
  /** Memory: ~/.talon/workspace/memory/ */
44
45
  memory: resolve(TALON_ROOT, "workspace", "memory"),
46
+ /** Daily memory notes: ~/.talon/workspace/memory/daily/ */
47
+ dailyMemory: resolve(TALON_ROOT, "workspace", "memory", "daily"),
45
48
  /** Sticker packs: ~/.talon/workspace/stickers/ */
46
49
  stickers: resolve(TALON_ROOT, "workspace", "stickers"),
47
50
  /** Prompt files: ~/.talon/prompts/ */
@@ -77,4 +80,11 @@ export const files = {
77
80
  pid: resolve(TALON_ROOT, "talon.pid"),
78
81
  /** Dream mode state: ~/.talon/workspace/memory/dream_state.json */
79
82
  dreamState: resolve(TALON_ROOT, "workspace", "memory", "dream_state.json"),
83
+ /** Heartbeat state: ~/.talon/workspace/memory/heartbeat_state.json */
84
+ heartbeatState: resolve(
85
+ TALON_ROOT,
86
+ "workspace",
87
+ "memory",
88
+ "heartbeat_state.json",
89
+ ),
80
90
  } as const;
package/src/util/time.ts CHANGED
@@ -29,12 +29,12 @@ function toHHMM(date: Date): string {
29
29
  }
30
30
 
31
31
  /** Format a Date in the configured timezone as YYYY-MM-DD. */
32
- function toYMD(date: Date): string {
32
+ export function toYMD(date: Date): string {
33
33
  return date.toLocaleDateString("en-CA", { timeZone: getTimezone() }); // en-CA gives YYYY-MM-DD
34
34
  }
35
35
 
36
36
  /** Get "today" and "yesterday" date strings in the configured timezone. */
37
- function todayAndYesterday(): { today: string; yesterday: string } {
37
+ export function todayAndYesterday(): { today: string; yesterday: string } {
38
38
  const now = new Date();
39
39
  const today = toYMD(now);
40
40
  const yd = new Date(now.getTime() - 86_400_000);
@@ -60,18 +60,38 @@ export function formatSmartTimestamp(ts: number): string {
60
60
  if (dateStr === yesterday) return `Yesterday ${time}`;
61
61
 
62
62
  const now = new Date();
63
- const thisYear = now.toLocaleDateString("en-CA", { timeZone: getTimezone() }).slice(0, 4);
63
+ const thisYear = now
64
+ .toLocaleDateString("en-CA", { timeZone: getTimezone() })
65
+ .slice(0, 4);
64
66
  const msgYear = dateStr.slice(0, 4);
65
67
 
66
68
  if (msgYear === thisYear) {
67
- const month = date.toLocaleString("en-US", { month: "short", timeZone: getTimezone() });
68
- const day = date.toLocaleString("en-US", { day: "numeric", timeZone: getTimezone() });
69
+ const month = date.toLocaleString("en-US", {
70
+ month: "short",
71
+ timeZone: getTimezone(),
72
+ });
73
+ const day = date.toLocaleString("en-US", {
74
+ day: "numeric",
75
+ timeZone: getTimezone(),
76
+ });
69
77
  return `${month} ${day} ${time}`;
70
78
  }
71
79
 
72
80
  return `${dateStr} ${time}`;
73
81
  }
74
82
 
83
+ /**
84
+ * Human-readable relative age: "just now", "5m ago", "3h ago", "2d ago".
85
+ * Used for user-facing displays where a precise timestamp isn't needed.
86
+ */
87
+ export function formatRelativeAge(ts: number): string {
88
+ const diff = Date.now() - ts;
89
+ if (diff < 60_000) return "just now";
90
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
91
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
92
+ return `${Math.floor(diff / 86_400_000)}d ago`;
93
+ }
94
+
75
95
  /**
76
96
  * Full datetime for system prompt injection.
77
97
  * Example: "2026-03-21 14:32 (Europe/Warsaw, Fri)"
@@ -81,6 +101,9 @@ export function formatFullDatetime(): string {
81
101
  const tz = getTimezone();
82
102
  const dateStr = toYMD(now);
83
103
  const time = toHHMM(now);
84
- const weekday = now.toLocaleString("en-US", { weekday: "short", timeZone: tz });
104
+ const weekday = now.toLocaleString("en-US", {
105
+ weekday: "short",
106
+ timeZone: tz,
107
+ });
85
108
  return `${dateStr} ${time} ${weekday} (${tz})`;
86
109
  }
@@ -69,7 +69,11 @@ export function startWatchdog(workspaceDir?: string): void {
69
69
  // Ensure workspace still exists (might have been deleted externally)
70
70
  if (workspaceDir && !existsSync(workspaceDir)) {
71
71
  logWarn("watchdog", "Workspace directory missing — recreating");
72
- try { mkdirSync(workspaceDir, { recursive: true }); } catch { /* ignore */ }
72
+ try {
73
+ mkdirSync(workspaceDir, { recursive: true });
74
+ } catch {
75
+ /* ignore */
76
+ }
73
77
  }
74
78
  }, 60_000); // Check every minute
75
79
  }