talon-agent 4.3.0 → 4.3.1

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": "4.3.0",
3
+ "version": "4.3.1",
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",
package/src/bootstrap.ts CHANGED
@@ -568,9 +568,12 @@ export async function initBackendAndDispatcher(
568
568
  const { crossSendHandlers } =
569
569
  await import("./core/engine/gateway-actions/cross-send.js");
570
570
  void deliverPendingProvisionReport(async (frontend, target, text) => {
571
+ // Chat-free: the same "no chat" sentinels handleChatFreeAction passes.
571
572
  const result = await crossSendHandlers.send_via(
572
573
  { frontend, target, text },
573
574
  0,
575
+ undefined,
576
+ "0",
574
577
  );
575
578
  return Boolean(result && (result as { ok?: unknown }).ok === true);
576
579
  });
@@ -34,6 +34,7 @@ import {
34
34
  } from "../../storage/cron-store.js";
35
35
  import { appendDailyLog } from "../../storage/daily-log.js";
36
36
  import { log, logError, logWarn } from "../../util/log.js";
37
+ import { numericChatIdFor } from "../../util/chat-id.js";
37
38
  import { runJobOneShot } from "./job-oneshot.js";
38
39
  import {
39
40
  jobAllowsRun,
@@ -464,10 +465,7 @@ const CRON_JOB_TIMEOUT_MS = 10 * 60_000; // 10-minute max per job
464
465
  export async function executeJob(job: CronJob): Promise<ExecuteJobResult> {
465
466
  if (!deps) return { status: "skipped", reason: "cron is not initialised" };
466
467
 
467
- const numericChatId = Number(job.chatId);
468
- if (!Number.isFinite(numericChatId)) {
469
- throw new Error(`Invalid chatId for job "${job.name}": ${job.chatId}`);
470
- }
468
+ const numericChatId = numericChatIdFor(job.chatId);
471
469
 
472
470
  if (job.type === "message") {
473
471
  await deps.sendMessage(numericChatId, job.content, job.chatId);
@@ -33,19 +33,19 @@ import type { SharedActionHandlers } from "./types.js";
33
33
  * Returns an error string, or null when valid.
34
34
  */
35
35
  async function validateCronModelOverride(
36
- chatId: number,
36
+ chatKey: string,
37
37
  model?: string,
38
38
  provider?: string,
39
39
  ): Promise<string | null> {
40
- const chatBackendId = getBackendIdForChat(String(chatId));
40
+ const chatBackendId = getBackendIdForChat(chatKey);
41
41
  if (!provider || provider === chatBackendId) {
42
42
  const capabilityErr = validateCronBackgroundCapability(
43
43
  chatBackendId,
44
- getBackendForChat(String(chatId)),
44
+ getBackendForChat(chatKey),
45
45
  );
46
46
  if (capabilityErr) return capabilityErr;
47
47
  if (!model) return null;
48
- return validateJobModelOverride(chatId, model);
48
+ return validateJobModelOverride(chatKey, model);
49
49
  }
50
50
 
51
51
  let acquired: Awaited<ReturnType<typeof acquireBackendInstance>> | null =
@@ -85,7 +85,7 @@ function validateCronBackgroundCapability(
85
85
  }
86
86
 
87
87
  export const cronHandlers: SharedActionHandlers = {
88
- create_cron_job: async (body, chatId) => {
88
+ create_cron_job: async (body, chatId, _backend, chatKey) => {
89
89
  const parsed = parseCronSpec(body);
90
90
  if (!parsed.ok) return { ok: false, error: parsed.error };
91
91
  const spec = parsed.effective;
@@ -103,7 +103,7 @@ export const cronHandlers: SharedActionHandlers = {
103
103
  // backend is rejected here instead of silently failing at fire time.
104
104
  if (jobType === "query") {
105
105
  const modelErr = await validateCronModelOverride(
106
- chatId,
106
+ chatKey,
107
107
  spec.model,
108
108
  spec.provider,
109
109
  );
@@ -114,7 +114,7 @@ export const cronHandlers: SharedActionHandlers = {
114
114
  addCronJob({
115
115
  ...spec,
116
116
  id,
117
- chatId: String(chatId),
117
+ chatId: chatKey,
118
118
  enabled: true,
119
119
  createdAt: Date.now(),
120
120
  runCount: 0,
@@ -147,8 +147,8 @@ export const cronHandlers: SharedActionHandlers = {
147
147
  };
148
148
  },
149
149
 
150
- list_cron_jobs: (body, chatId) => {
151
- const jobs = getCronJobsForChat(String(chatId));
150
+ list_cron_jobs: (body, chatId, _backend, chatKey) => {
151
+ const jobs = getCronJobsForChat(chatKey);
152
152
  if (jobs.length === 0)
153
153
  return { ok: true, text: "No cron jobs in this chat." };
154
154
  const fmt = (ms: number) =>
@@ -191,12 +191,12 @@ export const cronHandlers: SharedActionHandlers = {
191
191
  };
192
192
  },
193
193
 
194
- edit_cron_job: async (body, chatId) => {
194
+ edit_cron_job: async (body, chatId, _backend, chatKey) => {
195
195
  const jobId = String(body.job_id ?? "");
196
196
  if (!jobId) return { ok: false, error: "Missing job_id" };
197
197
  const job = getCronJob(jobId);
198
198
  if (!job) return { ok: false, error: `Job ${jobId} not found` };
199
- if (job.chatId !== String(chatId))
199
+ if (job.chatId !== chatKey)
200
200
  return { ok: false, error: "Job belongs to a different chat" };
201
201
 
202
202
  const parsed = parseCronSpec(body, job);
@@ -210,7 +210,7 @@ export const cronHandlers: SharedActionHandlers = {
210
210
  ("model" in updates || "provider" in updates || "type" in updates)
211
211
  ) {
212
212
  const modelErr = await validateCronModelOverride(
213
- chatId,
213
+ chatKey,
214
214
  effective.model,
215
215
  effective.provider,
216
216
  );
@@ -233,12 +233,12 @@ export const cronHandlers: SharedActionHandlers = {
233
233
  };
234
234
  },
235
235
 
236
- run_cron_job: async (body, chatId) => {
236
+ run_cron_job: async (body, chatId, _backend, chatKey) => {
237
237
  const jobId = String(body.job_id ?? "");
238
238
  if (!jobId) return { ok: false, error: "Missing job_id" };
239
239
  const job = getCronJob(jobId);
240
240
  if (!job) return { ok: false, error: `Job ${jobId} not found` };
241
- if (job.chatId !== String(chatId))
241
+ if (job.chatId !== chatKey)
242
242
  return { ok: false, error: "Job belongs to a different chat" };
243
243
  const result = await runJobNow(jobId);
244
244
  if (!result.ok) return { ok: false, error: result.error ?? "Run failed" };
@@ -246,12 +246,12 @@ export const cronHandlers: SharedActionHandlers = {
246
246
  return { ok: true, text: `Ran job "${job.name}" (${jobId}) now.` };
247
247
  },
248
248
 
249
- delete_cron_job: (body, chatId) => {
249
+ delete_cron_job: (body, chatId, _backend, chatKey) => {
250
250
  const jobId = String(body.job_id ?? "");
251
251
  if (!jobId) return { ok: false, error: "Missing job_id" };
252
252
  const job = getCronJob(jobId);
253
253
  if (!job) return { ok: false, error: `Job ${jobId} not found` };
254
- if (job.chatId !== String(chatId))
254
+ if (job.chatId !== chatKey)
255
255
  return { ok: false, error: "Job belongs to a different chat" };
256
256
  deleteCronJob(jobId);
257
257
  return { ok: true, text: `Deleted cron job "${job.name}" (${jobId})` };
@@ -26,7 +26,7 @@ import { parseDueDate } from "./shared.js";
26
26
  import type { SharedActionHandlers } from "./types.js";
27
27
 
28
28
  export const goalHandlers: SharedActionHandlers = {
29
- add_goal: (body, chatId) => {
29
+ add_goal: (body, chatId, _backend, chatKey) => {
30
30
  const title = String(body.title ?? "").trim();
31
31
  const description = body.description ? String(body.description) : undefined;
32
32
  const priority = body.priority ?? "normal";
@@ -48,7 +48,7 @@ export const goalHandlers: SharedActionHandlers = {
48
48
  };
49
49
  }
50
50
 
51
- const chatIdStr = String(chatId);
51
+ const chatIdStr = chatKey;
52
52
  // No open-goal cap: goals grow freely. The heartbeat re-reads every open
53
53
  // goal, so keep them tidy by closing finished ones (completed/abandoned).
54
54
 
@@ -75,10 +75,10 @@ export const goalHandlers: SharedActionHandlers = {
75
75
  };
76
76
  },
77
77
 
78
- list_goals: (body, chatId) => {
78
+ list_goals: (body, chatId, _backend, chatKey) => {
79
79
  const includeClosed = body.include_closed === true;
80
80
  const goals = getGoalsForChat(
81
- String(chatId),
81
+ chatKey,
82
82
  includeClosed ? GOAL_STATUSES : OPEN_GOAL_STATUSES,
83
83
  );
84
84
  if (goals.length === 0)
@@ -94,12 +94,12 @@ export const goalHandlers: SharedActionHandlers = {
94
94
  };
95
95
  },
96
96
 
97
- update_goal: (body, chatId) => {
97
+ update_goal: (body, chatId, _backend, chatKey) => {
98
98
  const goalId = String(body.goal_id ?? "");
99
99
  if (!goalId) return { ok: false, error: "Missing goal_id" };
100
100
  const goal = getGoal(goalId);
101
101
  if (!goal) return { ok: false, error: `Goal ${goalId} not found` };
102
- if (goal.chatId !== String(chatId))
102
+ if (goal.chatId !== chatKey)
103
103
  return { ok: false, error: "Goal belongs to a different chat" };
104
104
 
105
105
  const updates: Parameters<typeof updateGoal>[1] = {};
@@ -158,12 +158,12 @@ export const goalHandlers: SharedActionHandlers = {
158
158
  };
159
159
  },
160
160
 
161
- delete_goal: (body, chatId) => {
161
+ delete_goal: (body, chatId, _backend, chatKey) => {
162
162
  const goalId = String(body.goal_id ?? "");
163
163
  if (!goalId) return { ok: false, error: "Missing goal_id" };
164
164
  const goal = getGoal(goalId);
165
165
  if (!goal) return { ok: false, error: `Goal ${goalId} not found` };
166
- if (goal.chatId !== String(chatId))
166
+ if (goal.chatId !== chatKey)
167
167
  return { ok: false, error: "Goal belongs to a different chat" };
168
168
  deleteGoal(goalId);
169
169
  return { ok: true, text: `Deleted goal "${goal.title}" (${goalId})` };
@@ -15,9 +15,9 @@ import { formatMediaIndex } from "../../../storage/media-index.js";
15
15
  import type { SharedActionHandlers } from "./types.js";
16
16
 
17
17
  export const historyHandlers: SharedActionHandlers = {
18
- read_history: (body, chatId) => {
18
+ read_history: (body, chatId, _backend, chatKey) => {
19
19
  const limit = Math.min(100, Number(body.limit ?? 30));
20
- const cid = String(chatId);
20
+ const cid = chatKey;
21
21
  // The tool schema advertises `offset_id` and `before` for paging back;
22
22
  // this fallback used to ignore both and hand back the same newest
23
23
  // window no matter what the model asked, so "go further back" was a
@@ -36,7 +36,7 @@ export const historyHandlers: SharedActionHandlers = {
36
36
  return { ok: true, text: getRecentFormatted(cid, limit) };
37
37
  },
38
38
 
39
- search_history: (body, chatId) => {
39
+ search_history: (body, chatId, _backend, chatKey) => {
40
40
  const limit = Math.min(100, Number(body.limit ?? 20));
41
41
  // Optional ISO date bounds; an unparseable one is ignored rather
42
42
  // than silently narrowing the search to nothing.
@@ -48,35 +48,28 @@ export const historyHandlers: SharedActionHandlers = {
48
48
  };
49
49
  return {
50
50
  ok: true,
51
- text: searchHistory(String(chatId), String(body.query ?? ""), limit, {
51
+ text: searchHistory(chatKey, String(body.query ?? ""), limit, {
52
52
  after: bound(body.after),
53
53
  before: bound(body.before),
54
54
  }),
55
55
  };
56
56
  },
57
57
 
58
- get_user_messages: (body, chatId) => {
58
+ get_user_messages: (body, chatId, _backend, chatKey) => {
59
59
  const limit = Math.min(50, Number(body.limit ?? 20));
60
60
  return {
61
61
  ok: true,
62
- text: getMessagesByUser(
63
- String(chatId),
64
- String(body.user_name ?? ""),
65
- limit,
66
- ),
62
+ text: getMessagesByUser(chatKey, String(body.user_name ?? ""), limit),
67
63
  };
68
64
  },
69
65
 
70
- list_known_users: (body, chatId) => ({
66
+ list_known_users: (body, chatId, _backend, chatKey) => ({
71
67
  ok: true,
72
- text: getKnownUsers(String(chatId)),
68
+ text: getKnownUsers(chatKey),
73
69
  }),
74
70
 
75
- list_media: (body, chatId) => ({
71
+ list_media: (body, chatId, _backend, chatKey) => ({
76
72
  ok: true,
77
- text: formatMediaIndex(
78
- String(chatId),
79
- Math.min(20, Number(body.limit ?? 10)),
80
- ),
73
+ text: formatMediaIndex(chatKey, Math.min(20, Number(body.limit ?? 10))),
81
74
  }),
82
75
  };
@@ -72,15 +72,22 @@ const chatFreeActions: ReadonlySet<string> = new Set([
72
72
  ...whatsappAccountChatFreeActions,
73
73
  ]);
74
74
 
75
+ /**
76
+ * `chatKey` is the chat's canonical string id (see `SharedActionHandler`).
77
+ * It defaults to `String(chatId)`, which is exact for Telegram and for the
78
+ * numeric-only callers (tests, chat-free dispatch); the gateway passes the
79
+ * real string id it holds for the active turn.
80
+ */
75
81
  export async function handleSharedAction(
76
82
  body: Record<string, unknown>,
77
83
  chatId: number,
78
84
  backend?: Backend | null,
85
+ chatKey: string = String(chatId),
79
86
  ): Promise<ActionResult | null> {
80
87
  const action = body.action as string;
81
88
  const handler = handlers[action];
82
89
  if (!handler) return null; // not a shared action — delegate to frontend
83
- return handler(body, chatId, backend);
90
+ return handler(body, chatId, backend, chatKey);
84
91
  }
85
92
 
86
93
  /**
@@ -104,5 +111,5 @@ export async function handleChatFreeAction(
104
111
  if (!isChatFreeAction(action)) return null;
105
112
  const handler = handlers[action];
106
113
  if (!handler) return null;
107
- return handler(body, 0);
114
+ return handler(body, 0, undefined, "0");
108
115
  }
@@ -16,8 +16,8 @@ import {
16
16
  import type { SharedActionHandlers } from "./types.js";
17
17
 
18
18
  export const modelHandlers: SharedActionHandlers = {
19
- list_models: async (body, chatId) => {
20
- const chatIdStr = String(chatId);
19
+ list_models: async (body, chatId, _backend, chatKey) => {
20
+ const chatIdStr = chatKey;
21
21
  const currentId = getBackendIdForChat(chatIdStr);
22
22
  const requested = body.backend ? String(body.backend).trim() : "";
23
23
  const targetId = requested || currentId;
@@ -100,8 +100,8 @@ export const modelHandlers: SharedActionHandlers = {
100
100
 
101
101
  // Account-level, so it falls back to the pooled Claude backend when
102
102
  // another provider is serving this chat.
103
- plan_usage: async (_body, chatId) => {
104
- const current = getPooledBackend(getBackendIdForChat(String(chatId)));
103
+ plan_usage: async (_body, chatId, _backend, chatKey) => {
104
+ const current = getPooledBackend(getBackendIdForChat(chatKey));
105
105
  const source = current?.usage?.getPlanUsage
106
106
  ? current
107
107
  : getPooledBackend("claude");
@@ -131,8 +131,8 @@ export const modelHandlers: SharedActionHandlers = {
131
131
  };
132
132
  },
133
133
 
134
- list_backends: (body, chatId) => {
135
- const currentId = getBackendIdForChat(String(chatId));
134
+ list_backends: (body, chatId, _backend, chatKey) => {
135
+ const currentId = getBackendIdForChat(chatKey);
136
136
  const backends = getAvailableBackends().map((b) => ({
137
137
  id: b.id,
138
138
  label: b.label,
@@ -43,7 +43,7 @@ export async function performPluginReload(
43
43
  }
44
44
 
45
45
  export const pluginHandlers: SharedActionHandlers = {
46
- reload_plugins: async (body, chatId, backend) => {
46
+ reload_plugins: async (body, chatId, backend, chatKey) => {
47
47
  try {
48
48
  const { names } = await performPluginReload(backend);
49
49
 
@@ -52,13 +52,7 @@ export const pluginHandlers: SharedActionHandlers = {
52
52
  let mcpInfo = "";
53
53
  if (backend?.tools?.refreshTools) {
54
54
  try {
55
- // Prefer body._chatId (string chat ID passed by frontends that use
56
- // non-numeric IDs, e.g. Teams/terminal) over the numeric context ID.
57
- const refreshChatId =
58
- typeof body._chatId === "string" && body._chatId.length > 0
59
- ? body._chatId
60
- : String(chatId);
61
- const result = await backend.tools.refreshTools(refreshChatId);
55
+ const result = await backend.tools.refreshTools(chatKey);
62
56
  if (result) {
63
57
  const parts: string[] = [];
64
58
  if (result.added.length > 0)
@@ -30,15 +30,14 @@ export function parseDueDate(value: unknown): number | undefined {
30
30
  * when it isn't, or `null` when it's valid.
31
31
  */
32
32
  export async function validateJobModelOverride(
33
- chatId: number,
33
+ chatKey: string,
34
34
  model: string,
35
35
  ): Promise<string | null> {
36
36
  try {
37
- const chatIdStr = String(chatId);
38
37
  const ref = await resolveExplicitModelRef(
39
38
  model,
40
- getBackendForChat(chatIdStr),
41
- getBackendIdForChat(chatIdStr),
39
+ getBackendForChat(chatKey),
40
+ getBackendIdForChat(chatKey),
42
41
  );
43
42
  if (!ref) {
44
43
  return (
@@ -31,7 +31,7 @@ import { validateJobModelOverride } from "./shared.js";
31
31
  import type { SharedActionHandlers } from "./types.js";
32
32
 
33
33
  export const triggerHandlers: SharedActionHandlers = {
34
- trigger_create: async (body, chatId) => {
34
+ trigger_create: async (body, chatId, _backend, chatKey) => {
35
35
  const name = String(body.name ?? "").trim();
36
36
  const language = body.language;
37
37
  const script = String(body.script ?? "");
@@ -55,14 +55,13 @@ export const triggerHandlers: SharedActionHandlers = {
55
55
  const timeoutErr = validateTimeout(timeoutSeconds);
56
56
  if (timeoutErr) return { ok: false, error: timeoutErr };
57
57
 
58
- const chatIdStr = String(chatId);
59
- if (getTriggerByName(chatIdStr, name)) {
58
+ if (getTriggerByName(chatKey, name)) {
60
59
  return {
61
60
  ok: false,
62
61
  error: `A trigger named "${name}" already exists in this chat. Cancel it first or pick a different name.`,
63
62
  };
64
63
  }
65
- const active = getActiveTriggersForChat(chatIdStr);
64
+ const active = getActiveTriggersForChat(chatKey);
66
65
  if (active.length >= MAX_ACTIVE_PER_CHAT) {
67
66
  return {
68
67
  ok: false,
@@ -70,18 +69,10 @@ export const triggerHandlers: SharedActionHandlers = {
70
69
  };
71
70
  }
72
71
 
73
- const numericChatId = Number(chatId);
74
- if (!Number.isFinite(numericChatId)) {
75
- return {
76
- ok: false,
77
- error: `Cannot derive numeric chatId from ${chatId}`,
78
- };
79
- }
80
-
81
72
  // Validate the model up front so a bad id is rejected here instead of
82
73
  // silently failing at fire time.
83
74
  if (model) {
84
- const modelErr = await validateJobModelOverride(chatId, model);
75
+ const modelErr = await validateJobModelOverride(chatKey, model);
85
76
  if (modelErr) return { ok: false, error: modelErr };
86
77
  }
87
78
 
@@ -89,19 +80,19 @@ export const triggerHandlers: SharedActionHandlers = {
89
80
  const lang = language as TriggerLanguage;
90
81
  let scriptPath: string;
91
82
  try {
92
- scriptPath = writeScriptFile(chatIdStr, id, lang, script);
83
+ scriptPath = writeScriptFile(chatKey, id, lang, script);
93
84
  } catch (err) {
94
85
  return {
95
86
  ok: false,
96
87
  error: `Failed to write script: ${err instanceof Error ? err.message : err}`,
97
88
  };
98
89
  }
99
- const logPath = triggerLogPath(chatIdStr, id);
90
+ const logPath = triggerLogPath(chatKey, id);
100
91
 
101
92
  const trigger = {
102
93
  id,
103
- chatId: chatIdStr,
104
- numericChatId,
94
+ chatId: chatKey,
95
+ numericChatId: chatId,
105
96
  name,
106
97
  language: lang,
107
98
  scriptPath,
@@ -151,8 +142,8 @@ export const triggerHandlers: SharedActionHandlers = {
151
142
  };
152
143
  },
153
144
 
154
- trigger_list: (body, chatId) => {
155
- const triggers = getTriggersForChat(String(chatId));
145
+ trigger_list: (body, chatId, _backend, chatKey) => {
146
+ const triggers = getTriggersForChat(chatKey);
156
147
  if (triggers.length === 0)
157
148
  return { ok: true, text: "No triggers in this chat." };
158
149
  const lines = triggers.map((t) => {
@@ -181,12 +172,12 @@ export const triggerHandlers: SharedActionHandlers = {
181
172
  };
182
173
  },
183
174
 
184
- trigger_cancel: (body, chatId) => {
175
+ trigger_cancel: (body, chatId, _backend, chatKey) => {
185
176
  const triggerId = String(body.trigger_id ?? "");
186
177
  if (!triggerId) return { ok: false, error: "Missing trigger_id" };
187
178
  const t = getTrigger(triggerId);
188
179
  if (!t) return { ok: false, error: `Trigger ${triggerId} not found` };
189
- if (t.chatId !== String(chatId))
180
+ if (t.chatId !== chatKey)
190
181
  return { ok: false, error: "Trigger belongs to a different chat" };
191
182
  const wasRunning = cancelTrigger(triggerId);
192
183
  if (!wasRunning) {
@@ -201,12 +192,12 @@ export const triggerHandlers: SharedActionHandlers = {
201
192
  };
202
193
  },
203
194
 
204
- trigger_logs: (body, chatId) => {
195
+ trigger_logs: (body, chatId, _backend, chatKey) => {
205
196
  const triggerId = String(body.trigger_id ?? "");
206
197
  if (!triggerId) return { ok: false, error: "Missing trigger_id" };
207
198
  const t = getTrigger(triggerId);
208
199
  if (!t) return { ok: false, error: `Trigger ${triggerId} not found` };
209
- if (t.chatId !== String(chatId))
200
+ if (t.chatId !== chatKey)
210
201
  return { ok: false, error: "Trigger belongs to a different chat" };
211
202
  const lines = Math.min(500, Math.max(1, Number(body.lines ?? 80)));
212
203
  const { tail, truncated } = readTriggerLogTail(t.logPath, lines);
@@ -219,12 +210,12 @@ export const triggerHandlers: SharedActionHandlers = {
219
210
  };
220
211
  },
221
212
 
222
- trigger_delete: (body, chatId) => {
213
+ trigger_delete: (body, chatId, _backend, chatKey) => {
223
214
  const triggerId = String(body.trigger_id ?? "");
224
215
  if (!triggerId) return { ok: false, error: "Missing trigger_id" };
225
216
  const t = getTrigger(triggerId);
226
217
  if (!t) return { ok: false, error: `Trigger ${triggerId} not found` };
227
- if (t.chatId !== String(chatId))
218
+ if (t.chatId !== chatKey)
228
219
  return { ok: false, error: "Trigger belongs to a different chat" };
229
220
  // Cancel first if it's still running so we don't orphan a child
230
221
  cancelTrigger(triggerId);
@@ -9,10 +9,20 @@
9
9
  import type { ActionResult } from "../../types.js";
10
10
  import type { Backend } from "../../agent-runtime/capabilities.js";
11
11
 
12
+ /**
13
+ * `chatId` is the frontend's numeric id — the only identity the HTTP bridge
14
+ * carries. `chatKey` is the chat's canonical string id (the dispatcher's
15
+ * `chatId`, which every store is keyed on): for Telegram it is just
16
+ * `String(chatId)`, for every other frontend it is the real id the numeric
17
+ * one was derived from (`d_…`, `wa_…`, `discord_…`). Handlers that persist,
18
+ * look up, or route by chat must use `chatKey`, never `String(chatId)` —
19
+ * a `d_…` chat's jobs stored under its numeric id fire into Telegram.
20
+ */
12
21
  type SharedActionHandler = (
13
22
  body: Record<string, unknown>,
14
23
  chatId: number,
15
- backend?: Backend | null,
24
+ backend: Backend | null | undefined,
25
+ chatKey: string,
16
26
  ) => Promise<ActionResult> | ActionResult;
17
27
 
18
28
  export type SharedActionHandlers = Record<string, SharedActionHandler>;
@@ -307,6 +307,11 @@ export class Gateway {
307
307
  const action = typeof body.action === "string" ? body.action : "";
308
308
  if (!action) return { ok: false, error: "Missing action" };
309
309
  const t0 = Date.now();
310
+ // The canonical string id for this chat. The bridge only carries the
311
+ // numeric id, but the turn's Thread is keyed by the dispatcher's string
312
+ // chatId — hand that to the handlers so a `d_…`/`wa_…` chat's cron jobs,
313
+ // triggers and history land under the id the rest of the engine uses.
314
+ const chatKey = this.loom.stringIdForNumeric(chatId) ?? String(chatId);
310
315
 
311
316
  try {
312
317
  // Try frontend first — it has richer implementations (e.g. userbot history)
@@ -326,7 +331,7 @@ export class Gateway {
326
331
  }
327
332
 
328
333
  // Try plugin actions (loaded from external plugin packages)
329
- const pluginResult = await handlePluginAction(body, String(chatId));
334
+ const pluginResult = await handlePluginAction(body, chatKey);
330
335
  if (pluginResult) {
331
336
  logDebug(
332
337
  "gateway",
@@ -336,7 +341,12 @@ export class Gateway {
336
341
  }
337
342
 
338
343
  // Shared actions last — provides in-memory fallbacks for history, cron, etc.
339
- const shared = await handleSharedAction(body, chatId, this.backend);
344
+ const shared = await handleSharedAction(
345
+ body,
346
+ chatId,
347
+ this.backend,
348
+ chatKey,
349
+ );
340
350
  if (shared) {
341
351
  logDebug(
342
352
  "gateway",
@@ -22,6 +22,8 @@ export interface ContextRegistry {
22
22
  activeContextCount(): number;
23
23
  /** Resolve a string chat id to the numeric id of its active context. */
24
24
  numericForStringId(stringId: string): number | null;
25
+ /** Resolve a numeric id to the string chat id of its active context. */
26
+ stringIdForNumeric(numericChatId: number): string | null;
25
27
  /** Number of live Threads in the registry. */
26
28
  size(): number;
27
29
  }
@@ -146,6 +148,15 @@ export class Loom implements ContextRegistry {
146
148
  return thread?.contextActive ? (thread.numericChatId ?? null) : null;
147
149
  }
148
150
 
151
+ /**
152
+ * Resolve a numeric id to the string chat id of its active context — the
153
+ * Thread key, which is the dispatcher's `chatId` (see `acquireContext`).
154
+ * Null outside a turn: the numeric index only mirrors held contexts.
155
+ */
156
+ stringIdForNumeric(numericChatId: number): string | null {
157
+ return this.byNumeric.get(numericChatId)?.chatId ?? null;
158
+ }
159
+
149
160
  private resolve(id: number | string): Thread | undefined {
150
161
  if (typeof id === "number") return this.byNumeric.get(id);
151
162
  const direct = this.threads.get(id);
@@ -10,6 +10,19 @@ export function deriveNumericChatId(chatId: string): number {
10
10
  return hash.readUInt32BE(0);
11
11
  }
12
12
 
13
+ /**
14
+ * The numeric id a frontend uses for a chat. Telegram ids are numeric
15
+ * already; every other frontend (native, WhatsApp, Discord, Teams, terminal)
16
+ * derives its numeric id from the string one with `deriveNumericChatId`.
17
+ * This is the inverse the background runtimes need to address a stored
18
+ * string chat id the way its frontend does.
19
+ */
20
+ export function numericChatIdFor(chatId: string): number {
21
+ return isTelegramChatId(chatId)
22
+ ? Number(chatId)
23
+ : deriveNumericChatId(chatId);
24
+ }
25
+
13
26
  /** Generate a unique terminal chat ID. */
14
27
  export function generateTerminalChatId(): string {
15
28
  return `t_${Date.now()}`;