@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,36 +1,20 @@
1
- // The agent-facing Slack tool: the model states an intent, Pier performs it.
2
- //
3
- // The token never leaves Pier. The agent has no Slack client, no scopes and no
4
- // idea what a `ts` is; it names a channel and a time range, and gets a
5
- // transcript back. That is the whole point of putting this behind a tool
6
- // instead of documenting the Slack API in a skill.
7
- //
8
- // Every read goes to Slack. Slack is the source of truth and it is the only
9
- // party that knows about an edit or a deletion, so a stored copy can only be a
10
- // copy that is wrong later — and Pier is a workspace-internal app, where
11
- // `conversations.history`/`replies` are Tier 3 (~50+ req/min) and a read costs
12
- // one or two calls. If that assumption changes (a distributed non-Marketplace
13
- // app is capped at 1 req/min), this is the decision to revisit.
1
+ // The agent-facing Slack tool: the model states an intent, Pier performs it,
2
+ // and the token never leaves Pier. Every read goes to Slack — only it knows
3
+ // about an edit or a deletion. Affordable because a workspace-internal app gets
4
+ // Tier 3 (~50+ req/min) on history/replies; a distributed non-Marketplace app
5
+ // is capped at 1 req/min, and that is the decision to revisit.
14
6
  import { Type } from "typebox";
15
7
  import { saveInboundAll } from "../core/inbox.js";
16
8
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
17
9
  import { MARKDOWN_MAX } from "./slack-render.js";
18
- /** Hard cap on one read, so a wide range cannot blow up the model's context. */
10
+ /** So a wide range cannot blow up the model's context. */
19
11
  const MAX_MESSAGES = 400;
20
- /** Pages to walk before giving up on a very wide window. */
21
12
  const MAX_PAGES = 10;
22
- /** Where fetched bytes land: the adapter's own channel id, so a file the agent
23
- * asked for sits beside the ones people uploaded to Pier. */
24
13
  const INBOX_CHANNEL = "slack";
25
- /**
26
- * A Slack `ts` is `<epoch seconds>.<microseconds>` and sorts correctly as a
27
- * number but *not* as a string once the integer part changes width. Ordering
28
- * goes through this; the string itself is never rewritten, because it is the
29
- * id a reply or a reaction has to match exactly.
30
- */
14
+ /** A `ts` is `<epoch seconds>.<microseconds>`: sorts as a number, not as a
15
+ * string. Never rewritten — it is the id a reply must match exactly. */
31
16
  const tsToNumber = (ts) => Number(ts);
32
17
  const tsToIso = (ts) => new Date(Math.floor(tsToNumber(ts) * 1000)).toISOString();
33
- /** Minute precision in the transcript: the exact time is in the `ts` beside it. */
34
18
  const tsToMinute = (ts) => `${tsToIso(ts).slice(0, 16)}Z`;
35
19
  /** Epoch seconds at the year 2100: past this, the caller meant milliseconds. */
36
20
  const MAX_SECONDS = 4_102_444_800;
@@ -39,26 +23,19 @@ export function toTs(value) {
39
23
  if (value === undefined || value === "")
40
24
  return undefined;
41
25
  const raw = typeof value === "number" ? String(value) : value.trim();
42
- // A ts is passed through untouched — it is an id, not a number to reformat.
43
26
  const numeric = /^\d+(\.\d+)?$/.test(raw);
44
27
  const seconds = numeric ? Number(raw) : Date.parse(raw) / 1000;
45
28
  if (Number.isNaN(seconds))
46
29
  throw new Error(`not a time: ${value}`);
47
- // Milliseconds are the mistake worth naming: Slack takes the window without
48
- // complaint, finds nothing that far in the future, and answers with an empty
49
- // read that is indistinguishable from a channel where nobody spoke.
30
+ // Milliseconds: Slack takes the window without complaint and answers an empty
31
+ // read indistinguishable from a channel where nobody spoke.
50
32
  if (seconds > MAX_SECONDS) {
51
33
  throw new Error(`${value} is past the year 2100 — Slack times are epoch seconds, not milliseconds`);
52
34
  }
53
35
  return numeric ? raw : String(seconds);
54
36
  }
