@mrclrchtr/supi-web 1.14.3 → 1.15.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/README.md +8 -4
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +1 -1
- package/package.json +10 -2
- package/src/api.ts +10 -0
- package/src/context7-client.ts +13 -3
- package/src/docs.ts +295 -156
- package/src/fetch.ts +174 -92
- package/src/index.ts +10 -0
- package/src/temp-file.ts +6 -5
- package/src/tool/guidance.ts +34 -0
- package/src/tool/output.ts +50 -0
- package/src/tool/render.ts +63 -0
- package/src/tool/tool-specs.ts +107 -0
- package/src/web.ts +150 -78
- package/src/tool/web-docs-fetch-guidance.ts +0 -11
- package/src/tool/web-docs-search-guidance.ts +0 -10
- package/src/tool/web-fetch-md-guidance.ts +0 -41
package/src/fetch.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface FetchResult {
|
|
|
25
25
|
/** Fetch options. */
|
|
26
26
|
export interface FetchOptions {
|
|
27
27
|
timeoutMs?: number;
|
|
28
|
+
signal?: AbortSignal;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/** Validate that a string is a real http(s) URL. */
|
|
@@ -43,29 +44,35 @@ export async function fetchWithNegotiation(
|
|
|
43
44
|
options: FetchOptions = {},
|
|
44
45
|
): Promise<FetchResult> {
|
|
45
46
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
47
|
+
const { signal } = options;
|
|
46
48
|
|
|
47
49
|
// 1. Try HEAD negotiation for Markdown
|
|
48
|
-
const headResult = await tryHeadNegotiation(url, timeoutMs);
|
|
50
|
+
const headResult = await tryHeadNegotiation(url, timeoutMs, signal);
|
|
49
51
|
if (headResult) return headResult;
|
|
50
52
|
|
|
51
53
|
// 2. Range GET to sniff content type
|
|
52
|
-
const sniffResult = await trySniffNegotiation(url, timeoutMs);
|
|
54
|
+
const sniffResult = await trySniffNegotiation(url, timeoutMs, signal);
|
|
53
55
|
if (sniffResult) return sniffResult;
|
|
54
56
|
|
|
55
57
|
// 3. Try sibling .md URLs
|
|
56
|
-
const siblingResult = await trySiblingNegotiation(url, timeoutMs);
|
|
58
|
+
const siblingResult = await trySiblingNegotiation(url, timeoutMs, signal);
|
|
57
59
|
if (siblingResult) return siblingResult;
|
|
58
60
|
|
|
59
61
|
// 4. Full GET as HTML → convert to Markdown
|
|
60
|
-
return fetchAsHtml(url, timeoutMs);
|
|
62
|
+
return fetchAsHtml(url, timeoutMs, signal);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
async function tryHeadNegotiation(
|
|
65
|
+
async function tryHeadNegotiation(
|
|
66
|
+
url: string,
|
|
67
|
+
timeoutMs: number,
|
|
68
|
+
signal: AbortSignal | undefined,
|
|
69
|
+
): Promise<FetchResult | null> {
|
|
64
70
|
try {
|
|
65
71
|
const headRes = await timedFetch(
|
|
66
72
|
url,
|
|
67
73
|
{ method: "HEAD", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
|
|
68
74
|
timeoutMs,
|
|
75
|
+
signal,
|
|
69
76
|
);
|
|
70
77
|
if (!headRes.ok) return null;
|
|
71
78
|
const ct = headRes.headers.get("content-type") || "";
|
|
@@ -75,9 +82,12 @@ async function tryHeadNegotiation(url: string, timeoutMs: number): Promise<Fetch
|
|
|
75
82
|
url,
|
|
76
83
|
{ method: "GET", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
|
|
77
84
|
timeoutMs,
|
|
85
|
+
signal,
|
|
78
86
|
);
|
|
79
87
|
if (!getRes.ok)
|
|
80
|
-
throw new FetchError(`Fetch failed: ${getRes.status} ${getRes.statusText}`,
|
|
88
|
+
throw new FetchError(`Fetch failed: ${getRes.status} ${getRes.statusText}`, {
|
|
89
|
+
status: getRes.status,
|
|
90
|
+
});
|
|
81
91
|
return {
|
|
82
92
|
url: getRes.url || url,
|
|
83
93
|
text: await getRes.text(),
|
|
@@ -85,12 +95,17 @@ async function tryHeadNegotiation(url: string, timeoutMs: number): Promise<Fetch
|
|
|
85
95
|
isMarkdown: true,
|
|
86
96
|
isPlainText: false,
|
|
87
97
|
};
|
|
88
|
-
} catch {
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (signal?.aborted) throw err;
|
|
89
100
|
return null;
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
async function trySniffNegotiation(
|
|
104
|
+
async function trySniffNegotiation(
|
|
105
|
+
url: string,
|
|
106
|
+
timeoutMs: number,
|
|
107
|
+
signal: AbortSignal | undefined,
|
|
108
|
+
): Promise<FetchResult | null> {
|
|
94
109
|
try {
|
|
95
110
|
const sniffRes = await timedFetch(
|
|
96
111
|
url,
|
|
@@ -100,109 +115,152 @@ async function trySniffNegotiation(url: string, timeoutMs: number): Promise<Fetc
|
|
|
100
115
|
headers: { "User-Agent": USER_AGENT, Range: `bytes=0-${SNIFF_BYTES - 1}` },
|
|
101
116
|
},
|
|
102
117
|
timeoutMs,
|
|
118
|
+
signal,
|
|
103
119
|
);
|
|
104
120
|
const sniffText = await readPartialText(sniffRes, SNIFF_BYTES);
|
|
105
121
|
const ct = sniffRes.headers.get("content-type") || "";
|
|
106
122
|
const finalUrl = sniffRes.url || url;
|
|
107
123
|
|
|
108
124
|
if (!sniffRes.ok || isHtml(sniffText)) return null;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
isMarkdownContentType(ct) ||
|
|
112
|
-
looksLikeMarkdownUrl(finalUrl) ||
|
|
113
|
-
looksLikeMarkdown(sniffText)
|
|
114
|
-
) {
|
|
115
|
-
const fullRes = await timedFetch(
|
|
125
|
+
if (isMarkdownCandidate(ct, finalUrl, sniffText)) {
|
|
126
|
+
return fetchFullTextResult({
|
|
116
127
|
url,
|
|
117
|
-
{ method: "GET", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
|
|
118
128
|
timeoutMs,
|
|
119
|
-
|
|
120
|
-
if (!fullRes.ok)
|
|
121
|
-
throw new FetchError(
|
|
122
|
-
`Fetch failed: ${fullRes.status} ${fullRes.statusText}`,
|
|
123
|
-
fullRes.status,
|
|
124
|
-
);
|
|
125
|
-
return {
|
|
126
|
-
url: fullRes.url || url,
|
|
127
|
-
text: await fullRes.text(),
|
|
129
|
+
signal,
|
|
128
130
|
contentType: ct,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
};
|
|
131
|
+
kind: MARKDOWN_RESPONSE_KIND,
|
|
132
|
+
headers: { "User-Agent": USER_AGENT },
|
|
133
|
+
});
|
|
132
134
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
isPlainTextContentType(ct) &&
|
|
136
|
-
!looksLikeMarkdownUrl(finalUrl) &&
|
|
137
|
-
!looksLikeMarkdown(sniffText)
|
|
138
|
-
) {
|
|
139
|
-
const fullRes = await timedFetch(
|
|
135
|
+
if (isPlainTextCandidate(ct, finalUrl, sniffText)) {
|
|
136
|
+
return fetchFullTextResult({
|
|
140
137
|
url,
|
|
141
|
-
{ method: "GET", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
|
|
142
138
|
timeoutMs,
|
|
143
|
-
|
|
144
|
-
if (!fullRes.ok)
|
|
145
|
-
throw new FetchError(
|
|
146
|
-
`Fetch failed: ${fullRes.status} ${fullRes.statusText}`,
|
|
147
|
-
fullRes.status,
|
|
148
|
-
);
|
|
149
|
-
return {
|
|
150
|
-
url: fullRes.url || url,
|
|
151
|
-
text: await fullRes.text(),
|
|
139
|
+
signal,
|
|
152
140
|
contentType: ct,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
};
|
|
141
|
+
kind: PLAIN_TEXT_RESPONSE_KIND,
|
|
142
|
+
headers: { "User-Agent": USER_AGENT },
|
|
143
|
+
});
|
|
156
144
|
}
|
|
157
|
-
|
|
158
145
|
return null;
|
|
159
|
-
} catch {
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (signal?.aborted) throw err;
|
|
160
148
|
return null;
|
|
161
149
|
}
|
|
162
150
|
}
|
|
163
151
|
|
|
164
|
-
async function trySiblingNegotiation(
|
|
152
|
+
async function trySiblingNegotiation(
|
|
153
|
+
url: string,
|
|
154
|
+
timeoutMs: number,
|
|
155
|
+
signal: AbortSignal | undefined,
|
|
156
|
+
): Promise<FetchResult | null> {
|
|
165
157
|
for (const sibling of generateSiblingUrls(url)) {
|
|
166
158
|
try {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
redirect: "follow",
|
|
172
|
-
headers: { "User-Agent": USER_AGENT, Accept: ACCEPT_SIBLING },
|
|
173
|
-
},
|
|
174
|
-
timeoutMs,
|
|
175
|
-
);
|
|
176
|
-
const sibText = await readPartialText(sibRes, SNIFF_BYTES);
|
|
177
|
-
const sibCt = sibRes.headers.get("content-type") || "";
|
|
178
|
-
if (!sibRes.ok || isHtml(sibText) || isHtmlContentType(sibCt)) continue;
|
|
179
|
-
if (!looksLikeMarkdown(sibText) && !isMarkdownContentType(sibCt)) continue;
|
|
180
|
-
|
|
181
|
-
const fullRes = await timedFetch(
|
|
182
|
-
sibling,
|
|
183
|
-
{
|
|
184
|
-
method: "GET",
|
|
185
|
-
redirect: "follow",
|
|
186
|
-
headers: { "User-Agent": USER_AGENT, Accept: ACCEPT_SIBLING },
|
|
187
|
-
},
|
|
188
|
-
timeoutMs,
|
|
189
|
-
);
|
|
190
|
-
if (!fullRes.ok) continue;
|
|
191
|
-
return {
|
|
192
|
-
url: fullRes.url || sibling,
|
|
193
|
-
text: await fullRes.text(),
|
|
194
|
-
contentType: sibCt,
|
|
195
|
-
isMarkdown: true,
|
|
196
|
-
isPlainText: false,
|
|
197
|
-
};
|
|
198
|
-
} catch {
|
|
159
|
+
const result = await fetchMarkdownSibling(sibling, timeoutMs, signal);
|
|
160
|
+
if (result) return result;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (signal?.aborted) throw err;
|
|
199
163
|
// Try next sibling
|
|
200
164
|
}
|
|
201
165
|
}
|
|
202
166
|
return null;
|
|
203
167
|
}
|
|
204
168
|
|
|
205
|
-
async function
|
|
169
|
+
async function fetchMarkdownSibling(
|
|
170
|
+
sibling: string,
|
|
171
|
+
timeoutMs: number,
|
|
172
|
+
signal: AbortSignal | undefined,
|
|
173
|
+
): Promise<FetchResult | null> {
|
|
174
|
+
const headers = { "User-Agent": USER_AGENT, Accept: ACCEPT_SIBLING };
|
|
175
|
+
const sibRes = await timedFetch(
|
|
176
|
+
sibling,
|
|
177
|
+
{ method: "GET", redirect: "follow", headers },
|
|
178
|
+
timeoutMs,
|
|
179
|
+
signal,
|
|
180
|
+
);
|
|
181
|
+
const sibText = await readPartialText(sibRes, SNIFF_BYTES);
|
|
182
|
+
const sibCt = sibRes.headers.get("content-type") || "";
|
|
183
|
+
|
|
184
|
+
if (!sibRes.ok || isHtml(sibText) || isHtmlContentType(sibCt)) return null;
|
|
185
|
+
if (!looksLikeMarkdown(sibText) && !isMarkdownContentType(sibCt)) return null;
|
|
186
|
+
|
|
187
|
+
const fullRes = await timedFetch(
|
|
188
|
+
sibling,
|
|
189
|
+
{ method: "GET", redirect: "follow", headers },
|
|
190
|
+
timeoutMs,
|
|
191
|
+
signal,
|
|
192
|
+
);
|
|
193
|
+
if (!fullRes.ok) return null;
|
|
194
|
+
return buildFetchResult(fullRes, sibling, sibCt, MARKDOWN_RESPONSE_KIND);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface ResponseKind {
|
|
198
|
+
isMarkdown: boolean;
|
|
199
|
+
isPlainText: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface FetchFullTextOptions {
|
|
203
|
+
url: string;
|
|
204
|
+
timeoutMs: number;
|
|
205
|
+
signal: AbortSignal | undefined;
|
|
206
|
+
contentType: string;
|
|
207
|
+
kind: ResponseKind;
|
|
208
|
+
headers: Record<string, string>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const MARKDOWN_RESPONSE_KIND = { isMarkdown: true, isPlainText: false } as const;
|
|
212
|
+
const PLAIN_TEXT_RESPONSE_KIND = { isMarkdown: false, isPlainText: true } as const;
|
|
213
|
+
|
|
214
|
+
async function fetchFullTextResult(options: FetchFullTextOptions): Promise<FetchResult> {
|
|
215
|
+
const fullRes = await timedFetch(
|
|
216
|
+
options.url,
|
|
217
|
+
{ method: "GET", redirect: "follow", headers: options.headers },
|
|
218
|
+
options.timeoutMs,
|
|
219
|
+
options.signal,
|
|
220
|
+
);
|
|
221
|
+
if (!fullRes.ok)
|
|
222
|
+
throw new FetchError(`Fetch failed: ${fullRes.status} ${fullRes.statusText}`, {
|
|
223
|
+
status: fullRes.status,
|
|
224
|
+
});
|
|
225
|
+
return buildFetchResult(fullRes, options.url, options.contentType, options.kind);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function buildFetchResult(
|
|
229
|
+
response: Response,
|
|
230
|
+
fallbackUrl: string,
|
|
231
|
+
contentType: string,
|
|
232
|
+
kind: ResponseKind,
|
|
233
|
+
): Promise<FetchResult> {
|
|
234
|
+
return {
|
|
235
|
+
url: response.url || fallbackUrl,
|
|
236
|
+
text: await response.text(),
|
|
237
|
+
contentType,
|
|
238
|
+
isMarkdown: kind.isMarkdown,
|
|
239
|
+
isPlainText: kind.isPlainText,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isMarkdownCandidate(contentType: string, finalUrl: string, sniffText: string): boolean {
|
|
244
|
+
return (
|
|
245
|
+
isMarkdownContentType(contentType) ||
|
|
246
|
+
looksLikeMarkdownUrl(finalUrl) ||
|
|
247
|
+
looksLikeMarkdown(sniffText)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isPlainTextCandidate(contentType: string, finalUrl: string, sniffText: string): boolean {
|
|
252
|
+
return (
|
|
253
|
+
isPlainTextContentType(contentType) &&
|
|
254
|
+
!looksLikeMarkdownUrl(finalUrl) &&
|
|
255
|
+
!looksLikeMarkdown(sniffText)
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function fetchAsHtml(
|
|
260
|
+
url: string,
|
|
261
|
+
timeoutMs: number,
|
|
262
|
+
signal: AbortSignal | undefined,
|
|
263
|
+
): Promise<FetchResult> {
|
|
206
264
|
const res = await timedFetch(
|
|
207
265
|
url,
|
|
208
266
|
{
|
|
@@ -211,8 +269,10 @@ async function fetchAsHtml(url: string, timeoutMs: number): Promise<FetchResult>
|
|
|
211
269
|
headers: { "User-Agent": USER_AGENT, Accept: "text/html,*/*;q=0.1" },
|
|
212
270
|
},
|
|
213
271
|
timeoutMs,
|
|
272
|
+
signal,
|
|
214
273
|
);
|
|
215
|
-
if (!res.ok)
|
|
274
|
+
if (!res.ok)
|
|
275
|
+
throw new FetchError(`Fetch failed: ${res.status} ${res.statusText}`, { status: res.status });
|
|
216
276
|
return {
|
|
217
277
|
url: res.url || url,
|
|
218
278
|
text: await res.text(),
|
|
@@ -223,23 +283,45 @@ async function fetchAsHtml(url: string, timeoutMs: number): Promise<FetchResult>
|
|
|
223
283
|
}
|
|
224
284
|
|
|
225
285
|
/** Error thrown on fetch failures. */
|
|
286
|
+
export interface FetchErrorOptions extends ErrorOptions {
|
|
287
|
+
status?: number;
|
|
288
|
+
}
|
|
289
|
+
|
|
226
290
|
export class FetchError extends Error {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
super(message);
|
|
291
|
+
readonly status?: number;
|
|
292
|
+
|
|
293
|
+
constructor(message: string, options: FetchErrorOptions = {}) {
|
|
294
|
+
super(message, options);
|
|
232
295
|
this.name = "FetchError";
|
|
296
|
+
this.status = options.status;
|
|
233
297
|
}
|
|
234
298
|
}
|
|
235
299
|
|
|
236
|
-
async function timedFetch(
|
|
300
|
+
async function timedFetch(
|
|
301
|
+
url: string,
|
|
302
|
+
init: RequestInit,
|
|
303
|
+
timeoutMs: number,
|
|
304
|
+
signal?: AbortSignal,
|
|
305
|
+
): Promise<Response> {
|
|
237
306
|
const controller = new AbortController();
|
|
238
|
-
|
|
307
|
+
let timedOut = false;
|
|
308
|
+
const abortFromParent = () => controller.abort();
|
|
309
|
+
if (signal?.aborted) abortFromParent();
|
|
310
|
+
else signal?.addEventListener("abort", abortFromParent, { once: true });
|
|
311
|
+
|
|
312
|
+
const timer = setTimeout(() => {
|
|
313
|
+
timedOut = true;
|
|
314
|
+
controller.abort();
|
|
315
|
+
}, timeoutMs);
|
|
316
|
+
|
|
239
317
|
try {
|
|
240
318
|
return await fetch(url, { ...init, signal: controller.signal });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (timedOut) throw new FetchError(`Fetch timed out after ${timeoutMs}ms`, { cause: err });
|
|
321
|
+
throw err;
|
|
241
322
|
} finally {
|
|
242
323
|
clearTimeout(timer);
|
|
324
|
+
signal?.removeEventListener("abort", abortFromParent);
|
|
243
325
|
}
|
|
244
326
|
}
|
|
245
327
|
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* SuPi Web extension — public API exports.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
export type { Context7RequestOptions } from "./context7-client.ts";
|
|
5
6
|
export { htmlToMarkdown, wrapAsCodeBlock } from "./convert.ts";
|
|
6
7
|
export { default as docsExtension } from "./docs.ts";
|
|
7
8
|
export {
|
|
@@ -11,4 +12,13 @@ export {
|
|
|
11
12
|
fetchWithNegotiation,
|
|
12
13
|
isValidHttpUrl,
|
|
13
14
|
} from "./fetch.ts";
|
|
15
|
+
export {
|
|
16
|
+
WEB_FETCH_INLINE_MAX_CHARS,
|
|
17
|
+
WEB_TOOL_NAMES,
|
|
18
|
+
WEB_TOOL_SPECS,
|
|
19
|
+
type WebDocsFetchInput,
|
|
20
|
+
type WebDocsSearchInput,
|
|
21
|
+
type WebFetchMdInput,
|
|
22
|
+
type WebToolName,
|
|
23
|
+
} from "./tool/tool-specs.ts";
|
|
14
24
|
export { default } from "./web.ts";
|
package/src/temp-file.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Temporary file helpers for the
|
|
2
|
+
* Temporary file helpers for the web tools.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
-
import {
|
|
6
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
|
|
10
|
-
/** Write content to a temporary file and return the absolute path. */
|
|
11
|
+
/** Write content to a unique temporary file and return the absolute path. */
|
|
11
12
|
export async function writeTempFile(
|
|
12
13
|
content: string,
|
|
13
14
|
prefix: string,
|
|
14
15
|
suffix: string,
|
|
15
16
|
): Promise<string> {
|
|
16
|
-
const dir =
|
|
17
|
+
const dir = await mkdtemp(join(tmpdir(), `${prefix}-`));
|
|
17
18
|
const hash = randomBytes(4).toString("hex");
|
|
18
19
|
const filePath = join(dir, `${hash}${suffix}`);
|
|
19
|
-
|
|
20
|
+
await withFileMutationQueue(filePath, () => writeFile(filePath, content, "utf8"));
|
|
20
21
|
return filePath;
|
|
21
22
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { getWebToolSpec, WEB_FETCH_MD_TOOL_NAME, type WebToolName } from "./tool-specs.ts";
|
|
3
|
+
|
|
4
|
+
/** Prompt metadata sent to pi for a single web tool. */
|
|
5
|
+
export interface WebToolPromptSurface {
|
|
6
|
+
description: string;
|
|
7
|
+
promptSnippet: string;
|
|
8
|
+
promptGuidelines: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Build prompt metadata from the shared tool specs, adding runtime-specific guidance when useful. */
|
|
12
|
+
export function getWebToolPromptSurface(name: WebToolName): WebToolPromptSurface {
|
|
13
|
+
const spec = getWebToolSpec(name);
|
|
14
|
+
const promptGuidelines = [...spec.promptGuidelines];
|
|
15
|
+
|
|
16
|
+
if (name === WEB_FETCH_MD_TOOL_NAME && isGhAvailable()) {
|
|
17
|
+
promptGuidelines.push("Use `gh` CLI instead of web_fetch_md for GitHub URLs.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
description: spec.description,
|
|
22
|
+
promptSnippet: spec.promptSnippet,
|
|
23
|
+
promptGuidelines,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isGhAvailable(): boolean {
|
|
28
|
+
try {
|
|
29
|
+
const result = spawnSync("gh", ["--version"], { stdio: "ignore" });
|
|
30
|
+
return result.status === 0;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MAX_BYTES,
|
|
3
|
+
DEFAULT_MAX_LINES,
|
|
4
|
+
formatSize,
|
|
5
|
+
type TruncationResult,
|
|
6
|
+
truncateHead,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { writeTempFile } from "../temp-file.ts";
|
|
9
|
+
|
|
10
|
+
/** Human-readable truncation contract shared by all model-visible web outputs. */
|
|
11
|
+
export const MODEL_OUTPUT_LIMIT_DESCRIPTION = `Inline truncates at ${DEFAULT_MAX_LINES.toLocaleString()} lines/${formatSize(DEFAULT_MAX_BYTES)}; full saved to temp.`;
|
|
12
|
+
|
|
13
|
+
/** Result of preparing content for a tool response visible to the model. */
|
|
14
|
+
export interface ModelVisibleOutput {
|
|
15
|
+
/** Text safe to return in a tool result. */
|
|
16
|
+
text: string;
|
|
17
|
+
/** Truncation metadata when truncation happened. */
|
|
18
|
+
truncation?: TruncationResult;
|
|
19
|
+
/** Temp file containing the full, untruncated output when truncation happened. */
|
|
20
|
+
fullOutputPath?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Prepare content for model-visible output, preserving full output in a temp file if truncated. */
|
|
24
|
+
export async function limitModelVisibleOutput(
|
|
25
|
+
content: string,
|
|
26
|
+
options: { tempPrefix: string; suffix: string },
|
|
27
|
+
): Promise<ModelVisibleOutput> {
|
|
28
|
+
const truncation = truncateHead(content, {
|
|
29
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
30
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!truncation.truncated) {
|
|
34
|
+
return { text: content };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const fullOutputPath = await writeTempFile(content, options.tempPrefix, options.suffix);
|
|
38
|
+
const prefix = truncation.content.length > 0 ? `${truncation.content}\n\n` : "";
|
|
39
|
+
const notice = [
|
|
40
|
+
`[Output truncated: ${truncation.outputLines.toLocaleString()}/${truncation.totalLines.toLocaleString()} lines,`,
|
|
41
|
+
`${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}.`,
|
|
42
|
+
`Full: ${fullOutputPath}]`,
|
|
43
|
+
].join(" ");
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
text: `${prefix}${notice}`,
|
|
47
|
+
truncation,
|
|
48
|
+
fullOutputPath,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
interface RenderTheme {
|
|
5
|
+
fg: (color: ThemeColor, text: string) => string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface CollapsibleTextResultOptions {
|
|
9
|
+
summary: string;
|
|
10
|
+
body?: string;
|
|
11
|
+
expanded: boolean;
|
|
12
|
+
theme: RenderTheme;
|
|
13
|
+
fullOutputPath?: string;
|
|
14
|
+
bodyColor?: ThemeColor;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderToolCall(
|
|
18
|
+
name: string,
|
|
19
|
+
primary: string,
|
|
20
|
+
theme: RenderTheme,
|
|
21
|
+
secondary?: string,
|
|
22
|
+
): Text {
|
|
23
|
+
let content = theme.fg("toolTitle", name);
|
|
24
|
+
if (primary) {
|
|
25
|
+
content += ` ${theme.fg("accent", primary)}`;
|
|
26
|
+
}
|
|
27
|
+
if (secondary) {
|
|
28
|
+
content += theme.fg("dim", ` — ${secondary}`);
|
|
29
|
+
}
|
|
30
|
+
return new Text(content, 0, 0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Render a compact tool summary that expands into the full text body on demand. */
|
|
34
|
+
export function renderCollapsibleTextResult({
|
|
35
|
+
summary,
|
|
36
|
+
body,
|
|
37
|
+
expanded,
|
|
38
|
+
theme,
|
|
39
|
+
fullOutputPath,
|
|
40
|
+
bodyColor = "toolOutput",
|
|
41
|
+
}: CollapsibleTextResultOptions): Text {
|
|
42
|
+
const lines = [summary];
|
|
43
|
+
const bodyText = body?.trimEnd();
|
|
44
|
+
|
|
45
|
+
if (expanded && bodyText) {
|
|
46
|
+
lines.push(formatMultiline(bodyText, theme, bodyColor));
|
|
47
|
+
} else if (bodyText) {
|
|
48
|
+
lines[0] += theme.fg("dim", " (expand for output)");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (expanded && fullOutputPath) {
|
|
52
|
+
lines.push(theme.fg("dim", `Full output: ${fullOutputPath}`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return new Text(lines.filter(Boolean).join("\n"), 0, 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatMultiline(text: string, theme: RenderTheme, color: ThemeColor): string {
|
|
59
|
+
return text
|
|
60
|
+
.split("\n")
|
|
61
|
+
.map((line) => (line.length > 0 ? theme.fg(color, line) : ""))
|
|
62
|
+
.join("\n");
|
|
63
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type Static, type TSchema, Type } from "typebox";
|
|
3
|
+
import { MODEL_OUTPUT_LIMIT_DESCRIPTION } from "./output.ts";
|
|
4
|
+
|
|
5
|
+
export const WEB_FETCH_MD_TOOL_NAME = "web_fetch_md";
|
|
6
|
+
export const WEB_DOCS_SEARCH_TOOL_NAME = "web_docs_search";
|
|
7
|
+
export const WEB_DOCS_FETCH_TOOL_NAME = "web_docs_fetch";
|
|
8
|
+
|
|
9
|
+
export const WEB_TOOL_NAMES = [
|
|
10
|
+
WEB_FETCH_MD_TOOL_NAME,
|
|
11
|
+
WEB_DOCS_SEARCH_TOOL_NAME,
|
|
12
|
+
WEB_DOCS_FETCH_TOOL_NAME,
|
|
13
|
+
] as const;
|
|
14
|
+
export type WebToolName = (typeof WEB_TOOL_NAMES)[number];
|
|
15
|
+
|
|
16
|
+
export const WEB_FETCH_INLINE_MAX_CHARS = 15_000;
|
|
17
|
+
export const WEB_FETCH_OUTPUT_MODES = ["auto", "inline", "file"] as const;
|
|
18
|
+
export type WebFetchOutputMode = (typeof WEB_FETCH_OUTPUT_MODES)[number];
|
|
19
|
+
|
|
20
|
+
const OutputModeEnum = StringEnum(WEB_FETCH_OUTPUT_MODES, {
|
|
21
|
+
default: "auto",
|
|
22
|
+
description: "auto, inline, or file output",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const WebFetchMdParameters = Type.Object(
|
|
26
|
+
{
|
|
27
|
+
url: Type.String({ description: "Public http(s) URL" }),
|
|
28
|
+
output_mode: Type.Optional(OutputModeEnum),
|
|
29
|
+
abs_links: Type.Optional(Type.Boolean({ description: "Absolute links/images", default: true })),
|
|
30
|
+
timeout_ms: Type.Optional(Type.Number({ description: "Fetch timeout (ms)", default: 30_000 })),
|
|
31
|
+
},
|
|
32
|
+
{ additionalProperties: false },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const WebDocsSearchParameters = Type.Object(
|
|
36
|
+
{
|
|
37
|
+
library_name: Type.String({
|
|
38
|
+
description: "Library name (e.g. react, next.js, fastapi)",
|
|
39
|
+
}),
|
|
40
|
+
query: Type.String({
|
|
41
|
+
description: "Task/question for relevance ranking",
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
{ additionalProperties: false },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const WebDocsFetchParameters = Type.Object(
|
|
48
|
+
{
|
|
49
|
+
library_id: Type.String({
|
|
50
|
+
description: "Context7 ID (e.g. /facebook/react); search first if unknown",
|
|
51
|
+
}),
|
|
52
|
+
query: Type.String({ description: "Specific docs question" }),
|
|
53
|
+
raw: Type.Optional(
|
|
54
|
+
Type.Boolean({
|
|
55
|
+
description: "Return JSON snippets instead of Markdown",
|
|
56
|
+
default: false,
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
{ additionalProperties: false },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export type WebFetchMdInput = Static<typeof WebFetchMdParameters>;
|
|
64
|
+
export type WebDocsSearchInput = Static<typeof WebDocsSearchParameters>;
|
|
65
|
+
export type WebDocsFetchInput = Static<typeof WebDocsFetchParameters>;
|
|
66
|
+
|
|
67
|
+
export interface WebToolSpec {
|
|
68
|
+
name: WebToolName;
|
|
69
|
+
label: string;
|
|
70
|
+
description: string;
|
|
71
|
+
promptSnippet: string;
|
|
72
|
+
promptGuidelines: readonly string[];
|
|
73
|
+
parameters: TSchema;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const WEB_TOOL_SPECS = [
|
|
77
|
+
{
|
|
78
|
+
name: WEB_FETCH_MD_TOOL_NAME,
|
|
79
|
+
label: "Web Fetch",
|
|
80
|
+
description: `Fetch public http(s) URL as Markdown. Not for login/private pages; ask for allowed source. auto: inline <=${WEB_FETCH_INLINE_MAX_CHARS.toLocaleString()} chars else temp; inline: may truncate; file: temp path. Links absolute by default. ${MODEL_OUTPUT_LIMIT_DESCRIPTION}`,
|
|
81
|
+
promptSnippet: "web_fetch_md: public URL to Markdown",
|
|
82
|
+
promptGuidelines: ["Use web_fetch_md only for public http(s); ask if login/private."],
|
|
83
|
+
parameters: WebFetchMdParameters,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: WEB_DOCS_SEARCH_TOOL_NAME,
|
|
87
|
+
label: "Web Docs Search",
|
|
88
|
+
description: `Search Context7 for library IDs; returns compact Markdown. ${MODEL_OUTPUT_LIMIT_DESCRIPTION}`,
|
|
89
|
+
promptSnippet: "web_docs_search: Context7 library IDs",
|
|
90
|
+
promptGuidelines: ["Use web_docs_search before web_docs_fetch if ID unknown."],
|
|
91
|
+
parameters: WebDocsSearchParameters,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: WEB_DOCS_FETCH_TOOL_NAME,
|
|
95
|
+
label: "Web Docs Fetch",
|
|
96
|
+
description: `Fetch focused Context7 docs for known library_id; Markdown default, raw=true JSON snippets. Search first if unknown. ${MODEL_OUTPUT_LIMIT_DESCRIPTION}`,
|
|
97
|
+
promptSnippet: "web_docs_fetch: focused Context7 docs",
|
|
98
|
+
promptGuidelines: ["Use web_docs_fetch with known ID and narrow query; raw only for JSON."],
|
|
99
|
+
parameters: WebDocsFetchParameters,
|
|
100
|
+
},
|
|
101
|
+
] as const satisfies readonly WebToolSpec[];
|
|
102
|
+
|
|
103
|
+
export function getWebToolSpec(name: WebToolName): WebToolSpec {
|
|
104
|
+
const spec = WEB_TOOL_SPECS.find((candidate) => candidate.name === name);
|
|
105
|
+
if (!spec) throw new Error(`Unknown web tool: ${name}`);
|
|
106
|
+
return spec;
|
|
107
|
+
}
|