@timqi/pier 0.0.25 → 0.0.27

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 (60) hide show
  1. package/dist/agent/pi.js +16 -16
  2. package/dist/channels/attach.js +6 -3
  3. package/dist/channels/lark-outbound.js +6 -1
  4. package/dist/channels/lark.js +16 -4
  5. package/dist/channels/slack-api.js +8 -0
  6. package/dist/channels/slack-outbound.js +14 -5
  7. package/dist/channels/slack-tool.js +83 -4
  8. package/dist/channels/slack.js +156 -8
  9. package/dist/channels/telegram.js +15 -4
  10. package/dist/core/inbound-file.js +69 -3
  11. package/dist/core/reply.js +17 -5
  12. package/dist/core/router.js +34 -1
  13. package/dist/web/fs.js +6 -22
  14. package/dist/web/public/assets/{activity-xRauUtIM.js → activity-B44nbhXy.js} +1 -1
  15. package/dist/web/public/assets/activity-B44nbhXy.js.br +0 -0
  16. package/dist/web/public/assets/activity-B44nbhXy.js.gz +0 -0
  17. package/dist/web/public/assets/{boards-CxXk1JfX.js → boards-CeiKKBaY.js} +1 -1
  18. package/dist/web/public/assets/boards-CeiKKBaY.js.br +0 -0
  19. package/dist/web/public/assets/boards-CeiKKBaY.js.gz +0 -0
  20. package/dist/web/public/assets/{code-pUOkepAa.js → code-I0lxXuvE.js} +1 -1
  21. package/dist/web/public/assets/{explorer-C1KiLceA.js → explorer-BOeDogsY.js} +1 -1
  22. package/dist/web/public/assets/explorer-BOeDogsY.js.br +0 -0
  23. package/dist/web/public/assets/explorer-BOeDogsY.js.gz +0 -0
  24. package/dist/web/public/assets/{index-D9euDszY.js → index-BmKvoulH.js} +14 -13
  25. package/dist/web/public/assets/index-BmKvoulH.js.br +0 -0
  26. package/dist/web/public/assets/index-BmKvoulH.js.gz +0 -0
  27. package/dist/web/public/assets/{runs-BuE-mags.js → runs-C4OlQT7W.js} +1 -1
  28. package/dist/web/public/assets/runs-C4OlQT7W.js.br +0 -0
  29. package/dist/web/public/assets/runs-C4OlQT7W.js.gz +0 -0
  30. package/dist/web/public/assets/{settings-5KvCAMBF.js → settings-Cn8i_QiP.js} +1 -1
  31. package/dist/web/public/assets/settings-Cn8i_QiP.js.br +0 -0
  32. package/dist/web/public/assets/settings-Cn8i_QiP.js.gz +0 -0
  33. package/dist/web/public/assets/{task-runs-DIlw1pXt.js → task-runs-BW06ZEVz.js} +1 -1
  34. package/dist/web/public/assets/task-runs-BW06ZEVz.js.br +0 -0
  35. package/dist/web/public/assets/task-runs-BW06ZEVz.js.gz +0 -0
  36. package/dist/web/public/assets/{tasks-CcAxbMR8.js → tasks-DZzYlKsd.js} +1 -1
  37. package/dist/web/public/assets/tasks-DZzYlKsd.js.br +0 -0
  38. package/dist/web/public/assets/tasks-DZzYlKsd.js.gz +0 -0
  39. package/dist/web/public/index.html +1 -1
  40. package/dist/web/public/index.html.br +0 -0
  41. package/dist/web/public/index.html.gz +0 -0
  42. package/dist/web/server.js +11 -11
  43. package/package.json +1 -1
  44. package/skills/pier-slack/SKILL.md +61 -133
  45. package/dist/web/public/assets/activity-xRauUtIM.js.br +0 -0
  46. package/dist/web/public/assets/activity-xRauUtIM.js.gz +0 -0
  47. package/dist/web/public/assets/boards-CxXk1JfX.js.br +0 -0
  48. package/dist/web/public/assets/boards-CxXk1JfX.js.gz +0 -0
  49. package/dist/web/public/assets/explorer-C1KiLceA.js.br +0 -0
  50. package/dist/web/public/assets/explorer-C1KiLceA.js.gz +0 -0
  51. package/dist/web/public/assets/index-D9euDszY.js.br +0 -0
  52. package/dist/web/public/assets/index-D9euDszY.js.gz +0 -0
  53. package/dist/web/public/assets/runs-BuE-mags.js.br +0 -0
  54. package/dist/web/public/assets/runs-BuE-mags.js.gz +0 -0
  55. package/dist/web/public/assets/settings-5KvCAMBF.js.br +0 -0
  56. package/dist/web/public/assets/settings-5KvCAMBF.js.gz +0 -0
  57. package/dist/web/public/assets/task-runs-DIlw1pXt.js.br +0 -0
  58. package/dist/web/public/assets/task-runs-DIlw1pXt.js.gz +0 -0
  59. package/dist/web/public/assets/tasks-CcAxbMR8.js.br +0 -0
  60. package/dist/web/public/assets/tasks-CcAxbMR8.js.gz +0 -0
