@yagni-app/code-staging 0.3.4-staging.1156.1 → 0.3.5-staging.1160.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.
package/dist/extension/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { redactCommand } from "./redact.js";
|
|
|
8
8
|
import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs } from "./permission/guardian.js";
|
|
9
9
|
import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
10
10
|
import { makeAskYagniTool } from "./askYagniTool.js";
|
|
11
|
+
import { makeWebFetchTool } from "./webFetchTool.js";
|
|
11
12
|
import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
12
13
|
import { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
13
14
|
import { registerCmuxBridge } from "./cmux/index.js";
|
|
@@ -159,6 +160,9 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
159
160
|
// (flywheel-attributed records send dedupe: true). Run 7.
|
|
160
161
|
const flywheelState = makeFlywheelState();
|
|
161
162
|
pi.registerTool(makeAskYagniTool({ ...toolOpts, flywheel: flywheelState, getRepo: () => sessionRepo }));
|
|
163
|
+
// WebFetch (YAG-578): read an arbitrary URL as clean markdown + a
|
|
164
|
+
// standard-tier extraction, replacing the bash + curl + python dance.
|
|
165
|
+
pi.registerTool(makeWebFetchTool(toolOpts));
|
|
162
166
|
// Ticket write-back (spec 2026-08-09): explicit user-intent writes to the
|
|
163
167
|
// workspace tracker, attributed to the developer via per-user credentials.
|
|
164
168
|
if (!evalMode) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebFetch core: fetch + convert + extract, adapted from Claude Code's
|
|
3
|
+
* WebFetchTool (src/tools/WebFetchTool/utils.ts). Pure and pi-free so it is
|
|
4
|
+
* fully unit-testable against fixtures, mirroring resilientFetch.ts.
|
|
5
|
+
*
|
|
6
|
+
* Three stages:
|
|
7
|
+
* 1. FETCH an arbitrary URL with a PLAIN fetch impl (deliberately NOT the
|
|
8
|
+
* authed fetch — see the token-leak note below).
|
|
9
|
+
* 2. CONVERT HTML → markdown via turndown; non-HTML passes through raw.
|
|
10
|
+
* 3. EXTRACT a `standard`-tier completion against the user's prompt.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY (token leak): the extension's `makeAuthedFetch` transparently
|
|
13
|
+
* re-sends a second request WITH a fresh `Bearer $YAGNI_TOKEN` whenever the
|
|
14
|
+
* first returns 401 (tokenProvider.ts). Routing that wrapper at an arbitrary
|
|
15
|
+
* host would leak the token to any site that answers 401. The URL fetch must
|
|
16
|
+
* therefore use a plain global fetch, never opts.fetchImpl.
|
|
17
|
+
*/
|
|
18
|
+
/** Upper bound on the markdown handed to the extraction model (Claude's cap). */
|
|
19
|
+
export declare const MAX_MARKDOWN_LENGTH = 100000;
|
|
20
|
+
/** Upper bound on the raw HTTP body we will accept (Claude's PSR cap). */
|
|
21
|
+
export declare const MAX_HTTP_CONTENT_LENGTH: number;
|
|
22
|
+
/** Per-request wall clock for the URL fetch (Claude's 60s). */
|
|
23
|
+
export declare const FETCH_TIMEOUT_MS = 60000;
|
|
24
|
+
/** Same-host redirect hop cap (Claude's 10, matching common client defaults). */
|
|
25
|
+
export declare const MAX_REDIRECTS = 10;
|
|
26
|
+
/** The marker the tool returns when a redirect jumps host. */
|
|
27
|
+
export interface RedirectInfo {
|
|
28
|
+
type: "redirect";
|
|
29
|
+
originalUrl: string;
|
|
30
|
+
redirectUrl: string;
|
|
31
|
+
statusCode: number;
|
|
32
|
+
}
|
|
33
|
+
export interface FetchedContent {
|
|
34
|
+
content: string;
|
|
35
|
+
bytes: number;
|
|
36
|
+
code: number;
|
|
37
|
+
codeText: string;
|
|
38
|
+
contentType: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Reject URLs that could exfiltrate credentials or reach the user's own
|
|
42
|
+
* machine/network. Ported from Claude's validateURL: >2k chars, embedded
|
|
43
|
+
* userinfo, and single-label (non-public) hostnames are all refused.
|
|
44
|
+
*/
|
|
45
|
+
export declare function validateUrl(url: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Whether a redirect may be followed: same protocol/port, no userinfo, and the
|
|
48
|
+
* host differs only by an optional leading `www.`. Ported from Claude.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isPermittedRedirect(originalUrl: string, redirectUrl: string): boolean;
|
|
51
|
+
export interface FetchMarkdownOptions {
|
|
52
|
+
/** Plain global fetch for the arbitrary URL (never an authed wrapper). */
|
|
53
|
+
fetchImpl?: typeof fetch;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Fetch `url` (following only same-host redirects) and convert the body to
|
|
58
|
+
* markdown. Returns the converted content, or a {@link RedirectInfo} when the
|
|
59
|
+
* response routes to a different host (the caller re-invokes with the new URL).
|
|
60
|
+
*/
|
|
61
|
+
export declare function fetchMarkdown(url: string, opts?: FetchMarkdownOptions): Promise<FetchedContent | RedirectInfo>;
|
|
62
|
+
export interface ExtractionOptions {
|
|
63
|
+
baseUrl: string;
|
|
64
|
+
getToken: () => string | undefined;
|
|
65
|
+
/** The authed fetch (makeAuthedFetch) for the extraction call. */
|
|
66
|
+
fetchImpl?: typeof fetch;
|
|
67
|
+
attribution: () => Record<string, string>;
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build the extraction prompt: the fetched content plus the user's prompt plus
|
|
72
|
+
* the "answer only from the content" guardrails, mirroring Claude's
|
|
73
|
+
* makeSecondaryModelPrompt.
|
|
74
|
+
*/
|
|
75
|
+
export declare function makeExtractionPrompt(markdownContent: string, prompt: string): string;
|
|
76
|
+
export interface ExtractResult {
|
|
77
|
+
text: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Run the extraction: one `standard`-tier completion against the metric proxy
|
|
81
|
+
* (the same /v1/chat/completions surface pi's `yagni` provider drives). The
|
|
82
|
+
* content was already fetched locally; only this summarization rides the proxy.
|
|
83
|
+
*/
|
|
84
|
+
export declare function extract(markdownContent: string, prompt: string, opts: ExtractionOptions): Promise<ExtractResult>;
|
|
85
|
+
//# sourceMappingURL=webFetch.d.ts.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebFetch core: fetch + convert + extract, adapted from Claude Code's
|
|
3
|
+
* WebFetchTool (src/tools/WebFetchTool/utils.ts). Pure and pi-free so it is
|
|
4
|
+
* fully unit-testable against fixtures, mirroring resilientFetch.ts.
|
|
5
|
+
*
|
|
6
|
+
* Three stages:
|
|
7
|
+
* 1. FETCH an arbitrary URL with a PLAIN fetch impl (deliberately NOT the
|
|
8
|
+
* authed fetch — see the token-leak note below).
|
|
9
|
+
* 2. CONVERT HTML → markdown via turndown; non-HTML passes through raw.
|
|
10
|
+
* 3. EXTRACT a `standard`-tier completion against the user's prompt.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY (token leak): the extension's `makeAuthedFetch` transparently
|
|
13
|
+
* re-sends a second request WITH a fresh `Bearer $YAGNI_TOKEN` whenever the
|
|
14
|
+
* first returns 401 (tokenProvider.ts). Routing that wrapper at an arbitrary
|
|
15
|
+
* host would leak the token to any site that answers 401. The URL fetch must
|
|
16
|
+
* therefore use a plain global fetch, never opts.fetchImpl.
|
|
17
|
+
*/
|
|
18
|
+
/** Upper bound on the markdown handed to the extraction model (Claude's cap). */
|
|
19
|
+
export const MAX_MARKDOWN_LENGTH = 100_000;
|
|
20
|
+
/** Upper bound on the raw HTTP body we will accept (Claude's PSR cap). */
|
|
21
|
+
export const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024;
|
|
22
|
+
/** Per-request wall clock for the URL fetch (Claude's 60s). */
|
|
23
|
+
export const FETCH_TIMEOUT_MS = 60_000;
|
|
24
|
+
/** Same-host redirect hop cap (Claude's 10, matching common client defaults). */
|
|
25
|
+
export const MAX_REDIRECTS = 10;
|
|
26
|
+
/** URLs longer than this are rejected as a data-exfil surface (Claude). */
|
|
27
|
+
const MAX_URL_LENGTH = 2000;
|
|
28
|
+
/**
|
|
29
|
+
* Reject URLs that could exfiltrate credentials or reach the user's own
|
|
30
|
+
* machine/network. Ported from Claude's validateURL: >2k chars, embedded
|
|
31
|
+
* userinfo, and single-label (non-public) hostnames are all refused.
|
|
32
|
+
*/
|
|
33
|
+
export function validateUrl(url) {
|
|
34
|
+
if (url.length > MAX_URL_LENGTH)
|
|
35
|
+
return false;
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = new URL(url);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (parsed.username || parsed.password)
|
|
44
|
+
return false;
|
|
45
|
+
const parts = parsed.hostname.split(".");
|
|
46
|
+
if (parts.length < 2)
|
|
47
|
+
return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether a redirect may be followed: same protocol/port, no userinfo, and the
|
|
52
|
+
* host differs only by an optional leading `www.`. Ported from Claude.
|
|
53
|
+
*/
|
|
54
|
+
export function isPermittedRedirect(originalUrl, redirectUrl) {
|
|
55
|
+
try {
|
|
56
|
+
const orig = new URL(originalUrl);
|
|
57
|
+
const redir = new URL(redirectUrl);
|
|
58
|
+
if (redir.protocol !== orig.protocol)
|
|
59
|
+
return false;
|
|
60
|
+
if (redir.port !== orig.port)
|
|
61
|
+
return false;
|
|
62
|
+
if (redir.username || redir.password)
|
|
63
|
+
return false;
|
|
64
|
+
const stripWww = (host) => host.replace(/^www\./, "");
|
|
65
|
+
return stripWww(orig.hostname) === stripWww(redir.hostname);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Lazily import turndown once, on first HTML fetch. */
|
|
72
|
+
let turndownService;
|
|
73
|
+
async function getTurndown() {
|
|
74
|
+
if (!turndownService) {
|
|
75
|
+
const Turndown = (await import("turndown")).default;
|
|
76
|
+
turndownService = new Turndown();
|
|
77
|
+
}
|
|
78
|
+
return turndownService;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Fetch `url` (following only same-host redirects) and convert the body to
|
|
82
|
+
* markdown. Returns the converted content, or a {@link RedirectInfo} when the
|
|
83
|
+
* response routes to a different host (the caller re-invokes with the new URL).
|
|
84
|
+
*/
|
|
85
|
+
export async function fetchMarkdown(url, opts = {}) {
|
|
86
|
+
if (!validateUrl(url))
|
|
87
|
+
throw new Error(`Invalid URL: "${url}"`);
|
|
88
|
+
const response = await fetchWithPermittedRedirects(url, opts, 0);
|
|
89
|
+
if ("type" in response)
|
|
90
|
+
return response;
|
|
91
|
+
const raw = Buffer.from(response.data);
|
|
92
|
+
const contentType = response.headers?.["content-type"] ?? "";
|
|
93
|
+
const bytes = raw.length;
|
|
94
|
+
const text = raw.toString("utf-8");
|
|
95
|
+
let content;
|
|
96
|
+
if (contentType.includes("text/html")) {
|
|
97
|
+
content = (await getTurndown()).turndown(text);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
content = text;
|
|
101
|
+
}
|
|
102
|
+
if (content.length > MAX_MARKDOWN_LENGTH) {
|
|
103
|
+
content = `${content.slice(0, MAX_MARKDOWN_LENGTH)}\n\n[Content truncated due to length...]`;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
content,
|
|
107
|
+
bytes,
|
|
108
|
+
code: response.status,
|
|
109
|
+
codeText: response.statusText,
|
|
110
|
+
contentType,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Low-level fetch with manual, same-host-only redirect following. */
|
|
114
|
+
async function fetchWithPermittedRedirects(url, opts, depth) {
|
|
115
|
+
if (depth > MAX_REDIRECTS) {
|
|
116
|
+
throw new Error(`Too many redirects (exceeded ${MAX_REDIRECTS})`);
|
|
117
|
+
}
|
|
118
|
+
const signal = opts.signal;
|
|
119
|
+
const response = await (opts.fetchImpl ?? fetch)(url, {
|
|
120
|
+
method: "GET",
|
|
121
|
+
redirect: "manual",
|
|
122
|
+
signal,
|
|
123
|
+
headers: {
|
|
124
|
+
Accept: "text/markdown, text/html, */*",
|
|
125
|
+
"User-Agent": "YAGNI-Code (web-fetch)",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
if ([301, 302, 307, 308].includes(response.status)) {
|
|
129
|
+
const location = response.headers.get("location");
|
|
130
|
+
if (!location)
|
|
131
|
+
throw new Error("Redirect missing Location header");
|
|
132
|
+
const redirectUrl = new URL(location, url).toString();
|
|
133
|
+
if (!isPermittedRedirect(url, redirectUrl)) {
|
|
134
|
+
return { type: "redirect", originalUrl: url, redirectUrl, statusCode: response.status };
|
|
135
|
+
}
|
|
136
|
+
return fetchWithPermittedRedirects(redirectUrl, opts, depth + 1);
|
|
137
|
+
}
|
|
138
|
+
const contentLength = Number(response.headers.get("content-length") ?? "0");
|
|
139
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_HTTP_CONTENT_LENGTH) {
|
|
140
|
+
throw new Error(`Response exceeds ${MAX_HTTP_CONTENT_LENGTH} bytes`);
|
|
141
|
+
}
|
|
142
|
+
const data = await response.arrayBuffer();
|
|
143
|
+
if (data.byteLength > MAX_HTTP_CONTENT_LENGTH) {
|
|
144
|
+
throw new Error(`Response exceeds ${MAX_HTTP_CONTENT_LENGTH} bytes`);
|
|
145
|
+
}
|
|
146
|
+
const headers = {};
|
|
147
|
+
response.headers.forEach((value, key) => {
|
|
148
|
+
headers[key] = value;
|
|
149
|
+
});
|
|
150
|
+
return { data, headers, status: response.status, statusText: response.statusText };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Build the extraction prompt: the fetched content plus the user's prompt plus
|
|
154
|
+
* the "answer only from the content" guardrails, mirroring Claude's
|
|
155
|
+
* makeSecondaryModelPrompt.
|
|
156
|
+
*/
|
|
157
|
+
export function makeExtractionPrompt(markdownContent, prompt) {
|
|
158
|
+
const guidelines = "Provide a concise response based only on the content above. In your response:\n" +
|
|
159
|
+
" - Enforce a strict 125-character maximum for quotes from any source document.\n" +
|
|
160
|
+
" - Use quotation marks for exact language; any language outside the quotation should never be word-for-word the same.";
|
|
161
|
+
return `Web page content:\n---\n${markdownContent}\n---\n\n${prompt}\n\n${guidelines}`;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Run the extraction: one `standard`-tier completion against the metric proxy
|
|
165
|
+
* (the same /v1/chat/completions surface pi's `yagni` provider drives). The
|
|
166
|
+
* content was already fetched locally; only this summarization rides the proxy.
|
|
167
|
+
*/
|
|
168
|
+
export async function extract(markdownContent, prompt, opts) {
|
|
169
|
+
const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl}/v1/chat/completions`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: {
|
|
172
|
+
"content-type": "application/json",
|
|
173
|
+
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
174
|
+
...opts.attribution(),
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
model: "standard",
|
|
178
|
+
messages: [{ role: "user", content: makeExtractionPrompt(markdownContent, prompt) }],
|
|
179
|
+
}),
|
|
180
|
+
signal: opts.signal,
|
|
181
|
+
});
|
|
182
|
+
if (!res.ok) {
|
|
183
|
+
throw new Error(`web_fetch extraction failed: HTTP ${res.status}`);
|
|
184
|
+
}
|
|
185
|
+
const data = (await res.json());
|
|
186
|
+
const text = data.choices?.[0]?.message?.content;
|
|
187
|
+
if (typeof text !== "string") {
|
|
188
|
+
throw new Error("web_fetch extraction returned no content");
|
|
189
|
+
}
|
|
190
|
+
return { text };
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=webFetch.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { makeExtractionPrompt, MAX_MARKDOWN_LENGTH } from "./webFetch.js";
|
|
4
|
+
export interface MakeWebFetchToolOptions {
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
getToken: () => string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* The authed fetch (makeAuthedFetch) used for the EXTRACTION call only. It is
|
|
9
|
+
* deliberately never used for the arbitrary URL fetch — see the token-leak
|
|
10
|
+
* note in webFetch.ts.
|
|
11
|
+
*/
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
/**
|
|
14
|
+
* The PLAIN fetch used for the arbitrary URL fetch (defaults to global fetch,
|
|
15
|
+
* never the authed wrapper). Injectable so tests can stub the network without
|
|
16
|
+
* risking a real outbound request.
|
|
17
|
+
*/
|
|
18
|
+
webFetchImpl?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
declare const parameters: Type.TObject<{
|
|
21
|
+
url: Type.TString;
|
|
22
|
+
prompt: Type.TString;
|
|
23
|
+
}>;
|
|
24
|
+
/**
|
|
25
|
+
* Build the `web_fetch` tool definition — YAG-578.
|
|
26
|
+
*
|
|
27
|
+
* Fetches a URL, converts HTML to markdown, then extracts the requested
|
|
28
|
+
* information with a `standard`-tier completion. Read-only, client-side; it is
|
|
29
|
+
* the tidy replacement for a `bash` + `curl` + `python` fetch-and-strip dance.
|
|
30
|
+
*/
|
|
31
|
+
export declare function makeWebFetchTool(opts: MakeWebFetchToolOptions): ToolDefinition<typeof parameters, undefined>;
|
|
32
|
+
/** Re-exported for tests that assemble the extraction prompt. */
|
|
33
|
+
export { MAX_MARKDOWN_LENGTH, makeExtractionPrompt };
|
|
34
|
+
//# sourceMappingURL=webFetchTool.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { attributionHeaders } from "./config.js";
|
|
4
|
+
import { markdownOrPlain } from "./subagentRender.js";
|
|
5
|
+
import { extract, fetchMarkdown, makeExtractionPrompt, MAX_MARKDOWN_LENGTH, } from "./webFetch.js";
|
|
6
|
+
const parameters = Type.Object({
|
|
7
|
+
url: Type.String({ description: "The URL to fetch content from" }),
|
|
8
|
+
prompt: Type.String({ description: "The prompt to run on the fetched content" }),
|
|
9
|
+
});
|
|
10
|
+
/** Collapsed preview length, in lines. */
|
|
11
|
+
const PREVIEW_LINES = 8;
|
|
12
|
+
function clipLine(text, max) {
|
|
13
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
14
|
+
if (collapsed.length <= max)
|
|
15
|
+
return collapsed;
|
|
16
|
+
return `${collapsed.slice(0, max - 1)}…`;
|
|
17
|
+
}
|
|
18
|
+
function hostOf(url) {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(url).hostname;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return url;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build the `web_fetch` tool definition — YAG-578.
|
|
28
|
+
*
|
|
29
|
+
* Fetches a URL, converts HTML to markdown, then extracts the requested
|
|
30
|
+
* information with a `standard`-tier completion. Read-only, client-side; it is
|
|
31
|
+
* the tidy replacement for a `bash` + `curl` + `python` fetch-and-strip dance.
|
|
32
|
+
*/
|
|
33
|
+
export function makeWebFetchTool(opts) {
|
|
34
|
+
return {
|
|
35
|
+
name: "web_fetch",
|
|
36
|
+
label: "Web fetch",
|
|
37
|
+
description: "Fetch a URL, convert HTML to markdown, and extract the requested information " +
|
|
38
|
+
"using a small model. Read-only. Use this to read an arbitrary web page or docs " +
|
|
39
|
+
"URL (an API reference, a changelog, a docs page) instead of reaching for bash + curl. " +
|
|
40
|
+
"It will fail for authenticated or private URLs (Google Docs, Jira, GitHub, etc.) — " +
|
|
41
|
+
"use a specialized connector or MCP tool for those.",
|
|
42
|
+
promptSnippet: "web_fetch: fetch a URL, convert to markdown, and summarize against a prompt (read-only).",
|
|
43
|
+
promptGuidelines: [
|
|
44
|
+
"Use web_fetch to read a URL's content instead of bash + curl when you need a page or a summary of it.",
|
|
45
|
+
],
|
|
46
|
+
parameters,
|
|
47
|
+
renderCall(args, theme) {
|
|
48
|
+
const t = theme;
|
|
49
|
+
const url = clipLine(args?.url ?? "…", 80);
|
|
50
|
+
const prompt = args?.prompt ? clipLine(args.prompt, 60) : "";
|
|
51
|
+
let text = `${t.fg("toolTitle", t.bold("web_fetch"))} ${t.fg("accent", url)}`;
|
|
52
|
+
if (prompt)
|
|
53
|
+
text += ` — ${t.fg("dim", prompt)}`;
|
|
54
|
+
return new Text(text, 0, 0);
|
|
55
|
+
},
|
|
56
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
57
|
+
const t = theme;
|
|
58
|
+
const content = result.content.find((c) => c.type === "text");
|
|
59
|
+
const text = content && "text" in content ? content.text : "";
|
|
60
|
+
if (isPartial)
|
|
61
|
+
return new Text(t.fg("muted", text || "Fetching…"), 0, 0);
|
|
62
|
+
if (expanded) {
|
|
63
|
+
const container = new Container();
|
|
64
|
+
container.addChild(markdownOrPlain(text || "(no content)", t));
|
|
65
|
+
return container;
|
|
66
|
+
}
|
|
67
|
+
const lines = text.trim().split("\n");
|
|
68
|
+
const out = lines.slice(0, PREVIEW_LINES).map((l) => t.fg("toolOutput", l));
|
|
69
|
+
if (lines.length > PREVIEW_LINES)
|
|
70
|
+
out.push(t.fg("muted", " (ctrl+o to expand)"));
|
|
71
|
+
return new Text(out.join("\n"), 0, 0);
|
|
72
|
+
},
|
|
73
|
+
async execute(_toolCallId, params, signal, onUpdate) {
|
|
74
|
+
onUpdate?.({ content: [{ type: "text", text: "Fetching…" }], details: undefined });
|
|
75
|
+
const fetched = await fetchMarkdown(params.url, { fetchImpl: opts.webFetchImpl, signal });
|
|
76
|
+
// A cross-host redirect is not auto-followed; ask the model to re-call.
|
|
77
|
+
if (fetched.type === "redirect") {
|
|
78
|
+
const r = fetched;
|
|
79
|
+
const statusText = r.statusCode === 301 ? "Moved Permanently"
|
|
80
|
+
: r.statusCode === 308 ? "Permanent Redirect"
|
|
81
|
+
: r.statusCode === 307 ? "Temporary Redirect"
|
|
82
|
+
: "Found";
|
|
83
|
+
const message = `REDIRECT DETECTED: The URL redirects to a different host.\n\n` +
|
|
84
|
+
`Original URL: ${r.originalUrl}\nRedirect URL: ${r.redirectUrl}\n` +
|
|
85
|
+
`Status: ${r.statusCode} ${statusText}\n\n` +
|
|
86
|
+
`To complete your request, call web_fetch again with these parameters:\n` +
|
|
87
|
+
`- url: "${r.redirectUrl}"\n- prompt: "${params.prompt}"`;
|
|
88
|
+
return { content: [{ type: "text", text: message }], details: undefined };
|
|
89
|
+
}
|
|
90
|
+
const content = fetched.content;
|
|
91
|
+
const { text } = await extract(content, params.prompt, {
|
|
92
|
+
baseUrl: opts.baseUrl,
|
|
93
|
+
getToken: opts.getToken,
|
|
94
|
+
fetchImpl: opts.fetchImpl,
|
|
95
|
+
attribution: () => attributionHeaders(),
|
|
96
|
+
signal,
|
|
97
|
+
});
|
|
98
|
+
return { content: [{ type: "text", text }], details: undefined };
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Re-exported for tests that assemble the extraction prompt. */
|
|
103
|
+
export { MAX_MARKDOWN_LENGTH, makeExtractionPrompt };
|
|
104
|
+
//# sourceMappingURL=webFetchTool.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5-staging.1160.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
38
38
|
"@earendil-works/pi-tui": "0.84.1",
|
|
39
39
|
"smol-toml": "^1.8.0",
|
|
40
|
+
"turndown": "^7.2.4",
|
|
40
41
|
"typebox": "^1.3.15"
|
|
41
42
|
},
|
|
42
|
-
"yagniSourceSha": "
|
|
43
|
+
"yagniSourceSha": "cb341f87d13165d17246c8c335200c69982bd60b"
|
|
43
44
|
}
|