55
- /**
56
- * Whether a session opened now is given the tool at all: the same two switches
57
- * `handleSlackTool` checks, asked before the description is paid for. The call
58
- * keeps its own checks and its own two messages — a session opened while Slack
59
- * was configured outlives the operator switching it off, and that turn has to
60
- * say so rather than find the tool quietly gone.
61
- */
37
+ /** Asked before the description is paid for. `handleSlackTool` keeps its own
38
+ * checks: a session opened while Slack was configured outlives the switch. */
62
39
  export function slackToolAvailable(store) {
63
40
  const config = store.get("slack");
64
41
  return config.enabled && !!config.token && config.agentTool;
@@ -67,9 +44,6 @@ export function slackToolSpec(execute, available) {
67
44
  return {
68
45
  name: "slack",
69
46
  label: "Slack",
70
- // One screen of contract; the paragraph this once was lives in the
71
- // pier-slack skill, which the description sends the model to before it
72
- // posts — the part that goes wrong without instructions.
73
47
  description: "Read and write Slack through Pier, which holds the bot token. Operations: context (which Slack conversation this session is in), read_channel (transcript for a time range), read_thread (one thread; only what is new since a message via after), read_message (the one at ts), fetch_file (save a file a transcript names, by its F… id), post, edit/delete (Pier's own messages only), channels (what Pier can reach). Omit channel and thread_ts to act on the conversation you are in. since/until/after accept ISO 8601, epoch seconds or a ts. Every read fetches live; nothing is kept between calls. @mentions, #channels and links need Slack's own syntax — read the pier-slack skill before posting.",
74
48
  parameters: Type.Object({
75
49
  // A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
@@ -87,25 +61,17 @@ export function slackToolSpec(execute, available) {
87
61
  "channels",
88
62
  ],
89
63
  }),
90
- /**
91
- * Channel id (`C…`/`D…`/`G…`) or the `#name` shown by `channels`. Omit to
92
- * use the conversation this session is answering.
93
- */
94
64
  channel: Type.Optional(Type.String()),
95
65
  since: Type.Optional(Type.String()),
96
66
  until: Type.Optional(Type.String()),
97
- /** Strictly newer than this — "what changed since I last looked". */
98
67
  after: Type.Optional(Type.String()),
99
- /** The one message `read_message`, `edit` or `delete` is about. */
100
68
  ts: Type.Optional(Type.String()),
101
- /** `fetch_file`: the `F…` id a transcript line carries. */
102
69
  file: Type.Optional(Type.String()),
103
70
  limit: Type.Optional(Type.Number()),
104
71
  thread_ts: Type.Optional(Type.String()),
105
72
  text: Type.Optional(Type.String()),
106
73
  }),
107
74
  available,
108
- // Stands down with the tool: without Slack, its manual is a route to nowhere.
109
75
  skill: "pier-slack",
110
76
  execute,
111
77
  };
@@ -115,10 +81,8 @@ const required = (value, field) => {
115
81
  throw new Error(`${field} is required`);
116
82
  return value.trim();
117
83
  };
