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
@@ -12,15 +12,22 @@ describe("config", () => {
12
12
  function mockFs(
13
13
  configJson: Record<string, unknown> | null,
14
14
  promptFiles: Record<string, string> = {},
15
- workspaceEntries?: { name: string; isDir: boolean; size?: number; children?: { name: string; size: number }[] }[],
15
+ workspaceEntries?: {
16
+ name: string;
17
+ isDir: boolean;
18
+ size?: number;
19
+ children?: { name: string; size: number }[];
20
+ }[],
16
21
  ) {
17
22
  vi.doMock("node:fs", () => ({
18
23
  existsSync: vi.fn((path: string) => {
19
- if (path.includes("config.json") || path.includes("talon.json")) return configJson !== null;
24
+ if (path.includes("config.json") || path.includes("talon.json"))
25
+ return configJson !== null;
20
26
  // .talon directory checks (root, data)
21
27
  if (path.endsWith(".talon") || path.endsWith("/data")) return true;
22
28
  // workspace directory check
23
- if (path.endsWith("workspace") && workspaceEntries !== undefined) return true;
29
+ if (path.endsWith("workspace") && workspaceEntries !== undefined)
30
+ return true;
24
31
  if (typeof path === "string") {
25
32
  for (const key of Object.keys(promptFiles)) {
26
33
  if (path.includes(key)) return true;
@@ -29,7 +36,8 @@ describe("config", () => {
29
36
  return false;
30
37
  }),
31
38
  readFileSync: vi.fn((path: string) => {
32
- if (path.includes("config.json") || path.includes("talon.json")) return JSON.stringify(configJson ?? {});
39
+ if (path.includes("config.json") || path.includes("talon.json"))
40
+ return JSON.stringify(configJson ?? {});
33
41
  for (const [key, val] of Object.entries(promptFiles)) {
34
42
  if (path.includes(key)) return val;
35
43
  }
@@ -268,7 +276,9 @@ describe("config", () => {
268
276
 
269
277
  const { loadConfig } = await import("../util/config.js");
270
278
  const config = loadConfig();
271
- expect(config.systemPrompt).toContain("You are running in terminal mode.");
279
+ expect(config.systemPrompt).toContain(
280
+ "You are running in terminal mode.",
281
+ );
272
282
  });
273
283
 
274
284
  it("loads telegram.md prompt for telegram frontend", async () => {
@@ -287,13 +297,18 @@ describe("config", () => {
287
297
 
288
298
  const { loadConfig } = await import("../util/config.js");
289
299
  const config = loadConfig();
290
- expect(config.systemPrompt).toContain("You are a sharp and helpful AI assistant.");
300
+ expect(config.systemPrompt).toContain(
301
+ "You are a sharp and helpful AI assistant.",
302
+ );
291
303
  });
292
304
 
293
305
  it("custom.md overrides base.md", async () => {
294
306
  mockFs(
295
307
  { frontend: "terminal" },
296
- { "custom.md": "Custom prompt override.", "base.md": "Default base prompt." },
308
+ {
309
+ "custom.md": "Custom prompt override.",
310
+ "base.md": "Default base prompt.",
311
+ },
297
312
  );
298
313
 
299
314
  const { loadConfig } = await import("../util/config.js");
@@ -330,14 +345,10 @@ describe("config", () => {
330
345
  });
331
346
 
332
347
  it("includes workspace file listing when files exist", async () => {
333
- mockFs(
334
- { frontend: "terminal" },
335
- {},
336
- [
337
- { name: "notes.txt", isDir: false, size: 512 },
338
- { name: "data.csv", isDir: false, size: 2048 },
339
- ],
340
- );
348
+ mockFs({ frontend: "terminal" }, {}, [
349
+ { name: "notes.txt", isDir: false, size: 512 },
350
+ { name: "data.csv", isDir: false, size: 2048 },
351
+ ]);
341
352
 
342
353
  const { loadConfig } = await import("../util/config.js");
343
354
  const config = loadConfig();
@@ -348,16 +359,12 @@ describe("config", () => {
348
359
  });
349
360
 
350
361
  it("skips hidden files and node_modules in workspace listing", async () => {
351
- mockFs(
352
- { frontend: "terminal" },
353
- {},
354
- [
355
- { name: ".hidden", isDir: false, size: 100 },
356
- { name: "node_modules", isDir: true },
357
- { name: "talon.log", isDir: false, size: 500 },
358
- { name: "visible.txt", isDir: false, size: 200 },
359
- ],
360
- );
362
+ mockFs({ frontend: "terminal" }, {}, [
363
+ { name: ".hidden", isDir: false, size: 100 },
364
+ { name: "node_modules", isDir: true },
365
+ { name: "talon.log", isDir: false, size: 500 },
366
+ { name: "visible.txt", isDir: false, size: 200 },
367
+ ]);
361
368
 
362
369
  const { loadConfig } = await import("../util/config.js");
363
370
  const config = loadConfig();
@@ -373,13 +380,9 @@ describe("config", () => {
373
380
  for (let i = 0; i < 10; i++) {
374
381
  manyChildren.push({ name: `file${i}.txt`, size: 100 });
375
382
  }
376
- mockFs(
377
- { frontend: "terminal" },
378
- {},
379
- [
380
- { name: "bigdir", isDir: true, children: manyChildren },
381
- ],
382
- );
383
+ mockFs({ frontend: "terminal" }, {}, [
384
+ { name: "bigdir", isDir: true, children: manyChildren },
385
+ ]);
383
386
 
384
387
  const { loadConfig } = await import("../util/config.js");
385
388
  const config = loadConfig();
@@ -387,26 +390,37 @@ describe("config", () => {
387
390
  });
388
391
 
389
392
  it("lists subdirectory files when 8 or fewer", async () => {
390
- mockFs(
391
- { frontend: "terminal" },
392
- {},
393
- [
394
- {
395
- name: "smalldir",
396
- isDir: true,
397
- children: [
398
- { name: "a.txt", size: 50 },
399
- { name: "b.txt", size: 75 },
400
- ],
401
- },
402
- ],
403
- );
393
+ mockFs({ frontend: "terminal" }, {}, [
394
+ {
395
+ name: "smalldir",
396
+ isDir: true,
397
+ children: [
398
+ { name: "a.txt", size: 50 },
399
+ { name: "b.txt", size: 75 },
400
+ ],
401
+ },
402
+ ]);
404
403
 
405
404
  const { loadConfig } = await import("../util/config.js");
406
405
  const config = loadConfig();
407
406
  expect(config.systemPrompt).toContain("smalldir/a.txt");
408
407
  expect(config.systemPrompt).toContain("smalldir/b.txt");
409
408
  });
409
+
410
+ it("omits empty subdirectory from listing (line 163 FALSE branch: sub.length=0)", async () => {
411
+ // A directory entry with no children → listDir returns [] → sub.length=0
412
+ // → `else if (sub.length > 8)` is FALSE → omitted from listing
413
+ mockFs({ frontend: "terminal" }, {}, [
414
+ { name: "emptydir", isDir: true }, // no children → sub.length = 0
415
+ { name: "notes.txt", isDir: false, size: 100 },
416
+ ]);
417
+
418
+ const { loadConfig } = await import("../util/config.js");
419
+ const config = loadConfig();
420
+ // The empty subdirectory should NOT appear in listing
421
+ expect(config.systemPrompt).not.toContain("emptydir");
422
+ expect(config.systemPrompt).toContain("notes.txt");
423
+ });
410
424
  });
411
425
 
412
426
  describe("getFrontends", () => {
@@ -433,7 +447,8 @@ describe("config", () => {
433
447
  it("does nothing when pluginAdditions is empty", async () => {
434
448
  mockFs({ frontend: "terminal" });
435
449
 
436
- const { loadConfig, rebuildSystemPrompt } = await import("../util/config.js");
450
+ const { loadConfig, rebuildSystemPrompt } =
451
+ await import("../util/config.js");
437
452
  const config = loadConfig();
438
453
  const originalPrompt = config.systemPrompt;
439
454
  rebuildSystemPrompt(config, []);
@@ -443,7 +458,8 @@ describe("config", () => {
443
458
  it("appends plugin prompt additions to system prompt", async () => {
444
459
  mockFs({ frontend: "terminal" });
445
460
 
446
- const { loadConfig, rebuildSystemPrompt } = await import("../util/config.js");
461
+ const { loadConfig, rebuildSystemPrompt } =
462
+ await import("../util/config.js");
447
463
  const config = loadConfig();
448
464
  rebuildSystemPrompt(config, [
449
465
  "## Plugin A\nPlugin A instructions.",
@@ -459,7 +475,8 @@ describe("config", () => {
459
475
  { "terminal.md": "Terminal-specific prompt." },
460
476
  );
461
477
 
462
- const { loadConfig, rebuildSystemPrompt } = await import("../util/config.js");
478
+ const { loadConfig, rebuildSystemPrompt } =
479
+ await import("../util/config.js");
463
480
  const config = loadConfig();
464
481
  rebuildSystemPrompt(config, ["## Test Plugin\nTest addition."]);
465
482
  // Should use terminal (first in array) as the active frontend
@@ -473,12 +490,25 @@ describe("config", () => {
473
490
  { "terminal.md": "Terminal mode active." },
474
491
  );
475
492
 
476
- const { loadConfig, rebuildSystemPrompt } = await import("../util/config.js");
493
+ const { loadConfig, rebuildSystemPrompt } =
494
+ await import("../util/config.js");
477
495
  const config = loadConfig();
478
496
  rebuildSystemPrompt(config, ["## My Plugin\nDo special things."]);
479
497
  expect(config.systemPrompt).toContain("Terminal mode active.");
480
498
  expect(config.systemPrompt).toContain("Do special things.");
481
499
  });
500
+
501
+ it("uses telegram as default frontend when config.frontend is undefined", async () => {
502
+ mockFs({ frontend: "terminal" });
503
+
504
+ const { loadConfig, rebuildSystemPrompt } =
505
+ await import("../util/config.js");
506
+ const config = loadConfig();
507
+ // Force frontend to undefined to trigger the ?? "telegram" fallback on line 132
508
+ (config as Record<string, unknown>).frontend = undefined;
509
+ // Should not throw — uses telegram as default frontend file
510
+ expect(() => rebuildSystemPrompt(config, [])).not.toThrow();
511
+ });
482
512
  });
483
513
 
484
514
  describe("zod validation boundaries", () => {
@@ -525,12 +555,14 @@ describe("config", () => {
525
555
  // Simulate a corrupt JSON by having readFileSync throw
526
556
  vi.doMock("node:fs", () => ({
527
557
  existsSync: vi.fn((path: string) => {
528
- if (path.includes("config.json") || path.includes("talon.json")) return true;
558
+ if (path.includes("config.json") || path.includes("talon.json"))
559
+ return true;
529
560
  if (path.endsWith(".talon") || path.endsWith("/data")) return true;
530
561
  return false;
531
562
  }),
532
563
  readFileSync: vi.fn((path: string) => {
533
- if (path.includes("config.json") || path.includes("talon.json")) throw new Error("corrupt file");
564
+ if (path.includes("config.json") || path.includes("talon.json"))
565
+ throw new Error("corrupt file");
534
566
  return "";
535
567
  }),
536
568
  mkdirSync: vi.fn(),
@@ -544,3 +576,37 @@ describe("config", () => {
544
576
  });
545
577
  });
546
578
  });
579
+
580
+ describe("loadConfig — teams webhook validation", () => {
581
+ beforeEach(() => {
582
+ vi.resetModules();
583
+ });
584
+
585
+ it("throws when teams frontend has no teamsWebhookUrl", async () => {
586
+ vi.doMock("../util/log.js", () => ({
587
+ log: vi.fn(),
588
+ logError: vi.fn(),
589
+ logWarn: vi.fn(),
590
+ logDebug: vi.fn(),
591
+ }));
592
+ vi.doMock("write-file-atomic", () => ({ default: { sync: vi.fn() } }));
593
+ vi.doMock("node:fs", () => ({
594
+ existsSync: vi.fn((path: string) => {
595
+ if (path.includes("config.json")) return true;
596
+ return false;
597
+ }),
598
+ readFileSync: vi.fn(() =>
599
+ JSON.stringify({
600
+ frontend: "teams",
601
+ // teamsWebhookUrl intentionally omitted
602
+ }),
603
+ ),
604
+ mkdirSync: vi.fn(),
605
+ readdirSync: vi.fn(() => []),
606
+ statSync: vi.fn(() => ({ size: 0 })),
607
+ }));
608
+
609
+ const { loadConfig } = await import("../util/config.js");
610
+ expect(() => loadConfig()).toThrow("teamsWebhookUrl");
611
+ });
612
+ });