@fastagent-sh/fastagent 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/dist/agent.d.ts +72 -0
  4. package/dist/agent.js +14 -0
  5. package/dist/channels/body.d.ts +9 -0
  6. package/dist/channels/body.js +29 -0
  7. package/dist/channels/github/github.d.ts +31 -0
  8. package/dist/channels/github/github.js +80 -0
  9. package/dist/channels/github/scaffold/channel.ts +25 -0
  10. package/dist/channels/http.d.ts +13 -0
  11. package/dist/channels/http.js +161 -0
  12. package/dist/channels/respond.d.ts +5 -0
  13. package/dist/channels/respond.js +3 -0
  14. package/dist/channels/telegram/context-buffer.d.ts +59 -0
  15. package/dist/channels/telegram/context-buffer.js +139 -0
  16. package/dist/channels/telegram/invoke-turn.d.ts +53 -0
  17. package/dist/channels/telegram/invoke-turn.js +127 -0
  18. package/dist/channels/telegram/parse.d.ts +160 -0
  19. package/dist/channels/telegram/parse.js +153 -0
  20. package/dist/channels/telegram/preview.d.ts +22 -0
  21. package/dist/channels/telegram/preview.js +266 -0
  22. package/dist/channels/telegram/register-webhook.d.ts +13 -0
  23. package/dist/channels/telegram/register-webhook.js +73 -0
  24. package/dist/channels/telegram/scaffold/channel.ts +25 -0
  25. package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
  26. package/dist/channels/telegram/state.d.ts +9 -0
  27. package/dist/channels/telegram/state.js +58 -0
  28. package/dist/channels/telegram/telegram-api.d.ts +77 -0
  29. package/dist/channels/telegram/telegram-api.js +345 -0
  30. package/dist/channels/telegram/telegram.d.ts +35 -0
  31. package/dist/channels/telegram/telegram.js +352 -0
  32. package/dist/channels/telegram/turn-queue.d.ts +21 -0
  33. package/dist/channels/telegram/turn-queue.js +47 -0
  34. package/dist/channels/telegram/turn-store.d.ts +38 -0
  35. package/dist/channels/telegram/turn-store.js +150 -0
  36. package/dist/cli-auth.d.ts +24 -0
  37. package/dist/cli-auth.js +30 -0
  38. package/dist/cli-models.d.ts +11 -0
  39. package/dist/cli-models.js +20 -0
  40. package/dist/cli.d.ts +2 -0
  41. package/dist/cli.js +1288 -0
  42. package/dist/collect.d.ts +20 -0
  43. package/dist/collect.js +27 -0
  44. package/dist/core.d.ts +10 -0
  45. package/dist/core.js +8 -0
  46. package/dist/deploy/container.d.ts +56 -0
  47. package/dist/deploy/container.js +169 -0
  48. package/dist/deploy/fly/plan.d.ts +65 -0
  49. package/dist/deploy/fly/plan.js +142 -0
  50. package/dist/deploy/fly/run.d.ts +51 -0
  51. package/dist/deploy/fly/run.js +89 -0
  52. package/dist/deploy/preflight.d.ts +59 -0
  53. package/dist/deploy/preflight.js +191 -0
  54. package/dist/deploy/railway/plan.d.ts +48 -0
  55. package/dist/deploy/railway/plan.js +91 -0
  56. package/dist/deploy/railway/run.d.ts +81 -0
  57. package/dist/deploy/railway/run.js +176 -0
  58. package/dist/deploy/runner.d.ts +18 -0
  59. package/dist/deploy/runner.js +25 -0
  60. package/dist/deploy/secrets.d.ts +49 -0
  61. package/dist/deploy/secrets.js +88 -0
  62. package/dist/dev-supervisor.d.ts +14 -0
  63. package/dist/dev-supervisor.js +158 -0
  64. package/dist/engines/pi/auth.d.ts +17 -0
  65. package/dist/engines/pi/auth.js +118 -0
  66. package/dist/engines/pi/channel.d.ts +31 -0
  67. package/dist/engines/pi/channel.js +113 -0
  68. package/dist/engines/pi/chat.d.ts +19 -0
  69. package/dist/engines/pi/chat.js +200 -0
  70. package/dist/engines/pi/config.d.ts +110 -0
  71. package/dist/engines/pi/config.js +285 -0
  72. package/dist/engines/pi/create.d.ts +127 -0
  73. package/dist/engines/pi/create.js +222 -0
  74. package/dist/engines/pi/definition.d.ts +72 -0
  75. package/dist/engines/pi/definition.js +129 -0
  76. package/dist/engines/pi/harness.d.ts +50 -0
  77. package/dist/engines/pi/harness.js +41 -0
  78. package/dist/engines/pi/invoke.d.ts +28 -0
  79. package/dist/engines/pi/invoke.js +298 -0
  80. package/dist/engines/pi/login.d.ts +51 -0
  81. package/dist/engines/pi/login.js +125 -0
  82. package/dist/engines/pi/models.d.ts +41 -0
  83. package/dist/engines/pi/models.js +76 -0
  84. package/dist/engines/pi/report.d.ts +13 -0
  85. package/dist/engines/pi/report.js +21 -0
  86. package/dist/engines/pi/sessions.d.ts +15 -0
  87. package/dist/engines/pi/sessions.js +130 -0
  88. package/dist/engines/pi/tool-context.d.ts +17 -0
  89. package/dist/engines/pi/tool-context.js +13 -0
  90. package/dist/engines/pi/tool.d.ts +45 -0
  91. package/dist/engines/pi/tool.js +85 -0
  92. package/dist/engines/pi/wake-tool.d.ts +20 -0
  93. package/dist/engines/pi/wake-tool.js +110 -0
  94. package/dist/engines/pi/workspace.d.ts +53 -0
  95. package/dist/engines/pi/workspace.js +70 -0
  96. package/dist/env.d.ts +24 -0
  97. package/dist/env.js +57 -0
  98. package/dist/github.d.ts +3 -0
  99. package/dist/github.js +2 -0
  100. package/dist/host/node.d.ts +41 -0
  101. package/dist/host/node.js +50 -0
  102. package/dist/index.d.ts +2 -0
  103. package/dist/index.js +5 -0
  104. package/dist/invoke-stream.d.ts +10 -0
  105. package/dist/invoke-stream.js +26 -0
  106. package/dist/loader.d.ts +38 -0
  107. package/dist/loader.js +69 -0
  108. package/dist/log.d.ts +26 -0
  109. package/dist/log.js +51 -0
  110. package/dist/observe.d.ts +12 -0
  111. package/dist/observe.js +44 -0
  112. package/dist/pi.d.ts +16 -0
  113. package/dist/pi.js +12 -0
  114. package/dist/proxy.d.ts +9 -0
  115. package/dist/proxy.js +23 -0
  116. package/dist/runtime.d.ts +26 -0
  117. package/dist/runtime.js +29 -0
  118. package/dist/scaffold/add-channel.d.ts +46 -0
  119. package/dist/scaffold/add-channel.js +227 -0
  120. package/dist/scaffold/init.d.ts +54 -0
  121. package/dist/scaffold/init.js +249 -0
  122. package/dist/scaffold/templates/env.example +24 -0
  123. package/dist/scaffold/templates/fastagent.config.mjs +19 -0
  124. package/dist/scaffold/templates/gitignore +8 -0
  125. package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
  126. package/dist/scaffold/templates/gitignore.kit +2 -0
  127. package/dist/scaffold/templates/persona.md +10 -0
  128. package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
  129. package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
  130. package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
  131. package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
  132. package/dist/scaffold/templates.d.ts +17 -0
  133. package/dist/scaffold/templates.js +56 -0
  134. package/dist/scaffold/vendor-skill.d.ts +24 -0
  135. package/dist/scaffold/vendor-skill.js +157 -0
  136. package/dist/schedule/audit.d.ts +18 -0
  137. package/dist/schedule/audit.js +57 -0
  138. package/dist/schedule/cron.d.ts +7 -0
  139. package/dist/schedule/cron.js +33 -0
  140. package/dist/schedule/discover.d.ts +17 -0
  141. package/dist/schedule/discover.js +64 -0
  142. package/dist/schedule/schedule.d.ts +35 -0
  143. package/dist/schedule/schedule.js +23 -0
  144. package/dist/schedule/scheduler.d.ts +36 -0
  145. package/dist/schedule/scheduler.js +253 -0
  146. package/dist/schedule/state.d.ts +14 -0
  147. package/dist/schedule/state.js +56 -0
  148. package/dist/schedule/wakeups.d.ts +82 -0
  149. package/dist/schedule/wakeups.js +196 -0
  150. package/dist/telegram.d.ts +2 -0
  151. package/dist/telegram.js +2 -0
  152. package/dist/tunnel.d.ts +31 -0
  153. package/dist/tunnel.js +139 -0
  154. package/dist/version.d.ts +5 -0
  155. package/dist/version.js +13 -0
  156. package/dist/workspace.d.ts +9 -0
  157. package/dist/workspace.js +45 -0
  158. package/package.json +97 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Telegram protocol parsing β€” PURE: message field extraction, the prompt envelope, and the summon/route
