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 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. It's claim-once: the extension adopts a tab only while it still shows its default numeric label (its live 1-based position among the workspace's tabs); a manually renamed tab, or one renamed mid-session by hand, is never touched again for that session - the human's label always wins. One exception: 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. On every shutdown (quit, reload, `/new`, resume, fork) a claimed tab's label is restored to its then-current default position, so a successor session in the same pane can claim cleanly. 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. Caveats: a hard crash (`kill -9`) skips the restore and leaves the stale label - rename the tab by hand to recover; and the restored label 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.
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
- return name
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. Claim-once: adopt the tab only while it shows its default
500
- // (position) label; a human rename - before or during the session - wins
501
- // permanently. The one exception is exactly one leading "* ": that is
502
- // herdr-ntfy-notify's armed marker, ignored for ownership and carried
503
- // through on every write. All syncs serialize on one chain so overlapping
504
- // hooks never interleave a read with a rename.
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
- if (herdrClaim === null) {
531
- const tabs = await listTabs(sock, HERDR_TIMEOUT_MS);
532
- if (!tabs) return; // transient; retry next sync
533
- const position = positionOf(tabs, tabId);
534
- if (position === -1) return; // stale tab id (pane moved); retry harmlessly
535
- const own = tabs.find((t) => t.tab_id === tabId)!;
536
- const owned = matchOwned(own.label, String(position));
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 live = await getTab(sock, tabId, HERDR_TIMEOUT_MS);
547
- if (!live) return; // failed read is not a human rename; stay claimed
548
- const owned = matchOwned(live.label, herdrClaim.lastWritten);
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 (label === herdrClaim.lastWritten) return;
554
- if (await renameTab(sock, tabId, (owned.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS)) herdrClaim.lastWritten = label;
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): a successor session in this pane must find the default so
650
- // claim-once stays sound. The restored label is internally a custom name -
651
- // Herdr has no clear-to-auto API - so it looks right but won't renumber.
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
  });
@@ -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. Output is size-gated like slack_search.',
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({ channel, ts: params.ts, permalink: params.permalink, cursor: params.cursor }, deps);
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 text = typeof message.text === "string" ? message.text.replace(/\r?\n/g, " ") : "";
574
- return `${author} | ${ts} | ${text}`;
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.1.0",
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",