telegram-agent-kit 0.3.1 → 0.4.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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![license](https://img.shields.io/npm/l/telegram-agent-kit.svg)](./LICENSE)
7
7
  [![types](https://img.shields.io/npm/types/telegram-agent-kit.svg)](https://www.typescriptlang.org/)
8
8
 
9
- ESM-only. Runs on **Node 18+, Bun, Deno, and the browser** (the formatting core).
9
+ ESM-only. Runs on **Node 20+, Bun, Deno, and the browser** (the formatting core).
10
10
 
11
11
  ---
12
12
 
@@ -111,6 +111,12 @@ async function call(method: string, body: unknown, signal?: AbortSignal) {
111
111
  > transport. If your bot has no rich endpoint, point them at plain `sendMessage`
112
112
  > and set `rich: false` — the kit then renders via HTML only.
113
113
 
114
+ > **Note:** `rich: true` means *rich when it buys something*, not *rich always*.
115
+ > Drafts and final messages go out rich only for text that needs the rich
116
+ > renderer — today, a GFM table (`needsRich`); everything else is classic HTML, so
117
+ > ordinary replies keep the client's normal message font. `rich: false` is still
118
+ > the kill-switch: HTML only, always.
119
+
114
120
  ## How it works
115
121
 
116
122
  The kit is three layers plus one optional adapter. The dependency direction is
@@ -166,6 +172,8 @@ the user in the chat instead of going silent.
166
172
  - `chunkText(text)` / `safeSlice(text, max)` / `chunkRich(md)` — surrogate-safe splitting
167
173
  (classic limit 4096, rich limit 32768).
168
174
  - `repairRichTables(md)` · `neutralizeRichMedia(md)` · `extractTrailingCover(reply)` — rich helpers.
175
+ - `needsRich(md)` — does this text need the rich renderer (today: does it contain a GFM
176
+ table, outside code)? The gate behind `rich: true`; everything else goes classic.
169
177
 
170
178
  **Draft engine**
171
179
 
@@ -181,6 +189,7 @@ the user in the chat instead of going silent.
181
189
  Pass `errorNotice` (plain text, your language) to have a failed turn say so in the chat —
182
190
  omit it and the user just sees the draft stop, which reads as being ignored.
183
191
  - `sendReply(client, chatId, reply, opts, signal?)` / `sendText(...)` — the send path on its own.
192
+ `opts` is `{ rich: boolean, log: Logger }`, with the same `rich` semantics as above.
184
193
  - Types: `BotClient`, `AgentStream`, `Checkpointer`, `ThreadStore`, `RenderEvent`, `ChatKey`, `Logger`.
185
194
 
186
195
  **Errors**
@@ -196,6 +205,10 @@ the user in the chat instead of going silent.
196
205
  `__pregel_*` LangGraph internal-execution key — are stripped so the kit retains full control over
197
206
  checkpoint routing and execution.
198
207
  - `streamAgent(agent, input, config, signal?)` — lower-level event stream if you need direct control.
208
+ Yields tokens from the **root** agent only: a delegated agent (a `subagents` entry, or the built-in
209
+ `task` tool) runs its own model node and LangChain replays its events into the parent stream, so
210
+ without this its monologue would interleave with the reply. Naming the root agent does not change
211
+ what streams.
199
212
 
200
213
  > `@langchain/core` and `deepagents` are **type-only, optional** peers. The built
201
214
  > `/deepagents` bundle contains no runtime import of either, so the core stays
@@ -18,6 +18,7 @@ async function* streamAgent(agent, input, config, signal) {
18
18
  for await (const ev of iter) {
19
19
  if (ev.event === "on_chat_model_stream") {
20
20
  if (ev.metadata?.langgraph_node !== "model_request") continue;
21
+ if (ev.metadata?.checkpoint_ns?.includes("|")) continue;
21
22
  const text = extractText(ev.data?.chunk?.content);
22
23
  if (text) yield { type: "token", text };
23
24
  }
package/dist/index.d.ts CHANGED
@@ -5,7 +5,17 @@ type SendOpts = {
5
5
  rich: boolean;
6
6
  log: Logger;
7
7
  };
8
- /** Active reply path — rich when opts.rich, else classic. */
8
+ /** Active reply path — rich when `opts.rich`, but `sendRich` sends each chunk
9
+ * rich only if it actually needs the rich renderer (`needsRich`), else classic.
10
+ * `rich: true` means "rich when it buys something", not "rich always": the rich
11
+ * renderer sizes body text its own way with no Bot API field to override it, so
12
+ * pushing ordinary prose through it just makes every reply look unlike a normal
13
+ * message for nothing.
14
+ *
15
+ * Media is neutralized HERE, before the split, so it happens on BOTH paths:
16
+ * classic has no image renderer either (`mdToTelegramHtml` leaves `![a](u)`
17
+ * verbatim), so prose that routes to classic would otherwise show raw image
18
+ * markdown. Idempotent, so `sendReply`'s photo-fallback re-entry is harmless. */
9
19
  declare function sendText(client: BotClient, chatId: number, text: string, opts: SendOpts, signal?: AbortSignal): Promise<void>;
10
20
  /** Final reply send for a completed turn (client.ts sendReply). */
11
21
  declare function sendReply(client: BotClient, chatId: number, reply: string, opts: SendOpts, signal?: AbortSignal): Promise<void>;
@@ -141,6 +151,27 @@ declare function mdToTelegramHtml(md: string, opts?: MdToHtmlOpts): string;
141
151
  * non-table prose (mid-body or trailing) is not eligible either (else the orphan
142
152
  * glues onto non-table content). Pure and total. */
143
153
  declare function repairRichTables(md: string): string;
154
+ /** Does `md` carry structure only the rich renderer can draw? Today that means
155
+ * one thing: a GFM table. Everything else a reply contains — bold, italic,
156
+ * code, links, quotes, lists — classic HTML renders fine, and classic keeps the
157
+ * client's normal message font, while the rich renderer sizes body text its own
158
+ * way with no Bot API knob to override it (the only size field in the whole
159
+ * rich schema is `RichBlockSectionHeading.size`). So routing prose to classic is
160
+ * the only way to keep ordinary replies looking like ordinary messages.
161
+ *
162
+ * Scanned per LINE, not per blank-line block: a table needs no blank line above
163
+ * it, and models routinely put one directly under a lead-in line, a heading, a
164
+ * list, or a closing fence. A block-first-line check misses every one of those
165
+ * (and every table after `\n\n\n` or in CRLF output, where the block split
166
+ * lands wrong) — and a missed table is the one case where classic is WORSE:
167
+ * it has no table renderer, so the reply goes out as literal pipes.
168
+ *
169
+ * Code regions are skipped, using the SAME `advanceCodeRegion` walk as
170
+ * `repairRichTables`: a table inside a fence or an HTML `<pre>`/`<code>` region
171
+ * is a literal example, and classic renders it as code perfectly well — it is
172
+ * not a reason to go rich. Only a header+delimiter pair counts; stray pipe rows
173
+ * print as literal pipes either way. Pure and total. */
174
+ declare function needsRich(md: string): boolean;
144
175
  /** Extract a standalone cover image that is the LAST non-empty line of `md`:
145
176
  * the whole line is a single `![alt](http(s)://…)` token. Returns the URL plus
146
177
  * the body (the reply with that line removed, trailing whitespace trimmed).
@@ -168,4 +199,4 @@ declare function extractTrailingCover(md: string): {
168
199
  * total. */
169
200
  declare function neutralizeRichMedia(md: string): string;
170
201
 
171
- export { AgentStream, BotClient, ChatKey, Checkpointer, DEFAULT_DRAFT_CONSTANTS, type DraftConstants, type DraftStreamer, type DraftStreamerDeps, Logger, type MdToHtmlOpts, type RunTelegramTurnOpts, TelegramApiError, ThreadStore, type TurnContext, chunkRich, chunkText, createDraftStreamer, extractTrailingCover, isBadRequest, mdToTelegramHtml, neutralizeRichMedia, repairRichTables, runTelegramTurn, safeSlice, sendReply, sendText };
202
+ export { AgentStream, BotClient, ChatKey, Checkpointer, DEFAULT_DRAFT_CONSTANTS, type DraftConstants, type DraftStreamer, type DraftStreamerDeps, Logger, type MdToHtmlOpts, type RunTelegramTurnOpts, TelegramApiError, ThreadStore, type TurnContext, chunkRich, chunkText, createDraftStreamer, extractTrailingCover, isBadRequest, mdToTelegramHtml, needsRich, neutralizeRichMedia, repairRichTables, runTelegramTurn, safeSlice, sendReply, sendText };
package/dist/index.js CHANGED
@@ -365,6 +365,16 @@ ${block}`;
365
365
  }
366
366
  return out.join("\n\n");
367
367
  }
368
+ function needsRich(md) {
369
+ if (!md.includes("|")) return false;
370
+ const lines = md.split("\n");
371
+ let region = null;
372
+ for (let i = 0; i + 1 < lines.length; i++) {
373
+ if (region === null && isTableBlock(lines.slice(i, i + 2))) return true;
374
+ region = advanceCodeRegion(region, [lines[i] ?? ""]);
375
+ }
376
+ return false;
377
+ }
368
378
  var TRAILING_COVER_RE = /^!\[[^\]]*\]\(\s*(https?:\/\/[^\s)]+)[^)]*\)$/;
369
379
  function insideCodeRegion(lines, idx) {
370
380
  return advanceCodeRegion(null, lines.slice(0, idx)) !== null;
@@ -461,9 +471,11 @@ async function sendClassic(client, chatId, text, signal) {
461
471
  }
462
472
  }
463
473
  async function sendRich(client, chatId, markdown, log, signal) {
464
- for (const piece of chunkRich(
465
- neutralizeRichMedia(repairRichTables(markdown))
466
- )) {
474
+ for (const piece of chunkRich(repairRichTables(markdown))) {
475
+ if (!needsRich(piece)) {
476
+ await sendClassic(client, chatId, piece, signal);
477
+ continue;
478
+ }
467
479
  try {
468
480
  await client.sendRichMessage({ chatId, markdown: piece }, signal);
469
481
  } catch (err) {
@@ -479,8 +491,9 @@ async function sendRich(client, chatId, markdown, log, signal) {
479
491
  }
480
492
  }
481
493
  async function sendText(client, chatId, text, opts, signal) {
482
- if (opts.rich) await sendRich(client, chatId, text, opts.log, signal);
483
- else await sendClassic(client, chatId, text, signal);
494
+ const md = neutralizeRichMedia(text);
495
+ if (opts.rich) await sendRich(client, chatId, md, opts.log, signal);
496
+ else await sendClassic(client, chatId, md, signal);
484
497
  }
485
498
  async function sendCover(client, chatId, url, caption, signal) {
486
499
  if (caption === void 0 || caption === "") {
@@ -519,7 +532,7 @@ async function sendReply(client, chatId, reply, opts, signal) {
519
532
  else await sendCover(client, chatId, cover.url, cover.body, signal);
520
533
  } catch (err) {
521
534
  if (!isBadRequest(err)) throw err;
522
- await sendText(client, chatId, neutralizeRichMedia(reply), opts, signal);
535
+ await sendText(client, chatId, reply, opts, signal);
523
536
  return;
524
537
  }
525
538
  if (longBody) await sendText(client, chatId, cover.body, opts, signal);
@@ -574,7 +587,7 @@ function createDraftStreamer(deps) {
574
587
  const controller = new AbortController();
575
588
  inFlightController = controller;
576
589
  lastAttemptAt = t;
577
- const usingRich = richMode;
590
+ const usingRich = richMode && needsRich(text);
578
591
  const op = usingRich ? client.sendRichMessageDraft(
579
592
  { chatId, draftId, markdown: text },
580
593
  controller.signal
@@ -802,6 +815,7 @@ export {
802
815
  extractTrailingCover,
803
816
  isBadRequest,
804
817
  mdToTelegramHtml,
818
+ needsRich,
805
819
  neutralizeRichMedia,
806
820
  repairRichTables,
807
821
  runTelegramTurn,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telegram-agent-kit",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Render markdown to Telegram, stream agent replies into live drafts, and drive a snapshot/rollback turn-loop — runtime-agnostic.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "esm"
29
29
  ],
30
30
  "engines": {
31
- "node": ">=18"
31
+ "node": ">=20"
32
32
  },
33
33
  "files": [
34
34
  "dist"
@@ -65,10 +65,10 @@
65
65
  "devDependencies": {
66
66
  "@biomejs/biome": "^2.4.0",
67
67
  "@langchain/core": "^1.2.0",
68
- "@types/node": "^22.0.0",
68
+ "@types/node": "^24.13.3",
69
69
  "deepagents": "^1.10.5",
70
70
  "tsup": "^8.3.0",
71
- "typescript": "^5.6.0",
72
- "vitest": "^2.1.0"
71
+ "typescript": "^6.0.3",
72
+ "vitest": "^4.1.10"
73
73
  }
74
74
  }