@timqi/pier 0.0.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 (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
@@ -0,0 +1,334 @@
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.
14
+ import { Type } from "typebox";
15
+ import { MARKDOWN_MAX } from "./slack-render.js";
16
+ /** Hard cap on one read, so a wide range cannot blow up the model's context. */
17
+ const MAX_MESSAGES = 400;
18
+ /** Pages to walk before giving up on a very wide window. */
19
+ const MAX_PAGES = 10;
20
+ /**
21
+ * A Slack `ts` is `<epoch seconds>.<microseconds>` and sorts correctly as a
22
+ * number but *not* as a string once the integer part changes width. Ordering
23
+ * goes through this; the string itself is never rewritten, because it is the
24
+ * id a reply or a reaction has to match exactly.
25
+ */
26
+ const tsToNumber = (ts) => Number(ts);
27
+ const tsToIso = (ts) => new Date(Math.floor(tsToNumber(ts) * 1000)).toISOString();
28
+ /** Minute precision in the transcript: the exact time is in the `ts` beside it. */
29
+ const tsToMinute = (ts) => `${tsToIso(ts).slice(0, 16)}Z`;
30
+ /** Accepts an ISO date, an epoch-seconds number, or a raw Slack ts. */
31
+ export function toTs(value) {
32
+ if (value === undefined || value === "")
33
+ return undefined;
34
+ if (typeof value === "number")
35
+ return String(value);
36
+ const trimmed = value.trim();
37
+ if (/^\d+(\.\d+)?$/.test(trimmed))
38
+ return trimmed;
39
+ const parsed = Date.parse(trimmed);
40
+ if (Number.isNaN(parsed))
41
+ throw new Error(`not a time: ${value}`);
42
+ return String(parsed / 1000);
43
+ }
44
+ export function slackToolSpec(execute) {
45
+ return {
46
+ name: "slack",
47
+ label: "Slack",
48
+ description: "Read and write Slack through Pier, which holds the bot token. State what you want; Pier does the paging and hands back a finished transcript. context says which Slack conversation you are in; read_channel returns a channel's transcript for a time range; read_thread returns one thread, or only what is new in it since a given message via after; read_message returns the single message at a ts (pass thread_ts when it is a reply inside a thread); post sends a message; channels lists what Pier can reach. When you were reached through Slack, omit channel (and thread_ts) to act on the conversation you are already in. since/until/after accept ISO 8601, epoch seconds or a ts from an earlier read. Every read fetches live from Slack, so nothing is kept between calls — write down what you need to keep. Message text is standard markdown, but @mentions, #channels and links need Slack's own syntax — read the pier-slack skill before posting.",
49
+ parameters: Type.Object({
50
+ // A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
51
+ operation: Type.Unsafe({
52
+ type: "string",
53
+ enum: ["context", "read_channel", "read_thread", "read_message", "post", "channels"],
54
+ }),
55
+ /**
56
+ * Channel id (`C…`/`D…`/`G…`) or the `#name` shown by `channels`. Omit to
57
+ * use the conversation this session is answering.
58
+ */
59
+ channel: Type.Optional(Type.String()),
60
+ since: Type.Optional(Type.String()),
61
+ until: Type.Optional(Type.String()),
62
+ /** Strictly newer than this — "what changed since I last looked". */
63
+ after: Type.Optional(Type.String()),
64
+ /** The one message `read_message` is about. */
65
+ ts: Type.Optional(Type.String()),
66
+ limit: Type.Optional(Type.Number()),
67
+ thread_ts: Type.Optional(Type.String()),
68
+ text: Type.Optional(Type.String()),
69
+ }),
70
+ execute,
71
+ };
72
+ }
73
+ const required = (value, field) => {
74
+ if (typeof value !== "string" || !value.trim())
75
+ throw new Error(`${field} is required`);
76
+ return value.trim();
77
+ };
78
+ const record = (raw) => raw && typeof raw === "object" && !Array.isArray(raw) ? raw : undefined;
79
+ export async function handleSlackTool(deps, raw, callerSessionId = "") {
80
+ const input = record(raw);
81
+ if (!input)
82
+ throw new Error("slack tool parameters required");
83
+ const config = deps.store.get("slack");
84
+ // Two switches, and the error says which one, because "it does nothing" is
85
+ // the most expensive kind of failure for a model to diagnose.
86
+ if (!config.enabled || !config.token)
87
+ throw new Error("the Slack channel is not configured in Pier");
88
+ if (!config.agentTool)
89
+ throw new Error("Slack agent access is switched off in Pier's Console");
90
+ const client = deps.client();
91
+ if (!client)
92
+ throw new Error("the Slack client is unavailable");
93
+ if (input.operation === "channels") {
94
+ // Only what Pier has actually seen: Slack has no reliable "list my
95
+ // channels", and a name the agent cannot address is worse than no name.
96
+ return config.chats.map((chat) => ({
97
+ id: chat.id,
98
+ name: chat.name,
99
+ kind: chat.kind,
100
+ respondsToMessages: chat.enabled,
101
+ }));
102
+ }
103
+ const at = deps.here(callerSessionId);
104
+ if (input.operation === "context") {
105
+ if (!at) {
106
+ return {
107
+ inSlack: false,
108
+ note: "This session was not reached through Slack, so there is no current conversation. Name a channel explicitly.",
109
+ };
110
+ }
111
+ const chat = config.chats.find((c) => c.id === at.channel);
112
+ return {
113
+ inSlack: true,
114
+ channel: at.channel,
115
+ channelName: chat?.name ?? at.channel,
116
+ kind: chat?.kind ?? null,
117
+ threadTs: at.threadTs,
118
+ note: "Omit channel and thread_ts to read or post here. Speaker ids for mentions come from read_thread.",
119
+ };
120
+ }
121
+ // "Here" is the default target: an agent reached through a Slack thread
122
+ // should not have to be told which thread it is standing in.
123
+ const channel = input.channel === undefined || input.channel === ""
124
+ ? at?.channel ??
125
+ (() => {
126
+ throw new Error("channel is required: this session was not reached through Slack, so there is no current conversation");
127
+ })()
128
+ : resolveChannel(deps, required(input.channel, "channel"));
129
+ // `after` is the "what is new since I last looked" form of `since`: Slack's
130
+ // own bounds are inclusive-ish, so the boundary message is dropped here
131
+ // rather than trusted to the API.
132
+ const after = toTs(input.after);
133
+ if (input.operation === "read_channel") {
134
+ const since = after ?? toTs(input.since);
135
+ const until = toTs(input.until);
136
+ const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
137
+ return readChannel(deps, client, channel, since, until, after, limit);
138
+ }
139
+ if (input.operation === "read_thread") {
140
+ const threadTs = typeof input.thread_ts === "string" && input.thread_ts.trim()
141
+ ? input.thread_ts.trim()
142
+ : at?.threadTs;
143
+ if (!threadTs)
144
+ throw new Error("thread_ts is required outside a Slack thread");
145
+ return readThread(deps, client, channel, threadTs, after);
146
+ }
147
+ if (input.operation === "read_message") {
148
+ const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
149
+ return readMessage(deps, client, channel, required(input.ts, "ts"), asked || undefined);
150
+ }
151
+ if (input.operation === "post") {
152
+ const text = required(input.text, "text");
153
+ if (text.length > MARKDOWN_MAX) {
154
+ throw new Error(`text is ${text.length} chars; Slack accepts ${MARKDOWN_MAX} per message`);
155
+ }
156
+ // Defaults to the thread we are in; `thread_ts: "none"` is the explicit
157
+ // way to start a new top-level message instead.
158
+ const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
159
+ const threadTs = asked === "none"
160
+ ? undefined
161
+ : asked || (channel === at?.channel ? at?.threadTs : undefined);
162
+ const sent = await client.postMessage({
163
+ channel,
164
+ thread_ts: threadTs,
165
+ text,
166
+ // Slack's own markdown renderer: the agent writes markdown, not mrkdwn.
167
+ blocks: [{ type: "markdown", text }],
168
+ });
169
+ return {
170
+ channel,
171
+ ts: sent.ts,
172
+ at: sent.ts ? tsToIso(sent.ts) : null,
173
+ // Returned so a follow-up can reply under what was just posted.
174
+ threadTs: threadTs ?? sent.ts,
175
+ };
176
+ }
177
+ throw new Error(`unknown slack operation: ${String(input.operation)}`);
178
+ }
179
+ /** Accept a `#name` or a bare name as well as an id — models prefer names. */
180
+ function resolveChannel(deps, given) {
181
+ if (/^[CDG][A-Z0-9]+$/.test(given))
182
+ return given;
183
+ const wanted = given.replace(/^#/, "").toLowerCase();
184
+ const chats = deps.store.get("slack").chats;
185
+ const hit = chats.find((chat) => chat.name.replace(/^#/, "").toLowerCase() === wanted);
186
+ if (hit)
187
+ return hit.id;
188
+ throw new Error(`unknown channel ${given}; use an id or one of: ${chats.map((c) => c.name).join(", ") || "(none discovered yet)"}`);
189
+ }
190
+ async function readChannel(deps, client, channel, since, until, after, limit) {
191
+ const fetched = await fetchPages(deps, (cursor) => client.history(channel, { oldest: since, latest: until, cursor }), `history for ${channel}`);
192
+ const all = newerThan(transcript(fetched.messages), after);
193
+ // Slack hands back the newest first, so a window wider than the caps is
194
+ // truncated at its newest end — the oldest `limit` messages are the ones
195
+ // that read as a transcript.
196
+ const window = all.slice(0, limit);
197
+ return {
198
+ channel,
199
+ range: `${since ? tsToMinute(since) : "start"} → ${until ? tsToMinute(until) : "now"}`,
200
+ count: window.length,
201
+ ...(fetched.truncated || all.length > window.length ? { truncated: true } : {}),
202
+ ...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
203
+ format: LINE_FORMAT,
204
+ messages: await lines(deps, client, window),
205
+ };
206
+ }
207
+ async function readThread(deps, client, channel, threadTs, after) {
208
+ const fetched = await fetchPages(deps, (cursor) => client.replies(channel, threadTs, { oldest: after, cursor }), `thread ${threadTs} in ${channel}`);
209
+ const messages = newerThan(transcript(fetched.messages), after);
210
+ return {
211
+ channel,
212
+ // Hoisted: every line in a thread carries the same one.
213
+ threadTs,
214
+ count: messages.length,
215
+ ...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
216
+ format: LINE_FORMAT,
217
+ messages: await lines(deps, client, messages),
218
+ };
219
+ }
220
+ /**
221
+ * One message, because that is sometimes the whole question. A `ts` is unique
222
+ * only within its conversation, and `conversations.history` never returns what
223
+ * was posted inside a thread — so a reply has to be asked for through its
224
+ * thread, and saying which one is the caller's job.
225
+ */
226
+ async function readMessage(deps, client, channel, ts, threadTs) {
227
+ const page = threadTs
228
+ ? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
229
+ : await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
230
+ const found = page.messages.find((msg) => msg.ts === ts);
231
+ if (!found) {
232
+ throw new Error(threadTs
233
+ ? `no message ${ts} in thread ${threadTs}`
234
+ : `no message ${ts} in that channel — a reply posted inside a thread needs thread_ts`);
235
+ }
236
+ const [line] = await lines(deps, client, [found]);
237
+ return {
238
+ channel,
239
+ ...(found.thread_ts ? { threadTs: found.thread_ts } : {}),
240
+ format: LINE_FORMAT,
241
+ message: line,
242
+ };
243
+ }
244
+ /**
245
+ * Walk the cursor until it ends or the caps bite.
246
+ *
247
+ * A page that fails mid-walk does not throw away the pages before it: an
248
+ * agent that asked for a day of history and got an exception cannot tell a
249
+ * broken read from a quiet channel. It gets what there was, plus why the walk
250
+ * stopped, and decides for itself whether to retry or work with it.
251
+ */
252
+ async function fetchPages(deps, page, what) {
253
+ const messages = [];
254
+ let cursor;
255
+ for (let i = 0; i < MAX_PAGES; i++) {
256
+ let batch;
257
+ try {
258
+ batch = await page(cursor);
259
+ }
260
+ catch (err) {
261
+ if (messages.length === 0)
262
+ throw new Error(explain(err));
263
+ deps.log(`${what} stopped after ${messages.length} messages: ${String(err)}`);
264
+ return { messages, truncated: true, incomplete: explain(err) };
265
+ }
266
+ messages.push(...batch.messages);
267
+ cursor = batch.nextCursor;
268
+ if (!cursor || messages.length >= MAX_MESSAGES)
269
+ break;
270
+ }
271
+ if (cursor)
272
+ deps.log(`${what} truncated at ${messages.length} messages`);
273
+ return { messages, truncated: cursor !== undefined };
274
+ }
275
+ /**
276
+ * Slack's error codes are not instructions. Turn the ones an agent can act on
277
+ * into the action; anything else keeps its raw code, which is at least
278
+ * searchable.
279
+ */
280
+ function explain(err) {
281
+ const code = /slack [\w.]+: (\w+)/.exec(String(err))?.[1] ?? "";
282
+ return {
283
+ channel_not_found: "no such channel, or Pier's bot cannot see it — check the channels operation",
284
+ not_in_channel: "Pier's bot is not in that channel; someone has to invite it before it can read",
285
+ missing_scope: "Pier's Slack app lacks the scope for this read; the operator must reinstall it",
286
+ ratelimited: "Slack rate-limited this read; wait a minute or ask for a narrower range",
287
+ thread_not_found: "no thread with that ts in this channel",
288
+ }[code] ?? String(err);
289
+ }
290
+ /** Strictly newer, so `after: <last ts I saw>` never repeats that message. */
291
+ const newerThan = (messages, after) => after === undefined ? messages : messages.filter((m) => tsToNumber(m.ts) > tsToNumber(after));
292
+ /**
293
+ * Oldest first, one message per ts. Slack answers newest-first and its page
294
+ * bounds are inclusive-ish, so a paged read can repeat the message on the
295
+ * seam; the SQL `PRIMARY KEY` used to absorb that.
296
+ */
297
+ function transcript(messages) {
298
+ const byTs = new Map();
299
+ for (const msg of messages)
300
+ if (msg.ts)
301
+ byTs.set(msg.ts, msg);
302
+ return [...byTs.values()].sort((a, b) => tsToNumber(a.ts) - tsToNumber(b.ts));
303
+ }
304
+ /**
305
+ * One line per message instead of one object per message. Four hundred
306
+ * six-key objects spend most of their tokens on the key names; the same
307
+ * transcript as lines costs a fraction, and a model reads it more easily than
308
+ * it reads JSON. The shape is declared in the reply's `format` so nothing has
309
+ * to be guessed.
310
+ *
311
+ * The name makes it readable, the id is the only thing `<@…>` can be built
312
+ * from, and Slack's own `ts` string is passed through untouched — it is what
313
+ * a reply, a reaction or `after` has to match exactly.
314
+ */
315
+ const LINE_FORMAT = "<ts> | <time, UTC> | <name>[<id>] | <text>";
316
+ const speaker = (msg) => msg.user ?? msg.bot_id ?? null;
317
+ async function lines(deps, client, messages) {
318
+ // Names come from the directory the adapter also uses, so re-reading a
319
+ // thread costs no lookups; the store is not consulted because a member need
320
+ // not be bound to have spoken.
321
+ const ids = messages.map(speaker).filter((id) => !!id);
322
+ const names = await deps.directory.names(client, ids);
323
+ return messages.map((msg) => {
324
+ const id = speaker(msg);
325
+ const known = id ? names.get(id) : undefined;
326
+ const who = id ? (known && known !== id ? `${known}[${id}]` : `[${id}]`) : "[unknown]";
327
+ // A parent's reply count, so the agent can decide whether the thread is
328
+ // worth opening instead of spending a read to find out.
329
+ const replies = msg.reply_count && (msg.thread_ts ?? msg.ts) === msg.ts
330
+ ? ` [thread: ${msg.reply_count} replies]`
331
+ : "";
332
+ return `${msg.ts} | ${tsToMinute(msg.ts)} | ${who} | ${msg.text ?? ""}${replies}`;
333
+ });
334
+ }