@timqi/pier 0.1.0 → 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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  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 +7 -23
  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 +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  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 +4 -12
  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 +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  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 +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
  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-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
  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-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
  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-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
  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-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
  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 +30 -16
  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/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
@@ -1,27 +1,9 @@
1
1
  // Slack adapter: normalize inbound Socket Mode envelopes, render outbound turns.
2
- //
3
- // The anchor is threads. Pier never posts into a channel's main flow: a message
4
- // in the channel is answered in *its own* thread, a message in a thread is
5
- // answered in that thread. So a conversation is `<channel>/<threadTs>` and the
6
- // thread is the session — which makes Telegram's `topicMode` toggle meaningless
7
- // here, because there is no other behaviour to switch to. Threads cost no admin
8
- // right and no group conversion on Slack, so the feature Telegram has to
9
- // negotiate for is simply how this adapter always works.
10
- //
11
- // Three more things are Slack-specific and live only here:
12
- // - Commands cannot start with `/`. The Slack client intercepts an
13
- // unregistered slash command and never sends it to an app, so `stop` and
14
- // `settings` are bare words instead. Registering real slash commands is
15
- // deliberately not a feature: it needs manifest setup to add a second way to
16
- // do what a word in the thread already does, and Slack only sends `thread_ts`
17
- // for a command typed *inside* a thread, so it would be the weaker path too.
18
- // - Reactions are short names (`eyes`), not codepoints; `reactions.add`
19
- // rejects 👀 with `invalid_name`.
20
- // - Slack redelivers anything it did not see acknowledged, so `event_id` is
21
- // deduplicated.
22
- //
23
- // Everything policy-shaped (mention/bind gates, per-chat overrides) is in
24
- // config.ts, platform-blind and shared with Telegram.
2
+ // Pier never posts into a channel's main flow: a conversation is
3
+ // `<channel>/<threadTs>` and the thread is the session. Slack-specific: the
4
+ // client intercepts unregistered slash commands, so `stop` and `settings` are
5
+ // bare words; reactions are short names (`reactions.add` rejects 👀 with
6
+ // `invalid_name`); unacked envelopes are redelivered, so `event_id` is deduplicated.
25
7
  import { saveInboundAll } from "../core/inbox.js";
26
8
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
27
9
  import { awaitsTurn } from "../core/reply.js";
@@ -38,36 +20,19 @@ import { SlackOutbound } from "./slack-outbound.js";
38
20
  import { readThread, slackToolAvailable } from "./slack-tool.js";
39
21
  import { SlackPanel } from "./slack-panel.js";
40
22
  import { context, escapeMrkdwn, offeredLabel } from "./slack-render.js";
41
- /** Slack wants a short name here; the raw codepoint is an `invalid_name`. */
42
23
  const WORKING = "eyes";
43
- // Backpressure: how many channels may be handled at once before a new one
44
- // queues behind an existing chain. Unlike Telegram's poll loop this cannot
45
- // slow the source down — the envelope is already acked — so it bounds
46
- // concurrency (open sockets, downloads) rather than the backlog itself.
24
+ // The envelope is already acked, so this bounds concurrency (sockets,
25
+ // downloads), not the backlog.
47
26
  const MAX_ACTIVE_CHATS = 16;
48
27
  const RECEIPT_STALE_MS = 30 * 60_000;
49
28
  const DRAIN_TIMEOUT_MS = 5000;
50
- /**
51
- * How long a delivered `event_id` is remembered. Slack retries an unacked
52
- * envelope for a few minutes; we ack immediately, so this only has to cover
53
- * a redelivery that crossed our ack.
54
- */
29
+ /** Only has to cover a redelivery that crossed our immediate ack. */
55
30
  const DEDUP_TTL_MS = 5 * 60_000;
56
31
  const DEDUP_MAX = 2000;
