pi-quiver 4.4.0 → 5.0.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 +103 -25
- package/extensions/fast-mode.ts +1 -1
- package/extensions/provider-stall-watchdog.ts +3 -3
- package/extensions/session-name.ts +1 -1
- package/extensions/slack.ts +430 -0
- package/extensions/sword-header.ts +1 -1
- package/lib/extension-config.ts +46 -3
- package/lib/slack-cache.ts +338 -0
- package/lib/slack-core.ts +994 -0
- package/package.json +1 -1
|
@@ -0,0 +1,430 @@
|
|
|
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
|
+
discoverRepoRoot,
|
|
20
|
+
resolveSlackConfig,
|
|
21
|
+
resolveToken,
|
|
22
|
+
searchMessages,
|
|
23
|
+
readThread,
|
|
24
|
+
postMessage,
|
|
25
|
+
updateMessage,
|
|
26
|
+
deleteMessage,
|
|
27
|
+
pinMessage,
|
|
28
|
+
uploadFile,
|
|
29
|
+
SlackError,
|
|
30
|
+
type SlackConfig,
|
|
31
|
+
type CoreDeps,
|
|
32
|
+
type MutationResult,
|
|
33
|
+
type AnnounceResult,
|
|
34
|
+
type SearchResult,
|
|
35
|
+
type ThreadResult,
|
|
36
|
+
} from "../lib/slack-core.ts";
|
|
37
|
+
import { cacheFilePath, teamIdFor, resolveChannel, refreshCache, assertSameTeam, type CacheCtx } from "../lib/slack-cache.ts";
|
|
38
|
+
|
|
39
|
+
const IDENTITY = Type.Union([Type.Literal("user"), Type.Literal("bot")], {
|
|
40
|
+
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.',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Approved recovery fields only - never dump the raw Slack API response (err.data can carry it
|
|
44
|
+
// verbatim for ok:false mappings). Everything a caller needs to recover a mutation lives in this
|
|
45
|
+
// small whitelist.
|
|
46
|
+
const ALLOWED_ERROR_DATA_KEYS = new Set(["ts", "channel", "permalink", "detailPath", "thread_ts"]);
|
|
47
|
+
|
|
48
|
+
export function formatToolError(err: unknown, identity?: "user" | "bot"): Error {
|
|
49
|
+
if (err instanceof SlackError) {
|
|
50
|
+
const identitySuffix = err.code === "missing_scope" && identity ? ` (identity: ${identity})` : "";
|
|
51
|
+
const filtered = err.data
|
|
52
|
+
? Object.fromEntries(Object.entries(err.data).filter(([key]) => ALLOWED_ERROR_DATA_KEYS.has(key)))
|
|
53
|
+
: undefined;
|
|
54
|
+
const data = filtered && Object.keys(filtered).length > 0 ? `\n${JSON.stringify(filtered)}` : "";
|
|
55
|
+
return new Error(`${err.code}: ${err.message}${identitySuffix}${data}`);
|
|
56
|
+
}
|
|
57
|
+
if (err instanceof Error) return err;
|
|
58
|
+
return new Error(String(err));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ResolvedCall {
|
|
62
|
+
token: string;
|
|
63
|
+
filePath: string;
|
|
64
|
+
cacheCtx: CacheCtx;
|
|
65
|
+
deps: CoreDeps;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function resolveCall(
|
|
69
|
+
identity: "user" | "bot",
|
|
70
|
+
cfg: SlackConfig,
|
|
71
|
+
ctx: ExtensionContext,
|
|
72
|
+
signal: AbortSignal | undefined,
|
|
73
|
+
repoRoot: string,
|
|
74
|
+
): Promise<ResolvedCall> {
|
|
75
|
+
const token = resolveToken(identity, cfg, process.env, repoRoot);
|
|
76
|
+
const teamId = await teamIdFor(token, defaultApiCall, signal);
|
|
77
|
+
const otherIdentity = identity === "user" ? "bot" : "user";
|
|
78
|
+
const otherTeamId = await (async () => {
|
|
79
|
+
try {
|
|
80
|
+
const otherToken = resolveToken(otherIdentity, cfg, process.env, repoRoot);
|
|
81
|
+
return await teamIdFor(otherToken, defaultApiCall, signal);
|
|
82
|
+
} catch {
|
|
83
|
+
// Other identity's token can't be resolved/authenticated - best-effort
|
|
84
|
+
// cross-check only, never fail the acting identity's call for this.
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
if (otherTeamId !== undefined) {
|
|
89
|
+
assertSameTeam(teamId, otherTeamId);
|
|
90
|
+
}
|
|
91
|
+
const filePath = cacheFilePath(cfg, repoRoot, teamId);
|
|
92
|
+
const cacheCtx: CacheCtx = { apiCall: defaultApiCall, token, filePath, signal };
|
|
93
|
+
const deps: CoreDeps = { apiCall: defaultApiCall, token, signal };
|
|
94
|
+
return { token, filePath, cacheCtx, deps };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* slack_cache_refresh's identity pick: user token when present, else bot (spec "Cache" section).
|
|
99
|
+
* Pure and network-free - resolveToken only reads env/.env - so it's unit-testable without a
|
|
100
|
+
* transport seam.
|
|
101
|
+
*/
|
|
102
|
+
export function pickCacheRefreshIdentity(
|
|
103
|
+
cfg: SlackConfig,
|
|
104
|
+
env: Record<string, string | undefined>,
|
|
105
|
+
repoRoot: string,
|
|
106
|
+
): "user" | "bot" {
|
|
107
|
+
try {
|
|
108
|
+
resolveToken("user", cfg, env, repoRoot);
|
|
109
|
+
return "user";
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err instanceof SlackError && err.code === "missing_token") return "bot";
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** G9: markdown_text is rejected as a posting field (spec "Tool surface") - schemas don't define
|
|
117
|
+
* it, so TypeBox's non-strict Type.Object would otherwise pass it through silently. */
|
|
118
|
+
function assertNoMarkdownText(params: object): void {
|
|
119
|
+
if ("markdown_text" in params) {
|
|
120
|
+
throw new SlackError("invalid_argument", "markdown_text is not supported; use text and/or blocks");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function guarded<T>(fn: () => Promise<T>, identity?: "user" | "bot"): Promise<T> {
|
|
125
|
+
try {
|
|
126
|
+
return await fn();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw formatToolError(err, identity);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function channelLine(result: MutationResult | (MutationResult & { fileId: string })): string {
|
|
133
|
+
const parts = [`channel ${result.channel}`];
|
|
134
|
+
if (result.ts) parts.push(`ts ${result.ts}`);
|
|
135
|
+
if (result.permalink) parts.push(result.permalink);
|
|
136
|
+
if ("fileId" in result) parts.push(`file ${result.fileId}`);
|
|
137
|
+
if (result.warning) parts.push(`warning: ${result.warning}`);
|
|
138
|
+
if ((result as AnnounceResult).detailTs) parts.push(`detail ts ${(result as AnnounceResult).detailTs}`);
|
|
139
|
+
return parts.join(" | ");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function searchResultText(result: SearchResult): string {
|
|
143
|
+
return `${result.output}\n\ntotal: ${result.total} | page: ${result.page} of ${result.pageCount}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function threadResultText(result: ThreadResult): string {
|
|
147
|
+
const lines = [result.output, "", `complete: ${result.complete}`];
|
|
148
|
+
if (!result.complete && result.nextCursor) lines.push(`next_cursor: ${result.nextCursor}`);
|
|
149
|
+
if (result.caveat) lines.push(result.caveat);
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function oneLine(theme: Theme, name: string, arg: string): Text {
|
|
154
|
+
return new Text(`${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("accent", arg)}`, 0, 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function renderToolResult(
|
|
158
|
+
result: { content: { type: string; text?: string }[] },
|
|
159
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
160
|
+
theme: Theme,
|
|
161
|
+
context: { isError: boolean },
|
|
162
|
+
): Text {
|
|
163
|
+
const content = result.content[0];
|
|
164
|
+
const fullText = content?.type === "text" ? (content.text ?? "") : "";
|
|
165
|
+
if (options.isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
|
|
166
|
+
if (context.isError) {
|
|
167
|
+
const firstLine = fullText.split("\n")[0] || "slack call failed";
|
|
168
|
+
return new Text(theme.fg("error", firstLine), 0, 0);
|
|
169
|
+
}
|
|
170
|
+
if (!options.expanded) {
|
|
171
|
+
return new Text(theme.fg("toolOutput", fullText.split("\n")[0] || ""), 0, 0);
|
|
172
|
+
}
|
|
173
|
+
return new Text(
|
|
174
|
+
fullText
|
|
175
|
+
.split("\n")
|
|
176
|
+
.map((line) => theme.fg("toolOutput", line))
|
|
177
|
+
.join("\n"),
|
|
178
|
+
0,
|
|
179
|
+
0,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default function slackExtension(pi: ExtensionAPI) {
|
|
184
|
+
let registered = false;
|
|
185
|
+
|
|
186
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
187
|
+
const cfg = resolveSlackConfig(ctx.cwd, process.env, (m) => ctx.ui.notify(m, "warning"));
|
|
188
|
+
if (cfg.enabled !== true) return;
|
|
189
|
+
if (registered) return;
|
|
190
|
+
registered = true;
|
|
191
|
+
|
|
192
|
+
const repoRoot = discoverRepoRoot(ctx.cwd);
|
|
193
|
+
|
|
194
|
+
pi.registerTool({
|
|
195
|
+
name: "slack_search",
|
|
196
|
+
label: "Slack Search",
|
|
197
|
+
promptSnippet: "Search Slack messages with Slack's search operator grammar",
|
|
198
|
+
description:
|
|
199
|
+
'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.',
|
|
200
|
+
parameters: Type.Object({
|
|
201
|
+
query: Type.String({ description: "Slack search query, e.g. 'deploy in:#eng from:@alice'" }),
|
|
202
|
+
count: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 20 })),
|
|
203
|
+
page: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
204
|
+
}),
|
|
205
|
+
async execute(_toolCallId, params, signal) {
|
|
206
|
+
return guarded(async () => {
|
|
207
|
+
const { deps } = await resolveCall("user", cfg, ctx, signal, repoRoot);
|
|
208
|
+
const result = await searchMessages(params, deps);
|
|
209
|
+
return {
|
|
210
|
+
content: [{ type: "text" as const, text: searchResultText(result) }],
|
|
211
|
+
details: result,
|
|
212
|
+
};
|
|
213
|
+
}, "user");
|
|
214
|
+
},
|
|
215
|
+
renderCall: (args, theme) => oneLine(theme, "slack_search", args.query ?? ""),
|
|
216
|
+
renderResult: renderToolResult,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
pi.registerTool({
|
|
220
|
+
name: "slack_thread",
|
|
221
|
+
label: "Slack Thread",
|
|
222
|
+
promptSnippet: "Read all replies in a Slack thread",
|
|
223
|
+
description:
|
|
224
|
+
'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.',
|
|
225
|
+
parameters: Type.Object({
|
|
226
|
+
channel: Type.Optional(Type.String({ description: "#name or channel ID (user @names not accepted)" })),
|
|
227
|
+
ts: Type.Optional(Type.String({ description: "Thread parent timestamp" })),
|
|
228
|
+
permalink: Type.Optional(Type.String({ description: "A Slack message permalink URL to parse channel+ts from" })),
|
|
229
|
+
cursor: Type.Optional(Type.String({ description: "Resume pagination from a next_cursor returned by a prior capped call" })),
|
|
230
|
+
}),
|
|
231
|
+
async execute(_toolCallId, params, signal) {
|
|
232
|
+
return guarded(async () => {
|
|
233
|
+
const { deps, cacheCtx } = await resolveCall("user", cfg, ctx, signal, repoRoot);
|
|
234
|
+
const channel = params.channel !== undefined ? await resolveChannel(params.channel, cacheCtx) : undefined;
|
|
235
|
+
const result = await readThread({ channel, ts: params.ts, permalink: params.permalink, cursor: params.cursor }, deps);
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: "text" as const, text: threadResultText(result) }],
|
|
238
|
+
details: result,
|
|
239
|
+
};
|
|
240
|
+
}, "user");
|
|
241
|
+
},
|
|
242
|
+
renderCall: (args, theme) => oneLine(theme, "slack_thread", args.permalink ?? args.channel ?? ""),
|
|
243
|
+
renderResult: renderToolResult,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
pi.registerTool({
|
|
247
|
+
name: "slack_post",
|
|
248
|
+
label: "Slack Post",
|
|
249
|
+
promptSnippet: "Post a Slack message, reply, or headline+detail announcement",
|
|
250
|
+
description:
|
|
251
|
+
"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.",
|
|
252
|
+
parameters: Type.Object({
|
|
253
|
+
as: IDENTITY,
|
|
254
|
+
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
255
|
+
text: Type.Optional(Type.String({ description: "Message text, or the announce headline when thread_body is set" })),
|
|
256
|
+
blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
|
|
257
|
+
thread_ts: Type.Optional(Type.String({ description: "Reply into this existing thread instead of posting a new headline" })),
|
|
258
|
+
thread_body: Type.Optional(Type.String({ description: "Detail body for an announce headline, or the reply body when thread_ts is set" })),
|
|
259
|
+
}),
|
|
260
|
+
async execute(_toolCallId, params, signal) {
|
|
261
|
+
return guarded(async () => {
|
|
262
|
+
assertNoMarkdownText(params);
|
|
263
|
+
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
264
|
+
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
265
|
+
const result = await postMessage(
|
|
266
|
+
{ channel, text: params.text, blocks: params.blocks, thread_ts: params.thread_ts, thread_body: params.thread_body },
|
|
267
|
+
{ ...deps, thresholdChars: cfg.uploadThresholdChars, uploadBytes: defaultUploadBytes },
|
|
268
|
+
);
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: "text" as const, text: channelLine(result) }],
|
|
271
|
+
details: result,
|
|
272
|
+
};
|
|
273
|
+
}, params.as);
|
|
274
|
+
},
|
|
275
|
+
renderCall: (args, theme) => oneLine(theme, "slack_post", `as:${args.as} ${args.channel}`),
|
|
276
|
+
renderResult: renderToolResult,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
pi.registerTool({
|
|
280
|
+
name: "slack_update",
|
|
281
|
+
label: "Slack Update",
|
|
282
|
+
promptSnippet: "Edit an existing Slack message",
|
|
283
|
+
description:
|
|
284
|
+
'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).',
|
|
285
|
+
parameters: Type.Object({
|
|
286
|
+
as: IDENTITY,
|
|
287
|
+
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
288
|
+
ts: Type.String({ description: "Timestamp of the message to edit" }),
|
|
289
|
+
text: Type.Optional(Type.String()),
|
|
290
|
+
blocks: Type.Optional(Type.Array(Type.Unknown(), { description: "Block Kit JSON array, passed through unvalidated" })),
|
|
291
|
+
}),
|
|
292
|
+
async execute(_toolCallId, params, signal) {
|
|
293
|
+
return guarded(async () => {
|
|
294
|
+
assertNoMarkdownText(params);
|
|
295
|
+
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
296
|
+
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
297
|
+
const result = await updateMessage({ channel, ts: params.ts, text: params.text, blocks: params.blocks }, deps);
|
|
298
|
+
return {
|
|
299
|
+
content: [{ type: "text" as const, text: channelLine(result) }],
|
|
300
|
+
details: result,
|
|
301
|
+
};
|
|
302
|
+
}, params.as);
|
|
303
|
+
},
|
|
304
|
+
renderCall: (args, theme) => oneLine(theme, "slack_update", `as:${args.as} ${args.channel} ts:${args.ts}`),
|
|
305
|
+
renderResult: renderToolResult,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
pi.registerTool({
|
|
309
|
+
name: "slack_delete",
|
|
310
|
+
label: "Slack Delete",
|
|
311
|
+
promptSnippet: "Delete a Slack message",
|
|
312
|
+
description:
|
|
313
|
+
'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).',
|
|
314
|
+
parameters: Type.Object({
|
|
315
|
+
as: IDENTITY,
|
|
316
|
+
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
317
|
+
ts: Type.String({ description: "Timestamp of the message to delete" }),
|
|
318
|
+
}),
|
|
319
|
+
async execute(_toolCallId, params, signal) {
|
|
320
|
+
return guarded(async () => {
|
|
321
|
+
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
322
|
+
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
323
|
+
const result = await deleteMessage({ channel, ts: params.ts }, deps);
|
|
324
|
+
return {
|
|
325
|
+
content: [{ type: "text" as const, text: channelLine(result) }],
|
|
326
|
+
details: result,
|
|
327
|
+
};
|
|
328
|
+
}, params.as);
|
|
329
|
+
},
|
|
330
|
+
renderCall: (args, theme) => oneLine(theme, "slack_delete", `as:${args.as} ${args.channel} ts:${args.ts}`),
|
|
331
|
+
renderResult: renderToolResult,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
pi.registerTool({
|
|
335
|
+
name: "slack_pin",
|
|
336
|
+
label: "Slack Pin",
|
|
337
|
+
promptSnippet: "Pin a Slack message to its channel",
|
|
338
|
+
description:
|
|
339
|
+
'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).',
|
|
340
|
+
parameters: Type.Object({
|
|
341
|
+
as: IDENTITY,
|
|
342
|
+
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
343
|
+
ts: Type.String({ description: "Timestamp of the message to pin" }),
|
|
344
|
+
}),
|
|
345
|
+
async execute(_toolCallId, params, signal) {
|
|
346
|
+
return guarded(async () => {
|
|
347
|
+
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
348
|
+
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
349
|
+
const result = await pinMessage({ channel, ts: params.ts }, deps);
|
|
350
|
+
return {
|
|
351
|
+
content: [{ type: "text" as const, text: channelLine(result) }],
|
|
352
|
+
details: result,
|
|
353
|
+
};
|
|
354
|
+
}, params.as);
|
|
355
|
+
},
|
|
356
|
+
renderCall: (args, theme) => oneLine(theme, "slack_pin", `as:${args.as} ${args.channel} ts:${args.ts}`),
|
|
357
|
+
renderResult: renderToolResult,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
pi.registerTool({
|
|
361
|
+
name: "slack_upload",
|
|
362
|
+
label: "Slack Upload",
|
|
363
|
+
promptSnippet: "Upload a file to a Slack channel or thread",
|
|
364
|
+
description:
|
|
365
|
+
'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`.',
|
|
366
|
+
parameters: Type.Object({
|
|
367
|
+
as: IDENTITY,
|
|
368
|
+
channel: Type.String({ description: "#name or channel ID (user @names not accepted)" }),
|
|
369
|
+
path: Type.String({ description: "Absolute path, or a path relative to the current working directory" }),
|
|
370
|
+
filename: Type.Optional(Type.String({ description: "Defaults to the basename of path" })),
|
|
371
|
+
title: Type.Optional(Type.String()),
|
|
372
|
+
thread_ts: Type.Optional(Type.String({ description: "Attach the upload to this existing thread" })),
|
|
373
|
+
initial_comment: Type.Optional(Type.String()),
|
|
374
|
+
}),
|
|
375
|
+
async execute(_toolCallId, params, signal) {
|
|
376
|
+
return guarded(async () => {
|
|
377
|
+
const resolvedPath = isAbsolute(params.path) ? params.path : join(ctx.cwd, params.path);
|
|
378
|
+
let bytes: Uint8Array;
|
|
379
|
+
try {
|
|
380
|
+
bytes = readFileSync(resolvedPath);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
383
|
+
throw new SlackError("file_not_found", `Cannot read file at ${resolvedPath}: ${detail}`);
|
|
384
|
+
}
|
|
385
|
+
const { deps, cacheCtx } = await resolveCall(params.as, cfg, ctx, signal, repoRoot);
|
|
386
|
+
const channel = await resolveChannel(params.channel, cacheCtx);
|
|
387
|
+
const result = await uploadFile(
|
|
388
|
+
{
|
|
389
|
+
channel,
|
|
390
|
+
bytes,
|
|
391
|
+
filename: params.filename ?? basename(resolvedPath),
|
|
392
|
+
title: params.title,
|
|
393
|
+
thread_ts: params.thread_ts,
|
|
394
|
+
initial_comment: params.initial_comment,
|
|
395
|
+
},
|
|
396
|
+
{ ...deps, uploadBytes: defaultUploadBytes },
|
|
397
|
+
);
|
|
398
|
+
return {
|
|
399
|
+
content: [{ type: "text" as const, text: channelLine(result) }],
|
|
400
|
+
details: result,
|
|
401
|
+
};
|
|
402
|
+
}, params.as);
|
|
403
|
+
},
|
|
404
|
+
renderCall: (args, theme) => oneLine(theme, "slack_upload", `as:${args.as} ${args.channel} ${args.path}`),
|
|
405
|
+
renderResult: renderToolResult,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
pi.registerTool({
|
|
409
|
+
name: "slack_cache_refresh",
|
|
410
|
+
label: "Slack Cache Refresh",
|
|
411
|
+
promptSnippet: "Rebuild the Slack channel/user name->ID cache",
|
|
412
|
+
description:
|
|
413
|
+
'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 and user counts.',
|
|
414
|
+
parameters: Type.Object({}),
|
|
415
|
+
async execute(_toolCallId, _params, signal) {
|
|
416
|
+
const identity = pickCacheRefreshIdentity(cfg, process.env, repoRoot);
|
|
417
|
+
return guarded(async () => {
|
|
418
|
+
const { cacheCtx } = await resolveCall(identity, cfg, ctx, signal, repoRoot);
|
|
419
|
+
const result = await refreshCache(cacheCtx);
|
|
420
|
+
return {
|
|
421
|
+
content: [{ type: "text" as const, text: `channels: ${result.channels}, users: ${result.users}` }],
|
|
422
|
+
details: result,
|
|
423
|
+
};
|
|
424
|
+
}, identity);
|
|
425
|
+
},
|
|
426
|
+
renderCall: (_args, theme) => oneLine(theme, "slack_cache_refresh", ""),
|
|
427
|
+
renderResult: renderToolResult,
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
}
|
|
@@ -66,7 +66,7 @@ function renderSwordLines(theme: Theme): string[] {
|
|
|
66
66
|
export default function (pi: ExtensionAPI) {
|
|
67
67
|
pi.on("session_start", async (_event, ctx) => {
|
|
68
68
|
if (ctx.mode !== "tui") return;
|
|
69
|
-
const cfg = resolveConfig(ctx.cwd, "swordHeader", DEFAULT_CONFIG, coerce);
|
|
69
|
+
const cfg = resolveConfig(ctx.cwd, "swordHeader", DEFAULT_CONFIG, coerce, (m) => ctx.ui.notify(m, "warning"));
|
|
70
70
|
if (!cfg.enabled) return;
|
|
71
71
|
ctx.ui.setHeader((_tui, theme) => ({
|
|
72
72
|
render(_width: number): string[] {
|
package/lib/extension-config.ts
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* correct when these extensions are consumed as a git-tag-pinned package -
|
|
12
12
|
* unlike deriving the path from `import.meta.url`, which only held while an
|
|
13
13
|
* extension lived inside `<agentHome>/extensions/`.
|
|
14
|
+
*
|
|
15
|
+
* Within each layer, a nested `quiver.<key>` takes precedence over the flat
|
|
16
|
+
* `<key>` by presence alone (even when malformed); the flat top-level
|
|
17
|
+
* fallback is frozen to the pre-quiver LEGACY_FLAT_KEYS and never extended.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
import { readFileSync } from "node:fs";
|
|
@@ -29,21 +33,60 @@ export function settingsPaths(cwd: string): string[] {
|
|
|
29
33
|
return [join(getAgentDir(), "settings.json"), join(cwd, ".pi", "settings.json")];
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
/** Flat top-level fallback is frozen to these pre-quiver keys; never extend. */
|
|
37
|
+
const LEGACY_FLAT_KEYS = new Set(["fastMode", "sessionAutoName", "swordHeader", "providerStallWatchdog"]);
|
|
38
|
+
|
|
39
|
+
const emittedWarnings = new Set<string>();
|
|
40
|
+
|
|
41
|
+
function emitWarning(warn: ((message: string) => void) | undefined, message: string): void {
|
|
42
|
+
if (!warn || emittedWarnings.has(message)) return;
|
|
43
|
+
emittedWarnings.add(message);
|
|
44
|
+
warn(message);
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
/**
|
|
33
|
-
* Resolve a single extension config key across the settings layers.
|
|
34
|
-
*
|
|
48
|
+
* Resolve a single extension config key across the settings layers.
|
|
49
|
+
* `quiver.<key>` wins over flat `<key>` within a layer by presence (even
|
|
50
|
+
* when malformed); flat fallback exists only for LEGACY_FLAT_KEYS. `coerce`
|
|
51
|
+
* validates the layer's candidate into a partial patch (or `undefined` to
|
|
35
52
|
* skip); patches merge over `defaults` in layer order (project wins).
|
|
53
|
+
* `warn` receives one sentence per malformed or flat/nested-duplicated key,
|
|
54
|
+
* deduped per process.
|
|
36
55
|
*/
|
|
37
56
|
export function resolveConfig<T extends object>(
|
|
38
57
|
cwd: string,
|
|
39
58
|
key: string,
|
|
40
59
|
defaults: T,
|
|
41
60
|
coerce: (raw: unknown) => Partial<T> | undefined,
|
|
61
|
+
warn?: (message: string) => void,
|
|
42
62
|
): T {
|
|
43
63
|
const cfg: T = { ...defaults };
|
|
64
|
+
let nestedSeen = false;
|
|
65
|
+
let flatSeen = false;
|
|
44
66
|
for (const path of settingsPaths(cwd)) {
|
|
45
|
-
const
|
|
67
|
+
const settings = readSettings(path);
|
|
68
|
+
if (!settings) continue;
|
|
69
|
+
let root = settings.quiver;
|
|
70
|
+
if (root !== undefined && (root === null || typeof root !== "object" || Array.isArray(root))) {
|
|
71
|
+
emitWarning(warn, `pi-quiver: "quiver" in ${path} is not an object; ignored.`);
|
|
72
|
+
root = undefined;
|
|
73
|
+
}
|
|
74
|
+
const nested = root as Record<string, unknown> | undefined;
|
|
75
|
+
const hasNested = nested !== undefined && Object.hasOwn(nested, key);
|
|
76
|
+
const hasFlat = Object.hasOwn(settings, key);
|
|
77
|
+
nestedSeen ||= hasNested;
|
|
78
|
+
flatSeen ||= hasFlat;
|
|
79
|
+
if (!hasNested && !(hasFlat && LEGACY_FLAT_KEYS.has(key))) continue;
|
|
80
|
+
const candidate = hasNested ? nested![key] : settings[key];
|
|
81
|
+
const patch = coerce(candidate);
|
|
46
82
|
if (patch) Object.assign(cfg, patch);
|
|
83
|
+
else emitWarning(warn, `pi-quiver: "${key}" in ${path} has an unrecognized value; ignored.`);
|
|
84
|
+
}
|
|
85
|
+
if (nestedSeen && flatSeen) {
|
|
86
|
+
emitWarning(
|
|
87
|
+
warn,
|
|
88
|
+
`pi-quiver: "${key}" is set both flat and under "quiver" (nested wins within a layer; across layers the project layer wins regardless of shape) - move the flat entry under "quiver".`,
|
|
89
|
+
);
|
|
47
90
|
}
|
|
48
91
|
return cfg;
|
|
49
92
|
}
|