talon-agent 1.2.0 → 1.4.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 (46) hide show
  1. package/package.json +7 -6
  2. package/prompts/dream.md +6 -2
  3. package/prompts/mempalace.md +57 -0
  4. package/src/__tests__/compose-tools.test.ts +216 -0
  5. package/src/__tests__/cron-store-extended.test.ts +1 -1
  6. package/src/__tests__/dream.test.ts +118 -1
  7. package/src/__tests__/fuzz.test.ts +1 -3
  8. package/src/__tests__/gateway-actions.test.ts +1 -423
  9. package/src/__tests__/gateway-retry.test.ts +0 -4
  10. package/src/__tests__/handlers.test.ts +0 -4
  11. package/src/__tests__/heartbeat.test.ts +3 -0
  12. package/src/__tests__/mempalace-plugin.test.ts +295 -0
  13. package/src/__tests__/plugin.test.ts +169 -0
  14. package/src/__tests__/storage-save-errors.test.ts +1 -1
  15. package/src/__tests__/time.test.ts +1 -1
  16. package/src/__tests__/watchdog.test.ts +1 -3
  17. package/src/__tests__/workspace.test.ts +0 -1
  18. package/src/backend/claude-sdk/index.ts +39 -54
  19. package/src/backend/opencode/index.ts +5 -20
  20. package/src/bootstrap.ts +140 -11
  21. package/src/core/dream.ts +40 -6
  22. package/src/core/gateway-actions.ts +0 -87
  23. package/src/core/plugin.ts +103 -16
  24. package/src/core/tools/bridge.ts +40 -0
  25. package/src/core/tools/chat.ts +52 -0
  26. package/src/core/tools/history.ts +80 -0
  27. package/src/core/tools/index.ts +82 -0
  28. package/src/core/tools/mcp-server.ts +64 -0
  29. package/src/core/tools/media.ts +23 -0
  30. package/src/core/tools/members.ts +46 -0
  31. package/src/core/tools/messaging.ts +300 -0
  32. package/src/core/tools/scheduling.ts +89 -0
  33. package/src/core/tools/stickers.ts +143 -0
  34. package/src/core/tools/types.ts +60 -0
  35. package/src/core/tools/web.ts +26 -0
  36. package/src/frontend/telegram/actions.ts +10 -1
  37. package/src/frontend/telegram/handlers.ts +5 -17
  38. package/src/plugins/github/index.ts +106 -0
  39. package/src/plugins/mempalace/index.ts +147 -0
  40. package/src/plugins/playwright/index.ts +82 -0
  41. package/src/storage/sessions.ts +0 -10
  42. package/src/util/config.ts +31 -1
  43. package/src/util/log.ts +4 -1
  44. package/src/util/paths.ts +9 -0
  45. package/src/backend/claude-sdk/tools.ts +0 -651
  46. package/src/frontend/teams/tools.ts +0 -175
@@ -125,9 +125,6 @@ describe("gateway shared actions", () => {
125
125
  originalFetch = globalThis.fetch;
126
126
  originalEnv = { ...process.env };
127
127
  vi.clearAllMocks();
128
- // Reset env vars used by web_search
129
- delete process.env.TALON_BRAVE_API_KEY;
130
- delete process.env.TALON_SEARXNG_URL;
131
128
  });
132
129
 
133
130
  afterEach(() => {
@@ -276,377 +273,6 @@ describe("gateway shared actions", () => {
276
273
  });
277
274
  });
278
275
 
