pi-quiver 5.0.0 → 5.1.0

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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ Published to npm as `pi-quiver` (`pi install npm:pi-quiver`). Pushing a
8
8
  via OIDC trusted publishing. The release helper at
9
9
  `.agents/skills/release/scripts/release.sh` cuts the tag; CI publishes.
10
10
 
11
+ ## v5.1.0 - 2026-09-01
12
+
13
+ - `slack`: optional `policyPath` config injects a repo policy file into the system prompt every turn (`<slack-policy source="...">`); a missing/unreadable/empty file degrades to a `status=` block plus one deduped warning, tools stay fully usable either way (#9). `slack_post`/`slack_update` now resolve `@name` mentions to `<@U...>` (cache-first, one batched `users.list` live pass), leaving unresolvable names literal and reported via `unresolved mentions: ...` plus `details.unresolvedMentions`. The name cache gained an optional per-user `email` field and a file-level `snapshot_at` marker (set only by a full `slack_cache_refresh`, gating whether an alias match can be trusted straight from cache); `slack_cache_refresh`'s result line now reports an email/user ratio with a missing-scope hint. `slack_post` gained `unfurl_links`/`unfurl_media` params, applied to the headline and inline detail leg (never the upload stub), omitted when unset so Slack's default stands; `slack_update` has no equivalent (`chat.update` has no unfurl argument). See [doc/slack.md](doc/slack.md).
14
+
11
15
  ## v5.0.0 - 2026-08-29
12
16
 
13
17
  - **New opt-in `slack` extension** (#7): eight `slack_*` tools (search, thread, post, update, delete, pin, upload, cache refresh) for context-safe Slack search/threads/posting. Dual `user`/`bot` token identities resolved per call from process env or the repo's `.env`, never cross-identity fallback. Workspace-keyed channel/user name->ID cache with a repo-overridable `cachePath`. Fetch-style output size gating on search/thread reads. `slack_post`'s `thread_body` (no `thread_ts`) posts a transactional headline+thread announce - oversized detail bodies upload as a file - with a documented recovery path on delivery failure. OFF by default; nested-only `quiver.slack` config, no legacy flat form. See [doc/slack.md](doc/slack.md).
package/README.md CHANGED
@@ -18,7 +18,7 @@ But the moment an agent does that, one `fetch` or PDF read can dump hundreds of
18
18
 
19
19
  `fetch` and `doc_to_md` bring real web pages, GitHub issues/PRs, and local PDF/DOCX/PPTX files into context - and every result is size-gated by construction: over 32 KB or 1000 lines spills to a temp file with a preview and a grep/read hint, so a single call can never flood the window. Ingestion is what makes data-driven work possible; the gate is what keeps it safe.
20
20
 
21
- `session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`, and `slack` are opt-in ergonomics, recovery, and integration controls: session labeling, a themed startup header, Anthropic fast mode, semantic-stall recovery, and context-safe Slack search/threads/posting.
21
+ `session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`, and `slack` are opt-in ergonomics, recovery, and integration controls: session labeling, a themed startup header, Anthropic fast mode, semantic-stall recovery, and context-safe Slack search/threads/posting with repo-policy injection, `@name` mention resolution, cached emails, and per-call unfurl control.
22
22
 
23
23
  ## Part of the pi agent toolkit
24
24
 
@@ -16,6 +16,7 @@ import { basename, isAbsolute, join } from "node:path";
16
16
  import {
17
17
  defaultApiCall,
18
18
  defaultUploadBytes,
19
+ buildPolicyBlock,
19
20
  discoverRepoRoot,
20
21
  resolveSlackConfig,
21
22
  resolveToken,
@@ -26,6 +27,7 @@ import {
26
27
  deleteMessage,
27
28
  pinMessage,
28
29
  uploadFile,
30
+ formatUnresolvedSuffix,
29
31
  SlackError,
30
32
  type SlackConfig,
31
33
  type CoreDeps,
@@ -33,8 +35,9 @@ import {
33
35
  type AnnounceResult,
34
36
  type SearchResult,
35
37
  type ThreadResult,
38
+ type UnresolvedMention,
36
39
  } from "../lib/slack-core.ts";
37
- import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
40
+ import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, resolveMentions, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
38
41
 
39
42
  const IDENTITY = Type.Union([Type.Literal("user"), Type.Literal("bot")], {
40
43
  description: 'Which token to act as: "user" (a real person, needed for slack_search/slack_thread) or "bot" (an app identity). Determines which token env var is used and whose name shows as the author.',
@@ -139,6 +142,19 @@ export function channelLine(result: MutationResult | (MutationResult & { fileId:
139
142
  return parts.join(" | ");
140
143
  }
141
144
 
145
+ /** Shared by slack_post/slack_update: the channelLine + unresolved-mentions suffix result shape. */
146
+ function mentionAwareResult(
147
+ result: MutationResult | AnnounceResult,
148
+ mentions: { unresolved: UnresolvedMention[]; lookupError?: string },
149
+ opts: { detailUploaded?: boolean } = {},
150
+ ) {
151
+ const suffix = formatUnresolvedSuffix(mentions.unresolved, { lookupError: mentions.lookupError, ...opts });
152
+ return {
153
+ content: [{ type: "text" as const, text: suffix ? `${channelLine(result)} | ${suffix}` : channelLine(result) }],
154
+ details: { ...result, ...(mentions.unresolved.length > 0 ? { unresolvedMentions: mentions.unresolved } : {}) },
155
+ };
156
+ }
157
+
142
158
  export function searchResultText(result: SearchResult): string {
143
159
  return `${result.output}\n\ntotal: ${result.total} | page: ${result.page} of ${result.pageCount}`;
144
160
  }
@@ -191,6 +207,36 @@ export default function slackExtension(pi: ExtensionAPI) {
191
207
 
192
208
  const repoRoot = discoverRepoRoot(ctx.cwd);
193
209
 
210
+ const policyPath = cfg.policyPath;
211
+ if (policyPath !== undefined) {
212
+ const resolvedPolicyPath = isAbsolute(policyPath) ? policyPath : join(repoRoot, policyPath);
213
+ let policyWarned = false;
214
+ const warnOnce = (eventCtx: typeof ctx, message: string): void => {
215
+ if (policyWarned) return;
216
+ policyWarned = true;
217
+ if (eventCtx.hasUI) eventCtx.ui.notify(message, "warning");
218
+ else console.warn(message);
219
+ };
220
+
221
+ pi.on("before_agent_start", async (event, eventCtx) => {
222
+ let block: string;
223
+ try {
224
+ const body = readFileSync(resolvedPolicyPath, "utf8");
225
+ if (body.trim() === "") {
226
+ warnOnce(eventCtx, `pi-quiver: Slack policy file ${policyPath} is empty; posting policy is unknown this session.`);
227
+ block = buildPolicyBlock({ source: policyPath, status: "empty" });
228
+ } else {
229
+ block = buildPolicyBlock({ source: policyPath, status: "ok", body });
230
+ }
231
+ } catch (err) {
232
+ const code = (err as { code?: string }).code ?? (err instanceof Error ? err.message : String(err));
233
+ warnOnce(eventCtx, `pi-quiver: Slack policy file ${policyPath} could not be read (${code}); posting policy is unknown this session.`);
234
+ block = buildPolicyBlock({ source: policyPath, status: "unreadable", code });
235
+ }
236
+ return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
237
+ });
238
+ }
239
+
194
240
  pi.registerTool({
195
241
  name: "slack_search",
196
242
  label: "Slack Search",
@@ -248,7 +294,7 @@ export default function slackExtension(pi: ExtensionAPI) {
248
294
  label: "Slack Post",
249
295
  promptSnippet: "Post a Slack message, reply, or headline+detail announcement",
250
296
  description:
251
- "Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name or a channel ID (user @names not accepted). Plain post: `text` and/or `blocks` (Block Kit JSON, passed through unvalidated). Threaded reply: also set `thread_ts` - no headline is ever emitted, `thread_body` (or `text`) becomes the reply body. Announce mode: set `thread_body` WITHOUT `thread_ts` - posts a short single-line `text` headline, then posts `thread_body` as the first threaded reply in the same call; if `thread_body`'s rendered length exceeds the configured uploadThresholdChars (default 4000), it is delivered as a threaded file upload instead. Recovery: re-invoke with `thread_ts` set (never re-omit it) to post only into the existing thread - a second headline is never sent. On detail-delivery failure the headline is marked \"detail pending\" and the detail is saved to a temp file; the error names the path.",
297
+ "Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name or a channel ID (user @names not accepted). Plain post: `text` and/or `blocks` (Block Kit JSON, passed through unvalidated). Threaded reply: also set `thread_ts` - no headline is ever emitted, `thread_body` (or `text`) becomes the reply body. Announce mode: set `thread_body` WITHOUT `thread_ts` - posts a short single-line `text` headline, then posts `thread_body` as the first threaded reply in the same call; if `thread_body`'s rendered length exceeds the configured uploadThresholdChars (default 4000), it is delivered as a threaded file upload instead. Recovery: re-invoke with `thread_ts` set (never re-omit it) to post only into the existing thread - a second headline is never sent. On detail-delivery failure the headline is marked \"detail pending\" and the detail is saved to a temp file; the error names the path. `unfurl_links`/`unfurl_media` apply to this post only, are omitted when unset (Slack's default stands), and slack_update cannot change unfurling after the fact.",
252
298
  parameters: Type.Object({
253
299
  as: IDENTITY,
254
300
  channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
@@ -256,20 +302,69 @@ export default function slackExtension(pi: ExtensionAPI) {
256
302
  blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
257
303
  thread_ts: Type.Optional(Type.String({ description: "Reply into this existing thread instead of posting a new headline" })),
258
304
  thread_body: Type.Optional(Type.String({ description: "Detail body for an announce headline, or the reply body when thread_ts is set" })),
305
+ unfurl_links: Type.Optional(
306
+ Type.Boolean({ description: "Slack unfurls link previews by default; pass false to suppress text-link previews for this message." }),
307
+ ),
308
+ unfurl_media: Type.Optional(
309
+ Type.Boolean({ description: "Pass false to suppress image/video previews for this message." }),
310
+ ),
259
311
  }),
260
312
  async execute(_toolCallId, params, signal) {
261
313
  return guarded(async () => {
262
314
  assertNoMarkdownText(params);
263
315
  const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
264
316
  const channel = await resolveChannel(params.channel, cacheCtx);
317
+
318
+ // Only the fields core will actually send: announce uses text + thread_body (both
319
+ // scanned), a threaded reply collapses to thread_body ?? text (whichever param carried
320
+ // the body is the one core reads, so substitute into that same field - never the
321
+ // other), a plain post is text alone.
322
+ const isAnnounce = params.thread_body !== undefined && params.thread_ts === undefined;
323
+ const isReply = params.thread_ts !== undefined;
324
+ let text: string | undefined;
325
+ let threadBody: string | undefined;
326
+ let mentions: { unresolved: UnresolvedMention[]; lookupError?: string };
327
+ if (isAnnounce) {
328
+ const resolved = await resolveMentions(
329
+ [
330
+ { field: "text", value: params.text ?? "" },
331
+ { field: "thread_body", value: params.thread_body ?? "" },
332
+ ],
333
+ cacheCtx,
334
+ );
335
+ mentions = resolved;
336
+ text = resolved.values[0];
337
+ threadBody = resolved.values[1];
338
+ } else if (isReply) {
339
+ const replyField: "text" | "thread_body" = params.thread_body !== undefined ? "thread_body" : "text";
340
+ const resolved = await resolveMentions([{ field: replyField, value: params.thread_body ?? params.text ?? "" }], cacheCtx);
341
+ mentions = resolved;
342
+ if (replyField === "thread_body") {
343
+ text = params.text;
344
+ threadBody = resolved.values[0];
345
+ } else {
346
+ text = params.text === undefined ? undefined : resolved.values[0];
347
+ threadBody = undefined;
348
+ }
349
+ } else {
350
+ const resolved = await resolveMentions([{ field: "text", value: params.text ?? "" }], cacheCtx);
351
+ mentions = resolved;
352
+ text = params.text === undefined ? undefined : resolved.values[0];
353
+ }
354
+
265
355
  const result = await postMessage(
266
- { channel, text: params.text, blocks: params.blocks, thread_ts: params.thread_ts, thread_body: params.thread_body },
356
+ {
357
+ channel,
358
+ text,
359
+ blocks: params.blocks,
360
+ thread_ts: params.thread_ts,
361
+ thread_body: threadBody,
362
+ unfurl_links: params.unfurl_links,
363
+ unfurl_media: params.unfurl_media,
364
+ },
267
365
  { ...deps, thresholdChars: cfg.uploadThresholdChars, uploadBytes: defaultUploadBytes },
268
366
  );
269
- return {
270
- content: [{ type: "text" as const, text: channelLine(result) }],
271
- details: result,
272
- };
367
+ return mentionAwareResult(result, mentions, { detailUploaded: "detailUploaded" in result && result.detailUploaded === true });
273
368
  }, params.as);
274
369
  },
275
370
  renderCall: (args, theme) => oneLine(theme, "slack_post", `as:${args.as} ${args.channel}`),
@@ -294,11 +389,12 @@ export default function slackExtension(pi: ExtensionAPI) {
294
389
  assertNoMarkdownText(params);
295
390
  const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
296
391
  const channel = await resolveChannel(params.channel, cacheCtx);
297
- const result = await updateMessage({ channel, ts: params.ts, text: params.text, blocks: params.blocks }, deps);
298
- return {
299
- content: [{ type: "text" as const, text: channelLine(result) }],
300
- details: result,
301
- };
392
+ const mentions = await resolveMentions([{ field: "text", value: params.text ?? "" }], cacheCtx);
393
+ const result = await updateMessage(
394
+ { channel, ts: params.ts, text: params.text === undefined ? undefined : mentions.values[0], blocks: params.blocks },
395
+ deps,
396
+ );
397
+ return mentionAwareResult(result, mentions);
302
398
  }, params.as);
303
399
  },
304
400
  renderCall: (args, theme) => oneLine(theme, "slack_update", `as:${args.as} ${args.channel} ts:${args.ts}`),
@@ -410,15 +506,21 @@ export default function slackExtension(pi: ExtensionAPI) {
410
506
  label: "Slack Cache Refresh",
411
507
  promptSnippet: "Rebuild the Slack channel/user name->ID cache",
412
508
  description:
413
- 'Rebuild the Slack channel and user name->ID cache from scratch (full conversations.list + users.list scan, atomic replace). Uses the "user" identity when a user token is configured, else falls back to "bot" (no `as` param). Run this after channels/users change or when a #name/@name lookup unexpectedly fails with name_not_found. Reports the resulting channel and user counts.',
509
+ 'Rebuild the Slack channel and user name->ID cache from scratch (full conversations.list + users.list scan, atomic replace). Uses the "user" identity when a user token is configured, else falls back to "bot" (no `as` param). Run this after channels/users change or when a #name/@name lookup unexpectedly fails with name_not_found. Reports the resulting channel, user, and email counts - a low email/user ratio hints the users:read.email scope may be missing.',
414
510
  parameters: Type.Object({}),
415
511
  async execute(_toolCallId, _params, signal) {
416
512
  const identity = pickCacheRefreshIdentity(cfg, process.env, repoRoot);
417
513
  return guarded(async () => {
418
514
  const { cacheCtx } = await resolveCall(identity, cfg, ctx, signal, repoRoot);
419
515
  const result = await refreshCache(cacheCtx);
516
+ const ratio =
517
+ result.users === 0
518
+ ? ""
519
+ : result.emails === 0
520
+ ? ` | emails: 0/${result.users} (users:read.email scope may be missing)`
521
+ : ` | emails: ${result.emails}/${result.users}`;
420
522
  return {
421
- content: [{ type: "text" as const, text: `channels: ${result.channels}, users: ${result.users}` }],
523
+ content: [{ type: "text" as const, text: `channels: ${result.channels}, users: ${result.users}${ratio}` }],
422
524
  details: result,
423
525
  };
424
526
  }, identity);
@@ -7,14 +7,24 @@
7
7
 
8
8
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
9
  import { dirname, isAbsolute, join } from "node:path";
10
- import type { ApiCall, SlackConfig } from "./slack-core.ts";
10
+ import type { ApiCall, SlackConfig, UnresolvedMention } from "./slack-core.ts";
11
11
  import { SlackError } from "./slack-core.ts";
12
12
 
13
+ export interface UserEntry {
14
+ id: string;
15
+ display_name: string;
16
+ real_name: string;
17
+ email?: string;
18
+ }
19
+
13
20
  export interface SlackCacheFile {
14
21
  team_id: string;
15
22
  channels: Record<string, string>;
16
- users: Record<string, { id: string; display_name: string; real_name: string }>;
23
+ users: Record<string, UserEntry>;
17
24
  refreshed_at: string;
25
+ /** Set only by refreshCache's full replacement; presence means this file once held a complete
26
+ * workspace listing, which is the only state where an alias match may be trusted from cache. */
27
+ snapshot_at?: string;
18
28
  }
19
29
 
20
30
  export interface CacheCtx {
@@ -99,6 +109,16 @@ function stripPrefix(input: string): string {
99
109
  return input.startsWith("#") || input.startsWith("@") ? input.slice(1) : input;
100
110
  }
101
111
 
112
+ function toUserEntry(u: SlackUser): UserEntry {
113
+ const email = u.profile?.email;
114
+ return {
115
+ id: u.id,
116
+ display_name: u.profile?.display_name ?? "",
117
+ real_name: u.profile?.real_name ?? u.real_name ?? "",
118
+ ...(email ? { email } : {}),
119
+ };
120
+ }
121
+
102
122
  export async function resolveChannel(input: string, ctx: CacheCtx): Promise<string> {
103
123
  if (RAW_CHANNEL_ID.test(input)) return input;
104
124
  if (input.startsWith("@")) {
@@ -150,7 +170,7 @@ export async function resolveChannel(input: string, ctx: CacheCtx): Promise<stri
150
170
  interface SlackUser {
151
171
  id: string;
152
172
  name: string;
153
- profile?: { display_name?: string; real_name?: string };
173
+ profile?: { display_name?: string; real_name?: string; email?: string };
154
174
  real_name?: string;
155
175
  }
156
176
 
@@ -208,11 +228,7 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
208
228
  for (const u of members) {
209
229
  if (u.name === name) {
210
230
  mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
211
- base.users[name] = {
212
- id: u.id,
213
- display_name: u.profile?.display_name ?? "",
214
- real_name: u.profile?.real_name ?? u.real_name ?? "",
215
- };
231
+ base.users[name] = toUserEntry(u);
216
232
  });
217
233
  return u.id;
218
234
  }
@@ -231,11 +247,7 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
231
247
  if (displayCandidates.length === 1) {
232
248
  const u = displayCandidates[0];
233
249
  mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
234
- base.users[u.name] = {
235
- id: u.id,
236
- display_name: u.profile?.display_name ?? "",
237
- real_name: u.profile?.real_name ?? u.real_name ?? "",
238
- };
250
+ base.users[u.name] = toUserEntry(u);
239
251
  });
240
252
  return u.id;
241
253
  }
@@ -249,11 +261,7 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
249
261
  if (realCandidates.length === 1) {
250
262
  const u = realCandidates[0];
251
263
  mergeAndWrite(ctx.filePath, cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal)), (base) => {
252
- base.users[u.name] = {
253
- id: u.id,
254
- display_name: u.profile?.display_name ?? "",
255
- real_name: u.profile?.real_name ?? u.real_name ?? "",
256
- };
264
+ base.users[u.name] = toUserEntry(u);
257
265
  });
258
266
  return u.id;
259
267
  }
@@ -267,16 +275,202 @@ export async function resolveUser(input: string, ctx: CacheCtx): Promise<string>
267
275
  throw new SlackError("name_not_found", `No user named "${name}" was found in the workspace.`);
268
276
  }
269
277
 
278
+ const MENTION_DENY = new Set(["here", "channel", "everyone"]);
279
+ const MENTION_BOUNDARY = new Set([" ", "\t", "\n", "\r", "(", "[", "*", "_", '"', "'"]);
280
+ // `_` is trimmed here even though it is also a boundary character: it is a legal username
281
+ // character too, so an italic-wrapped mention like `_@alice_` is otherwise unreachable - the
282
+ // trailing `_` must be stripped for the candidate to resolve.
283
+ const MENTION_TRAILING = /[.,;:!?)_]+$/;
284
+ const MENTION_SCAN = /(\\?)@([A-Za-z0-9._-]+)/g;
285
+
286
+ function isBoundary(text: string, atIndex: number): boolean {
287
+ if (atIndex === 0) return true;
288
+ return MENTION_BOUNDARY.has(text[atIndex - 1]);
289
+ }
290
+
291
+ /**
292
+ * Substitutes `@name` -> `<@U...>` across every field a mutation will actually send.
293
+ * Never throws for an unresolvable name: not-found, ambiguous, and a failed live lookup all
294
+ * leave the text literal and report it, because a mention is prose, not an addressed parameter.
295
+ */
296
+ export async function resolveMentions(
297
+ fields: { field: "text" | "thread_body"; value: string }[],
298
+ ctx: CacheCtx,
299
+ ): Promise<{
300
+ values: string[];
301
+ unresolved: UnresolvedMention[];
302
+ lookupError?: string;
303
+ }> {
304
+ interface Candidate {
305
+ field: "text" | "thread_body";
306
+ start: number;
307
+ end: number;
308
+ escaped: boolean;
309
+ /** [name, endOffsetForThatName] pairs, trimmed variant first so it wins the lookup race. */
310
+ lookups: [string, number][];
311
+ }
312
+
313
+ const perField: Candidate[][] = fields.map(() => []);
314
+ const wanted = new Set<string>();
315
+
316
+ fields.forEach((f, fi) => {
317
+ for (const m of f.value.matchAll(MENTION_SCAN)) {
318
+ const escaped = m[1] === "\\";
319
+ const at = m.index + m[1].length;
320
+ if (escaped) {
321
+ if (!isBoundary(f.value, m.index)) continue;
322
+ } else if (!isBoundary(f.value, at)) continue;
323
+
324
+ const raw = m[2];
325
+ const fullEnd = m.index + m[0].length;
326
+ const trimmed = raw.replace(MENTION_TRAILING, "");
327
+ // Check the deny list against both forms: `_@channel_`/`@here.` still carry the
328
+ // trailing punctuation stripped below, so a raw-only check misses them.
329
+ if (MENTION_DENY.has(raw) || MENTION_DENY.has(trimmed)) continue;
330
+ // A name that is entirely trailing-punctuation (e.g. `@...`) trims to "" - not a
331
+ // real candidate, so skip it rather than looking up an empty username.
332
+ if (trimmed === "") continue;
333
+ const trimmedEnd = fullEnd - (raw.length - trimmed.length);
334
+ const lookups: [string, number][] =
335
+ trimmed === raw ? [[raw, fullEnd]] : [[trimmed, trimmedEnd], [raw, fullEnd]];
336
+ perField[fi].push({ field: f.field, start: m.index, end: fullEnd, escaped, lookups });
337
+ if (!escaped) for (const [name] of lookups) wanted.add(name);
338
+ }
339
+ });
340
+
341
+ const cached = readCacheFile(ctx.filePath);
342
+ const resolved = new Map<string, string>();
343
+ const aliasTrusted = cached?.snapshot_at !== undefined;
344
+ // Names an aliasTrusted FULL snapshot already proved ambiguous: a live users.list call
345
+ // cannot un-ambiguate them, so they must not fall into `outstanding`.
346
+ const conclusivelyAmbiguous = new Set<string>();
347
+
348
+ if (cached) {
349
+ for (const name of wanted) {
350
+ const byUsername = cached.users[name];
351
+ if (byUsername) {
352
+ resolved.set(name, byUsername.id);
353
+ continue;
354
+ }
355
+ if (!aliasTrusted) continue;
356
+ const entries = Object.values(cached.users);
357
+ const display = entries.filter((u) => u.display_name === name);
358
+ if (display.length === 1) {
359
+ resolved.set(name, display[0].id);
360
+ continue;
361
+ }
362
+ if (display.length > 1) {
363
+ conclusivelyAmbiguous.add(name);
364
+ continue;
365
+ }
366
+ const real = entries.filter((u) => u.real_name === name);
367
+ if (real.length === 1) resolved.set(name, real[0].id);
368
+ else if (real.length > 1) conclusivelyAmbiguous.add(name);
369
+ }
370
+ }
371
+
372
+ const outstanding = [...wanted].filter((n) => !resolved.has(n) && !conclusivelyAmbiguous.has(n));
373
+ let lookupError: string | undefined;
374
+
375
+ if (outstanding.length > 0) {
376
+ try {
377
+ const found = new Map<string, SlackUser>();
378
+ const aliasHits = new Map<string, SlackUser[]>();
379
+ let cursor = "";
380
+ for (let page = 1; ; page++) {
381
+ if (page > MAX_LIST_PAGES) {
382
+ throw new SlackError(
383
+ "pagination_overflow",
384
+ `resolveMentions: users.list did not terminate within ${MAX_LIST_PAGES} pages.`,
385
+ );
386
+ }
387
+ const data = await ctx.apiCall(
388
+ "users.list",
389
+ ctx.token,
390
+ { limit: 1000, ...(cursor ? { cursor } : {}) },
391
+ { retry: false, signal: ctx.signal },
392
+ );
393
+ for (const u of (data.members as SlackUser[]) ?? []) {
394
+ for (const name of outstanding) {
395
+ if (u.name === name) found.set(name, u);
396
+ else if (u.profile?.display_name === name || (u.profile?.real_name ?? u.real_name) === name) {
397
+ aliasHits.set(name, [...(aliasHits.get(name) ?? []), u]);
398
+ }
399
+ }
400
+ }
401
+ const meta = data.response_metadata as { next_cursor?: string } | undefined;
402
+ const nextCursor = meta?.next_cursor ?? "";
403
+ if (nextCursor !== "" && nextCursor === cursor) break;
404
+ cursor = nextCursor;
405
+ if (!cursor) break;
406
+ }
407
+
408
+ const writes: SlackUser[] = [];
409
+ for (const name of outstanding) {
410
+ const exact = found.get(name);
411
+ const aliases = aliasHits.get(name) ?? [];
412
+ const pick = exact ?? (aliases.length === 1 ? aliases[0] : undefined);
413
+ if (!pick) continue;
414
+ resolved.set(name, pick.id);
415
+ writes.push(pick);
416
+ }
417
+ if (writes.length > 0) {
418
+ const teamId = cached?.team_id ?? (await teamIdFor(ctx.token, ctx.apiCall, ctx.signal));
419
+ mergeAndWrite(ctx.filePath, teamId, (base) => {
420
+ for (const u of writes) base.users[u.name] = toUserEntry(u);
421
+ });
422
+ }
423
+ } catch (err) {
424
+ lookupError = err instanceof Error ? err.message : String(err);
425
+ }
426
+ }
427
+
428
+ const unresolved: UnresolvedMention[] = [];
429
+ const seenUnresolved = new Set<string>();
430
+ const values = fields.map((f, fi) => {
431
+ let out = "";
432
+ let cursor = 0;
433
+ for (const c of perField[fi]) {
434
+ out += f.value.slice(cursor, c.start);
435
+ const literal = f.value.slice(c.start, c.end);
436
+ if (c.escaped) {
437
+ out += literal.slice(1);
438
+ cursor = c.end;
439
+ } else {
440
+ const hit = c.lookups.find(([n]) => resolved.has(n));
441
+ if (hit !== undefined) {
442
+ out += `<@${resolved.get(hit[0])}>`;
443
+ cursor = hit[1];
444
+ } else {
445
+ out += literal;
446
+ const name = `@${c.lookups[0][0]}`;
447
+ // Documented contract (doc/slack.md): unresolvedMentions is deduplicated by
448
+ // (field, name), so "cc @bob @bob" reports @bob once, not once per occurrence.
449
+ const key = `${c.field}\u0000${name}`;
450
+ if (!seenUnresolved.has(key)) {
451
+ seenUnresolved.add(key);
452
+ unresolved.push({ field: c.field, name });
453
+ }
454
+ cursor = c.end;
455
+ }
456
+ }
457
+ }
458
+ return out + f.value.slice(cursor);
459
+ });
460
+
461
+ return { values, unresolved, ...(lookupError !== undefined ? { lookupError } : {}) };
462
+ }
463
+
270
464
  /**
271
465
  * Full snapshot replace, deliberately asymmetric with mergeAndWrite: refresh's job is to
272
466
  * atomically overwrite the whole file, so a concurrent mergeAndWrite write racing this one may
273
467
  * be clobbered (last-writer-wins). Accepted per spec (doc/specs/2026-08-29-gh-7-slack-extension.md
274
468
  * Cache section) - a clobbered merge self-heals on the next cache miss.
275
469
  */
276
- export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; users: number }> {
470
+ export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; users: number; emails: number }> {
277
471
  const teamId = await teamIdFor(ctx.token, ctx.apiCall, ctx.signal);
278
472
  const channels: Record<string, string> = {};
279
- const users: Record<string, { id: string; display_name: string; real_name: string }> = {};
473
+ const users: Record<string, UserEntry> = {};
280
474
 
281
475
  let cursor = "";
282
476
  for (let page = 1; ; page++) {
@@ -319,7 +513,7 @@ export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; u
319
513
  const data = await ctx.apiCall("users.list", ctx.token, { limit: 1000, ...(cursor ? { cursor } : {}) }, { retry: false, signal: ctx.signal });
320
514
  const members = (data.members as SlackUser[]) ?? [];
321
515
  for (const u of members) {
322
- users[u.name] = { id: u.id, display_name: u.profile?.display_name ?? "", real_name: u.profile?.real_name ?? u.real_name ?? "" };
516
+ users[u.name] = toUserEntry(u);
323
517
  }
324
518
  const meta = data.response_metadata as { next_cursor?: string } | undefined;
325
519
  const nextCursor = meta?.next_cursor ?? "";
@@ -333,6 +527,11 @@ export async function refreshCache(ctx: CacheCtx): Promise<{ channels: number; u
333
527
  if (!cursor) break;
334
528
  }
335
529
 
336
- atomicWrite(ctx.filePath, { team_id: teamId, channels, users, refreshed_at: new Date().toISOString() });
337
- return { channels: Object.keys(channels).length, users: Object.keys(users).length };
530
+ const snapshotAt = new Date().toISOString();
531
+ atomicWrite(ctx.filePath, { team_id: teamId, channels, users, refreshed_at: snapshotAt, snapshot_at: snapshotAt });
532
+ return {
533
+ channels: Object.keys(channels).length,
534
+ users: Object.keys(users).length,
535
+ emails: Object.values(users).filter((u) => u.email !== undefined).length,
536
+ };
338
537
  }
package/lib/slack-core.ts CHANGED
@@ -19,6 +19,7 @@ import { resolveConfig } from "./extension-config.ts";
19
19
  export interface SlackConfig {
20
20
  enabled: boolean;
21
21
  cachePath: string | undefined;
22
+ policyPath: string | undefined;
22
23
  userTokenEnv: string;
23
24
  botTokenEnv: string;
24
25
  uploadThresholdChars: number;
@@ -27,6 +28,7 @@ export interface SlackConfig {
27
28
  export const DEFAULT_SLACK_CONFIG: SlackConfig = {
28
29
  enabled: false,
29
30
  cachePath: undefined,
31
+ policyPath: undefined,
30
32
  userTokenEnv: "SLACK_USER_TOKEN",
31
33
  botTokenEnv: "SLACK_BOT_TOKEN",
32
34
  uploadThresholdChars: 4000,
@@ -49,6 +51,7 @@ export function coerce(raw: unknown): Partial<SlackConfig> | undefined {
49
51
  const patch: Partial<SlackConfig> = {};
50
52
  if (typeof o.enabled === "boolean") patch.enabled = o.enabled;
51
53
  if (typeof o.cachePath === "string") patch.cachePath = o.cachePath;
54
+ if (typeof o.policyPath === "string") patch.policyPath = o.policyPath;
52
55
  if (typeof o.userTokenEnv === "string") patch.userTokenEnv = o.userTokenEnv;
53
56
  if (typeof o.botTokenEnv === "string") patch.botTokenEnv = o.botTokenEnv;
54
57
  if (typeof o.uploadThresholdChars === "number" && Number.isInteger(o.uploadThresholdChars) && o.uploadThresholdChars > 0) {
@@ -635,7 +638,14 @@ async function withPermalink(deps: CoreDeps, channel: string, ts: string): Promi
635
638
  }
636
639
 
637
640
  export async function postPlain(
638
- args: { channel: string; text?: string; blocks?: unknown[]; thread_ts?: string },
641
+ args: {
642
+ channel: string;
643
+ text?: string;
644
+ blocks?: unknown[];
645
+ thread_ts?: string;
646
+ unfurl_links?: boolean;
647
+ unfurl_media?: boolean;
648
+ },
639
649
  deps: CoreDeps,
640
650
  ): Promise<MutationResult> {
641
651
  assertTextWithinLimit(args.text);
@@ -644,6 +654,8 @@ export async function postPlain(
644
654
  if (args.text !== undefined) params.text = args.text;
645
655
  if (args.blocks !== undefined) params.blocks = args.blocks;
646
656
  if (args.thread_ts !== undefined) params.thread_ts = args.thread_ts;
657
+ if (args.unfurl_links !== undefined) params.unfurl_links = args.unfurl_links;
658
+ if (args.unfurl_media !== undefined) params.unfurl_media = args.unfurl_media;
647
659
 
648
660
  const data = await deps.apiCall("chat.postMessage", deps.token, params, { retry: true, signal: deps.signal });
649
661
  const channel = typeof data.channel === "string" ? data.channel : args.channel;
@@ -740,6 +752,49 @@ export function linkCollapsedLength(text: string): number {
740
752
  return text.replace(/<([^|>]+)\|([^>]+)>/g, "$2").replace(/<[^>]+>/g, "x").length;
741
753
  }
742
754
 
755
+ function escapeAttr(value: string): string {
756
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
757
+ }
758
+
759
+ /** Pure: the handler in extensions/slack.ts owns readFileSync and maps the outcome to this input. */
760
+ export function buildPolicyBlock(input: {
761
+ source: string;
762
+ status: "ok" | "unreadable" | "empty";
763
+ body?: string;
764
+ code?: string;
765
+ }): string {
766
+ const source = escapeAttr(input.source);
767
+ if (input.status === "ok") {
768
+ return `<slack-policy source="${source}">\n${input.body ?? ""}</slack-policy>`;
769
+ }
770
+ const reason =
771
+ input.status === "empty"
772
+ ? `Configured Slack policy file is empty.`
773
+ : `Configured Slack policy file could not be read (${input.code ?? "unknown error"}).`;
774
+ return `<slack-policy source="${source}" status="${input.status}">\n${reason} Slack tools are available but the repository's posting policy is unknown - ask the operator before posting.\n</slack-policy>`;
775
+ }
776
+
777
+ export interface UnresolvedMention {
778
+ field: "text" | "thread_body";
779
+ name: string;
780
+ }
781
+
782
+ /** Pure: the `channelLine` suffix segment for unresolved mentions. Empty string means "append nothing". */
783
+ export function formatUnresolvedSuffix(
784
+ unresolved: UnresolvedMention[],
785
+ opts: { lookupError?: string; detailUploaded?: boolean },
786
+ ): string {
787
+ if (unresolved.length === 0) return "";
788
+ const names: string[] = [];
789
+ for (const u of unresolved) if (!names.includes(u.name)) names.push(u.name);
790
+ let suffix = `unresolved mentions: ${names.join(", ")}`;
791
+ if (opts.lookupError !== undefined) suffix += ` (lookup failed: ${opts.lookupError})`;
792
+ if (opts.detailUploaded && unresolved.some((u) => u.field === "thread_body")) {
793
+ suffix += ` (detail uploaded as a file - slack_update cannot repair it; repost to fix)`;
794
+ }
795
+ return suffix;
796
+ }
797
+
743
798
  export function persistDetail(body: string): string {
744
799
  const dir = join(tmpdir(), "pi-slack");
745
800
  mkdirSync(dir, { recursive: true });
@@ -763,6 +818,11 @@ function assertHeadline(text: string): void {
763
818
 
764
819
  export interface AnnounceResult extends MutationResult {
765
820
  detailTs?: string;
821
+ // Set only when the detail leg took the file-upload path (deliverDetailUpload) rather than the
822
+ // inline threaded chat.postMessage reply - detailTs alone can't distinguish the two, since both
823
+ // paths set it. slack_update can edit the headline or the "Detail attached." stub, never an
824
+ // uploaded file's contents, so callers need this to know a mention-repair edit won't reach it.
825
+ detailUploaded?: true;
766
826
  }
767
827
 
768
828
  async function deliverDetailUpload(
@@ -874,14 +934,23 @@ async function recoverFromDetailFailure(
874
934
 
875
935
  export async function announce(
876
936
  args: { channel: string; text: string; thread_body: string },
877
- deps: CoreDeps & { uploadBytes: UploadBytes; thresholdChars: number; persist?: (body: string) => string },
937
+ deps: CoreDeps & {
938
+ uploadBytes: UploadBytes;
939
+ thresholdChars: number;
940
+ persist?: (body: string) => string;
941
+ unfurl_links?: boolean;
942
+ unfurl_media?: boolean;
943
+ },
878
944
  ): Promise<AnnounceResult> {
879
945
  assertHeadline(args.text);
880
946
  const persist = deps.persist ?? persistDetail;
947
+ const unfurl: Record<string, unknown> = {};
948
+ if (deps.unfurl_links !== undefined) unfurl.unfurl_links = deps.unfurl_links;
949
+ if (deps.unfurl_media !== undefined) unfurl.unfurl_media = deps.unfurl_media;
881
950
 
882
951
  let headlineData: Record<string, unknown>;
883
952
  try {
884
- headlineData = await deps.apiCall("chat.postMessage", deps.token, { channel: args.channel, text: args.text }, { retry: false, signal: deps.signal });
953
+ headlineData = await deps.apiCall("chat.postMessage", deps.token, { channel: args.channel, text: args.text, ...unfurl }, { retry: false, signal: deps.signal });
885
954
  } catch (err) {
886
955
  if (err instanceof SlackError && err.code === "transport") {
887
956
  const { detailPath, note } = persistOrDescribe(args.thread_body, persist);
@@ -923,7 +992,7 @@ export async function announce(
923
992
  const causeMessage = err instanceof Error ? err.message : String(err);
924
993
  return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
925
994
  }
926
- return { channel, ts, permalink, warning, detailTs };
995
+ return { channel, ts, permalink, warning, detailTs, detailUploaded: true };
927
996
  }
928
997
 
929
998
  let detailData: Record<string, unknown>;
@@ -931,14 +1000,14 @@ export async function announce(
931
1000
  detailData = await deps.apiCall(
932
1001
  "chat.postMessage",
933
1002
  deps.token,
934
- { channel, text: args.thread_body, thread_ts: ts },
1003
+ { channel, text: args.thread_body, thread_ts: ts, ...unfurl },
935
1004
  { retry: true, signal: deps.signal },
936
1005
  );
937
1006
  } catch (err) {
938
1007
  if (err instanceof SlackError && err.code === "msg_too_long") {
939
1008
  try {
940
1009
  const { detailTs } = await deliverDetailUpload(channel, ts, args.thread_body, deps);
941
- return { channel, ts, permalink, warning, detailTs };
1010
+ return { channel, ts, permalink, warning, detailTs, detailUploaded: true };
942
1011
  } catch (uploadErr) {
943
1012
  const causeMessage = uploadErr instanceof Error ? uploadErr.message : String(uploadErr);
944
1013
  return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
@@ -953,11 +1022,22 @@ export async function announce(
953
1022
  }
954
1023
 
955
1024
  export async function postMessage(
956
- args: { channel: string; text?: string; blocks?: unknown[]; thread_ts?: string; thread_body?: string },
1025
+ args: {
1026
+ channel: string;
1027
+ text?: string;
1028
+ blocks?: unknown[];
1029
+ thread_ts?: string;
1030
+ thread_body?: string;
1031
+ unfurl_links?: boolean;
1032
+ unfurl_media?: boolean;
1033
+ },
957
1034
  deps: CoreDeps & { uploadBytes: UploadBytes; thresholdChars: number; persist?: (body: string) => string },
958
1035
  ): Promise<MutationResult | AnnounceResult> {
959
1036
  if (args.thread_body !== undefined && args.thread_ts === undefined) {
960
- return announce({ channel: args.channel, text: args.text ?? "", thread_body: args.thread_body }, deps);
1037
+ return announce(
1038
+ { channel: args.channel, text: args.text ?? "", thread_body: args.thread_body },
1039
+ { ...deps, unfurl_links: args.unfurl_links, unfurl_media: args.unfurl_media },
1040
+ );
961
1041
  }
962
1042
 
963
1043
  if (args.thread_ts !== undefined) {
@@ -965,7 +1045,14 @@ export async function postMessage(
965
1045
  // the threshold/upload path only applies when composing plain mrkdwn from thread_body/text.
966
1046
  if (args.blocks !== undefined) {
967
1047
  return postPlain(
968
- { channel: args.channel, text: args.thread_body ?? args.text, blocks: args.blocks, thread_ts: args.thread_ts },
1048
+ {
1049
+ channel: args.channel,
1050
+ text: args.thread_body ?? args.text,
1051
+ blocks: args.blocks,
1052
+ thread_ts: args.thread_ts,
1053
+ unfurl_links: args.unfurl_links,
1054
+ unfurl_media: args.unfurl_media,
1055
+ },
969
1056
  deps,
970
1057
  );
971
1058
  }
@@ -980,7 +1067,10 @@ export async function postMessage(
980
1067
  // (server-side). This body is extension-composed detail, same as announce's detail leg, so
981
1068
  // it gets the same upload fallback instead of a bare throw.
982
1069
  try {
983
- return await postPlain({ channel: args.channel, text: body, thread_ts: args.thread_ts }, deps);
1070
+ return await postPlain(
1071
+ { channel: args.channel, text: body, thread_ts: args.thread_ts, unfurl_links: args.unfurl_links, unfurl_media: args.unfurl_media },
1072
+ deps,
1073
+ );
984
1074
  } catch (err) {
985
1075
  if (err instanceof SlackError && (err.code === "text_too_long" || err.code === "msg_too_long")) {
986
1076
  const { detailTs } = await deliverDetailUploadOrPersist(args.channel, args.thread_ts, body, deps);
@@ -990,5 +1080,8 @@ export async function postMessage(
990
1080
  }
991
1081
  }
992
1082
 
993
- return postPlain({ channel: args.channel, text: args.text, blocks: args.blocks }, deps);
1083
+ return postPlain(
1084
+ { channel: args.channel, text: args.text, blocks: args.blocks, unfurl_links: args.unfurl_links, unfurl_media: args.unfurl_media },
1085
+ deps,
1086
+ );
994
1087
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, a themed ASCII startup header, Opus 4.8 fast mode, and a provider-stall watchdog.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",