@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,26 +1,13 @@
1
- // Reaction receipts: the whole lifecycle, storage included.
2
- //
3
- // A 👀 goes on an inbound message and comes off when its turn settles. Both
4
- // halves live in Telegram, not in Pier, so anything ending the process between
5
- // them leaves the emoji on a user's message with nobody left to clear it —
6
- // and so does a message whose session never started a turn at all. Making the
7
- // pending set durable is what closes the loop: an adapter clears every receipt
8
- // it finds at startup (nothing in memory can be its own yet), and sweeps its
9
- // own stragglers on a timer.
1
+ // Reaction receipts: a 👀 goes on an inbound message and comes off when its
2
+ // turn settles. Durable, because the emoji lives on the platform: a process
3
+ // ending between the two halves would leave it with nobody to clear it.
10
4
  import { pierDb } from "../db.js";
11
- /**
12
- * How often the straggler sweep may really run. Adapters ask on the inbound
13
- * path — per envelope, or per `getUpdates` round trip — and the books only
14
- * change on the scale of `staleMs`, so a busy chat would otherwise run this
15
- * query hundreds of times a minute. Throttled here rather than in each
16
- * adapter, which is where the same timestamp had been copied twice already.
17
- */
5
+ /** Adapters ask on every inbound envelope; the books change on the scale of `staleMs`. */
18
6
  const SWEEP_EVERY_MS = 60_000;
19
7
  const toReceipt = (row) => ({
20
8
  conversationId: row.conversation_id,
21
9
  chatId: row.chat_id,
22
- // SQLite hands back whatever affinity it stored; the column is TEXT, but
23
- // coercing keeps a numeric-looking id from arriving as a number.
10
+ // The column is TEXT, but a numeric-looking id could still arrive as a number.
24
11
  messageId: String(row.message_id),
25
12
  });
