talon-agent 3.27.0 → 3.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.27.0",
3
+ "version": "3.28.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "chatbot"
19
19
  ],
20
20
  "engines": {
21
- "node": ">=24"
21
+ "node": ">=24.15"
22
22
  },
23
23
  "type": "module",
24
24
  "main": "./src/index.ts",
@@ -40,7 +40,7 @@ is a primary responsibility, not an optional extra. Goal tools
40
40
  (`update_goal`, `list_goals`, …) require an explicit `chat_id` parameter
41
41
  in heartbeat mode — use the chat id shown next to each goal.
42
42
 
43
- OUTBOUND MESSAGING: You also have access to the frontend tool servers — {{toolList}} — which expose `send`, `react`, and the rest of the messaging surface. Because there is NO ambient chat in heartbeat mode, every outbound tool call MUST include an explicit `chat_id` parameter. The bridge promotes that chat_id to the routing target, so `send(type="text", text="...", chat_id=N)` from `{{exampleFrontend}}-tools` delivers a message to chat N on that frontend. Known chat IDs live in your memory.md (per-frontend — for Telegram, Dylan's DM ID and group IDs are recorded; other frontends list their own). Without `chat_id`, the gateway returns 'No active chat context and no explicit numeric chat_id'.
43
+ OUTBOUND MESSAGING: You also have access to the frontend tool servers — {{toolList}} — which expose `send`, `react`, and the rest of the messaging surface. Because there is NO ambient chat in heartbeat mode, every outbound tool call MUST include an explicit `chat_id` parameter. The bridge promotes that chat_id to the routing target, so `send(type="text", text="...", chat_id=N)` from `{{exampleFrontend}}-tools` delivers a message to chat N on that frontend. Known chat IDs live in your memory.md (per-frontend — for Telegram, Dylan's DM ID and group IDs are recorded; other frontends list their own). Without `chat_id`, the gateway returns 'No active chat context and no explicit numeric chat_id'. Cross-frontend: every tool server also carries `send_via(frontend=..., target=..., text=...)`, which delivers through ANY enabled frontend — target is the numeric chat id as a string, or for WhatsApp a phone number ("+353871234567") or wa_dm_/wa_group_ id.
44
44
 
45
45
  Reaching out is part of the job, not an exception: when a run turns up
46
46
  something a user would genuinely want to know — a goal completed or
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Cross-frontend send — the server side of the `send_via` tool.
3
+ *
4
+ * A chat-free shared action: any session (an active Telegram chat, the
5
+ * heartbeat, a dream run) delivers a plain text message through ANY
6
+ * enabled messaging frontend by naming it explicitly. Explicit is the
7
+ * point — the gateway's normal chat_id routing infers the owning
8
+ * frontend from the numeric id's shape, and WhatsApp's hash-derived ids
9
+ * also match the Telegram matcher, so a cross-frontend send routed that
10
+ * way lands on the wrong platform once the originating turn's context
11
+ * is cleared.
12
+ *
13
+ * Core never imports src/frontend (dependency-cruiser enforces it), so
14
+ * dispatch goes through a broker: `Gateway.registerFrontendHandler`
15
+ * mirrors each frontend's action handler here, and the action calls the
16
+ * target's own `send_message` — the one send action every messaging
17
+ * frontend implements. A numeric target doubles as the handler's chatId
18
+ * key, so id-addressed frontends (telegram, discord, teams, native)
19
+ * need no changes; non-numeric forms (WhatsApp phone numbers, wa_* ids)
20
+ * ride in `body.target` for the frontend's adapter to resolve.
21
+ */
22
+
23
+ import type { FrontendActionHandler } from "../../types.js";
24
+ import type { SharedActionHandlers } from "./types.js";
25
+
26
+ const targets = new Map<string, FrontendActionHandler>();
27
+
28
+ /**
29
+ * Broker registration — called by `Gateway.registerFrontendHandler` as
30
+ * each frontend wires up (and with null on deregistration), so the set
31
+ * of reachable targets is exactly the set of enabled frontends.
32
+ */
33
+ export function registerCrossSendTarget(
34
+ name: string,
35
+ handler: FrontendActionHandler | null,
36
+ ): void {
37
+ if (handler === null) targets.delete(name);
38
+ else targets.set(name, handler);
39
+ }
40
+
41
+ export const crossSendHandlers: SharedActionHandlers = {
42
+ send_via: async (body) => {
43
+ const frontend = String(body.frontend ?? "")
44
+ .trim()
45
+ .toLowerCase();
46
+ const target = String(body.target ?? "").trim();
47
+ const text = String(body.text ?? "");
48
+ if (!frontend) {
49
+ return { ok: false, error: "send_via: frontend is required" };
50
+ }
51
+ if (!target) {
52
+ return { ok: false, error: "send_via: target is required" };
53
+ }
54
+ if (!text.trim()) {
55
+ return { ok: false, error: "send_via: text is required" };
56
+ }
57
+ const handler = targets.get(frontend);
58
+ if (!handler) {
59
+ const enabled = [...targets.keys()].sort().join(", ") || "none";
60
+ return {
61
+ ok: false,
62
+ error: `send_via: the ${frontend} frontend is not enabled (enabled: ${enabled})`,
63
+ };
64
+ }
65
+ // A numeric target is the handler's chatId key; other forms travel in
66
+ // body.target for the frontend's adapter to resolve. 0 is the same
67
+ // "no chat" sentinel the chat-free dispatch itself uses.
68
+ const numericTarget = /^-?\d+$/.test(target) ? Number(target) : 0;
69
+ const result = await handler(
70
+ { action: "send_message", text, target },
71
+ numericTarget,
72
+ );
73
+ if (!result) {
74
+ return {
75
+ ok: false,
76
+ error: `send_via: the ${frontend} frontend does not implement send_message`,
77
+ };
78
+ }
79
+ return result;
80
+ },
81
+ };
82
+
83
+ /**
84
+ * send_via is chat-free by design: it reads only its own explicit target
85
+ * and the broker, so it stays reachable from heartbeat/background runs —
86
+ * and skipping chat resolution is what keeps the wrong-frontend numeric
87
+ * routing hazard out of the path entirely.
88
+ */
89
+ export const crossSendChatFreeActions: ReadonlySet<string> = new Set([
90
+ "send_via",
91
+ ]);
@@ -17,6 +17,7 @@
17
17
  * - `plugins` — plugin hot-reload
18
18
  * - `models` — model / backend discovery
19
19
  * - `mesh` — companion device mesh (presence + location)
20
+ * - `cross-send` — explicit-target sends through any enabled frontend
20
21
  */
21
22
 
22
23
  import type { ActionResult } from "../../types.js";
@@ -31,7 +32,11 @@ import { scriptHandlers } from "./scripts.js";
31
32
  import { skillHandlers } from "./skills.js";
32
33
  import { pluginHandlers } from "./plugins.js";
33
34
  import { modelHandlers } from "./models.js";
34
- import { meshHandlers, chatFreeActions } from "./mesh.js";
35
+ import {
36
+ meshHandlers,
37
+ chatFreeActions as meshChatFreeActions,
38
+ } from "./mesh.js";
39
+ import { crossSendHandlers, crossSendChatFreeActions } from "./cross-send.js";
35
40
  import { nativeHandlers } from "./native.js";
36
41
 
37
42
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
@@ -48,9 +53,19 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
48
53
  ...pluginHandlers,
49
54
  ...modelHandlers,
50
55
  ...meshHandlers,
56
+ ...crossSendHandlers,
51
57
  ...nativeHandlers,
52
58
  });
