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,256 @@
1
+ /**
2
+ * Tests for workspace migrateLayout, identity seeding, and prompt seeding.
3
+ * Uses temp directories and mocks process.cwd() + os.homedir().
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
7
+ import {
8
+ mkdirSync,
9
+ writeFileSync,
10
+ rmSync,
11
+ existsSync,
12
+ readFileSync,
13
+ } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ const TEST_ROOT = join(tmpdir(), `talon-migrate-test-${Date.now()}`);
18
+ const OLD_WORKSPACE = join(TEST_ROOT, "workspace");
19
+ const NEW_ROOT = join(TEST_ROOT, ".talon");
20
+
21
+ beforeEach(() => {
22
+ vi.resetModules();
23
+ if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
24
+ mkdirSync(TEST_ROOT, { recursive: true });
25
+
26
+ vi.doMock("node:os", async (importOriginal) => {
27
+ const actual = (await importOriginal()) as Record<string, unknown>;
28
+ return { ...actual, homedir: () => TEST_ROOT };
29
+ });
30
+ vi.doMock("../util/log.js", () => ({
31
+ log: vi.fn(),
32
+ logError: vi.fn(),
33
+ logWarn: vi.fn(),
34
+ logDebug: vi.fn(),
35
+ }));
36
+ });
37
+
38
+ afterEach(() => {
39
+ if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
40
+ });
41
+
42
+ describe("migrateLayout", () => {
43
+ it("is a no-op when workspace/ does not exist", async () => {
44
+ const { migrateLayout } = await import("../util/workspace.js");
45
+ expect(() => migrateLayout()).not.toThrow();
46
+ // workspace/sessions.json should NOT have been created since migration never ran
47
+ expect(existsSync(join(NEW_ROOT, "data", "sessions.json"))).toBe(false);
48
+ });
49
+
50
+ it("is a no-op when .talon/ already exists", async () => {
51
+ mkdirSync(OLD_WORKSPACE, { recursive: true });
52
+ mkdirSync(NEW_ROOT, { recursive: true });
53
+
54
+ const { migrateLayout } = await import("../util/workspace.js");
55
+ expect(() => migrateLayout()).not.toThrow();
56
+ // workspace/ should still exist — migration was skipped
57
+ expect(existsSync(OLD_WORKSPACE)).toBe(true);
58
+ });
59
+
60
+ it("migrates files from workspace/ to .talon/ layout", async () => {
61
+ mkdirSync(OLD_WORKSPACE, { recursive: true });
62
+ writeFileSync(join(OLD_WORKSPACE, "sessions.json"), '{"chat1":{}}');
63
+ writeFileSync(join(OLD_WORKSPACE, "history.json"), "{}");
64
+ writeFileSync(join(OLD_WORKSPACE, "talon.json"), '{"frontend":"telegram"}');
65
+
66
+ const originalCwd = process.cwd;
67
+ process.cwd = () => TEST_ROOT;
68
+
69
+ try {
70
+ const { migrateLayout } = await import("../util/workspace.js");
71
+ migrateLayout();
72
+
73
+ const dataDir = join(NEW_ROOT, "data");
74
+ expect(existsSync(join(dataDir, "sessions.json"))).toBe(true);
75
+ expect(existsSync(join(dataDir, "history.json"))).toBe(true);
76
+ expect(existsSync(join(NEW_ROOT, "config.json"))).toBe(true);
77
+ // Original files should be gone
78
+ expect(existsSync(join(OLD_WORKSPACE, "sessions.json"))).toBe(false);
79
+ } finally {
80
+ process.cwd = originalCwd;
81
+ }
82
+ });
83
+
84
+ it("migrates directories from workspace/ to .talon/workspace/ layout", async () => {
85
+ mkdirSync(OLD_WORKSPACE, { recursive: true });
86
+ const memoryDir = join(OLD_WORKSPACE, "memory");
87
+ mkdirSync(memoryDir);
88
+ writeFileSync(join(memoryDir, "notes.md"), "# Memory");
89
+
90
+ const originalCwd = process.cwd;
91
+ process.cwd = () => TEST_ROOT;
92
+
93
+ try {
94
+ const { migrateLayout } = await import("../util/workspace.js");
95
+ migrateLayout();
96
+
97
+ const newMemory = join(NEW_ROOT, "workspace", "memory");
98
+ expect(existsSync(newMemory)).toBe(true);
99
+ expect(existsSync(join(newMemory, "notes.md"))).toBe(true);
100
+ } finally {
101
+ process.cwd = originalCwd;
102
+ }
103
+ });
104
+
105
+ it("removes empty workspace/ after migration", async () => {
106
+ mkdirSync(OLD_WORKSPACE, { recursive: true });
107
+ // No files — workspace/ is empty
108
+
109
+ const originalCwd = process.cwd;
110
+ process.cwd = () => TEST_ROOT;
111
+
112
+ try {
113
+ const { migrateLayout } = await import("../util/workspace.js");
114
+ migrateLayout();
115
+
116
+ // Empty workspace/ should be removed
117
+ expect(existsSync(OLD_WORKSPACE)).toBe(false);
118
+ } finally {
119
+ process.cwd = originalCwd;
120
+ }
121
+ });
122
+
123
+ it("falls back to copy+delete when renameSync throws (cross-filesystem simulation)", async () => {
124
+ mkdirSync(OLD_WORKSPACE, { recursive: true });
125
+ writeFileSync(join(OLD_WORKSPACE, "sessions.json"), '{"chat1":{}}');
126
+ const memDir = join(OLD_WORKSPACE, "memory");
127
+ mkdirSync(memDir);
128
+ writeFileSync(join(memDir, "notes.md"), "# notes");
129
+
130
+ const originalCwd = process.cwd;
131
+ process.cwd = () => TEST_ROOT;
132
+
133
+ // Override renameSync to simulate cross-device link error
134
+ vi.doMock("node:fs", async (importOriginal) => {
135
+ const actual = await importOriginal<typeof import("node:fs")>();
136
+ return {
137
+ ...actual,
138
+ renameSync: vi.fn(() => {
139
+ throw Object.assign(
140
+ new Error("EXDEV: cross-device link not permitted"),
141
+ { code: "EXDEV" },
142
+ );
143
+ }),
144
+ };
145
+ });
146
+
147
+ try {
148
+ const { migrateLayout } = await import("../util/workspace.js");
149
+ migrateLayout();
150
+
151
+ // File was copied via copyFileSync fallback (line 57)
152
+ expect(existsSync(join(NEW_ROOT, "data", "sessions.json"))).toBe(true);
153
+ // Directory was copied via cpSync fallback (line 81)
154
+ expect(
155
+ existsSync(join(NEW_ROOT, "workspace", "memory", "notes.md")),
156
+ ).toBe(true);
157
+ } finally {
158
+ process.cwd = originalCwd;
159
+ }
160
+ });
161
+
162
+ it("leaves workspace/ when non-migration files remain after migration", async () => {
163
+ mkdirSync(OLD_WORKSPACE, { recursive: true });
164
+ // A file that is NOT in the migration list
165
+ writeFileSync(join(OLD_WORKSPACE, "unknown-extra-file.txt"), "extra");
166
+
167
+ const originalCwd = process.cwd;
168
+ process.cwd = () => TEST_ROOT;
169
+
170
+ try {
171
+ const { migrateLayout } = await import("../util/workspace.js");
172
+ migrateLayout();
173
+
174
+ // workspace/ should still exist since it's not empty
175
+ expect(existsSync(OLD_WORKSPACE)).toBe(true);
176
+ expect(existsSync(join(OLD_WORKSPACE, "unknown-extra-file.txt"))).toBe(
177
+ true,
178
+ );
179
+ } finally {
180
+ process.cwd = originalCwd;
181
+ }
182
+ });
183
+ });
184
+
185
+ describe("initWorkspace — identity and prompt seeding", () => {
186
+ it("creates identity.md when it does not exist", async () => {
187
+ const originalCwd = process.cwd;
188
+ process.cwd = () => TEST_ROOT;
189
+
190
+ try {
191
+ const { initWorkspace } = await import("../util/workspace.js");
192
+ initWorkspace(join(TEST_ROOT, "ws"));
193
+
194
+ // identity.md is at ~/.talon/workspace/identity.md
195
+ const identityFile = join(NEW_ROOT, "workspace", "identity.md");
196
+ expect(existsSync(identityFile)).toBe(true);
197
+ const content = readFileSync(identityFile, "utf-8");
198
+ expect(content).toContain("Identity");
199
+ } finally {
200
+ process.cwd = originalCwd;
201
+ }
202
+ });
203
+
204
+ it("seeds .md prompt files from prompts/ directory", async () => {
205
+ // prompts/ is resolved relative to process.cwd()
206
+ const promptsDir = join(TEST_ROOT, "prompts");
207
+ mkdirSync(promptsDir, { recursive: true });
208
+ writeFileSync(join(promptsDir, "system.md"), "# System Prompt");
209
+ writeFileSync(join(promptsDir, "dream.md"), "# Dream Prompt");
210
+ writeFileSync(join(promptsDir, "not-a-prompt.txt"), "ignored");
211
+
212
+ const originalCwd = process.cwd;
213
+ process.cwd = () => TEST_ROOT;
214
+
215
+ try {
216
+ const { initWorkspace } = await import("../util/workspace.js");
217
+ initWorkspace(join(TEST_ROOT, "ws"));
218
+
219
+ // prompts are seeded to ~/.talon/prompts/
220
+ const talonPromptsDir = join(NEW_ROOT, "prompts");
221
+ expect(existsSync(join(talonPromptsDir, "system.md"))).toBe(true);
222
+ expect(existsSync(join(talonPromptsDir, "dream.md"))).toBe(true);
223
+ // .txt file should NOT be copied
224
+ expect(existsSync(join(talonPromptsDir, "not-a-prompt.txt"))).toBe(false);
225
+ } finally {
226
+ process.cwd = originalCwd;
227
+ }
228
+ });
229
+
230
+ it("does not overwrite existing prompt files", async () => {
231
+ const promptsDir = join(TEST_ROOT, "prompts");
232
+ mkdirSync(promptsDir, { recursive: true });
233
+ writeFileSync(join(promptsDir, "custom.md"), "# Package version");
234
+
235
+ const talonPromptsDir = join(NEW_ROOT, "prompts");
236
+ mkdirSync(talonPromptsDir, { recursive: true });
237
+ writeFileSync(
238
+ join(talonPromptsDir, "custom.md"),
239
+ "# User customized version",
240
+ );
241
+
242
+ const originalCwd = process.cwd;
243
+ process.cwd = () => TEST_ROOT;
244
+
245
+ try {
246
+ const { initWorkspace } = await import("../util/workspace.js");
247
+ initWorkspace(join(TEST_ROOT, "ws"));
248
+
249
+ // User version should be preserved
250
+ const content = readFileSync(join(talonPromptsDir, "custom.md"), "utf-8");
251
+ expect(content).toBe("# User customized version");
252
+ } finally {
253
+ process.cwd = originalCwd;
254
+ }
255
+ });
256
+ });
@@ -1,5 +1,13 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
- import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs";
2
+ import {
3
+ mkdirSync,
4
+ writeFileSync,
5
+ rmSync,
6
+ existsSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ symlinkSync,
10
+ } from "node:fs";
3
11
  import { join } from "node:path";
4
12
  import { tmpdir } from "node:os";
5
13
 
@@ -17,6 +25,7 @@ import {
17
25
  cleanupUploads,
18
26
  startUploadCleanup,
19
27
  stopUploadCleanup,
28
+ migrateLayout,
20
29
  } from "../util/workspace.js";
21
30
 
22
31
  const TEST_ROOT = join(tmpdir(), `talon-ws-test-${Date.now()}`);
@@ -182,3 +191,56 @@ describe("startUploadCleanup / stopUploadCleanup", () => {
182
191
  expect(() => stopUploadCleanup()).not.toThrow();
183
192
  });
184
193
  });
194
+
195
+ describe("migrateLayout", () => {
196
+ it("is a no-op when workspace/ directory does not exist", () => {
197
+ // No workspace/ dir → should not throw or create anything
198
+ expect(() => migrateLayout()).not.toThrow();
199
+ });
200
+
201
+ it("is a no-op when .talon/ already exists", () => {
202
+ // Even if workspace/ exists, skip migration if .talon/ already there
203
+ expect(() => migrateLayout()).not.toThrow();
204
+ });
205
+ });
206
+
207
+ describe("getWorkspaceDiskUsage — edge cases", () => {
208
+ it("returns 0 for non-existent directory", () => {
209
+ expect(getWorkspaceDiskUsage("/non/existent/path/xyz123")).toBe(0);
210
+ });
211
+
212
+ it("counts multiple files correctly", () => {
213
+ mkdirSync(TEST_ROOT, { recursive: true });
214
+ writeFileSync(join(TEST_ROOT, "a.txt"), "12345"); // 5 bytes
215
+ writeFileSync(join(TEST_ROOT, "b.txt"), "123"); // 3 bytes
216
+ const usage = getWorkspaceDiskUsage(TEST_ROOT);
217
+ expect(usage).toBe(8);
218
+ });
219
+
220
+ it("skips symlinks — entry.isFile() FALSE branch (L147)", () => {
221
+ mkdirSync(TEST_ROOT, { recursive: true });
222
+ writeFileSync(join(TEST_ROOT, "real.txt"), "hello"); // 5 bytes
223
+ // symlink: isDirectory()=false, isFile()=false → skipped by walk
224
+ symlinkSync(join(TEST_ROOT, "real.txt"), join(TEST_ROOT, "link.txt"));
225
+ const usage = getWorkspaceDiskUsage(TEST_ROOT);
226
+ // Only real.txt counts (5 bytes); symlink is not counted
227
+ expect(usage).toBe(5);
228
+ });
229
+ });
230
+
231
+ describe("startUploadCleanup — setInterval callback (function coverage)", () => {
232
+ it("fires periodic cleanup via setInterval arrow function", () => {
233
+ vi.useFakeTimers();
234
+ mkdirSync(TEST_ROOT, { recursive: true });
235
+ mkdirSync(join(TEST_ROOT, "uploads"), { recursive: true });
236
+ writeFileSync(join(TEST_ROOT, "uploads", "file.jpg"), "data");
237
+
238
+ startUploadCleanup(TEST_ROOT);
239
+ // Advance past CLEANUP_INTERVAL_MS (1 hour) to fire the setInterval callback
240
+ vi.advanceTimersByTime(60 * 60 * 1000 + 1);
241
+ stopUploadCleanup();
242
+
243
+ vi.useRealTimers();
244
+ // Just verify no error was thrown — the arrow function was exercised
245
+ });
246
+ });
@@ -113,7 +113,10 @@ Examples:
113
113
  first_name: z.string().optional().describe("Contact first name"),
114
114
  last_name: z.string().optional().describe("Contact last name"),
115
115
  title: z.string().optional().describe("Audio title (for type=audio)"),
116
- performer: z.string().optional().describe("Audio performer/artist (for type=audio)"),
116
+ performer: z
117
+ .string()
118
+ .optional()
119
+ .describe("Audio performer/artist (for type=audio)"),
117
120
  emoji: z.string().optional().describe("Dice emoji (🎲🎯🏀⚽🎳🎰)"),
118
121
  delay_seconds: z
119
122
  .number()
@@ -370,7 +373,9 @@ server.tool(
370
373
  "download_media",
371
374
  "Download a photo, document, or other media from a message by its ID. Saves the file to the workspace and returns the file path so you can read/analyze it. Use this when you see a [photo] or [document] in chat history but don't have the file.",
372
375
  {
373
- message_id: z.number().describe("Message ID containing the media to download"),
376
+ message_id: z
377
+ .number()
378
+ .describe("Message ID containing the media to download"),
374
379
  },
375
380
  async (params) => textResult(await callBridge("download_media", params)),
376
381
  );
@@ -379,7 +384,11 @@ server.tool(
379
384
  "get_sticker_pack",
380
385
  "Get all stickers in a sticker pack by its name. Returns emoji + file_id for each sticker so you can send them. Use when you see a sticker set name in chat history.",
381
386
  {
382
- set_name: z.string().describe("Sticker set name (e.g. 'AnimatedEmojies' or from sticker metadata)"),
387
+ set_name: z
388
+ .string()
389
+ .describe(
390
+ "Sticker set name (e.g. 'AnimatedEmojies' or from sticker metadata)",
391
+ ),
383
392
  },
384
393
  async (params) => textResult(await callBridge("get_sticker_pack", params)),
385
394
  );
@@ -388,7 +397,9 @@ server.tool(
388
397
  "download_sticker",
389
398
  "Download a sticker image to workspace so you can view its contents. Returns the file path.",
390
399
  {
391
- file_id: z.string().describe("Sticker file_id from chat history or sticker pack listing"),
400
+ file_id: z
401
+ .string()
402
+ .describe("Sticker file_id from chat history or sticker pack listing"),
392
403
  },
393
404
  async (params) => textResult(await callBridge("download_sticker", params)),
394
405
  );
@@ -437,10 +448,19 @@ Type "message" sends the content as a text message.
437
448
  Type "query" runs the content as a Claude prompt with full tool access (can search, create files, send messages, etc).`,
438
449
  {
439
450
  name: z.string().describe("Human-readable name for the job"),
440
- schedule: z.string().describe("Cron expression (5-field: minute hour day month weekday)"),
441
- type: z.enum(["message", "query"]).describe("Job type: 'message' sends text, 'query' runs a Claude prompt"),
451
+ schedule: z
452
+ .string()
453
+ .describe("Cron expression (5-field: minute hour day month weekday)"),
454
+ type: z
455
+ .enum(["message", "query"])
456
+ .describe("Job type: 'message' sends text, 'query' runs a Claude prompt"),
442
457
  content: z.string().describe("Message text or query prompt"),
443
- timezone: z.string().optional().describe("IANA timezone (e.g. 'America/New_York'). Defaults to system timezone."),
458
+ timezone: z
459
+ .string()
460
+ .optional()
461
+ .describe(
462
+ "IANA timezone (e.g. 'America/New_York'). Defaults to system timezone.",
463
+ ),
444
464
  },
445
465
  async (params) => textResult(await callBridge("create_cron_job", params)),
446
466
  );
@@ -496,11 +516,23 @@ The set name will automatically get "_by_<botname>" appended if needed.
496
516
  Example: create_sticker_set(user_id=123, name="cool_pack", title="Cool Stickers", file_path="/path/to/sticker.png", emoji_list=["😎"])`,
497
517
  {
498
518
  user_id: z.number().describe("Telegram user ID who will own the pack"),
499
- name: z.string().describe("Short name for the pack (a-z, 0-9, underscores). Will get _by_<botname> appended."),
519
+ name: z
520
+ .string()
521
+ .describe(
522
+ "Short name for the pack (a-z, 0-9, underscores). Will get _by_<botname> appended.",
523
+ ),
500
524
  title: z.string().describe("Display title for the pack (1-64 chars)"),
501
- file_path: z.string().describe("Path to the sticker image file (PNG/WEBP, 512x512 max)"),
502
- emoji_list: z.array(z.string()).optional().describe("Emojis for this sticker (default: ['🎨'])"),
503
- format: z.enum(["static", "animated", "video"]).optional().describe("Sticker format (default: static)"),
525
+ file_path: z
526
+ .string()
527
+ .describe("Path to the sticker image file (PNG/WEBP, 512x512 max)"),
528
+ emoji_list: z
529
+ .array(z.string())
530
+ .optional()
531
+ .describe("Emojis for this sticker (default: ['🎨'])"),
532
+ format: z
533
+ .enum(["static", "animated", "video"])
534
+ .optional()
535
+ .describe("Sticker format (default: static)"),
504
536
  },
505
537
  async (params) => textResult(await callBridge("create_sticker_set", params)),
506
538
  );
@@ -512,8 +544,14 @@ server.tool(
512
544
  user_id: z.number().describe("Telegram user ID who owns the pack"),
513
545
  name: z.string().describe("Sticker set name (including _by_<botname>)"),
514
546
  file_path: z.string().describe("Path to the sticker image file"),
515
- emoji_list: z.array(z.string()).optional().describe("Emojis for this sticker (default: ['🎨'])"),
516
- format: z.enum(["static", "animated", "video"]).optional().describe("Sticker format (default: static)"),
547
+ emoji_list: z
548
+ .array(z.string())
549
+ .optional()
550
+ .describe("Emojis for this sticker (default: ['🎨'])"),
551
+ format: z
552
+ .enum(["static", "animated", "video"])
553
+ .optional()
554
+ .describe("Sticker format (default: static)"),
517
555
  },
518
556
  async (params) => textResult(await callBridge("add_sticker_to_set", params)),
519
557
  );
@@ -522,9 +560,12 @@ server.tool(
522
560
  "delete_sticker_from_set",
523
561
  "Remove a specific sticker from a pack by its file_id.",
524
562
  {
525
- sticker_file_id: z.string().describe("file_id of the sticker to remove (get from get_sticker_pack)"),
563
+ sticker_file_id: z
564
+ .string()
565
+ .describe("file_id of the sticker to remove (get from get_sticker_pack)"),
526
566
  },
527
- async (params) => textResult(await callBridge("delete_sticker_from_set", params)),
567
+ async (params) =>
568
+ textResult(await callBridge("delete_sticker_from_set", params)),
528
569
  );
529
570
 
530
571
  server.tool(
@@ -534,7 +575,8 @@ server.tool(
534
575
  name: z.string().describe("Sticker set name"),
535
576
  title: z.string().describe("New title (1-64 chars)"),
536
577
  },
537
- async (params) => textResult(await callBridge("set_sticker_set_title", params)),
578
+ async (params) =>
579
+ textResult(await callBridge("set_sticker_set_title", params)),
538
580
  );
539
581
 
540
582
  server.tool(
@@ -566,9 +608,13 @@ server.tool(
566
608
  "list_media",
567
609
  "List recent photos, documents, and other media in the current chat with file paths. Use this to find a previously sent photo or file to re-read or reference.",
568
610
  {
569
- limit: z.number().optional().describe("Number of entries (default 10, max 20)"),
611
+ limit: z
612
+ .number()
613
+ .optional()
614
+ .describe("Number of entries (default 10, max 20)"),
570
615
  },
571
- async (params) => textResult(await callBridge("list_media", { limit: params.limit })),
616
+ async (params) =>
617
+ textResult(await callBridge("list_media", { limit: params.limit })),
572
618
  );
573
619
 
574
620
  // ── Web ─────────────────────────────────────────────────────────────────────
package/src/bootstrap.ts CHANGED
@@ -21,6 +21,7 @@ import { initDispatcher } from "./core/dispatcher.js";
21
21
  import { initPulse, resetPulseTimer } from "./core/pulse.js";
22
22
  import { initCron } from "./core/cron.js";
23
23
  import { initDream } from "./core/dream.js";
24
+ import { initHeartbeat } from "./core/heartbeat.js";
24
25
  import { log } from "./util/log.js";
25
26
  import type { TalonConfig } from "./util/config.js";
26
27
  import type { QueryBackend, ContextManager } from "./core/types.js";
@@ -63,14 +64,11 @@ export async function bootstrap(
63
64
 
64
65
  // Load plugins (external tool packages)
65
66
  if (config.plugins.length > 0) {
66
- const { loadPlugins, getPluginPromptAdditions } = await import(
67
- "./core/plugin.js"
68
- );
67
+ const { loadPlugins, getPluginPromptAdditions } =
68
+ await import("./core/plugin.js");
69
69
  const frontends =
70
70
  options.frontendNames ??
71
- (Array.isArray(config.frontend)
72
- ? config.frontend
73
- : [config.frontend]);
71
+ (Array.isArray(config.frontend) ? config.frontend : [config.frontend]);
74
72
  await loadPlugins(config.plugins, frontends);
75
73
  rebuildSystemPrompt(config, getPluginPromptAdditions());
76
74
  }
@@ -99,18 +97,14 @@ export async function initBackendAndDispatcher(
99
97
  let backend: QueryBackend;
100
98
 
101
99
  if (config.backend === "opencode") {
102
- const {
103
- initOpenCodeAgent,
104
- handleMessage: opencodeHandleMessage,
105
- } = await import("./backend/opencode/index.js");
100
+ const { initOpenCodeAgent, handleMessage: opencodeHandleMessage } =
101
+ await import("./backend/opencode/index.js");
106
102
  initOpenCodeAgent(config, frontend.getBridgePort);
107
103
  backend = { query: (params) => opencodeHandleMessage(params) };
108
104
  log("bot", "Backend: OpenCode");
109
105
  } else {
110
- const {
111
- initAgent: initClaudeAgent,
112
- handleMessage: claudeHandleMessage,
113
- } = await import("./backend/claude-sdk/index.js");
106
+ const { initAgent: initClaudeAgent, handleMessage: claudeHandleMessage } =
107
+ await import("./backend/claude-sdk/index.js");
114
108
  initClaudeAgent(config, frontend.getBridgePort);
115
109
  backend = { query: (params) => claudeHandleMessage(params) };
116
110
  log("bot", "Backend: Claude SDK");
@@ -131,4 +125,10 @@ export async function initBackendAndDispatcher(
131
125
  claudeBinary: config.claudeBinary,
132
126
  workspace: config.workspace,
133
127
  });
128
+ initHeartbeat({
129
+ model: config.model,
130
+ heartbeatModel: config.heartbeatModel,
131
+ claudeBinary: config.claudeBinary,
132
+ workspace: config.workspace,
133
+ });
134
134
  }