pi-tandem 0.1.0 → 0.2.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.
@@ -0,0 +1,8 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import injectProjectContext from "./pi-tandem";
3
+ import registerWebTools from "./web-tools";
4
+
5
+ export default function (pi: ExtensionAPI) {
6
+ injectProjectContext(pi);
7
+ registerWebTools(pi);
8
+ }
@@ -1,11 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { filterCliTools } from "../runtime/cli-tools.mjs";
4
+ import { filterCliTools } from "../../runtime/cli-tools.mjs";
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  const prompt = filterCliTools(
8
- readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../prompt.md"), "utf8"),
8
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../prompt.md"), "utf8"),
9
9
  );
10
10
 
11
11
  export default function (pi: ExtensionAPI) {
@@ -0,0 +1,246 @@
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(/&amp;/g, "&")
80
+ .replace(/&lt;/g, "<")
81
+ .replace(/&gt;/g, ">")
82
+ .replace(/&quot;/g, '"')
83
+ .replace(/&#39;/g, "'")
84
+ .replace(/&nbsp;/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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tandem",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/skhoroshavin/tandem-skills.git"