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
|
@@ -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
|
+
}
|
package/src/core/plugin.ts
CHANGED
|
@@ -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 =
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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({
|
|
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({
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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({
|
|
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 (
|
|
124
|
-
|
|
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
|
}
|