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
@@ -0,0 +1,515 @@
1
+ /**
2
+ * Heartbeat — periodic background agent for user-defined maintenance tasks.
3
+ *
4
+ * Runs at a configurable interval (default: 60 minutes).
5
+ * The agent reads instructions from ~/.talon/workspace/heartbeat-instructions.md
6
+ * and executes them using filesystem-only tools (no Telegram/MCP access).
7
+ *
8
+ * Modeled after dream.ts but more general-purpose.
9
+ */
10
+
11
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
12
+ import { appendFile, mkdir } from "node:fs/promises";
13
+ import { resolve } from "node:path";
14
+ import writeFileAtomic from "write-file-atomic";
15
+ import { query } from "@anthropic-ai/claude-agent-sdk";
16
+ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
17
+ import { files as pathFiles, dirs } from "../util/paths.js";
18
+ import { log, logError, logWarn } from "../util/log.js";
19
+ import { toYMD } from "../util/time.js";
20
+
21
+ // ── Types ────────────────────────────────────────────────────────────────────
22
+
23
+ export type HeartbeatState = {
24
+ /** Unix millisecond timestamp of the last successfully completed heartbeat run. */
25
+ last_run: number;
26
+ /** Human-readable ISO timestamp of the last successfully completed heartbeat run. */
27
+ last_run_at?: string;
28
+ /** Unix millisecond timestamp of the last time a heartbeat was started (success or failure). */
29
+ last_started?: number;
30
+ /** "idle" when no heartbeat is running, "running" while one is active. */
31
+ status: "idle" | "running";
32
+ /** Total number of successfully completed heartbeat runs. */
33
+ run_count: number;
34
+ };
35
+
36
+ // ── Constants ────────────────────────────────────────────────────────────────
37
+
38
+ const HEARTBEAT_STATE_FILE = pathFiles.heartbeatState;
39
+ const HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000; // 10-minute max
40
+ const HEARTBEAT_LOGS_DIR = resolve(dirs.logs, "heartbeats");
41
+ const STARTUP_DELAY_MS = 5 * 60 * 1000; // 5-minute delay before first run
42
+
43
+ // ── State ────────────────────────────────────────────────────────────────────
44
+
45
+ let running = false; // in-process guard (one heartbeat at a time)
46
+ let currentRunPromise: Promise<void> | null = null; // tracks in-flight run for graceful shutdown
47
+ let timer: ReturnType<typeof setInterval> | null = null;
48
+ let startupTimer: ReturnType<typeof setTimeout> | null = null;
49
+ let intervalMinutesRef = 60; // stored from startHeartbeatTimer
50
+ let configRef: {
51
+ model?: string;
52
+ heartbeatModel?: string;
53
+ claudeBinary?: string;
54
+ workspace?: string;
55
+ } | null = null;
56
+
57
+ export function initHeartbeat(cfg: {
58
+ model?: string;
59
+ /** Override model used specifically for heartbeat (e.g. haiku for cost savings). Falls back to main model. */
60
+ heartbeatModel?: string;
61
+ claudeBinary?: string;
62
+ workspace?: string;
63
+ }): void {
64
+ configRef = cfg;
65
+ }
66
+
67
+ // ── Public API ───────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Start the heartbeat timer. First run happens after a 5-minute startup delay,
71
+ * then repeats at the configured interval.
72
+ */
73
+ export function startHeartbeatTimer(intervalMinutes: number): void {
74
+ if (timer || startupTimer) return; // already running or scheduled to start
75
+
76
+ if (!Number.isFinite(intervalMinutes) || intervalMinutes <= 0) {
77
+ logWarn(
78
+ "heartbeat",
79
+ `Refusing to start heartbeat timer with invalid intervalMinutes: ${intervalMinutes}`,
80
+ );
81
+ return;
82
+ }
83
+
84
+ intervalMinutesRef = intervalMinutes;
85
+ const intervalMs = intervalMinutes * 60 * 1000;
86
+ log(
87
+ "heartbeat",
88
+ `Starting heartbeat timer (every ${intervalMinutes}min, first run in 5min)`,
89
+ );
90
+
91
+ startupTimer = setTimeout(() => {
92
+ startupTimer = null;
93
+ // Run immediately after startup delay
94
+ executeHeartbeat("auto").catch(() => {});
95
+
96
+ // Then set up the recurring interval
97
+ timer = setInterval(() => {
98
+ executeHeartbeat("auto").catch(() => {});
99
+ }, intervalMs);
100
+ }, STARTUP_DELAY_MS);
101
+ }
102
+
103
+ /**
104
+ * Stop the heartbeat timer. Does not wait for in-flight runs.
105
+ * Use awaitCurrentRun() after this to wait for a running heartbeat to finish.
106
+ */
107
+ export function stopHeartbeatTimer(): void {
108
+ if (startupTimer) {
109
+ clearTimeout(startupTimer);
110
+ startupTimer = null;
111
+ }
112
+ if (timer) {
113
+ clearInterval(timer);
114
+ timer = null;
115
+ log("heartbeat", "Heartbeat timer stopped");
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Wait for any in-flight heartbeat run to complete.
121
+ * Call after stopHeartbeatTimer() during graceful shutdown.
122
+ */
123
+ export async function awaitCurrentRun(timeoutMs = 10_000): Promise<void> {
124
+ if (currentRunPromise) {
125
+ log("heartbeat", "Waiting for in-flight heartbeat to complete...");
126
+ try {
127
+ await Promise.race([
128
+ currentRunPromise,
129
+ new Promise<void>((resolve) => {
130
+ const t = setTimeout(() => {
131
+ logWarn(
132
+ "heartbeat",
133
+ "In-flight heartbeat did not finish within shutdown budget, proceeding",
134
+ );
135
+ resolve();
136
+ }, timeoutMs);
137
+ t.unref();
138
+ }),
139
+ ]);
140
+ } catch {
141
+ // Already logged in executeHeartbeat
142
+ }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Force a heartbeat run immediately.
148
+ * Returns a promise that resolves when the heartbeat completes.
149
+ * Throws if a heartbeat is already running.
150
+ */
151
+ export async function forceHeartbeat(): Promise<void> {
152
+ if (running) throw new Error("Heartbeat already running");
153
+ await executeHeartbeat("forced");
154
+ }
155
+
156
+ /**
157
+ * Get the current heartbeat status.
158
+ */
159
+ export function getHeartbeatStatus(): HeartbeatState | null {
160
+ return readHeartbeatState();
161
+ }
162
+
163
+ // ── Core execution ──────────────────────────────────────────────────────────
164
+
165
+ async function executeHeartbeat(trigger: "auto" | "forced"): Promise<void> {
166
+ if (running) return;
167
+
168
+ const state = readHeartbeatState();
169
+ const now = Date.now();
170
+ const previousRunCount = state?.run_count ?? 0;
171
+ const previousLastRun = state?.last_run ?? 0;
172
+
173
+ running = true;
174
+ // Mark as running with last_started, but preserve last_run from previous successful run
175
+ writeHeartbeatState({
176
+ last_run: previousLastRun,
177
+ last_started: now,
178
+ status: "running",
179
+ run_count: previousRunCount,
180
+ });
181
+ log(
182
+ "heartbeat",
183
+ `${trigger === "forced" ? "Force-triggering" : "Triggering"} heartbeat #${previousRunCount + 1} (last run: ${previousLastRun ? new Date(previousLastRun).toISOString() : "never"})`,
184
+ );
185
+
186
+ const run = (async () => {
187
+ try {
188
+ const heartbeatLogPath = await runHeartbeatAgent(
189
+ previousLastRun,
190
+ previousRunCount + 1,
191
+ );
192
+ // Only update last_run and increment run_count on success
193
+ writeHeartbeatState({
194
+ last_run: Date.now(),
195
+ last_started: now,
196
+ status: "idle",
197
+ run_count: previousRunCount + 1,
198
+ });
199
+ log(
200
+ "heartbeat",
201
+ `Heartbeat #${previousRunCount + 1} complete (${trigger}), log: ${heartbeatLogPath}`,
202
+ );
203
+ } catch (err) {
204
+ logError(
205
+ "heartbeat",
206
+ `Heartbeat #${previousRunCount + 1} failed (${trigger})`,
207
+ err,
208
+ );
209
+ // On failure, revert to idle but keep previous last_run and run_count
210
+ writeHeartbeatState({
211
+ last_run: previousLastRun,
212
+ last_started: now,
213
+ status: "idle",
214
+ run_count: previousRunCount,
215
+ });
216
+ if (trigger === "forced") throw err;
217
+ } finally {
218
+ running = false;
219
+ currentRunPromise = null;
220
+ }
221
+ })();
222
+
223
+ currentRunPromise = run;
224
+ await run;
225
+ }
226
+
227
+ // ── Heartbeat agent ─────────────────────────────────────────────────────────
228
+
229
+ async function runHeartbeatAgent(
230
+ lastRunTimestamp: number,
231
+ runCount: number,
232
+ ): Promise<string> {
233
+ if (!configRef) {
234
+ throw new Error("Heartbeat agent not initialized");
235
+ }
236
+
237
+ const lastRunIso =
238
+ lastRunTimestamp > 0 ? new Date(lastRunTimestamp).toISOString() : "never";
239
+
240
+ const logsDir = dirs.logs;
241
+ const memoryFile = pathFiles.memory;
242
+ const workspace = configRef.workspace ?? dirs.workspace;
243
+ const instructionsFile = resolve(workspace, "heartbeat-instructions.md");
244
+ const dailyMemoryFile = resolve(dirs.dailyMemory, `${toYMD(new Date())}.md`);
245
+
246
+ // Load prompt template from the prompts directory (seeded to ~/.talon/prompts/)
247
+ const promptPath = resolve(dirs.prompts, "heartbeat.md");
248
+
249
+ let prompt: string;
250
+ try {
251
+ prompt = readFileSync(promptPath, "utf-8")
252
+ .replace(/\{\{workspace\}\}/g, workspace)
253
+ .replace(/\{\{logsDir\}\}/g, logsDir)
254
+ .replace(/\{\{lastRunIso\}\}/g, lastRunIso)
255
+ .replace(/\{\{memoryFile\}\}/g, memoryFile)
256
+ .replace(/\{\{instructionsFile\}\}/g, instructionsFile)
257
+ .replace(/\{\{dailyMemoryFile\}\}/g, dailyMemoryFile)
258
+ .replace(/\{\{runCount\}\}/g, String(runCount))
259
+ .replace(/\{\{intervalMinutes\}\}/g, String(intervalMinutesRef));
260
+ } catch {
261
+ throw new Error(`Failed to read heartbeat prompt from ${promptPath}`);
262
+ }
263
+
264
+ const model =
265
+ configRef.heartbeatModel ?? configRef.model ?? "claude-sonnet-4-6";
266
+
267
+ // Set up heartbeat log file
268
+ const heartbeatLogFile = await createHeartbeatLogFile();
269
+ await appendHeartbeatLog(
270
+ heartbeatLogFile,
271
+ `# Heartbeat Run #${runCount} — ${new Date().toISOString()}\n`,
272
+ );
273
+ await appendHeartbeatLog(
274
+ heartbeatLogFile,
275
+ `**Trigger:** ${lastRunIso === "never" ? "first run" : `last_run=${lastRunIso}`}, model=${model}\n`,
276
+ );
277
+ await appendHeartbeatLog(
278
+ heartbeatLogFile,
279
+ `**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`,
280
+ );
281
+
282
+ const options = {
283
+ model,
284
+ systemPrompt:
285
+ "You are a background heartbeat agent for Talon. Use only filesystem tools. Follow the user-defined instructions precisely. Be efficient — you have limited time.",
286
+ cwd: workspace,
287
+ permissionMode: "bypassPermissions" as const,
288
+ allowDangerouslySkipPermissions: true,
289
+ ...(configRef.claudeBinary
290
+ ? { pathToClaudeCodeExecutable: configRef.claudeBinary }
291
+ : {}),
292
+ // No MCP servers — filesystem tools only
293
+ mcpServers: {},
294
+ disallowedTools: [
295
+ "EnterPlanMode",
296
+ "ExitPlanMode",
297
+ "EnterWorktree",
298
+ "ExitWorktree",
299
+ "TodoWrite",
300
+ "TodoRead",
301
+ "TaskCreate",
302
+ "TaskUpdate",
303
+ "TaskGet",
304
+ "TaskList",
305
+ "TaskOutput",
306
+ "TaskStop",
307
+ "AskUserQuestion",
308
+ "Agent",
309
+ ],
310
+ };
311
+
312
+ // NOTE: The timeout races against the agent promise but cannot abort the
313
+ // underlying Claude subprocess (the Agent SDK does not expose an abort
314
+ // mechanism). On timeout, we still await the agent promise to ensure the
315
+ // running lock is not released while the subprocess is still active.
316
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
317
+ const timeoutPromise = new Promise<never>((_, reject) => {
318
+ timeoutHandle = setTimeout(
319
+ () => reject(new Error("Heartbeat agent timed out")),
320
+ HEARTBEAT_TIMEOUT_MS,
321
+ );
322
+ });
323
+
324
+ const agentPromise = (async () => {
325
+ const qi = query({
326
+ prompt,
327
+ options: options as Parameters<typeof query>[0]["options"],
328
+ });
329
+ for await (const msg of qi) {
330
+ await logHeartbeatMessage(heartbeatLogFile, msg);
331
+ }
332
+ await appendHeartbeatLog(
333
+ heartbeatLogFile,
334
+ `\n---\n**Heartbeat #${runCount} completed at ${new Date().toISOString()}**\n`,
335
+ );
336
+ })();
337
+
338
+ try {
339
+ await Promise.race([agentPromise, timeoutPromise]);
340
+ } catch (err) {
341
+ await appendHeartbeatLog(
342
+ heartbeatLogFile,
343
+ `\n---\n**Heartbeat #${runCount} FAILED at ${new Date().toISOString()}:** ${err}\n`,
344
+ );
345
+ // On timeout, wait for the agent to actually finish before releasing the lock
346
+ // to prevent overlapping heartbeat runs
347
+ await agentPromise.catch(() => {});
348
+ throw err;
349
+ } finally {
350
+ if (timeoutHandle) clearTimeout(timeoutHandle);
351
+ }
352
+
353
+ return heartbeatLogFile;
354
+ }
355
+
356
+ // ── Logging helpers ─────────────────────────────────────────────────────────
357
+
358
+ let heartbeatLogFileSequence = 0;
359
+
360
+ async function createHeartbeatLogFile(): Promise<string> {
361
+ if (!existsSync(HEARTBEAT_LOGS_DIR)) {
362
+ await mkdir(HEARTBEAT_LOGS_DIR, { recursive: true });
363
+ }
364
+ const now = new Date();
365
+ const ts = now.toISOString().replace(/[:.]/g, "-");
366
+ const seq = heartbeatLogFileSequence++;
367
+ return resolve(HEARTBEAT_LOGS_DIR, `heartbeat-${ts}-${seq}.md`);
368
+ }
369
+
370
+ async function appendHeartbeatLog(
371
+ logFile: string,
372
+ text: string,
373
+ ): Promise<void> {
374
+ try {
375
+ await appendFile(logFile, text);
376
+ } catch (err) {
377
+ logError("heartbeat", "Failed to write heartbeat log", err);
378
+ }
379
+ }
380
+
381
+ async function logHeartbeatMessage(
382
+ logFile: string,
383
+ msg: SDKMessage,
384
+ ): Promise<void> {
385
+ try {
386
+ const ts = new Date().toISOString().slice(11, 19);
387
+
388
+ switch (msg.type) {
389
+ case "assistant": {
390
+ const textBlocks = msg.message.content
391
+ .filter((b) => b.type === "text")
392
+ .map((b) => ("text" in b ? (b as { text: string }).text : ""));
393
+ const toolUseBlocks = msg.message.content
394
+ .filter((b) => b.type === "tool_use")
395
+ .map((b) => {
396
+ const tu = b as { name: string; input: unknown };
397
+ return `**Tool call:** \`${tu.name}\`\n\`\`\`json\n${JSON.stringify(tu.input, null, 2)}\n\`\`\``;
398
+ });
399
+
400
+ if (textBlocks.length > 0) {
401
+ await appendHeartbeatLog(
402
+ logFile,
403
+ `\n## [${ts}] Assistant\n${textBlocks.join("\n")}\n`,
404
+ );
405
+ }
406
+ if (toolUseBlocks.length > 0) {
407
+ await appendHeartbeatLog(
408
+ logFile,
409
+ `\n${toolUseBlocks.join("\n\n")}\n`,
410
+ );
411
+ }
412
+ break;
413
+ }
414
+ case "result": {
415
+ const result =
416
+ "result" in msg
417
+ ? (msg as { result: string }).result
418
+ : JSON.stringify(msg);
419
+ const truncated =
420
+ result.length > 2000
421
+ ? result.slice(0, 2000) + "\n... (truncated)"
422
+ : result;
423
+ await appendHeartbeatLog(
424
+ logFile,
425
+ `\n### [${ts}] Result (${msg.subtype})\n\`\`\`\n${truncated}\n\`\`\`\n`,
426
+ );
427
+ break;
428
+ }
429
+ case "system": {
430
+ await appendHeartbeatLog(
431
+ logFile,
432
+ `\n### [${ts}] System (${msg.subtype})\n`,
433
+ );
434
+ break;
435
+ }
436
+ case "user": {
437
+ if (msg.tool_use_result != null) {
438
+ const raw =
439
+ typeof msg.tool_use_result === "string"
440
+ ? msg.tool_use_result
441
+ : JSON.stringify(msg.tool_use_result, null, 2);
442
+ const truncated =
443
+ raw.length > 2000 ? raw.slice(0, 2000) + "\n... (truncated)" : raw;
444
+ await appendHeartbeatLog(
445
+ logFile,
446
+ `\n### [${ts}] Tool Result\n\`\`\`\n${truncated}\n\`\`\`\n`,
447
+ );
448
+ }
449
+ break;
450
+ }
451
+ default:
452
+ break;
453
+ }
454
+ } catch {
455
+ // Don't let logging errors break the heartbeat
456
+ }
457
+ }
458
+
459
+ // ── State helpers ────────────────────────────────────────────────────────────
460
+
461
+ function normalizeHeartbeatState(parsed: unknown): HeartbeatState | null {
462
+ if (!parsed || typeof parsed !== "object") return null;
463
+
464
+ const candidate = parsed as Record<string, unknown>;
465
+ const { last_run, last_run_at, last_started, status, run_count } = candidate;
466
+
467
+ if (typeof last_run !== "number" || !Number.isFinite(last_run)) return null;
468
+ if (typeof run_count !== "number" || !Number.isFinite(run_count)) return null;
469
+ if (status !== "idle" && status !== "running") return null;
470
+ if (last_run_at !== undefined && typeof last_run_at !== "string") return null;
471
+ if (
472
+ last_started !== undefined &&
473
+ (typeof last_started !== "number" || !Number.isFinite(last_started))
474
+ ) {
475
+ return null;
476
+ }
477
+
478
+ return {
479
+ last_run,
480
+ run_count,
481
+ status,
482
+ ...(last_run_at !== undefined ? { last_run_at } : {}),
483
+ ...(last_started !== undefined ? { last_started } : {}),
484
+ };
485
+ }
486
+
487
+ function readHeartbeatState(): HeartbeatState | null {
488
+ try {
489
+ if (!existsSync(HEARTBEAT_STATE_FILE)) return null;
490
+ const raw = readFileSync(HEARTBEAT_STATE_FILE, "utf-8");
491
+ return normalizeHeartbeatState(JSON.parse(raw));
492
+ } catch {
493
+ return null;
494
+ }
495
+ }
496
+
497
+ function writeHeartbeatState(state: HeartbeatState): void {
498
+ try {
499
+ const dir = resolve(HEARTBEAT_STATE_FILE, "..");
500
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
501
+ const { last_run_at: _lastRunAt, ...rest } = state;
502
+ const enriched: HeartbeatState = {
503
+ ...rest,
504
+ ...(state.last_run !== 0
505
+ ? { last_run_at: new Date(state.last_run).toISOString() }
506
+ : {}),
507
+ };
508
+ writeFileAtomic.sync(
509
+ HEARTBEAT_STATE_FILE,
510
+ JSON.stringify(enriched, null, 2) + "\n",
511
+ );
512
+ } catch (err) {
513
+ logError("heartbeat", "Failed to write heartbeat state", err);
514
+ }
515
+ }
@@ -407,7 +407,7 @@ export function getPluginMcpServers(
407
407
 
408
408
  // Resolve tsx from Talon's own node_modules (not cwd which may be ~/.talon/workspace/)
409
409
  const tsxPath = resolve(
410
- import.meta.dirname ?? ".",
410
+ import.meta.dirname,
411
411
  "../../node_modules/tsx/dist/esm/index.mjs",
412
412
  );
413
413
 
@@ -30,13 +30,10 @@ export function enrichGroupPrompt(
30
30
  if (recentMsgs.length <= 1) return prompt;
31
31
 
32
32
  const priorMsgs = recentMsgs.slice(0, -1);
33
- if (priorMsgs.length === 0) return prompt;
34
-
35
33
  const senderName = priorMsgs[0].senderName;
36
34
  const contextLines = priorMsgs
37
35
  .map(
38
- (m) =>
39
- ` [${formatSmartTimestamp(m.timestamp)}] ${m.text.slice(0, 200)}`,
36
+ (m) => ` [${formatSmartTimestamp(m.timestamp)}] ${m.text.slice(0, 200)}`,
40
37
  )
41
38
  .join("\n");
42
39
  return `[${senderName}'s recent messages in this group:\n${contextLines}]\n\n${prompt}`;
package/src/core/pulse.ts CHANGED
@@ -136,9 +136,10 @@ async function pulseChat(chatId: string): Promise<void> {
136
136
 
137
137
  // Get unread messages
138
138
  const recent = getRecentHistory(chatId, 15);
139
- const unread = lastChecked !== undefined
140
- ? recent.filter((m) => m.msgId > lastChecked)
141
- : recent;
139
+ const unread =
140
+ lastChecked !== undefined
141
+ ? recent.filter((m) => m.msgId > lastChecked)
142
+ : recent;
142
143
  if (unread.length === 0) return;
143
144
 
144
145
  const summary = unread
@@ -53,7 +53,9 @@ export function createTeamsActionHandler(
53
53
 
54
54
  case "send_message_with_buttons": {
55
55
  const text = String(body.text ?? "");
56
- const rows = body.rows as Array<Array<{ text: string; url?: string }>> | undefined;
56
+ const rows = body.rows as
57
+ | Array<Array<{ text: string; url?: string }>>
58
+ | undefined;
57
59
  const buttons = rows?.flat().map((b) => ({ text: b.text, url: b.url }));
58
60
  try {
59
61
  const card = buildAdaptiveCard(text, buttons);
@@ -30,11 +30,21 @@ function markdownToCardBody(text: string): CardElement[] {
30
30
  for (const token of tokens) {
31
31
  switch (token.type) {
32
32
  case "heading":
33
- body.push({ type: "TextBlock", text: `**${cleanInline(token.text)}**`, wrap: true, size: "Medium", weight: "Bolder" });
33
+ body.push({
34
+ type: "TextBlock",
35
+ text: `**${cleanInline(token.text)}**`,
36
+ wrap: true,
37
+ size: "Medium",
38
+ weight: "Bolder",
39
+ });
34
40
  break;
35
41
 
36
42
  case "paragraph":
37
- body.push({ type: "TextBlock", text: cleanInline(token.text), wrap: true });
43
+ body.push({
44
+ type: "TextBlock",
45
+ text: cleanInline(token.text),
46
+ wrap: true,
47
+ });
38
48
  break;
39
49
 
40
50
  case "code": {
@@ -79,7 +89,14 @@ function markdownToCardBody(text: string): CardElement[] {
79
89
  style: "accent",
80
90
  cells: header.map((cell) => ({
81
91
  type: "TableCell",
82
- items: [{ type: "TextBlock", text: cleanInline(cell.text), weight: "Bolder", wrap: true }],
92
+ items: [
93
+ {
94
+ type: "TextBlock",
95
+ text: cleanInline(cell.text),
96
+ weight: "Bolder",
97
+ wrap: true,
98
+ },
99
+ ],
83
100
  })),
84
101
  };
85
102
 
@@ -87,7 +104,9 @@ function markdownToCardBody(text: string): CardElement[] {
87
104
  type: "TableRow",
88
105
  cells: row.map((cell) => ({
89
106
  type: "TableCell",
90
- items: [{ type: "TextBlock", text: cleanInline(cell.text), wrap: true }],
107
+ items: [
108
+ { type: "TextBlock", text: cleanInline(cell.text), wrap: true },
109
+ ],
91
110
  })),
92
111
  }));
93
112
 
@@ -106,13 +125,25 @@ function markdownToCardBody(text: string): CardElement[] {
106
125
  body.push({
107
126
  type: "Container",
108
127
  style: "emphasis",
109
- items: [{ type: "TextBlock", text: cleanInline(String(bqToken.text ?? "")), wrap: true, isSubtle: true }],
128
+ items: [
129
+ {
130
+ type: "TextBlock",
131
+ text: cleanInline(String(bqToken.text)),
132
+ wrap: true,
133
+ isSubtle: true,
134
+ },
135
+ ],
110
136
  });
111
137
  break;
112
138
  }
113
139
 
114
140
  case "hr":
115
- body.push({ type: "TextBlock", text: "───────────────────────────────", wrap: false, isSubtle: true });
141
+ body.push({
142
+ type: "TextBlock",
143
+ text: "───────────────────────────────",
144
+ wrap: false,
145
+ isSubtle: true,
146
+ });
116
147
  break;
117
148
 
118
149
  case "space":
@@ -120,8 +151,16 @@ function markdownToCardBody(text: string): CardElement[] {
120
151
 
121
152
  default:
122
153
  // Fallback: render as plain text
123
- if ("text" in token && typeof token.text === "string" && token.text.trim()) {
124
- body.push({ type: "TextBlock", text: cleanInline(token.text), wrap: true });
154
+ if (
155
+ "text" in token &&
156
+ typeof token.text === "string" &&
157
+ token.text.trim()
158
+ ) {
159
+ body.push({
160
+ type: "TextBlock",
161
+ text: cleanInline(token.text),
162
+ wrap: true,
163
+ });
125
164
  }
126
165
  break;
127
166
  }