pi-quiver 6.7.0 → 6.8.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 +9 -0
- package/README.md +1 -1
- package/extensions/session-name.ts +84 -20
- package/extensions/slack.ts +22 -7
- package/lib/slack-core.ts +35 -24
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,15 @@ 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.8.0 - 2026-10-01
|
|
12
|
+
|
|
13
|
+
- `session-name`: opt-in automatic naming starts in the background after three completed model/tool rounds. Shorter runs start a non-blocking attempt at run end; initial generation is best-effort with a 30-second local deadline and no automatic retry per session activation.
|
|
14
|
+
|
|
15
|
+
## v6.7.1 - 2026-10-01
|
|
16
|
+
|
|
17
|
+
- `slack_post`/`slack_update` explain native Slack representations for skill-required formatting, with complete readable fallback; otherwise plain content stays plain.
|
|
18
|
+
- `slack_post` announcements carry native blocks in the detail reply without upload fallback and preserve structured detail in JSON for existing-thread recovery.
|
|
19
|
+
|
|
11
20
|
## v6.7.0 - 2026-09-30
|
|
12
21
|
|
|
13
22
|
- README: `swordHeader` note on why pi's built-in logo flashes before the sword appears and how `quietStartup` removes it.
|
package/README.md
CHANGED
|
@@ -199,7 +199,7 @@ same outcome here because `enabled` is the only field either layer sets, but
|
|
|
199
199
|
the merge is per-field: a global field with no project counterpart would
|
|
200
200
|
survive untouched.
|
|
201
201
|
|
|
202
|
-
`sessionAutoName.enabled`
|
|
202
|
+
`sessionAutoName.enabled` starts one background initial naming attempt for an unnamed session after three completed model/tool rounds in the current agent run, not three user messages. A round is one completed assistant turn and its tool results, regardless of how many tools run. If the run ends sooner, it starts the same background attempt at run end. Initial naming is best-effort: preparation and generation have a 30-second local deadline, and the agent, editor return, and shutdown do not wait for naming. The session can remain unnamed if generation fails, times out, or the process exits first; there is no automatic retry during that session activation, though reopening an unnamed session allows another attempt on its next run. An existing name suppresses initial naming, and a human rename while generation is pending takes precedence. `false` (default) makes no automatic naming 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 by a revisit; 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. Revisit counts come from the persisted transcript, so they survive resume.
|
|
203
203
|
|
|
204
204
|
`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.
|
|
205
205
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Session naming.
|
|
3
3
|
*
|
|
4
4
|
* - /session-name [name] : manually set or show the session name.
|
|
5
|
-
* - Auto-naming : after
|
|
5
|
+
* - Auto-naming : after three rounds (or a shorter run), derive a name
|
|
6
6
|
* from the conversation (unless one is already set).
|
|
7
7
|
* - Revisiting : re-derive the name later in a long session, once
|
|
8
8
|
* the work has revealed what it actually is.
|
|
@@ -372,16 +372,21 @@ export function withAuthBaseUrl<M extends { baseUrl: string }>(
|
|
|
372
372
|
return auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
async function generateName(
|
|
375
|
+
export async function generateName(
|
|
376
376
|
ctx: ExtensionContext,
|
|
377
377
|
opts: PromptOptions = {},
|
|
378
|
+
signal?: AbortSignal,
|
|
379
|
+
getComplete: () => Promise<CompleteFn> = loadComplete,
|
|
378
380
|
): Promise<typeof KEEP | GeneratedName | undefined> {
|
|
381
|
+
if (signal?.aborted) return undefined;
|
|
379
382
|
const conversation = buildConversationText(ctx, 4000, Boolean(opts.currentName));
|
|
380
383
|
if (conversation.length < 8) return undefined;
|
|
381
384
|
|
|
382
385
|
const model = ctx.model;
|
|
383
386
|
if (!model) return undefined;
|
|
387
|
+
const prompt = buildNamingPrompt(conversation, opts);
|
|
384
388
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
389
|
+
if (signal?.aborted) return undefined;
|
|
385
390
|
// ok=true with apiKey=undefined is the env-key path: the key lives in
|
|
386
391
|
// process.env (e.g. ANTHROPIC_API_KEY), not auth.json. getApiKeyAndHeaders
|
|
387
392
|
// deliberately opts out of the env fallback (includeFallback: false), so it
|
|
@@ -389,9 +394,8 @@ async function generateName(
|
|
|
389
394
|
// itself via withEnvApiKey/getEnvApiKey. Only bail when auth genuinely failed.
|
|
390
395
|
if (!auth?.ok) return undefined;
|
|
391
396
|
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
const complete = await loadComplete();
|
|
397
|
+
const complete = await getComplete();
|
|
398
|
+
if (signal?.aborted) return undefined;
|
|
395
399
|
const response = await complete(
|
|
396
400
|
withAuthBaseUrl(model, auth),
|
|
397
401
|
{
|
|
@@ -399,9 +403,10 @@ async function generateName(
|
|
|
399
403
|
{ role: "user" as const, content: [{ type: "text" as const, text: prompt }], timestamp: Date.now() },
|
|
400
404
|
],
|
|
401
405
|
},
|
|
402
|
-
{ apiKey: auth.apiKey, headers: auth.headers, env: auth.env, reasoningEffort: "low" },
|
|
406
|
+
{ apiKey: auth.apiKey, headers: auth.headers, env: auth.env, reasoningEffort: "low", signal },
|
|
403
407
|
);
|
|
404
408
|
|
|
409
|
+
if (signal?.aborted) return undefined;
|
|
405
410
|
const raw = response.content
|
|
406
411
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
407
412
|
.map((c) => c.text)
|
|
@@ -413,10 +418,16 @@ async function generateName(
|
|
|
413
418
|
type NameGenerator = (
|
|
414
419
|
ctx: ExtensionContext,
|
|
415
420
|
opts?: PromptOptions,
|
|
421
|
+
signal?: AbortSignal,
|
|
416
422
|
) => Promise<typeof KEEP | GeneratedName | undefined>;
|
|
417
423
|
|
|
418
424
|
export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = generateName) {
|
|
419
425
|
let autoNameTried = false;
|
|
426
|
+
let activation = 0;
|
|
427
|
+
let initialRounds = 0;
|
|
428
|
+
let initialDone: Promise<void> = Promise.resolve();
|
|
429
|
+
let initialAttempt: { cancel: () => void } | null = null;
|
|
430
|
+
const invalidateInitial = (): void => { initialAttempt?.cancel(); };
|
|
420
431
|
// Who chose the current name. A human's wording is never overwritten by a
|
|
421
432
|
// revisit - at most we suggest - so unknown provenance (a resumed session, a
|
|
422
433
|
// rename from outside this extension) is treated as human.
|
|
@@ -472,6 +483,7 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
472
483
|
tabLabel?: string,
|
|
473
484
|
author?: NameAuthor,
|
|
474
485
|
mode?: Mode,
|
|
486
|
+
ownsName?: () => boolean,
|
|
475
487
|
): Promise<void> => {
|
|
476
488
|
const clean = applyDenyList(name, cfg.deny);
|
|
477
489
|
if (author) recordNameAuthor(clean, author);
|
|
@@ -480,7 +492,7 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
480
492
|
lastSyncedName = clean;
|
|
481
493
|
currentTabLabel = toTabLabel(applyDenyList(tabLabel ?? clean, cfg.deny));
|
|
482
494
|
renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
|
|
483
|
-
await syncHerdrTab(cfg, currentTabLabel, mode);
|
|
495
|
+
await syncHerdrTab(cfg, currentTabLabel, mode, ownsName);
|
|
484
496
|
};
|
|
485
497
|
|
|
486
498
|
// Re-assert the tab from the current session name. Self-heals when the name
|
|
@@ -533,19 +545,23 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
533
545
|
return { claimable: false, armed: false };
|
|
534
546
|
};
|
|
535
547
|
|
|
536
|
-
const syncHerdrTab = (cfg: Config, label: string | null, mode: Mode | undefined): Promise<void> => {
|
|
548
|
+
const syncHerdrTab = (cfg: Config, label: string | null, mode: Mode | undefined, ownsName?: () => boolean): Promise<void> => {
|
|
537
549
|
const run = async (): Promise<void> => {
|
|
550
|
+
if (ownsName && !ownsName()) return;
|
|
538
551
|
if (!cfg.herdrTab || mode !== "tui" || !label) return;
|
|
539
552
|
if (!isHerdrActive()) return;
|
|
540
553
|
const sock = process.env.HERDR_SOCKET_PATH as string;
|
|
541
554
|
const tabId = process.env.HERDR_TAB_ID as string;
|
|
542
555
|
const live = await getTab(sock, tabId, HERDR_TIMEOUT_MS);
|
|
556
|
+
if (ownsName && !ownsName()) return;
|
|
543
557
|
if (!live) return; // transient or stale tab id; a failed read is never a human rename
|
|
544
558
|
if (typeof herdrClaim === "object" && herdrClaim !== null) {
|
|
545
559
|
const owned = matchOwned(live.label, herdrClaim.lastWritten);
|
|
546
560
|
if (owned.owned) {
|
|
547
561
|
if (label === herdrClaim.lastWritten) return;
|
|
548
|
-
if (await renameTab(sock, tabId, (owned.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS))
|
|
562
|
+
if (await renameTab(sock, tabId, (owned.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS)) {
|
|
563
|
+
herdrClaim.lastWritten = label;
|
|
564
|
+
}
|
|
549
565
|
return;
|
|
550
566
|
}
|
|
551
567
|
}
|
|
@@ -589,6 +605,7 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
589
605
|
handler: async (args, ctx) => {
|
|
590
606
|
const name = args.trim();
|
|
591
607
|
if (name) {
|
|
608
|
+
invalidateInitial();
|
|
592
609
|
autoNameTried = true; // manual name wins; don't auto-overwrite later
|
|
593
610
|
await setName(loadConfig(ctx), name, undefined, "human", ctx.mode);
|
|
594
611
|
ctx.ui.notify(`Session named: ${pi.getSessionName()}`, "info");
|
|
@@ -600,6 +617,15 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
600
617
|
});
|
|
601
618
|
|
|
602
619
|
pi.on("session_start", async (_event, ctx) => {
|
|
620
|
+
invalidateInitial();
|
|
621
|
+
activation++;
|
|
622
|
+
autoNameTried = false;
|
|
623
|
+
initialRounds = 0;
|
|
624
|
+
nameAuthor = "human";
|
|
625
|
+
lastRevisitAt = 0;
|
|
626
|
+
expectedInternalName = null;
|
|
627
|
+
lastSyncedName = null;
|
|
628
|
+
currentTabLabel = null;
|
|
603
629
|
// Every session claims afresh, exactly like a fresh process: /new, resume,
|
|
604
630
|
// and fork each tear the previous session down (restore included) first.
|
|
605
631
|
// Before loadConfig so `enabled: false, herdrTab: true` resets too.
|
|
@@ -636,6 +662,8 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
636
662
|
return;
|
|
637
663
|
}
|
|
638
664
|
expectedInternalName = null;
|
|
665
|
+
invalidateInitial();
|
|
666
|
+
autoNameTried = true;
|
|
639
667
|
if (!current) return;
|
|
640
668
|
const cfg = loadConfig(ctx);
|
|
641
669
|
if (!cfg.enabled) return;
|
|
@@ -659,24 +687,60 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
659
687
|
// internally a custom name - Herdr has no clear-to-auto API - so it won't
|
|
660
688
|
// renumber on reorders; the successor claims any numeric label regardless.
|
|
661
689
|
pi.on("session_shutdown", async () => {
|
|
690
|
+
invalidateInitial();
|
|
691
|
+
activation++;
|
|
662
692
|
await restoreHerdrTab();
|
|
663
693
|
});
|
|
664
694
|
|
|
665
|
-
|
|
695
|
+
const startInitial = (ctx: ExtensionContext): void => {
|
|
666
696
|
if (autoNameTried || pi.getSessionName()) return;
|
|
667
697
|
const cfg = loadConfig(ctx);
|
|
668
|
-
if (!cfg.enabled) return;
|
|
698
|
+
if (!cfg.enabled) return;
|
|
669
699
|
autoNameTried = true;
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
700
|
+
const identity = activation;
|
|
701
|
+
const mode = ctx.mode;
|
|
702
|
+
const notify = ctx.hasUI ? ctx.ui.notify.bind(ctx.ui) : undefined;
|
|
703
|
+
const controller = new AbortController();
|
|
704
|
+
let active = true;
|
|
705
|
+
let settle!: () => void;
|
|
706
|
+
initialDone = new Promise<void>((resolve) => { settle = resolve; });
|
|
707
|
+
const finish = (): void => {
|
|
708
|
+
if (!active) return;
|
|
709
|
+
active = false;
|
|
710
|
+
clearTimeout(timer);
|
|
711
|
+
if (initialAttempt === attempt) initialAttempt = null;
|
|
712
|
+
settle();
|
|
713
|
+
};
|
|
714
|
+
const attempt = { cancel: (): void => { finish(); controller.abort(); } };
|
|
715
|
+
const timer = setTimeout(attempt.cancel, 30_000);
|
|
716
|
+
timer.unref();
|
|
717
|
+
initialAttempt = attempt;
|
|
718
|
+
void (async () => {
|
|
719
|
+
try {
|
|
720
|
+
const generated = await generate(ctx, { rules: cfg.rules }, controller.signal);
|
|
721
|
+
if (!active || identity !== activation || controller.signal.aborted) return;
|
|
722
|
+
if (!generated || generated === KEEP || pi.getSessionName()) return;
|
|
723
|
+
clearTimeout(timer);
|
|
724
|
+
const applied = applyDenyList(generated.sessionName, cfg.deny);
|
|
725
|
+
await setName(cfg, generated.sessionName, generated.tabLabel, "auto", mode,
|
|
726
|
+
() => active && identity === activation && !controller.signal.aborted && pi.getSessionName() === applied);
|
|
727
|
+
if (active && identity === activation && !controller.signal.aborted && pi.getSessionName() === applied) {
|
|
728
|
+
notify?.(`Auto-named session: ${applied}`, "info");
|
|
729
|
+
}
|
|
730
|
+
} catch {
|
|
731
|
+
// Naming is best-effort, including late failures after cancellation.
|
|
732
|
+
} finally {
|
|
733
|
+
finish();
|
|
675
734
|
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
|
|
735
|
+
})();
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
pi.on("agent_start", () => { initialRounds = 0; });
|
|
739
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
740
|
+
initialRounds++;
|
|
741
|
+
if (initialRounds >= 3) startInitial(ctx);
|
|
679
742
|
});
|
|
743
|
+
pi.on("agent_end", (_event, ctx) => { startInitial(ctx); });
|
|
680
744
|
|
|
681
745
|
// Revisit. A name derived from the first turn describes the opening move,
|
|
682
746
|
// which is frequently not what the session turns out to be about - the work
|
|
@@ -724,7 +788,7 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
724
788
|
})();
|
|
725
789
|
});
|
|
726
790
|
|
|
727
|
-
return { revisitSettled: () => revisitDone };
|
|
791
|
+
return { initialSettled: () => initialDone, revisitSettled: () => revisitDone };
|
|
728
792
|
}
|
|
729
793
|
|
|
730
794
|
export default function (pi: ExtensionAPI) {
|
package/extensions/slack.ts
CHANGED
|
@@ -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
|
|
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
|
|
310
|
-
blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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.
|
|
3
|
+
"version": "6.8.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",
|