@sassywares/pi-extensions 0.0.0-stage → 0.3.1

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.
@@ -0,0 +1,623 @@
1
+ /**
2
+ * Web Search + Fetch Tools
3
+ *
4
+ * Zero-config web search using Exa's public MCP endpoint, plus a basic
5
+ * HTML/text page fetcher with local-address protection.
6
+ * Optionally set EXA_API_KEY to use the direct Exa API instead.
7
+ *
8
+ * Load with: pi -e ~/.pi/agent/extensions/web-search.ts
9
+ */
10
+
11
+ import { lookup } from "node:dns/promises";
12
+ import { isIP } from "node:net";
13
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { Type } from "typebox";
15
+
16
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
17
+ const EXA_API_URL = "https://api.exa.ai/search";
18
+ const REQUEST_TIMEOUT_MS = 30_000;
19
+ const FETCH_TIMEOUT_MS = 20_000;
20
+ const MAX_SNIPPET_CHARS = 400;
21
+ const MAX_FETCH_BYTES = 2 * 1024 * 1024;
22
+ const DEFAULT_FETCH_CHARS = 12_000;
23
+ const MAX_FETCH_CHARS = 50_000;
24
+ const MAX_REDIRECTS = 5;
25
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
26
+
27
+ interface SearchResult {
28
+ title: string;
29
+ url: string;
30
+ snippet: string;
31
+ }
32
+
33
+ interface SearchDetails {
34
+ query: string;
35
+ provider: "exa-mcp" | "exa-api";
36
+ count: number;
37
+ results: SearchResult[];
38
+ }
39
+
40
+ const webSearchParams = Type.Object({
41
+ query: Type.String({
42
+ description: "The search query. Be specific; include keywords and context.",
43
+ }),
44
+ numResults: Type.Optional(
45
+ Type.Integer({
46
+ description: "Number of results to return (1-10, default 5).",
47
+ minimum: 1,
48
+ maximum: 10,
49
+ default: 5,
50
+ }),
51
+ ),
52
+ });
53
+
54
+ function requestSignal(signal?: AbortSignal): AbortSignal {
55
+ return signal
56
+ ? AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal])
57
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
58
+ }
59
+
60
+ function normalizeWhitespace(value: string): string {
61
+ return value.replace(/\s+/g, " ").trim();
62
+ }
63
+
64
+ function snippet(value: string): string {
65
+ const text = normalizeWhitespace(value);
66
+ return text.length > MAX_SNIPPET_CHARS
67
+ ? `${text.slice(0, MAX_SNIPPET_CHARS - 1)}…`
68
+ : text;
69
+ }
70
+
71
+ function parseMcpResults(text: string): SearchResult[] {
72
+ const blocks = text.split(/(?=^Title: )/m).filter((block) => block.trim());
73
+ const results: SearchResult[] = [];
74
+
75
+ for (const block of blocks) {
76
+ const title = block.match(/^Title: (.+)$/m)?.[1]?.trim() ?? "";
77
+ const url = block.match(/^URL: (.+)$/m)?.[1]?.trim() ?? "";
78
+ if (!url) continue;
79
+
80
+ let body = "";
81
+ const textStart = block.indexOf("\nText: ");
82
+ if (textStart >= 0) {
83
+ body = block.slice(textStart + "\nText: ".length);
84
+ } else {
85
+ const highlights = block.match(/\nHighlights:\s*\n/);
86
+ if (highlights?.index != null) {
87
+ body = block.slice(highlights.index + highlights[0].length);
88
+ }
89
+ }
90
+
91
+ results.push({
92
+ title: title || url,
93
+ url,
94
+ snippet: snippet(body.replace(/\n---\s*$/, "")),
95
+ });
96
+ }
97
+
98
+ return results;
99
+ }
100
+
101
+ function parseMcpResponse(body: string): unknown {
102
+ // Exa's MCP endpoint may answer with plain JSON or an SSE stream.
103
+ const dataLines = body
104
+ .split(/\r?\n/)
105
+ .filter((line) => line.startsWith("data:"))
106
+ .map((line) => line.slice(5).trim())
107
+ .filter(Boolean);
108
+
109
+ for (const line of dataLines) {
110
+ try {
111
+ return JSON.parse(line);
112
+ } catch {
113
+ // Try the next data line.
114
+ }
115
+ }
116
+
117
+ try {
118
+ return JSON.parse(body);
119
+ } catch {
120
+ throw new Error(`Exa returned an unreadable response: ${body.slice(0, 300)}`);
121
+ }
122
+ }
123
+
124
+ async function searchWithExaMcp(
125
+ query: string,
126
+ numResults: number,
127
+ signal?: AbortSignal,
128
+ ): Promise<SearchResult[]> {
129
+ const response = await fetch(`${EXA_MCP_URL}?tools=web_search_exa`, {
130
+ method: "POST",
131
+ headers: {
132
+ "Content-Type": "application/json",
133
+ Accept: "application/json, text/event-stream",
134
+ "x-exa-source": "pi-web-search-extension",
135
+ },
136
+ body: JSON.stringify({
137
+ jsonrpc: "2.0",
138
+ id: 1,
139
+ method: "tools/call",
140
+ params: {
141
+ name: "web_search_exa",
142
+ arguments: { query, numResults },
143
+ },
144
+ }),
145
+ signal: requestSignal(signal),
146
+ });
147
+
148
+ if (!response.ok) {
149
+ const text = await response.text();
150
+ if (response.status === 429) {
151
+ throw new Error(
152
+ "Exa MCP rate limit reached. Try again later, or set EXA_API_KEY to use the direct Exa API.",
153
+ );
154
+ }
155
+ throw new Error(`Exa MCP error ${response.status}: ${text.slice(0, 300)}`);
156
+ }
157
+
158
+ const payload = parseMcpResponse(await response.text()) as {
159
+ result?: { content?: Array<{ type?: string; text?: string }>; isError?: boolean };
160
+ error?: { message?: string };
161
+ };
162
+
163
+ if (payload.error) {
164
+ throw new Error(`Exa MCP error: ${payload.error.message ?? "unknown error"}`);
165
+ }
166
+ if (payload.result?.isError) {
167
+ const message = payload.result.content?.find((item) => item.type === "text")?.text;
168
+ throw new Error(`Exa MCP error: ${message ?? "search failed"}`);
169
+ }
170
+
171
+ const text = payload.result?.content?.find(
172
+ (item) => item.type === "text" && item.text?.trim(),
173
+ )?.text;
174
+
175
+ if (!text) {
176
+ throw new Error("Exa MCP returned no content");
177
+ }
178
+
179
+ return parseMcpResults(text).slice(0, numResults);
180
+ }
181
+
182
+ async function searchWithExaApi(
183
+ query: string,
184
+ numResults: number,
185
+ apiKey: string,
186
+ signal?: AbortSignal,
187
+ ): Promise<SearchResult[]> {
188
+ const response = await fetch(EXA_API_URL, {
189
+ method: "POST",
190
+ headers: {
191
+ "Content-Type": "application/json",
192
+ "x-api-key": apiKey,
193
+ },
194
+ body: JSON.stringify({
195
+ query,
196
+ numResults,
197
+ type: "auto",
198
+ contents: { text: { maxCharacters: MAX_SNIPPET_CHARS } },
199
+ }),
200
+ signal: requestSignal(signal),
201
+ });
202
+
203
+ if (!response.ok) {
204
+ const text = await response.text();
205
+ if (response.status === 429) {
206
+ throw new Error("Exa API rate limit reached. Try again later.");
207
+ }
208
+ throw new Error(`Exa API error ${response.status}: ${text.slice(0, 300)}`);
209
+ }
210
+
211
+ const payload = (await response.json()) as {
212
+ results?: Array<{ title?: string; url?: string; text?: string }>;
213
+ };
214
+
215
+ return (payload.results ?? [])
216
+ .filter((result) => typeof result.url === "string" && result.url.length > 0)
217
+ .slice(0, numResults)
218
+ .map((result) => ({
219
+ title: result.title || result.url!,
220
+ url: result.url!,
221
+ snippet: snippet(result.text ?? ""),
222
+ }));
223
+ }
224
+
225
+ interface FetchDetails {
226
+ url: string;
227
+ finalUrl: string;
228
+ title?: string;
229
+ contentType: string;
230
+ truncated: boolean;
231
+ totalChars: number;
232
+ returnedChars: number;
233
+ }
234
+
235
+ function formatResults(query: string, results: SearchResult[]): string {
236
+ if (results.length === 0) {
237
+ return `No web search results found for: ${query}`;
238
+ }
239
+
240
+ const lines = [`Web search results for: ${query}`, ""];
241
+ results.forEach((result, index) => {
242
+ lines.push(`${index + 1}. ${result.title}`);
243
+ lines.push(` ${result.url}`);
244
+ if (result.snippet) lines.push(` ${result.snippet}`);
245
+ lines.push("");
246
+ });
247
+ return lines.join("\n").trim();
248
+ }
249
+
250
+ function isPrivateIp(ip: string): boolean {
251
+ const version = isIP(ip);
252
+ if (version === 4) {
253
+ const parts = ip.split(".").map(Number);
254
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
255
+ return true;
256
+ }
257
+ const [a, b] = parts;
258
+ return (
259
+ a === 0 ||
260
+ a === 10 ||
261
+ a === 127 ||
262
+ (a === 100 && b >= 64 && b <= 127) ||
263
+ (a === 169 && b === 254) ||
264
+ (a === 172 && b >= 16 && b <= 31) ||
265
+ (a === 192 && b === 0) ||
266
+ (a === 192 && b === 168) ||
267
+ (a === 198 && (b === 18 || b === 19)) ||
268
+ (a === 198 && b === 51) ||
269
+ (a === 203 && b === 0) ||
270
+ a >= 224
271
+ );
272
+ }
273
+
274
+ if (version === 6) {
275
+ const value = ip.toLowerCase().split("%")[0];
276
+ if (value === "::" || value === "::1") return true;
277
+ if (value.startsWith("::ffff:")) {
278
+ const mapped = value.slice("::ffff:".length);
279
+ return isIP(mapped) === 4 ? isPrivateIp(mapped) : true;
280
+ }
281
+ if (/^f[cd][0-9a-f]{2}:/.test(value)) return true; // fc00::/7
282
+ if (/^fe[89ab][0-9a-f]:/.test(value)) return true; // fe80::/10
283
+ if (/^ff[0-9a-f]{2}:/.test(value)) return true; // multicast
284
+ if (value.startsWith("2001:db8:") || value === "2001:db8::") return true; // documentation
285
+ return false;
286
+ }
287
+
288
+ return true;
289
+ }
290
+
291
+ async function assertPublicUrl(url: URL): Promise<void> {
292
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
293
+ throw new Error(`Unsupported URL protocol: ${url.protocol}`);
294
+ }
295
+ if (url.username || url.password) {
296
+ throw new Error("URLs with embedded credentials are not allowed");
297
+ }
298
+
299
+ const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
300
+ if (
301
+ hostname === "localhost" ||
302
+ hostname.endsWith(".localhost") ||
303
+ hostname.endsWith(".local") ||
304
+ hostname === "0.0.0.0"
305
+ ) {
306
+ throw new Error("Fetching local or private network addresses is blocked");
307
+ }
308
+
309
+ if (isIP(hostname)) {
310
+ if (isPrivateIp(hostname)) {
311
+ throw new Error("Fetching local or private network addresses is blocked");
312
+ }
313
+ return;
314
+ }
315
+
316
+ let addresses: Array<{ address: string }>;
317
+ try {
318
+ addresses = await lookup(hostname, { all: true, verbatim: true });
319
+ } catch {
320
+ throw new Error(`Could not resolve host: ${hostname}`);
321
+ }
322
+
323
+ if (addresses.length === 0) {
324
+ throw new Error(`Could not resolve host: ${hostname}`);
325
+ }
326
+ if (addresses.some(({ address }) => isPrivateIp(address))) {
327
+ throw new Error("Fetching local or private network addresses is blocked");
328
+ }
329
+ }
330
+
331
+ const ENTITY_MAP: Record<string, string> = {
332
+ amp: "&",
333
+ lt: "<",
334
+ gt: ">",
335
+ quot: '"',
336
+ apos: "'",
337
+ nbsp: " ",
338
+ ndash: "–",
339
+ mdash: "—",
340
+ hellip: "…",
341
+ lsquo: "‘",
342
+ rsquo: "’",
343
+ ldquo: "“",
344
+ rdquo: "”",
345
+ bull: "•",
346
+ middot: "·",
347
+ copy: "©",
348
+ reg: "®",
349
+ trade: "™",
350
+ };
351
+
352
+ function decodeEntities(text: string): string {
353
+ return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]+);/gi, (match, entity: string) => {
354
+ if (entity.startsWith("#")) {
355
+ const code = entity[1]?.toLowerCase() === "x"
356
+ ? Number.parseInt(entity.slice(2), 16)
357
+ : Number.parseInt(entity.slice(1), 10);
358
+ if (Number.isFinite(code) && code >= 0 && code <= 0x10ffff) {
359
+ try {
360
+ return String.fromCodePoint(code);
361
+ } catch {
362
+ return match;
363
+ }
364
+ }
365
+ return match;
366
+ }
367
+ return ENTITY_MAP[entity.toLowerCase()] ?? match;
368
+ });
369
+ }
370
+
371
+ function extractTitle(html: string): string | undefined {
372
+ const raw = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
373
+ if (!raw) return undefined;
374
+ const title = normalizeWhitespace(decodeEntities(raw));
375
+ return title || undefined;
376
+ }
377
+
378
+ function htmlToText(html: string): string {
379
+ const text = html
380
+ .replace(/<!--[\s\S]*?-->/g, "")
381
+ .replace(/<head[\s\S]*?<\/head>/gi, "")
382
+ .replace(/<(script|style|noscript|svg|canvas|template|iframe|object|embed)[^>]*>[\s\S]*?<\/\1>/gi, "")
383
+ .replace(/<(nav|footer|aside|form|button|select|textarea)[^>]*>[\s\S]*?<\/\1>/gi, "")
384
+ .replace(/<(br|hr)\s*\/?>/gi, "\n")
385
+ .replace(/<\/(p|div|section|article|header|main|li|h[1-6]|tr|blockquote|pre|table|dt|dd)>/gi, "\n")
386
+ .replace(/<li[^>]*>/gi, "- ")
387
+ .replace(/<[^>]+>/g, "");
388
+
389
+ return normalizePlainText(decodeEntities(text));
390
+ }
391
+
392
+ function normalizePlainText(text: string): string {
393
+ return text
394
+ .replace(/\r\n?/g, "\n")
395
+ .replace(/[ \t\f\v]+/g, " ")
396
+ .replace(/ *\n */g, "\n")
397
+ .replace(/\n{3,}/g, "\n\n")
398
+ .trim();
399
+ }
400
+
401
+ function decodeBody(bytes: Uint8Array, contentType: string): string {
402
+ const charset = /charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType)?.[1];
403
+ for (const candidate of [charset, "utf-8"]) {
404
+ if (!candidate) continue;
405
+ try {
406
+ return new TextDecoder(candidate).decode(bytes);
407
+ } catch {
408
+ // Try the next supported encoding.
409
+ }
410
+ }
411
+ return new TextDecoder().decode(bytes);
412
+ }
413
+
414
+ async function readBodyText(response: Response, contentType: string): Promise<string> {
415
+ const declaredLength = Number(response.headers.get("content-length") ?? "0");
416
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_FETCH_BYTES) {
417
+ throw new Error(`Response is too large (${declaredLength} bytes; limit is ${MAX_FETCH_BYTES})`);
418
+ }
419
+
420
+ const reader = response.body?.getReader();
421
+ if (!reader) return "";
422
+
423
+ const chunks: Uint8Array[] = [];
424
+ let total = 0;
425
+ try {
426
+ while (true) {
427
+ const { done, value } = await reader.read();
428
+ if (done) break;
429
+ if (!value) continue;
430
+ total += value.byteLength;
431
+ if (total > MAX_FETCH_BYTES) {
432
+ await reader.cancel();
433
+ throw new Error(`Response is too large (limit is ${MAX_FETCH_BYTES} bytes)`);
434
+ }
435
+ chunks.push(value);
436
+ }
437
+ } finally {
438
+ reader.releaseLock();
439
+ }
440
+
441
+ const bytes = new Uint8Array(total);
442
+ let offset = 0;
443
+ for (const chunk of chunks) {
444
+ bytes.set(chunk, offset);
445
+ offset += chunk.byteLength;
446
+ }
447
+ return decodeBody(bytes, contentType);
448
+ }
449
+
450
+ function isReadableMime(mime: string): boolean {
451
+ return mime === "text/html" || mime === "application/xhtml+xml" || mime.startsWith("text/");
452
+ }
453
+
454
+ async function fetchPublicUrl(
455
+ rawUrl: string,
456
+ signal?: AbortSignal,
457
+ ): Promise<{ response: Response; finalUrl: URL }> {
458
+ let current: URL;
459
+ try {
460
+ current = new URL(rawUrl);
461
+ } catch {
462
+ throw new Error("Invalid URL");
463
+ }
464
+
465
+ for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect++) {
466
+ await assertPublicUrl(current);
467
+ const response = await fetch(current, {
468
+ redirect: "manual",
469
+ headers: {
470
+ Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.1",
471
+ "Accept-Language": "en-US,en;q=0.8",
472
+ "User-Agent": "Mozilla/5.0 (compatible; pi-web-fetch/1.0)",
473
+ },
474
+ signal: signal
475
+ ? AbortSignal.any([AbortSignal.timeout(FETCH_TIMEOUT_MS), signal])
476
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS),
477
+ });
478
+
479
+ if (!REDIRECT_STATUSES.has(response.status)) {
480
+ return { response, finalUrl: current };
481
+ }
482
+
483
+ const location = response.headers.get("location");
484
+ await response.body?.cancel();
485
+ if (!location) {
486
+ throw new Error(`Redirect ${response.status} did not include a Location header`);
487
+ }
488
+ try {
489
+ current = new URL(location, current);
490
+ } catch {
491
+ throw new Error("Redirected to an invalid URL");
492
+ }
493
+ }
494
+
495
+ throw new Error(`Too many redirects (maximum ${MAX_REDIRECTS})`);
496
+ }
497
+
498
+ const webFetchParams = Type.Object({
499
+ url: Type.String({
500
+ description: "Absolute http(s) URL to fetch.",
501
+ }),
502
+ maxChars: Type.Optional(
503
+ Type.Integer({
504
+ description: "Maximum characters of readable text to return (1000-50000, default 12000).",
505
+ minimum: 1_000,
506
+ maximum: MAX_FETCH_CHARS,
507
+ default: DEFAULT_FETCH_CHARS,
508
+ }),
509
+ ),
510
+ });
511
+
512
+ const webFetchTool = defineTool({
513
+ name: "web_fetch",
514
+ label: "Web Fetch",
515
+ description:
516
+ "Fetch an http(s) URL and return its readable text. Supports HTML and plain-text pages, follows up to 5 redirects, blocks local/private network addresses, limits responses to 2 MB, and truncates output. Use this after web_search when a snippet is not enough.",
517
+ promptSnippet: "Fetch and read the text of a web page",
518
+ promptGuidelines: [
519
+ "Use web_fetch to read a page found by web_search or supplied by the user when the snippet is insufficient.",
520
+ "Localhost, private network addresses, and URLs with embedded credentials are blocked by the tool.",
521
+ ],
522
+ parameters: webFetchParams,
523
+
524
+ async execute(_toolCallId, params, signal) {
525
+ const url = params.url.trim();
526
+ if (!url) {
527
+ throw new Error("URL must not be empty");
528
+ }
529
+
530
+ const maxChars = params.maxChars ?? DEFAULT_FETCH_CHARS;
531
+ const { response, finalUrl } = await fetchPublicUrl(url, signal);
532
+
533
+ if (!response.ok) {
534
+ await response.body?.cancel();
535
+ throw new Error(`Fetch failed: HTTP ${response.status} ${response.statusText}`.trim());
536
+ }
537
+
538
+ const contentType = response.headers.get("content-type") ?? "";
539
+ const mime = contentType.split(";")[0].trim().toLowerCase();
540
+ if (mime && !isReadableMime(mime)) {
541
+ await response.body?.cancel();
542
+ throw new Error(`Unsupported content type: ${mime}. Only HTML and plain text are supported.`);
543
+ }
544
+
545
+ const raw = await readBodyText(response, contentType);
546
+ const isHtml =
547
+ mime === "text/html" ||
548
+ mime === "application/xhtml+xml" ||
549
+ (!mime && /^\s*</.test(raw));
550
+ const title = isHtml ? extractTitle(raw) : undefined;
551
+
552
+ let text = isHtml ? htmlToText(raw) : normalizePlainText(raw);
553
+ if (!text) text = "(No readable text extracted from the page.)";
554
+
555
+ const totalChars = text.length;
556
+ const truncated = totalChars > maxChars;
557
+ if (truncated) {
558
+ text = `${text.slice(0, maxChars).trimEnd()}\n\n[truncated: showing ${maxChars} of ${totalChars} characters]`;
559
+ }
560
+
561
+ const header = [
562
+ `Fetched: ${finalUrl.href}`,
563
+ title ? `Title: ${title}` : "",
564
+ `Content-Type: ${mime || "unknown"}`,
565
+ ].filter(Boolean).join("\n");
566
+
567
+ return {
568
+ content: [{ type: "text", text: `${header}\n\n${text}` }],
569
+ details: {
570
+ url,
571
+ finalUrl: finalUrl.href,
572
+ title,
573
+ contentType: mime || "unknown",
574
+ truncated,
575
+ totalChars,
576
+ returnedChars: Math.min(totalChars, maxChars),
577
+ } satisfies FetchDetails,
578
+ };
579
+ },
580
+ });
581
+
582
+ const webSearchTool = defineTool({
583
+ name: "web_search",
584
+ label: "Web Search",
585
+ description:
586
+ "Search the web for current information. Returns titles, URLs, and snippets. Use this when the answer depends on recent events, external documentation, or facts not available in the repository. Follow up with `web_fetch` when a snippet is insufficient, and cite the URLs you use.",
587
+ promptSnippet: "Search the web for current information",
588
+ promptGuidelines: [
589
+ "Use web_search when the answer depends on current events, external documentation, or facts not present in the repository.",
590
+ "Cite the URLs of sources you rely on.",
591
+ "Use web_fetch to read a result when its snippet is not enough.",
592
+ ],
593
+ parameters: webSearchParams,
594
+
595
+ async execute(_toolCallId, params, signal) {
596
+ const query = params.query.trim();
597
+ if (!query) {
598
+ throw new Error("Search query must not be empty");
599
+ }
600
+
601
+ const numResults = params.numResults ?? 5;
602
+ const apiKey = process.env.EXA_API_KEY?.trim();
603
+ const provider: SearchDetails["provider"] = apiKey ? "exa-api" : "exa-mcp";
604
+ const results = apiKey
605
+ ? await searchWithExaApi(query, numResults, apiKey, signal)
606
+ : await searchWithExaMcp(query, numResults, signal);
607
+
608
+ return {
609
+ content: [{ type: "text", text: formatResults(query, results) }],
610
+ details: {
611
+ query,
612
+ provider,
613
+ count: results.length,
614
+ results,
615
+ } satisfies SearchDetails,
616
+ };
617
+ },
618
+ });
619
+
620
+ export default function webSearchExtension(pi: ExtensionAPI) {
621
+ pi.registerTool(webSearchTool);
622
+ pi.registerTool(webFetchTool);
623
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@sassywares/pi-extensions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.1",
4
+ "description": "A zesty power pack for pi: auto-titled sessions, real web access, clean handoffs, and isolated subagents.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Sassywares",
8
+ "homepage": "https://github.com/sassywares/sassy-pi-extensions#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/sassywares/sassy-pi-extensions.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/sassywares/sassy-pi-extensions/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "pi-extension",
20
+ "auto-title",
21
+ "web-search",
22
+ "handoff",
23
+ "subagent"
24
+ ],
25
+ "files": [
26
+ "extensions",
27
+ "agents",
28
+ "scripts",
29
+ "README.md",
30
+ "NOTICE",
31
+ "LICENSE"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "typebox": "*"
39
+ },
40
+ "pi": {
41
+ "extensions": [
42
+ "./extensions/auto-title.ts",
43
+ "./extensions/web-search.ts",
44
+ "./extensions/handoff.ts",
45
+ "./extensions/subagent/index.ts"
46
+ ]
47
+ }
48
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ AGENTS_DIR="$REPO/agents"
6
+ TARGET="${PI_AGENTS_DIR:-${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/agents}"
7
+
8
+ if [ ! -d "$AGENTS_DIR" ]; then
9
+ echo "sassy-pi-extensions: $AGENTS_DIR does not exist" >&2
10
+ exit 1
11
+ fi
12
+
13
+ mkdir -p "$TARGET"
14
+
15
+ # One symlink per agent keeps the directory writable for personal agents.
16
+ for src in "$AGENTS_DIR"/*.md; do
17
+ name="$(basename "$src")"
18
+ dest="$TARGET/$name"
19
+
20
+ if [ -L "$dest" ]; then
21
+ ln -sfn "$src" "$dest"
22
+ continue
23
+ fi
24
+
25
+ if [ -e "$dest" ]; then
26
+ if cmp -s "$src" "$dest"; then
27
+ # Identical copy from before this repo became the source of truth.
28
+ rm "$dest"
29
+ ln -s "$src" "$dest"
30
+ else
31
+ echo "sassy-pi-extensions: skipping $dest (exists and differs)" >&2
32
+ fi
33
+ continue
34
+ fi
35
+
36
+ ln -s "$src" "$dest"
37
+ done
38
+
39
+ echo "sassy-pi-extensions: agents linked into $TARGET"