talon-agent 1.9.1 → 1.9.2

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": "1.9.1",
3
+ "version": "1.9.2",
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",
@@ -51,13 +51,14 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@anthropic-ai/claude-agent-sdk": "^0.2.108",
54
+ "@anthropic-ai/sdk": "^0.95.0",
54
55
  "@brave/brave-search-mcp-server": "^2.0.75",
55
56
  "@clack/prompts": "^1.2.0",
56
57
  "@grammyjs/auto-retry": "^2.0.2",
57
58
  "@grammyjs/transformer-throttler": "^1.2.1",
58
59
  "@modelcontextprotocol/sdk": "^1.29.0",
59
60
  "@opencode-ai/sdk": "^1.4.0",
60
- "@playwright/mcp": "^0.0.70",
61
+ "@playwright/mcp": "^0.0.74",
61
62
  "big-integer": "^1.6.52",
62
63
  "cheerio": "^1.2.0",
63
64
  "croner": "^10.0.1",
@@ -85,6 +86,6 @@
85
86
  "vitest": "^4.1.3"
86
87
  },
87
88
  "overrides": {
88
- "@anthropic-ai/sdk": "^0.86.1"
89
+ "@anthropic-ai/sdk": "^0.95.0"
89
90
  }
90
91
  }
@@ -7,6 +7,7 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
7
7
  - **Wings** = top-level categories (people, projects, topics)
8
8
  - **Rooms** = specific subjects within a wing
9
9
  - **Drawers** = individual memory chunks (verbatim text)
10
+ - **Tunnels** = cross-wing links between related rooms (auto-created in mempalace 3.3.4+ when topics overlap, plus manual)
10
11
  - **Knowledge Graph** = entity-relationship facts with temporal validity
11
12
 
12
13
  ### Protocol — FOLLOW EVERY SESSION
@@ -25,6 +26,7 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
25
26
  - `mempalace_status` — Palace overview: total drawers, wings, rooms.
26
27
  - `mempalace_list_wings` / `mempalace_list_rooms` — Browse structure.
27
28
  - `mempalace_get_taxonomy` — Full wing/room/count tree.
29
+ - `mempalace_get_aaak_spec` — Get the AAAK closet/compression spec. Only needed when reading/writing AAAK-compressed memories directly.
28
30
 
29
31
  **Knowledge Graph (Temporal Facts):**
30
32
 
@@ -34,24 +36,42 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
34
36
  - `mempalace_kg_timeline` — Chronological story of an entity.
35
37
  - `mempalace_kg_stats` — Graph overview: entities, triples, relationship types.
36
38
 
37
- **Palace Graph (Cross-Domain Connections):**
39
+ **Palace Graph & Cross-Wing Tunnels:**
38
40
 
39
41
  - `mempalace_traverse` — Walk from a room, find connected ideas across wings.
40
42
  - `mempalace_find_tunnels` — Find rooms that bridge two wings.
43
+ - `mempalace_follow_tunnels` — From a specific (wing, room) pair, walk the outbound tunnels and see connected rooms with drawer previews.
44
+ - `mempalace_create_tunnel` — Manually create a cross-wing tunnel between two (wing, room) pairs. Use when you spot a connection the auto-detector missed.
45
+ - `mempalace_list_tunnels` — List tunnels (optionally filtered by wing).
46
+ - `mempalace_delete_tunnel` — Remove a tunnel by ID when it's wrong or noisy.
41
47
  - `mempalace_graph_stats` — Graph connectivity overview.
42
48
 
43
- **Write:**
49
+ **Drawers:**
44
50
 
45
51
  - `mempalace_add_drawer` — Store verbatim content into a wing/room. Auto-checks duplicates.
52
+ - `mempalace_get_drawer` — Fetch a single drawer by ID. Returns full content + metadata. Use after a search hit when you need the verbatim text.
53
+ - `mempalace_list_drawers` — Browse drawers in a wing/room with pagination (`limit`, `offset`). Use for inventory/cleanup, not search.
54
+ - `mempalace_update_drawer` — Edit an existing drawer's content, wing, or room in place. Use to refine misfiled or stale entries instead of delete + re-add.
46
55
  - `mempalace_delete_drawer` — Remove a drawer by ID.
47
- - `mempalace_diary_write` — Write a session diary entry (agent_name, entry, topic).
48
- - `mempalace_diary_read` — Read recent diary entries.
56
+
57
+ **Diary:**
58
+
59
+ - `mempalace_diary_write` — Write a session diary entry (`agent_name`, `entry`, `topic`, optional `wing`).
60
+ - `mempalace_diary_read` — Read recent diary entries. Optional `wing` filter scopes by project.
61
+
62
+ **Maintenance:**
63
+
64
+ - `mempalace_memories_filed_away` — Check whether a recent checkpoint was saved (message count, timestamp). Useful for confirming a stop-hook flush happened.
65
+ - `mempalace_reconnect` — Force reconnect to the palace database. Run after external CLI/scripts modify the palace directly (the in-memory HNSW index can otherwise go stale).
66
+ - `mempalace_hook_settings` — Toggle silent-save / desktop-toast for the auto-save hooks.
49
67
 
50
68
  ### Tips
51
69
 
52
70
  - Search is **semantic** (meaning-based), not keyword. "What did we discuss about database performance?" works better than "database".
53
71
  - The knowledge graph stores typed relationships with **time windows**. It knows WHEN things were true.
54
72
  - Use `mempalace_check_duplicate` before storing new content to avoid clutter.
73
+ - **Tunnels auto-form** when drawers across different wings share topics (mempalace 3.3.4+). You don't have to wire connections by hand most of the time — but `mempalace_create_tunnel` is there when the auto-detector misses something obvious, and `mempalace_delete_tunnel` is there when it overreaches.
74
+ - After updating facts via the CLI / external scripts, call `mempalace_reconnect` so the live MCP server picks up the changes.
55
75
  - Diary entries accumulate across sessions. Write them to build continuity of self.
56
76
  - Entity detection runs per-language; results include `created_at` timestamps you can surface when the user asks "when did I last…".
57
77
 
@@ -71,10 +71,11 @@ describe("log", () => {
71
71
  );
72
72
  });