279
- // ════════════════════════════════════════════════════════════════════════
280
- // web_search
281
- // ════════════════════════════════════════════════════════════════════════
282
-
283
- describe("web_search", () => {
284
- it("returns error for missing query", async () => {
285
- const result = await handleSharedAction({ action: "web_search" }, 123);
286
- expect(result).toEqual({ ok: false, error: "Missing query" });
287
- });
288
-
289
- it("returns error for empty query string", async () => {
290
- const result = await handleSharedAction(
291
- { action: "web_search", query: "" },
292
- 123,
293
- );
294
- expect(result).toEqual({ ok: false, error: "Missing query" });
295
- });
296
-
297
- it("uses Brave API when key is configured", async () => {
298
- process.env.TALON_BRAVE_API_KEY = "test-brave-key";
299
- const mockFetch = vi.fn().mockResolvedValueOnce(
300
- mockResponse({
301
- ok: true,
302
- contentType: "application/json",
303
- json: {
304
- web: {
305
- results: [
306
- {
307
- title: "Result 1",
308
- url: "https://example.com/1",
309
- description: "Description 1",
310
- },
311
- {
312
- title: "Result 2",
313
- url: "https://example.com/2",
314
- description: "Description 2",
315
- },
316
- ],
317
- },
318
- },
319
- }),
320
- );
321
- vi.stubGlobal("fetch", mockFetch);
322
-
323
- const result = await handleSharedAction(
324
- { action: "web_search", query: "test query" },
325
- 123,
326
- );
327
-
328
- expect(result?.ok).toBe(true);
329
- expect(result?.text).toContain("via Brave");
330
- expect(result?.text).toContain("Result 1");
331
- expect(result?.text).toContain("https://example.com/1");
332
- expect(result?.text).toContain("Description 1");
333
- expect(result?.text).toContain("Result 2");
334
-
335
- // Verify Brave API was called correctly
336
- expect(mockFetch).toHaveBeenCalledTimes(1);
337
- const [url, opts] = mockFetch.mock.calls[0];
338
- expect(url).toContain("api.search.brave.com");
339
- expect(url).toContain("q=test%20query");
340
- expect(url).toContain("count=5");
341
- expect(opts.headers["X-Subscription-Token"]).toBe("test-brave-key");
342
- });
343
-
344
- it("respects custom limit for Brave API", async () => {
345
- process.env.TALON_BRAVE_API_KEY = "test-brave-key";
346
- const mockFetch = vi.fn().mockResolvedValueOnce(
347
- mockResponse({
348
- ok: true,
349
- json: {
350
- web: {
351
- results: [{ title: "R", url: "https://r.com", description: "d" }],
352
- },
353
- },
354
- }),
355
- );
356
- vi.stubGlobal("fetch", mockFetch);
357
-
358
- await handleSharedAction(
359
- { action: "web_search", query: "test", limit: 8 },
360
- 123,
361
- );
362
- const [url] = mockFetch.mock.calls[0];
363
- expect(url).toContain("count=8");
364
- });
365
-
366
- it("clamps search limit to 10", async () => {
367
- process.env.TALON_BRAVE_API_KEY = "test-brave-key";
368
- const mockFetch = vi.fn().mockResolvedValueOnce(
369
- mockResponse({
370
- ok: true,
371
- json: {
372
- web: {
373
- results: [{ title: "R", url: "https://r.com", description: "d" }],
374
- },
375
- },
376
- }),
377
- );
378
- vi.stubGlobal("fetch", mockFetch);
379
-
380
- await handleSharedAction(
381
- { action: "web_search", query: "test", limit: 50 },
382
- 123,
383
- );
384
- const [url] = mockFetch.mock.calls[0];
385
- expect(url).toContain("count=10");
386
- });
387
-
388
- it("falls back to SearXNG when Brave returns non-ok", async () => {
389
- process.env.TALON_BRAVE_API_KEY = "test-brave-key";
390
- const mockFetch = vi
391
- .fn()
392
- .mockResolvedValueOnce(mockResponse({ ok: false, status: 429 })) // Brave fails
393
- .mockResolvedValueOnce(
394
- mockResponse({
395
- ok: true,
396
- json: {
397
- results: [
398
- {
399
- title: "SearX Result",
400
- url: "https://searx.example.com",
401
- content: "SearX snippet",
402
- },
403
- ],
404
- },
405
- }),
406
- );
407
- vi.stubGlobal("fetch", mockFetch);
408
-
409
- const result = await handleSharedAction(
410
- { action: "web_search", query: "fallback test" },
411
- 123,
412
- );
413
-
414
- expect(mockFetch).toHaveBeenCalledTimes(2);
415
- expect(result?.ok).toBe(true);
416
- expect(result?.text).toContain("via SearXNG");
417
- expect(result?.text).toContain("SearX Result");
418
- });
419
-
420
- it("falls back to SearXNG when Brave throws an error", async () => {
421
- process.env.TALON_BRAVE_API_KEY = "test-brave-key";
422
- const mockFetch = vi
423
- .fn()
424
- .mockRejectedValueOnce(new Error("network error")) // Brave throws
425
- .mockResolvedValueOnce(
426
- mockResponse({
427
- ok: true,
428
- json: {
429
- results: [
430
- {
431
- title: "Fallback",
432
- url: "https://fb.com",
433
- content: "snippet",
434
- },
435
- ],
436
- },
437
- }),
438
- );
439
- vi.stubGlobal("fetch", mockFetch);
440
-
441
- const result = await handleSharedAction(
442
- { action: "web_search", query: "test" },
443
- 123,
444
- );
445
-
446
- expect(result?.ok).toBe(true);
447
- expect(result?.text).toContain("via SearXNG");
448
- });
449
-
450
- it("uses SearXNG directly when no Brave key", async () => {
451
- // No TALON_BRAVE_API_KEY set
452
- const mockFetch = vi.fn().mockResolvedValueOnce(
453
- mockResponse({
454
- ok: true,
455
- json: {
456
- results: [
457
- {
458
- title: "Direct SearX",
459
- url: "https://searx.com/r",
460
- content: "content here",
461
- },
462
- ],
463
- },
464
- }),
465
- );
466
- vi.stubGlobal("fetch", mockFetch);
467
-
468
- const result = await handleSharedAction(
469
- { action: "web_search", query: "direct" },
470
- 123,
471
- );
472
-
473
- expect(mockFetch).toHaveBeenCalledTimes(1);
474
- const [url] = mockFetch.mock.calls[0];
475
- expect(url).toContain("localhost:8080");
476
- expect(url).toContain("format=json");
477
- expect(result?.ok).toBe(true);
478
- expect(result?.text).toContain("via SearXNG");
479
- });
480
-
481
- it("uses custom SearXNG URL from env", async () => {
482
- process.env.TALON_SEARXNG_URL = "http://my-searx:9090";
483
- const mockFetch = vi.fn().mockResolvedValueOnce(
484
- mockResponse({
485
- ok: true,
486
- json: {
487
- results: [{ title: "T", url: "https://t.com", content: "c" }],
488
- },
489
- }),
490
- );
491
- vi.stubGlobal("fetch", mockFetch);
492
-
493
- await handleSharedAction({ action: "web_search", query: "custom" }, 123);
494
-
495
- const [url] = mockFetch.mock.calls[0];
496
- expect(url).toContain("my-searx:9090");
497
- });
498
-
499
- it("returns 'no results' when both providers fail", async () => {
500
- process.env.TALON_BRAVE_API_KEY = "test-key";
501
- const mockFetch = vi
502
- .fn()
503
- .mockRejectedValueOnce(new Error("brave fail"))
504
- .mockRejectedValueOnce(new Error("searx fail"));
505
- vi.stubGlobal("fetch", mockFetch);
506
-
507
- const result = await handleSharedAction(
508
- { action: "web_search", query: "nothing" },
509
- 123,
510
- );
511
-
512
- expect(result?.ok).toBe(true);
513
- expect(result?.text).toBe('No results for "nothing".');
514
- });
515
-
516
- it("returns 'no results' when both return non-ok", async () => {
517
- process.env.TALON_BRAVE_API_KEY = "test-key";
518
- const mockFetch = vi
519
- .fn()
520
- .mockResolvedValueOnce(mockResponse({ ok: false, status: 500 }))
521
- .mockResolvedValueOnce(mockResponse({ ok: false, status: 503 }));
522
- vi.stubGlobal("fetch", mockFetch);
523
-
524
- const result = await handleSharedAction(
525
- { action: "web_search", query: "failing" },
526
- 123,
527
- );
528
-
529
- expect(result?.ok).toBe(true);
530
- expect(result?.text).toBe('No results for "failing".');
531
- });
532
-
533
- it("returns 'no results' when Brave returns empty results array", async () => {
534
- process.env.TALON_BRAVE_API_KEY = "test-key";
535
- const mockFetch = vi
536
- .fn()
537
- .mockResolvedValueOnce(
538
- mockResponse({ ok: true, json: { web: { results: [] } } }),
539
- )
540
- .mockResolvedValueOnce(
541
- mockResponse({ ok: true, json: { results: [] } }),
542
- );
543
- vi.stubGlobal("fetch", mockFetch);
544
-
545
- const result = await handleSharedAction(
546
- { action: "web_search", query: "empty" },
547
- 123,
548
- );
549
-
550
- expect(result?.ok).toBe(true);
551
- expect(result?.text).toBe('No results for "empty".');
552
- });
553
-
554
- it("handles Brave response with missing web field", async () => {
555
- process.env.TALON_BRAVE_API_KEY = "test-key";
556
- const mockFetch = vi
557
- .fn()
558
- .mockResolvedValueOnce(mockResponse({ ok: true, json: {} })) // no web field
559
- .mockResolvedValueOnce(
560
- mockResponse({
561
- ok: true,
562
- json: {
563
- results: [
564
- { title: "FallbackR", url: "https://f.com", content: "fb" },
565
- ],
566
- },
567
- }),
568
- );
569
- vi.stubGlobal("fetch", mockFetch);
570
-
571
- const result = await handleSharedAction(
572
- { action: "web_search", query: "test" },
573
- 123,
574
- );
575
- expect(result?.text).toContain("via SearXNG");
576
- });
577
-
578
- it("truncates long snippets to 200 chars", async () => {
579
- process.env.TALON_BRAVE_API_KEY = "test-key";
580
- const longDesc = "A".repeat(500);
581
- const mockFetch = vi.fn().mockResolvedValueOnce(
582
- mockResponse({
583
- ok: true,
584
- json: {
585
- web: {
586
- results: [
587
- { title: "Long", url: "https://l.com", description: longDesc },
588
- ],
589
- },
590
- },
591
- }),
592
- );
593
- vi.stubGlobal("fetch", mockFetch);
594
-
595
- const result = await handleSharedAction(
596
- { action: "web_search", query: "long" },
597
- 123,
598
- );
599
- // The snippet should be sliced to 200 chars
600
- expect(result?.text).not.toContain("A".repeat(201));
601
- expect(result?.text).toContain("A".repeat(200));
602
- });
603
-
604
- it("handles missing description in Brave results", async () => {
605
- process.env.TALON_BRAVE_API_KEY = "test-key";
606
- const mockFetch = vi.fn().mockResolvedValueOnce(
607
- mockResponse({
608
- ok: true,
609
- json: {
610
- web: { results: [{ title: "NoDesc", url: "https://nd.com" }] },
611
- },
612
- }),
613
- );
614
- vi.stubGlobal("fetch", mockFetch);
615
-
616
- const result = await handleSharedAction(
617
- { action: "web_search", query: "nodesc" },
618
- 123,
619
- );
620
- expect(result?.ok).toBe(true);
621
- expect(result?.text).toContain("NoDesc");
622
- });
623
-
624
- it("slices SearXNG results to limit", async () => {
625
- const mockFetch = vi.fn().mockResolvedValueOnce(
626
- mockResponse({
627
- ok: true,
628
- json: {
629
- results: Array.from({ length: 20 }, (_, i) => ({
630
- title: `R${i}`,
631
- url: `https://r${i}.com`,
632
- content: `c${i}`,
633
- })),
634
- },
635
- }),
636
- );
637
- vi.stubGlobal("fetch", mockFetch);
638
-
639
- const result = await handleSharedAction(
640
- { action: "web_search", query: "many", limit: 3 },
641
- 123,
642
- );
643
- // Should only contain 3 results (numbered 1-3)
644
- expect(result?.text).toContain("1. R0");
645
- expect(result?.text).toContain("3. R2");
646
- expect(result?.text).not.toContain("4. R3");
647
- });
648
- });
649
-
650
276
  // ════════════════════════════════════════════════════════════════════════