3
+ * policy. The defining invariant is purity: no state, no IO, no Bot API calls β€” plain data-in β†’ data-out.
4
+ * In telegram.ts's pipeline (verify β†’ decide via `route` β†’ run the turn β†’ stream reply), this is the
5
+ * "decide" and prompt-building half; telegram.ts wires it in and owns the stateful lifecycle. Kept
6
+ * separate so this layer tests as plain functions and reads without the factory's noise.
7
+ */
8
+ /** A Telegram message (the common subset; `[k]` keeps the rest reachable without a types dependency). */
9
+ export interface TelegramMessage {
10
+ message_id: number;
11
+ text?: string;
12
+ /** Entities Telegram's server parsed out of `text` (mentions, commands, URLs, code…). Offsets are
13
+ * UTF-16 code units β€” exactly JS string indexing, so `text.slice(offset, offset + length)` is the
14
+ * entity's text verbatim. */
15
+ entities?: {
16
+ type: string;
17
+ offset: number;
18
+ length: number;
19
+ [k: string]: unknown;
20
+ }[];
21
+ /** Caption on a media message (photo/document/…) β€” often the user's instruction for the attachment. */
22
+ caption?: string;
23
+ /** Entities of `caption`, same shape as {@link TelegramMessage.entities}. */
24
+ caption_entities?: {
25
+ type: string;
26
+ offset: number;
27
+ length: number;
28
+ [k: string]: unknown;
29
+ }[];
30
+ /** Photo sizes, smallest β†’ largest. The channel sends the largest to the model as a vision image. */
31
+ photo?: {
32
+ file_id: string;
33
+ file_unique_id: string;
34
+ width: number;
35
+ height: number;
36
+ file_size?: number;
37
+ }[];
38
+ /** Structured payloads worth rendering into the prompt as text (no new modality needed). */
39
+ location?: {
40
+ latitude: number;
41
+ longitude: number;
42
+ [k: string]: unknown;
43
+ };
44
+ contact?: {
45
+ phone_number?: string;
46
+ first_name: string;
47
+ last_name?: string;
48
+ [k: string]: unknown;
49
+ };
50
+ poll?: {
51
+ question: string;
52
+ options?: {
53
+ text: string;
54
+ }[];
55
+ [k: string]: unknown;
56
+ };
57
+ /** Files β€” the channel downloads document/voice/video/audio on a routed message to disk. */
58
+ document?: {
59
+ file_id: string;
60
+ file_name?: string;
61
+ mime_type?: string;
62
+ file_size?: number;
63
+ [k: string]: unknown;
64
+ };
65
+ voice?: {
66
+ file_id: string;
67
+ [k: string]: unknown;
68
+ };
69
+ video?: {
70
+ file_id: string;
71
+ [k: string]: unknown;
72
+ };
73
+ audio?: {
74
+ file_id: string;
75
+ [k: string]: unknown;
76
+ };
77
+ /** Present in Threaded Mode (topics in private chats); reply with the same id to stay in-thread. */
78
+ message_thread_id?: number;
79
+ /** The message this one replies to, if any β€” inject its text/media so the agent has the referent. */
80
+ reply_to_message?: TelegramMessage;
81
+ chat: {
82
+ id: number;
83
+ type: string;
84
+ [k: string]: unknown;
85
+ };
86
+ from?: {
87
+ id: number;
88
+ username?: string;
89
+ is_bot?: boolean;
90
+ first_name?: string;
91
+ [k: string]: unknown;
92
+ };
93
+ [k: string]: unknown;
94
+ }
95
+ /** A Telegram update (the common subset the channel ACTS on β€” an update kind not listed here is ACKed
96
+ * and dropped before `route` sees it, so listing it would be a false promise; `[k]` keeps the raw
97
+ * payload reachable). Narrow for what you route on, e.g. `update.message?.text`. */
98
+ export interface TelegramUpdate {
99
+ update_id: number;
100
+ message?: TelegramMessage;
101
+ channel_post?: TelegramMessage;
102
+ [k: string]: unknown;
103
+ }
104
+ /** What `route` returns: act with these (every field optional β€” omitted ones default from the message), or null to ignore. */
105
+ export interface TelegramRoute {
106
+ /** Conversation identity (default: `chat` or `chat:thread`). */
107
+ session?: string;
108
+ /** Reply target chat (default: the message's chat). */
109
+ chatId?: number | string;
110
+ /** Reply thread (default: the message's thread). */
111
+ threadId?: number;
112
+ /** Base prompt (default: {@link telegramEnvelope}); the channel still appends attachments + the HTML hint. */
113
+ text?: string;
114
+ }
115
+ /** The actionable message in an update (a fresh message or channel post). Edits (`edited_message` /
116
+ * `edited_channel_post`) are deliberately NOT actionable: answering them re-answers every typo fix (a
117
+ * duplicate reply per edit), so an edited message changes nothing β€” the standard bot behavior. The
118
+ * trade-off: editing a mention INTO an old message does not summon either; send a new message. */
119
+ export declare function pickMessage(update: TelegramUpdate): TelegramMessage | undefined;
120
+ /** file_ids to send the model as vision images: this message's largest photo + a replied-to photo. */
121
+ export declare function extractImages(m: TelegramMessage): string[];
122
+ /** The message's OWN photo (largest size), without the replied-to message's β€” the buffer path uses
123
+ * this: each message is its own entry there, so counting the replied-to attachment again would
124
+ * duplicate it and squeeze the attachment cap (the reply relation is expressed by `replyTo` instead). */
125
+ export declare function ownImages(m: TelegramMessage): string[];
126
+ /** file_ids to download to disk for the agent's tools: this message's files + a replied-to message's
127
+ * (so "summarize this file" works when the user replies to a document with the mention). */
128
+ export declare function extractFiles(m: TelegramMessage): string[];
129
+ /** The message's OWN files, without the replied-to message's (see {@link ownImages} for why). */
130
+ export declare function ownFiles(m: TelegramMessage): string[];
131
+ /** A stable sender label for attribution. In a shared (multi-user) session the model must tell who is
132
+ * who across turns; a username-less user still gets a name + id rather than vanishing. */
133
+ export declare function fromLabel(from: TelegramMessage["from"]): string | undefined;
134
+ /** A one-line description of a message's attachment, so the envelope names what was sent even before
135
+ * the agent opens it (and so a media-only message isn't blank). */
136
+ export declare function attachmentSummary(m: TelegramMessage): string | undefined;
137
+ /** A one-line, length-capped rendering of a message's content for the context buffer. */
138
+ export declare function messageText(m: TelegramMessage): string;
139
+ /**
140
+ * The default base prompt: a context envelope (chat/thread/sender + a group note + reply) then the
141
+ * user's text/caption and a compact rendering of structured payloads (location/contact/poll). The
142
+ * sender is named on every message and a group chat is flagged β€” in a shared multi-user session that is
143
+ * how the model tells participants apart and knows it is not a 1:1. The reply
144
+ * block carries the replied-to sender, message id, and text/caption or an attachment summary (and the
145
+ * channel downloads a replied-to file/photo too). Exported so a custom `route` can reuse it, e.g.
146
+ * `text: `${telegramEnvelope(m)}\n\n[extra]``. The channel still appends downloaded attachments.
147
+ */
148
+ export declare function telegramEnvelope(m: TelegramMessage): string;
149
+ /**
150
+ * The default routing policy (used when `route` is omitted; exported so a custom route can reuse it):
151
+ * answer private chats always; a group only on a reply to THIS bot (by `botId`) or a `mention` entity
152
+ * naming it (when `botUsername` is supplied β€” telegramChannel parses the id from the token and resolves
153
+ * the username via getMe). A bare or directed slash command does NOT summon in a group (that was noisy;
154
+ * a bot author who wants commands adds a custom route). Returns `{}` (act; the channel fills
155
+ * session/target/prompt from the message) or `null` (ignore).
156
+ */
157
+ export declare function defaultTelegramRoute(update: TelegramUpdate, options?: {
158
+ botUsername?: string;
159
+ botId?: number;
160
+ }): TelegramRoute | null;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Telegram protocol parsing β€” PURE: message field extraction, the prompt envelope, and the summon/route
3
+ * policy. The defining invariant is purity: no state, no IO, no Bot API calls β€” plain data-in β†’ data-out.
4
+ * In telegram.ts's pipeline (verify β†’ decide via `route` β†’ run the turn β†’ stream reply), this is the
5
+ * "decide" and prompt-building half; telegram.ts wires it in and owns the stateful lifecycle. Kept
6
+ * separate so this layer tests as plain functions and reads without the factory's noise.
7
+ */
8
+ /** The actionable message in an update (a fresh message or channel post). Edits (`edited_message` /
9
+ * `edited_channel_post`) are deliberately NOT actionable: answering them re-answers every typo fix (a
10
+ * duplicate reply per edit), so an edited message changes nothing β€” the standard bot behavior. The
11
+ * trade-off: editing a mention INTO an old message does not summon either; send a new message. */
12
+ export function pickMessage(update) {
13
+ return update.message ?? update.channel_post;
14
+ }
15
+ /** file_ids to send the model as vision images: this message's largest photo + a replied-to photo. */
16
+ export function extractImages(m) {
17
+ return [...ownImages(m), ...(m.reply_to_message ? ownImages(m.reply_to_message) : [])];
18
+ }
19
+ /** The message's OWN photo (largest size), without the replied-to message's β€” the buffer path uses
20
+ * this: each message is its own entry there, so counting the replied-to attachment again would
21
+ * duplicate it and squeeze the attachment cap (the reply relation is expressed by `replyTo` instead). */
22
+ export function ownImages(m) {
23
+ return [m.photo?.at(-1)?.file_id].filter((id) => Boolean(id));
24
+ }
25
+ /** file_ids to download to disk for the agent's tools: this message's files + a replied-to message's
26
+ * (so "summarize this file" works when the user replies to a document with the mention). */
27
+ export function extractFiles(m) {
28
+ return [...ownFiles(m), ...(m.reply_to_message ? ownFiles(m.reply_to_message) : [])];
29
+ }
30
+ /** The message's OWN files, without the replied-to message's (see {@link ownImages} for why). */
31
+ export function ownFiles(m) {
32
+ return [m.document?.file_id, m.voice?.file_id, m.video?.file_id, m.audio?.file_id].filter((id) => Boolean(id));
33
+ }
34
+ /** A stable sender label for attribution. In a shared (multi-user) session the model must tell who is
35
+ * who across turns; a username-less user still gets a name + id rather than vanishing. */
36
+ export function fromLabel(from) {
37
+ if (!from)
38
+ return undefined;
39
+ return from.username ? `@${from.username}` : `${from.first_name ?? "user"} (id ${from.id})`;
40
+ }
41
+ /** A one-line description of a message's attachment, so the envelope names what was sent even before
42
+ * the agent opens it (and so a media-only message isn't blank). */
43
+ export function attachmentSummary(m) {
44
+ if (m.photo?.length)
45
+ return "[photo]";
46
+ if (m.document) {
47
+ return `[document: ${m.document.file_name ?? "file"}${m.document.mime_type ? ` (${m.document.mime_type})` : ""}]`;
48
+ }
49
+ if (m.voice)
50
+ return "[voice message]";
51
+ if (m.video)
52
+ return "[video]";
53
+ if (m.audio)
54
+ return "[audio]";
55
+ return undefined;
56
+ }
57
+ /** A message's readable body: its text, else its caption, else a one-line attachment summary; undefined
58
+ * for an empty/service message. The single source for "what did this message say" β€” envelope, reply-
59
+ * quote, and the context-buffer line all read through it so their fallbacks cannot drift apart. */
60
+ function bodyOf(m) {
61
+ return m.text ?? m.caption ?? attachmentSummary(m);
62
+ }
63
+ /** A one-line, length-capped rendering of a message's content for the context buffer. */
64
+ export function messageText(m) {
65
+ return (bodyOf(m) ?? "").replace(/\s+/g, " ").trim().slice(0, 280);
66
+ }
67
+ /**
68
+ * The default base prompt: a context envelope (chat/thread/sender + a group note + reply) then the
69
+ * user's text/caption and a compact rendering of structured payloads (location/contact/poll). The
70
+ * sender is named on every message and a group chat is flagged β€” in a shared multi-user session that is
71
+ * how the model tells participants apart and knows it is not a 1:1. The reply
72
+ * block carries the replied-to sender, message id, and text/caption or an attachment summary (and the
73
+ * channel downloads a replied-to file/photo too). Exported so a custom `route` can reuse it, e.g.
74
+ * `text: `${telegramEnvelope(m)}\n\n[extra]``. The channel still appends downloaded attachments.
75
+ */
76
+ export function telegramEnvelope(m) {
77
+ const r = m.reply_to_message;
78
+ const meta = [
79
+ `chat ${m.chat.id} (${m.chat.type})`,
80
+ m.message_thread_id ? `thread ${m.message_thread_id}` : undefined,
81
+ fromLabel(m.from) ? `from ${fromLabel(m.from)}` : undefined,
82
+ ]
83
+ .filter(Boolean)
84
+ .join(", ");
85
+ // In a shared group session the model sees turns from different people (each `from`-tagged); tell it
86
+ // so it addresses participants by name and does not assume one continuous interlocutor. A 1:1 DM is
87
+ // self-evident, so no note there.
88
+ const isGroup = m.chat.type === "group" || m.chat.type === "supergroup";
89
+ const scope = isGroup ? "\n[group chat β€” multiple people; each message is prefixed with its sender]" : "";
90
+ const replyTo = r
91
+ ? `\n[in reply to ${fromLabel(r.from) ?? `msg ${r.message_id}`} (msg ${r.message_id}): ${(bodyOf(r) ?? "(empty)").slice(0, 280)}]`
92
+ : "";
93
+ const parts = [bodyOf(m) ?? ""];
94
+ if (m.location)
95
+ parts.push(`[location: ${m.location.latitude},${m.location.longitude}]`);
96
+ if (m.contact)
97
+ parts.push(`[contact: ${m.contact.first_name} ${m.contact.phone_number ?? ""}]`);
98
+ if (m.poll)
99
+ parts.push(`[poll: ${m.poll.question} β€” ${(m.poll.options ?? []).map((o) => o.text).join(" / ")}]`);
100
+ return `[telegram: ${meta}]${scope}${replyTo}\n${parts.filter(Boolean).join("\n")}`;
101
+ }
102
+ /** Normalize a configured bot username: drop a leading `@`, trim, lowercase (usernames are case-
103
+ * insensitive). Undefined when unknown. */
104
+ function botName(botUsername) {
105
+ const s = botUsername?.replace(/^@/, "").trim().toLowerCase();
106
+ return s || undefined;
107
+ }
108
+ /**
109
+ * Whether the message @mentions the bot β€” read from the `mention` ENTITIES Telegram's server already
110
+ * parsed, not a regex over the raw text. The entity type excludes by construction what a text scan
111
+ * false-matches: `@bot` inside a code block or a URL is not a `mention` entity, a glued `/cmd@bot` is a
112
+ * `bot_command` β€” and slicing the exact offset/length range makes `@fast` vs `@fastagent` confusion
113
+ * impossible. (Offsets are UTF-16 code units = native JS string indexing.) No text fallback: mention
114
+ * entities are produced server-side, so their absence means there is no mention.
115
+ */
116
+ function mentionsBot(m, botUsername) {
117
+ const name = botName(botUsername);
118
+ if (!name)
119
+ return false;
120
+ const text = m.text ?? m.caption ?? "";
121
+ const entities = (m.text !== undefined ? m.entities : m.caption_entities) ?? [];
122
+ return entities.some((e) => e.type === "mention" && text.slice(e.offset, e.offset + e.length).toLowerCase() === `@${name}`);
123
+ }
124
+ /** Whether the message replies to THIS bot β€” not just any bot: in a multi-bot group, replying to
125
+ * another bot must not summon ours. Identity is the bot's numeric id (stable; a username is a mutable
126
+ * handle) β€” telegramChannel parses it synchronously from the token, so there is no resolution race.
127
+ * Without an id, fall back to username; with NEITHER, fail closed (false): answering "is this a reply
128
+ * to me?" with "I don't know who I am, so yes" would mis-summon in every multi-bot group β€” a caller
129
+ * reusing the route bare must supply an identity to get reply summon. */
130
+ function repliesToBot(m, options) {
131
+ const r = m.reply_to_message?.from;
132
+ if (r?.is_bot !== true)
133
+ return false;
134
+ if (options?.botId !== undefined)
135
+ return r.id === options.botId;
136
+ const name = botName(options?.botUsername);
137
+ return name !== undefined && r.username?.toLowerCase() === name;
138
+ }
139
+ /**
140
+ * The default routing policy (used when `route` is omitted; exported so a custom route can reuse it):
141
+ * answer private chats always; a group only on a reply to THIS bot (by `botId`) or a `mention` entity
142
+ * naming it (when `botUsername` is supplied β€” telegramChannel parses the id from the token and resolves
143
+ * the username via getMe). A bare or directed slash command does NOT summon in a group (that was noisy;
144
+ * a bot author who wants commands adds a custom route). Returns `{}` (act; the channel fills
145
+ * session/target/prompt from the message) or `null` (ignore).
146
+ */
147
+ export function defaultTelegramRoute(update, options) {
148
+ const m = pickMessage(update);
149
+ if (!m)
150
+ return null;
151
+ const summoned = m.chat.type === "private" || repliesToBot(m, options) || mentionsBot(m, options?.botUsername);
152
+ return summoned ? {} : null;
153
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Live-preview rendering: consume one turn's event stream into a Telegram chat. A single real message
3
+ * is sent once ("πŸ’­ Thinking…", or an already-sent "⏳ queued" notice is taken over), then
4
+ * editMessageText'd in place with the latest tool calls + partial text (PLAIN β€” a partial answer may
5
+ * carry unbalanced HTML); on completion the same message is edited into the final answer as HTML. One
6
+ * message, works in groups and private (unlike sendMessageDraft, which is private/forum-topic only).
7
+ */
8
+ import type { AgentEvent } from "../../agent.ts";
9
+ import { type Target } from "./telegram-api.ts";
10
+ /** A terminal failure, as the channel hands it to `onError`. */
11
+ export interface TelegramFailure {
12
+ details: string;
13
+ retryable: boolean;
14
+ }
15
+ /** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
16
+ export declare function defaultErrorMessage(failed: TelegramFailure): string;
17
+ /**
18
+ * Consume one turn's event stream into a Telegram chat, live (see the module header for the preview
19
+ * model). Preview edits are best-effort (logged once if they fail); the final write is authoritative
20
+ * and surfaces a real failure (bad token, etc.).
21
+ */
22
+ export declare function streamReply(events: AsyncIterable<AgentEvent>, api: string, botToken: string, target: Target, formatError: (failed: TelegramFailure) => string | undefined, previewId?: number): Promise<void>;
@@ -0,0 +1,266 @@
1
+ import { log } from "../../log.js";
2
+ import { TELEGRAM_MAX_TEXT, callApi, editMessageText, sendMessage } from "./telegram-api.js";
3
+ /** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
4
+ export function defaultErrorMessage(failed) {
5
+ return failed.retryable ? "⚠️ Temporary problem β€” please try again." : "⚠️ Sorry, something went wrong.";
6
+ }
7
+ /** How often (ms) to edit the live-preview message; tool events still flush on the next loop. Edits to
8
+ * one message are rate-limited tighter than sends, so pace them ~1.5s (vs every token). Doubles as the
9
+ * answer-preview aging window (see answerView): partial answer text stays hidden until it has existed
10
+ * this long β€” one knob, same order of magnitude. */
11
+ const EDIT_THROTTLE_MS = 1500;
12
+ /** Max length of a tool's arg preview in the live view. */
13
+ const TOOL_ARG_MAX = 48;
14
+ /** How much of the (growing) reasoning to peek at in the live view β€” the most recent tail. */
15
+ const THINKING_PREVIEW = 280;
16
+ /** One-line, truncated: collapse whitespace so a multi-line command/arg stays on one line. */
17
+ function clip(s) {
18
+ const one = s.replace(/\s+/g, " ").trim();
19
+ return one.length > TOOL_ARG_MAX ? `${one.slice(0, TOOL_ARG_MAX - 1)}…` : one;
20
+ }
21
+ /**
22
+ * A compact, human-readable preview of a tool call's args so the live view reads `πŸ”§ read AGENTS.md`
23
+ * rather than just `πŸ”§ read`. Generic (the channel knows no tool schemas): show the salient value β€” the
24
+ * first primitive field, conventionally the subject (path / command / query / url) β€” else compact JSON.
25
+ */
26
+ function summarizeArgs(args) {
27
+ if (args === null || typeof args !== "object" || Array.isArray(args))
28
+ return clip(String(args));
29
+ const values = Object.values(args);
30
+ const primary = values.find((v) => typeof v === "string" || typeof v === "number");
31
+ if (primary !== undefined)
32
+ return clip(String(primary));
33
+ return values.length > 0 ? clip(JSON.stringify(args)) : "";
34
+ }
35
+ /**
36
+ * The terminal-write POLICY: resolve the single preview message into `text`. streamReply owns the
37
+ * preview lifecycle, so this composition of transport primitives lives here, not in telegram-api. One
38
+ * message β†’ edit the preview in place; if the edit fails (preview gone, or a persistent 429/5xx) fall
39
+ * back to deleting the placeholder and sending fresh, so no "Thinking…" is left pinned above the answer.
40
+ * Many messages β†’ delete the preview and send the whole answer as consecutive fresh messages (editing
41
+ * would pin the first chunk where an active group has scrolled past). No preview β†’ fresh send. EMPTY text
42
+ * = "say nothing" β†’ just delete the preview.
43
+ */
44
+ async function finalize(api, botToken, target, messageId, text, opts = {}) {
45
+ const html = opts.html ?? true;
46
+ if (text.trim() === "") {
47
+ if (messageId !== undefined)
48
+ await callApi(api, botToken, "deleteMessage", { chat_id: target.chatId, message_id: messageId });
49
+ return;
50
+ }
51
+ if (messageId !== undefined) {
52
+ // "Fits in one message" is a plain length check against Telegram's limit β€” not chunkText, whose html
53
+ // mode exists for SPLIT chunks and is irrelevant to an un-split text.
54
+ if (text.length <= TELEGRAM_MAX_TEXT) {
55
+ try {
56
+ await editMessageText(api, botToken, target, messageId, text, { html });
57
+ return;
58
+ }
59
+ catch {
60
+ // Edit failed β€” the preview may be gone, or still there (429 retries exhausted / 5xx). Fall through
61
+ // to delete + fresh send below so a still-present "Thinking…" is not left pinned above the answer.
62
+ }
63
+ }
64
+ // Too long for one message, OR a failed single-message edit: remove the placeholder best-effort (a lingering one above
65
+ // the answer is worse than the extra call), then send the whole reply as fresh, consecutive messages.
66
+ await callApi(api, botToken, "deleteMessage", { chat_id: target.chatId, message_id: messageId }).catch(() => { });
67
+ }
68
+ await sendMessage(api, botToken, target, text, { html });
69
+ }
70
+ /**
71
+ * Consume one turn's event stream into a Telegram chat, live (see the module header for the preview
72
+ * model). Preview edits are best-effort (logged once if they fail); the final write is authoritative
73
+ * and surfaces a real failure (bad token, etc.).
74
+ */
75
+ export async function streamReply(events, api, botToken, target, formatError, previewId) {
76
+ const tools = [];
77
+ const toolIndexById = new Map();
78
+ let thinking = "";
79
+ let answer = "";
80
+ let answerPreviewSince;
81
+ const mark = { running: "…", ok: "βœ“", error: "βœ—" };
82
+ const toolView = () => tools.map((t) => `πŸ”§ ${t.label} ${mark[t.status]}`).join("\n");
83
+ // Reasoning is process, not the answer: shown (capped to its tail) in the live preview only, never
84
+ // in the persisted final message (which is `answer` alone).
85
+ const thinkingView = () => {
86
+ const t = thinking.replace(/\s+/g, " ").trim();
87
+ if (t === "")
88
+ return "";
89
+ return `πŸ’­ ${t.length > THINKING_PREVIEW ? `…${t.slice(t.length - THINKING_PREVIEW + 1)}` : t}`;
90
+ };
91
+ // The answer is hidden until its first delta has aged one EDIT_THROTTLE_MS: the pump's leading-edge
92
+ // flush would otherwise turn the very first content delta (often a lone character or unbalanced markup)
93
+ // into its own Telegram edit β€” the short-reply flicker (placeholder β†’ "O" β†’ "OK."). Aging is anchored
94
+ // at delta ARRIVAL (set in the event loop, not here) so an in-flight edit can't skew the clock, and
95
+ // there is deliberately NO timer at the boundary: a young answer surfaces on the next content-driven
96
+ // preview pass, so a turn completing within the window sends the final answer edit only.
97
+ const answerView = () => {
98
+ if (answer.trim() === "" || answerPreviewSince === undefined)
99
+ return "";
100
+ return Date.now() - answerPreviewSince >= EDIT_THROTTLE_MS ? answer : "";
101
+ };
102
+ const view = () => {
103
+ const v = [thinkingView(), toolView(), answerView()]
104
+ .filter((s) => s.trim() !== "")
105
+ .join("\n\n")
106
+ .trim();
107
+ // Before any reasoning/tool/text arrives, show an explicit placeholder rather than an empty edit.
108
+ return v === "" ? "πŸ’­ Thinking…" : v;
109
+ };
110
+ // The live preview is ONE real message: sent once (capturing its id + threading under the asker),
111
+ // then edited in place. messageId/lastSent are shared with the final write on completion.
112
+ // `previewId`: an already-sent message to take over as the preview (the "⏳ queued" notice) β€” the
113
+ // pump edits it in place, so the queue notice morphs into the live view instead of leaving an orphan.
114
+ let messageId = previewId;
115
+ let previewSent = messageId !== undefined; // a placeholder send was attempted β€” guards against re-sending when no id came back
116
+ let finalized = false; // a terminal write (completed/failed) ran β€” the finally skips its orphan cleanup
117
+ let lastSent = "";
118
+ const flushPreview = async () => {
119
+ const text = view();
120
+ if (text === lastSent)
121
+ return; // skip an unchanged edit (Telegram rejects "message is not modified")
122
+ lastSent = text;
123
+ if (messageId !== undefined) {
124
+ await editMessageText(api, botToken, target, messageId, text); // plain β€” a partial answer may carry unbalanced HTML
125
+ return;
126
+ }
127
+ // No preview message yet. Send the placeholder ONCE; never re-send (that would spam a new message per
128
+ // frame). If Telegram returns ok WITHOUT a message_id (proxy / odd API base / unparseable body) we
129
+ // cannot edit β€” fail visibly and stop previewing (the final write still lands via finalize).
130
+ if (previewSent)
131
+ return;
132
+ previewSent = true;
133
+ messageId = await sendMessage(api, botToken, target, text, { html: false });
134
+ if (messageId === undefined)
135
+ throw new Error("telegram sendMessage returned ok without a message_id β€” live preview disabled for this turn");
136
+ };
137
+ // ── Live-preview pump: a SINGLE serialized writer. ──────────────────────────────────────────
138
+ // Events mutate state (thinking / tools / answer) and mark the preview dirty; the pump edits the
139
+ // message to the LATEST view() with at most ONE edit in flight, paced by a throttle. One-in-flight is
140
+ // the whole point: concurrent edits can reach Telegram out of order β€” an older frame landing over a
141
+ // newer one is the "shows 3-4 steps, blanks, re-fills" flicker. Serializing keeps frames monotonic.
142
+ // (No keepalive: a real message does not expire, unlike a Bot API `sendMessageDraft` (30s window).)
143
+ let dirty = false;
144
+ let pumping = false;
145
+ let stopped = false;
146
+ let previewErrLogged = false;
147
+ let pumpDone;
148
+ let wakeThrottle; // set while the pump is mid-throttle; finish() cuts it short
149
+ const runPump = async () => {
150
+ pumping = true;
151
+ try {
152
+ while (dirty && !stopped) {
153
+ dirty = false;
154
+ try {
155
+ await flushPreview();
156
+ }
157
+ catch (e) {
158
+ // Best-effort preview (the final write is authoritative), but a failing edit must be visible β€”
159
+ // log once per turn so a never-rendering preview is diagnosable, not silent.
160
+ if (!previewErrLogged) {
161
+ previewErrLogged = true;
162
+ log.warn(`[telegram] live preview failed (final reply still sends): ${String(e)}`);
163
+ }
164
+ }
165
+ if (dirty && !stopped) {
166
+ // Pace + coalesce a burst into one edit. Interruptible: finish() cuts this short so the final
167
+ // write is not delayed by up to EDIT_THROTTLE_MS after the turn completes.
168
+ await new Promise((resolve) => {
169
+ const t = setTimeout(resolve, EDIT_THROTTLE_MS);
170
+ wakeThrottle = () => {
171
+ clearTimeout(t);
172
+ resolve();
173
+ };
174
+ });
175
+ wakeThrottle = undefined;
176
+ }
177
+ }
178
+ }
179
+ finally {
180
+ pumping = false;
181
+ }
182
+ };
183
+ // Mark the preview dirty and ensure the single writer is running (an edit already in flight picks up
184
+ // the new state on its next loop). Synchronous β€” callers never await a network write.
185
+ const touch = () => {
186
+ dirty = true;
187
+ if (!pumping)
188
+ pumpDone = runPump();
189
+ };
190
+ touch(); // send the "πŸ’­ Thinking…" placeholder immediately
191
+ // Stop the pump and await any in-flight edit, so the final write below is strictly the LAST one to the
192
+ // preview message (no stale frame landing after the answer).
193
+ const finish = async () => {
194
+ stopped = true;
195
+ wakeThrottle?.(); // cut an in-flight throttle so the final write is not delayed up to EDIT_THROTTLE_MS
196
+ await pumpDone?.catch(() => { });
197
+ };
198
+ try {
199
+ for await (const e of events) {
200
+ if (e.type === "text") {
201
+ answer += e.delta;
202
+ if (answerPreviewSince === undefined && answer.trim() !== "")
203
+ answerPreviewSince = Date.now();
204
+ touch();
205
+ }
206
+ else if (e.type === "thinking") {
207
+ thinking += e.delta;
208
+ touch();
209
+ }
210
+ else if (e.type === "tool_started") {
211
+ const arg = summarizeArgs(e.args);
212
+ toolIndexById.set(e.id, tools.length);
213
+ tools.push({ label: arg ? `${e.name} ${arg}` : e.name, status: "running" });
214
+ touch();
215
+ }
216
+ else if (e.type === "tool_ended") {
217
+ const i = toolIndexById.get(e.id);
218
+ const t = i === undefined ? undefined : tools[i];
219
+ if (t)
220
+ t.status = e.isError ? "error" : "ok";
221
+ touch();
222
+ }
223
+ else if (e.type === "completed") {
224
+ await finish();
225
+ // Edit the preview into the final answer (HTML, plain fallback); the persisted message is the
226
+ // answer alone β€” the process (thinking/tools) was preview-only. Mark finalized BEFORE delivering:
227
+ // the terminal was reached, so a delivery failure here is a plain failure, not an "abnormal exit"
228
+ // (which would wrongly fire the finally's neutral-notice fallback = double delivery + wrong text).
229
+ finalized = true;
230
+ await finalize(api, botToken, target, messageId, answer.trim() !== "" ? answer : "(no reply)");
231
+ return;
232
+ }
233
+ else if (e.type === "failed") {
234
+ await finish();
235
+ // Two audiences: the chat (customer-facing β€” formatError, neutral by default) and the operator
236
+ // log (dev-facing β€” the full details, via the throw below + the handler's catch). Same terminal
237
+ // write as completed: edit β†’ fresh-send if the preview is gone; an empty notice deletes the
238
+ // placeholder (suppress = no residue). HTML like the answer β€” symmetric, and finalize already
239
+ // falls back to plain if a custom onError returns markup Telegram rejects. Best-effort β€” we throw
240
+ // below regardless.
241
+ finalized = true;
242
+ {
243
+ const msg = formatError({ details: e.details, retryable: e.retryable }) ?? "";
244
+ await finalize(api, botToken, target, messageId, msg).catch(() => { });
245
+ }
246
+ throw new Error(`agent failed: ${e.details} (retryable=${e.retryable})`);
247
+ }
248
+ }
249
+ throw new Error("stream ended without a terminal event"); // violates SPEC MUST 1
250
+ }
251
+ finally {
252
+ await finish();
253
+ // Abnormal exit (stream ended without a terminal, the generator threw, or the consumer abandoned): no
254
+ // terminal write ran. Show the SAME neutral notice a `failed` event would β€” the preview may show real
255
+ // partial work, so don't delete it silently, and don't leave the user in silence. A suppressing
256
+ // onError still collapses to a delete (finalize on empty text).
257
+ if (!finalized) {
258
+ // retryable:false β€” an abnormal end (no terminal / a throw) is of UNKNOWN retryability, so use the
259
+ // neutral "something went wrong" default rather than promising "try again" that may not help.
260
+ const notice = formatError({ details: "the turn ended without completing", retryable: false }) ?? "";
261
+ // finalize handles messageId===undefined (no preview reached) with a fresh send β€” so the user is
262
+ // told even when the turn died before any message.
263
+ await finalize(api, botToken, target, messageId, notice).catch(() => { });
264
+ }
265
+ }
266
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Waits for the server to be
3
+ * REACHABLE first β€” polling `<baseUrl>/health` β€” because Telegram VERIFIES the URL when you set it, and a
4
+ * fresh deploy's container (healthcheck + routing) or a fresh tunnel's DNS is not routable for some
5
+ * seconds after the deploy/tunnel command returns. Tracking real readiness (not a fixed timer) is what
6
+ * fixes the race that made the first real deploy need a manual `setWebhook`. Missing tokens print the
7
+ * manual instruction instead of failing. `opts` (timeouts) exist for tests; production uses the defaults.
8
+ */
9
+ export declare function registerTelegramWebhook(baseUrl: string, opts?: {
10
+ readyTimeoutMs?: number;
11
+ readyIntervalMs?: number;
12
+ retryMs?: number;
13
+ }): Promise<void>;