package/dist/agent/pi.js CHANGED
@@ -37,27 +37,27 @@ const PROVIDER_CHECK_MAX_TOKENS = 8192;
37
37
  /** Neither half of a probe is worth more than a screen. */
38
38
  const clip = (text) => text.length > 4000 ? `${text.slice(0, 4000)}\n[… ${text.length - 4000} more characters]` : text;
39
39
  /** Pier's baseline replaces Pi's generic default; a user's SYSTEM.md follows it. */
40
- const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspace: you can read and change files and run shell commands. Act with expert care — do the work, verify results, and state what you could not check.
40
+ const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspace: you can read and change files and run shell commands. Act with expert care — do the work and verify the result.
41
41
 
42
42
  # Communication
43
- These rules govern conversational replies. When the reply *is* the deliverable — a report that was asked for, a review, a task run whose result another agent reads — the work sets the length: complete beats brief, and nothing below caps it.
44
- - Answer with the conclusion only. Reasons, process, trade-offs, alternatives: only when asked.
45
- - Cap per reply: 60 words (90 Chinese chars), max 3 bullets; 180 words (270 Chinese chars) when explicitly asked why or how. Code blocks, diffs and commands don't count.
46
- - Reply in the language of the request; code, paths, identifiers and quoted output stay verbatim.
47
- - Never: preamble, restating the question, closing summaries, "I'm going to..." narration, listing changes already visible in the diff.
48
- - After edits, say only: file(s) touched + one line on the result. Don't explain self-evident code.
49
- - Show file paths as \`path:line\`, or the path alone when no single line is the point — never invent a number.
50
- - If the honest answer needs more than the cap, give the conclusion plus one short "want the details?" — don't dump it.
51
- - Blocked on a decision only the person you work for can make? Ask one short question. Otherwise pick the sensible default and note it.
43
+ These rules govern conversational replies. A human reads them on a phone-sized screen, so the cap is about their attention, not about tokens. When the reply is the deliverable — the request names an artifact (report, review, digest, plan) or another agent reads the result (task runs) — the length rules don't apply; the style rules still do.
44
+ - Answer with the conclusion. Add the one fact that changes what the user does next — a failure and its cause, an assumption you made, a risk. Trade-offs, process, alternatives: only when asked.
45
+ - Cap per reply: 60 words (90 Chinese chars), max 3 bullets; 120 words (180 Chinese chars) when the question asks for reasoning, comparison or options. A command the user is meant to run counts as one line. Don't paste code or diffs to explain — name the file.
46
+ - Past the cap by a lot? Conclusion plus one short "want the details?" — don't dump it. Past it by a sentence? Finish the sentence.
47
+ - Reply in the language of the request; paths, identifiers and quoted output stay verbatim.
48
+ - Never: preamble, restating the question, closing summaries, "I'm going to..." narration, narrating each edit.
49
+ - After edits, say only: file(s) touched + one line on the result.
50
+ - Don't quote code to explain it — no snippets, no walkthroughs. Code the user asked for (a command, a one-liner, a value) is the answer: one block, nothing around it.
51
+ - \`path:line\` when you're pointing at one line; the bare path otherwise.
52
+ - Blocked on a decision only the requester can make? Ask them, one short question. Otherwise pick the sensible default and note it.
52
53
 
53
- # Working style (any machine)
54
- These hold wherever Pier runs; a user's SYSTEM.md adds the local ones (which tools exist, which hosts, which paths).
55
- - Orient first — list and search before you act. Never guess a path.
54
+ # Working style — holds on any machine; a user's SYSTEM.md adds the local facts (tools, hosts, paths)
55
+ - Before touching files: list and search first. Never guess a path or a line number.
56
56
  - Read before you edit. Match the surrounding code's style, naming, and comment density.
57
57
  - Do exactly what was asked. No unrequested refactors, no extra files, no README updates.
58
- - Destructive or irreversible actions (rm, force push, migrations, deploys): ask first.
59
- - Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.
60
- - Every bash call already runs in the working directory this prompt names — don't prefix \`cd <cwd> &&\`, \`cd\` only to go somewhere else. Each call is a fresh shell: \`cd\`, \`export\`, \`source\` never carry over, so chain what must share state into one command.`;
58
+ - Each bash call is a fresh shell in the working directory; chain what must share state.
59
+ - Destructive or irreversible actions on things you didn't create — deleting user files, force push, migrations, deploys, service restarts: ask first; unattended, don't do them and report what you would have done.
60
+ - Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.`;
61
61
  export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
62
62
  /** Patching the call is cheaper than replacing the tool: the built-in keeps its
63
63
  * shell settings, and the agent spends no tokens deciding a timeout. */
@@ -14,7 +14,7 @@
14
14
  // the conversation — lives here so three adapters do not each have a copy.
15
15
  import { readFile, stat } from "node:fs/promises";
16
16
  import { basename, extname } from "node:path";
17
- import { lostMarker } from "../core/inbound-file.js";
17
+ import { lostMarker, replaceOutsideCode } from "../core/inbound-file.js";
18
18
  /**
19
19
  * One cap for every platform: Telegram refuses a photo past 10 MB, which is
20
20
  * the smallest of the three, and a turn that lands on one chat and not on
@@ -35,11 +35,14 @@ const nameOf = (path) => basename(path) || "file";
35
35
  * Split a turn's markdown into the text an IM chat should show and the files
36
36
  * it linked. Each link collapses to its label — or to the file's name when the
37
37
  * agent wrote none — so the sentence it sat in still reads, and the turn never
38
- * becomes empty just because its only content was an attachment.
38
+ * becomes empty just because its only content was an attachment. A link inside
39
+ * code is an example of the convention, not a use of it: it is left alone.
39
40
  */
40
41
  export function splitAttachments(markdown) {
41
42
  const paths = [];
42
- const text = markdown.replace(LINK, (_m, label, raw) => {
43
+ const text = replaceOutsideCode(markdown, LINK, (match) => {
44
+ const label = match[1];
45
+ const raw = match[2];
43
46
  let path = raw;
44
47
  try {
45
48
  path = decodeURIComponent(raw);
@@ -100,11 +100,16 @@ export class LarkOutbound {
100
100
  * A system note: quoted, labelled with where it came from, and deliberately
101
101
  * plain — no buttons and no turn footer, because the turn this input
102
102
  * triggers has not ended yet.
103
+ *
104
+ * Answers with the id of the last card it posted — where the caller puts the
105
+ * 👀 for that turn, at the foot of the topic the reply will land in.
103
106
  */
104
107
  async note(root, note) {
105
108
  const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
109
+ let messageId;
106
110
  for (const part of chunk(`*${originLabel(note.origin)}*\n${body}`, LARK_MAX)) {
107
- await this.api.replyCard(root, card([markdown(part)]));
111
+ messageId = (await this.api.replyCard(root, card([markdown(part)]))).messageId;
108
112
  }
113
+ return messageId;
109
114
  }
110
115
  }
@@ -22,6 +22,7 @@
22
22
  // config.ts, platform-blind and shared with Telegram and Slack.
23
23
  import { saveInboundAll } from "../core/inbox.js";
24
24
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
25
+ import { awaitsTurn } from "../core/reply.js";
25
26
  import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
26
27
  import { logger } from "../log.js";
27
28
  import { Chains } from "./chains.js";
@@ -484,14 +485,25 @@ export class LarkChannel {
484
485
  // reply failed to send looks like work until the stale sweep.
485
486
  await this.receipts.settleAfter(conversation, () => this.out.reply(root, reply));
486
487
  }
487
- /** A system note, posted without touching the receipts: the turn it
488
- * triggers has not ended yet. */
488
+ /**
489
+ * A system note, and the 👀 goes on the note itself: the turn it triggers has
490
+ * no message of the user's to carry them — nobody typed one — so without this
491
+ * the topic shows nothing at all while the agent works. The turn-end `send`
492
+ * clears it like any other receipt; an error note is not marked, because no
493
+ * turn follows it (`awaitsTurn`) and the eyes would sit there until the stale
494
+ * sweep.
495
+ */
489
496
  async notify(conversation, note) {
490
- const { root } = parseConversation(conversation);
497
+ const { chatId, root } = parseConversation(conversation);
491
498
  if (!root) {
492
499
  this.log(`refusing to post a system note to ${conversation}: no thread root in the conversation id`);
493
500
  return;
494
501
  }
495
- await this.out.note(root, note);
502
+ const messageId = await this.out.note(root, note);
503
+ // The last card of a long note, so the eyes sit at the foot of the topic,
504
+ // where the reply will land. A reaction that fails is swallowed and logged
505
+ // by receipts.ts, so the note itself is never lost to one.
506
+ if (messageId && awaitsTurn(note.origin))
507
+ this.receipts.mark(conversation, chatId, messageId);
496
508
  }
497
509
  }
@@ -279,6 +279,14 @@ export class SlackApi {
279
279
  * Slack file URLs are private: they need the bot token as a bearer header and
280
280
  * answer HTML (a login page) rather than an error when it is missing.
281
281
  */
282
+ /** A read method, so form-encoded (see read()); `id` is the `F…` id. */
283
+ async filesInfo(id) {
284
+ const body = await this.read("files.info", { file: id });
285
+ // `ok` without a file would leave the caller downloading `undefined`.
286
+ if (!body.file)
287
+ throw new Error("slack files.info: no file in the response");
288
+ return body.file;
289
+ }
282
290
  async downloadFile(file, maxBytes) {
283
291
  const url = file.url_private_download ?? file.url_private;
284
292
  if (!url)
@@ -70,13 +70,19 @@ export class SlackOutbound {
70
70
  * A system note: quoted, labelled with where it came from, and deliberately
71
71
  * plain — no buttons and no turn footer, because the turn this input
72
72
  * triggers has not ended yet.
73
+ *
74
+ * Answers with the `ts` of the last message it posted — where the caller
75
+ * puts the 👀 for that turn, at the foot of the thread the reply will land
76
+ * in. Undefined when there was nothing to post.
73
77
  */
74
78
  async note(channel, threadTs, note) {
75
79
  // Markdown's own blockquote, so the note reads as quoted on either path.
76
80
  const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
81
+ let ts;
77
82
  for (const part of chunk(`_${originLabel(note.origin)}_\n${body}`, this.budget())) {
78
- await this.post(channel, threadTs, part, []);
83
+ ts = await this.post(channel, threadTs, part, []);
79
84
  }
85
+ return ts;
80
86
  }
81
87
  /** Which budget `chunk()` should respect, given the path we are on. */
82
88
  budget() {
@@ -98,10 +104,10 @@ export class SlackOutbound {
98
104
  if (this.markdownBlocks) {
99
105
  const blocks = [...(body ? [markdown(body)] : []), ...trailing];
100
106
  if (!blocks.length)
101
- return;
107
+ return undefined;
102
108
  try {
103
- await this.api.postMessage({ channel, thread_ts: threadTs, text: notice, blocks });
104
- return;
109
+ const sent = await this.api.postMessage({ channel, thread_ts: threadTs, text: notice, blocks });
110
+ return sent.ts;
105
111
  }
106
112
  catch (err) {
107
113
  if (!isBlockRejection(err))
@@ -112,16 +118,19 @@ export class SlackOutbound {
112
118
  }
113
119
  // Legacy path: translate to mrkdwn and split into section blocks. The body
114
120
  // was chunked against the larger budget, so it may need splitting again.
121
+ let ts;
115
122
  for (const part of body ? chunk(toMrkdwn(body), MRKDWN_MAX) : [""]) {
116
123
  const blocks = [...sections(part), ...trailing];
117
124
  if (!blocks.length)
118
125
  continue;
119
- await this.api.postMessage({
126
+ const sent = await this.api.postMessage({
120
127
  channel,
121
128
  thread_ts: threadTs,
122
129
  text: part || notice || "…",
123
130
  blocks,
124
131
  });
132
+ ts = sent.ts;
125
133
  }
134
+ return ts;
126
135
  }
127
136
  }
@@ -12,11 +12,16 @@
12
12
  // one or two calls. If that assumption changes (a distributed non-Marketplace
13
13
  // app is capped at 1 req/min), this is the decision to revisit.
14
14
  import { Type } from "typebox";
15
+ import { saveInboundAll } from "../core/inbox.js";
16
+ import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
15
17
  import { MARKDOWN_MAX } from "./slack-render.js";
16
18
  /** Hard cap on one read, so a wide range cannot blow up the model's context. */
17
19
  const MAX_MESSAGES = 400;
18
20
  /** Pages to walk before giving up on a very wide window. */
19
21
  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
+ const INBOX_CHANNEL = "slack";
20
25
  /**
21
26
  * A Slack `ts` is `<epoch seconds>.<microseconds>` and sorts correctly as a
22
27
  * number but *not* as a string once the integer part changes width. Ordering
@@ -65,7 +70,7 @@ export function slackToolSpec(execute, available) {
65
70
  // One screen of contract; the paragraph this once was lives in the
66
71
  // pier-slack skill, which the description sends the model to before it
67
72
  // posts — the part that goes wrong without instructions.
68
- 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), 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.",
73
+ 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.",
69
74
  parameters: Type.Object({
70
75
  // A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
71
76
  operation: Type.Unsafe({
@@ -75,6 +80,7 @@ export function slackToolSpec(execute, available) {
75
80
  "read_channel",
76
81
  "read_thread",
77
82
  "read_message",
83
+ "fetch_file",
78
84
  "post",
79
85
  "edit",
80
86
  "delete",
@@ -92,6 +98,8 @@ export function slackToolSpec(execute, available) {
92
98
  after: Type.Optional(Type.String()),
93
99
  /** The one message `read_message`, `edit` or `delete` is about. */
94
100
  ts: Type.Optional(Type.String()),
101
+ /** `fetch_file`: the `F…` id a transcript line carries. */
102
+ file: Type.Optional(Type.String()),
95
103
  limit: Type.Optional(Type.Number()),
96
104
  thread_ts: Type.Optional(Type.String()),
97
105
  text: Type.Optional(Type.String()),
@@ -161,6 +169,12 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
161
169
  note: "Omit channel and thread_ts to read or post here. Speaker ids for mentions come from read_thread.",
162
170
  };
163
171
  }
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.
175
+ if (input.operation === "fetch_file") {
176
+ return fetchFile(deps, client, required(input.file, "file"));
177
+ }
164
178
  // "Here" is the default target: an agent reached through a Slack thread
165
179
  // should not have to be told which thread it is standing in.
166
180
  const channel = input.channel === undefined || input.channel === ""
@@ -329,7 +343,12 @@ async function readChannel(deps, client, channel, since, until, after, limit) {
329
343
  messages: await lines(deps, client, window),
330
344
  };
331
345
  }
332
- async function readThread(deps, client, channel, threadTs, after, limit) {
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
+ */
351
+ export async function readThread(deps, client, channel, threadTs, after, limit) {
333
352
  const fetched = await fetchPages(deps, (cursor) => client.replies(channel, threadTs, { oldest: after, cursor }), `thread ${threadTs} in ${channel}`);
334
353
  const all = newerThan(transcript(fetched.messages), after);
335
354
  // Oldest first, as in a channel read: a thread cut at its newest end still
@@ -363,6 +382,45 @@ async function readMessage(deps, client, channel, ts, threadTs) {
363
382
  message: line,
364
383
  };
365
384
  }
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
+ */
395
+ async function fetchFile(deps, client, id) {
396
+ let file;
397
+ try {
398
+ file = await client.filesInfo(id);
399
+ }
400
+ 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.
404
+ if (/missing_scope/.test(String(err))) {
405
+ throw new Error("Pier's Slack app cannot read files — add the files:read scope to the Slack app " +
406
+ "under OAuth & Permissions and reinstall it");
407
+ }
408
+ throw new Error(explain(err));
409
+ }
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
+ const [marker] = await saveInboundAll(INBOX_CHANNEL, [{
415
+ label: file.name ?? id,
416
+ name: file.name,
417
+ mimeType: file.mimetype ?? "application/octet-stream",
418
+ size: file.size,
419
+ // The response's content-type wins: Slack's metadata is a guess.
420
+ fetch: () => client.downloadFile(file, MAX_INBOUND_BYTES),
421
+ }], deps.log);
422
+ return marker;
423
+ }
366
424
  /**
367
425
  * Walk the cursor until it ends or the caps bite.
368
426
  *
@@ -411,6 +469,7 @@ function explain(err) {
411
469
  cant_update_message: "Slack only lets Pier edit what its own bot posted; anyone else's message can only be replied to",
412
470
  edit_window_closed: "Slack's edit window for that message has closed; post a correction instead of rewriting it",
413
471
  message_not_found: "no message with that ts in this channel — a ts only means anything in the conversation it came from",
472
+ file_not_found: "no file with that id, or Pier's bot cannot see it — the F… id comes from a transcript line",
414
473
  }[code] ?? String(err);
415
474
  }
416
475
  /** Strictly newer, so `after: <last ts I saw>` never repeats that message. */
@@ -437,8 +496,28 @@ function transcript(messages) {
437
496
  * The name makes it readable, the id is the only thing `<@…>` can be built
438
497
  * from, and Slack's own `ts` string is passed through untouched — it is what
439
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
+ */
506
+ const LINE_FORMAT = "<ts> | <time, UTC> | <name>[<id>] | <text>, then — when there are any —"
507
+ + " [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. */
510
+ const sizeLabel = (bytes) => bytes < 1024
511
+ ? `${bytes}B`
512
+ : bytes < 1024 * 1024
513
+ ? `${Math.round(bytes / 1024)}KB`
514
+ : `${(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.
440
519
  */
441
- const LINE_FORMAT = "<ts> | <time, UTC> | <name>[<id>] | <text>";
520
+ const uploads = (files) => (files ?? []).map((file) => ` [file: ${file.name ?? file.mimetype ?? "file"} ${file.id}${file.size === undefined ? "" : ` ${sizeLabel(file.size)}`}]`).join("");
442
521
  const speaker = (msg) => msg.user ?? msg.bot_id ?? null;
443
522
  async function lines(deps, client, messages) {
444
523
  // Names come from the directory the adapter also uses, so re-reading a
@@ -455,6 +534,6 @@ async function lines(deps, client, messages) {
455
534
  const replies = msg.reply_count && (msg.thread_ts ?? msg.ts) === msg.ts
456
535
  ? ` [thread: ${msg.reply_count} replies]`
457
536
  : "";
458
- return `${msg.ts} | ${tsToMinute(msg.ts)} | ${who} | ${msg.text ?? ""}${replies}`;
537
+ return `${msg.ts} | ${tsToMinute(msg.ts)} | ${who} | ${msg.text ?? ""}${replies}${uploads(msg.files)}`;
459
538
  });
460
539
  }
@@ -24,6 +24,7 @@
24
24
  // config.ts, platform-blind and shared with Telegram.
25
25
  import { saveInboundAll } from "../core/inbox.js";
26
26
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
27
+ import { awaitsTurn } from "../core/reply.js";
27
28
  import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
28
29
  import { logger } from "../log.js";
29
30
  import { Chains } from "./chains.js";
@@ -34,6 +35,7 @@ import { ReceiptLedger, Receipts } from "./receipts.js";
34
35
  import { SlackDirectory } from "./slack-directory.js";
35
36
  import { SlackApi, } from "./slack-api.js";
36
37
  import { SlackOutbound } from "./slack-outbound.js";
38
+ import { readThread, slackToolAvailable } from "./slack-tool.js";
37
39
  import { SlackPanel } from "./slack-panel.js";
38
40
  import { context, escapeMrkdwn, offeredLabel } from "./slack-render.js";
39
41
  /** Slack wants a short name here; the raw codepoint is an `invalid_name`. */
@@ -82,8 +84,29 @@ const threadOf = (event) => event.thread_ts ?? event.ts ?? "";
82
84
  /**
83
85
  * Subtypes worth reading. Everything else (joins, edits, deletions, topic
84
86
  * changes) is noise, and `bot_message` is either our own echo or another app's.
87
+ * `message_share` is in because a forward is a person handing the agent
88
+ * something to look at; dropping it delivered the sharer's comment alone, or
89
+ * nothing at all when they forwarded without one.
85
90
  */
86
- const READABLE_SUBTYPES = new Set(["file_share", "thread_broadcast"]);
91
+ const READABLE_SUBTYPES = new Set(["file_share", "thread_broadcast", "message_share"]);
92
+ /**
93
+ * The forwarded messages an event carries. `is_share` is the flag proper, and
94
+ * a `message_share` may arrive without it. An `is_msg_unfurl` on its own is
95
+ * Slack previewing a permalink somebody pasted — the sender did not choose to
96
+ * forward that message, so quoting it as if they had puts words in their mouth.
97
+ * A real share carries *both* flags, which is why the unfurl flag can only
98
+ * rule one out.
99
+ */
100
+ const sharesOf = (event) => (event.attachments ?? []).filter((a) => a.is_share === true || (event.subtype === "message_share" && !a.is_msg_unfurl));
101
+ /** A share's own uploads, which Slack hangs off the attachment. */
102
+ const sharedFiles = (share) => share.files ?? share.original_message?.files ?? [];
103
+ /**
104
+ * How many replies a shared thread may have before Pier stops reading it
105
+ * eagerly. A token budget, not a Slack limit: a handful of lines is worth
106
+ * spending on something a human deliberately forwarded, a few hundred is not,
107
+ * and past this the agent gets the coordinates and decides for itself.
108
+ */
109
+ const INLINE_REPLY_MAX = 30;
87
110
  /**
88
111
  * What the user asked for, from text a mention has already been stripped from.
89
112
  * `/stop` is accepted for muscle memory even though Slack rarely lets one
@@ -238,9 +261,20 @@ export class SlackChannel {
238
261
  if (!ts)
239
262
  return this.log("message event without a ts, dropped");
240
263
  const raw = (event.text ?? "").trim();
241
- const files = event.files ?? [];
242
- if (!raw && !files.length)
264
+ const shares = sharesOf(event);
265
+ // A share's files are in the attachment, so they join the event's own and
266
+ // ride the one save loop below — size gate and lost markers included.
267
+ const files = [...(event.files ?? []), ...shares.flatMap(sharedFiles)];
268
+ // A forward with no comment of its own is still content, and the whole
269
+ // point of the message.
270
+ if (!raw && !files.length && !shares.length) {
271
+ // A subtype we opted into that carried nothing readable is a shape this
272
+ // adapter did not recognize, not an empty message — most likely a share
273
+ // whose attachment `sharesOf` ruled out. Saying so beats vanishing (5b).
274
+ if (event.subtype)
275
+ this.log(`${event.subtype} with nothing readable in it, dropped`);
243
276
  return;
277
+ }
244
278
  const { kind } = await this.directory.channel(this.api, channel, event);
245
279
  const isDm = kind === "dm";
246
280
  if (!this.discovered.has(channel)) {
@@ -270,12 +304,13 @@ export class SlackChannel {
270
304
  return this.abortTurn(here, channel, threadTs);
271
305
  // `@bot` on its own (the text is empty once the mention is stripped) and
272
306
  // `settings` are the same request: show me this conversation's settings.
273
- if (this.panel && (command?.name === "settings" || (!text && !files.length))) {
307
+ if (this.panel && (command?.name === "settings" || (!text && !files.length && !shares.length))) {
274
308
  return this.panel.open(here, channel, threadTs);
275
309
  }
276
310
  // Downloading only past the gate: an unauthorized sender must not be able
277
311
  // to make the bot pull bytes on their behalf.
278
312
  const markers = await this.saveAttachments(files);
313
+ const shared = await Promise.all(shares.map((share) => this.sharedBlock(share)));
279
314
  // A Slack thread is many people talking into one session, so the agent is
280
315
  // told who spoke — and the id, which is what a mention needs. Resolved
281
316
  // *before* the mark: every await between mark() and dispatch is a window
@@ -289,7 +324,8 @@ export class SlackChannel {
289
324
  key: here,
290
325
  senderId: event.user,
291
326
  sender,
292
- text: [text, ...markers].filter(Boolean).join("\n"),
327
+ // Markers stay last: the inbound-file convention is a *trailing* block.
328
+ text: [text, ...shared, ...markers].filter(Boolean).join("\n"),
293
329
  mode: "steer",
294
330
  });
295
331
  }
@@ -434,6 +470,107 @@ export class SlackChannel {
434
470
  }
435
471
  return name ?? channel;
436
472
  }
473
+ /**
474
+ * What a forwarded message contributes to the prompt: who wrote it, where it
475
+ * lives, its text, and — when it is a thread parent — either the thread
476
+ * itself or the coordinates for `read_thread`. Nothing is invented: a name, a
477
+ * channel or a ts the event does not carry is simply left out of the line.
478
+ *
479
+ * The eager read runs whether or not `agentTool` is on, because this is
480
+ * inbound normalization of a message a human deliberately handed the agent —
481
+ * the same act as an upload, which nothing gates either. `agentTool` governs
482
+ * the agent reaching *out*, and the only thing it changes here is the hint,
483
+ * which would otherwise name a tool this session does not have.
484
+ */
485
+ async sharedBlock(share) {
486
+ const source = share.original_message;
487
+ const ts = share.ts ?? source?.ts;
488
+ const threadTs = share.thread_ts ?? source?.thread_ts ?? ts;
489
+ const replies = share.reply_count ?? source?.reply_count;
490
+ // A share of a *reply* is one message; only a parent has a thread — and
491
+ // the three fields a thread needs travel together so neither branch below
492
+ // has to assert they are there.
493
+ const parent = share.channel_id && ts && threadTs === ts && replies
494
+ ? { channel: share.channel_id, ts, replies }
495
+ : null;
496
+ // `name<id>` is the sender prefix's grammar (core/identity.ts), and the id
497
+ // is the only thing a mention can be built from. Resolved through the same
498
+ // cache the senders use, so a shared author already seen costs nothing.
499
+ const author = share.author_id
500
+ ? `${await this.directory.user(this.api, share.author_id)}<${share.author_id}>`
501
+ : share.author_name || share.author_subname;
502
+ const where = share.channel_name
503
+ ? `#${share.channel_name}${share.channel_id ? `<${share.channel_id}>` : ""}`
504
+ : share.channel_id;
505
+ const head = [
506
+ "shared message",
507
+ author && `from ${author}`,
508
+ where && `in ${where}`,
509
+ ts && `at ${ts}`,
510
+ ].filter(Boolean).join(" ");
511
+ // `fallback` is the plain-text rendering Slack sends when a share's `text`
512
+ // is empty (a file-only forward, or one whose body is all blocks).
513
+ // Empty rather than absent is the normal case for a file-only forward, so
514
+ // these fall through on "" as well.
515
+ const body = (share.text || share.fallback || source?.text || "").trim();
516
+ const thread = parent && parent.replies <= INLINE_REPLY_MAX
517
+ ? await this.sharedThread(parent.channel, parent.ts, parent.replies)
518
+ : { transcript: false, lines: [] };
519
+ // Past the budget, or read and failed: the coordinates are what is left,
520
+ // and they carry the tool's own parameter names so nothing has to be
521
+ // guessed from a permalink. Naming the tool is a lie when the Console has
522
+ // switched agent access off, so only that half goes — the coordinates are
523
+ // true either way.
524
+ const how = slackToolAvailable(this.deps.store) ? "read with the slack tool: " : "";
525
+ const hint = parent && !thread.transcript
526
+ ? `[thread: ${parent.replies} replies — ${how}channel ${parent.channel}, thread_ts ${parent.ts}]`
527
+ : "";
528
+ // The transcript opens with the shared message itself, so repeating its
529
+ // text above it would only cost tokens.
530
+ return [`[${head}]`, thread.transcript ? "" : body, ...thread.lines, hint]
531
+ .filter(Boolean).join("\n");
532
+ }
533
+ /**
534
+ * A small shared thread, read eagerly and inlined as the slack tool's own
535
+ * lines — through the tool's own read, so the paging, the seam dedup, the
536
+ * ordering and the error-to-action translation exist once (slack-tool.ts).
537
+ *
538
+ * A read that fails or comes back cut says so in the prompt (5b): a thread
539
+ * the agent silently never saw is indistinguishable from one with nothing in
540
+ * it, and the turn is dispatched either way.
541
+ */
542
+ async sharedThread(channel, ts, replies) {
543
+ const deps = { directory: this.directory, log: this.log };
544
+ try {
545
+ // The parent plus its replies, and one over the budget so a reply_count
546
+ // that undercounts still reports itself as cut rather than as complete.
547
+ const read = await readThread(deps, this.api, channel, ts, undefined, INLINE_REPLY_MAX + 2);
548
+ if (!read.messages.length)
549
+ return { transcript: false, lines: [] };
550
+ return {
551
+ transcript: true,
552
+ lines: [
553
+ `[thread: ${replies} replies, oldest first — ${read.format}]`,
554
+ ...read.messages,
555
+ // Cut either because a page failed or because the thread turned out
556
+ // longer than `reply_count` promised; a transcript that answers as
557
+ // if it were complete is the one nobody double-checks.
558
+ ...(read.incomplete || read.truncated
559
+ ? [`[thread partly read: ${read.incomplete ?? `cut at ${read.count} lines`}]`]
560
+ : []),
561
+ ],
562
+ };
563
+ }
564
+ catch (err) {
565
+ this.log(`shared thread ${channel}/${ts} not read: ${String(err)}`);
566
+ // Said in the prompt in the same shape as an attachment that never made
567
+ // it, because it is the same fact to the reader.
568
+ return {
569
+ transcript: false,
570
+ lines: [`[thread not read: ${err instanceof Error ? err.message : String(err)}]`],
571
+ };
572
+ }
573
+ }
437
574
  /** The upload list as the shared save loop wants it (the loop itself, size
438
575
  * gate and lost markers included, is core/inbox.ts). */
439
576
  saveAttachments(files) {
@@ -467,14 +604,25 @@ export class SlackChannel {
467
604
  // reply failed to send looks like work until the stale sweep.
468
605
  await this.receipts.settleAfter(conversation, () => this.out.reply(channel, threadTs, reply));
469
606
  }
470
- /** A system note, posted without touching the receipts: the turn it triggers
471
- * has not ended yet. */
607
+ /**
608
+ * A system note, and the 👀 goes on the note itself: the turn it triggers has
609
+ * no message of the user's to carry them — nobody typed one — so without this
610
+ * the thread shows nothing at all while the agent works. The turn-end `send`
611
+ * clears it like any other receipt; an error note is not marked, because no
612
+ * turn follows it (`awaitsTurn`) and the eyes would sit there until the stale
613
+ * sweep.
614
+ */
472
615
  async notify(conversation, note) {
473
616
  const { channel, threadTs } = parseConversation(conversation);
474
617
  if (!threadTs) {
475
618
  this.log(`refusing to post a system note to ${conversation}: no thread in the conversation id`);
476
619
  return;
477
620
  }
478
- await this.out.note(channel, threadTs, note);
621
+ const ts = await this.out.note(channel, threadTs, note);
622
+ // The last chunk of a long note, so the eyes sit at the foot of the thread,
623
+ // where the reply will land. A reaction that fails is swallowed and logged
624
+ // by receipts.ts, so the note itself is never lost to one.
625
+ if (ts && awaitsTurn(note.origin))
626
+ this.receipts.mark(conversation, channel, ts);
479
627
  }
480
628
  }
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // Everything policy-shaped (mention/bind gates, per-chat overrides) is in
13
13
  // config.ts, platform-blind and shared with the adapters still to come.
14
- import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
14
+ import { awaitsTurn, formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
15
15
  import { saveInboundAll } from "../core/inbox.js";
16
16
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
17
17
  import { sendAttachments, splitAttachments } from "./attach.js";
@@ -463,20 +463,31 @@ export class TelegramChannel {
463
463
  }
464
464
  /**
465
465
  * A system note: quoted, labelled with where it came from, and deliberately
466
- * plain — no buttons, no turn footer, and the 👀 receipts stay up, because
467
- * the turn this input triggers has not ended yet.
466
+ * plain — no buttons and no turn footer, because the turn this input triggers
467
+ * has not ended yet.
468
+ *
469
+ * That turn has no message of the user's to carry the 👀 — nobody typed one —
470
+ * so the note wears them, on its last chunk, until the turn-end `send`
471
+ * clears it. An error note is not marked: no turn follows it (`awaitsTurn`),
472
+ * and the eyes would sit there until the stale sweep.
468
473
  */
469
474
  async notify(conversation, note) {
470
475
  const { chatId, topicId } = parseConversation(conversation);
471
476
  const label = originLabel(note.origin);
477
+ let posted;
472
478
  for (const part of chunk(`<i>${label}</i>\n<blockquote>${toTelegramHtml(note.text)}</blockquote>`)) {
473
- await this.api.sendMessage({
479
+ posted = await this.api.sendMessage({
474
480
  chat_id: chatId,
475
481
  message_thread_id: topicId,
476
482
  text: part,
477
483
  parse_mode: "HTML",
478
484
  });
479
485
  }
486
+ // A failed reaction is swallowed and logged by receipts.ts, so the note
487
+ // itself is never lost to one.
488
+ if (posted && awaitsTurn(note.origin)) {
489
+ this.receipts.mark(conversation, chatId, String(posted.message_id));
490
+ }
480
491
  }
481
492
  }
482
493
  /** Display name from an update, which always carries enough to build one. */