pi-quiver 6.7.0 → 6.7.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,11 @@ 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
+ ## v6.7.1 - 2026-10-01
12
+
13
+ - `slack_post`/`slack_update` explain native Slack representations for skill-required formatting, with complete readable fallback; otherwise plain content stays plain.
14
+ - `slack_post` announcements carry native blocks in the detail reply without upload fallback and preserve structured detail in JSON for existing-thread recovery.
15
+
11
16
  ## v6.7.0 - 2026-09-30
12
17
 
13
18
  - README: `swordHeader` note on why pi's built-in logo flashes before the sword appears and how `quietStartup` removes it.
@@ -40,6 +40,19 @@ import {
40
40
  } from "../lib/slack-core.ts";
41
41
  import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, resolveMentions, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
42
42
 
43
+ const SLACK_FORMATTING_GUIDANCE: { rules: string[]; example: string } = {
44
+ rules: [
45
+ "For slack_post and slack_update, represent only formatting required by the calling skill. Keep otherwise plain content plain; preserve required structure rather than flattening it to a prose paragraph.",
46
+ 'For slack_post and slack_update, use required lists as rich_text blocks containing rich_text_list with style: "bullet" or style: "ordered" and rich_text_section items. Preserve order and item boundaries; use line-separated text markers only in fallback, not as a substitute for the native list.',
47
+ "For slack_post and slack_update, represent required block quotations with rich_text_quote inside rich_text. Preserve quoted wording and supplied attribution. In text-only fallback, put > at the start of quoted lines and distinguish quotation from commentary; retain the required native quotation.",
48
+ "For slack_post and slack_update, inside rich_text represent required emphasis with text-element style properties bold, italic, strike; use code style for inline code, rich_text_preformatted for multiline code, and native link elements for labeled links. Keep mrkdwn delimiters out of native text leaves. On text-only surfaces, use *bold*, _italic_, ~strike~, backticks for inline code, triple-backticks for multiline code, and <url|label> for labeled links; code alone does not introduce blocks.",
49
+ "For slack_post and slack_update, preserve literal code. In text-only code and fallback, supply \\@name for literal mention-like names, including echo \\@alice for literal echo @alice; backticks do not suppress mention scanning. Rely on name lookup only in text and thread_body; treat @name in blocks as literal.",
50
+ "For slack_post and slack_update, when authoring blocks also supply complete readable fallback with substantive content, list item boundaries, and quotation/commentary distinctions for notifications and screen readers. Pair blocks with text for ordinary posts and updates, with thread_body ?? text for existing-thread replies, and with thread_body for announcement detail without thread_ts. Keep the announcement text headline text-only; blocks belong only to its detail. Without thread_body, blocks do not create an announcement. Pass caller-authored blocks through for Slack validation; existing blocks-only callers remain supported.",
51
+ "For slack_post and slack_update, keep blocks-bearing threaded replies and announcement details as messages: they bypass the local MAX_TEXT_LENGTH fallback guard and never use threshold-based or msg_too_long upload fallback.",
52
+ ],
53
+ example: 'Native list example: {"text":"- First\\n- Second","blocks":[{"type":"rich_text","elements":[{"type":"rich_text_list","style":"bullet","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"First"}]},{"type":"rich_text_section","elements":[{"type":"text","text":"Second"}]}]}]}]}',
54
+ };
55
+
43
56
  const IDENTITY = Type.Union([Type.Literal("user"), Type.Literal("bot")], {
44
57
  description: 'Which token to act as: "user" (a real person, needed for slack_search/slack_thread) or "bot" (an app identity). Determines which credential source is used and whose name shows as the author.',
45
58
  });
