pi-quiver 6.1.0 → 6.2.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 +4 -0
- package/README.md +2 -2
- package/extensions/slack.ts +9 -4
- package/lib/slack-core.ts +96 -4
- package/package.json +1 -1
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
|
+
## v6.2.0 - 2026-09-15
|
|
12
|
+
|
|
13
|
+
- `slack_thread` flattens Block Kit blocks in its default rendering (blocks joined by ` / `, `[<type>]` for unknown types) and gains an opt-in `raw: true` mode returning thread messages as a pure JSON array for `slack_update` round-trips; both modes go through the existing size gate. (#21)
|
|
14
|
+
|
|
11
15
|
## v6.1.0 - 2026-09-15
|
|
12
16
|
|
|
13
17
|
- `slack`: tokens also resolve from a fixed per-user file - `$XDG_CONFIG_HOME/pi-quiver/.env` or `~/.config/pi-quiver/.env` (Linux/macOS), `%APPDATA%\pi-quiver\.env` (Windows) - after process env, the repo `.env`, and the primary checkout's `.env`. Every rung now falls through when it lacks the key; a repo `.env` holding only the bot token no longer blocks the user token. Breaking: an unreadable `.env` at any rung propagates the raw filesystem error instead of collapsing to `missing_token`; the `missing_token` message now lists every checked path (#22).
|
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ A 300 KB changelog page never touches your context window - you get a preview an
|
|
|
70
70
|
| `extensions/sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
|
|
71
71
|
| `extensions/fast-mode.ts` | `/fast` | Inject Anthropic fast-mode (`speed: "fast"` + `anthropic-beta: fast-mode-2026-02-01`) into every Claude Opus 4.8 / Opus 5 request, any thinking level. `--fast` flag + `/fast [on\|off\|status]`. OFF by default. |
|
|
72
72
|
| `extensions/provider-stall-watchdog.ts` | - | Opt-in provider-stall recovery, in two tiers: a pre-first-event deadline (`firstEventMs`, 20s) on every provider request in every mode, and the mid-stream pair (warn at 2 min, recover at 4 min) in TUI runs only. Policy D offers each stall to Pi's retry loop until the stall retry budget (`maxStallRetries`, default = `retry.maxRetries`) is exhausted. OFF by default. |
|
|
73
|
-
| `extensions/slack.ts` | `slack_search`, `slack_thread`, `slack_post`, `slack_update`, `slack_delete`, `slack_pin`, `slack_upload`, `slack_cache_refresh` | Context-safe Slack search/threads/posting with dual `user`/`bot` token identities, a workspace-keyed channel/user name->ID cache, DM targets by `@name` / user ID, fetch-style output size gating, and a transactional headline+detail-thread announce protocol with a documented recovery path. OFF by default. Behavior lives in `lib/slack-core.ts` and `lib/slack-cache.ts`. |
|
|
73
|
+
| `extensions/slack.ts` | `slack_search`, `slack_thread`, `slack_post`, `slack_update`, `slack_delete`, `slack_pin`, `slack_upload`, `slack_cache_refresh` | Context-safe Slack search/threads/posting with dual `user`/`bot` token identities, Block Kit flattening and optional raw JSON output for threads, a workspace-keyed channel/user name->ID cache, DM targets by `@name` / user ID, fetch-style output size gating, and a transactional headline+detail-thread announce protocol with a documented recovery path. OFF by default. Behavior lives in `lib/slack-core.ts` and `lib/slack-cache.ts`. |
|
|
74
74
|
|
|
75
75
|
Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md), [doc/doc-to-md.md](doc/doc-to-md.md), [doc/slack.md](doc/slack.md).
|
|
76
76
|
|
|
@@ -280,7 +280,7 @@ Operational notes:
|
|
|
280
280
|
| `botTokenEnv` | `SLACK_BOT_TOKEN` | Env var name holding the bot token. |
|
|
281
281
|
| `uploadThresholdChars` | `4000` | Link-collapsed length above which an announce/thread detail body is delivered as a file upload instead of inline text. |
|
|
282
282
|
|
|
283
|
-
Each setting can also be overridden per-process via `PI_QUIVER_SLACK_ENABLED`, `PI_QUIVER_SLACK_CACHE_PATH`, `PI_QUIVER_SLACK_USER_TOKEN_ENV`, `PI_QUIVER_SLACK_BOT_TOKEN_ENV`, and `PI_QUIVER_SLACK_UPLOAD_THRESHOLD_CHARS` - applied on top of the resolved `settings.json` layers, same override rung the extension's config resolver defines. `userTokenCommand` and `userTokenCommandTimeoutSeconds` are settings-only: pi executes the argv directly, without a shell, on every user-identity Slack tool call. For example, macOS Keychain can supply the token with `"userTokenCommand": ["security", "find-generic-password", "-s", "slack-user-token", "-w"]`. Its stdout is the token; empty output, nonzero exit, or timeout is a sanitized hard error and never falls back to `userTokenEnv`. Without the command, tokens resolve per call from process env, then the repo `.env`, then the primary checkout's `.env` (linked worktrees), then a per-user `~/.config/pi-quiver/.env` (`$XDG_CONFIG_HOME` / `%APPDATA%` aware); a file lacking the key falls through to the next rung - see [doc/slack.md](doc/slack.md). Bot resolution is unchanged. Restart pi after changing Slack settings because the extension captures them at session start. Full reference incl. cache layering, the announce protocol, and the `search.messages`/`conversations.replies` throttle caveats: [doc/slack.md](doc/slack.md).
|
|
283
|
+
Each setting can also be overridden per-process via `PI_QUIVER_SLACK_ENABLED`, `PI_QUIVER_SLACK_CACHE_PATH`, `PI_QUIVER_SLACK_USER_TOKEN_ENV`, `PI_QUIVER_SLACK_BOT_TOKEN_ENV`, and `PI_QUIVER_SLACK_UPLOAD_THRESHOLD_CHARS` - applied on top of the resolved `settings.json` layers, same override rung the extension's config resolver defines. `userTokenCommand` and `userTokenCommandTimeoutSeconds` are settings-only: pi executes the argv directly, without a shell, on every user-identity Slack tool call. For example, macOS Keychain can supply the token with `"userTokenCommand": ["security", "find-generic-password", "-s", "slack-user-token", "-w"]`. Its stdout is the token; empty output, nonzero exit, or timeout is a sanitized hard error and never falls back to `userTokenEnv`. Without the command, tokens resolve per call from process env, then the repo `.env`, then the primary checkout's `.env` (linked worktrees), then a per-user `~/.config/pi-quiver/.env` (`$XDG_CONFIG_HOME` / `%APPDATA%` aware); a file lacking the key falls through to the next rung - see [doc/slack.md](doc/slack.md). Bot resolution is unchanged. Restart pi after changing Slack settings because the extension captures them at session start. `slack_thread` flattens Block Kit blocks by default and accepts `raw: true` for JSON output suitable for `slack_update` round-trips. Full reference incl. cache layering, the announce protocol, thread output modes, and the `search.messages`/`conversations.replies` throttle caveats: [doc/slack.md](doc/slack.md).
|
|
284
284
|
|
|
285
285
|
### doc_to_md settings
|
|
286
286
|
|
package/extensions/slack.ts
CHANGED
|
@@ -161,7 +161,8 @@ export function searchResultText(result: SearchResult): string {
|
|
|
161
161
|
return `${result.output}\n\ntotal: ${result.total} | page: ${result.page} of ${result.pageCount}`;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
export function threadResultText(result: ThreadResult): string {
|
|
164
|
+
export function threadResultText(result: ThreadResult, raw = false): string {
|
|
165
|
+
if (raw) return result.output;
|
|
165
166
|
const lines = [result.output, "", `complete: ${result.complete}`];
|
|
166
167
|
if (!result.complete && result.nextCursor) lines.push(`next_cursor: ${result.nextCursor}`);
|
|
167
168
|
if (result.caveat) lines.push(result.caveat);
|
|
@@ -269,21 +270,25 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
269
270
|
label: "Slack Thread",
|
|
270
271
|
promptSnippet: "Read all replies in a Slack thread",
|
|
271
272
|
description:
|
|
272
|
-
'Read a Slack thread via conversations.replies. Always uses the "user" identity (no `as` param). Provide either `channel` (#name, channel ID, @name, or user ID (DM)) plus `ts`, or a `permalink` (parsed for channel+ts). Paginates by cursor until Slack reports no more replies or a cap of 50 pages / 5,000 messages is hit; the result carries a `complete` flag and a resumable `next_cursor` when capped. Caveat: since 2025-05-29, conversations.replies is rate-limited to ~1 request/minute (limit capped at 15) for apps that are neither Marketplace-listed nor classified internal - hitting that throttle mid-pagination returns the messages collected so far plus a caveat and a resumable cursor instead of spinning.
|
|
273
|
+
'Read a Slack thread via conversations.replies. Always uses the "user" identity (no `as` param). Provide either `channel` (#name, channel ID, @name, or user ID (DM)) plus `ts`, or a `permalink` (parsed for channel+ts). Paginates by cursor until Slack reports no more replies or a cap of 50 pages / 5,000 messages is hit; the result carries a `complete` flag and a resumable `next_cursor` when capped. Caveat: since 2025-05-29, conversations.replies is rate-limited to ~1 request/minute (limit capped at 15) for apps that are neither Marketplace-listed nor classified internal - hitting that throttle mid-pagination returns the messages collected so far plus a caveat and a resumable cursor instead of spinning. Default output is one line per message; messages with Block Kit blocks render those blocks flattened (blocks joined by " / ") instead of the text fallback. `raw: true` is for block extraction and slack_update round-trips: it returns the thread messages as a pure JSON array (no status trailer - `complete`/`next_cursor` stay in the result details and are not visible in the content, so an incomplete raw thread is a partial array with no in-content signal: try JSON.parse, on failure read the file named in the truncation line, and use default mode when completeness matters). Both modes are size-gated like slack_search: over the cap the full output is written to a temp file.',
|
|
273
274
|
parameters: Type.Object({
|
|
274
275
|
channel: Type.Optional(Type.String({ description: "#name, channel ID, @name, or user ID (DM)" })),
|
|
275
276
|
ts: Type.Optional(Type.String({ description: "Thread parent timestamp" })),
|
|
276
277
|
permalink: Type.Optional(Type.String({ description: "A Slack message permalink URL to parse channel+ts from" })),
|
|
277
278
|
cursor: Type.Optional(Type.String({ description: "Resume pagination from a next_cursor returned by a prior capped call" })),
|
|
279
|
+
raw: Type.Optional(Type.Boolean({ description: "Return thread messages as a JSON array of raw message objects (blocks untouched) instead of compact lines" })),
|
|
278
280
|
}),
|
|
279
281
|
async execute(_toolCallId, params, signal) {
|
|
280
282
|
return guarded(async () => {
|
|
281
283
|
const { deps, cacheCtx } = await resolveCall("user", cfg, ctx, signal, repoRoot);
|
|
282
284
|
const channel =
|
|
283
285
|
params.permalink === undefined && params.channel !== undefined ? await resolveChannel(params.channel, cacheCtx) : undefined;
|
|
284
|
-
const result = await readThread(
|
|
286
|
+
const result = await readThread(
|
|
287
|
+
{ channel, ts: params.ts, permalink: params.permalink, cursor: params.cursor, raw: params.raw },
|
|
288
|
+
deps,
|
|
289
|
+
);
|
|
285
290
|
return {
|
|
286
|
-
content: [{ type: "text" as const, text: threadResultText(result) }],
|
|
291
|
+
content: [{ type: "text" as const, text: threadResultText(result, params.raw === true) }],
|
|
287
292
|
details: result,
|
|
288
293
|
};
|
|
289
294
|
}, "user");
|
package/lib/slack-core.ts
CHANGED
|
@@ -567,15 +567,107 @@ export async function searchMessages(
|
|
|
567
567
|
return { ...gated, total, page, pageCount };
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
+
function inlineElementText(element: unknown): string {
|
|
571
|
+
if (typeof element !== "object" || element === null) return "[unknown]";
|
|
572
|
+
const el = element as Record<string, unknown>;
|
|
573
|
+
const type = typeof el.type === "string" ? el.type : "unknown";
|
|
574
|
+
switch (type) {
|
|
575
|
+
case "text":
|
|
576
|
+
return typeof el.text === "string" ? el.text : "[text]";
|
|
577
|
+
case "user":
|
|
578
|
+
return typeof el.user_id === "string" ? `<@${el.user_id}>` : "[user]";
|
|
579
|
+
case "channel":
|
|
580
|
+
return typeof el.channel_id === "string" ? `<#${el.channel_id}>` : "[channel]";
|
|
581
|
+
case "link":
|
|
582
|
+
if (typeof el.text === "string" && el.text.length > 0) return el.text;
|
|
583
|
+
return typeof el.url === "string" ? el.url : "[link]";
|
|
584
|
+
case "emoji":
|
|
585
|
+
return typeof el.name === "string" ? `:${el.name}:` : "[emoji]";
|
|
586
|
+
default:
|
|
587
|
+
return `[${type}]`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function richTextContainerText(element: unknown): string {
|
|
592
|
+
if (typeof element !== "object" || element === null) return "[unknown]";
|
|
593
|
+
const el = element as Record<string, unknown>;
|
|
594
|
+
const type = typeof el.type === "string" ? el.type : "unknown";
|
|
595
|
+
switch (type) {
|
|
596
|
+
case "rich_text_section":
|
|
597
|
+
case "rich_text_quote":
|
|
598
|
+
case "rich_text_preformatted":
|
|
599
|
+
return Array.isArray(el.elements) ? el.elements.map(inlineElementText).join("") : "";
|
|
600
|
+
case "rich_text_list":
|
|
601
|
+
return Array.isArray(el.elements)
|
|
602
|
+
? el.elements.map(richTextContainerText).filter((s) => s.length > 0).join(" ")
|
|
603
|
+
: "";
|
|
604
|
+
default:
|
|
605
|
+
return `[${type}]`;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function contextElementText(element: unknown): string {
|
|
610
|
+
if (typeof element !== "object" || element === null) return "[unknown]";
|
|
611
|
+
const el = element as Record<string, unknown>;
|
|
612
|
+
const type = typeof el.type === "string" ? el.type : "unknown";
|
|
613
|
+
if (type === "plain_text" || type === "mrkdwn") {
|
|
614
|
+
return typeof el.text === "string" ? el.text : `[${type}]`;
|
|
615
|
+
}
|
|
616
|
+
return `[${type}]`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function blockText(block: unknown): string {
|
|
620
|
+
if (typeof block !== "object" || block === null) return "[unknown]";
|
|
621
|
+
const b = block as Record<string, unknown>;
|
|
622
|
+
const type = typeof b.type === "string" ? b.type : "unknown";
|
|
623
|
+
switch (type) {
|
|
624
|
+
case "header": {
|
|
625
|
+
const text = (b.text as Record<string, unknown> | undefined)?.text;
|
|
626
|
+
return typeof text === "string" ? text : "";
|
|
627
|
+
}
|
|
628
|
+
case "section": {
|
|
629
|
+
const parts: string[] = [];
|
|
630
|
+
const text = (b.text as Record<string, unknown> | undefined)?.text;
|
|
631
|
+
if (typeof text === "string" && text.length > 0) parts.push(text);
|
|
632
|
+
if (Array.isArray(b.fields)) {
|
|
633
|
+
for (const field of b.fields) {
|
|
634
|
+
const fieldText = (field as Record<string, unknown> | undefined)?.text;
|
|
635
|
+
if (typeof fieldText === "string" && fieldText.length > 0) parts.push(fieldText);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return parts.join(" ");
|
|
639
|
+
}
|
|
640
|
+
case "context":
|
|
641
|
+
return Array.isArray(b.elements)
|
|
642
|
+
? b.elements.map(contextElementText).filter((s) => s.length > 0).join(" ")
|
|
643
|
+
: "";
|
|
644
|
+
case "rich_text":
|
|
645
|
+
return Array.isArray(b.elements)
|
|
646
|
+
? b.elements.map(richTextContainerText).filter((s) => s.length > 0).join(" ")
|
|
647
|
+
: "";
|
|
648
|
+
default:
|
|
649
|
+
return `[${type}]`;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function renderBlocks(blocks: unknown[]): string {
|
|
654
|
+
return blocks
|
|
655
|
+
.map(blockText)
|
|
656
|
+
.filter((s) => s.length > 0)
|
|
657
|
+
.join(" / ")
|
|
658
|
+
.replace(/\r?\n/g, " ");
|
|
659
|
+
}
|
|
660
|
+
|
|
570
661
|
function renderThreadLine(message: Record<string, unknown>): string {
|
|
571
662
|
const author = typeof message.user === "string" ? message.user : String(message.username ?? "unknown");
|
|
572
663
|
const ts = String(message.ts ?? "");
|
|
573
|
-
const
|
|
574
|
-
|
|
664
|
+
const flattened = Array.isArray(message.blocks) && message.blocks.length > 0 ? renderBlocks(message.blocks) : "";
|
|
665
|
+
const body = (flattened.length > 0 ? flattened : typeof message.text === "string" ? message.text : "").replace(/\r?\n/g, " ");
|
|
666
|
+
return `${author} | ${ts} | ${body}`;
|
|
575
667
|
}
|
|
576
668
|
|
|
577
669
|
export async function readThread(
|
|
578
|
-
args: { channel?: string; ts?: string; permalink?: string; cursor?: string },
|
|
670
|
+
args: { channel?: string; ts?: string; permalink?: string; cursor?: string; raw?: boolean },
|
|
579
671
|
deps: CoreDeps,
|
|
580
672
|
): Promise<ThreadResult> {
|
|
581
673
|
let channel = args.channel;
|
|
@@ -641,7 +733,7 @@ export async function readThread(
|
|
|
641
733
|
cursor = fetchedCursor;
|
|
642
734
|
}
|
|
643
735
|
|
|
644
|
-
const rendered = messages.map(renderThreadLine).join("\n");
|
|
736
|
+
const rendered = args.raw === true ? JSON.stringify(messages, null, 2) : messages.map(renderThreadLine).join("\n");
|
|
645
737
|
const gated = gateOutput(rendered, "thread");
|
|
646
738
|
return { ...gated, complete, nextCursor, caveat, messageCount: messages.length };
|
|
647
739
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-quiver",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.2.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",
|