@timqi/pier 0.1.4 → 0.1.6

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 (120) hide show
  1. package/README.md +4 -4
  2. package/dist/agent/events.js +12 -3
  3. package/dist/agent/listing.js +10 -2
  4. package/dist/agent/packages.js +25 -14
  5. package/dist/agent/pi.js +13 -40
  6. package/dist/channels/attach.js +2 -2
  7. package/dist/channels/commands.js +10 -8
  8. package/dist/channels/config.js +4 -6
  9. package/dist/channels/control.js +60 -14
  10. package/dist/channels/conversations.js +60 -13
  11. package/dist/channels/handoff.js +90 -0
  12. package/dist/channels/lark-api.js +7 -0
  13. package/dist/channels/lark-outbound.js +19 -0
  14. package/dist/channels/lark-panel.js +41 -26
  15. package/dist/channels/lark-render.js +2 -1
  16. package/dist/channels/lark.js +35 -26
  17. package/dist/channels/lines.js +1 -1
  18. package/dist/channels/panel.js +324 -115
  19. package/dist/channels/receipts.js +25 -12
  20. package/dist/channels/routes.js +21 -4
  21. package/dist/channels/runtime.js +21 -7
  22. package/dist/channels/slack-outbound.js +10 -0
  23. package/dist/channels/slack-panel.js +81 -40
  24. package/dist/channels/slack-render.js +3 -1
  25. package/dist/channels/slack.js +29 -14
  26. package/dist/channels/types.js +1 -2
  27. package/dist/cli.js +13 -5
  28. package/dist/core/identity.js +47 -12
  29. package/dist/core/reply.js +4 -1
  30. package/dist/core/router.js +20 -3
  31. package/dist/db.js +6 -1
  32. package/dist/main.js +42 -9
  33. package/dist/service.js +4 -2
  34. package/dist/settings.js +3 -12
  35. package/dist/socket.js +10 -6
  36. package/dist/tasks/cli.js +17 -7
  37. package/dist/tasks/operations.js +24 -12
  38. package/dist/tasks/routes.js +1 -0
  39. package/dist/tools.js +1 -3
  40. package/dist/web/public/assets/activity-BWpiRicQ.js +5 -0
  41. package/dist/web/public/assets/activity-BWpiRicQ.js.br +0 -0
  42. package/dist/web/public/assets/activity-BWpiRicQ.js.gz +0 -0
  43. package/dist/web/public/assets/boards-BnRMjBln.js +1 -0
  44. package/dist/web/public/assets/boards-BnRMjBln.js.br +0 -0
  45. package/dist/web/public/assets/boards-BnRMjBln.js.gz +0 -0
  46. package/dist/web/public/assets/explorer-DLZ0ccgE.js +4 -0
  47. package/dist/web/public/assets/explorer-DLZ0ccgE.js.br +0 -0
  48. package/dist/web/public/assets/explorer-DLZ0ccgE.js.gz +0 -0
  49. package/dist/web/public/assets/index-DMq4awU8.css +2 -0
  50. package/dist/web/public/assets/index-DMq4awU8.css.br +0 -0
  51. package/dist/web/public/assets/index-DMq4awU8.css.gz +0 -0
  52. package/dist/web/public/assets/index-T-vLXPch.js +85 -0
  53. package/dist/web/public/assets/index-T-vLXPch.js.br +0 -0
  54. package/dist/web/public/assets/index-T-vLXPch.js.gz +0 -0
  55. package/dist/web/public/assets/runs-oFQ162um.js +1 -0
  56. package/dist/web/public/assets/runs-oFQ162um.js.br +0 -0
  57. package/dist/web/public/assets/runs-oFQ162um.js.gz +0 -0
  58. package/dist/web/public/assets/settings-_t0Js-DJ.js +5 -0
  59. package/dist/web/public/assets/settings-_t0Js-DJ.js.br +0 -0
  60. package/dist/web/public/assets/settings-_t0Js-DJ.js.gz +0 -0
  61. package/dist/web/public/assets/task-runs-m3ufvOQI.js +3 -0
  62. package/dist/web/public/assets/task-runs-m3ufvOQI.js.br +0 -0
  63. package/dist/web/public/assets/task-runs-m3ufvOQI.js.gz +0 -0
  64. package/dist/web/public/assets/tasks-C1ATvPUc.js +4 -0
  65. package/dist/web/public/assets/tasks-C1ATvPUc.js.br +0 -0
  66. package/dist/web/public/assets/tasks-C1ATvPUc.js.gz +0 -0
  67. package/dist/web/public/index.html +3 -3
  68. package/dist/web/public/index.html.br +0 -0
  69. package/dist/web/public/index.html.gz +0 -0
  70. package/dist/web/push.js +4 -10
  71. package/dist/web/server.js +5 -4
  72. package/dist/{extensions/web → websearch}/artifacts.js +2 -2
  73. package/dist/websearch/cli.js +73 -0
  74. package/dist/websearch/run.js +273 -0
  75. package/docs/deploy.md +4 -4
  76. package/package.json +2 -3
  77. package/skills/pier-help/SKILL.md +22 -10
  78. package/skills/pier-tasks/SKILL.md +17 -8
  79. package/skills/pier-web/SKILL.md +50 -0
  80. package/dist/channels/telegram-api.js +0 -86
  81. package/dist/channels/telegram-panel.js +0 -97
  82. package/dist/channels/telegram-render.js +0 -69
  83. package/dist/channels/telegram.js +0 -421
  84. package/dist/extensions/index.js +0 -10
  85. package/dist/extensions/web/index.js +0 -9
  86. package/dist/extensions/web/tools.js +0 -265
  87. package/dist/web/public/assets/activity-NLS8W9yl.js +0 -5
  88. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  89. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  90. package/dist/web/public/assets/boards-DleegoiC.js +0 -1
  91. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  92. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  93. package/dist/web/public/assets/explorer-vJvV1Sx0.js +0 -4
  94. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  95. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  96. package/dist/web/public/assets/index-Bs-gol9o.css +0 -2
  97. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  98. package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
  99. package/dist/web/public/assets/index-Cyp2DKKF.js +0 -85
  100. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  101. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  102. package/dist/web/public/assets/runs-C5FveZ75.js +0 -1
  103. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  104. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  105. package/dist/web/public/assets/settings-nJHIBbAz.js +0 -5
  106. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  107. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  108. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js +0 -3
  109. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  110. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  111. package/dist/web/public/assets/tasks-R2Pv0Abg.js +0 -4
  112. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  113. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  114. /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
  115. /package/dist/{extensions/web → websearch}/content.js +0 -0
  116. /package/dist/{extensions/web → websearch}/http.js +0 -0
  117. /package/dist/{extensions/web → websearch}/json.js +0 -0
  118. /package/dist/{extensions/web → websearch}/language.js +0 -0
  119. /package/dist/{extensions/web → websearch}/openai.js +0 -0
  120. /package/dist/{extensions/web → websearch}/provider.js +0 -0