@@ -302,14 +315,15 @@ export default function slackExtension(pi: ExtensionAPI) {
302
315
  label: "Slack Post",
303
316
  promptSnippet: "Post a Slack message, reply, or headline+detail announcement",
304
317
  description:
305
- "Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name, channel ID, @name, or user ID (DM). 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.",
318
+ "Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name, channel ID, @name, or user ID (DM). 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 the detail `blocks` with `thread_body` fallback (or text-only `thread_body`) as the first threaded reply in the same call; for text-only detail, 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 returned error supplies recovery-artifact details. `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." + "\n\n" + SLACK_FORMATTING_GUIDANCE.rules.join("\n") + "\n\n" + SLACK_FORMATTING_GUIDANCE.example,
319
+ promptGuidelines: SLACK_FORMATTING_GUIDANCE.rules,
306
320
  parameters: Type.Object({
307
321
  as: IDENTITY,
308
322
  channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
309
- text: Type.Optional(Type.String({ description: "Message text, or the announce headline when thread_body is set" })),
310
- blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
323
+ text: Type.Optional(Type.String({ description: "Message fallback, or text-only announcement headline when thread_body is set without thread_ts" })),
324
+ blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON for the posted body, or detail reply in combined announcements; passed through unvalidated" })),
311
325
  thread_ts: Type.Optional(Type.String({ description: "Reply into this existing thread instead of posting a new headline" })),
312
- thread_body: Type.Optional(Type.String({ description: "Detail body for an announce headline, or the reply body when thread_ts is set" })),
326
+ thread_body: Type.Optional(Type.String({ description: "Detail fallback in announce mode without thread_ts, or reply fallback when thread_ts is set" })),
313
327
  unfurl_links: Type.Optional(
314
328
  Type.Boolean({ description: "Slack unfurls link previews by default; pass false to suppress text-link previews for this message." }),
315
329
  ),
@@ -384,13 +398,14 @@ export default function slackExtension(pi: ExtensionAPI) {
384
398
  label: "Slack Update",
385
399
  promptSnippet: "Edit an existing Slack message",
386
400
  description:
387
- 'Edit a message via chat.update, as `as: "user"` or `as: "bot"`. `channel` accepts #name, channel ID, @name, or user ID (DM). Only the identity that originally posted the message can edit it (Slack constraint; surfaced as an error otherwise). Accepts `text` and/or `blocks` (Block Kit JSON, unvalidated).',
401
+ 'Edit a message via chat.update, as `as: "user"` or `as: "bot"`. `channel` accepts #name, channel ID, @name, or user ID (DM). Only the identity that originally posted the message can edit it (Slack constraint; surfaced as an error otherwise). Accepts `text` and/or `blocks` (Block Kit JSON, unvalidated).' + "\n\n" + SLACK_FORMATTING_GUIDANCE.rules.join("\n") + "\n\n" + SLACK_FORMATTING_GUIDANCE.example,
402
+ promptGuidelines: SLACK_FORMATTING_GUIDANCE.rules,
388
403
  parameters: Type.Object({
389
404
  as: IDENTITY,
390
405
  channel: Type.String({ description: "#name, channel ID, @name, or user ID (DM)" }),
391
406
  ts: Type.String({ description: "Timestamp of the message to edit" }),
392
- text: Type.Optional(Type.String()),
393
- blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
407
+ text: Type.Optional(Type.String({ description: "Readable message fallback with blocks, or text-only message body" })),
408
+ blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON for the edited message body, passed through unvalidated" })),
394
409
  }),
395
410
  async execute(_toolCallId, params, signal) {
396
411
  return guarded(async () => {
package/lib/slack-core.ts CHANGED
@@ -800,7 +800,7 @@ export async function postPlain(
800
800
  },
801
801
  deps: CoreDeps,
802
802
  ): Promise<MutationResult> {
803
- assertTextWithinLimit(args.text);
803
+ if (!(args.blocks && args.thread_ts !== undefined)) assertTextWithinLimit(args.text);
804
804
 
805
805
  const params: Record<string, unknown> = { channel: args.channel };
806
806
  if (args.text !== undefined) params.text = args.text;
@@ -947,13 +947,13 @@ export function formatUnresolvedSuffix(
947
947
  return suffix;
948
948
  }
949
949
 
950
- export function persistDetail(body: string): string {
950
+ export function persistDetail(body: string, format: "md" | "json" = "md"): string {
951
951
  const dir = join(tmpdir(), "pi-slack");
952
952
  mkdirSync(dir, { recursive: true });
953
953
  const hash = createHash("sha256").update(body).digest("hex").slice(0, 8);
954
954
  // Same-millisecond re-invocation with identical content hashes to the same filename and
955
955
  // overwrites with byte-identical bytes - harmless, so no collision handling is needed here.
956
- const path = join(dir, `${Date.now()}-${hash}-detail.md`);
956
+ const path = join(dir, `${Date.now()}-${hash}-detail.${format}`);
957
957
  writeFileSync(path, body, "utf8");
958
958
  return path;
959
959
  }
@@ -1007,7 +1007,7 @@ async function deliverDetailUploadOrPersist(
1007
1007
  channel: string,
1008
1008
  threadTs: string,
1009
1009
  body: string,
1010
- deps: CoreDeps & { uploadBytes: UploadBytes; persist?: (body: string) => string },
1010
+ deps: CoreDeps & { uploadBytes: UploadBytes; persist?: (body: string, format?: "md" | "json") => string },
1011
1011
  ): Promise<{ detailTs?: string }> {
1012
1012
  try {
1013
1013
  return await deliverDetailUpload(channel, threadTs, body, deps);
@@ -1028,14 +1028,22 @@ async function deliverDetailUploadOrPersist(
1028
1028
  * also failed and the (now unrecoverable) detail body's length, and omit detailPath from the
1029
1029
  * caller's structured error data.
1030
1030
  */
1031
- function persistOrDescribe(body: string, persist: (body: string) => string): { detailPath?: string; note: string } {
1031
+ function persistOrDescribe(
1032
+ body: string,
1033
+ persist: (body: string, format?: "md" | "json") => string,
1034
+ format: "md" | "json" = "md",
1035
+ knownHeadline = true,
1036
+ ): { detailPath?: string; note: string } {
1032
1037
  try {
1033
- const detailPath = persist(body);
1034
- return { detailPath, note: `the full detail was saved to ${detailPath}` };
1038
+ const detailPath = persist(body, format);
1039
+ const recovery = format === "json"
1040
+ ? `${knownHeadline ? "; recover with saved text as thread_body and saved blocks using the known thread_ts" : "; locate the headline in the channel and obtain its ts before recovering with saved text as thread_body and saved blocks using that thread_ts"}; correct rejected blocks before recovery, preserving the original artifact`
1041
+ : "";
1042
+ return { detailPath, note: `the full detail was saved to ${detailPath}${format === "json" ? " (json)" : ""}${recovery}` };
1035
1043
  } catch (err) {
1036
1044
  const msg = err instanceof Error ? err.message : String(err);
1037
1045
  return {
1038
- note: `persisting the ${body.length}-char detail body ALSO failed (${msg}) - the detail is unrecoverable`,
1046
+ note: `persisting the ${body.length}-char detail ${format === "json" ? "payload" : "body"} ALSO failed (${msg}) - the detail is unrecoverable`,
1039
1047
  };
1040
1048
  }
1041
1049
  }
@@ -1055,9 +1063,10 @@ async function recoverFromDetailFailure(
1055
1063
  ts: string,
1056
1064
  permalink: string | undefined,
1057
1065
  deps: CoreDeps,
1058
- persist: (body: string) => string,
1066
+ persist: (body: string, format?: "md" | "json") => string,
1067
+ format: "md" | "json" = "md",
1059
1068
  ): Promise<never> {
1060
- const { detailPath, note } = persistOrDescribe(detailBody, persist);
1069
+ const { detailPath, note } = persistOrDescribe(detailBody, persist, format);
1061
1070
 
1062
1071
  let markerFailureMessage: string | undefined;
1063
1072
  try {
@@ -1085,17 +1094,19 @@ async function recoverFromDetailFailure(
1085
1094
  }
1086
1095
 
1087
1096
  export async function announce(
1088
- args: { channel: string; text: string; thread_body: string },
1097
+ args: { channel: string; text: string; thread_body: string; blocks?: unknown[] },
1089
1098
  deps: CoreDeps & {
1090
1099
  uploadBytes: UploadBytes;
1091
1100
  thresholdChars: number;
1092
- persist?: (body: string) => string;
1101
+ persist?: (body: string, format?: "md" | "json") => string;
1093
1102
  unfurl_links?: boolean;
1094
1103
  unfurl_media?: boolean;
1095
1104
  },
1096
1105
  ): Promise<AnnounceResult> {
1097
1106
  assertHeadline(args.text);
1098
1107
  const persist = deps.persist ?? persistDetail;
1108
+ const detailFormat = args.blocks ? "json" : "md";
1109
+ const detailBody = args.blocks ? JSON.stringify({ text: args.thread_body, blocks: args.blocks }) : args.thread_body;
1099
1110
  const unfurl: Record<string, unknown> = {};
1100
1111
  if (deps.unfurl_links !== undefined) unfurl.unfurl_links = deps.unfurl_links;
1101
1112
  if (deps.unfurl_media !== undefined) unfurl.unfurl_media = deps.unfurl_media;
@@ -1105,7 +1116,7 @@ export async function announce(
1105
1116
  headlineData = await deps.apiCall("chat.postMessage", deps.token, { channel: args.channel, text: args.text, ...unfurl }, { retry: false, signal: deps.signal });
1106
1117
  } catch (err) {
1107
1118
  if (err instanceof SlackError && err.code === "transport") {
1108
- const { detailPath, note } = persistOrDescribe(args.thread_body, persist);
1119
+ const { detailPath, note } = persistOrDescribe(detailBody, persist, detailFormat, false);
1109
1120
  throw new SlackError(
1110
1121
  "outcome_unknown",
1111
1122
  `Headline post to ${args.channel} may or may not have reached Slack (${err.message}); check the channel before re-invoking slack_post. ${capitalize(note)}.`,
@@ -1125,24 +1136,24 @@ export async function announce(
1125
1136
  try {
1126
1137
  ts = requireResponseString(headlineData, "chat.postMessage", "ts");
1127
1138
  } catch (err) {
1128
- const { detailPath, note } = persistOrDescribe(args.thread_body, persist);
1139
+ const { detailPath, note } = persistOrDescribe(detailBody, persist, detailFormat, false);
1129
1140
  const detail = err instanceof Error ? err.message : String(err);
1130
1141
  throw new SlackError(
1131
1142
  "outcome_unknown",
1132
- `Headline post to ${channel} WAS accepted by Slack (ok:true) but the response was unparseable (${detail}); do NOT re-invoke slack_post - thread the detail manually. ${capitalize(note)}.`,
1143
+ `Headline post to ${channel} WAS accepted by Slack (ok:true) but the response was unparseable (${detail}); ${args.blocks ? "do NOT repost the headline - recover only into its thread" : "do NOT re-invoke slack_post - thread the detail manually"}. ${capitalize(note)}.`,
1133
1144
  { channel, ...(detailPath ? { detailPath } : {}) },
1134
1145
  );
1135
1146
  }
1136
1147
 
1137
1148
  const { permalink, warning } = await withPermalink(deps, channel, ts);
1138
1149
 
1139
- if (linkCollapsedLength(args.thread_body) > deps.thresholdChars) {
1150
+ if (!args.blocks && linkCollapsedLength(args.thread_body) > deps.thresholdChars) {
1140
1151
  let detailTs: string | undefined;
1141
1152
  try {
1142
1153
  ({ detailTs } = await deliverDetailUpload(channel, ts, args.thread_body, deps));
1143
1154
  } catch (err) {
1144
1155
  const causeMessage = err instanceof Error ? err.message : String(err);
1145
- return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
1156
+ return recoverFromDetailFailure(causeMessage, args.text, detailBody, channel, ts, permalink, deps, persist, detailFormat);
1146
1157
  }
1147
1158
  return { channel, ts, permalink, warning, detailTs, detailUploaded: true };
1148
1159
  }
@@ -1152,21 +1163,21 @@ export async function announce(
1152
1163
  detailData = await deps.apiCall(
1153
1164
  "chat.postMessage",
1154
1165
  deps.token,
1155
- { channel, text: args.thread_body, thread_ts: ts, ...unfurl },
1166
+ { channel, text: args.thread_body, thread_ts: ts, ...(args.blocks ? { blocks: args.blocks } : {}), ...unfurl },
1156
1167
  { retry: true, signal: deps.signal },
1157
1168
  );
1158
1169
  } catch (err) {
1159
- if (err instanceof SlackError && err.code === "msg_too_long") {
1170
+ if (!args.blocks && err instanceof SlackError && err.code === "msg_too_long") {
1160
1171
  try {
1161
1172
  const { detailTs } = await deliverDetailUpload(channel, ts, args.thread_body, deps);
1162
1173
  return { channel, ts, permalink, warning, detailTs, detailUploaded: true };
1163
1174
  } catch (uploadErr) {
1164
1175
  const causeMessage = uploadErr instanceof Error ? uploadErr.message : String(uploadErr);
1165
- return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
1176
+ return recoverFromDetailFailure(causeMessage, args.text, detailBody, channel, ts, permalink, deps, persist, detailFormat);
1166
1177
  }
1167
1178
  }
1168
- const causeMessage = err instanceof Error ? err.message : String(err);
1169
- return recoverFromDetailFailure(causeMessage, args.text, args.thread_body, channel, ts, permalink, deps, persist);
1179
+ const causeMessage = args.blocks && err instanceof SlackError && !err.message.startsWith(`${err.code}:`) ? `${err.code}: ${err.message}` : err instanceof Error ? err.message : String(err);
1180
+ return recoverFromDetailFailure(causeMessage, args.text, detailBody, channel, ts, permalink, deps, persist, detailFormat);
1170
1181
  }
1171
1182
 
1172
1183
  const detailTs = requireResponseString(detailData, "chat.postMessage", "ts");
@@ -1183,11 +1194,11 @@ export async function postMessage(
1183
1194
  unfurl_links?: boolean;
1184
1195
  unfurl_media?: boolean;
1185
1196
  },
1186
- deps: CoreDeps & { uploadBytes: UploadBytes; thresholdChars: number; persist?: (body: string) => string },
1197
+ deps: CoreDeps & { uploadBytes: UploadBytes; thresholdChars: number; persist?: (body: string, format?: "md" | "json") => string },
1187
1198
  ): Promise<MutationResult | AnnounceResult> {
1188
1199
  if (args.thread_body !== undefined && args.thread_ts === undefined) {
1189
1200
  return announce(
1190
- { channel: args.channel, text: args.text ?? "", thread_body: args.thread_body },
1201
+ { channel: args.channel, text: args.text ?? "", thread_body: args.thread_body, blocks: args.blocks },
1191
1202
  { ...deps, unfurl_links: args.unfurl_links, unfurl_media: args.unfurl_media },
1192
1203
  );
1193
1204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "6.7.0",
3
+ "version": "6.7.1",
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",