pi-quiver 4.5.0 → 5.1.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 +34 -6
- package/extensions/slack.ts +532 -0
- package/lib/slack-cache.ts +537 -0
- package/lib/slack-core.ts +1087 -0
- 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
|
+
## v5.1.0 - 2026-09-01
|
|
12
|
+
|
|
13
|
+
- `slack`: optional `policyPath` config injects a repo policy file into the system prompt every turn (`<slack-policy source="...">`); a missing/unreadable/empty file degrades to a `status=` block plus one deduped warning, tools stay fully usable either way (#9). `slack_post`/`slack_update` now resolve `@name` mentions to `<@U...>` (cache-first, one batched `users.list` live pass), leaving unresolvable names literal and reported via `unresolved mentions: ...` plus `details.unresolvedMentions`. The name cache gained an optional per-user `email` field and a file-level `snapshot_at` marker (set only by a full `slack_cache_refresh`, gating whether an alias match can be trusted straight from cache); `slack_cache_refresh`'s result line now reports an email/user ratio with a missing-scope hint. `slack_post` gained `unfurl_links`/`unfurl_media` params, applied to the headline and inline detail leg (never the upload stub), omitted when unset so Slack's default stands; `slack_update` has no equivalent (`chat.update` has no unfurl argument). See [doc/slack.md](doc/slack.md).
|
|
14
|
+
|
|
15
|
+
## v5.0.0 - 2026-08-29
|
|
16
|
+
|
|
17
|
+
- **New opt-in `slack` extension** (#7): eight `slack_*` tools (search, thread, post, update, delete, pin, upload, cache refresh) for context-safe Slack search/threads/posting. Dual `user`/`bot` token identities resolved per call from process env or the repo's `.env`, never cross-identity fallback. Workspace-keyed channel/user name->ID cache with a repo-overridable `cachePath`. Fetch-style output size gating on search/thread reads. `slack_post`'s `thread_body` (no `thread_ts`) posts a transactional headline+thread announce - oversized detail bodies upload as a file - with a documented recovery path on delivery failure. OFF by default; nested-only `quiver.slack` config, no legacy flat form. See [doc/slack.md](doc/slack.md).
|
|
18
|
+
|
|
11
19
|
## v4.5.0 - 2026-08-29
|
|
12
20
|
|
|
13
21
|
- Settings resolution: every pi-quiver setting is now read from an optional `"quiver"` root object in `settings.json` (`quiver.<key>`), grouping the four legacy flat keys (`fastMode`, `sessionAutoName`, `swordHeader`, `providerStallWatchdog`) plus any future key. The flat top-level form keeps working, but only for those four legacy keys - it is frozen there and never extended to new settings. Within a layer, `quiver.<key>` wins over flat `<key>` by presence; malformed values and flat/nested duplicates now emit a warning instead of resolving silently.
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ But the moment an agent does that, one `fetch` or PDF read can dump hundreds of
|
|
|
18
18
|
|
|
19
19
|
`fetch` and `doc_to_md` bring real web pages, GitHub issues/PRs, and local PDF/DOCX/PPTX files into context - and every result is size-gated by construction: over 32 KB or 1000 lines spills to a temp file with a preview and a grep/read hint, so a single call can never flood the window. Ingestion is what makes data-driven work possible; the gate is what keeps it safe.
|
|
20
20
|
|
|
21
|
-
`session-name`, `sword-header`, `fast-mode`,
|
|
21
|
+
`session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`, and `slack` are opt-in ergonomics, recovery, and integration controls: session labeling, a themed startup header, Anthropic fast mode, semantic-stall recovery, and context-safe Slack search/threads/posting with repo-policy injection, `@name` mention resolution, cached emails, and per-call unfurl control.
|
|
22
22
|
|
|
23
23
|
## Part of the pi agent toolkit
|
|
24
24
|
|
|
@@ -33,7 +33,7 @@ No code dependency between them. pi-quiver is call-level: it gates the size of w
|
|
|
33
33
|
|
|
34
34
|
## Mental model
|
|
35
35
|
|
|
36
|
-
Every ingestion extension here is context-safe by construction, not by convention: the size check runs on every call, there's no flag to forget. `fetch` and `doc_to_md` bring real sources in; `session-name`, `sword-header`, `fast-mode`,
|
|
36
|
+
Every ingestion extension here is context-safe by construction, not by convention: the size check runs on every call, there's no flag to forget. `fetch` and `doc_to_md` bring real sources in; `session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`, and `slack` are opt-in.
|
|
37
37
|
|
|
38
38
|
```mermaid
|
|
39
39
|
flowchart LR
|
|
@@ -68,8 +68,9 @@ A 300 KB changelog page never touches your context window - you get a preview an
|
|
|
68
68
|
| `extensions/sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
|
|
69
69
|
| `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. |
|
|
70
70
|
| `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. |
|
|
71
|
+
| `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, 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`. |
|
|
71
72
|
|
|
72
|
-
Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md), [doc/doc-to-md.md](doc/doc-to-md.md).
|
|
73
|
+
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).
|
|
73
74
|
|
|
74
75
|
## Key concepts
|
|
75
76
|
|
|
@@ -78,7 +79,7 @@ Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md
|
|
|
78
79
|
| Size gate | Text/Markdown/JSON output over 32 KB or 1000 lines spills to a temp file with a 60-line preview instead of inlining. |
|
|
79
80
|
| Content routing | HTML -> Markdown, binary -> untouched file, GitHub URLs -> `gh` CLI (failed runs/jobs get failed-step logs appended), everything else -> the size gate. |
|
|
80
81
|
| Graceful degradation | Optional binaries (`gh`, `uv`, LibreOffice) are never hard install-time deps; each has a defined, documented fallback or failure mode. |
|
|
81
|
-
| Opt-in extensions | `session-name`, `sword-header`, `fast-mode`,
|
|
82
|
+
| Opt-in extensions | `session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`, and `slack` do nothing until explicitly enabled in `settings.json`. |
|
|
82
83
|
| Provider stall recovery | The watchdog detects a missing first stream event and missing parsed semantic progress, not network liveness. The pre-first-event tier covers every mode and origin; the mid-stream tier is TUI-only. |
|
|
83
84
|
|
|
84
85
|
## When to use
|
|
@@ -152,7 +153,8 @@ These extensions are opt-in via `settings.json` (project `.pi/settings.json` ove
|
|
|
152
153
|
}, // or boolean shorthand
|
|
153
154
|
"swordHeader": false, // or { "enabled": true }
|
|
154
155
|
"fastMode": false, // or { "enabled": true }
|
|
155
|
-
"providerStallWatchdog": false
|
|
156
|
+
"providerStallWatchdog": false, // or { "enabled": true }
|
|
157
|
+
"slack": false // or { "enabled": true, ... }
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
160
|
```
|
|
@@ -231,6 +233,32 @@ Operational notes:
|
|
|
231
233
|
- **A watchdog abort that the provider ignores escalates after a fixed 10s.** Any post-abort stream event re-arms that deadline (bytes prove only that the connection was alive at that instant), so a stream that emits a straggler and then wedges still escalates 10s after its last event. This reduces the hang; it cannot force the provider to stop, and undici's timeouts remain the final backstop.
|
|
232
234
|
- **Headless runs report on stderr.** In `print`/`json` mode pi binds a no-op UI, so watchdog notices go out via `console.warn`. Nothing is ever written to stdout, which `json` mode uses for its protocol. In TUI and RPC the notices render as main-window notifications, not the bottom status line.
|
|
233
235
|
|
|
236
|
+
`slack` is OFF by default and, once enabled, adds eight `slack_*` tools (search, thread, post, update, delete, pin, upload, cache refresh) covering context-safe Slack search/threads/posting under dual `user`/`bot` token identities. Nested-only from day one (no legacy flat form):
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"quiver": {
|
|
241
|
+
"slack": {
|
|
242
|
+
"enabled": true,
|
|
243
|
+
"cachePath": ".pi/slack-cache.json",
|
|
244
|
+
"userTokenEnv": "SLACK_USER_TOKEN",
|
|
245
|
+
"botTokenEnv": "SLACK_BOT_TOKEN",
|
|
246
|
+
"uploadThresholdChars": 4000
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
| Key | Default | Meaning |
|
|
253
|
+
| --- | --- | --- |
|
|
254
|
+
| `enabled` | `false` | Master switch, checked at `session_start`; toggling takes effect next session. |
|
|
255
|
+
| `cachePath` | user-scope per-OS cache dir | Overrides where the workspace-keyed channel/user name->ID cache file is written; relative paths resolve against the repo root. |
|
|
256
|
+
| `userTokenEnv` | `SLACK_USER_TOKEN` | Env var name holding the user token (required for `slack_search`/`slack_thread`, no bot fallback). |
|
|
257
|
+
| `botTokenEnv` | `SLACK_BOT_TOKEN` | Env var name holding the bot token. |
|
|
258
|
+
| `uploadThresholdChars` | `4000` | Link-collapsed length above which an announce/thread detail body is delivered as a file upload instead of inline text. |
|
|
259
|
+
|
|
260
|
+
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. Tokens themselves are resolved per call: process env first, then the repo's `.env` file (or the primary checkout's, for a worktree with none) - never a fallback across identities. Full reference incl. cache layering, the announce protocol, and the `search.messages`/`conversations.replies` throttle caveats: [doc/slack.md](doc/slack.md).
|
|
261
|
+
|
|
234
262
|
### Migrating from flat keys
|
|
235
263
|
|
|
236
264
|
The flat top-level form (`"fastMode": ...` etc. directly under `settings.json`)
|
|
@@ -261,7 +289,7 @@ flat form to fall back to.
|
|
|
261
289
|
|
|
262
290
|
**Exposed:** the `quiver` plugin, served from this repo's `.claude-plugin/marketplace.json`, with two skills: `fetch` (invoked as `quiver:fetch` / `/quiver:fetch`) and `doc-to-md` (invoked as `quiver:doc-to-md` / `/quiver:doc-to-md`). The `fetch` skill runs `npx -y pi-quiver@latest fetch <url> [flags]` via Bash - full parameter parity with the pi tool (`--method`, `--header`, `--body`, `--raw`, `--timeout-ms`), same GitHub `gh` routing (including failed-step logs on failed runs/jobs), same size gate, same binary-to-temp-file handling. See [doc/fetch.md](doc/fetch.md#claude-code-cli-pi-quiver-fetch) for exit codes and flags. The `doc-to-md` skill runs `npx -y pi-quiver@latest doc-to-md <path>` via Bash - same backend ladder, size gate, and degraded-fallback marking as the pi tool. See [doc/doc-to-md.md](doc/doc-to-md.md#cli-pi-quiver-doc-to-md) for exit codes.
|
|
263
291
|
|
|
264
|
-
**Not exposed:** the other pi extensions in this package (`session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`) - the marketplace allowlists only `./skills/fetch` and `./skills/doc-to-md`, and the npm tarball never ships `skills/` or `.claude-plugin/` (pi's own `files` allowlist excludes them, and pi's explicit `pi.extensions` manifest makes them invisible to pi's convention-directory auto-discovery either way).
|
|
292
|
+
**Not exposed:** the other pi extensions in this package (`session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`, `slack`) - the marketplace allowlists only `./skills/fetch` and `./skills/doc-to-md`, and the npm tarball never ships `skills/` or `.claude-plugin/` (pi's own `files` allowlist excludes them, and pi's explicit `pi.extensions` manifest makes them invisible to pi's convention-directory auto-discovery either way).
|
|
265
293
|
|
|
266
294
|
Add the marketplace and enable the plugin in `.claude/settings.json`:
|
|
267
295
|
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack extension: opt-in, default-off. On `session_start` resolves
|
|
3
|
+
* `quiver.slack`; if not enabled, registers zero tools (no `.env` read, no
|
|
4
|
+
* network, no cache I/O). If enabled, registers the eight `slack_*` tools as
|
|
5
|
+
* thin wrappers over lib/slack-core.ts and lib/slack-cache.ts.
|
|
6
|
+
*
|
|
7
|
+
* Toggling takes effect at the next session (registration-time gate, same
|
|
8
|
+
* convention as the other opt-in extensions).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
13
|
+
import { Type } from "@sinclair/typebox";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
defaultApiCall,
|
|
18
|
+
defaultUploadBytes,
|
|
19
|
+
buildPolicyBlock,
|
|
20
|
+
discoverRepoRoot,
|
|
21
|
+
resolveSlackConfig,
|
|
22
|
+
resolveToken,
|
|
23
|
+
searchMessages,
|
|
24
|
+
readThread,
|
|
25
|
+
postMessage,
|
|
26
|
+
updateMessage,
|
|
27
|
+
deleteMessage,
|
|
28
|
+
pinMessage,
|
|
29
|
+
uploadFile,
|
|
30
|
+
formatUnresolvedSuffix,
|
|
31
|
+
SlackError,
|
|
32
|
+
type SlackConfig,
|
|
33
|
+
type CoreDeps,
|
|
34
|
+
type MutationResult,
|
|
35
|
+
type AnnounceResult,
|
|
36
|
+
type SearchResult,
|
|
37
|
+
type ThreadResult,
|
|
38
|
+
type UnresolvedMention,
|
|
39
|
+
} from "../lib/slack-core.ts";
|
|
40
|
+
import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, resolveMentions, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
|
|
41
|
+
|
|
42
|
+
const IDENTITY = Type.Union([Type.Literal("user"), Type.Literal("bot")], {
|
|
43
|
+
description: 'Which token to act as: "user" (a real person, needed for slack_search/slack_thread) or "bot" (an app identity). Determines which token env var is used and whose name shows as the author.',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Approved recovery fields only - never dump the raw Slack API response (err.data can carry it
|
|
47
|
+
// verbatim for ok:false mappings). Everything a caller needs to recover a mutation lives in this
|
|
48
|
+
// small whitelist.
|
|
49
|
+
const ALLOWED_ERROR_DATA_KEYS = new Set(["ts", "channel", "permalink", "detailPath", "thread_ts"]);
|
|
50
|
+
|
|
51
|
+
export function formatToolError(err: unknown, identity?: "user" | "bot"): Error {
|
|
52
|
+
if (err instanceof SlackError) {
|
|
53
|
+
const identitySuffix = err.code === "missing_scope" && identity ? ` (identity: ${identity})` : "";
|
|
54
|
+
const filtered = err.data
|
|
55
|
+
? Object.fromEntries(Object.entries(err.data).filter(([key]) => ALLOWED_ERROR_DATA_KEYS.has(key)))
|
|
56
|
+
: undefined;
|
|
57
|
+
const data = filtered && Object.keys(filtered).length > 0 ? `\n${JSON.stringify(filtered)}` : "";
|
|
58
|
+
return new Error(`${err.code}: ${err.message}${identitySuffix}${data}`);
|
|
59
|
+
}
|
|
60
|
+
if (err instanceof Error) return err;
|
|
61
|
+
return new Error(String(err));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ResolvedCall {
|
|
65
|
+
token: string;
|
|
66
|
+
filePath: string;
|
|
67
|
+
cacheCtx: CacheCtx;
|
|
68
|
+
deps: CoreDeps;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function resolveCall(
|
|
72
|
+
identity: "user" | "bot",
|
|
73
|
+
cfg: SlackConfig,
|
|
74
|
+
ctx: ExtensionContext,
|
|
75
|
+
signal: AbortSignal | undefined,
|
|
76
|
+
repoRoot: string,
|
|
77
|
+
): Promise<ResolvedCall> {
|
|
78
|
+
const token = resolveToken(identity, cfg, process.env, repoRoot);
|
|
79
|
+
const teamId = await teamIdFor(token, defaultApiCall, signal);
|
|
80
|
+
const otherIdentity = identity === "user" ? "bot" : "user";
|
|
81
|
+
const otherTeamId = await (async () => {
|
|
82
|
+
try {
|
|
83
|
+
const otherToken = resolveToken(otherIdentity, cfg, process.env, repoRoot);
|
|
84
|
+
return await teamIdFor(otherToken, defaultApiCall, signal);
|
|
85
|
+
} catch {
|
|
86
|
+
// Other identity's token can't be resolved/authenticated - best-effort
|
|
87
|
+
// cross-check only, never fail the acting identity's call for this.
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
if (otherTeamId !== undefined) {
|
|
92
|
+
assertSameTeam(teamId, otherTeamId);
|
|
93
|
+
}
|
|
94
|
+
const filePath = cacheFilePath(cfg, repoRoot, teamId);
|
|
95
|
+
const cacheCtx: CacheCtx = { apiCall: defaultApiCall, token, filePath, signal };
|
|
96
|
+
const deps: CoreDeps = { apiCall: defaultApiCall, token, signal };
|
|
97
|
+
return { token, filePath, cacheCtx, deps };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* slack_cache_refresh's identity pick: user token when present, else bot (spec "Cache" section).
|
|
102
|
+
* Pure and network-free - resolveToken only reads env/.env - so it's unit-testable without a
|
|
103
|
+
* transport seam.
|
|
104
|
+
*/
|
|
105
|
+
export function pickCacheRefreshIdentity(
|
|
106
|
+
cfg: SlackConfig,
|
|
107
|
+
env: Record<string, string | undefined>,
|
|
108
|
+
repoRoot: string,
|
|
109
|
+
): "user" | "bot" {
|
|
110
|
+
try {
|
|
111
|
+
resolveToken("user", cfg, env, repoRoot);
|
|
112
|
+
return "user";
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err instanceof SlackError && err.code === "missing_token") return "bot";
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** G9: markdown_text is rejected as a posting field (spec "Tool surface") - schemas don't define
|
|
120
|
+
* it, so TypeBox's non-strict Type.Object would otherwise pass it through silently. */
|
|
121
|
+
function assertNoMarkdownText(params: object): void {
|
|
122
|
+
if ("markdown_text" in params) {
|
|
123
|
+
throw new SlackError("invalid_argument", "markdown_text is not supported; use text and/or blocks");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function guarded<T>(fn: () => Promise<T>, identity?: "user" | "bot"): Promise<T> {
|
|
128
|
+
try {
|
|
129
|
+
return await fn();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
throw formatToolError(err, identity);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function channelLine(result: MutationResult | (MutationResult & { fileId: string })): string {
|
|
136
|
+
const parts = [`channel ${result.channel}`];
|
|
137
|
+
if (result.ts) parts.push(`ts ${result.ts}`);
|
|
138
|
+
if (result.permalink) parts.push(result.permalink);
|
|
139
|
+
if ("fileId" in result) parts.push(`file ${result.fileId}`);
|
|
140
|
+
if (result.warning) parts.push(`warning: ${result.warning}`);
|
|
141
|
+
if ((result as AnnounceResult).detailTs) parts.push(`detail ts ${(result as AnnounceResult).detailTs}`);
|
|
142
|
+
return parts.join(" | ");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Shared by slack_post/slack_update: the channelLine + unresolved-mentions suffix result shape. */
|
|
146
|
+
function mentionAwareResult(
|
|
147
|
+
result: MutationResult | AnnounceResult,
|
|
148
|
+
mentions: { unresolved: UnresolvedMention[]; lookupError?: string },
|
|
149
|
+
opts: { detailUploaded?: boolean } = {},
|
|
150
|
+
) {
|
|
151
|
+
const suffix = formatUnresolvedSuffix(mentions.unresolved, { lookupError: mentions.lookupError, ...opts });
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: "text" as const, text: suffix ? `${channelLine(result)} | ${suffix}` : channelLine(result) }],
|
|
154
|
+
details: { ...result, ...(mentions.unresolved.length > 0 ? { unresolvedMentions: mentions.unresolved } : {}) },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function searchResultText(result: SearchResult): string {
|
|
159
|
+
return `${result.output}\n\ntotal: ${result.total} | page: ${result.page} of ${result.pageCount}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function threadResultText(result: ThreadResult): string {
|
|
163
|
+
const lines = [result.output, "", `complete: ${result.complete}`];
|
|
164
|
+
if (!result.complete && result.nextCursor) lines.push(`next_cursor: ${result.nextCursor}`);
|
|
165
|
+
if (result.caveat) lines.push(result.caveat);
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function oneLine(theme: Theme, name: string, arg: string): Text {
|
|
170
|
+
return new Text(`${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("accent", arg)}`, 0, 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function renderToolResult(
|
|
174
|
+
result: { content: { type: string; text?: string }[] },
|
|
175
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
176
|
+
theme: Theme,
|
|
177
|
+
context: { isError: boolean },
|
|
178
|
+
): Text {
|
|
179
|
+
const content = result.content[0];
|
|
180
|
+
const fullText = content?.type === "text" ? (content.text ?? "") : "";
|
|
181
|
+
if (options.isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
|
|
182
|
+
if (context.isError) {
|
|
183
|
+
const firstLine = fullText.split("\n")[0] || "slack call failed";
|
|
184
|
+
return new Text(theme.fg("error", firstLine), 0, 0);
|
|
185
|
+
}
|
|
186
|
+
if (!options.expanded) {
|
|
187
|
+
return new Text(theme.fg("toolOutput", fullText.split("\n")[0] || ""), 0, 0);
|
|
188
|
+
}
|
|
189
|
+
return new Text(
|
|
190
|
+
fullText
|
|
191
|
+
.split("\n")
|
|
192
|
+
.map((line) => theme.fg("toolOutput", line))
|
|
193
|
+
.join("\n"),
|
|
194
|
+
0,
|
|
195
|
+
0,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export default function slackExtension(pi: ExtensionAPI) {
|
|
200
|
+
let registered = false;
|
|
201
|
+
|
|
202
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
203
|
+
const cfg = resolveSlackConfig(ctx.cwd, process.env, (m) => ctx.ui.notify(m, "warning"));
|
|
204
|
+
if (cfg.enabled !== true) return;
|
|
205
|
+
if (registered) return;
|
|
206
|
+
registered = true;
|
|
207
|
+
|
|
208
|
+
const repoRoot = discoverRepoRoot(ctx.cwd);
|
|
209
|
+
|
|
210
|
+
const policyPath = cfg.policyPath;
|
|
211
|
+
if (policyPath !== undefined) {
|
|
212
|
+
const resolvedPolicyPath = isAbsolute(policyPath) ? policyPath : join(repoRoot, policyPath);
|
|
213
|
+
let policyWarned = false;
|
|
214
|
+
const warnOnce = (eventCtx: typeof ctx, message: string): void => {
|
|
215
|
+
if (policyWarned) return;
|
|
216
|
+
policyWarned = true;
|
|
217
|
+
if (eventCtx.hasUI) eventCtx.ui.notify(message, "warning");
|
|
218
|
+
else console.warn(message);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
pi.on("before_agent_start", async (event, eventCtx) => {
|
|
222
|
+
let block: string;
|
|
223
|
+
try {
|
|
224
|
+
const body = readFileSync(resolvedPolicyPath, "utf8");
|
|
225
|
+
if (body.trim() === "") {
|
|
226
|
+
warnOnce(eventCtx, `pi-quiver: Slack policy file ${policyPath} is empty; posting policy is unknown this session.`);
|
|
227
|
+
block = buildPolicyBlock({ source: policyPath, status: "empty" });
|
|
228
|
+
} else {
|
|
229
|
+
block = buildPolicyBlock({ source: policyPath, status: "ok", body });
|
|
230
|
+
}
|
|
231
|
+
} catch (err) {
|
|
232
|
+
const code = (err as { code?: string }).code ?? (err instanceof Error ? err.message : String(err));
|
|
233
|
+
warnOnce(eventCtx, `pi-quiver: Slack policy file ${policyPath} could not be read (${code}); posting policy is unknown this session.`);
|
|
234
|
+
block = buildPolicyBlock({ source: policyPath, status: "unreadable", code });
|
|
235
|
+
}
|
|
236
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
pi.registerTool({
|
|
241
|
+
name: "slack_search",
|
|
242
|
+
label: "Slack Search",
|
|
243
|
+
promptSnippet: "Search Slack messages with Slack's search operator grammar",
|
|
244
|
+
description:
|
|
245
|
+
'Search Slack messages via search.messages. Always uses the "user" identity (search.messages accepts only user tokens - no `as` param). `query` uses Slack\'s documented operator grammar (e.g. `in:#channel-name`, `from:@display-name`); channel/user names in the query are passed through as-is, never rewritten to IDs. `count` (default 20, max 100) and optional `page` control a single page of Slack\'s offset-paginated results. Output is compact (author, channel, ts, permalink, text) and size-gated: over 32KB/1000 lines it is written to a temp file with a 60-line preview.',
|
|
246
|
+
parameters: Type.Object({
|
|
247
|
+
query: Type.String({ description: "Slack search query, e.g. 'deploy in:#eng from:@alice'" }),
|
|
248
|
+
count: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 20 })),
|
|
249
|
+
page: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
250
|
+
}),
|
|
251
|
+
async execute(_toolCallId, params, signal) {
|
|
252
|
+
return guarded(async () => {
|
|
253
|
+
const { deps } = await resolveCall("user", cfg, ctx, signal, repoRoot);
|
|
254
|
+
const result = await searchMessages(params, deps);
|
|
255
|
+
return {
|
|
256
|
+
content: [{ type: "text" as const, text: searchResultText(result) }],
|
|
257
|
+
details: result,
|
|
258
|
+
};
|
|
259
|
+
}, "user");
|
|
260
|
+
},
|
|
261
|
+
renderCall: (args, theme) => oneLine(theme, "slack_search", args.query ?? ""),
|
|
262
|
+
renderResult: renderToolResult,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
pi.registerTool({
|
|
266
|
+
name: "slack_thread",
|
|
267
|
+
label: "Slack Thread",
|
|
268
|
+
promptSnippet: "Read all replies in a Slack thread",
|
|
269
|
+
description:
|
|
270
|
+
'Read a Slack thread via conversations.replies. Always uses the "user" identity (no `as` param). Provide either `channel` (#name or channel ID; user @names not accepted) 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.',
|
|
271
|
+
parameters: Type.Object({
|
|
272
|
+
channel: Type.Optional(Type.String({ description: "#name or channel ID (user @names not accepted)" })),
|
|
273
|
+
ts: Type.Optional(Type.String({ description: "Thread parent timestamp" })),
|
|
274
|
+
permalink: Type.Optional(Type.String({ description: "A Slack message permalink URL to parse channel+ts from" })),
|
|
275
|
+
cursor: Type.Optional(Type.String({ description: "Resume pagination from a next_cursor returned by a prior capped call" })),
|
|
276
|
+
}),
|
|
277
|
+
async execute(_toolCallId, params, signal) {
|
|
278
|
+
return guarded(async () => {
|
|
279
|
+
const { deps, cacheCtx } = await resolveCall("user", cfg, ctx, signal, repoRoot);
|
|
280
|
+
const channel = params.channel !== undefined ? await resolveChannel(params.channel, cacheCtx) : undefined;
|
|
281
|
+
const result = await readThread({ channel, ts: params.ts, permalink: params.permalink, cursor: params.cursor }, deps);
|
|
282
|
+
return {
|
|
283
|
+
content: [{ type: "text" as const, text: threadResultText(result) }],
|
|
284
|
+
details: result,
|
|
285
|
+
};
|
|
286
|
+
}, "user");
|
|
287
|
+
},
|
|
288
|
+
renderCall: (args, theme) => oneLine(theme, "slack_thread", args.permalink ?? args.channel ?? ""),
|
|
289
|
+
renderResult: renderToolResult,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
pi.registerTool({
|
|
293
|
+
name: "slack_post",
|
|
294
|
+
label: "Slack Post",
|
|
295
|
+
promptSnippet: "Post a Slack message, reply, or headline+detail announcement",
|
|
296
|
+
description:
|
|
297
|
+
"Post a Slack message via chat.postMessage, as `as: \"user\"` or `as: \"bot\"`. `channel` accepts #name or a channel ID (user @names not accepted). Plain post: `text` and/or `blocks` (Block Kit JSON, passed through unvalidated). Threaded reply: also set `thread_ts` - no headline is ever emitted, `thread_body` (or `text`) becomes the reply body. Announce mode: set `thread_body` WITHOUT `thread_ts` - posts a short single-line `text` headline, then posts `thread_body` as the first threaded reply in the same call; if `thread_body`'s rendered length exceeds the configured uploadThresholdChars (default 4000), it is delivered as a threaded file upload instead. Recovery: re-invoke with `thread_ts` set (never re-omit it) to post only into the existing thread - a second headline is never sent. On detail-delivery failure the headline is marked \"detail pending\" and the detail is saved to a temp file; the error names the path. `unfurl_links`/`unfurl_media` apply to this post only, are omitted when unset (Slack's default stands), and slack_update cannot change unfurling after the fact.",
|
|
298
|
+
parameters: Type.Object({
|
|
299
|
+
as: IDENTITY,
|
|
300
|
+
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
301
|
+
text: Type.Optional(Type.String({ description: "Message text, or the announce headline when thread_body is set" })),
|
|
302
|
+
blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
|
|
303
|
+
thread_ts: Type.Optional(Type.String({ description: "Reply into this existing thread instead of posting a new headline" })),
|
|
304
|
+
thread_body: Type.Optional(Type.String({ description: "Detail body for an announce headline, or the reply body when thread_ts is set" })),
|
|
305
|
+
unfurl_links: Type.Optional(
|
|
306
|
+
Type.Boolean({ description: "Slack unfurls link previews by default; pass false to suppress text-link previews for this message." }),
|
|
307
|
+
),
|
|
308
|
+
unfurl_media: Type.Optional(
|
|
309
|
+
Type.Boolean({ description: "Pass false to suppress image/video previews for this message." }),
|
|
310
|
+
),
|
|
311
|
+
}),
|
|
312
|
+
async execute(_toolCallId, params, signal) {
|
|
313
|
+
return guarded(async () => {
|
|
314
|
+
assertNoMarkdownText(params);
|
|
315
|
+
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
316
|
+
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
317
|
+
|
|
318
|
+
// Only the fields core will actually send: announce uses text + thread_body (both
|
|
319
|
+
// scanned), a threaded reply collapses to thread_body ?? text (whichever param carried
|
|
320
|
+
// the body is the one core reads, so substitute into that same field - never the
|
|
321
|
+
// other), a plain post is text alone.
|
|
322
|
+
const isAnnounce = params.thread_body !== undefined && params.thread_ts === undefined;
|
|
323
|
+
const isReply = params.thread_ts !== undefined;
|
|
324
|
+
let text: string | undefined;
|
|
325
|
+
let threadBody: string | undefined;
|
|
326
|
+
let mentions: { unresolved: UnresolvedMention[]; lookupError?: string };
|
|
327
|
+
if (isAnnounce) {
|
|
328
|
+
const resolved = await resolveMentions(
|
|
329
|
+
[
|
|
330
|
+
{ field: "text", value: params.text ?? "" },
|
|
331
|
+
{ field: "thread_body", value: params.thread_body ?? "" },
|
|
332
|
+
],
|
|
333
|
+
cacheCtx,
|
|
334
|
+
);
|
|
335
|
+
mentions = resolved;
|
|
336
|
+
text = resolved.values[0];
|
|
337
|
+
threadBody = resolved.values[1];
|
|
338
|
+
} else if (isReply) {
|
|
339
|
+
const replyField: "text" | "thread_body" = params.thread_body !== undefined ? "thread_body" : "text";
|
|
340
|
+
const resolved = await resolveMentions([{ field: replyField, value: params.thread_body ?? params.text ?? "" }], cacheCtx);
|
|
341
|
+
mentions = resolved;
|
|
342
|
+
if (replyField === "thread_body") {
|
|
343
|
+
text = params.text;
|
|
344
|
+
threadBody = resolved.values[0];
|
|
345
|
+
} else {
|
|
346
|
+
text = params.text === undefined ? undefined : resolved.values[0];
|
|
347
|
+
threadBody = undefined;
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
const resolved = await resolveMentions([{ field: "text", value: params.text ?? "" }], cacheCtx);
|
|
351
|
+
mentions = resolved;
|
|
352
|
+
text = params.text === undefined ? undefined : resolved.values[0];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const result = await postMessage(
|
|
356
|
+
{
|
|
357
|
+
channel,
|
|
358
|
+
text,
|
|
359
|
+
blocks: params.blocks,
|
|
360
|
+
thread_ts: params.thread_ts,
|
|
361
|
+
thread_body: threadBody,
|
|
362
|
+
unfurl_links: params.unfurl_links,
|
|
363
|
+
unfurl_media: params.unfurl_media,
|
|
364
|
+
},
|
|
365
|
+
{ ...deps, thresholdChars: cfg.uploadThresholdChars, uploadBytes: defaultUploadBytes },
|
|
366
|
+
);
|
|
367
|
+
return mentionAwareResult(result, mentions, { detailUploaded: "detailUploaded" in result && result.detailUploaded === true });
|
|
368
|
+
}, params.as);
|
|
369
|
+
},
|
|
370
|
+
renderCall: (args, theme) => oneLine(theme, "slack_post", `as:${args.as} ${args.channel}`),
|
|
371
|
+
renderResult: renderToolResult,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
pi.registerTool({
|
|
375
|
+
name: "slack_update",
|
|
376
|
+
label: "Slack Update",
|
|
377
|
+
promptSnippet: "Edit an existing Slack message",
|
|
378
|
+
description:
|
|
379
|
+
'Edit a message via chat.update, as `as: "user"` or `as: "bot"`. `channel` accepts #name or a channel ID (user @names not accepted). 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).',
|
|
380
|
+
parameters: Type.Object({
|
|
381
|
+
as: IDENTITY,
|
|
382
|
+
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
383
|
+
ts: Type.String({ description: "Timestamp of the message to edit" }),
|
|
384
|
+
text: Type.Optional(Type.String()),
|
|
385
|
+
blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
|
|
386
|
+
}),
|
|
387
|
+
async execute(_toolCallId, params, signal) {
|
|
388
|
+
return guarded(async () => {
|
|
389
|
+
assertNoMarkdownText(params);
|
|
390
|
+
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
391
|
+
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
392
|
+
const mentions = await resolveMentions([{ field: "text", value: params.text ?? "" }], cacheCtx);
|
|
393
|
+
const result = await updateMessage(
|
|
394
|
+
{ channel, ts: params.ts, text: params.text === undefined ? undefined : mentions.values[0], blocks: params.blocks },
|
|
395
|
+
deps,
|
|
396
|
+
);
|
|
397
|
+
return mentionAwareResult(result, mentions);
|
|
398
|
+
}, params.as);
|
|
399
|
+
},
|
|
400
|
+
renderCall: (args, theme) => oneLine(theme, "slack_update", `as:${args.as} ${args.channel} ts:${args.ts}`),
|
|
401
|
+
renderResult: renderToolResult,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
pi.registerTool({
|
|
405
|
+
name: "slack_delete",
|
|
406
|
+
label: "Slack Delete",
|
|
407
|
+
promptSnippet: "Delete a Slack message",
|
|
408
|
+
description:
|
|
409
|
+
'Delete a message via chat.delete, as `as: "user"` or `as: "bot"`. `channel` accepts #name or a channel ID (user @names not accepted). Only the identity that originally posted the message can delete it (Slack constraint; surfaced as an error otherwise).',
|
|
410
|
+
parameters: Type.Object({
|
|
411
|
+
as: IDENTITY,
|
|
412
|
+
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
413
|
+
ts: Type.String({ description: "Timestamp of the message to delete" }),
|
|
414
|
+
}),
|
|
415
|
+
async execute(_toolCallId, params, signal) {
|
|
416
|
+
return guarded(async () => {
|
|
417
|
+
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
418
|
+
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
419
|
+
const result = await deleteMessage({ channel, ts: params.ts }, deps);
|
|
420
|
+
return {
|
|
421
|
+
content: [{ type: "text" as const, text: channelLine(result) }],
|
|
422
|
+
details: result,
|
|
423
|
+
};
|
|
424
|
+
}, params.as);
|
|
425
|
+
},
|
|
426
|
+
renderCall: (args, theme) => oneLine(theme, "slack_delete", `as:${args.as} ${args.channel} ts:${args.ts}`),
|
|
427
|
+
renderResult: renderToolResult,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
pi.registerTool({
|
|
431
|
+
name: "slack_pin",
|
|
432
|
+
label: "Slack Pin",
|
|
433
|
+
promptSnippet: "Pin a Slack message to its channel",
|
|
434
|
+
description:
|
|
435
|
+
'Pin a message via pins.add, as `as: "user"` or `as: "bot"`. `channel` accepts #name or a channel ID (user @names not accepted). Slack errors are mapped: already_pinned, not_pinnable (this message type cannot be pinned), too_many_pins (the channel hit Slack\'s pin limit).',
|
|
436
|
+
parameters: Type.Object({
|
|
437
|
+
as: IDENTITY,
|
|
438
|
+
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
439
|
+
ts: Type.String({ description: "Timestamp of the message to pin" }),
|
|
440
|
+
}),
|
|
441
|
+
async execute(_toolCallId, params, signal) {
|
|
442
|
+
return guarded(async () => {
|
|
443
|
+
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
444
|
+
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
445
|
+
const result = await pinMessage({ channel, ts: params.ts }, deps);
|
|
446
|
+
return {
|
|
447
|
+
content: [{ type: "text" as const, text: channelLine(result) }],
|
|
448
|
+
details: result,
|
|
449
|
+
};
|
|
450
|
+
}, params.as);
|
|
451
|
+
},
|
|
452
|
+
renderCall: (args, theme) => oneLine(theme, "slack_pin", `as:${args.as} ${args.channel} ts:${args.ts}`),
|
|
453
|
+
renderResult: renderToolResult,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
pi.registerTool({
|
|
457
|
+
name: "slack_upload",
|
|
458
|
+
label: "Slack Upload",
|
|
459
|
+
promptSnippet: "Upload a file to a Slack channel or thread",
|
|
460
|
+
description:
|
|
461
|
+
'Upload a file to Slack (getUploadURLExternal -> upload -> completeUploadExternal), as `as: "user"` or `as: "bot"`. `channel` accepts #name or a channel ID (user @names not accepted). `path` is an absolute path or resolved relative to the current working directory; a missing file errors before any network call. `filename` defaults to the path\'s basename. Optional `title`, `thread_ts` (attach to an existing thread), and `initial_comment`.',
|
|
462
|
+
parameters: Type.Object({
|
|
463
|
+
as: IDENTITY,
|
|
464
|
+
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
465
|
+
path: Type.String({ description: "Absolute path, or a path relative to the current working directory" }),
|
|
466
|
+
filename: Type.Optional(Type.String({ description: "Defaults to the basename of path" })),
|
|
467
|
+
title: Type.Optional(Type.String()),
|
|
468
|
+
thread_ts: Type.Optional(Type.String({ description: "Attach the upload to this existing thread" })),
|
|
469
|
+
initial_comment: Type.Optional(Type.String()),
|
|
470
|
+
}),
|
|
471
|
+
async execute(_toolCallId, params, signal) {
|
|
472
|
+
return guarded(async () => {
|
|
473
|
+
const resolvedPath = isAbsolute(params.path) ? params.path : join(ctx.cwd, params.path);
|
|
474
|
+
let bytes: Uint8Array;
|
|
475
|
+
try {
|
|
476
|
+
bytes = readFileSync(resolvedPath);
|
|
477
|
+
} catch (err) {
|
|
478
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
479
|
+
throw new SlackError("file_not_found", `Cannot read file at ${resolvedPath}: ${detail}`);
|
|
480
|
+
}
|
|
481
|
+
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
482
|
+
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
483
|
+
const result = await uploadFile(
|
|
484
|
+
{
|
|
485
|
+
channel,
|
|
486
|
+
bytes,
|
|
487
|
+
filename: params.filename ?? basename(resolvedPath),
|
|
488
|
+
title: params.title,
|
|
489
|
+
thread_ts: params.thread_ts,
|
|
490
|
+
initial_comment: params.initial_comment,
|
|
491
|
+
},
|
|
492
|
+
{ ...deps, uploadBytes: defaultUploadBytes },
|
|
493
|
+
);
|
|
494
|
+
return {
|
|
495
|
+
content: [{ type: "text" as const, text: channelLine(result) }],
|
|
496
|
+
details: result,
|
|
497
|
+
};
|
|
498
|
+
}, params.as);
|
|
499
|
+
},
|
|
500
|
+
renderCall: (args, theme) => oneLine(theme, "slack_upload", `as:${args.as} ${args.channel} ${args.path}`),
|
|
501
|
+
renderResult: renderToolResult,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
pi.registerTool({
|
|
505
|
+
name: "slack_cache_refresh",
|
|
506
|
+
label: "Slack Cache Refresh",
|
|
507
|
+
promptSnippet: "Rebuild the Slack channel/user name->ID cache",
|
|
508
|
+
description:
|
|
509
|
+
'Rebuild the Slack channel and user name->ID cache from scratch (full conversations.list + users.list scan, atomic replace). Uses the "user" identity when a user token is configured, else falls back to "bot" (no `as` param). Run this after channels/users change or when a #name/@name lookup unexpectedly fails with name_not_found. Reports the resulting channel, user, and email counts - a low email/user ratio hints the users:read.email scope may be missing.',
|
|
510
|
+
parameters: Type.Object({}),
|
|
511
|
+
async execute(_toolCallId, _params, signal) {
|
|
512
|
+
const identity = pickCacheRefreshIdentity(cfg, process.env, repoRoot);
|
|
513
|
+
return guarded(async () => {
|
|
514
|
+
const { cacheCtx } = await resolveCall(identity, cfg, ctx, signal, repoRoot);
|
|
515
|
+
const result = await refreshCache(cacheCtx);
|
|
516
|
+
const ratio =
|
|
517
|
+
result.users === 0
|
|
518
|
+
? ""
|
|
519
|
+
: result.emails === 0
|
|
520
|
+
? ` | emails: 0/${result.users} (users:read.email scope may be missing)`
|
|
521
|
+
: ` | emails: ${result.emails}/${result.users}`;
|
|
522
|
+
return {
|
|
523
|
+
content: [{ type: "text" as const, text: `channels: ${result.channels}, users: ${result.users}${ratio}` }],
|
|
524
|
+
details: result,
|
|
525
|
+
};
|
|
526
|
+
}, identity);
|
|
527
|
+
},
|
|
528
|
+
renderCall: (_args, theme) => oneLine(theme, "slack_cache_refresh", ""),
|
|
529
|
+
renderResult: renderToolResult,
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
}
|