@@ -1,97 +0,0 @@
1
- // Telegram's half of the settings panel (panel.ts has the rest). A forced reply
2
- // arrives as an ordinary message, so the prompt's id is remembered to recognize it.
3
- import { ChatPanel, CWD_PLACEHOLDER, CWD_TAIL, PANEL_PREFIX, } from "./panel.js";
4
- import { escapeHtml as esc } from "./telegram-render.js";
5
- const button = (b) => ({ text: b.label, callback_data: `${PANEL_PREFIX}${b.action}` });
6
- export class TelegramPanel extends ChatPanel {
7
- deps;
8
- platform = "telegram";
9
- fence = ["<code>", "</code>"];
10
- cwdPrompts = new Map();
11
- constructor(deps) {
12
- super(deps);
13
- this.deps = deps;
14
- }
15
- esc(text) {
16
- return esc(text);
17
- }
18
- gateExtras(chat, policy) {
19
- return chat.kind === "forum" ? ` · topics ${policy.topicMode ? "on" : "off"}` : "";
20
- }
21
- // --- rendering ---------------------------------------------------------------
22
- text(view, note) {
23
- const body = view.groups
24
- .map((g) => [`<b>${g.title}</b>${g.suffix ?? ""}`, ...g.lines].join("\n"))
25
- .join("\n\n");
26
- return note ? `${body}\n\n<i>${esc(note)}</i>` : body;
27
- }
28
- /** A long model id does not share a row with anything. */
29
- keyboard(view) {
30
- return {
31
- inline_keyboard: [
32
- ...(view.picks ?? []).map((pick) => [button(pick)]),
33
- ...view.rows.map((row) => row.map(button)),
34
- ],
35
- };
36
- }
37
- async open(key, chatId, topicId) {
38
- const view = await this.view(key, chatId);
39
- const sent = await this.deps.api.sendMessage({
40
- chat_id: chatId,
41
- message_thread_id: topicId,
42
- text: this.text(view),
43
- parse_mode: "HTML",
44
- reply_markup: this.keyboard(view),
45
- });
46
- this.remember(key, { chatId, topicId, messageId: sent.message_id, models: [] });
47
- }
48
- async draw(state, view, note) {
49
- await this.deps.api.editMessage({
50
- chat_id: state.chatId,
51
- message_id: state.messageId,
52
- text: this.text(view, note),
53
- parse_mode: "HTML",
54
- reply_markup: this.keyboard(view),
55
- }).catch((err) => this.deps.log(`panel edit failed: ${String(err)}`));
56
- }
57
- async erase(state) {
58
- await this.deps.api.deleteMessage(state.chatId, state.messageId)
59
- .catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
60
- }
61
- // --- actions -----------------------------------------------------------------
62
- /** Returns false when the payload is not ours. */
63
- async onCallback(query, key) {
64
- return this.dispatch(key, query.data ?? "", undefined, async () => {
65
- const message = query.message;
66
- if (message)
67
- await this.open(key, String(message.chat.id), message.message_thread_id);
68
- });
69
- }
70
- // --- working directory (one typed answer) ------------------------------------
71
- async promptCwd(key, state) {
72
- const sent = await this.deps.api.sendMessage({
73
- chat_id: state.chatId,
74
- message_thread_id: state.topicId,
75
- text: `Reply with an absolute path. ${CWD_TAIL}`,
76
- reply_markup: { force_reply: true, input_field_placeholder: CWD_PLACEHOLDER },
77
- });
78
- this.cwdPrompts.set(key.conversationId, sent.message_id);
79
- }
80
- /** True when this message was the answer and must not reach the agent. */
81
- async consumeCwdReply(msg, key) {
82
- const pending = this.cwdPrompts.get(key.conversationId);
83
- if (!pending || msg.reply_to_message?.message_id !== pending)
84
- return false;
85
- this.cwdPrompts.delete(key.conversationId);
86
- const path = (msg.text ?? "").trim();
87
- const started = await this.startSessionIn(key, path);
88
- // The answer was typed in the chat, so the outcome is said there too.
89
- await this.deps.api.sendMessage({
90
- chat_id: msg.chat.id,
91
- message_thread_id: msg.message_thread_id,
92
- text: "error" in started ? started.error : `New session in <code>${esc(path)}</code>.`,
93
- parse_mode: "HTML",
94
- });
95
- return true;
96
- }
97
- }
@@ -1,69 +0,0 @@
1
- import { chunkText } from "./chunk.js";
2
- // How a reply looks on Telegram: markdown → Bot API HTML. Telegram's parser
3
- // rejects the whole message on any tag outside its documented set (b, i, s,
4
- // code, pre, a), so text is escaped first and only those are reintroduced.
5
- const MAX_CHARS = 3800; // Telegram's hard limit is 4096; leave room for tags.
6
- export const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
7
- /** Inline emphasis, applied after escaping so `<b>` can only come from us. */
8
- function inline(text) {
9
- return text
10
- // Links first: their label may itself carry emphasis.
11
- .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => `<a href="${url.replace(/"/g, "&quot;")}">${label}</a>`)
12
- .replace(/\*\*([^\n*]+)\*\*/g, "<b>$1</b>")
13
- .replace(/~~([^\n~]+)~~/g, "<s>$1</s>")
14
- .replace(/(^|[\s(])[*_]([^\n*_]+)[*_](?=[\s).,!?:;]|$)/g, "$1<i>$2</i>")
15
- .replace(/^#{1,6}[ \t]+(.+)$/gm, "<b>$1</b>");
16
- }
17
- /** Code is stashed before escaping so emphasis inside it stays literal. */
18
- export function toTelegramHtml(markdown) {
19
- const stash = [];
20
- const keep = (html) => `\uE000${stash.push(html) - 1}\uE001`;
21
- let out = markdown.replace(/```([\w.+-]*)\n?([\s\S]*?)```/g, (_m, lang, code) => keep(lang
22
- ? `<pre><code class="language-${escapeHtml(lang)}">${escapeHtml(code.replace(/\n+$/, ""))}</code></pre>`
23
- : `<pre>${escapeHtml(code.replace(/\n+$/, ""))}</pre>`));
24
- out = out.replace(/`([^`\n]+)`/g, (_m, code) => keep(`<code>${escapeHtml(code)}</code>`));
25
- out = inline(escapeHtml(out));
26
- return out.replace(/\uE000(\d+)\uE001/g, (_m, i) => stash[Number(i)] ?? "");
27
- }
28
- /** A cut inside a <pre> block is fine: Telegram closes the tag on its own. */
29
- export const chunk = (html) => chunkText(html, MAX_CHARS);
30
- // --- next-step buttons -------------------------------------------------------
31
- /** `callback_data` caps at 64 bytes (~21 CJK characters), so buttons send an
32
- * index and the label is read back off the message's own keyboard. */
33
- export const OFFER_PREFIX = "sg:";
34
- // Buttons in one row split its width evenly, so packing is budgeted by display
35
- // width, not count.
36
- const ROW_WIDTH = 26;
37
- const ROW_BUTTONS = 3;
38
- /** CJK, fullwidth punctuation and emoji take two cells. */
39
- const displayWidth = (label) => [...label].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1), 0);
40
- /** A row that is already wide stops accepting: a long label squeezed in
41
- * truncates everything beside it. */
42
- export function keyboard(labels) {
43
- if (!labels.length)
44
- return undefined;
45
- const rows = [];
46
- let used = 0;
47
- labels.forEach((label, index) => {
48
- const width = displayWidth(label);
49
- const row = rows.at(-1);
50
- const button = { text: label, callback_data: `${OFFER_PREFIX}${index}` };
51
- if (!row || row.length >= ROW_BUTTONS || used + width > ROW_WIDTH) {
52
- rows.push([button]);
53
- used = width;
54
- return;
55
- }
56
- row.push(button);
57
- used += width;
58
- });
59
- return { inline_keyboard: rows };
60
- }
61
- /** A payload we never wrote (an older label-as-payload button) passes through. */
62
- export function offeredLabel(msg, data) {
63
- if (!data.startsWith(OFFER_PREFIX))
64
- return data;
65
- return msg.reply_markup?.inline_keyboard
66
- .flat()
67
- .find((button) => button.callback_data === data)
68
- ?.text;
69
- }
@@ -1,421 +0,0 @@
1
- // Telegram adapter: normalize inbound updates, render outbound turns.
2
- // Telegram-specific: topic mode, where a message in a forum group's General
3
- // opens a topic that becomes the session; 👀 receipts on every message that
4
- // entered a turn; `/bind <code>` in a DM.
5
- import { awaitsTurn, formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
6
- import { saveInboundAll } from "../core/inbox.js";
7
- import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
8
- import { sendAttachments, splitAttachments } from "./attach.js";
9
- import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
10
- import { logger } from "../log.js";
11
- import { Chains } from "./chains.js";
12
- import { parseCommand } from "./commands.js";
13
- import { Gatekeeper } from "./gatekeeper.js";
14
- import { ReceiptLedger, Receipts } from "./receipts.js";
15
- import { TelegramPanel } from "./telegram-panel.js";
16
- import { chunk, escapeHtml, keyboard, offeredLabel, toTelegramHtml } from "./telegram-render.js";
17
- import { TelegramApi, } from "./telegram-api.js";
18
- const WORKING = "👀";
19
- const POLL_SECONDS = 30;
20
- // The poll loop waits here without advancing the offset past what it accepted.
21
- const MAX_ACTIVE_CHATS = 16;
22
- // Generous: a real coding turn can be long.
23
- const RECEIPT_STALE_MS = 30 * 60_000;
24
- const DRAIN_TIMEOUT_MS = 5000;
25
- const TOPIC_TITLE_MAX = 60;
26
- /** `<chatId>/<topicId>` or `<chatId>`; the only definition of the format. */
27
- const conversationId = (chatId, topicId) => topicId ? `${chatId}/${topicId}` : String(chatId);
28
- export const parseConversation = (id) => {
29
- const [chatId = "", topic] = id.split("/");
30
- const topicId = topic ? Number(topic) : undefined;
31
- return { chatId, topicId: Number.isSafeInteger(topicId) ? topicId : undefined };
32
- };
33
- /** Telegram numbers General as topic 1 and omits the id on plain groups. */
34
- const inGeneral = (msg) => !msg.message_thread_id || msg.message_thread_id === 1;
35
- function topicTitle(text) {
36
- const line = (text.split("\n").find((l) => l.trim()) ?? "").trim();
37
- if (!line)
38
- return `Session ${new Date().toISOString().slice(5, 16).replace("T", " ")}`;
39
- return line.length > TOPIC_TITLE_MAX ? `${line.slice(0, TOPIC_TITLE_MAX - 3).trimEnd()}...` : line;
40
- }
41
- export class TelegramChannel {
42
- deps;
43
- id = "telegram";
44
- api;
45
- log;
46
- receipts;
47
- chains;
48
- gate;
49
- /** Absent when no control was wired (tests). */
50
- panel;
51
- me;
52
- offset;
53
- running = false;
54
- constructor(deps) {
55
- this.deps = deps;
56
- this.api = deps.client ?? new TelegramApi(deps.store.get("telegram").token);
57
- this.log = deps.log ?? ((m) => logger("telegram").warn(m));
58
- // No cap: the poll loop applies backpressure itself.
59
- this.chains = new Chains(this.log);
60
- this.gate = new Gatekeeper(deps.store, "telegram", this.log);
61
- this.receipts = new Receipts(
62
- // The ledger keeps message ids as opaque strings (a Slack ts is not a number).
63
- { setReaction: (chatId, messageId, emoji) => this.api.setReaction(chatId, Number(messageId), emoji) }, deps.receipts ?? new ReceiptLedger("telegram"), this.log, WORKING, RECEIPT_STALE_MS);
64
- if (deps.control) {
65
- this.panel = new TelegramPanel({
66
- api: this.api,
67
- control: deps.control,
68
- store: deps.store,
69
- log: this.log,
70
- });
71
- }
72
- }
73
- async start(onMessage) {
74
- const me = await this.api.getMe();
75
- this.me = { id: me.id, username: me.username ?? "" };
76
- if (!this.me.username) {
77
- // Every group with require-mention on goes silent; loud, not a debug line.
78
- this.log("bot has no username: mention detection is disabled");
79
- }
80
- this.running = true;
81
- void this.receipts.sweep(true);
82
- void this.poll(onMessage);
83
- }
84
- async stop() {
85
- this.running = false;
86
- // reload() starts a replacement right after; two adapters handling one
87
- // message would prompt the session twice. Bounded: a stuck handler must not
88
- // hold the Console's save request open.
89
- await this.chains.drain(DRAIN_TIMEOUT_MS);
90
- }
91
- // --- inbound ---------------------------------------------------------------
92
- async poll(onMessage) {
93
- while (this.running) {
94
- try {
95
- // Floor on an empty round trip: a proxy that answers instantly would
96
- // otherwise turn the long poll into a hot loop.
97
- const startedAt = Date.now();
98
- const updates = await this.api.getUpdates(this.offset, POLL_SECONDS);
99
- if (!this.running)
100
- return;
101
- void this.receipts.sweep();
102
- if (!updates.length && Date.now() - startedAt < 1000) {
103
- await new Promise((r) => setTimeout(r, 1000));
104
- }
105
- for (const update of updates) {
106
- if (!this.running)
107
- return;
108
- while (this.chains.size >= MAX_ACTIVE_CHATS)
109
- await this.chains.oldest();
110
- this.offset = update.update_id + 1;
111
- const chat = update.message?.chat.id ?? update.callback_query?.message?.chat.id;
112
- if (chat === undefined)
113
- continue; // malformed: no chat to answer in
114
- this.chains.run(String(chat), async () => {
115
- try {
116
- if (update.callback_query)
117
- await this.onCallback(update.callback_query, onMessage);
118
- else if (update.message)
119
- await this.onMessage(update.message, onMessage);
120
- }
121
- catch (err) {
122
- this.log(`update ${update.update_id} dropped: ${String(err)}`);
123
- }
124
- });
125
- }
126
- }
127
- catch (err) {
128
- if (!this.running)
129
- return;
130
- this.log(`poll failed, retrying: ${String(err)}`);
131
- await new Promise((r) => setTimeout(r, 3000));
132
- }
133
- }
134
- }
135
- async onMessage(msg, onMessage) {
136
- if (!msg.from || msg.from.id === this.me?.id)
137
- return; // own echo, or malformed
138
- const raw = (msg.text ?? msg.caption ?? "").trim();
139
- if (!raw && !msg.photo?.length && !msg.document)
140
- return;
141
- const chatId = String(msg.chat.id);
142
- const isDm = msg.chat.type === "private";
143
- const kind = isDm ? "dm" : msg.chat.is_forum ? "forum" : "group";
144
- const name = msg.chat.title ?? [msg.from.first_name, msg.from.last_name].filter(Boolean).join(" ");
145
- if (this.gate.mayDiscover({ isDm, userId: String(msg.from.id) })) {
146
- this.deps.store.discoverChat("telegram", { id: chatId, name: name || chatId, kind });
147
- }
148
- const text = this.stripMention(raw);
149
- const command = parseCommand(text);
150
- const mine = !command?.target || command.target.toLowerCase() === this.me?.username.toLowerCase();
151
- const bindRequest = mine && command?.name === "bind" && isDm;
152
- const admitted = this.gate.admit("message", chatId, {
153
- isDm,
154
- addressed: this.addressed(raw, msg),
155
- userId: String(msg.from.id),
156
- bindRequest,
157
- });
158
- if (!admitted) {
159
- if (isDm)
160
- await this.hintBind(msg);
161
- return;
162
- }
163
- if (bindRequest)
164
- return this.bind(msg, command?.args ?? "");
165
- if (mine && command?.name === "stop")
166
- return this.abortTurn(msg);
167
- const here = {
168
- channelId: this.id,
169
- conversationId: conversationId(chatId, msg.message_thread_id),
170
- };
171
- if (await this.panel?.consumeCwdReply(msg, here))
172
- return;
173
- // A bare `@bot` and `/settings` are the same request.
174
- if (this.panel && mine && (command?.name === "settings" || (!text && !msg.photo?.length && !msg.document))) {
175
- return this.panel.open(here, chatId, msg.message_thread_id);
176
- }
177
- // Downloading only past the gate: an unauthorized sender must not make the
178
- // bot pull bytes on their behalf.
179
- const markers = await this.saveAttachments(msg);
180
- const topicId = await this.routeTopic(msg, text);
181
- const key = { channelId: this.id, conversationId: conversationId(chatId, topicId) };
182
- this.receipts.mark(key.conversationId, chatId, String(msg.message_id));
183
- // Steer: a follow-up is the wrong default when the human is watching a 👀.
184
- onMessage({
185
- key,
186
- senderId: String(msg.from.id),
187
- sender: { id: String(msg.from.id), name: senderName(msg.from) },
188
- text: [text, ...markers].filter(Boolean).join("\n"),
189
- mode: "steer",
190
- });
191
- }
192
- async onCallback(query, onMessage) {
193
- // Unanswered, the tapped button keeps its spinner until Telegram gives up.
194
- await this.api.answerCallbackQuery(query.id)
195
- .catch((err) => this.log(`callback ack failed: ${String(err)}`));
196
- const msg = query.message;
197
- if (!msg || !query.data)
198
- return;
199
- const chatId = String(msg.chat.id);
200
- const admitted = this.gate.admit("callback", chatId, {
201
- isDm: msg.chat.type === "private",
202
- addressed: true, // pressing the bot's own button is addressing it
203
- userId: String(query.from.id),
204
- });
205
- if (!admitted)
206
- return;
207
- const key = {
208
- channelId: this.id,
209
- conversationId: conversationId(chatId, msg.message_thread_id),
210
- };
211
- if (await this.panel?.onCallback(query, key))
212
- return;
213
- const text = offeredLabel(msg, query.data);
214
- if (text === undefined) {
215
- // A callback query may be answered exactly once, and the ack above was
216
- // it; a second answer is silently dropped.
217
- await this.api.sendMessage({
218
- chat_id: chatId,
219
- message_thread_id: msg.message_thread_id,
220
- text: STALE_OPTION,
221
- }).catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
222
- return;
223
- }
224
- await this.api.clearKeyboard(chatId, msg.message_id)
225
- .catch((err) => this.log(`retiring options failed: ${String(err)}`));
226
- // A bot cannot post as the user, so the pick is echoed: otherwise the chat
227
- // shows an answer to a request nobody can see, with nothing to carry the eyes.
228
- const echo = await this.api.sendMessage({
229
- chat_id: chatId,
230
- message_thread_id: msg.message_thread_id,
231
- text: picked(escapeHtml(text)),
232
- parse_mode: "HTML",
233
- }).catch((err) => {
234
- this.log(`option echo failed: ${String(err)}`);
235
- return undefined;
236
- });
237
- if (echo)
238
- this.receipts.mark(key.conversationId, chatId, String(echo.message_id));
239
- onMessage({ key, senderId: String(query.from.id), text, mode: "steer" });
240
- }
241
- /** The abort ends the turn, which reaches send() and clears the receipts. */
242
- async abortTurn(msg) {
243
- const key = {
244
- channelId: this.id,
245
- conversationId: conversationId(msg.chat.id, msg.message_thread_id),
246
- };
247
- await this.deps.control?.abort(key);
248
- await this.api.sendMessage({
249
- chat_id: msg.chat.id,
250
- message_thread_id: msg.message_thread_id,
251
- text: STOPPED,
252
- });
253
- }
254
- /** Groups stay silent, but a DM that swallows every message looks broken
255
- * rather than locked. */
256
- async hintBind(msg) {
257
- if (!this.gate.mayHint(String(msg.from?.id ?? "")))
258
- return;
259
- await this.api.sendMessage({
260
- chat_id: msg.chat.id,
261
- text: bindHint("/bind <code>"),
262
- }).catch((err) => this.log(`bind hint failed: ${String(err)}`));
263
- }
264
- async bind(msg, code) {
265
- const user = msg.from;
266
- const name = senderName(user);
267
- const outcome = this.deps.store.redeemBindCode("telegram", code, { id: String(user.id), name });
268
- await this.api.sendMessage({
269
- chat_id: msg.chat.id,
270
- text: bindResult(outcome, name),
271
- });
272
- }
273
- /** A reply or a command stays put; failure falls back to the current thread. */
274
- async routeTopic(msg, text) {
275
- // Every reason to decline is logged: six invisible conditions are
276
- // indistinguishable from a bug.
277
- const decline = !this.deps.store.policy("telegram", String(msg.chat.id)).topicMode
278
- ? "topic mode off for this chat"
279
- : msg.chat.type !== "supergroup"
280
- ? `chat is a ${msg.chat.type}, not a supergroup`
281
- : !msg.chat.is_forum
282
- ? "group has Topics disabled in Telegram"
283
- : !inGeneral(msg)
284
- ? `already inside topic ${msg.message_thread_id}`
285
- : msg.reply_to_message
286
- ? "message is a reply, so it continues an existing thread"
287
- : text.startsWith("/")
288
- ? "message is a command"
289
- : "";
290
- if (decline) {
291
- this.log(`no new topic in chat ${msg.chat.id}: ${decline}`);
292
- return msg.message_thread_id;
293
- }
294
- const title = topicTitle(text);
295
- try {
296
- const topic = await this.api.createForumTopic(msg.chat.id, title);
297
- await this.api.sendMessage({
298
- chat_id: msg.chat.id,
299
- text: `→ <a href="${topicLink(msg.chat, topic.message_thread_id)}">${escapeHtml(title)}</a>`,
300
- parse_mode: "HTML",
301
- message_thread_id: msg.message_thread_id,
302
- reply_to_message_id: msg.message_id,
303
- // Without the pointer, General shows a question whose answer went elsewhere.
304
- }).catch((err) => this.log(`topic pointer failed: ${String(err)}`));
305
- return topic.message_thread_id;
306
- }
307
- catch (err) {
308
- this.log(`topic creation failed, staying in General: ${String(err)}`);
309
- return msg.message_thread_id;
310
- }
311
- }
312
- saveAttachments(msg) {
313
- // A photo is a size ladder; the last entry is the largest.
314
- const photo = msg.photo?.at(-1);
315
- const doc = msg.document;
316
- return saveInboundAll(this.id, [
317
- ...(photo ? [{
318
- label: "photo",
319
- mimeType: "image/jpeg",
320
- size: photo.file_size,
321
- // A filename comes only from getFile, so the fetch's wins.
322
- fetch: async () => this.api.downloadFile(photo.file_id, MAX_INBOUND_BYTES),
323
- }] : []),
324
- ...(doc ? [{
325
- label: doc.file_name ?? "file",
326
- name: doc.file_name,
327
- mimeType: doc.mime_type ?? "application/octet-stream",
328
- size: doc.file_size,
329
- fetch: async () => this.api.downloadFile(doc.file_id, MAX_INBOUND_BYTES),
330
- }] : []),
331
- ], this.log);
332
- }
333
- // --- addressing ------------------------------------------------------------
334
- addressed(text, msg) {
335
- if (msg.reply_to_message?.from?.id === this.me?.id)
336
- return true;
337
- if (text.startsWith("/")) {
338
- const target = /^\/\S+?@(\S+)/.exec(text)?.[1];
339
- return !target || target.toLowerCase() === this.me?.username.toLowerCase();
340
- }
341
- const handle = `@${this.me?.username.toLowerCase()}`;
342
- return !!this.me?.username && text.toLowerCase().includes(handle);
343
- }
344
- stripMention(text) {
345
- const handle = `@${this.me?.username ?? ""}`;
346
- if (!this.me?.username)
347
- return text;
348
- return text.toLowerCase().startsWith(handle.toLowerCase())
349
- ? text.slice(handle.length).replace(/^[\s,:-]+/, "")
350
- : text;
351
- }
352
- // --- outbound --------------------------------------------------------------
353
- async send(conversation, reply) {
354
- const { chatId, topicId } = parseConversation(conversation);
355
- // A local file link is dead in Telegram: the bytes are uploaded instead.
356
- const { text: spoken, paths } = splitAttachments(reply.text);
357
- const text = spoken.trim();
358
- // An empty turn still posts its footer and says which kind of nothing (§5).
359
- const buttons = keyboard(reply.suggestions);
360
- const quiet = isSilentReply(reply)
361
- ? `<i>${quietLabel(reply.silence && escapeHtml(reply.silence))}</i>`
362
- : "";
363
- // A keyboard cannot ride a message that was never sent.
364
- const body = ((text ? toTelegramHtml(text) : quiet) + turnFooter(reply.meta)) ||
365
- (buttons ? "…" : "");
366
- // A 👀 left up by a failed send looks like work until the stale sweep.
367
- await this.receipts.settleAfter(conversation, async () => {
368
- if (body.trim()) {
369
- const parts = chunk(body);
370
- for (const [i, part] of parts.entries()) {
371
- await this.api.sendMessage({
372
- chat_id: chatId,
373
- message_thread_id: topicId,
374
- text: part,
375
- parse_mode: "HTML",
376
- reply_markup: i === parts.length - 1 ? buttons : undefined,
377
- });
378
- }
379
- }
380
- const lost = await sendAttachments(paths, (file) => this.api.sendFile({ chat_id: chatId, message_thread_id: topicId, file }), this.log);
381
- if (lost) {
382
- await this.api.sendMessage({
383
- chat_id: chatId,
384
- message_thread_id: topicId,
385
- text: escapeHtml(lost),
386
- parse_mode: "HTML",
387
- });
388
- }
389
- }, reply.meta);
390
- }
391
- /** No footer: the turn this input triggers has not ended. The 👀 goes on the
392
- * note itself, since that turn has no message of the user's to carry them. */
393
- async notify(conversation, note) {
394
- const { chatId, topicId } = parseConversation(conversation);
395
- const label = originLabel(note.origin);
396
- let posted;
397
- for (const part of chunk(`<i>${label}</i>\n<blockquote>${toTelegramHtml(note.text)}</blockquote>`)) {
398
- posted = await this.api.sendMessage({
399
- chat_id: chatId,
400
- message_thread_id: topicId,
401
- text: part,
402
- parse_mode: "HTML",
403
- });
404
- }
405
- if (posted && awaitsTurn(note.origin)) {
406
- this.receipts.mark(conversation, chatId, String(posted.message_id));
407
- }
408
- }
409
- }
410
- const senderName = (user) => [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username || String(user.id);
411
- /** A private supergroup links as `/c/<internal id>`: the chat id with its
412
- * `-100` prefix removed. */
413
- function topicLink(chat, topicId) {
414
- if (chat.username)
415
- return `https://t.me/${chat.username}/${topicId}`;
416
- const internal = String(chat.id).replace(/^-100(?=\d)/, "").replace(/^-/, "");
417
- return `https://t.me/c/${internal}/${topicId}`;
418
- }
419
- /** One newline, not a blank line: Telegram has no muted text, so tucking it
420
- * against the reply is the only way it reads as a footnote. */
421
- const turnFooter = (meta) => meta ? `\n<i>${formatTurnMeta(meta)}</i>` : "";
@@ -1,10 +0,0 @@
1
- // The extensions Pier ships with. Bundled rather than dropped in
2
- // <agentDir>/extensions because a copy on disk has an owner problem: an update
3
- // either clobbers the user's edits or skips them forever.
4
- import web from "./web/index.js";
5
- export const BUNDLED = [
6
- { name: "web", factory: web },
7
- ];
8
- /** The enabled ones as Pi inline extensions; unknown names are not ours. */
9
- export const inlineExtensions = (enabled) => BUNDLED.filter((ext) => enabled.includes(ext.name))
10
- .map(({ name, factory }) => ({ name, factory }));
@@ -1,9 +0,0 @@
1
- // Two tools — web_search and web_fetch — served by the provider's own hosted
2
- // web stack, so an agent reaches the public web with no extra key, service or
3
- // dependency. Registered as an inline extension by agent/pi.ts when the
4
- // Console has it switched on.
5
- import { webFetch, webSearch } from "./tools.js";
6
- export default function web(pi) {
7
- pi.registerTool(webSearch);
8
- pi.registerTool(webFetch);
9
- }