118
- /**
119
- * Slack rejects an oversized message outright, so the length is checked here:
120
- * a refusal the agent can act on beats a post that silently never happened.
121
- */
84
+ /** Slack rejects an oversized message outright; a refusal the agent can act
85
+ * on beats a post that silently never happened. */
122
86
  const messageText = (raw) => {
123
87
  const text = required(raw, "text");
124
88
  if (text.length > MARKDOWN_MAX) {
@@ -132,8 +96,8 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
132
96
  if (!input)
133
97
  throw new Error("slack tool parameters required");
134
98
  const config = deps.store.get("slack");
135
- // Two switches, and the error says which one, because "it does nothing" is
136
- // the most expensive kind of failure for a model to diagnose.
99
+ // The error says which switch: "it does nothing" is the most expensive
100
+ // failure for a model to diagnose.
137
101
  if (!config.enabled || !config.token)
138
102
  throw new Error("the Slack channel is not configured in Pier");
139
103
  if (!config.agentTool)
@@ -142,8 +106,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
142
106
  if (!client)
143
107
  throw new Error("the Slack client is unavailable");
144
108
  if (input.operation === "channels") {
145
- // Only what Pier has actually seen: Slack has no reliable "list my
146
- // channels", and a name the agent cannot address is worse than no name.
109
+ // Only what Pier has seen: Slack has no reliable "list my channels".
147
110
  return config.chats.map((chat) => ({
148
111
  id: chat.id,
149
112
  name: chat.name,
@@ -169,26 +132,18 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
169
132
  note: "Omit channel and thread_ts to read or post here. Speaker ids for mentions come from read_thread.",
170
133
  };
171
134
  }
172
- // Before the channel default below: a file id is unique workspace-wide, so
173
- // asking a session that never touched Slack for a channel it has no way to
174
- // name would refuse a call that needs none.
135
+ // Before the channel default: a file id is unique workspace-wide.
175
136
  if (input.operation === "fetch_file") {
176
137
  return fetchFile(deps, client, required(input.file, "file"));
177
138
  }
178
- // "Here" is the default target: an agent reached through a Slack thread
179
- // should not have to be told which thread it is standing in.
180
139
  const channel = input.channel === undefined || input.channel === ""
181
140
  ? at?.channel ??
182
141
  (() => {
183
142
  throw new Error("channel is required: this session was not reached through Slack, so there is no current conversation");
184
143
  })()
185
144
  : resolveChannel(deps, required(input.channel, "channel"));
186
- // `after` is the "what is new since I last looked" form of `since`: Slack's
187
- // own bounds are inclusive-ish, so the boundary message is dropped here
188
- // rather than trusted to the API.
145
+ // Slack's bounds are inclusive-ish, so the boundary message is dropped here.
189
146
  const after = toTs(input.after);
190
- // Both reads take it, so it is read once: a cap that worked on a channel and
191
- // was ignored on a thread would be the more expensive kind of surprise.
192
147
  const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
193
148
  if (input.operation === "read_channel") {
194
149
  const since = after ?? toTs(input.since);
@@ -209,8 +164,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
209
164
  }
210
165
  if (input.operation === "post") {
211
166
  const text = messageText(input.text);
212
- // Defaults to the thread we are in; `thread_ts: "none"` is the explicit
213
- // way to start a new top-level message instead.
167
+ // `thread_ts: "none"` is the explicit way to start a top-level message.
214
168
  const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
215
169
  const threadTs = asked === "none"
216
170
  ? undefined
@@ -226,24 +180,18 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
226
180
  channel,
227
181
  ts: sent.ts,
228
182
  at: sent.ts ? tsToIso(sent.ts) : null,
229
- // Returned so a follow-up can reply under what was just posted.
230
183
  threadTs: threadTs ?? sent.ts,
231
184
  ...inertMention(text),
232
185
  };
233
186
  }
234
187
  if (input.operation === "edit") {
235
- // Explicit `ts`, for delete's reason: an edit replaces the text outright,
236
- // and Slack keeps no visible record of what it said before.
237
188
  const ts = required(input.ts, "ts");
238
189
  const text = messageText(input.text);
239
- // Defaulted from `here` as `post` is: a correction is nearly always to a
240
- // reply the agent made in this thread, and `conversations.history` cannot
241
- // see inside a thread — without this the read below always comes up empty.
190
+ // `conversations.history` cannot see inside a thread, so the read below
191
+ // needs the thread `post` would default to.
242
192
  const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
243
193
  const inThread = asked || (channel === at?.channel ? at?.threadTs : undefined);
244
- // Read before the write, because after it the old wording exists nowhere:
245
- // Slack keeps no version, so this log is the only record of what was
246
- // replaced. Best-effort — a failed read must not block the correction.
194
+ // Slack keeps no version, so this log is the only record of what was replaced.
247
195
  const was = await previousText(client, channel, ts, inThread);
248
196
  try {
249
197
  await client.updateMessage({ channel, ts, text, blocks: [{ type: "markdown", text }] });
@@ -255,8 +203,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
255
203
  return { channel, ts, edited: true, ...inertMention(text) };
256
204
  }
257
205
  if (input.operation === "delete") {
258
- // Never defaulted from `here`: the thread's ts is the parent message, and
259
- // "delete" with an implied target is the one mistake with no undo.
206
+ // Never defaulted from `here`: an implied target is the one mistake with no undo.
260
207
  const ts = required(input.ts, "ts");
261
208
  try {
262
209
  await client.deleteMessage(channel, ts);
@@ -264,42 +211,31 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
264
211
  catch (err) {
265
212
  throw new Error(explain(err));
266
213
  }
267
- // A removal leaves nothing behind to read, so the log is the only record
268
- // that it happened at all.
269
214
  deps.log(`slack tool deleted ${ts} in ${channel}`);
270
215
  return { channel, ts, deleted: true };
271
216
  }
272
217
  throw new Error(`unknown slack operation: ${String(input.operation)}`);
273
218
  }
274
- /**
275
- * The one message at `ts`. A `ts` is unique only within its conversation, and
276
- * `conversations.history` never returns what was posted inside a thread — so a
277
- * reply has to be asked for through its thread, and only the caller knows.
278
- */
219
+ /** `conversations.history` never returns what was posted inside a thread, so
220
+ * a reply has to be asked for through its thread. */
279
221
  async function oneMessage(client, channel, ts, threadTs) {
280
222
  const page = threadTs
281
223
  ? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
282
224
  : await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
283
225
  return page.messages.find((msg) => msg.ts === ts);
284
226
  }
285
- /** One line of what a message said, for the log an edit leaves behind. */
286
227
  async function previousText(client, channel, ts, threadTs) {
287
228
  try {
288
229
  const text = (await oneMessage(client, channel, ts, threadTs))?.text;
289
230
  return text === undefined ? undefined : text.slice(0, 200).replace(/\s+/g, " ");
290
231
  }
291
232
  catch {
292
- // Not swallowed: the caller logs that the old wording was not captured,
293
- // which is the fact that matters. Refusing the edit over it would be worse.
233
+ // The caller logs "not captured"; refusing the edit over it would be worse.
294
234
  return undefined;
295
235
  }
296
236
  }
297
- /**
298
- * A plain `@alice` is the one Slack mistake that looks like it worked: the
299
- * message goes up, renders as text, and notifies nobody. Reported after the
300
- * fact rather than refused — a name in prose is legitimate, an unping is not
301
- * worth losing the message over.
302
- */
237
+ /** A plain `@alice` looks like it worked and notifies nobody. Reported, not
238
+ * refused: a name in prose is legitimate. */
303
239
  function inertMention(text) {
304
240
  const prose = text
305
241
  .replace(/```[\s\S]*?```|`[^`]*`/g, "") // code says @ and # for other reasons
@@ -315,7 +251,6 @@ function inertMention(text) {
315
251
  `Edit this ts if it was meant to reach someone.`,
316
252
  };
317
253
  }
318
- /** Accept a `#name` or a bare name as well as an id — models prefer names. */
319
254
  function resolveChannel(deps, given) {
320
255
  if (/^[CDG][A-Z0-9]+$/.test(given))
321
256
  return given;
@@ -329,9 +264,7 @@ function resolveChannel(deps, given) {
329
264
  async function readChannel(deps, client, channel, since, until, after, limit) {
330
265
  const fetched = await fetchPages(deps, (cursor) => client.history(channel, { oldest: since, latest: until, cursor }), `history for ${channel}`);
331
266
  const all = newerThan(transcript(fetched.messages), after);
332
- // Slack hands back the newest first, so a window wider than the caps is
333
- // truncated at its newest end — the oldest `limit` messages are the ones
334
- // that read as a transcript.
267
+ // Truncated at the newest end: the oldest `limit` messages read as a transcript.
335
268
  const window = all.slice(0, limit);
336
269
  return {
337
270
  channel,
@@ -343,21 +276,13 @@ async function readChannel(deps, client, channel, since, until, after, limit) {
343
276
  messages: await lines(deps, client, window),
344
277
  };
345
278
  }
346
- /**
347
- * Exported for one caller: the adapter inlines a small shared thread, and a
348
- * second `conversations.replies` walker — its paging, seam dedup, ordering and
349
- * error translation — is exactly the copy this file exists to prevent.
350
- */
279
+ /** Exported for the adapter, which inlines a small shared thread. */
351
280
  export async function readThread(deps, client, channel, threadTs, after, limit) {
352
281
  const fetched = await fetchPages(deps, (cursor) => client.replies(channel, threadTs, { oldest: after, cursor }), `thread ${threadTs} in ${channel}`);
353
282
  const all = newerThan(transcript(fetched.messages), after);
354
- // Oldest first, as in a channel read: a thread cut at its newest end still
355
- // reads as a thread, and the cut has to be said either way — a long thread
356
- // that answers as if it were complete is the read nobody double-checks.
357
283
  const messages = all.slice(0, limit);
358
284
  return {
359
285
  channel,
360
- // Hoisted: every line in a thread carries the same one.
361
286
  threadTs,
362
287
  count: messages.length,
363
288
  ...(fetched.truncated || all.length > messages.length ? { truncated: true } : {}),
@@ -366,7 +291,6 @@ export async function readThread(deps, client, channel, threadTs, after, limit)
366
291
  messages: await lines(deps, client, messages),
367
292
  };
368
293
  }
369
- /** One message, because that is sometimes the whole question. */
370
294
  async function readMessage(deps, client, channel, ts, threadTs) {
371
295
  const found = await oneMessage(client, channel, ts, threadTs);
372
296
  if (!found) {
@@ -382,35 +306,23 @@ async function readMessage(deps, client, channel, ts, threadTs) {
382
306
  message: line,
383
307
  };
384
308
  }
385
- /**
386
- * A file a transcript named, on disk. Fetching is explicit rather than
387
- * automatic: one channel read can name a hundred uploads, and the agent — not
388
- * Pier — knows which of them the question is about. Even then the reply is only
389
- * the marker line, so the bytes enter its context if it opens the file.
390
- *
391
- * `files.info` every call. A `SlackFile` carries a signed url that expires and
392
- * a name its owner can change, so a kept copy is the same wrong-later copy this
393
- * file's header refuses for messages.
394
- */
309
+ /** Explicit, not automatic: one read can name a hundred uploads, and the agent
310
+ * knows which one the question is about. `files.info` every call: the signed
311
+ * url expires. */
395
312
  async function fetchFile(deps, client, id) {
396
313
  let file;
397
314
  try {
398
315
  file = await client.filesInfo(id);
399
316
  }
400
317
  catch (err) {
401
- // The manifest's scopes are only applied when an app is *created*, so an
402
- // app installed before this operation existed refuses for a reason no
403
- // agent can guess. Name the fix, as uploadFile does for files:write.
318
+ // Manifest scopes apply only at app creation, so an older install refuses
319
+ // for a reason no agent can guess.
404
320
  if (/missing_scope/.test(String(err))) {
405
321
  throw new Error("Pier's Slack app cannot read files — add the files:read scope to the Slack app " +
406
322
  "under OAuth & Permissions and reinstall it");
407
323
  }
408
324
  throw new Error(explain(err));
409
325
  }
410
- // The shared save loop owns the size gate, the marker and the lost-marker
411
- // wording (core/inbox.ts), so a file over the cap or a refused download
412
- // answers in the words every other inbound failure uses — never a stack, and
413
- // never an empty reply.
414
326
  const [marker] = await saveInboundAll(INBOX_CHANNEL, [{
415
327
  label: file.name ?? id,
416
328
  name: file.name,
@@ -421,14 +333,8 @@ async function fetchFile(deps, client, id) {
421
333
  }], deps.log);
422
334
  return marker;
423
335
  }
424
- /**
425
- * Walk the cursor until it ends or the caps bite.
426
- *
427
- * A page that fails mid-walk does not throw away the pages before it: an
428
- * agent that asked for a day of history and got an exception cannot tell a
429
- * broken read from a quiet channel. It gets what there was, plus why the walk
430
- * stopped, and decides for itself whether to retry or work with it.
431
- */
336
+ /** A page that fails mid-walk keeps the pages before it, plus why it stopped:
337
+ * an exception alone cannot be told from a quiet channel. */
432
338
  async function fetchPages(deps, page, what) {
433
339
  const messages = [];
434
340
  let cursor;
@@ -452,11 +358,8 @@ async function fetchPages(deps, page, what) {
452
358
  deps.log(`${what} truncated at ${messages.length} messages`);
453
359
  return { messages, truncated: cursor !== undefined };
454
360
  }
455
- /**
456
- * Slack's error codes are not instructions. Turn the ones an agent can act on
457
- * into the action; anything else keeps its raw code, which is at least
458
- * searchable.
459
- */
361
+ /** Error codes an agent can act on become the action; the rest keep their
362
+ * searchable raw code. */
460
363
  function explain(err) {
461
364
  const code = /slack [\w.]+: (\w+)/.exec(String(err))?.[1] ?? "";
462
365
  return {
@@ -474,11 +377,8 @@ function explain(err) {
474
377
  }
475
378
  /** Strictly newer, so `after: <last ts I saw>` never repeats that message. */
476
379
  const newerThan = (messages, after) => after === undefined ? messages : messages.filter((m) => tsToNumber(m.ts) > tsToNumber(after));
477
- /**
478
- * Oldest first, one message per ts. Slack answers newest-first and its page
479
- * bounds are inclusive-ish, so a paged read can repeat the message on the
480
- * seam; the SQL `PRIMARY KEY` used to absorb that.
481
- */
380
+ /** Oldest first, one per ts: page bounds are inclusive-ish, so a paged read
381
+ * can repeat the message on the seam. */
482
382
  function transcript(messages) {
483
383
  const byTs = new Map();
484
384
  for (const msg of messages)
@@ -486,51 +386,28 @@ function transcript(messages) {
486
386
  byTs.set(msg.ts, msg);
487
387
  return [...byTs.values()].sort((a, b) => tsToNumber(a.ts) - tsToNumber(b.ts));
488
388
  }
489
- /**
490
- * One line per message instead of one object per message. Four hundred
491
- * six-key objects spend most of their tokens on the key names; the same
492
- * transcript as lines costs a fraction, and a model reads it more easily than
493
- * it reads JSON. The shape is declared in the reply's `format` so nothing has
494
- * to be guessed.
495
- *
496
- * The name makes it readable, the id is the only thing `<@…>` can be built
497
- * from, and Slack's own `ts` string is passed through untouched — it is what
498
- * a reply, a reaction or `after` has to match exactly.
499
- *
500
- * The two suffixes are what a message *has* rather than what it said, so they
501
- * are declared here and appended only when there is one: a message with
502
- * neither reads exactly as it always did. Kept terse — the adapter puts this
503
- * string in a prompt (`slack.ts`, an inlined shared thread), so every word is
504
- * paid for per read.
505
- */
389
+ /** Lines, not objects: four hundred six-key objects spend most of their tokens
390
+ * on key names. The id is the only thing `<@…>` can be built from. Terse: the
391
+ * adapter puts this string in a prompt, so every word is paid for per read. */
506
392
  const LINE_FORMAT = "<ts> | <time, UTC> | <name>[<id>] | <text>, then — when there are any —"
507
393
  + " [thread: <n> replies] and one [file: <name> <F… id> <size>] per upload";
508
- /** Enough to judge a fetch against the 32 MB cap without arithmetic; left out
509
- * entirely when Slack sent no size, because a guessed one would be worse. */
394
+ /** Enough to judge a fetch against the 32 MB cap without arithmetic. */
510
395
  const sizeLabel = (bytes) => bytes < 1024
511
396
  ? `${bytes}B`
512
397
  : bytes < 1024 * 1024
513
398
  ? `${Math.round(bytes / 1024)}KB`
514
399
  : `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
515
- /**
516
- * A message's uploads, named with the one handle that can fetch them. Without
517
- * this the transcript dropped `files` entirely: a PDF somebody posted read as
518
- * an empty message, and nothing said there was anything to open.
519
- */
400
+ /** Without this a PDF somebody posted reads as an empty message. */
520
401
  const uploads = (files) => (files ?? []).map((file) => ` [file: ${file.name ?? file.mimetype ?? "file"} ${file.id}${file.size === undefined ? "" : ` ${sizeLabel(file.size)}`}]`).join("");
521
402
  const speaker = (msg) => msg.user ?? msg.bot_id ?? null;
522
403
  async function lines(deps, client, messages) {
523
- // Names come from the directory the adapter also uses, so re-reading a
524
- // thread costs no lookups; the store is not consulted because a member need
525
- // not be bound to have spoken.
404
+ // The store is not consulted: a member need not be bound to have spoken.
526
405
  const ids = messages.map(speaker).filter((id) => !!id);
527
406
  const names = await deps.directory.names(client, ids);
528
407
  return messages.map((msg) => {
529
408
  const id = speaker(msg);
530
409
  const known = id ? names.get(id) : undefined;
531
410
  const who = id ? (known && known !== id ? `${known}[${id}]` : `[${id}]`) : "[unknown]";
532
- // A parent's reply count, so the agent can decide whether the thread is
533
- // worth opening instead of spending a read to find out.
534
411
  const replies = msg.reply_count && (msg.thread_ts ?? msg.ts) === msg.ts
535
412
  ? ` [thread: ${msg.reply_count} replies]`
536
413
  : "";