pi-tandem 0.2.0 → 0.3.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 +11 -0
- package/extensions/tandem/index.ts +0 -2
- package/package.json +8 -2
- package/prompt.md +11 -0
- package/runtime/cli-tools.mjs +35 -12
- package/skills/code-review/SKILL.md +21 -0
- package/extensions/tandem/web-tools.ts +0 -246
package/README.md
CHANGED
|
@@ -18,6 +18,17 @@ Modern coding agents lean toward autonomy: multi-file changes in one go, subagen
|
|
|
18
18
|
- **`subagent` skill** to run a task in a fresh agent session on request, with full visibility and control, instead of harness-managed subagents
|
|
19
19
|
- **Tool-specific instructions** (`gh`, `aws`, `jira`, ...), added to the prompt only if the tool is actually installed
|
|
20
20
|
|
|
21
|
+
## Recommended tools
|
|
22
|
+
|
|
23
|
+
The plugin notices which CLI tools are actually installed on your machine, and for each one it finds, adds a short note telling the model that the tool is available and recommended to use. Installing these gets you significantly more out of this package:
|
|
24
|
+
|
|
25
|
+
- `gh` - GitHub CLI: repos, issues, PRs, releases, workflows
|
|
26
|
+
- `pandoc` - read docx/odt/rtf/html (and fetched web pages) as text instead of raw markup
|
|
27
|
+
- `pdftotext` (poppler) - extract text from PDFs
|
|
28
|
+
- a Chromium-based browser (Brave, Chromium, Chrome, Edge) - headless-render fallback for JS-heavy or bot-blocked pages
|
|
29
|
+
- `osascript` (macOS only) - drive the user's real browser sessions via AppleScript
|
|
30
|
+
- `jira`, `aws`, `saml2aws` - if your workflow includes them
|
|
31
|
+
|
|
21
32
|
## Attribution
|
|
22
33
|
|
|
23
34
|
The "lazy senior" part of the coding section in the prompt is adapted from [ponytail](https://github.com/DietrichGebert/ponytail) by DietrichGebert (MIT).
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import injectProjectContext from "./pi-tandem";
|
|
3
|
-
import registerWebTools from "./web-tools";
|
|
4
3
|
|
|
5
4
|
export default function (pi: ExtensionAPI) {
|
|
6
5
|
injectProjectContext(pi);
|
|
7
|
-
registerWebTools(pi);
|
|
8
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tandem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/skhoroshavin/tandem-skills.git"
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"keywords": [
|
|
10
10
|
"pi-package"
|
|
11
11
|
],
|
|
12
|
+
"type": "module",
|
|
12
13
|
"license": "MIT",
|
|
13
14
|
"files": [
|
|
14
15
|
"extensions/",
|
|
@@ -17,5 +18,10 @@
|
|
|
17
18
|
"prompt.md",
|
|
18
19
|
"LICENSE",
|
|
19
20
|
"README.md"
|
|
20
|
-
]
|
|
21
|
+
],
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
24
|
+
"@types/node": "^26.4.1",
|
|
25
|
+
"typescript": "^7.0.2"
|
|
26
|
+
}
|
|
21
27
|
}
|
package/prompt.md
CHANGED
|
@@ -43,6 +43,17 @@ A number of CLI tools are installed on this laptop and fully authenticated, you'
|
|
|
43
43
|
<!--cli:saml2aws-->
|
|
44
44
|
- AWS credentials are short-lived: on an expired or invalid token error, ask the user to run saml2aws login --idp-account <profile> --skip-prompt themselves, giving them the full command to copy-paste
|
|
45
45
|
<!--/cli-->
|
|
46
|
+
<!--cli:pdftotext-->
|
|
47
|
+
- Use `pdftotext` to extract text from PDFs: `pdftotext input.pdf output.txt`, or `pdftotext input.pdf -` to print to stdout
|
|
48
|
+
<!--/cli-->
|
|
49
|
+
<!--cli:pandoc-->
|
|
50
|
+
- Use `pandoc` to read documents other than pdf as markdown or plain text: `pandoc input.docx -o output.md` (also works for odt, rtf, html, ...)
|
|
51
|
+
- Unless your task requires seeing actual tags, always convert HTML fetched from the web to plain text or markdown, instead of reading it raw, for example when using `curl`: `curl -s <url> | pandoc -f html -t plain`.
|
|
52
|
+
<!--/cli-->
|
|
53
|
+
<!--cli:browser-->
|
|
54
|
+
- Use a real headless browser when you need to read a JS-rendered or bot-blocked page: `<browser-binary> --headless --disable-gpu --user-data-dir=$(mktemp -d) --dump-dom --virtual-time-budget=5000 <url>`
|
|
55
|
+
- When in doubt, always try `curl` first before resorting to a headless browser
|
|
56
|
+
<!--/cli-->
|
|
46
57
|
<!--cli:osascript-->
|
|
47
58
|
- Use `osascript` with `execute <tab> javascript "<js>"` to read pages and interact using the user's
|
|
48
59
|
real logged-in sessions (analyzing dashboards, checking Google Calendar and Mail, etc)
|
package/runtime/cli-tools.mjs
CHANGED
|
@@ -18,18 +18,41 @@ export function isOnPath(binary) {
|
|
|
18
18
|
return false;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Chromium-family binary for the headless-render fallback; PATH first, then app bundles
|
|
22
|
+
function findBrowserBinary() {
|
|
23
|
+
for (const name of ["brave", "brave-browser", "chromium", "chromium-browser", "google-chrome", "google-chrome-stable"]) {
|
|
24
|
+
if (isOnPath(name)) return name;
|
|
25
|
+
}
|
|
26
|
+
if (process.platform === "darwin") {
|
|
27
|
+
for (const app of ["Brave Browser", "Chromium", "Google Chrome", "Microsoft Edge"]) {
|
|
28
|
+
const bin = `/Applications/${app}.app/Contents/MacOS/${app}`;
|
|
29
|
+
try {
|
|
30
|
+
if (statSync(bin).isFile()) return bin;
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
// drop <!--cli:tool--> blocks for absent tools, and the whole section when none remain
|
|
37
|
+
function toolAvailable(tool, browser) {
|
|
38
|
+
if (tool === "browser") return browser !== undefined;
|
|
39
|
+
if (tool === "osascript") return process.platform === "darwin" && isOnPath(tool);
|
|
40
|
+
return isOnPath(tool);
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
export function filterCliTools(raw) {
|
|
23
|
-
let
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
let hasTools = false;
|
|
45
|
+
const browser = findBrowserBinary();
|
|
46
|
+
const filtered = raw
|
|
47
|
+
.replace(
|
|
48
|
+
/<!--cli:([a-z0-9]+)-->\r?\n?([\s\S]*?)<!--\/cli-->\r?\n?/g,
|
|
49
|
+
(_marker, tool, body) => {
|
|
50
|
+
if (!toolAvailable(tool, browser)) return "";
|
|
51
|
+
hasTools = true;
|
|
52
|
+
return body;
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
.replace(/<browser-binary>/g, () => (browser ? JSON.stringify(browser) : "<browser-binary>"))
|
|
56
|
+
.replace(/\n{3,}/g, "\n\n");
|
|
57
|
+
return hasTools ? filtered : filtered.replace(/\n## CLI tools[\s\S]*?(?=\n## )/, "");
|
|
35
58
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: code-review
|
|
3
|
+
description: Use when asked to do a code review.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Read the full diff and everything it touches before judging; the worst review
|
|
7
|
+
mistakes come from reviewing the diff only.
|
|
8
|
+
|
|
9
|
+
Report rules:
|
|
10
|
+
|
|
11
|
+
- Actionable findings only, ordered by severity: ones requiring changes, or
|
|
12
|
+
at least explicit decisions; "checked and fine" does not qualify.
|
|
13
|
+
- Each finding: what is wrong and why it matters in 1-3 lines, then the
|
|
14
|
+
smallest fix - as a diff if it fits within 50 LoC, otherwise described
|
|
15
|
+
generally, asking for a decision.
|
|
16
|
+
- No praise, no diff summary, no restating the request, no advice beyond
|
|
17
|
+
findings.
|
|
18
|
+
- If nothing actionable: say exactly that, plus one line on what was checked.
|
|
19
|
+
- Before writing, verify: grep for references to removed or renamed
|
|
20
|
+
identifiers; where the repo has rendered build output, check it is in sync;
|
|
21
|
+
diff the commit message claims against the actual change.
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { formatSize, type TruncationResult, truncateHead } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { Type } from "typebox";
|
|
7
|
-
|
|
8
|
-
// web_search (Brave Search API) and web_fetch (generic HTTP reader) for pi.
|
|
9
|
-
|
|
10
|
-
const MIN_SEARCH_RESULTS = 1;
|
|
11
|
-
const MAX_SEARCH_RESULTS = 10;
|
|
12
|
-
const DEFAULT_SEARCH_RESULTS = 5;
|
|
13
|
-
const BRAVE_API_KEY_ENV_VAR = "BRAVE_SEARCH_API_KEY";
|
|
14
|
-
|
|
15
|
-
interface SearchResult {
|
|
16
|
-
title: string;
|
|
17
|
-
url: string;
|
|
18
|
-
snippet: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function refusePrivateHost(hostname: string): never {
|
|
22
|
-
throw new Error(`Refusing to fetch private/loopback address: ${hostname}`);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Rejects anything that is not a public http(s) endpoint: no non-HTTP schemes,
|
|
26
|
-
// and hostnames that point at loopback, RFC1918, link-local (incl. cloud
|
|
27
|
-
// metadata) or IPv6 local addresses. Literal hostnames only - no DNS lookup.
|
|
28
|
-
function assertPublicHttpUrl(raw: string): void {
|
|
29
|
-
let url: URL;
|
|
30
|
-
try {
|
|
31
|
-
url = new URL(raw);
|
|
32
|
-
} catch {
|
|
33
|
-
throw new Error(`Invalid URL: ${raw}`);
|
|
34
|
-
}
|
|
35
|
-
if (!/^https?:$/.test(url.protocol)) {
|
|
36
|
-
throw new Error(`Unsupported URL protocol: ${url.protocol}. Only http and https are supported.`);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
40
|
-
if (host.includes(":")) {
|
|
41
|
-
// IPv6 literal (DNS names never contain ":"); ::ffff:* are IPv4-mapped
|
|
42
|
-
// forms whose dotted target is hidden by URL canonicalization.
|
|
43
|
-
if (
|
|
44
|
-
host === "::" ||
|
|
45
|
-
host === "::1" ||
|
|
46
|
-
host.startsWith("fe80:") ||
|
|
47
|
-
host.startsWith("fc") ||
|
|
48
|
-
host.startsWith("fd") ||
|
|
49
|
-
host.startsWith("::ffff:")
|
|
50
|
-
) {
|
|
51
|
-
refusePrivateHost(url.hostname);
|
|
52
|
-
}
|
|
53
|
-
} else {
|
|
54
|
-
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
55
|
-
if (v4) {
|
|
56
|
-
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
57
|
-
const reserved =
|
|
58
|
-
a === 0 || // unspecified
|
|
59
|
-
a === 10 || // RFC1918
|
|
60
|
-
a === 127 || // loopback
|
|
61
|
-
(a === 169 && b === 254) || // link-local, incl. cloud metadata
|
|
62
|
-
(a === 172 && b >= 16 && b <= 31) || // RFC1918
|
|
63
|
-
(a === 192 && b === 168); // RFC1918
|
|
64
|
-
if (reserved) refusePrivateHost(url.hostname);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const OMITTED_ELEMENTS_REGEX = /<(script|style|noscript)\b[\s\S]*?<\/\1>/gi;
|
|
70
|
-
const BLOCK_BREAK_REGEX =
|
|
71
|
-
/<\/(p|div|h[1-6]|li|tr|blockquote|pre|section|article|header|footer|nav|details|summary)>|<br\s*\/?>/gi;
|
|
72
|
-
const ANY_TAG_REGEX = /<[^>]+>/g;
|
|
73
|
-
const TITLE_TAG_REGEX = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
|
74
|
-
|
|
75
|
-
function htmlToText(html: string): string {
|
|
76
|
-
let text = html.replace(OMITTED_ELEMENTS_REGEX, "").replace(BLOCK_BREAK_REGEX, "\n");
|
|
77
|
-
text = text.replace(ANY_TAG_REGEX, " ");
|
|
78
|
-
text = text
|
|
79
|
-
.replace(/&/g, "&")
|
|
80
|
-
.replace(/</g, "<")
|
|
81
|
-
.replace(/>/g, ">")
|
|
82
|
-
.replace(/"/g, '"')
|
|
83
|
-
.replace(/'/g, "'")
|
|
84
|
-
.replace(/ /g, " ")
|
|
85
|
-
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
|
|
86
|
-
return text.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function pageTitle(html: string): string | undefined {
|
|
90
|
-
return html.match(TITLE_TAG_REGEX)?.[1].replace(ANY_TAG_REGEX, "").trim() || undefined;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function braveSearch(
|
|
94
|
-
query: string,
|
|
95
|
-
maxResults: number,
|
|
96
|
-
apiKey: string,
|
|
97
|
-
signal: AbortSignal | undefined,
|
|
98
|
-
): Promise<SearchResult[]> {
|
|
99
|
-
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
100
|
-
url.searchParams.set("q", query);
|
|
101
|
-
url.searchParams.set("count", String(maxResults));
|
|
102
|
-
const res = await fetch(url.toString(), {
|
|
103
|
-
headers: {
|
|
104
|
-
Accept: "application/json",
|
|
105
|
-
"Accept-Encoding": "gzip",
|
|
106
|
-
"X-Subscription-Token": apiKey,
|
|
107
|
-
},
|
|
108
|
-
signal,
|
|
109
|
-
});
|
|
110
|
-
if (!res.ok) {
|
|
111
|
-
throw new Error(`Brave API error (${res.status}): ${await res.text()}`);
|
|
112
|
-
}
|
|
113
|
-
const raw = (await res.json()) as { web?: { results?: Array<{ title?: string; url?: string; description?: string }> } };
|
|
114
|
-
return (raw.web?.results ?? []).map((r) => ({ title: r.title ?? "", url: r.url ?? "", snippet: r.description ?? "" }));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function readPage(url: string, raw: boolean, signal: AbortSignal | undefined): Promise<{ text: string; title?: string; contentType?: string }> {
|
|
118
|
-
const res = await fetch(url, {
|
|
119
|
-
signal,
|
|
120
|
-
redirect: "follow",
|
|
121
|
-
headers: {
|
|
122
|
-
"User-Agent": "Mozilla/5.0 (compatible; tandem-pi/1.0)",
|
|
123
|
-
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5",
|
|
124
|
-
},
|
|
125
|
-
});
|
|
126
|
-
if (!res.ok) {
|
|
127
|
-
throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
128
|
-
}
|
|
129
|
-
const contentType = res.headers.get("content-type") ?? "";
|
|
130
|
-
if (/^(image|video|audio)\//.test(contentType)) {
|
|
131
|
-
throw new Error(`Unsupported content type: ${contentType}. web_fetch supports text pages only.`);
|
|
132
|
-
}
|
|
133
|
-
const rawBody = await res.text();
|
|
134
|
-
const pageContentType = contentType || undefined;
|
|
135
|
-
if (!raw && /text\/html/.test(contentType)) {
|
|
136
|
-
return { text: htmlToText(rawBody), title: pageTitle(rawBody), contentType: pageContentType };
|
|
137
|
-
}
|
|
138
|
-
return { text: rawBody, contentType: pageContentType };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async function writeTempCopy(content: string): Promise<string> {
|
|
142
|
-
const dir = await mkdtemp(join(tmpdir(), "tandem-fetch-"));
|
|
143
|
-
const file = join(dir, "content.txt");
|
|
144
|
-
await writeFile(file, content, "utf8");
|
|
145
|
-
return file;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function formatTruncationNote(truncation: TruncationResult, tempFile: string): string {
|
|
149
|
-
const omittedLines = truncation.totalLines - truncation.outputLines;
|
|
150
|
-
const omittedBytes = truncation.totalBytes - truncation.outputBytes;
|
|
151
|
-
return (
|
|
152
|
-
`\n\n[Content truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines` +
|
|
153
|
-
` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).` +
|
|
154
|
-
` ${omittedLines} lines (${formatSize(omittedBytes)}) omitted.` +
|
|
155
|
-
` Full content saved to: ${tempFile}]`
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function formatSearchResults(query: string, results: SearchResult[]): string {
|
|
160
|
-
const header = `**Search results for "${query}":**`;
|
|
161
|
-
const list = results
|
|
162
|
-
.map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}`)
|
|
163
|
-
.join("\n\n");
|
|
164
|
-
return `${header}\n\n${list}`;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function formatPageHeader(url: string, title: string | undefined, contentType: string): string {
|
|
168
|
-
const lines = [`**Fetched:** ${url}`];
|
|
169
|
-
if (title) lines.push(`**Title:** ${title}`);
|
|
170
|
-
if (contentType) lines.push(`**Content-Type:** ${contentType}`);
|
|
171
|
-
return `${lines.join("\n")}\n\n`;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export default function (pi: ExtensionAPI) {
|
|
175
|
-
pi.registerTool({
|
|
176
|
-
name: "web_search",
|
|
177
|
-
label: "Web Search",
|
|
178
|
-
description:
|
|
179
|
-
"Search the web for information. Returns a list of results with titles, URLs, and snippets. Use when you need current information not in your training data.",
|
|
180
|
-
parameters: Type.Object({
|
|
181
|
-
query: Type.String({
|
|
182
|
-
description: "The search query. Be specific and use natural language.",
|
|
183
|
-
}),
|
|
184
|
-
max_results: Type.Optional(
|
|
185
|
-
Type.Number({
|
|
186
|
-
description: `Maximum number of results to return (${MIN_SEARCH_RESULTS}-${MAX_SEARCH_RESULTS}). Default: ${DEFAULT_SEARCH_RESULTS}.`,
|
|
187
|
-
default: DEFAULT_SEARCH_RESULTS,
|
|
188
|
-
minimum: MIN_SEARCH_RESULTS,
|
|
189
|
-
maximum: MAX_SEARCH_RESULTS,
|
|
190
|
-
}),
|
|
191
|
-
),
|
|
192
|
-
}),
|
|
193
|
-
async execute(_toolCallId, params, signal, onUpdate) {
|
|
194
|
-
const apiKey = process.env[BRAVE_API_KEY_ENV_VAR]?.trim();
|
|
195
|
-
if (!apiKey) {
|
|
196
|
-
throw new Error(`${BRAVE_API_KEY_ENV_VAR} is not set. Export it to enable web_search.`);
|
|
197
|
-
}
|
|
198
|
-
onUpdate?.({
|
|
199
|
-
content: [{ type: "text", text: `Searching Brave for: "${params.query}"...` }],
|
|
200
|
-
details: undefined,
|
|
201
|
-
});
|
|
202
|
-
const maxResults = Math.min(
|
|
203
|
-
Math.max(params.max_results ?? DEFAULT_SEARCH_RESULTS, MIN_SEARCH_RESULTS),
|
|
204
|
-
MAX_SEARCH_RESULTS,
|
|
205
|
-
);
|
|
206
|
-
const results = await braveSearch(params.query, maxResults, apiKey, signal);
|
|
207
|
-
if (results.length === 0) {
|
|
208
|
-
return { content: [{ type: "text", text: `No results found for "${params.query}".` }], details: undefined };
|
|
209
|
-
}
|
|
210
|
-
return { content: [{ type: "text", text: formatSearchResults(params.query, results) }], details: undefined };
|
|
211
|
-
},
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
pi.registerTool({
|
|
215
|
-
name: "web_fetch",
|
|
216
|
-
label: "Web Fetch",
|
|
217
|
-
description:
|
|
218
|
-
"Fetch the content of a specific URL. Returns text content for HTML pages (tags stripped), raw text for plain text or JSON. Supports http and https only. Content is truncated to avoid overwhelming the context window.",
|
|
219
|
-
parameters: Type.Object({
|
|
220
|
-
url: Type.String({
|
|
221
|
-
description: "The URL to fetch. Must be http or https.",
|
|
222
|
-
}),
|
|
223
|
-
raw: Type.Optional(
|
|
224
|
-
Type.Boolean({
|
|
225
|
-
description: "If true, return the raw HTML instead of extracted text. Default: false.",
|
|
226
|
-
default: false,
|
|
227
|
-
}),
|
|
228
|
-
),
|
|
229
|
-
}),
|
|
230
|
-
async execute(_toolCallId, params, signal, onUpdate) {
|
|
231
|
-
const { url, raw = false } = params;
|
|
232
|
-
assertPublicHttpUrl(url);
|
|
233
|
-
onUpdate?.({ content: [{ type: "text", text: `Fetching: ${url}...` }], details: undefined });
|
|
234
|
-
const { text: pageText, title, contentType } = await readPage(url, raw, signal);
|
|
235
|
-
const truncation = truncateHead(pageText);
|
|
236
|
-
let output = truncation.content;
|
|
237
|
-
if (truncation.truncated) {
|
|
238
|
-
output += formatTruncationNote(truncation, await writeTempCopy(pageText));
|
|
239
|
-
}
|
|
240
|
-
return {
|
|
241
|
-
content: [{ type: "text", text: formatPageHeader(url, title, contentType ?? "") + output }],
|
|
242
|
-
details: undefined,
|
|
243
|
-
};
|
|
244
|
-
},
|
|
245
|
-
});
|
|
246
|
-
}
|