@timqi/pier 0.0.29 → 0.1.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.
Files changed (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +8 -24
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +6 -14
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
@@ -1,16 +1,7 @@
1
1
  // Telegram adapter: normalize inbound updates, render outbound turns.
2
- //
3
- // Three behaviours are Telegram-specific and live only here:
4
- // - Topic mode. A message that lands in a forum group's General opens a fresh
5
- // topic named after its first line, and the conversation (hence the Pi
6
- // session) is that topic. One group therefore hosts many parallel sessions.
7
- // - Reaction receipts. Intermediate reasoning is never posted to IM; instead
8
- // every message that entered a turn wears 👀 until the turn settles, so a
9
- // steered message gets feedback without a line in the chat.
10
- // - Bind. `/bind <code>` in a DM redeems a Console-issued code.
11
- //
12
- // Everything policy-shaped (mention/bind gates, per-chat overrides) is in
13
- // config.ts, platform-blind and shared with the adapters still to come.
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.
14
5
  import { awaitsTurn, formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
15
6
  import { saveInboundAll } from "../core/inbox.js";
16
7
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
@@ -26,20 +17,13 @@ import { chunk, escapeHtml, keyboard, offeredLabel, toTelegramHtml } from "./tel
26
17
  import { TelegramApi, } from "./telegram-api.js";
27
18
  const WORKING = "👀";
28
19
  const POLL_SECONDS = 30;
29
- // Backpressure: how many chats may be mid-handling before the poll loop waits.
30
- // Bounds memory without ever advancing the offset past what we accepted.
20
+ // The poll loop waits here without advancing the offset past what it accepted.
31
21
  const MAX_ACTIVE_CHATS = 16;
32
- // Longest a 👀 may sit before we assume its turn will never settle (a dispatch
33
- // that failed, a session that died). Generous: a real coding turn can be long.
22
+ // Generous: a real coding turn can be long.
34
23
  const RECEIPT_STALE_MS = 30 * 60_000;
35
- // Longest stop() waits for in-flight handlers before letting reload() proceed.
36
24
  const DRAIN_TIMEOUT_MS = 5000;
37
25
  const TOPIC_TITLE_MAX = 60;
38
- /**
39
- * A forum conversation is `<chatId>/<topicId>`; anything else is `<chatId>`.
40
- * This pair is the only definition of the format — control.ts and the panel
41
- * decode with it rather than splitting on "/" themselves.
42
- */
26
+ /** `<chatId>/<topicId>` or `<chatId>`; the only definition of the format. */
43
27
  const conversationId = (chatId, topicId) => topicId ? `${chatId}/${topicId}` : String(chatId);
44
28
  export const parseConversation = (id) => {
45
29
  const [chatId = "", topic] = id.split("/");
@@ -59,13 +43,10 @@ export class TelegramChannel {
59
43
  id = "telegram";
60
44
  api;
61
45
  log;
62
- /** 👀 lifecycle, durable; see receipts.ts for why it is not just a Map. */
63
46
  receipts;
64
- /** Ordering per chat, concurrency across them; see chains.ts. */
65
47
  chains;
66
- /** The inbound gate and the bind-hint throttle; see gatekeeper.ts. */
67
48
  gate;
68
- /** The in-chat settings panel; absent when no control was wired (tests). */
49
+ /** Absent when no control was wired (tests). */
69
50
  panel;
70
51
  me;
71
52
  offset;
@@ -74,14 +55,11 @@ export class TelegramChannel {
74
55
  this.deps = deps;
75
56
  this.api = deps.client ?? new TelegramApi(deps.store.get("telegram").token);
76
57
  this.log = deps.log ?? ((m) => logger("telegram").warn(m));
77
- // No cap here: the poll loop applies backpressure itself, before advancing
78
- // the ack cursor past an update it has not accepted.
58
+ // No cap: the poll loop applies backpressure itself.
79
59
  this.chains = new Chains(this.log);
80
60
  this.gate = new Gatekeeper(deps.store, "telegram", this.log);
81
61
  this.receipts = new Receipts(
82
- // The ledger keeps message ids as opaque strings (a Slack ts is not a
83
- // number); Telegram's own are numeric, so the cast happens right here at
84
- // the API boundary rather than leaking a platform's id type into shared code.
62
+ // The ledger keeps message ids as opaque strings (a Slack ts is not a number).
85
63
  { setReaction: (chatId, messageId, emoji) => this.api.setReaction(chatId, Number(messageId), emoji) }, deps.receipts ?? new ReceiptLedger("telegram"), this.log, WORKING, RECEIPT_STALE_MS);
86
64
  if (deps.control) {
87
65
  this.panel = new TelegramPanel({
@@ -96,35 +74,30 @@ export class TelegramChannel {
96
74
  const me = await this.api.getMe();
97
75
  this.me = { id: me.id, username: me.username ?? "" };
98
76
  if (!this.me.username) {
99
- // Without a handle, "was I mentioned?" can only ever answer no, so every
100
- // group with require-mention on goes silent. Loud, not a debug line.
77
+ // Every group with require-mention on goes silent; loud, not a debug line.
101
78
  this.log("bot has no username: mention detection is disabled");
102
79
  }
103
80
  this.running = true;
104
- // Best-effort and off the critical path.
105
81
  void this.receipts.sweep(true);
106
82
  void this.poll(onMessage);
107
83
  }
108
84
  async stop() {
109
85
  this.running = false;
110
- // Let in-flight updates finish: reload() starts a replacement right after,
111
- // and two adapters handling one message would prompt the session twice.
112
- // Bounded, because reload() runs on the Console's save request — a stuck
113
- // handler must not hold that open.
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.
114
89
  await this.chains.drain(DRAIN_TIMEOUT_MS);
115
90
  }
116
91
  // --- inbound ---------------------------------------------------------------
117
92
  async poll(onMessage) {
118
93
  while (this.running) {
119
94
  try {
120
- // Floor on an empty round trip: getUpdates is supposed to block for
121
- // POLL_SECONDS, and a proxy that answers instantly would otherwise
122
- // turn this into a hot loop.
95
+ // Floor on an empty round trip: a proxy that answers instantly would
96
+ // otherwise turn the long poll into a hot loop.
123
97
  const startedAt = Date.now();
124
98
  const updates = await this.api.getUpdates(this.offset, POLL_SECONDS);
125
99
  if (!this.running)
126
100
  return;
127
- // Asked on every round trip, throttled inside receipts.ts.
128
101
  void this.receipts.sweep();
129
102
  if (!updates.length && Date.now() - startedAt < 1000) {
130
103
  await new Promise((r) => setTimeout(r, 1000));
@@ -169,10 +142,11 @@ export class TelegramChannel {
169
142
  const isDm = msg.chat.type === "private";
170
143
  const kind = isDm ? "dm" : msg.chat.is_forum ? "forum" : "group";
171
144
  const name = msg.chat.title ?? [msg.from.first_name, msg.from.last_name].filter(Boolean).join(" ");
172
- this.deps.store.discoverChat("telegram", { id: chatId, name: name || chatId, kind });
145
+ if (this.gate.mayDiscover({ isDm, userId: String(msg.from.id) })) {
146
+ this.deps.store.discoverChat("telegram", { id: chatId, name: name || chatId, kind });
147
+ }
173
148
  const text = this.stripMention(raw);
174
149
  const command = parseCommand(text);
175
- // A command aimed at another bot in the same group is not ours to answer.
176
150
  const mine = !command?.target || command.target.toLowerCase() === this.me?.username.toLowerCase();
177
151
  const bindRequest = mine && command?.name === "bind" && isDm;
178
152
  const admitted = this.gate.admit("message", chatId, {
@@ -194,35 +168,31 @@ export class TelegramChannel {
194
168
  channelId: this.id,
195
169
  conversationId: conversationId(chatId, msg.message_thread_id),
196
170
  };
197
- // A typed answer to the panel's directory prompt, not a prompt for the agent.
198
171
  if (await this.panel?.consumeCwdReply(msg, here))
199
172
  return;
200
- // `@bot` on its own (text is empty once the mention is stripped) and
201
- // `/settings` are the same request: show me this conversation's settings.
173
+ // A bare `@bot` and `/settings` are the same request.
202
174
  if (this.panel && mine && (command?.name === "settings" || (!text && !msg.photo?.length && !msg.document))) {
203
175
  return this.panel.open(here, chatId, msg.message_thread_id);
204
176
  }
205
- // Downloading only past the gate: an unauthorized sender must not be able
206
- // to make the bot pull bytes on their behalf.
177
+ // Downloading only past the gate: an unauthorized sender must not make the
178
+ // bot pull bytes on their behalf.
207
179
  const markers = await this.saveAttachments(msg);
208
180
  const topicId = await this.routeTopic(msg, text);
209
181
  const key = { channelId: this.id, conversationId: conversationId(chatId, topicId) };
210
182
  this.receipts.mark(key.conversationId, chatId, String(msg.message_id));
211
- // IM messages steer by default: a follow-up that waits for the turn to end
212
- // is the wrong default when the human is watching a 👀 in a chat window.
183
+ // Steer: a follow-up is the wrong default when the human is watching a 👀.
213
184
  onMessage({
214
185
  key,
215
186
  senderId: String(msg.from.id),
216
- // A group is many people talking into one session; the update already
217
- // carries the name, so no lookup is needed here.
218
187
  sender: { id: String(msg.from.id), name: senderName(msg.from) },
219
188
  text: [text, ...markers].filter(Boolean).join("\n"),
220
189
  mode: "steer",
221
190
  });
222
191
  }
223
- /** Quick-reply buttons send their own label back as an ordinary message. */
224
192
  async onCallback(query, onMessage) {
225
- await this.api.answerCallbackQuery(query.id).catch(() => { });
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)}`));
226
196
  const msg = query.message;
227
197
  if (!msg || !query.data)
228
198
  return;
@@ -238,14 +208,12 @@ export class TelegramChannel {
238
208
  channelId: this.id,
239
209
  conversationId: conversationId(chatId, msg.message_thread_id),
240
210
  };
241
- // Panel taps are namespaced `cfg:` and never reach the agent.
242
211
  if (await this.panel?.onCallback(query, key))
243
212
  return;
244
213
  const text = offeredLabel(msg, query.data);
245
214
  if (text === undefined) {
246
- // Said in the chat, not as a second answerCallbackQuery: a query may be
247
- // answered exactly once, and the toast already went to the ack above —
248
- // against the real API the second answer is silently dropped.
215
+ // A callback query may be answered exactly once, and the ack above was
216
+ // it; a second answer is silently dropped.
249
217
  await this.api.sendMessage({
250
218
  chat_id: chatId,
251
219
  message_thread_id: msg.message_thread_id,
@@ -253,17 +221,10 @@ export class TelegramChannel {
253
221
  }).catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
254
222
  return;
255
223
  }
256
- // The options belonged to the turn that just ended; once one is taken the
257
- // rest answer a question the conversation has moved past (the web drops
258
- // them for the same reason).
259
- await this.api.clearKeyboard(chatId, msg.message_id).catch(() => { });
260
- // A bot cannot post as the user, so the pick is echoed and marked as one.
261
- // Without it the chat shows an answer to a request nobody can see being
262
- // made, and there is no message of the user's to carry the eyes.
263
- //
264
- // No reply quote: the marker already says what this is, and quoting a long
265
- // answer to show which of its buttons was tapped costs more space than it
266
- // explains.
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.
267
228
  const echo = await this.api.sendMessage({
268
229
  chat_id: chatId,
269
230
  message_thread_id: msg.message_thread_id,
@@ -273,17 +234,11 @@ export class TelegramChannel {
273
234
  this.log(`option echo failed: ${String(err)}`);
274
235
  return undefined;
275
236
  });
276
- // The receipt goes on the echo, not on the bot message that held the
277
- // buttons: the eyes mean "this input is being worked on".
278
237
  if (echo)
279
238
  this.receipts.mark(key.conversationId, chatId, String(echo.message_id));
280
239
  onMessage({ key, senderId: String(query.from.id), text, mode: "steer" });
281
240
  }
282
- /**
283
- * Stop the turn this conversation is running. The abort makes Pi end the
284
- * turn, which reaches send() through the normal turn-end path and clears the
285
- * 👀 receipts — so nothing here touches them.
286
- */
241
+ /** The abort ends the turn, which reaches send() and clears the receipts. */
287
242
  async abortTurn(msg) {
288
243
  const key = {
289
244
  channelId: this.id,
@@ -296,10 +251,8 @@ export class TelegramChannel {
296
251
  text: STOPPED,
297
252
  });
298
253
  }
299
- /**
300
- * Tell an unbound DM sender what to do. Groups stay silent (see gate()), but
301
- * a DM that swallows every message looks broken rather than locked.
302
- */
254
+ /** Groups stay silent, but a DM that swallows every message looks broken
255
+ * rather than locked. */
303
256
  async hintBind(msg) {
304
257
  if (!this.gate.mayHint(String(msg.from?.id ?? "")))
305
258
  return;
@@ -311,23 +264,16 @@ export class TelegramChannel {
311
264
  async bind(msg, code) {
312
265
  const user = msg.from;
313
266
  const name = senderName(user);
314
- const ok = this.deps.store.redeemBindCode("telegram", code, { id: String(user.id), name });
267
+ const outcome = this.deps.store.redeemBindCode("telegram", code, { id: String(user.id), name });
315
268
  await this.api.sendMessage({
316
269
  chat_id: msg.chat.id,
317
- text: bindResult(ok, name),
270
+ text: bindResult(outcome, name),
318
271
  });
319
272
  }
320
- /**
321
- * Topic mode: a message arriving in a forum group's General starts a new
322
- * topic, so every request gets its own thread and its own Pi session. A
323
- * reply or a slash command stays put — it is continuing something, not
324
- * starting it. Failure falls back to the current thread rather than losing
325
- * the message.
326
- */
273
+ /** A reply or a command stays put; failure falls back to the current thread. */
327
274
  async routeTopic(msg, text) {
328
- // Every reason to decline is named and logged: "why did it not open a
329
- // topic" is the question this feature will be asked forever, and silence
330
- // makes six invisible conditions indistinguishable from a bug.
275
+ // Every reason to decline is logged: six invisible conditions are
276
+ // indistinguishable from a bug.
331
277
  const decline = !this.deps.store.policy("telegram", String(msg.chat.id)).topicMode
332
278
  ? "topic mode off for this chat"
333
279
  : msg.chat.type !== "supergroup"
@@ -348,15 +294,14 @@ export class TelegramChannel {
348
294
  const title = topicTitle(text);
349
295
  try {
350
296
  const topic = await this.api.createForumTopic(msg.chat.id, title);
351
- // The whole point of the notice is to get out of General, so the title is
352
- // the link into the new topic rather than decoration.
353
297
  await this.api.sendMessage({
354
298
  chat_id: msg.chat.id,
355
299
  text: `→ <a href="${topicLink(msg.chat, topic.message_thread_id)}">${escapeHtml(title)}</a>`,
356
300
  parse_mode: "HTML",
357
301
  message_thread_id: msg.message_thread_id,
358
302
  reply_to_message_id: msg.message_id,
359
- }).catch(() => { });
303
+ // Without the pointer, General shows a question whose answer went elsewhere.
304
+ }).catch((err) => this.log(`topic pointer failed: ${String(err)}`));
360
305
  return topic.message_thread_id;
361
306
  }
362
307
  catch (err) {
@@ -364,10 +309,8 @@ export class TelegramChannel {
364
309
  return msg.message_thread_id;
365
310
  }
366
311
  }
367
- /** The message's attachments as the shared save loop wants them (the loop
368
- * itself, size gate and lost markers included, is core/inbox.ts). */
369
312
  saveAttachments(msg) {
370
- // Telegram sends a photo as a size ladder — the last entry is the largest.
313
+ // A photo is a size ladder; the last entry is the largest.
371
314
  const photo = msg.photo?.at(-1);
372
315
  const doc = msg.document;
373
316
  return saveInboundAll(this.id, [
@@ -375,7 +318,7 @@ export class TelegramChannel {
375
318
  label: "photo",
376
319
  mimeType: "image/jpeg",
377
320
  size: photo.file_size,
378
- // Telegram only learns a filename from getFile, so the fetch's wins.
321
+ // A filename comes only from getFile, so the fetch's wins.
379
322
  fetch: async () => this.api.downloadFile(photo.file_id, MAX_INBOUND_BYTES),
380
323
  }] : []),
381
324
  ...(doc ? [{
@@ -388,7 +331,6 @@ export class TelegramChannel {
388
331
  ], this.log);
389
332
  }
390
333
  // --- addressing ------------------------------------------------------------
391
- /** Mentioned, replying to the bot, or a slash command aimed at this bot. */
392
334
  addressed(text, msg) {
393
335
  if (msg.reply_to_message?.from?.id === this.me?.id)
394
336
  return true;
@@ -399,7 +341,6 @@ export class TelegramChannel {
399
341
  const handle = `@${this.me?.username.toLowerCase()}`;
400
342
  return !!this.me?.username && text.toLowerCase().includes(handle);
401
343
  }
402
- /** A leading @bot is addressing, not content — the agent should not see it. */
403
344
  stripMention(text) {
404
345
  const handle = `@${this.me?.username ?? ""}`;
405
346
  if (!this.me?.username)
@@ -409,31 +350,20 @@ export class TelegramChannel {
409
350
  : text;
410
351
  }
411
352
  // --- outbound --------------------------------------------------------------
412
- /**
413
- * Called on every turn-end, empty text included: the turn settled with
414
- * nothing to say, and the 👀 receipts still have to come off.
415
- */
416
353
  async send(conversation, reply) {
417
354
  const { chatId, topicId } = parseConversation(conversation);
418
- // A file the agent linked is local to this machine, so the link is dead in
419
- // Telegram: the bytes are uploaded instead and the label stays in the text.
355
+ // A local file link is dead in Telegram: the bytes are uploaded instead.
420
356
  const { text: spoken, paths } = splitAttachments(reply.text);
421
357
  const text = spoken.trim();
422
- // A turn that produced no text still posts its footer, and says which kind
423
- // of nothing it was: total silence is indistinguishable from a crash, and
424
- // the person waiting cannot tell. See AGENTS.md — an empty turn is still an
425
- // event, and an event nobody can see is not observable.
358
+ // An empty turn still posts its footer and says which kind of nothing (§5).
426
359
  const buttons = keyboard(reply.suggestions);
427
360
  const quiet = isSilentReply(reply)
428
361
  ? `<i>${quietLabel(reply.silence && escapeHtml(reply.silence))}</i>`
429
362
  : "";
430
- // A turn that is only its options still has to carry them: with no text,
431
- // no silence marker and no meta the body is empty, and a keyboard cannot
432
- // ride a message that was never sent — so it gets the smallest one.
363
+ // A keyboard cannot ride a message that was never sent.
433
364
  const body = ((text ? toTelegramHtml(text) : quiet) + turnFooter(reply.meta)) ||
434
365
  (buttons ? "…" : "");
435
- // settleAfter: a 👀 left up because the reply failed would sit there until
436
- // the stale sweep, looking like the agent is still working.
366
+ // A 👀 left up by a failed send looks like work until the stale sweep.
437
367
  await this.receipts.settleAfter(conversation, async () => {
438
368
  if (body.trim()) {
439
369
  const parts = chunk(body);
@@ -443,13 +373,10 @@ export class TelegramChannel {
443
373
  message_thread_id: topicId,
444
374
  text: part,
445
375
  parse_mode: "HTML",
446
- // Next-step buttons ride the last chunk; a click sends the label.
447
376
  reply_markup: i === parts.length - 1 ? buttons : undefined,
448
377
  });
449
378
  }
450
379
  }
451
- // Attachments follow the words, so the message that introduces them is
452
- // above them; anything that could not be sent says so in the chat.
453
380
  const lost = await sendAttachments(paths, (file) => this.api.sendFile({ chat_id: chatId, message_thread_id: topicId, file }), this.log);
454
381
  if (lost) {
455
382
  await this.api.sendMessage({
@@ -459,18 +386,10 @@ export class TelegramChannel {
459
386
  parse_mode: "HTML",
460
387
  });
461
388
  }
462
- });
389
+ }, reply.meta);
463
390
  }
464
- /**
465
- * A system note: quoted, labelled with where it came from, and deliberately
466
- * plain — no buttons and no turn footer, because the turn this input triggers
467
- * has not ended yet.
468
- *
469
- * That turn has no message of the user's to carry the 👀 — nobody typed one —
470
- * so the note wears them, on its last chunk, until the turn-end `send`
471
- * clears it. An error note is not marked: no turn follows it (`awaitsTurn`),
472
- * and the eyes would sit there until the stale sweep.
473
- */
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. */
474
393
  async notify(conversation, note) {
475
394
  const { chatId, topicId } = parseConversation(conversation);
476
395
  const label = originLabel(note.origin);
@@ -483,31 +402,20 @@ export class TelegramChannel {
483
402
  parse_mode: "HTML",
484
403
  });
485
404
  }
486
- // A failed reaction is swallowed and logged by receipts.ts, so the note
487
- // itself is never lost to one.
488
405
  if (posted && awaitsTurn(note.origin)) {
489
406
  this.receipts.mark(conversation, chatId, String(posted.message_id));
490
407
  }
491
408
  }
492
409
  }
493
- /** Display name from an update, which always carries enough to build one. */
494
410
  const senderName = (user) => [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username || String(user.id);
495
- /**
496
- * Deep link to a forum topic. A public supergroup links by username; a private
497
- * one uses the `/c/<internal id>` form, which is the chat id with its `-100`
498
- * supergroup prefix removed. Both only resolve for members — exactly the
499
- * audience standing in General.
500
- */
411
+ /** A private supergroup links as `/c/<internal id>`: the chat id with its
412
+ * `-100` prefix removed. */
501
413
  function topicLink(chat, topicId) {
502
414
  if (chat.username)
503
415
  return `https://t.me/${chat.username}/${topicId}`;
504
416
  const internal = String(chat.id).replace(/^-100(?=\d)/, "").replace(/^-/, "");
505
417
  return `https://t.me/c/${internal}/${topicId}`;
506
418
  }
507
- /**
508
- * The web shows a turn's cost on hover; IM has none, so it becomes a footer.
509
- * One newline, not a blank line: Telegram has no small or muted text, so the
510
- * only way to make it read as a footnote instead of its own paragraph is to
511
- * keep it tucked against the reply.
512
- */
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. */
513
421
  const turnFooter = (meta) => meta ? `\n<i>${formatTurnMeta(meta)}</i>` : "";
@@ -1,23 +1,12 @@
1
1
  // IM channel configuration types — the wire contract shared by the store, the
2
- // adapters and the Console view (which type-only imports it, so this file must
3
- // stay free of node builtins, exactly like core/types.ts).
4
- //
5
- // Defaults are least-privilege: mention AND bind are required. The platform
6
- // values are *seeds*, copied into a chat when the bot first sees it — not a
7
- // fallback consulted at runtime. So every chat carries its own answer and a
8
- // switch means what it says, instead of a three-state "inherit" nobody can
9
- // read off the screen.
2
+ // adapters and the Console view (type-only, so no node builtins here). Defaults
3
+ // are least-privilege, and platform values are seeds copied into a chat on
4
+ // discovery, not a runtime fallback: a switch means what it says.
10
5
  const PLATFORMS = ["telegram", "slack", "lark"];
11
6
  /** Validate at the boundary: an unknown platform is a 404, not a new row. */
12
7
  export const isChannelPlatform = (v) => typeof v === "string" && PLATFORMS.includes(v);
13
- /**
14
- * The chat a conversation id belongs to. Every adapter spells its ids
15
- * `<chatId>` or `<chatId>/<thread>` — Telegram's topic, Slack's thread_ts,
16
- * Lark's root message — so the chat half has one decoder instead of one per
17
- * platform (control.ts used to import all three adapters for exactly this).
18
- * The *thread* half stays with each adapter: its type and meaning genuinely
19
- * differ per platform.
20
- */
8
+ /** Every adapter spells its ids `<chatId>` or `<chatId>/<thread>`, so the chat
9
+ * half has one decoder; the thread half genuinely differs per platform. */
21
10
  export const chatOf = (conversationId) => conversationId.split("/", 1)[0] ?? "";
22
11
  export const defaultChannelConfig = () => ({
23
12
  enabled: false,
package/dist/cli.js CHANGED
@@ -1,10 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // What `pier` does when typed. Dispatch only — no logic, and no imports of the
3
- // server until a command that needs it: `pier service install` on a machine
4
- // with no database should not open one.
5
- //
6
- // Hand-rolled against node:util's parseArgs rather than a CLI framework: this
7
- // small command set does not earn a dependency (AGENTS.md 8).
2
+ // What `pier` does when typed. Dispatch only, and no server imports until a
3
+ // command needs them: `pier service install` must not open a database.
8
4
  import { execFileSync } from "node:child_process";
9
5
  import { accessSync, constants, realpathSync } from "node:fs";
10
6
  import { delimiter, join, resolve } from "node:path";
@@ -77,16 +73,13 @@ else if (values.version || command === "version") {
77
73
  process.stdout.write(`${version}\n`);
78
74
  }
79
75
  else if (!command) {
80
- // Typing the bare name is how someone finds out what this is, so it answers
81
- // that and nothing else: it used to start a server, which is a surprising
82
- // amount to have done by accident.
76
+ // The bare name must not start a server by accident.
83
77
  process.stdout.write(HELP);
84
78
  }
85
79
  else if (command === "serve") {
86
80
  if (subcommand)
87
81
  fail(`unexpected argument "${subcommand}"`);
88
82
  allowOnly([], "pier serve");
89
- // The server starts on import; this file stays a dispatcher.
90
83
  await import("./main.js");
91
84
  }
92
85
  else if (command === "service") {
@@ -117,11 +110,8 @@ else {
117
110
  process.stderr.write(`pier: unknown command "${command}"\n\n${HELP}`);
118
111
  process.exit(2);
119
112
  }
120
- /**
121
- * Checking is Pier's own code; applying it is npm's. Under systemd the work is
122
- * handed to a second unit — this process is about to be restarted, and a child
123
- * of the service being restarted dies with it.
124
- */
113
+ /** Under systemd the install is handed to a second unit: a child of the
114
+ * service being restarted dies with it. */
125
115
  async function update(checkOnly) {
126
116
  const check = new UpdateCheck(version);
127
117
  await check.refresh();
@@ -148,18 +138,13 @@ async function update(checkOnly) {
148
138
  return;
149
139
  }
150
140
  }
151
- // No service manager owns this process, so do not mutate its rollback point
152
- // until the operator is ready to run all three steps.
141
+ // No service manager: leave the rollback point to the operator's own sequence.
153
142
  say(`pier backup`);
154
143
  say(`npm install -g @timqi/pier@${latest}`);
155
144
  say(`then restart Pier.`);
156
145
  }
157
- /**
158
- * What the daily task runs, and what an operator can type. The setting is the
159
- * instruction; this converges on it and prints what happened, one line per
160
- * tool. Non-zero when anything failed — the task run is then a failed run with
161
- * this text in it, which is the whole tools status surface.
162
- */
146
+ /** Non-zero when anything failed: the task run is then a failed run with this
147
+ * text in it, which is the whole tools status surface. */
163
148
  async function tools(action = "") {
164
149
  if (action !== "sync") {
165
150
  process.stderr.write(`pier tools: unknown action "${action}"\n\n${HELP}`);
@@ -171,8 +156,7 @@ async function tools(action = "") {
171
156
  import("./settings.js"),
172
157
  ]);
173
158
  try {
174
- // Read inside the sync's lock, not here: a sync that queued behind another
175
- // one must converge on the set as it is when its turn comes.
159
+ // Read inside the sync's lock: a queued sync converges on the set as it is then.
176
160
  const settings = new SettingsStore();
177
161
  const report = await new ManagedTools().sync(() => settings.get());
178
162
  say(report.summary);
@@ -184,8 +168,6 @@ async function tools(action = "") {
184
168
  process.exitCode = 1;
185
169
  }
186
170
  }
187
- /** Both are signals to the running unit: SIGUSR2 drains then exits (systemd
188
- * starts the next process), SIGHUP reloads config in place (main.ts). */
189
171
  async function signalService(command) {
190
172
  if (process.platform !== "linux") {
191
173
  return fail(`only under the systemd service — send ${command === "restart" ? "SIGUSR2" : "SIGHUP"} to the pier process yourself`);
@@ -193,13 +175,11 @@ async function signalService(command) {
193
175
  const { UNIT_NAME } = await import("./service.js");
194
176
  const signal = command === "restart" ? "SIGUSR2" : "SIGHUP";
195
177
  try {
196
- // `--kill-who`, not the newer `--kill-whom`: the old spelling is the one
197
- // every systemd still parses (systemd/systemd#29793).
178
+ // `--kill-who`, not `--kill-whom`: the spelling every systemd parses (systemd/systemd#29793).
198
179
  execFileSync("systemctl", ["--user", "kill", "-s", signal, "--kill-who=main", UNIT_NAME], { stdio: "inherit" });
199
180
  }
200
181
  catch (err) {
201
- // A failed kill already printed why on the inherited stderr; a missing
202
- // systemctl printed nothing, so name it (same shape as `service status`).
182
+ // A failed kill already printed why; a missing systemctl printed nothing.
203
183
  if (err.code === "ENOENT") {
204
184
  process.stderr.write(`pier: systemctl is not on PATH — no systemd here.\n`);
205
185
  }
@@ -212,8 +192,7 @@ async function signalService(command) {
212
192
  }
213
193
  async function backup() {
214
194
  const [{ backupDb }, { PIER_DB }] = await Promise.all([import("./db.js"), import("./paths.js")]);
215
- // This tree's version: the updater runs `backup` before npm replaces it, so
216
- // it is the release the copy pairs with.
195
+ // This tree's version: the updater runs `backup` before npm replaces it.
217
196
  const path = backupDb(version, PIER_DB);
218
197
  process.stdout.write(path ? `backed up ${path}\n` : `no database yet — nothing to back up.\n`);
219
198
  }
@@ -225,7 +204,7 @@ function commandPath(name) {
225
204
  return realpathSync(path);
226
205
  }
227
206
  catch {
228
- // Keep looking: version managers commonly put several prefixes on PATH.
207
+ // Keep looking: version managers put several prefixes on PATH.
229
208
  }
230
209
  }
231
210
  return fail(`${name} is not executable on PATH`);
@@ -240,7 +219,7 @@ async function service(action = "status") {
240
219
  process.exit(2);
241
220
  }
242
221
  switch (action) {
243
- case "install": { // The installed unit owns this Node prefix until replaced.
222
+ case "install": {
244
223
  allowOnly(["force", "port", "host", "pier-home"], "pier service install");
245
224
  const port = typeof values.port === "string" ? Number(values.port) : 3141;
246
225
  if (!Number.isInteger(port) || port < 1 || port > 65_535)
@@ -255,8 +234,7 @@ async function service(action = "status") {
255
234
  if (!install({
256
235
  execPath: process.execPath,
257
236
  npmPath: commandPath("npm"),
258
- // This command is typed in the operator's shell, so its PATH is the one
259
- // they expect a turn's commands to see; the unit records it.
237
+ // Typed in the operator's shell, so this PATH is the one a turn should see.
260
238
  shellPath: process.env.PATH,
261
239
  entry: fileURLToPath(new URL("./main.js", import.meta.url)),
262
240
  host,
@@ -276,13 +254,11 @@ async function service(action = "status") {
276
254
  case "status":
277
255
  allowOnly([], "pier service status");
278
256
  try {
279
- // Inherited, not captured: systemctl's own output is the answer, and
280
- // its exit code is nonzero for a service that is merely stopped.
257
+ // Exit code is nonzero for a service that is merely stopped.
281
258
  execFileSync("systemctl", ["--user", "status", UNIT_NAME], { stdio: "inherit" });
282
259
  }
283
260
  catch (err) {
284
- // A stopped service already printed its status; a missing systemctl
285
- // printed nothing, so name it.
261
+ // A stopped service already printed its status; a missing systemctl printed nothing.
286
262
  if (err.code === "ENOENT") {
287
263
  process.stderr.write(`pier: systemctl is not on PATH — no systemd here.\n`);
288
264
  }