73
73
 
74
- it("includes Error message in context", () => {
75
- logError("bridge", "request failed", new Error("timeout"));
74
+ it("includes Error message and stack in context", () => {
75
+ const err = new Error("timeout");
76
+ logError("bridge", "request failed", err);
76
77
  expect(mockError).toHaveBeenCalledWith(
77
- { component: "bridge", err: "timeout" },
78
+ { component: "bridge", err: "timeout", stack: err.stack },
78
79
  "request failed",
79
80
  );
80
81
  });
@@ -0,0 +1,615 @@
1
+ /**
2
+ * Functional tests for tool schemas + the Telegram action handler.
3
+ *
4
+ * Covers two wiring layers that have historically broken silently:
5
+ *
6
+ * 1. Tool definition → bridge call
7
+ * `tool.execute(parsedParams, bridge)` must call the bridge
8
+ * with the correct action name and the correct params shape.
9
+ * Catches: action-name typos, missing param forwarding, and
10
+ * multiplexed dispatch (e.g. `send` → 13 different bridge
11
+ * actions depending on `type`).
12
+ *
13
+ * 2. Bridge → Telegram Bot API
14
+ * `createTelegramActionHandler(...)` translates a bridge
15
+ * action body into the right grammy `bot.api.*` call with
16
+ * the right arguments. Catches: drift between the bridge
17
+ * action name produced by a tool and the case label in the
18
+ * handler switch, or wrong arg ordering.
19
+ *
20
+ * No real bot, no real network, no spawned processes. Pure
21
+ * in-process round trips with vi.fn() spies.
22
+ */
23
+
24
+ import { describe, it, expect, vi, beforeEach } from "vitest";
25
+ import { z } from "zod";
26
+ import { ALL_TOOLS } from "../core/tools/index.js";
27
+ import type { ToolDefinition } from "../core/tools/types.js";
28
+
29
+ // ── Helpers ─────────────────────────────────────────────────────────────────
30
+
31
+ function getTool(name: string): ToolDefinition {
32
+ const tool = ALL_TOOLS.find((t) => t.name === name);
33
+ if (!tool) throw new Error(`tool ${name} not found in ALL_TOOLS`);
34
+ return tool;
35
+ }
36
+
37
+ /** Run the tool's zod schema over `raw` and return the parsed/coerced output. */
38
+ function parseSchema(
39
+ tool: ToolDefinition,
40
+ raw: Record<string, unknown>,
41
+ ): Record<string, unknown> {
42
+ const obj = z.object(tool.schema as Record<string, z.ZodTypeAny>);
43
+ return obj.parse(raw) as Record<string, unknown>;
44
+ }
45
+
46
+ /** Build a vi-mocked bridge that records every call and returns ok. */
47
+ function makeBridge() {
48
+ return vi.fn(async (_action: string, _params: Record<string, unknown>) => ({
49
+ ok: true,
50
+ }));
51
+ }
52
+
53
+ // ════════════════════════════════════════════════════════════════════════════
54
+ // Part A — Tool definition → bridge call
55
+ // ════════════════════════════════════════════════════════════════════════════
56
+
57
+ describe("Tool → bridge round-trip", () => {
58
+ // ── Single-action tools (1:1 with a bridge action name) ──────────────────
59
+ describe("single-action telegram tools", () => {
60
+ const cases: Array<{
61
+ tool: string;
62
+ params: Record<string, unknown>;
63
+ bridgeAction: string;
64
+ expectedParams?: Record<string, unknown>;
65
+ }> = [
66
+ {
67
+ tool: "react",
68
+ params: { message_id: 2081, emoji: "❤️" },
69
+ bridgeAction: "react",
70
+ expectedParams: { message_id: 2081, emoji: "❤️" },
71
+ },
72
+ {
73
+ tool: "edit_message",
74
+ params: { message_id: 2081, text: "edited" },
75
+ bridgeAction: "edit_message",
76
+ expectedParams: { message_id: 2081, text: "edited" },
77
+ },
78
+ {
79
+ tool: "delete_message",
80
+ params: { message_id: 2081 },
81
+ bridgeAction: "delete_message",
82
+ expectedParams: { message_id: 2081 },
83
+ },
84
+ {
85
+ tool: "forward_message",
86
+ params: { message_id: 2081 },
87
+ bridgeAction: "forward_message",
88
+ },
89
+ {
90
+ tool: "pin_message",
91
+ params: { message_id: 2081 },
92
+ bridgeAction: "pin_message",
93
+ },
94
+ {
95
+ tool: "unpin_message",
96
+ params: { message_id: 2081 },
97
+ bridgeAction: "unpin_message",
98
+ },
99
+ {
100
+ tool: "stop_poll",
101
+ params: { message_id: 2081 },
102
+ bridgeAction: "stop_poll",
103
+ },
104
+ {
105
+ tool: "get_member_info",
106
+ params: { user_id: 352042062 },
107
+ bridgeAction: "get_member_info",
108
+ },
109
+ {
110
+ tool: "get_message_by_id",
111
+ params: { message_id: 2081 },
112
+ bridgeAction: "get_message_by_id",
113
+ },
114
+ {
115
+ tool: "download_media",
116
+ params: { message_id: 2081 },
117
+ bridgeAction: "download_media",
118
+ },
119
+ ];
120
+
121
+ for (const c of cases) {
122
+ it(`${c.tool} → bridge("${c.bridgeAction}")`, async () => {
123
+ const tool = getTool(c.tool);
124
+ const bridge = makeBridge();
125
+ const parsed = parseSchema(tool, c.params);
126
+
127
+ await tool.execute(parsed, bridge);
128
+
129
+ expect(bridge).toHaveBeenCalledTimes(1);
130
+ const [action, params] = bridge.mock.calls[0]!;
131
+ expect(action).toBe(c.bridgeAction);
132
+ if (c.expectedParams) {
133
+ expect(params).toEqual(c.expectedParams);
134
+ }
135
+ });
136
+ }
137
+ });
138
+
139
+ // ── send tool dispatches to many bridge actions ──────────────────────────
140
+ describe("send tool dispatches by type", () => {
141
+ const cases: Array<{
142
+ label: string;
143
+ params: Record<string, unknown>;
144
+ bridgeAction: string;
145
+ paramShape?: (p: Record<string, unknown>) => void;
146
+ }> = [
147
+ {
148
+ label: "text",
149
+ params: { type: "text", text: "hello" },
150
+ bridgeAction: "send_message",
151
+ paramShape: (p) => {
152
+ expect(p.text).toBe("hello");
153
+ },
154
+ },
155
+ {
156
+ label: "text with reply_to",
157
+ params: { type: "text", text: "ok", reply_to: 2081 },
158
+ bridgeAction: "send_message",
159
+ paramShape: (p) => {
160
+ expect(p.reply_to_message_id).toBe(2081);
161
+ },
162
+ },
163
+ {
164
+ label: "text with buttons",
165
+ params: {
166
+ type: "text",
167
+ text: "choose",
168
+ buttons: [[{ text: "A", callback_data: "a" }]],
169
+ },
170
+ bridgeAction: "send_message_with_buttons",
171
+ paramShape: (p) => {
172
+ expect((p.rows as unknown[]).length).toBe(1);
173
+ },
174
+ },
175
+ {
176
+ label: "delayed text → schedule_message",
177
+ params: { type: "text", text: "later", delay_seconds: 60 },
178
+ bridgeAction: "schedule_message",
179
+ paramShape: (p) => {
180
+ expect(p.delay_seconds).toBe(60);
181
+ },
182
+ },
183
+ {
184
+ label: "photo",
185
+ params: { type: "photo", file_path: "/tmp/x.jpg", caption: "hi" },
186
+ bridgeAction: "send_photo",
187
+ },
188
+ {
189
+ label: "file",
190
+ params: { type: "file", file_path: "/tmp/x.pdf" },
191
+ bridgeAction: "send_file",
192
+ },
193
+ {
194
+ label: "video",
195
+ params: { type: "video", file_path: "/tmp/x.mp4" },
196
+ bridgeAction: "send_video",
197
+ },
198
+ {
199
+ label: "voice",
200
+ params: { type: "voice", file_path: "/tmp/x.ogg" },
201
+ bridgeAction: "send_voice",
202
+ },
203
+ {
204
+ label: "audio",
205
+ params: {
206
+ type: "audio",
207
+ file_path: "/tmp/song.mp3",
208
+ title: "T",
209
+ performer: "P",
210
+ },
211
+ bridgeAction: "send_audio",
212
+ paramShape: (p) => {
213
+ expect(p.title).toBe("T");
214
+ expect(p.performer).toBe("P");
215
+ },
216
+ },
217
+ {
218
+ label: "animation",
219
+ params: { type: "animation", file_path: "/tmp/x.gif" },
220
+ bridgeAction: "send_animation",
221
+ },
222
+ {
223
+ label: "sticker",
224
+ params: { type: "sticker", file_id: "CAACAgI..." },
225
+ bridgeAction: "send_sticker",
226
+ },
227
+ {
228
+ label: "poll",
229
+ params: {
230
+ type: "poll",
231
+ question: "Best?",
232
+ options: ["A", "B"],
233
+ },
234
+ bridgeAction: "send_poll",
235
+ paramShape: (p) => {
236
+ expect(p.question).toBe("Best?");
237
+ expect(p.type).toBe("regular");
238
+ },
239
+ },
240
+ {
241
+ label: "poll with correct_option_id → quiz",
242
+ params: {
243
+ type: "poll",
244
+ question: "Q?",
245
+ options: ["A", "B"],
246
+ correct_option_id: 1,
247
+ },
248
+ bridgeAction: "send_poll",
249
+ paramShape: (p) => {
250
+ expect(p.type).toBe("quiz");
251
+ },
252
+ },
253
+ {
254
+ label: "location",
255
+ params: { type: "location", latitude: 53.15, longitude: -6.07 },
256
+ bridgeAction: "send_location",
257
+ },
258
+ {
259
+ label: "contact",
260
+ params: {
261
+ type: "contact",
262
+ phone_number: "+1234",
263
+ first_name: "Test",
264
+ },
265
+ bridgeAction: "send_contact",
266
+ },
267
+ {
268
+ label: "dice",
269
+ params: { type: "dice" },
270
+ bridgeAction: "send_dice",
271
+ },
272
+ ];
273
+
274
+ for (const c of cases) {
275
+ it(`send(${c.label}) → bridge("${c.bridgeAction}")`, async () => {
276
+ const tool = getTool("send");
277
+ const bridge = makeBridge();
278
+ const parsed = parseSchema(tool, c.params);
279
+
280
+ await tool.execute(parsed, bridge);
281
+
282
+ expect(bridge).toHaveBeenCalledTimes(1);
283
+ const [action, params] = bridge.mock.calls[0]!;
284
+ expect(action).toBe(c.bridgeAction);
285
+ if (c.paramShape) c.paramShape(params as Record<string, unknown>);
286
+ });
287
+ }
288
+ });
289
+
290
+ // ── Coercion goes end-to-end through execute() ───────────────────────────
291
+ describe("ID coercion survives the schema → execute pipeline", () => {
292
+ it("react with stringified message_id arrives at bridge as a number", async () => {
293
+ const tool = getTool("react");
294
+ const bridge = makeBridge();
295
+ const parsed = parseSchema(tool, {
296
+ message_id: "2081",
297
+ emoji: "❤️",
298
+ });
299
+
300
+ await tool.execute(parsed, bridge);
301
+
302
+ const [, params] = bridge.mock.calls[0]!;
303
+ expect((params as { message_id: unknown }).message_id).toBe(2081);
304
+ expect(typeof (params as { message_id: unknown }).message_id).toBe(
305
+ "number",
306
+ );
307
+ });
308
+
309
+ it("send.reply_to with stringified value survives to send_message", async () => {
310
+ const tool = getTool("send");
311
+ const bridge = makeBridge();
312
+ const parsed = parseSchema(tool, {
313
+ type: "text",
314
+ text: "hi",
315
+ reply_to: "2081",
316
+ });
317
+
318
+ await tool.execute(parsed, bridge);
319
+
320
+ const [, params] = bridge.mock.calls[0]!;
321
+ expect(
322
+ (params as { reply_to_message_id: unknown }).reply_to_message_id,
323
+ ).toBe(2081);
324
+ });
325
+
326
+ it("get_member_info with stringified user_id arrives as number", async () => {
327
+ const tool = getTool("get_member_info");
328
+ const bridge = makeBridge();
329
+ const parsed = parseSchema(tool, { user_id: "352042062" });
330
+
331
+ await tool.execute(parsed, bridge);
332
+
333
+ const [, params] = bridge.mock.calls[0]!;
334
+ expect((params as { user_id: unknown }).user_id).toBe(352042062);
335
+ });
336
+ });
337
+ });
338
+
339
+ // ════════════════════════════════════════════════════════════════════════════
340
+ // Part B — Telegram action handler → grammy Bot API
341
+ // ════════════════════════════════════════════════════════════════════════════
342
+
343
+ import {
344
+ createTelegramActionHandler,
345
+ // re-export for test side-only — handler depends on the InputFile constructor
346
+ } from "../frontend/telegram/actions.js";
347
+ import type { Bot } from "grammy";
348
+ import type { Gateway } from "../core/gateway.js";
349
+
350
+ interface BotApiSpy {
351
+ setMessageReaction: ReturnType<typeof vi.fn>;
352
+ editMessageText: ReturnType<typeof vi.fn>;
353
+ deleteMessage: ReturnType<typeof vi.fn>;
354
+ pinChatMessage: ReturnType<typeof vi.fn>;
355
+ unpinChatMessage: ReturnType<typeof vi.fn>;
356
+ forwardMessage: ReturnType<typeof vi.fn>;
357
+ copyMessage: ReturnType<typeof vi.fn>;
358
+ sendMessage: ReturnType<typeof vi.fn>;
359
+ sendChatAction: ReturnType<typeof vi.fn>;
360
+ }
361
+
362
+ function makeBotSpy(): { bot: Bot; api: BotApiSpy } {
363
+ const api: BotApiSpy = {
364
+ setMessageReaction: vi.fn(async () => true),
365
+ editMessageText: vi.fn(async () => ({ message_id: 1 })),
366
+ deleteMessage: vi.fn(async () => true),
367
+ pinChatMessage: vi.fn(async () => true),
368
+ unpinChatMessage: vi.fn(async () => true),
369
+ forwardMessage: vi.fn(async () => ({ message_id: 999 })),
370
+ copyMessage: vi.fn(async () => ({ message_id: 1000 })),
371
+ sendMessage: vi.fn(async () => ({ message_id: 1001 })),
372
+ sendChatAction: vi.fn(async () => true),
373
+ };
374
+ const bot = { api } as unknown as Bot;
375
+ return { bot, api };
376
+ }
377
+
378
+ function makeGateway(): Gateway {
379
+ return {
380
+ incrementMessages: vi.fn(),
381
+ incrementErrors: vi.fn(),
382
+ incrementRetries: vi.fn(),
383
+ incrementSuccess: vi.fn(),
384
+ } as unknown as Gateway;
385
+ }
386
+
387
+ class StubInputFile {
388
+ // Match the grammy InputFile shape just enough for `new InputFileClass(...)`.
389
+ data: unknown;
390
+ filename: string;
391
+ constructor(data: unknown, filename: string) {
392
+ this.data = data;
393
+ this.filename = filename;
394
+ }
395
+ }
396
+
397
+ describe("createTelegramActionHandler", () => {
398
+ let bot: Bot;
399
+ let api: BotApiSpy;
400
+ let gateway: Gateway;
401
+ let handler: ReturnType<typeof createTelegramActionHandler>;
402
+ const chatId = 12345;
403
+
404
+ beforeEach(() => {
405
+ const spy = makeBotSpy();
406
+ bot = spy.bot;
407
+ api = spy.api;
408
+ gateway = makeGateway();
409
+ handler = createTelegramActionHandler(
410
+ bot,
411
+ StubInputFile as unknown as typeof import("grammy").InputFile,
412
+ "fake-token",
413
+ gateway,
414
+ );
415
+ });
416
+
417
+ it("react → bot.api.setMessageReaction with chatId, message_id, emoji", async () => {
418
+ const result = await handler(
419
+ { action: "react", message_id: 2081, emoji: "❤️" },
420
+ chatId,
421
+ );
422
+
423
+ expect(api.setMessageReaction).toHaveBeenCalledTimes(1);
424
+ expect(api.setMessageReaction).toHaveBeenCalledWith(chatId, 2081, [
425
+ { type: "emoji", emoji: "❤️" },
426
+ ]);
427
+ expect(result).toEqual({ ok: true });
428
+ });
429
+
430
+ it("react with stringified message_id (post-coercion) still calls bot.api correctly", async () => {
431
+ // Even if something upstream skipped coercion and delivered a string,
432
+ // the handler does Number(body.message_id) and recovers.
433
+ await handler({ action: "react", message_id: "2081", emoji: "🔥" }, chatId);
434
+
435
+ expect(api.setMessageReaction).toHaveBeenCalledWith(chatId, 2081, [
436
+ { type: "emoji", emoji: "🔥" },
437
+ ]);
438
+ });
439
+
440
+ it("react falls back to 👍 if custom emoji rejected, and reports ok", async () => {
441
+ const seen: string[] = [];
442
+ api.setMessageReaction.mockImplementation(
443
+ async (
444
+ _chatId: number,
445
+ _msgId: number,
446
+ reactions: Array<{ type: string; emoji: string }>,
447
+ ) => {
448
+ seen.push(reactions[0]!.emoji);
449
+ if (reactions[0]!.emoji === "🦄") {
450
+ throw new Error("REACTION_INVALID");
451
+ }
452
+ return true;
453
+ },
454
+ );
455
+
456
+ const result = await handler(
457
+ { action: "react", message_id: 1, emoji: "🦄" },
458
+ chatId,
459
+ );
460
+
461
+ expect(api.setMessageReaction).toHaveBeenCalledTimes(2);
462
+ expect(seen).toEqual(["🦄", "👍"]);
463
+ expect(result).toEqual({ ok: true });
464
+ });
465
+
466
+ it("delete_message → bot.api.deleteMessage(chatId, message_id)", async () => {
467
+ const result = await handler(
468
+ { action: "delete_message", message_id: 2081 },
469
+ chatId,
470
+ );
471
+
472
+ expect(api.deleteMessage).toHaveBeenCalledWith(chatId, 2081);
473
+ expect(result).toEqual({ ok: true });
474
+ });
475
+
476
+ it("pin_message → bot.api.pinChatMessage(chatId, message_id)", async () => {
477
+ await handler({ action: "pin_message", message_id: 2081 }, chatId);
478
+ expect(api.pinChatMessage).toHaveBeenCalledWith(chatId, 2081);
479
+ });
480
+
481
+ it("unpin_message → bot.api.unpinChatMessage(chatId, message_id?)", async () => {
482
+ await handler({ action: "unpin_message", message_id: 2081 }, chatId);
483
+ expect(api.unpinChatMessage).toHaveBeenCalledWith(chatId, 2081);
484
+
485
+ api.unpinChatMessage.mockClear();
486
+ await handler({ action: "unpin_message" }, chatId);
487
+ expect(api.unpinChatMessage).toHaveBeenCalledWith(chatId, undefined);
488
+ });
489
+
490
+ it("forward_message → bot.api.forwardMessage(chatId, chatId, message_id)", async () => {
491
+ const result = await handler(
492
+ { action: "forward_message", message_id: 2081 },
493
+ chatId,
494
+ );
495
+
496
+ expect(api.forwardMessage).toHaveBeenCalledWith(chatId, chatId, 2081);
497
+ expect(result).toEqual({ ok: true, message_id: 999 });
498
+ });
499
+
500
+ it("forward_message rejects cross-chat targets", async () => {
501
+ const result = await handler(
502
+ { action: "forward_message", message_id: 2081, to_chat_id: 99999 },
503
+ chatId,
504
+ );
505
+
506
+ expect(result).toEqual({
507
+ ok: false,
508
+ error: "Cross-chat forwarding not allowed.",
509
+ });
510
+ expect(api.forwardMessage).not.toHaveBeenCalled();
511
+ });
512
+
513
+ it("copy_message → bot.api.copyMessage(chatId, chatId, message_id)", async () => {
514
+ const result = await handler(
515
+ { action: "copy_message", message_id: 2081 },
516
+ chatId,
517
+ );
518
+
519
+ expect(api.copyMessage).toHaveBeenCalledWith(chatId, chatId, 2081);
520
+ expect(result).toEqual({ ok: true, message_id: 1000 });
521
+ });
522
+
523
+ it("edit_message → bot.api.editMessageText(chatId, message_id, html, opts)", async () => {
524
+ await handler(
525
+ { action: "edit_message", message_id: 2081, text: "**bold**" },
526
+ chatId,
527
+ );
528
+
529
+ expect(api.editMessageText).toHaveBeenCalledTimes(1);
530
+ const call = api.editMessageText.mock.calls[0]!;
531
+ expect(call[0]).toBe(chatId);
532
+ expect(call[1]).toBe(2081);
533
+ expect(typeof call[2]).toBe("string");
534
+ expect(call[3]).toEqual({ parse_mode: "HTML" });
535
+ });
536
+
537
+ it("edit_message rejects text > TELEGRAM_MAX_TEXT (4096)", async () => {
538
+ const longText = "x".repeat(5000);
539
+ const result = await handler(
540
+ { action: "edit_message", message_id: 2081, text: longText },
541
+ chatId,
542
+ );
543
+
544
+ expect(result).toEqual({
545
+ ok: false,
546
+ error: "Text too long (max 4096)",
547
+ });
548
+ expect(api.editMessageText).not.toHaveBeenCalled();
549
+ });
550
+
551
+ it("send_chat_action → bot.api.sendChatAction(chatId, action_str)", async () => {
552
+ await handler(
553
+ { action: "send_chat_action", chat_action: "typing" },
554
+ chatId,
555
+ );
556
+ expect(api.sendChatAction).toHaveBeenCalledWith(chatId, "typing");
557
+ });
558
+
559
+ it("send_message → withRetry → bot.api.sendMessage with HTML and reply_to", async () => {
560
+ const result = await handler(
561
+ {
562
+ action: "send_message",
563
+ text: "hello",
564
+ reply_to_message_id: 2081,
565
+ },
566
+ chatId,
567
+ );
568
+
569
+ expect(api.sendMessage).toHaveBeenCalledTimes(1);
570
+ expect(gateway.incrementMessages).toHaveBeenCalledWith(chatId);
571
+ const call = api.sendMessage.mock.calls[0]!;
572
+ expect(call[0]).toBe(chatId);
573
+ expect(typeof call[1]).toBe("string");
574
+ expect(call[2]).toMatchObject({
575
+ reply_parameters: { message_id: 2081 },
576
+ parse_mode: "HTML",
577
+ });
578
+ expect(result).toEqual({ ok: true, message_id: 1001 });
579
+ });
580
+
581
+ it("schedule_message returns a schedule id and keeps a timer", async () => {
582
+ vi.useFakeTimers();
583
+ try {
584
+ const result = (await handler(
585
+ {
586
+ action: "schedule_message",
587
+ text: "later",
588
+ delay_seconds: 5,
589
+ },
590
+ chatId,
591
+ )) as { ok: true; schedule_id: string; delay_seconds: number };
592
+
593
+ expect(result.ok).toBe(true);
594
+ expect(result.delay_seconds).toBe(5);
595
+ expect(typeof result.schedule_id).toBe("string");
596
+
597
+ // Cancel before it fires so we don't leak a real send.
598
+ const cancel = await handler(
599
+ { action: "cancel_scheduled", schedule_id: result.schedule_id },
600
+ chatId,
601
+ );
602
+ expect(cancel).toEqual({ ok: true, cancelled: true });
603
+ } finally {
604
+ vi.useRealTimers();
605
+ }
606
+ });
607
+
608
+ it("cancel_scheduled with unknown id returns ok:false", async () => {
609
+ const result = await handler(
610
+ { action: "cancel_scheduled", schedule_id: "nonexistent" },
611
+ chatId,
612
+ );
613
+ expect(result).toEqual({ ok: false, error: "Schedule not found" });
614
+ });
615
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Regression + audit tests — ID-shaped tool params use the strict
3
+ * shared `idSchema` from `core/tools/schemas.ts`.
4
+ *
5
+ * Background: some MCP transport / model paths deliver `message_id`,
6
+ * `user_id`, `reply_to`, `offset_id` as JSON strings ("2081") rather
7
+ * than numbers (2081). Plain `z.number()` rejects those with
8
+ * `expected number, received string`, which manifested as
9
+ * `react`/`pin_message`/`get_member_info`/`download_media` failing
10
+ * out of nowhere even when the model formatted the call correctly
11
+ * for a number-typed JSON Schema field.
12
+ *
13
+ * Initial fix used `z.coerce.number().int()`, but per Copilot review
14
+ * that was too lax — `""`/`null` coerce to 0 and `true` to 1, which
15
+ * then pass `.int()` and reach the bot API. The current `idSchema` is
16
+ * a union: `z.number().int().positive()` OR a digit-only string that
17
+ * gets transformed to a positive integer. Everything else is rejected.
18
+ */
19
+ import { describe, it, expect } from "vitest";
20
+ import { z } from "zod";
21
+ import { ALL_TOOLS } from "../core/tools/index.js";
22
+
23
+ const ID_FIELD_NAMES = new Set([
24
+ "message_id",
25
+ "user_id",
26
+ "reply_to",
27
+ "offset_id",
28
+ ]);
29
+
30
+ function getIdSchema(toolName: string, field: string): z.ZodTypeAny {
31
+ const tool = ALL_TOOLS.find((t) => t.name === toolName);
32
+ if (!tool) throw new Error(`tool ${toolName} not found`);
33
+ const schema = (tool.schema as Record<string, z.ZodTypeAny>)[field];
34
+ if (!schema) throw new Error(`field ${field} not found on ${toolName}`);
35
+ return schema;
36
+ }
37
+
38
+ describe("ID-shaped tool params accept stringified numbers", () => {
39
+ // Spot-check a representative set across all four files.
40
+ const cases: Array<[string, string]> = [
41
+ ["react", "message_id"],
42
+ ["edit_message", "message_id"],
43
+ ["delete_message", "message_id"],
44
+ ["forward_message", "message_id"],
45
+ ["pin_message", "message_id"],
46
+ ["unpin_message", "message_id"],
47
+ ["stop_poll", "message_id"],
48
+ ["send", "reply_to"],
49
+ ["get_member_info", "user_id"],
50
+ ["create_sticker_set", "user_id"],
51
+ ["add_sticker_to_set", "user_id"],
52
+ ["read_chat_history", "offset_id"],
53
+ ["get_message_by_id", "message_id"],
54
+ ["download_media", "message_id"],
55
+ ];
56
+
57
+ for (const [tool, field] of cases) {
58
+ it(`${tool}.${field} accepts a number`, () => {
59
+ const s = getIdSchema(tool, field);
60
+ const out = s.parse(2081);
61
+ expect(out).toBe(2081);
62
+ });
63
+
64
+ it(`${tool}.${field} accepts a numeric string and coerces it`, () => {
65
+ const s = getIdSchema(tool, field);
66
+ const out = s.parse("2081");
67
+ expect(out).toBe(2081);
68
+ });
69
+
70
+ it(`${tool}.${field} rejects a non-numeric string`, () => {
71
+ const s = getIdSchema(tool, field);
72
+ expect(() => s.parse("abc")).toThrow();
73
+ });
74
+
75
+ it(`${tool}.${field} rejects a non-integer`, () => {
76
+ const s = getIdSchema(tool, field);
77
+ expect(() => s.parse(1.5)).toThrow();
78
+ });
79
+ }
80
+ });
81
+
82
+ describe("Audit: every ID-shaped field in tool schemas is the strict idSchema", () => {
83
+ /**
84
+ * For each ID field on every tool, this test asserts the full
85
+ * accept/reject contract of `idSchema` — not just "accepts a
86
+ * string." That guards against a future replacement that passes
87
+ * the loose check (`safeParse("123")` succeeds) but doesn't
88
+ * actually return a positive integer (e.g. `z.string()` would
89
+ * accept "123" and return the string "123").
90
+ */
91
+ for (const tool of ALL_TOOLS) {
92
+ const schema = tool.schema as Record<string, unknown>;
93
+ for (const field of Object.keys(schema)) {
94
+ if (!ID_FIELD_NAMES.has(field)) continue;
95
+ const zSchema = schema[field] as z.ZodTypeAny;
96
+
97
+ it(`${tool.name}.${field}: accepts a positive integer number`, () => {
98
+ const r = zSchema.safeParse(2081);
99
+ expect(r.success).toBe(true);
100
+ if (r.success) expect(r.data).toBe(2081);
101
+ });
102
+
103
+ it(`${tool.name}.${field}: accepts a digit string and returns a number`, () => {
104
+ const r = zSchema.safeParse("2081");
105
+ expect(r.success).toBe(true);
106
+ if (r.success) {
107
+ expect(typeof r.data).toBe("number");
108
+ expect(Number.isInteger(r.data as number)).toBe(true);
109
+ expect(r.data).toBe(2081);
110
+ }
111
+ });
112
+
113
+ // Reject the inputs that bare `z.coerce.number().int()` was too
114
+ // permissive about — empty/whitespace/null/booleans coerce to
115
+ // 0/0/0/1 and would pass `.int()`. The strict union rejects all.
116
+ const rejectCases: Array<[unknown, string]> = [
117
+ ["", "empty string"],
118
+ [" ", "whitespace string"],
119
+ ["abc", "non-numeric string"],
120
+ ["2081abc", "mixed string"],
121
+ [null, "null"],
122
+ [true, "true"],
123
+ [false, "false"],
124
+ [0, "zero"],
125
+ [-1, "negative integer"],
126
+ [1.5, "non-integer"],
127
+ ];
128
+ for (const [bad, label] of rejectCases) {
129
+ it(`${tool.name}.${field}: rejects ${label}`, () => {
130
+ const r = zSchema.safeParse(bad);
131
+ expect(r.success).toBe(false);
132
+ });
133
+ }
134
+ }
135
+ }
136
+ });
@@ -227,10 +227,9 @@ export async function handleMessage(
227
227
  // ── Build result ──────────────────────────────────────────────────────────
228
228
 
229
229
  state.allResponseText += state.currentBlockText;
230
- const totalPrompt =
231
- state.sdkInputTokens + state.sdkCacheRead + state.sdkCacheWrite;
230
+ const cacheTotal = state.sdkInputTokens + state.sdkCacheRead;
232
231
  const cacheHitPct =
233
- totalPrompt > 0 ? Math.round((state.sdkCacheRead / totalPrompt) * 100) : 0;
232
+ cacheTotal > 0 ? Math.round((state.sdkCacheRead / cacheTotal) * 100) : 0;
234
233
 
235
234
  log(
236
235
  "agent",
@@ -267,9 +267,19 @@ export class Gateway {
267
267
  res.end(json);
268
268
  } catch (err) {
269
269
  if (res.headersSent) return;
270
- const msg = err instanceof Error ? err.message : String(err);
270
+ // Log full error (incl. stack via logError's structured `stack`
271
+ // field) on the server; return a generic message to the client so
272
+ // we don't leak implementation details.
273
+ // CodeQL: js/stack-trace-exposure (alert #4).
274
+ logError(
275
+ "gateway",
276
+ `Unhandled error on ${req.method} ${req.url}`,
277
+ err,
278
+ );
271
279
  res.writeHead(500, { "Content-Type": "application/json" });
272
- res.end(JSON.stringify({ ok: false, error: msg }));
280
+ res.end(
281
+ JSON.stringify({ ok: false, error: "Internal server error" }),
282
+ );
273
283
  }
274
284
  },
275
285
  );
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from "zod";
6
6
  import type { ToolDefinition } from "./types.js";
7
+ import { idSchema } from "./schemas.js";
7
8
 
8
9
  export const historyTools: ToolDefinition[] = [
9
10
  {
@@ -19,7 +20,7 @@ export const historyTools: ToolDefinition[] = [
19
20
  .string()
20
21
  .optional()
21
22
  .describe("Fetch messages before this date (ISO format)"),
22
- offset_id: z.number().optional().describe("Fetch before this message ID"),
23
+ offset_id: idSchema.optional().describe("Fetch before this message ID"),
23
24
  },
24
25
  execute: (params, bridge) =>
25
26
  bridge("read_history", {
@@ -58,7 +59,7 @@ export const historyTools: ToolDefinition[] = [
58
59
  {
59
60
  name: "get_message_by_id",
60
61
  description: "Get a specific message by ID.",
61
- schema: { message_id: z.number() },
62
+ schema: { message_id: idSchema },
62
63
  execute: (params, bridge) => bridge("get_message_by_id", params),
63
64
  frontends: ["telegram"],
64
65
  tag: "history",
@@ -69,9 +70,9 @@ export const historyTools: ToolDefinition[] = [
69
70
  description:
70
71
  "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.",
71
72
  schema: {
72
- message_id: z
73
- .number()
74
- .describe("Message ID containing the media to download"),
73
+ message_id: idSchema.describe(
74
+ "Message ID containing the media to download",
75
+ ),
75
76
  },
76
77
  execute: (params, bridge) => bridge("download_media", params),
77
78
  frontends: ["telegram"],
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from "zod";
6
6
  import type { ToolDefinition } from "./types.js";
7
+ import { idSchema } from "./schemas.js";
7
8
 
8
9
  export const memberTools: ToolDefinition[] = [
9
10
  {
@@ -19,7 +20,7 @@ export const memberTools: ToolDefinition[] = [
19
20
  {
20
21
  name: "get_member_info",
21
22
  description: "Get detailed info about a user by ID.",
22
- schema: { user_id: z.number() },
23
+ schema: { user_id: idSchema },
23
24
  execute: (params, bridge) => bridge("get_member_info", params),
24
25
  frontends: ["telegram"],
25
26
  tag: "members",
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from "zod";
6
6
  import type { ToolDefinition } from "./types.js";
7
+ import { idSchema } from "./schemas.js";
7
8
 
8
9
  export const messagingTools: ToolDefinition[] = [
9
10
  // ── Telegram unified send ─────────────────────────────────────────────
@@ -43,7 +44,7 @@ Examples:
43
44
  .string()
44
45
  .optional()
45
46
  .describe("Message text (for type=text). Supports Markdown."),
46
- reply_to: z.number().optional().describe("Message ID to reply to"),
47
+ reply_to: idSchema.optional().describe("Message ID to reply to"),
47
48
  file_path: z
48
49
  .string()
49
50
  .optional()
@@ -227,7 +228,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
227
228
  description:
228
229
  "Add an emoji reaction to a message. Valid: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉 🤩 🤮 💩 🙏 👌 🕊 🤡 🥱 🥴 😍 🐳 ❤‍🔥 🌚 🌭 💯 🤣 ⚡ 🍌 🏆 💔 🤨 😐 🍓 🍾 💋 🖕 😈 😴 😭 🤓 👻 👨‍💻 👀 🎃 🙈 😇 😨 🤝 ✍ 🤗 🫡 🎅 🎄 ☃ 💅 🤪 🗿 🆒 💘 🙉 🦄 😘 💊 🙊 😎 👾 🤷 🤷‍♂ 🤷‍♀ 😡",
229
230
  schema: {
230
- message_id: z.number().describe("Message ID"),
231
+ message_id: idSchema.describe("Message ID"),
231
232
  emoji: z.string().describe("Reaction emoji"),
232
233
  },
233
234
  execute: (params, bridge) => bridge("react", params),
@@ -239,7 +240,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
239
240
  {
240
241
  name: "edit_message",
241
242
  description: "Edit a previously sent message.",
242
- schema: { message_id: z.number(), text: z.string() },
243
+ schema: { message_id: idSchema, text: z.string() },
243
244
  execute: (params, bridge) => bridge("edit_message", params),
244
245
  frontends: ["telegram"],
245
246
  tag: "messaging",
@@ -249,7 +250,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
249
250
  {
250
251
  name: "delete_message",
251
252
  description: "Delete a message.",
252
- schema: { message_id: z.number() },
253
+ schema: { message_id: idSchema },
253
254
  execute: (params, bridge) => bridge("delete_message", params),
254
255
  frontends: ["telegram"],
255
256
  tag: "messaging",
@@ -259,7 +260,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
259
260
  {
260
261
  name: "forward_message",
261
262
  description: "Forward a message within the chat.",
262
- schema: { message_id: z.number() },
263
+ schema: { message_id: idSchema },
263
264
  execute: (params, bridge) => bridge("forward_message", params),
264
265
  frontends: ["telegram"],
265
266
  tag: "messaging",
@@ -269,7 +270,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
269
270
  {
270
271
  name: "pin_message",
271
272
  description: "Pin a message.",
272
- schema: { message_id: z.number() },
273
+ schema: { message_id: idSchema },
273
274
  execute: (params, bridge) => bridge("pin_message", params),
274
275
  frontends: ["telegram"],
275
276
  tag: "messaging",
@@ -279,7 +280,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
279
280
  {
280
281
  name: "unpin_message",
281
282
  description: "Unpin a message.",
282
- schema: { message_id: z.number().optional() },
283
+ schema: { message_id: idSchema.optional() },
283
284
  execute: (params, bridge) => bridge("unpin_message", params),
284
285
  frontends: ["telegram"],
285
286
  tag: "messaging",
@@ -291,7 +292,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
291
292
  description:
292
293
  "Stop an active poll and get the final results. Returns vote counts for each option.",
293
294
  schema: {
294
- message_id: z.number().describe("Message ID of the poll to stop"),
295
+ message_id: idSchema.describe("Message ID of the poll to stop"),
295
296
  },
296
297
  execute: (params, bridge) => bridge("stop_poll", params),
297
298
  frontends: ["telegram"],
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared zod schema fragments for tool input definitions.
3
+ */
4
+
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * Telegram-style positive integer ID (message_id, user_id, reply_to,
9
+ * offset_id, etc.).
10
+ *
11
+ * Accepts:
12
+ * - actual numbers that are positive integers (`2081`)
13
+ * - digit-only strings (`"2081"`) which are transformed into numbers
14
+ *
15
+ * Rejects:
16
+ * - non-numeric strings (`"abc"`, `"2081abc"`, `""`, `" "`)
17
+ * - booleans, `null`, `undefined` (would otherwise coerce to 0/1)
18
+ * - non-integer numbers (`1.5`)
19
+ * - zero and negatives
20
+ *
21
+ * Use this instead of `z.number()` or `z.coerce.number()` for any ID
22
+ * field on tool input schemas. The plain coercion path was too lax —
23
+ * `z.coerce.number().int()` happily turns `null`/`""` into `0` and
24
+ * `true` into `1`, both of which the Telegram bot API would then
25
+ * dispatch to. The strict union avoids that.
26
+ */
27
+ export const idSchema = z.union([
28
+ z.number().int().positive(),
29
+ z
30
+ .string()
31
+ .regex(/^\d+$/, "must be a positive integer")
32
+ .transform((s) => Number(s))
33
+ .pipe(z.number().int().positive()),
34
+ ]);
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { z } from "zod";
6
6
  import type { ToolDefinition } from "./types.js";
7
+ import { idSchema } from "./schemas.js";
7
8
 
8
9
  export const stickerTools: ToolDefinition[] = [
9
10
  {
@@ -56,7 +57,7 @@ The set name will automatically get "_by_<botname>" appended if needed.
56
57
 
57
58
  Example: create_sticker_set(user_id=123, name="cool_pack", title="Cool Stickers", file_path="/path/to/sticker.png", emoji_list=["😎"])`,
58
59
  schema: {
59
- user_id: z.number().describe("Telegram user ID who will own the pack"),
60
+ user_id: idSchema.describe("Telegram user ID who will own the pack"),
60
61
  name: z
61
62
  .string()
62
63
  .describe(
@@ -85,7 +86,7 @@ Example: create_sticker_set(user_id=123, name="cool_pack", title="Cool Stickers"
85
86
  description:
86
87
  "Add a new sticker to an existing sticker pack created by the bot.",
87
88
  schema: {
88
- user_id: z.number().describe("Telegram user ID who owns the pack"),
89
+ user_id: idSchema.describe("Telegram user ID who owns the pack"),
89
90
  name: z.string().describe("Sticker set name (including _by_<botname>)"),
90
91
  file_path: z.string().describe("Path to the sticker image file"),
91
92
  emoji_list: z
@@ -529,10 +529,9 @@ export function registerCommands(
529
529
  "\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
530
530
  const contextWarn = ctxPct >= 80 ? " \u26A0\uFE0F consider /reset" : "";
531
531
 
532
- const totalPrompt =
533
- displayInputTokens + displayCacheRead + displayCacheWrite;
532
+ const cacheTotal = displayInputTokens + displayCacheRead;
534
533
  const cacheHitPct =
535
- totalPrompt > 0 ? Math.round((displayCacheRead / totalPrompt) * 100) : 0;
534
+ cacheTotal > 0 ? Math.round((displayCacheRead / cacheTotal) * 100) : 0;
536
535
 
537
536
  const avgResponseMs =
538
537
  info.turns > 0 && u.totalResponseMs
package/src/util/log.ts CHANGED
@@ -128,7 +128,10 @@ export function logError(
128
128
  err?: unknown,
129
129
  ): void {
130
130
  if (err instanceof Error) {
131
- logger.error({ component, err: err.message }, message);
131
+ // Capture both the concise message (for log consumers that look at `err`)
132
+ // and the full stack (for diagnostics). pino-pretty renders the `stack`
133
+ // field on its own line; JSON consumers can read either field.
134
+ logger.error({ component, err: err.message, stack: err.stack }, message);
132
135
  } else if (err !== undefined) {
133
136
  logger.error({ component, err: String(err) }, message);
134
137
  } else {