53
59
 
60
+ /**
61
+ * All chat-free actions — each domain module declares its own set, merged
62
+ * here into the one view the gateway (and `isChatFreeAction`) consults.
63
+ */
64
+ const chatFreeActions: ReadonlySet<string> = new Set([
65
+ ...meshChatFreeActions,
66
+ ...crossSendChatFreeActions,
67
+ ]);
68
+
54
69
  export async function handleSharedAction(
55
70
  body: Record<string, unknown>,
56
71
  chatId: number,
@@ -25,6 +25,7 @@ import {
25
25
  handleChatFreeAction,
26
26
  isChatFreeAction,
27
27
  } from "./gateway-actions/index.js";
28
+ import { registerCrossSendTarget } from "./gateway-actions/cross-send.js";
28
29
  import {
29
30
  handleHubRequest,
30
31
  getHubSessionCount,
@@ -154,6 +155,9 @@ export class Gateway {
154
155
  name: string,
155
156
  handler: FrontendActionHandler | null,
156
157
  ): void {
158
+ // Mirror into the cross-send broker so the chat-free `send_via`
159
+ // action can dispatch to any enabled frontend by explicit name.
160
+ registerCrossSendTarget(name, handler);
157
161
  if (handler === null) {
158
162
  this.frontendHandlers.delete(name);
159
163
  return;
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition } from "./types.js";
3
+
4
+ /**
5
+ * Cross-frontend send. Deliberately NOT frontend-restricted (the meshTools
6
+ * pattern): the whole point is reaching a DIFFERENT enabled frontend than
7
+ * the one the session lives on — a Telegram chat messaging a WhatsApp
8
+ * number, the heartbeat reaching WhatsApp when only telegram-tools is
9
+ * mounted. The gateway serves it as a chat-free action (see
10
+ * engine/gateway-actions/cross-send.ts), so it works from background runs
11
+ * with no ambient chat, and the target frontend is named EXPLICITLY —
12
+ * never inferred from the shape of a numeric id.
13
+ */
14
+ export const crossSendTools: ToolDefinition[] = [
15
+ {
16
+ name: "send_via",
17
+ description: `Send a plain text message through ANY enabled messaging frontend — including one this chat does not live on. Use it to reach someone on another platform (e.g. from Telegram or a heartbeat run, message a WhatsApp number). For the current chat, keep using the normal delivery tools (end_turn / send / send_message).
18
+
19
+ Target forms:
20
+ - whatsapp: a phone number with country code ("+353871234567" or "353871234567"), a wa_dm_<number>/wa_group_<id> chat id, or a raw JID; a bare numeric chat id works only for chats already seen since startup
21
+ - telegram / discord / teams / native: the numeric chat id, as a string (Telegram supergroups/channels are negative, DMs positive)
22
+
23
+ Examples:
24
+ send_via(frontend="whatsapp", target="+353871234567", text="On my way")
25
+ send_via(frontend="telegram", target="-1001426819337", text="Build is green")
26
+
27
+ Fails with a clear error when the named frontend is not enabled or not connected.`,
28
+ schema: {
29
+ frontend: z
30
+ .enum(["telegram", "whatsapp", "discord", "teams", "native"])
31
+ .describe("Messaging frontend to deliver through."),
32
+ target: z
33
+ .string()
34
+ .describe(
35
+ "Destination chat: numeric chat id as a string; for WhatsApp also a phone number with country code or a wa_dm_/wa_group_ id.",
36
+ ),
37
+ text: z
38
+ .string()
39
+ .describe(
40
+ "Message text. Markdown supported where the platform supports it.",
41
+ ),
42
+ },
43
+ execute: (params, bridge) => bridge("send_via", params),
44
+ tag: "messaging",
45
+ },
46
+ ];
@@ -22,6 +22,7 @@ import { webTools } from "./web.js";
22
22
  import { adminTools } from "./admin.js";
23
23
  import { modelTools } from "./models.js";
24
24
  import { meshTools } from "./mesh.js";
25
+ import { crossSendTools } from "./cross-send.js";
25
26
  import { moderationTools } from "./moderation.js";
26
27
  import { nativeTools } from "./native.js";
27
28
 
@@ -42,6 +43,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
42
43
  ...adminTools,
43
44
  ...modelTools,
44
45
  ...meshTools,
46
+ ...crossSendTools,
45
47
  ...moderationTools,
46
48
  ];
47
49
 
@@ -19,7 +19,11 @@
19
19
  import type { WASocket } from "baileys";
20
20
  import type { Gateway } from "../../../core/engine/gateway.js";
21
21
  import type { ActionResult } from "../../../core/types.js";
22
- import { lookupWhatsAppChat } from "../registry.js";
22
+ import {
23
+ lookupWhatsAppChat,
24
+ resolveWhatsAppTarget,
25
+ type WhatsAppChatInfo,
26
+ } from "../registry.js";
23
27
  import { chatInfoHandlers } from "./chat-info.js";
24
28
  import { mediaHandlers } from "./media.js";
25
29
  import { messagingHandlers } from "./messaging.js";
@@ -57,7 +61,26 @@ export function createWhatsAppActionHandler(
57
61
  return { ok: false, error: "WhatsApp socket is not connected" };
58
62
  }
59
63
 
60
- const chat = lookupWhatsAppChat(chatId) ?? null;
64
+ // An explicit cross-send target (send_via) names the destination
65
+ // itself — phone number, wa_* id, or JID — and may be a chat this
66
+ // process has never seen; resolveWhatsAppTarget registers it so the
67
+ // usual numeric-id routing works from then on. Without a target,
68
+ // the gateway's numeric chatId (an active chat) is the destination.
69
+ let chat: WhatsAppChatInfo | null;
70
+ if (body.target !== undefined) {
71
+ chat = resolveWhatsAppTarget(body.target) ?? null;
72
+ if (!chat) {
73
+ return {
74
+ ok: false,
75
+ error:
76
+ `Unresolvable WhatsApp target "${String(body.target)}" — use a ` +
77
+ `phone number with country code, a wa_dm_/wa_group_ id, or a ` +
78
+ `known chat id.`,
79
+ };
80
+ }
81
+ } else {
82
+ chat = lookupWhatsAppChat(chatId) ?? null;
83
+ }
61
84
  if (!chat && !CHATLESS_ACTIONS.has(action)) {
62
85
  return {
63
86
  ok: false,
@@ -85,6 +85,45 @@ export function lookupWhatsAppChatByString(
85
85
  return byString.get(chatId);
86
86
  }
87
87
 
88
+ /**
89
+ * Resolve an explicit send target — the `target` a cross-frontend
90
+ * `send_via` names — onto chat info, REGISTERING the chat when it's new
91
+ * so numeric-id routing and replies work from then on. Accepted forms:
92
+ *
93
+ * - a registered Talon id: `wa_dm_*`/`wa_group_*` string, or its
94
+ * numeric hash (only resolvable while registered — the hash can't
95
+ * be reversed)
96
+ * - a raw JID: `<number>@s.whatsapp.net` or `<id>@g.us`
97
+ * - a phone number with country code: "+353871234567", "353 87 123 4567"
98
+ *
99
+ * Digits with punctuation normalize to a DM JID; an all-digit string is
100
+ * tried as a registered numeric id first, then treated as a phone number.
101
+ * Returns undefined when nothing sendable can be derived.
102
+ */
103
+ export function resolveWhatsAppTarget(
104
+ raw: unknown,
105
+ ): WhatsAppChatInfo | undefined {
106
+ const target = String(raw ?? "").trim();
107
+ if (!target) return undefined;
108
+ const registered = byString.get(target);
109
+ if (registered) return registered;
110
+ if (/^\d+$/.test(target)) {
111
+ const byHash = byNumeric.get(Number(target));
112
+ if (byHash) return byHash;
113
+ }
114
+ if (target.includes("@")) return registerWhatsAppChat(target);
115
+ if (target.startsWith("wa_group_")) {
116
+ return registerWhatsAppChat(`${target.slice("wa_group_".length)}@g.us`);
117
+ }
118
+ if (target.startsWith("wa_dm_")) {
119
+ return registerWhatsAppChat(
120
+ `${target.slice("wa_dm_".length)}@s.whatsapp.net`,
121
+ );
122
+ }
123
+ const digits = target.replace(/[^0-9]/g, "");
124
+ return digits ? registerWhatsAppChat(`${digits}@s.whatsapp.net`) : undefined;
125
+ }
126
+
88
127
  /** Test seam: forget every registered chat. */
89
128
  export function resetWhatsAppRegistry(): void {
90
129
  byNumeric.clear();