57
- /**
58
- * Commands that may appear as a bare word, and exactly how many arguments each
59
- * takes. Both halves are load-bearing, because Slack gives us no leading `/` to
60
- * key on: a closed set keeps ordinary prose from being a command, and the
61
- * exact arity keeps "settings are broken, please help" from opening the panel
62
- * (or worse, "stop the deploy and tell me why" from aborting the turn that was
63
- * about to explain). Anything longer is a sentence, and goes to the agent.
64
- */
32
+ /** Bare words with exact arity, since there is no leading `/` to key on: "stop
33
+ * the deploy and tell me why" is a sentence for the agent, not an abort. */
65
34
  const BARE_COMMANDS = new Map([["stop", 0], ["settings", 0], ["bind", 1]]);
66
- /**
67
- * A Slack conversation is always `<channel>/<threadTs>` — the thread is the
68
- * session. This pair is the only definition of the format; control.ts decodes
69
- * with it rather than splitting on "/" itself.
70
- */
35
+ /** The only definition of the conversation id format; control.ts decodes with it. */
71
36
  const conversationId = (channel, threadTs) => `${channel}/${threadTs}`;
72
37
  export const parseConversation = (id) => {
73
38
  const at = id.indexOf("/");
@@ -75,43 +40,20 @@ export const parseConversation = (id) => {
75
40
  ? { channel: id, threadTs: "" }
76
41
  : { channel: id.slice(0, at), threadTs: id.slice(at + 1) };
77
42
  };
78
- /**
79
- * The thread a message belongs to. A message already in a thread keeps it; one
80
- * posted in the channel becomes the root of its own — which is what makes
81
- * every request its own session without asking Slack for anything.
82
- */
43
+ /** A message posted in the channel becomes the root of its own thread. */
83
44
  const threadOf = (event) => event.thread_ts ?? event.ts ?? "";
84
- /**
85
- * Subtypes worth reading. Everything else (joins, edits, deletions, topic
86
- * changes) is noise, and `bot_message` is either our own echo or another app's.
87
- * `message_share` is in because a forward is a person handing the agent
88
- * something to look at; dropping it delivered the sharer's comment alone, or
89
- * nothing at all when they forwarded without one.
90
- */
45
+ /** Everything else (joins, edits, topic changes, `bot_message`) is noise; a
46
+ * forward is a person handing the agent something to look at. */
91
47
  const READABLE_SUBTYPES = new Set(["file_share", "thread_broadcast", "message_share"]);
92
- /**
93
- * The forwarded messages an event carries. `is_share` is the flag proper, and
94
- * a `message_share` may arrive without it. An `is_msg_unfurl` on its own is
95
- * Slack previewing a permalink somebody pasted — the sender did not choose to
96
- * forward that message, so quoting it as if they had puts words in their mouth.
97
- * A real share carries *both* flags, which is why the unfurl flag can only
98
- * rule one out.
99
- */
48
+ /** `is_share` is the flag proper, and a `message_share` may arrive without it.
49
+ * `is_msg_unfurl` alone is Slack previewing a pasted permalink, which the
50
+ * sender did not choose to forward; a real share carries both flags. */
100
51
  const sharesOf = (event) => (event.attachments ?? []).filter((a) => a.is_share === true || (event.subtype === "message_share" && !a.is_msg_unfurl));
101
- /** A share's own uploads, which Slack hangs off the attachment. */
102
52
  const sharedFiles = (share) => share.files ?? share.original_message?.files ?? [];
103
- /**
104
- * How many replies a shared thread may have before Pier stops reading it
105
- * eagerly. A token budget, not a Slack limit: a handful of lines is worth
106
- * spending on something a human deliberately forwarded, a few hundred is not,
107
- * and past this the agent gets the coordinates and decides for itself.
108
- */
53
+ /** A token budget, not a Slack limit: past this the agent gets the
54
+ * coordinates and decides for itself. */
109
55
  const INLINE_REPLY_MAX = 30;
110
- /**
111
- * What the user asked for, from text a mention has already been stripped from.
112
- * `/stop` is accepted for muscle memory even though Slack rarely lets one
113
- * through; a bare `stop` is the form that actually arrives.
114
- */
56
+ /** `/stop` is accepted for muscle memory; a bare `stop` is what actually arrives. */
115
57
  function slackCommand(text) {
116
58
  const slash = parseCommand(text);
117
59
  if (slash)
@@ -128,27 +70,17 @@ export class SlackChannel {
128
70
  id = "slack";
129
71
  api;
130
72
  log;
131
- /** 👀 lifecycle, durable; see receipts.ts for why it is not just a Map. */
132
73
  receipts;
133
- /** Ordering per channel, concurrency across them; see chains.ts. */
134
74
  chains;
135
- /** The inbound gate and the bind-hint throttle; see gatekeeper.ts. */
136
75
  gate;
137
- /** `event_id`s already handled, against Slack's at-least-once delivery. */
138
76
  seen;
139
- /** The in-chat settings panel; absent when no control was wired (tests). */
77
+ /** Absent when no control was wired (tests). */
140
78
  panel;
141
- /** Channel kinds/names and user names, cached; see slack-directory.ts. */
142
79
  directory;
143
- /**
144
- * Channels already reported to the store this process. Slack's message event
145
- * carries no channel name, so discovery costs an API call — once per channel
146
- * per process rather than once per message. A rename is picked up on restart,
147
- * which is soon enough for a Console display label.
148
- */
80
+ /** The message event carries no channel name, so discovery costs an API call
81
+ * — once per channel per process. */
149
82
  discovered = new Set();
150
83
  me = "";
151
- /** Precompiled from `me`: a leading mention, and any mention. */
152
84
  mention;
153
85
  out;
154
86
  socket;
@@ -164,8 +96,7 @@ export class SlackChannel {
164
96
  this.api = deps.client ?? new SlackApi(config.token, config.appToken, this.log);
165
97
  this.out = new SlackOutbound(this.api, this.log);
166
98
  this.receipts = new Receipts(
167
- // Slack names its reactions; the clear needs that name back, and Pier
168
- // only ever applies the one.
99
+ // The clear needs the reaction's name back.
169
100
  {
170
101
  setReaction: (channel, ts, emoji) => emoji
171
102
  ? this.api.addReaction(channel, ts, emoji)
@@ -184,20 +115,17 @@ export class SlackChannel {
184
115
  const auth = await this.api.authTest();
185
116
  this.me = auth.userId;
186
117
  if (this.me) {
187
- // A Slack user id is `[A-Z0-9]+`, so it needs no escaping — but building
188
- // these once keeps two regex compiles off the per-message path.
118
+ // A Slack user id is `[A-Z0-9]+`, so it needs no escaping.
189
119
  this.mention = {
190
120
  leading: new RegExp(`^\\s*<@${this.me}>[\\s,:-]*`),
191
121
  any: new RegExp(`<@${this.me}>`, "g"),
192
122
  };
193
123
  }
194
124
  else {
195
- // Without our own user id, "was I mentioned?" can only answer no, so
196
- // every channel with require-mention on goes silent. Loud, not a debug line.
125
+ // Every channel with require-mention on goes silent; loud, not a debug line.
197
126
  this.log("auth.test returned no user id: mention detection is disabled");
198
127
  }
199
128
  this.running = true;
200
- // Best-effort and off the critical path.
201
129
  void this.receipts.sweep(true);
202
130
  this.socket = await this.api.connect((env) => this.onEnvelope(env, onMessage));
203
131
  }
@@ -208,22 +136,17 @@ export class SlackChannel {
208
136
  await this.chains.drain(DRAIN_TIMEOUT_MS);
209
137
  }
210
138
  // --- inbound ---------------------------------------------------------------
211
- /**
212
- * Already acknowledged by the transport. Routing is synchronous so ordering
213
- * is decided here, before any await.
214
- */
139
+ /** Routing is synchronous so ordering is decided before any await. */
215
140
  onEnvelope(env, onMessage) {
216
141
  if (!this.running)
217
142
  return;
218
- // Asked on every envelope, throttled inside receipts.ts.
219
143
  void this.receipts.sweep();
220
144
  if (env.type === "events_api") {
221
145
  const payload = env.payload;
222
146
  const event = payload?.event;
223
147
  if (!event)
224
148
  return;
225
- // `app_mention` duplicates a `message.channels` we already get, and has
226
- // its own event_id, so dedup cannot save us — it has to be ignored here.
149
+ // `app_mention` duplicates a `message.channels` under its own event_id.
227
150
  if (event.type !== "message") {
228
151
  this.log(`ignored event type ${event.type}`);
229
152
  return;
@@ -240,8 +163,7 @@ export class SlackChannel {
240
163
  const interaction = env.payload;
241
164
  if (!interaction)
242
165
  return;
243
- // A modal submission carries its conversation in private_metadata, so it
244
- // is not tied to a channel chain.
166
+ // A modal submission carries its conversation in private_metadata.
245
167
  const channel = interaction.channel?.id ?? "modal";
246
168
  this.chains.run(channel, () => this.onInteraction(interaction, onMessage));
247
169
  return;
@@ -249,7 +171,6 @@ export class SlackChannel {
249
171
  this.log(`ignored envelope type ${env.type}`);
250
172
  }
251
173
  async onMessage(event, onMessage) {
252
- // Our own echo, another app, or a subtype that is not a person talking.
253
174
  if (event.bot_id || !event.user || event.user === this.me)
254
175
  return;
255
176
  if (event.subtype && !READABLE_SUBTYPES.has(event.subtype)) {
@@ -262,22 +183,17 @@ export class SlackChannel {
262
183
  return this.log("message event without a ts, dropped");
263
184
  const raw = (event.text ?? "").trim();
264
185
  const shares = sharesOf(event);
265
- // A share's files are in the attachment, so they join the event's own and
266
- // ride the one save loop below — size gate and lost markers included.
267
186
  const files = [...(event.files ?? []), ...shares.flatMap(sharedFiles)];
268
- // A forward with no comment of its own is still content, and the whole
269
- // point of the message.
270
187
  if (!raw && !files.length && !shares.length) {
271
- // A subtype we opted into that carried nothing readable is a shape this
272
- // adapter did not recognize, not an empty message — most likely a share
273
- // whose attachment `sharesOf` ruled out. Saying so beats vanishing (5b).
188
+ // An opted-in subtype with nothing readable is a shape this adapter did
189
+ // not recognize, not an empty message (§5).
274
190
  if (event.subtype)
275
191
  this.log(`${event.subtype} with nothing readable in it, dropped`);
276
192
  return;
277
193
  }
278
194
  const { kind } = await this.directory.channel(this.api, channel, event);
279
195
  const isDm = kind === "dm";
280
- if (!this.discovered.has(channel)) {
196
+ if (!this.discovered.has(channel) && this.gate.mayDiscover({ isDm, userId: event.user })) {
281
197
  this.discovered.add(channel);
282
198
  const name = await this.nameOf(channel, event);
283
199
  this.deps.store.discoverChat("slack", { id: channel, name, kind });
@@ -302,37 +218,30 @@ export class SlackChannel {
302
218
  return this.bind(channel, event.user, threadTs, command?.args ?? "");
303
219
  if (command?.name === "stop")
304
220
  return this.abortTurn(here, channel, threadTs);
305
- // `@bot` on its own (the text is empty once the mention is stripped) and
306
- // `settings` are the same request: show me this conversation's settings.
221
+ // A bare `@bot` and `settings` are the same request.
307
222
  if (this.panel && (command?.name === "settings" || (!text && !files.length && !shares.length))) {
308
223
  return this.panel.open(here, channel, threadTs);
309
224
  }
310
- // Downloading only past the gate: an unauthorized sender must not be able
311
- // to make the bot pull bytes on their behalf.
225
+ // Downloading only past the gate: an unauthorized sender must not make the
226
+ // bot pull bytes on their behalf.
312
227
  const markers = await this.saveAttachments(files);
313
228
  const shared = await Promise.all(shares.map((share) => this.sharedBlock(share)));
314
- // A Slack thread is many people talking into one session, so the agent is
315
- // told who spoke — and the id, which is what a mention needs. Resolved
316
- // *before* the mark: every await between mark() and dispatch is a window
317
- // in which a previous turn can end and settle, taking this receipt with it
318
- // (paid for on Lark first).
229
+ // Resolved before the mark: any await between mark() and dispatch is a
230
+ // window in which a previous turn can settle and take this receipt with it.
319
231
  const sender = { id: event.user, name: await this.directory.user(this.api, event.user) };
320
232
  this.receipts.mark(here.conversationId, channel, ts);
321
- // IM messages steer by default: a follow-up that waits for the turn to end
322
- // is the wrong default when the human is watching a 👀 in a thread.
233
+ // Steer: a follow-up is the wrong default when the human is watching a 👀.
323
234
  onMessage({
324
235
  key: here,
325
236
  senderId: event.user,
326
237
  sender,
327
- // Markers stay last: the inbound-file convention is a *trailing* block.
238
+ // Markers last: the inbound-file convention is a trailing block.
328
239
  text: [text, ...shared, ...markers].filter(Boolean).join("\n"),
329
240
  mode: "steer",
330
241
  });
331
242
  }
332
243
  // --- interactions ----------------------------------------------------------
333
244
  async onInteraction(interaction, onMessage) {
334
- // A modal submission carries the conversation in private_metadata and is
335
- // answered entirely by the panel.
336
245
  if (interaction.type === "view_submission") {
337
246
  if (!(await this.panel?.onViewSubmission(interaction))) {
338
247
  this.log(`unhandled view submission ${interaction.view?.callback_id ?? "?"}`);
@@ -363,24 +272,19 @@ export class SlackChannel {
363
272
  });
364
273
  if (!admitted)
365
274
  return;
366
- // Panel clicks are namespaced `cfg:` and never reach the agent.
367
275
  if (await this.panel?.onAction(interaction, key, actionId))
368
276
  return;
369
277
  const text = offeredLabel(message.blocks, actionId);
370
278
  if (text === undefined) {
371
- // The person clicked and would otherwise see nothing happen (5b).
279
+ // The person clicked and would otherwise see nothing happen (§5).
372
280
  this.log(`unknown action ${actionId} in channel ${channel}`);
373
281
  await this.api.postMessage({ channel, thread_ts: threadTs, text: STALE_OPTION })
374
282
  .catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
375
283
  return;
376
284
  }
377
- // The options belonged to the turn that just ended; once one is taken the
378
- // rest answer a question the conversation has moved past (the web drops
379
- // them for the same reason).
380
285
  await this.retireOptions(channel, message.ts, message.blocks);
381
- // A bot cannot post as the user, so the pick is echoed and marked as one.
382
- // Without it the thread shows an answer to a request nobody can see being
383
- // made, and there is no message of the user's to carry the eyes.
286
+ // A bot cannot post as the user, so the pick is echoed: otherwise the
287
+ // thread shows an answer to a request nobody can see, with nothing to carry the eyes.
384
288
  const sender = { id: user, name: await this.directory.user(this.api, user) };
385
289
  const echo = await this.api.postMessage({
386
290
  channel,
@@ -390,38 +294,26 @@ export class SlackChannel {
390
294
  this.log(`option echo failed: ${String(err)}`);
391
295
  return undefined;
392
296
  });
393
- // The receipt goes on the echo, not on the bot message that held the
394
- // buttons: the eyes mean "this input is being worked on". No await
395
- // between mark and dispatch — see onMessage.
297
+ // No await between mark and dispatch — see onMessage.
396
298
  if (echo?.ts)
397
299
  this.receipts.mark(key.conversationId, channel, echo.ts);
398
300
  onMessage({ key, senderId: user, sender, text, mode: "steer" });
399
301
  }
400
- /**
401
- * Drop the actions row, keeping the reply itself exactly as it was. A turn
402
- * that was *nothing but* its options leaves nothing to keep, and Slack will
403
- * not accept a message with neither text nor blocks — so it becomes a muted
404
- * line rather than staying clickable forever.
405
- */
302
+ /** Slack will not accept a message with neither text nor blocks, so a turn
303
+ * that was nothing but its options becomes a muted line. */
406
304
  async retireOptions(channel, ts, blocks) {
407
305
  const kept = (blocks ?? []).filter((b) => b.type !== "actions");
408
306
  const fallback = kept.find((b) => b.type === "section")?.text.text;
409
307
  await this.api.setBlocks(channel, ts, fallback ?? "Option taken.", kept.length ? kept : [context("_Option taken._")]).catch((err) => this.log(`retiring options failed: ${String(err)}`));
410
308
  }
411
- /**
412
- * Stop the turn this conversation is running. The abort makes Pi end the
413
- * turn, which reaches send() through the normal turn-end path and clears the
414
- * 👀 receipts — so nothing here touches them.
415
- */
309
+ /** The abort ends the turn, which reaches send() and clears the receipts. */
416
310
  async abortTurn(key, channel, threadTs) {
417
311
  await this.deps.control?.abort(key);
418
312
  await this.api.postMessage({ channel, thread_ts: threadTs, text: STOPPED });
419
313
  }
420
314
  // --- bind ------------------------------------------------------------------
421
- /**
422
- * Tell an unbound DM sender what to do. Channels stay silent (see gate()),
423
- * but a DM that swallows every message looks broken rather than locked.
424
- */
315
+ /** Channels stay silent, but a DM that swallows every message looks broken
316
+ * rather than locked. */
425
317
  async hintBind(channel, userId, threadTs) {
426
318
  if (!this.gate.mayHint(userId))
427
319
  return;
@@ -433,36 +325,28 @@ export class SlackChannel {
433
325
  }
434
326
  async bind(channel, userId, threadTs, code) {
435
327
  const name = await this.directory.user(this.api, userId);
436
- const ok = this.deps.store.redeemBindCode("slack", code, { id: userId, name });
328
+ const outcome = this.deps.store.redeemBindCode("slack", code, { id: userId, name });
437
329
  await this.api.postMessage({
438
330
  channel,
439
331
  thread_ts: threadTs,
440
- text: bindResult(ok, escapeMrkdwn(name)),
332
+ text: bindResult(outcome, escapeMrkdwn(name)),
441
333
  });
442
334
  }
443
335
  // --- addressing ------------------------------------------------------------
444
- /**
445
- * Mentioned, or continuing a thread Pier already owns — Slack's equivalent
446
- * of Telegram's "replying to the bot". The thread check is what lets a
447
- * conversation flow without an `@` on every line, and it is durable so it
448
- * still holds after a restart.
449
- */
336
+ /** Mentioned, or continuing a thread Pier already owns — durable, so it
337
+ * holds after a restart. */
450
338
  addressed(raw, event, key) {
451
339
  if (this.me && raw.includes(`<@${this.me}>`))
452
340
  return true;
453
341
  return !!event.thread_ts && !!this.deps.control?.knows(key);
454
342
  }
455
- /**
456
- * A leading `<@BOT>` is addressing, not content — the agent should not see
457
- * it. Slack does not strip it for us, and puts it wherever the user typed it.
458
- */
343
+ /** Slack does not strip the mention for us. */
459
344
  stripMention(text) {
460
345
  if (!this.mention)
461
346
  return text;
462
347
  return text.replace(this.mention.leading, "").replace(this.mention.any, "").trim();
463
348
  }
464
349
  // --- lookups ---------------------------------------------------------------
465
- /** A readable label for the Console's channel list; the id is the fallback. */
466
350
  async nameOf(channel, event) {
467
351
  const { kind, name } = await this.directory.channel(this.api, channel, event);
468
352
  if (kind === "dm") {
@@ -470,32 +354,20 @@ export class SlackChannel {
470
354
  }
471
355
  return name ?? channel;
472
356
  }
473
- /**
474
- * What a forwarded message contributes to the prompt: who wrote it, where it
475
- * lives, its text, and — when it is a thread parent — either the thread
476
- * itself or the coordinates for `read_thread`. Nothing is invented: a name, a
477
- * channel or a ts the event does not carry is simply left out of the line.
478
- *
479
- * The eager read runs whether or not `agentTool` is on, because this is
480
- * inbound normalization of a message a human deliberately handed the agent —
481
- * the same act as an upload, which nothing gates either. `agentTool` governs
482
- * the agent reaching *out*, and the only thing it changes here is the hint,
483
- * which would otherwise name a tool this session does not have.
484
- */
357
+ /** The eager thread read runs whether or not `agentTool` is on: a human
358
+ * handing the agent a message is the same act as an upload, which nothing
359
+ * gates. `agentTool` only changes the hint, which must not name a tool this
360
+ * session does not have. */
485
361
  async sharedBlock(share) {
486
362
  const source = share.original_message;
487
363
  const ts = share.ts ?? source?.ts;
488
364
  const threadTs = share.thread_ts ?? source?.thread_ts ?? ts;
489
365
  const replies = share.reply_count ?? source?.reply_count;
490
- // A share of a *reply* is one message; only a parent has a thread — and
491
- // the three fields a thread needs travel together so neither branch below
492
- // has to assert they are there.
366
+ // A share of a reply is one message; only a parent has a thread.
493
367
  const parent = share.channel_id && ts && threadTs === ts && replies
494
368
  ? { channel: share.channel_id, ts, replies }
495
369
  : null;
496
- // `name<id>` is the sender prefix's grammar (core/identity.ts), and the id
497
- // is the only thing a mention can be built from. Resolved through the same
498
- // cache the senders use, so a shared author already seen costs nothing.
370
+ // `name<id>` is the sender prefix's grammar (core/identity.ts).
499
371
  const author = share.author_id
500
372
  ? `${await this.directory.user(this.api, share.author_id)}<${share.author_id}>`
501
373
  : share.author_name || share.author_subname;
@@ -508,42 +380,28 @@ export class SlackChannel {
508
380
  where && `in ${where}`,
509
381
  ts && `at ${ts}`,
510
382
  ].filter(Boolean).join(" ");
511
- // `fallback` is the plain-text rendering Slack sends when a share's `text`
512
- // is empty (a file-only forward, or one whose body is all blocks).
513
- // Empty rather than absent is the normal case for a file-only forward, so
514
- // these fall through on "" as well.
383
+ // `fallback` is Slack's plain-text rendering when a share's `text` is
384
+ // empty (a file-only forward, or a body that is all blocks); "" is normal.
515
385
  const body = (share.text || share.fallback || source?.text || "").trim();
516
386
  const thread = parent && parent.replies <= INLINE_REPLY_MAX
517
387
  ? await this.sharedThread(parent.channel, parent.ts, parent.replies)
518
388
  : { transcript: false, lines: [] };
519
- // Past the budget, or read and failed: the coordinates are what is left,
520
- // and they carry the tool's own parameter names so nothing has to be
521
- // guessed from a permalink. Naming the tool is a lie when the Console has
522
- // switched agent access off, so only that half goes — the coordinates are
523
- // true either way.
389
+ // The coordinates carry the tool's own parameter names; naming the tool is
390
+ // a lie when the Console has switched agent access off.
524
391
  const how = slackToolAvailable(this.deps.store) ? "read with the slack tool: " : "";
525
392
  const hint = parent && !thread.transcript
526
393
  ? `[thread: ${parent.replies} replies — ${how}channel ${parent.channel}, thread_ts ${parent.ts}]`
527
394
  : "";
528
- // The transcript opens with the shared message itself, so repeating its
529
- // text above it would only cost tokens.
395
+ // The transcript opens with the shared message itself.
530
396
  return [`[${head}]`, thread.transcript ? "" : body, ...thread.lines, hint]
531
397
  .filter(Boolean).join("\n");
532
398
  }
533
- /**
534
- * A small shared thread, read eagerly and inlined as the slack tool's own
535
- * lines — through the tool's own read, so the paging, the seam dedup, the
536
- * ordering and the error-to-action translation exist once (slack-tool.ts).
537
- *
538
- * A read that fails or comes back cut says so in the prompt (5b): a thread
539
- * the agent silently never saw is indistinguishable from one with nothing in
540
- * it, and the turn is dispatched either way.
541
- */
399
+ /** Through the slack tool's own read, so paging and dedup exist once. A read
400
+ * that fails or comes back cut says so in the prompt (§5). */
542
401
  async sharedThread(channel, ts, replies) {
543
402
  const deps = { directory: this.directory, log: this.log };
544
403
  try {
545
- // The parent plus its replies, and one over the budget so a reply_count
546
- // that undercounts still reports itself as cut rather than as complete.
404
+ // One over the budget, so an undercounting reply_count still reports as cut.
547
405
  const read = await readThread(deps, this.api, channel, ts, undefined, INLINE_REPLY_MAX + 2);
548
406
  if (!read.messages.length)
549
407
  return { transcript: false, lines: [] };
@@ -552,9 +410,6 @@ export class SlackChannel {
552
410
  lines: [
553
411
  `[thread: ${replies} replies, oldest first — ${read.format}]`,
554
412
  ...read.messages,
555
- // Cut either because a page failed or because the thread turned out
556
- // longer than `reply_count` promised; a transcript that answers as
557
- // if it were complete is the one nobody double-checks.
558
413
  ...(read.incomplete || read.truncated
559
414
  ? [`[thread partly read: ${read.incomplete ?? `cut at ${read.count} lines`}]`]
560
415
  : []),
@@ -563,16 +418,12 @@ export class SlackChannel {
563
418
  }
564
419
  catch (err) {
565
420
  this.log(`shared thread ${channel}/${ts} not read: ${String(err)}`);
566
- // Said in the prompt in the same shape as an attachment that never made
567
- // it, because it is the same fact to the reader.
568
421
  return {
569
422
  transcript: false,
570
423
  lines: [`[thread not read: ${err instanceof Error ? err.message : String(err)}]`],
571
424
  };
572
425
  }
573
426
  }
574
- /** The upload list as the shared save loop wants it (the loop itself, size
575
- * gate and lost markers included, is core/inbox.ts). */
576
427
  saveAttachments(files) {
577
428
  return saveInboundAll(this.id, files.map((file) => ({
578
429
  label: file.name ?? "attachment",
@@ -584,34 +435,20 @@ export class SlackChannel {
584
435
  })), this.log);
585
436
  }
586
437
  // --- outbound --------------------------------------------------------------
587
- /**
588
- * Called on every turn-end, empty text included: the turn settled with
589
- * nothing to say, and the 👀 receipts still have to come off.
590
- */
591
438
  async send(conversation, reply) {
592
439
  const { channel, threadTs } = parseConversation(conversation);
593
- // Every id this adapter mints carries a thread, so an empty one is a
594
- // corrupted or foreign conversation id. Posting it would put an agent turn
595
- // in the channel's main flow — the one thing this adapter promises never to
596
- // do — so it is refused loudly instead, and the receipts still come off so
597
- // no 👀 is stranded.
440
+ // No thread is a foreign id; posting it would put a turn in the channel's
441
+ // main flow. Refused loudly, receipts still cleared.
598
442
  if (!threadTs) {
599
443
  this.log(`refusing to answer ${conversation}: no thread in the conversation id`);
600
444
  await this.receipts.settle(conversation);
601
445
  return;
602
446
  }
603
- // settleAfter: the turn ended either way, and a 👀 left up because the
604
- // reply failed to send looks like work until the stale sweep.
605
- await this.receipts.settleAfter(conversation, () => this.out.reply(channel, threadTs, reply));
447
+ // The turn ended either way; a 👀 left up by a failed send looks like work.
448
+ await this.receipts.settleAfter(conversation, () => this.out.reply(channel, threadTs, reply), reply.meta);
606
449
  }
607
- /**
608
- * A system note, and the 👀 goes on the note itself: the turn it triggers has
609
- * no message of the user's to carry them — nobody typed one — so without this
610
- * the thread shows nothing at all while the agent works. The turn-end `send`
611
- * clears it like any other receipt; an error note is not marked, because no
612
- * turn follows it (`awaitsTurn`) and the eyes would sit there until the stale
613
- * sweep.
614
- */
450
+ /** The 👀 goes on the note itself: the turn it triggers has no message of
451
+ * the user's to carry them. */
615
452
  async notify(conversation, note) {
616
453
  const { channel, threadTs } = parseConversation(conversation);
617
454
  if (!threadTs) {
@@ -619,9 +456,6 @@ export class SlackChannel {
619
456
  return;
620
457
  }
621
458
  const ts = await this.out.note(channel, threadTs, note);
622
- // The last chunk of a long note, so the eyes sit at the foot of the thread,
623
- // where the reply will land. A reaction that fails is swallowed and logged
624
- // by receipts.ts, so the note itself is never lost to one.
625
459
  if (ts && awaitsTurn(note.origin))
626
460
  this.receipts.mark(conversation, channel, ts);
627
461
  }