651
277
  // fetch_url
652
278
  // ════════════════════════════════════════════════════════════════════════
@@ -2082,14 +1708,13 @@ describe("gateway shared actions", () => {
2082
1708
  });
2083
1709
  });
2084
1710
 
2085
- // ── Additional branch coverage for fetch_url and web_search ──────────────
1711
+ // ── Additional branch coverage for fetch_url ──────────────────────────────
2086
1712
 
2087
1713
  describe("gateway-actions — additional branch coverage", () => {
2088
1714
  let originalFetch: typeof globalThis.fetch;
2089
1715
 
2090
1716
  beforeEach(() => {
2091
1717
  originalFetch = globalThis.fetch;
2092
- delete process.env.TALON_BRAVE_API_KEY;
2093
1718
  });
2094
1719
 
2095
1720
  afterEach(() => {
@@ -2144,51 +1769,4 @@ describe("gateway-actions — additional branch coverage", () => {
2144
1769
  // Should succeed (downloaded as bin), covering ct ?? "" right side and ct.split("/")[1] ?? "file" right side
2145
1770
  expect(result?.ok).toBe(true);
2146
1771
  });
2147
-
2148
- it("handles search result with missing content field (line 113 r.content ?? '' branch)", async () => {
2149
- const mockFetch = vi.fn().mockResolvedValueOnce({
2150
- ok: true,
2151
- status: 200,
2152
- headers: new Headers(),
2153
- json: async () => ({
2154
- results: [
2155
- { title: "Result", url: "https://example.com", content: undefined },
2156
- ],
2157
- }),
2158
- text: async () => "",
2159
- arrayBuffer: async () => new ArrayBuffer(0),
2160
- } as unknown as Response);
2161
- vi.stubGlobal("fetch", mockFetch);
2162
- process.env.TALON_SEARXNG_URL = "http://localhost:8080";
2163
-
2164
- const result = await handleSharedAction(
2165
- { action: "web_search", query: "test" },
2166
- 123,
2167
- );
2168
- expect(result?.ok).toBe(true);
2169
- // snippet should be "" (from ?? "")
2170
- expect(result?.text).toBeDefined();
2171
- });
2172
-
2173
- it("handles search response with no results array (line 113 data.results ?? [] branch)", async () => {
2174
- const mockFetch = vi.fn().mockResolvedValueOnce({
2175
- ok: true,
2176
- status: 200,
2177
- headers: new Headers(),
2178
- json: async () => ({
2179
- /* no results property */
2180
- }),
2181
- text: async () => "",
2182
- arrayBuffer: async () => new ArrayBuffer(0),
2183
- } as unknown as Response);
2184
- vi.stubGlobal("fetch", mockFetch);
2185
- process.env.TALON_SEARXNG_URL = "http://localhost:8080";
2186
-
2187
- const result = await handleSharedAction(
2188
- { action: "web_search", query: "empty" },
2189
- 123,
2190
- );
2191
- expect(result?.ok).toBe(true);
2192
- expect(result?.text).toContain("No results for");
2193
- });
2194
1772
  });
@@ -244,10 +244,8 @@ describe("withRetry", () => {
244
244
 
245
245
  describe("exhausting all retries", () => {
246
246
  it("throws the last error after 3 total attempts for retryable errors", async () => {
247
- let calls = 0;
248
247
  const networkErr = talonErr("network");
249
248
  const fn = vi.fn(async () => {
250
- calls++;
251
249
  throw networkErr;
252
250
  });
253
251
 
@@ -257,9 +255,7 @@ describe("withRetry", () => {
257
255
  });
258
256
 
259
257
  it("throws the last error after 3 total attempts for overloaded errors", async () => {
260
- let calls = 0;
261
258
  const fn = vi.fn(async () => {
262
- calls++;
263
259
  throw talonErr("overloaded");
264
260
  });
265
261
 
@@ -533,8 +533,6 @@ describe("handleTextMessage — integration via mock Context", () => {
533
533
  });
534
534
 
535
535
  describe("handlePhotoMessage — downloads and enqueues photo", () => {
536
- let restoreFetch: () => void;
537
-
538
536
  beforeEach(() => {
539
537
  // Mock bot.api.getFile for file download
540
538
  mockBot.api.getFile = vi.fn(async () => ({ file_path: "photos/test.jpg" }));
@@ -547,7 +545,6 @@ describe("handlePhotoMessage — downloads and enqueues photo", () => {
547
545
  headers: { get: (_name: string) => null },
548
546
  arrayBuffer: async () => jpegBuf.buffer,
549
547
  }));
550
- restoreFetch = () => {};
551
548
  vi.stubGlobal("fetch", mockFetch);
552
549
 
553
550
  executeMock.mockResolvedValue({
@@ -795,7 +792,6 @@ describe("rate limiting — isUserRateLimited via handleTextMessage", () => {
795
792
  }
796
793
 
797
794
  // 16th message should be rate limited (return early without enqueuing)
798
- const before = executeMock.mock.calls.length;
799
795
  await handleTextMessage(makeCtx(15), mockBot, mockConfig);
800
796
 
801
797
  // Wait to confirm no debounce fires for the 16th chat
@@ -197,6 +197,7 @@ describe("forceHeartbeat", () => {
197
197
 
198
198
  // Make agent throw
199
199
  queryMock.mockImplementationOnce(async function* () {
200
+ yield; // satisfy require-yield
200
201
  throw new Error("Agent exploded");
201
202
  });
202
203
 
@@ -217,6 +218,7 @@ describe("forceHeartbeat", () => {
217
218
  existsSyncMock.mockReturnValue(false);
218
219
 
219
220
  queryMock.mockImplementationOnce(async function* () {
221
+ yield; // satisfy require-yield
220
222
  throw new Error("Boom");
221
223
  });
222
224
 
@@ -340,6 +342,7 @@ describe("awaitCurrentRun", () => {
340
342
  });
341
343
 
342
344
  queryMock.mockImplementationOnce(async function* () {
345
+ yield; // satisfy require-yield
343
346
  await agentPromise;
344
347
  });
345
348