pi-quiver 6.1.0 → 6.3.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 +8 -0
- package/README.md +3 -3
- package/extensions/session-name.ts +40 -31
- 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,14 @@ 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.3.0 - 2026-09-16
|
|
12
|
+
|
|
13
|
+
- `session-name` Herdr sink claims any numeric tab label (optionally behind one `* `) at any position instead of only the label equal to the tab's live position, so tabs reordered before the first auto-name - or after a `/new` restore - get named; the claim resets at every `session_start` (`/new`, resume, fork behave like a fresh process) and a tab that reverts to a bare number is re-claimed on the next turn. The extension never writes a digits-only label (`1234` -> `#1234`) and the naming prompt asks for `PR 1234` / `issue 123` / `ticket ABC-123` over a bare ID.
|
|
14
|
+
|
|
15
|
+
## v6.2.0 - 2026-09-15
|
|
16
|
+
|
|
17
|
+
- `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)
|
|
18
|
+
|
|
11
19
|
## v6.1.0 - 2026-09-15
|
|
12
20
|
|
|
13
21
|
- `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
|
|
|
@@ -200,7 +200,7 @@ survive untouched.
|
|
|
200
200
|
|
|
201
201
|
`sessionAutoName.enabled` makes one extra short LLM call per session (once, after the first turn) to title it; `false` (default) makes no model calls. `rules` appends house conventions to the naming prompt (later rules win when they conflict with the built-ins). Literal, case-insensitive `deny` phrases are stripped from every name; whitespace inside a phrase is loose, so `"acme corp"` also catches `AcmeCorp`. `revisitFirstTurn` re-evaluates the name once that many model round trips have completed, while `revisitEveryTurns` does so at every multiple; both default to `0` (off) because each revisit costs another short LLM call. For example, `10` and `100` mark round trips 10, 100, 200, 300. Revisits only run when the agent has fully settled (idle, nothing queued) - an automated multi-turn run such as a subagent chain is never renamed or delayed mid-flight; cadence points it crossed fire once, at the settle. A machine-generated name is replaced when stale. A name set by a human is never overwritten: the extension strongly prefers it, and announces a suggestion only when the work has clearly moved on. Counts come from the persisted transcript, so they survive resume.
|
|
202
202
|
|
|
203
|
-
`herdrTab` (default `true`) mirrors the same curated label to the Herdr tab bar over Herdr's unix socket, independent of `ghosttyTab` - either sink can be toggled off without affecting the other.
|
|
203
|
+
`herdrTab` (default `true`) mirrors the same curated label to the Herdr tab bar over Herdr's unix socket, independent of `ghosttyTab` - either sink can be toggled off without affecting the other. A tab is claimable while its label is a bare number (`1`, `4`, ...) at any position - Herdr's vocabulary for "unnamed" - so reordering tabs before or after naming never matters; a tab you deliberately name `3` is taken over too. A non-numeric label is treated as a human's and is never overwritten; the check repeats every turn, so renumbering the tab by hand hands it back. Exactly one leading `* ` (herdr-ntfy-notify's armed marker) is not a human rename - it is preserved across renames and the shutdown restore, and removing it keeps the claim. Every session claims afresh: on each shutdown (quit, reload, `/new`, resume, fork) a claimed tab is restored to its then-current position number, and the successor session in the pane claims it again on its first name. The label the extension writes is never digits-only - a bare `1234` becomes `#1234`, and the naming prompt asks the model for `PR 1234` / `issue 123` / `ticket ABC-123` instead of a bare ID. It only ever runs in TUI mode, on an attached TTY, under Herdr (`HERDR_ENV`/`HERDR_TAB_ID`/`HERDR_SOCKET_PATH` all set) - `pi -p`/json/rpc runs and background subagents never touch the tab. Caveat: the restored number is internally a custom name (Herdr has no clear-to-auto API), so that tab keeps a number-looking label but stops renumbering on later tab closes/reorders.
|
|
204
204
|
|
|
205
205
|
`fastMode` only affects `claude-opus-4-8` and `claude-opus-5` requests on Anthropic's `anthropic-messages` API; enabling it opts into premium fast-mode pricing. `--fast` forces it on for one launch; `/fast on|off` toggles live. Proxy providers (opencode, cloudflare-ai-gateway) are excluded. `fastMode`'s header injection needs the `before_provider_headers` hook (pi bundling `@earendil-works/pi-coding-agent` >= 0.80.5); on older pi the beta header is silently not sent. The `anthropic-beta` header is discovered at request time by probing pi's own request assembly (no network), so conditional betas pi adds - e.g. `server-side-fallback-2026-07-01` for models with server-side fallback - are preserved alongside `fast-mode-2026-02-01`. If the probe fails, the header falls back to the static OAuth-identity + fast-mode list. See [doc/fetch.md](doc/fetch.md) and [doc/doc-to-md.md](doc/doc-to-md.md) for the ingestion tools' full reference; session-name/sword-header behavior above is complete.
|
|
206
206
|
|
|
@@ -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
|
|
|
@@ -173,12 +173,15 @@ export function isGhosttyActive(
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
export function toTabLabel(name: string, maxWords = 4): string {
|
|
176
|
-
|
|
176
|
+
const label = name
|
|
177
177
|
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
178
178
|
.split(/\s+/)
|
|
179
179
|
.filter(Boolean)
|
|
180
180
|
.slice(0, maxWords)
|
|
181
181
|
.join(" ");
|
|
182
|
+
// A bare number is Herdr's "unnamed tab" vocabulary; our own label must
|
|
183
|
+
// never look like one or a successor session would treat it as claimable.
|
|
184
|
+
return /^\d+$/.test(label) ? `#${label}` : label;
|
|
182
185
|
}
|
|
183
186
|
|
|
184
187
|
/**
|
|
@@ -338,6 +341,7 @@ export function buildNamingPrompt(conversation: string, opts: PromptOptions = {}
|
|
|
338
341
|
"- Describe the actual task, NOT the tool/skill/command used to start it.",
|
|
339
342
|
"- Lead with an action verb (e.g. refine, fix, add, rework).",
|
|
340
343
|
"- Preserve ticket/issue IDs (e.g. ABC-123, PROJ-42, #99) verbatim.",
|
|
344
|
+
"- If the whole TAB would be just an ID, say what it is: PR 1234, issue 123, ticket ABC-123. Never reply with digits only.",
|
|
341
345
|
"- No quotes, no trailing punctuation, plain ASCII.",
|
|
342
346
|
...rules.map((r) => `- ${r}`),
|
|
343
347
|
"Example -> SESSION: Refine Linear Ticket ABC-123 / TAB: Refine ABC-123",
|
|
@@ -496,12 +500,15 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
496
500
|
if (currentTabLabel) renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
|
|
497
501
|
};
|
|
498
502
|
|
|
499
|
-
// Herdr sink.
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
503
|
+
// Herdr sink. A tab is claimable while its label is a bare number - Herdr's
|
|
504
|
+
// vocabulary for "unnamed", and the only label this extension or
|
|
505
|
+
// herdr-ntfy-notify ever write back - regardless of position: Herdr stores a
|
|
506
|
+
// renamed number as a permanent custom label that reorders never update. A
|
|
507
|
+
// non-numeric label is a human's and is never overwritten; it is re-checked
|
|
508
|
+
// each turn so a tab renumbered by hand becomes claimable again. Exactly one
|
|
509
|
+
// leading "* " is herdr-ntfy-notify's armed marker, ignored for ownership and
|
|
510
|
+
// carried through on every write. All syncs serialize on one chain so
|
|
511
|
+
// overlapping hooks never interleave a read with a rename.
|
|
505
512
|
// Used both for turn_start syncs (bounds a wedged-but-accepting Herdr so it
|
|
506
513
|
// can't stall the turn) and for the session_end restore.
|
|
507
514
|
const HERDR_TIMEOUT_MS = 500;
|
|
@@ -514,44 +521,42 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
514
521
|
};
|
|
515
522
|
|
|
516
523
|
const ARMED_PREFIX = "* ";
|
|
524
|
+
const NUMERIC = /^\d+$/;
|
|
517
525
|
const matchOwned = (live: string, expected: string): { owned: boolean; armed: boolean } => {
|
|
518
526
|
if (live === expected) return { owned: true, armed: false };
|
|
519
527
|
if (live === ARMED_PREFIX + expected) return { owned: true, armed: true };
|
|
520
528
|
return { owned: false, armed: false };
|
|
521
529
|
};
|
|
530
|
+
const matchClaimable = (live: string): { claimable: boolean; armed: boolean } => {
|
|
531
|
+
if (NUMERIC.test(live)) return { claimable: true, armed: false };
|
|
532
|
+
if (live.startsWith(ARMED_PREFIX) && NUMERIC.test(live.slice(ARMED_PREFIX.length))) return { claimable: true, armed: true };
|
|
533
|
+
return { claimable: false, armed: false };
|
|
534
|
+
};
|
|
522
535
|
|
|
523
536
|
const syncHerdrTab = (cfg: Config, label: string | null, mode: Mode | undefined): Promise<void> => {
|
|
524
537
|
const run = async (): Promise<void> => {
|
|
525
538
|
if (!cfg.herdrTab || mode !== "tui" || !label) return;
|
|
526
|
-
if (herdrClaim === "backed-off") return;
|
|
527
539
|
if (!isHerdrActive()) return;
|
|
528
540
|
const sock = process.env.HERDR_SOCKET_PATH as string;
|
|
529
541
|
const tabId = process.env.HERDR_TAB_ID as string;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
if (
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if (!owned.owned) {
|
|
538
|
-
herdrClaim = "backed-off"; // human (or crashed predecessor) owns it
|
|
542
|
+
const live = await getTab(sock, tabId, HERDR_TIMEOUT_MS);
|
|
543
|
+
if (!live) return; // transient or stale tab id; a failed read is never a human rename
|
|
544
|
+
if (typeof herdrClaim === "object" && herdrClaim !== null) {
|
|
545
|
+
const owned = matchOwned(live.label, herdrClaim.lastWritten);
|
|
546
|
+
if (owned.owned) {
|
|
547
|
+
if (label === herdrClaim.lastWritten) return;
|
|
548
|
+
if (await renameTab(sock, tabId, (owned.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS)) herdrClaim.lastWritten = label;
|
|
539
549
|
return;
|
|
540
550
|
}
|
|
541
|
-
if (await renameTab(sock, tabId, (owned.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS)) {
|
|
542
|
-
herdrClaim = { lastWritten: label };
|
|
543
|
-
}
|
|
544
|
-
return;
|
|
545
551
|
}
|
|
546
|
-
const
|
|
547
|
-
if (!
|
|
548
|
-
|
|
549
|
-
if (!owned.owned) {
|
|
550
|
-
herdrClaim = "backed-off";
|
|
552
|
+
const claim = matchClaimable(live.label);
|
|
553
|
+
if (!claim.claimable) {
|
|
554
|
+
herdrClaim = "backed-off"; // human owns it until the label is numeric again
|
|
551
555
|
return;
|
|
552
556
|
}
|
|
553
|
-
if (
|
|
554
|
-
|
|
557
|
+
if (await renameTab(sock, tabId, (claim.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS)) {
|
|
558
|
+
herdrClaim = { lastWritten: label };
|
|
559
|
+
}
|
|
555
560
|
};
|
|
556
561
|
herdrChain = herdrChain.then(run, run);
|
|
557
562
|
return herdrChain;
|
|
@@ -595,6 +600,10 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
595
600
|
});
|
|
596
601
|
|
|
597
602
|
pi.on("session_start", async (_event, ctx) => {
|
|
603
|
+
// Every session claims afresh, exactly like a fresh process: /new, resume,
|
|
604
|
+
// and fork each tear the previous session down (restore included) first.
|
|
605
|
+
// Before loadConfig so `enabled: false, herdrTab: true` resets too.
|
|
606
|
+
herdrClaim = null;
|
|
598
607
|
const cfg = loadConfig(ctx);
|
|
599
608
|
if (!cfg.enabled) return; // off by default; opt in via settings.json
|
|
600
609
|
const current = pi.getSessionName();
|
|
@@ -646,9 +655,9 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
646
655
|
});
|
|
647
656
|
|
|
648
657
|
// Restore the numeric label on every teardown reason (quit/reload/new/
|
|
649
|
-
// resume/fork)
|
|
650
|
-
//
|
|
651
|
-
//
|
|
658
|
+
// resume/fork) so the pane reads as unnamed again. The restored label is
|
|
659
|
+
// internally a custom name - Herdr has no clear-to-auto API - so it won't
|
|
660
|
+
// renumber on reorders; the successor claims any numeric label regardless.
|
|
652
661
|
pi.on("session_shutdown", async () => {
|
|
653
662
|
await restoreHerdrTab();
|
|
654
663
|
});
|
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.3.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",
|