26
13
  export class ReceiptLedger {
@@ -30,7 +17,6 @@ export class ReceiptLedger {
30
17
  this.platform = platform;
31
18
  this.db = db;
32
19
  }
33
- /** Re-marking the same message replaces the row rather than duplicating it. */
34
20
  add(receipt) {
35
21
  this.db.prepare(`
36
22
  INSERT INTO receipts(platform, conversation_id, chat_id, message_id, created_at)
@@ -39,14 +25,17 @@ export class ReceiptLedger {
39
25
  conversation_id = excluded.conversation_id, created_at = excluded.created_at
40
26
  `).run(this.platform, receipt.conversationId, receipt.chatId, receipt.messageId, Date.now());
41
27
  }
42
- /** Claim a conversation's receipts: returned once, then gone. */
43
- take(conversationId) {
28
+ /** Returned once, then gone. `bookedBy` claims only what was on the books by
29
+ * then — see `Receipts.settle`. */
30
+ take(conversationId, bookedBy) {
31
+ const scope = bookedBy === undefined ? "" : " AND created_at <= ?";
32
+ const args = bookedBy === undefined ? [] : [bookedBy];
44
33
  const rows = this.db.prepare(`
45
34
  SELECT conversation_id, chat_id, message_id FROM receipts
46
- WHERE platform = ? AND conversation_id = ?
47
- `).all(this.platform, conversationId);
48
- this.db.prepare("DELETE FROM receipts WHERE platform = ? AND conversation_id = ?")
49
- .run(this.platform, conversationId);
35
+ WHERE platform = ? AND conversation_id = ?${scope}
36
+ `).all(this.platform, conversationId, ...args);
37
+ this.db.prepare(`DELETE FROM receipts WHERE platform = ? AND conversation_id = ?${scope}`)
38
+ .run(this.platform, conversationId, ...args);
50
39
  return rows.map(toReceipt);
51
40
  }
52
41
  /** Claim receipts older than `ageMs`; `0` claims everything (startup sweep). */
@@ -61,20 +50,15 @@ export class ReceiptLedger {
61
50
  return rows.map(toReceipt);
62
51
  }
63
52
  }
64
- /**
65
- * Marks messages as being worked on and unmarks them when their turn settles.
66
- * Lives next to the ledger because the ordering rule spans both: a receipt is
67
- * booked synchronously (so an instant turn cannot clear an unbooked one) while
68
- * the platform call is in flight, and the clear must wait for that call to land
69
- * or the reaction stays up forever.
70
- */
53
+ /** A receipt is booked synchronously (an instant turn must not clear an
54
+ * unbooked one) while the platform call is in flight, and the clear waits for
55
+ * that call to land or the reaction stays up forever. */
71
56
  export class Receipts {
72
57
  api;
73
58
  ledger;
74
59
  log;
75
60
  emoji;
76
61
  staleMs;
77
- /** In-flight `setReaction` per marked message. Only this process's own. */
78
62
  applying = new Map();
79
63
  sweptAt = 0;
80
64
  constructor(api, ledger, log, emoji,
@@ -91,40 +75,33 @@ export class Receipts {
91
75
  .catch((err) => this.log(`reaction failed: ${String(err)}`)));
92
76
  this.ledger.add({ conversationId, chatId, messageId });
93
77
  }
94
- /** The turn this conversation was running has ended. */
95
- settle(conversationId) {
96
- return this.clear(this.ledger.take(conversationId));
78
+ /** Only the messages *this* turn was working on: a message queued mid-turn
79
+ * is still owed an answer. `meta` says when the turn began; no meta clears
80
+ * everything (the refusal paths have no turn to scope by). */
81
+ settle(conversationId, meta) {
82
+ const began = meta && meta.completedAt - meta.durationMs;
83
+ return this.clear(this.ledger.take(conversationId, began));
97
84
  }
98
- /**
99
- * Deliver a turn and settle its receipts *whatever happens* — a 👀 left on
100
- * a message because the reply failed to send sits there looking like work
101
- * until the stale sweep. The try/finally was copied into all three
102
- * adapters' send() before landing here; the error still propagates, because
103
- * a failed delivery is the router's to report.
104
- */
105
- async settleAfter(conversationId, deliver) {
85
+ /** Settles whatever happens; the error still propagates, because a failed
86
+ * delivery is the router's to report. */
87
+ async settleAfter(conversationId, deliver, meta) {
106
88
  try {
107
89
  await deliver();
108
90
  }
109
91
  finally {
110
- await this.settle(conversationId);
92
+ await this.settle(conversationId, meta);
111
93
  }
112
94
  }
113
- /**
114
- * Everything on the books at startup is orphaned — nothing in memory can be
115
- * ours yet — and past `staleMs` a receipt's turn is never going to settle.
116
- */
95
+ /** `all` is the startup sweep: everything on the books is orphaned then. */
117
96
  sweep(all = false) {
118
97
  const now = Date.now();
119
- // `all` is the startup sweep: it takes everything, so it is never skipped.
120
98
  if (!all && now - this.sweptAt < SWEEP_EVERY_MS)
121
99
  return Promise.resolve();
122
100
  this.sweptAt = now;
123
101
  return this.clear(this.ledger.takeStale(all ? 0 : this.staleMs));
124
102
  }
125
103
  async clear(receipts) {
126
- // Wait together, then launch clears in booking order: if one apply is slow,
127
- // it must not let a later receipt clear first.
104
+ // A slow apply must not let a later receipt clear first.
128
105
  await Promise.all(receipts.map(({ chatId, messageId }) => this.applying.get(`${chatId}:${messageId}`)));
129
106
  for (const { chatId, messageId } of receipts)
130
107
  this.applying.delete(`${chatId}:${messageId}`);
@@ -1,6 +1,4 @@
1
- // Settings → Channels HTTP surface. One document per platform: credentials,
2
- // global defaults, bound users and discovered chats travel together, so the
3
- // UI never has to stitch two half-configs.
1
+ // Settings → Channels HTTP surface: one document per platform.
4
2
  import { isThinkingLevel } from "../core/types.js";
5
3
  import { defaultChannelConfig, isChannelPlatform, } from "./types.js";
6
4
  /** Stable mask: recomputable at write time, so "unchanged" is detectable. */
@@ -17,12 +15,8 @@ function asModel(v) {
17
15
  const id = asString(ref.id);
18
16
  return provider && id ? { provider, id } : null;
19
17
  }
20
- /**
21
- * Apply the client's edits on top of what the store knows. Iterating the
22
- * stored list, not the payload, is what makes the save non-destructive: chats
23
- * are discovered, so one that appeared while the operator had the page open
24
- * must survive their save instead of being deleted by a stale client list.
25
- */
18
+ /** Iterates the stored list, not the payload: a chat discovered while the
19
+ * operator had the page open must survive their save. */
26
20
  function parseChats(raw, known) {
27
21
  if (!Array.isArray(raw))
28
22
  return known;
@@ -1,20 +1,16 @@
1
- // Channel lifecycle: which adapters are running, and where their sessions
2
- // live. Keeps main.ts wiring-only and gives the Console one call to apply a
3
- // config change.
1
+ // Channel lifecycle: which adapters are running; one call for the Console to
2
+ // apply a config change.
4
3
  import { logger } from "../log.js";
5
4
  import { LarkChannel } from "./lark.js";
6
5
  import { SlackChannel } from "./slack.js";
7
6
  import { TelegramChannel } from "./telegram.js";
8
- /** Platforms with an adapter, and what each needs before it can start. */
9
7
  const ADAPTERS = [
10
8
  { platform: "telegram", needsAppToken: false, build: (deps) => new TelegramChannel(deps) },
11
9
  { platform: "slack", needsAppToken: true, build: (deps) => new SlackChannel(deps) },
12
- // Lark's "token" is the App ID and "appToken" the App Secret; the adapter
13
- // needs both before it can start, same gate as Slack's two credentials.
10
+ // Lark's "token" is the App ID and "appToken" the App Secret.
14
11
  { platform: "lark", needsAppToken: true, build: (deps) => new LarkChannel(deps) },
15
12
  ];
16
- // Lifecycle news, which is not what the injected sink below is for: that one
17
- // is a warning sink the adapters share, and "slack started" is not a warning.
13
+ // The injected sink is for warnings; "slack started" is not one.
18
14
  const log = logger("channels");
19
15
  // The parameter below shadows `log` inside its own default expression.
20
16
  const warn = (m) => log.warn(m);
@@ -32,10 +28,8 @@ export class ChannelRuntime {
32
28
  this.control = control;
33
29
  this.log = log;
34
30
  }
35
- /** (Re)start every platform whose config says it should run. Idempotent.
36
- * Serialized — two concurrent Console saves raced into duplicate live
37
- * adapters; platforms restart in parallel so a hung start on one cannot
38
- * stall the other, and a failure is logged, never swallowed. */
31
+ /** Serialized: two concurrent Console saves would race into duplicate live
32
+ * adapters. Platforms restart in parallel so a hung start cannot stall another. */
39
33
  reload() {
40
34
  const run = this.reloading.catch(() => { }).then(async () => {
41
35
  if (this.stopped)
@@ -55,24 +49,20 @@ export class ChannelRuntime {
55
49
  const existing = this.live.get(platform);
56
50
  if (existing) {
57
51
  this.live.delete(platform);
58
- // Never fatal — the config still has to be applied — but a socket that
59
- // refuses to close is exactly what makes the next start behave oddly.
52
+ // Never fatal: the config still has to be applied.
60
53
  await existing.stop().catch((err) => this.log(`${platform} did not stop cleanly: ${String(err)}`));
61
54
  }
62
55
  const config = this.store.get(platform);
63
56
  if (!config.enabled || !config.token)
64
57
  return;
65
58
  if (needsAppToken && !config.appToken) {
66
- // Named, not silent: "enabled but nothing happens" is otherwise
67
- // indistinguishable from a broken adapter.
59
+ // "Enabled but nothing happens" is indistinguishable from a broken adapter.
68
60
  this.log(`${platform}: enabled but no app token, not starting`);
69
61
  return;
70
62
  }
71
63
  const channel = build({
72
64
  store: this.store,
73
65
  log: (m) => this.log(`${platform}: ${m}`),
74
- // The runtime owns the router, so channel control (stop, the settings
75
- // panel) is wired here instead of widening the Channel seam.
76
66
  control: this.control,
77
67
  });
78
68
  try {
@@ -88,9 +78,8 @@ export class ChannelRuntime {
88
78
  this.live.set(platform, channel);
89
79
  log.info(`${platform} started`);
90
80
  }
91
- /** Post a note into a conversation on a live adapter. Boot-time restart-note
92
- * delivery (src/drain.ts) has no session to report through; false means the
93
- * platform is not running, so the caller can say so instead of dropping it. */
81
+ /** For restart-note delivery (src/drain.ts), which has no session to report
82
+ * through; false means the platform is not running. */
94
83
  async notify(platform, conversationId, text) {
95
84
  const channel = this.live.get(platform);
96
85
  if (!channel)
@@ -99,8 +88,8 @@ export class ChannelRuntime {
99
88
  return true;
100
89
  }
101
90
  async stop() {
102
- // Join the reload queue first: an in-flight restart could otherwise
103
- // register an adapter after `live` was cleared — running, unstoppable.
91
+ // An in-flight restart could otherwise register an adapter after `live`
92
+ // was cleared — running, unstoppable.
104
93
  this.stopped = true;
105
94
  await this.reloading.catch(() => { });
106
95
  for (const channel of this.live.values()) {
@@ -1,37 +1,18 @@
1
- // Thin Slack client: HTTP shapes and the Socket Mode transport, no policy.
2
- // The one file in channels/ that talks to slack.com, so the adapter stays
3
- // testable against `SlackClient`.
4
- //
5
- // Socket Mode, not the Events API over HTTP: Pier is one local process and must
6
- // not require a public inbound URL. It needs two credentials — an app-level
7
- // token (`xapp-`) opens the socket, the bot token (`xoxb-`) signs every Web API
8
- // call — which is why ChannelConfig carries `appToken` beside `token`.
9
- //
10
- // No SDK: `apps.connections.open` plus Node's built-in WebSocket is the whole
11
- // protocol, and @slack/socket-mode would pull a dependency tree to wrap it.
1
+ // Thin Slack client: HTTP shapes and the Socket Mode transport, no policy; the
2
+ // one file that talks to slack.com. Socket Mode so Pier needs no public inbound
3
+ // URL: the app-level token (`xapp-`) opens the socket, the bot token (`xoxb-`)
4
+ // signs every Web API call. No SDK: `apps.connections.open` plus Node's
5
+ // WebSocket is the whole protocol.
12
6
  import { readCapped } from "../core/inbox.js";
13
7
  const BASE = "https://slack.com/api";
14
- /**
15
- * Did Slack refuse the payload because of the *block* itself? The `markdown`
16
- * block is recent, so a workspace that predates it answers with one of these —
17
- * the signal to re-render the turn as legacy mrkdwn rather than to lose it.
18
- *
19
- * Slack has no capability API to ask up front, so the only detection is a
20
- * failed send. That makes the test's *narrowness* the whole safety property:
21
- * the caller latches the answer for the process, so anything matched here
22
- * degrades every later message too. `invalid_arguments` is deliberately NOT
23
- * matched even though avibe lists it — avibe retries per message, where a
24
- * broad match costs one fallback; latching turns the same breadth into a
25
- * permanent downgrade triggered by an unrelated bad argument (a malformed
26
- * `thread_ts` would silently cost the whole process its rendering). A wrong
27
- * call should surface as an error, not as a quieter renderer.
28
- */
8
+ /** A workspace that predates the `markdown` block answers with one of these;
9
+ * there is no capability API, so a failed send is the only detection. The
10
+ * caller latches the answer for the process, so the match must stay narrow:
11
+ * `invalid_arguments` is deliberately not matched — a malformed `thread_ts`
12
+ * would otherwise permanently downgrade the renderer. */
29
13
  export const isBlockRejection = (err) => /invalid_blocks|unsupported_block_type/.test(String(err));
30
- /**
31
- * A connection that dies younger than this was a failed attempt, however it
32
- * ended: Slack answers "too many connections" by accepting the socket and
33
- * closing it straight away, which is not an error the loop would otherwise see.
34
- */
14
+ /** Younger than this was a failed attempt: Slack answers "too many
15
+ * connections" by accepting the socket and closing it straight away. */
35
16
  const MIN_CONNECTION_MS = 5000;
36
17
  const RECONNECT_FLOOR_MS = 1000;
37
18
  const RECONNECT_MAX_MS = 30_000;
@@ -42,20 +23,16 @@ export class SlackApi {
42
23
  openSocket;
43
24
  socketRunning = false;
44
25
  constructor(token, appToken, log = () => { },
45
- /** Injected in tests; production opens a real WebSocket. */
26
+ /** Injected in tests. */
46
27
  openSocket = (url) => new WebSocket(url)) {
47
28
  this.token = token;
48
29
  this.appToken = appToken;
49
30
  this.log = log;
50
31
  this.openSocket = openSocket;
51
32
  }
52
- /**
53
- * Slack accepts a JSON body only on *write* methods. A read method
54
- * (`users.info`, `conversations.info|history|replies`) silently ignores it and
55
- * then reports the missing parameter — `users.info` answers `user_not_found`,
56
- * which reads like "no such person" rather than "you sent the id in a place I
57
- * do not look". So reads go form-encoded. This was worth four broken calls.
58
- */
33
+ /** Slack accepts a JSON body only on write methods; a read method silently
34
+ * ignores it and reports the parameter missing (`users.info` answers
35
+ * `user_not_found`). So reads go form-encoded. */
59
36
  async read(method, params) {
60
37
  const form = new URLSearchParams();
61
38
  for (const [key, value] of Object.entries(params)) {
@@ -77,9 +54,8 @@ export class SlackApi {
77
54
  body: form ? payload.toString() : JSON.stringify(payload),
78
55
  signal: AbortSignal.timeout(30_000),
79
56
  });
80
- // Slack answers a flood (a long turn split into chunks hits ~1 msg/s per
81
- // channel) with the exact wait in a header. Obeying it once turns a dropped
82
- // reply into a late one; a second 429 is a real problem and throws.
57
+ // A long turn split into chunks hits ~1 msg/s per channel; the header
58
+ // carries the exact wait. Obeyed once; a second 429 throws.
83
59
  if (res.status === 429 && retry) {
84
60
  const after = Number(res.headers.get("retry-after") ?? "1");
85
61
  if (Number.isFinite(after) && after <= 60) {
@@ -99,32 +75,26 @@ export class SlackApi {
99
75
  return { userId: body.user_id ?? "" };
100
76
  }
101
77
  // --- Socket Mode -----------------------------------------------------------
102
- /**
103
- * Reconnecting is part of the protocol, not the adapter's problem: Slack
104
- * cycles a connection every few hours with `disconnect: refresh_requested`,
105
- * so the loop reopens until `close()` clears the flag.
106
- */
78
+ /** Slack cycles a connection every few hours with `disconnect:
79
+ * refresh_requested`, so the loop reopens until `close()` clears the flag. */
107
80
  async connect(onEnvelope) {
108
81
  this.socketRunning = true;
109
82
  let socket;
110
83
  const run = async () => {
111
84
  let backoff = RECONNECT_FLOOR_MS;
112
85
  while (this.socketRunning) {
113
- // Set once the socket exists, so a slow `apps.connections.open` cannot
114
- // make a connection that died instantly look like a healthy one.
86
+ // Set once the socket exists, not before `apps.connections.open`.
115
87
  let connectedAt = 0;
116
88
  try {
117
89
  const open = await this.call("apps.connections.open", {}, this.appToken);
118
90
  if (!open.url)
119
91
  throw new Error("apps.connections.open returned no url");
120
- // stop() may have landed while that call was in flight. Opening now
121
- // would leave a live socket nobody holds a reference to.
92
+ // stop() may have landed while that call was in flight.
122
93
  if (!this.socketRunning)
123
94
  return;
124
95
  socket = this.openSocket(open.url);
125
96
  connectedAt = Date.now();
126
- // Resolves on close, never rejects: a dropped socket is normal and
127
- // the loop's job is to reopen it, not to treat it as an error.
97
+ // Resolves on close, never rejects: a dropped socket is normal.
128
98
  await new Promise((resolve) => {
129
99
  const ws = socket;
130
100
  ws.onmessage = (ev) => {
@@ -133,13 +103,11 @@ export class SlackApi {
133
103
  env = JSON.parse(String(ev.data));
134
104
  }
135
105
  catch {
136
- // Validate at the boundary: log and drop, never half-handle.
137
106
  this.log(`unparseable socket frame dropped`);
138
107
  return;
139
108
  }
140
- // Ack first and always. Handling happens after, because a turn
141
- // outlives the deadline and Slack redelivers what it never saw
142
- // acknowledged.
109
+ // Ack first: a turn outlives the deadline, and an unacked
110
+ // envelope is redelivered.
143
111
  if (env.envelope_id) {
144
112
  try {
145
113
  ws.send(JSON.stringify({ envelope_id: env.envelope_id }));
@@ -151,8 +119,6 @@ export class SlackApi {
151
119
  if (env.type === "hello")
152
120
  return;
153
121
  if (env.type === "disconnect") {
154
- // Expected: Slack recycles connections. Closing resolves the
155
- // promise below and the loop reopens.
156
122
  this.log(`socket disconnect (${env.reason ?? "no reason"}), reconnecting`);
157
123
  ws.close();
158
124
  return;
@@ -170,10 +136,7 @@ export class SlackApi {
170
136
  }
171
137
  if (!this.socketRunning)
172
138
  return;
173
- // The anti-spin floor. A socket that lived a while was healthy, so the
174
- // next attempt starts from the floor again; one that died young — or
175
- // threw — backs off, because reopening instantly would hammer
176
- // apps.connections.open in a tight loop.
139
+ // A socket that lived a while was healthy; one that died young backs off.
177
140
  if (connectedAt && Date.now() - connectedAt >= MIN_CONNECTION_MS) {
178
141
  backoff = RECONNECT_FLOOR_MS;
179
142
  }
@@ -219,7 +182,6 @@ export class SlackApi {
219
182
  await this.call("reactions.add", { channel, timestamp: ts, name });
220
183
  }
221
184
  catch (err) {
222
- // The reaction is already where we want it; that is a success.
223
185
  if (!String(err).includes("already_reacted"))
224
186
  throw err;
225
187
  }
@@ -249,7 +211,7 @@ export class SlackApi {
249
211
  }
250
212
  async page(method, params) {
251
213
  const body = await this.read(method, params);
252
- // An empty cursor means "no more"; Slack sends `""` rather than omitting it.
214
+ // Slack sends `""` for "no more" rather than omitting it.
253
215
  const next = body.response_metadata?.next_cursor;
254
216
  return { messages: body.messages ?? [], nextCursor: next || undefined };
255
217
  }
@@ -267,26 +229,21 @@ export class SlackApi {
267
229
  return this.page("conversations.replies", {
268
230
  channel,
269
231
  ts,
270
- // Slack drops the boundary message unless asked; the caller wants it and
271
- // filters for itself, the same as `history` above.
232
+ // Slack drops the boundary message unless asked; the caller filters for itself.
272
233
  oldest: query.oldest,
273
234
  inclusive: true,
274
235
  limit: query.limit ?? 200,
275
236
  cursor: query.cursor,
276
237
  });
277
238
  }
278
- /**
279
- * Slack file URLs are private: they need the bot token as a bearer header and
280
- * answer HTML (a login page) rather than an error when it is missing.
281
- */
282
- /** A read method, so form-encoded (see read()); `id` is the `F…` id. */
283
239
  async filesInfo(id) {
284
240
  const body = await this.read("files.info", { file: id });
285
- // `ok` without a file would leave the caller downloading `undefined`.
286
241
  if (!body.file)
287
242
  throw new Error("slack files.info: no file in the response");
288
243
  return body.file;
289
244
  }
245
+ /** File URLs need the bot token as a bearer header, and answer HTML (a login
246
+ * page) rather than an error without it. */
290
247
  async downloadFile(file, maxBytes) {
291
248
  const url = file.url_private_download ?? file.url_private;
292
249
  if (!url)
@@ -298,21 +255,13 @@ export class SlackApi {
298
255
  if (!res.ok)
299
256
  throw new Error(`slack file download: ${res.status}`);
300
257
  const mimeType = res.headers.get("content-type")?.split(";")[0] ?? file.mimetype ?? "application/octet-stream";
301
- // Bounded mid-stream: the event's size metadata is the platform's word.
302
258
  return { bytes: await readCapped(res.body, maxBytes), mimeType };
303
259
  }
304
- /**
305
- * Three calls, because that is what Slack's current upload is: ask for a
306
- * one-shot URL, POST the bytes to it (that host is not the Web API and
307
- * answers with plain text, not JSON), then tell Slack where the file goes.
308
- * `files.upload` did it in one, and is retired.
309
- */
260
+ /** Three calls is Slack's current upload (`files.upload` is retired); the
261
+ * upload host is not the Web API and answers plain text. */
310
262
  async uploadFile(channel, threadTs, file) {
311
- // A read method: form-encoded, or Slack ignores the body (see read()).
312
263
  const slot = await this.read("files.getUploadURLExternal", { filename: file.name, length: file.bytes.length }).catch((err) => {
313
- // An app installed before Pier could upload has every other scope, so
314
- // this reads as a mysterious refusal in the chat. Name the fix instead:
315
- // the manifest is only applied when an app is *created*.
264
+ // The manifest is only applied when an app is created; name the fix.
316
265
  if (!/missing_scope/.test(String(err)))
317
266
  throw err;
318
267
  throw new Error("the Slack app is missing the files:write scope — add it under " +
@@ -324,8 +273,7 @@ export class SlackApi {
324
273
  const put = await fetch(slot.upload_url, {
325
274
  method: "POST",
326
275
  headers: { "content-type": "application/octet-stream" },
327
- // Copied into a fresh view: a request body must be backed by an
328
- // ArrayBuffer, and a Buffer read off disk is the wider ArrayBufferLike.
276
+ // A request body must be backed by an ArrayBuffer, not a Buffer's ArrayBufferLike.
329
277
  body: new Uint8Array(file.bytes),
330
278
  signal: AbortSignal.timeout(120_000),
331
279
  });
@@ -1,15 +1,7 @@
1
- // Who and where, cached: user display names and channel kind/name.
2
- //
3
- // Every inbound message needs the sender's name, and every transcript the tool
4
- // returns needs one per speaker — so without a cache this is one `users.info`
5
- // per message and per read. Neither answer changes in practice, so the cache is
6
- // process-lifetime and shared: the adapter and the agent-facing tool ask the
7
- // same instance, which is why a repeated `read_thread` costs no lookups at all.
8
- //
9
- // Failures are logged and fall back to the id, never swallowed: without
10
- // `users:read` this fails for every message, and the only symptom used to be an
11
- // agent telling the human "Slack does not expose your display name" — a scope
12
- // problem wearing a product problem's clothes.
1
+ // User display names and channel kind/name, cached for the process and shared
2
+ // by the adapter and the agent-facing tool. Failures fall back to the id and
3
+ // are logged: without `users:read` every lookup fails, and the symptom is
4
+ // otherwise a scope problem wearing a product problem's clothes.
13
5
  export class SlackDirectory {
14
6
  log;
15
7
  channels = new Map();
@@ -17,11 +9,8 @@ export class SlackDirectory {
17
9
  constructor(log) {
18
10
  this.log = log;
19
11
  }
20
- /**
21
- * Kind and display name together, because one `conversations.info` answers
22
- * both. The event usually settles the kind for free (`channel_type`), and a
23
- * `D`-prefixed id is always a DM — but only the lookup knows the name.
24
- */
12
+ /** The event usually settles the kind for free (`channel_type`, or a
13
+ * `D`-prefixed id); only the lookup knows the name. */
25
14
  async channel(api, channel, event) {
26
15
  const cached = this.channels.get(channel);
27
16
  if (cached)
@@ -31,8 +20,6 @@ export class SlackDirectory {
31
20
  : channel.startsWith("D")
32
21
  ? "dm"
33
22
  : undefined;
34
- // A DM's name comes from its member, not from the channel, so a DM settled
35
- // by the event needs no lookup at all.
36
23
  if (fromEvent === "dm") {
37
24
  const facts = { kind: fromEvent };
38
25
  this.channels.set(channel, facts);
@@ -42,8 +29,7 @@ export class SlackDirectory {
42
29
  this.log(`conversations.info failed for ${channel}: ${String(err)}`);
43
30
  return undefined;
44
31
  });
45
- // Uncached on failure, so the next message retries rather than pinning a
46
- // guess for the life of the process.
32
+ // Uncached on failure, so the next message retries.
47
33
  if (!info)
48
34
  return { kind: fromEvent ?? "group" };
49
35
  const facts = {
@@ -53,7 +39,6 @@ export class SlackDirectory {
53
39
  this.channels.set(channel, facts);
54
40
  return facts;
55
41
  }
56
- /** Display name for a user id; the id itself when Slack will not say. */
57
42
  async user(api, userId) {
58
43
  const hit = this.users.get(userId);
59
44
  if (hit !== undefined)
@@ -65,7 +50,6 @@ export class SlackDirectory {
65
50
  this.users.set(userId, name);
66
51
  return name;
67
52
  }
68
- /** Names for every speaker in a transcript, in one pass. */
69
53
  async names(api, userIds) {
70
54
  const out = new Map();
71
55
  for (const id of userIds) {