@pi-archimedes/web 0.0.0-stage → 2.9.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,80 @@
1
+ import dns from 'dns';
2
+ import { isIP, BlockList } from 'net';
3
+
4
+ const blockList = new BlockList();
5
+
6
+ // IPv4
7
+ blockList.addRange('127.0.0.0', '127.255.255.255', 'ipv4');
8
+ blockList.addRange('10.0.0.0', '10.255.255.255', 'ipv4');
9
+ blockList.addRange('172.16.0.0', '172.31.255.255', 'ipv4');
10
+ blockList.addRange('192.168.0.0', '192.168.255.255', 'ipv4');
11
+ blockList.addRange('169.254.0.0', '169.254.255.255', 'ipv4');
12
+ blockList.addRange('100.64.0.0', '100.127.255.255', 'ipv4'); // CGNAT
13
+ blockList.addRange('0.0.0.0', '0.255.255.255', 'ipv4');
14
+
15
+ // IPv6
16
+ blockList.addAddress('::1', 'ipv6');
17
+ blockList.addAddress('::', 'ipv6');
18
+ blockList.addRange('fc00::', 'fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 'ipv6');
19
+ blockList.addRange('fe80::', 'febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 'ipv6');
20
+ blockList.addRange('198.18.0.0', '198.19.255.255', 'ipv4');
21
+ blockList.addRange('192.0.0.0', '192.0.0.255', 'ipv4');
22
+ blockList.addRange('224.0.0.0', '239.255.255.255', 'ipv4');
23
+ blockList.addRange('240.0.0.0', '255.255.255.255', 'ipv4');
24
+ blockList.addRange('ff00::', 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 'ipv6');
25
+
26
+ export function isPrivateIp(ip: string): boolean {
27
+ let address = ip;
28
+ if (address.startsWith('[') && address.endsWith(']')) {
29
+ address = address.slice(1, -1);
30
+ }
31
+
32
+ // Handle IPv4-mapped IPv6
33
+ if (address.toLowerCase().startsWith('::ffff:')) {
34
+ let ipv4 = address.substring(7);
35
+ // Convert hex if needed (e.g. ::ffff:7f00:1 -> 127.0.0.1)
36
+ if (ipv4.includes(':')) {
37
+ // Simple heuristic for this task: convert hex parts
38
+ const parts = ipv4.split(':');
39
+ const bytes = parts.flatMap(p => [parseInt(p.padStart(4, '0').slice(0, 2), 16), parseInt(p.padStart(4, '0').slice(2), 16)]);
40
+ ipv4 = bytes.join('.');
41
+ }
42
+ address = ipv4;
43
+ }
44
+
45
+ if (isIP(address) === 0) return false;
46
+ const family = address.includes(':') ? 'ipv6' : 'ipv4';
47
+ return blockList.check(address, family);
48
+ }
49
+
50
+ export async function assertSafeUrl(urlString: string, options?: { allowUrl?: string | undefined }): Promise<void> {
51
+ const url = new URL(urlString);
52
+
53
+ if (options?.allowUrl) {
54
+ const allowUrl = new URL(options.allowUrl);
55
+ if (url.toString() === allowUrl.toString() || url.origin === allowUrl.origin) {
56
+ return;
57
+ }
58
+ }
59
+
60
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
61
+
62
+ if (hostname.toLowerCase() === 'localhost') {
63
+ throw new Error('SSRF protection: access to private network address blocked');
64
+ }
65
+
66
+ if (isIP(hostname)) {
67
+ if (isPrivateIp(hostname)) {
68
+ throw new Error('SSRF protection: access to private network address blocked');
69
+ }
70
+ return;
71
+ }
72
+
73
+ const results = await dns.promises.lookup(hostname, { all: true });
74
+ const entries = Array.isArray(results) ? results : [results];
75
+ for (const entry of entries) {
76
+ if (isPrivateIp(entry.address)) {
77
+ throw new Error('SSRF protection: access to private network address blocked');
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { storeResponse, getResponse, clearCache, getCacheStats } from './cache.js';
3
+
4
+ describe('cache', () => {
5
+ beforeEach(() => {
6
+ clearCache();
7
+ });
8
+
9
+ it('should store and retrieve items', () => {
10
+ const id = storeResponse({ type: 'search', content: 'test content' });
11
+ const item = getResponse(id);
12
+ expect(item).toBeDefined();
13
+ expect(item?.content).toBe('test content');
14
+ expect(item?.type).toBe('search');
15
+ });
16
+
17
+ it('should track stats correctly', () => {
18
+ storeResponse({ type: 'search', content: 'c1' });
19
+ storeResponse({ type: 'fetch', content: 'c2' });
20
+ const stats = getCacheStats();
21
+ expect(stats.count).toBe(2);
22
+ expect(stats.estimatedBytes).toBeGreaterThan(0);
23
+ });
24
+
25
+ it('evicts oldest when capacity exceeded', () => {
26
+ const ids: string[] = [];
27
+ for (let i = 0; i < 51; i++) {
28
+ ids.push(storeResponse({ type: 'search', content: `content ${i}` }));
29
+ }
30
+
31
+ expect(getResponse(ids[0]!)).toBeUndefined();
32
+
33
+ for (let i = 1; i <= 50; i++) {
34
+ expect(getResponse(ids[i]!)).toBeDefined();
35
+ }
36
+ });
37
+ });
@@ -0,0 +1,50 @@
1
+ export interface StoredItem {
2
+ id: string;
3
+ type: "search" | "fetch";
4
+ content: string;
5
+ metadata?: Record<string, unknown>;
6
+ timestamp: number;
7
+ }
8
+
9
+ const cache = new Map<string, StoredItem>();
10
+ const MAX_ITEMS = 50;
11
+
12
+ export function storeResponse(data: Omit<StoredItem, "id" | "timestamp">): string {
13
+ if (cache.size >= MAX_ITEMS) {
14
+ const oldestKey = cache.keys().next().value;
15
+ if (oldestKey) cache.delete(oldestKey);
16
+ }
17
+
18
+ const id = `web_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
19
+ const item: StoredItem = {
20
+ ...data,
21
+ id,
22
+ timestamp: Date.now(),
23
+ };
24
+ cache.set(id, item);
25
+ return id;
26
+ }
27
+
28
+ export function getResponse(responseId: string): StoredItem | undefined {
29
+ const item = cache.get(responseId);
30
+ if (item) {
31
+ cache.delete(responseId);
32
+ cache.set(responseId, item);
33
+ }
34
+ return item;
35
+ }
36
+
37
+ export function clearCache(): void {
38
+ cache.clear();
39
+ }
40
+
41
+ export function getCacheStats(): { count: number; estimatedBytes: number } {
42
+ let estimatedBytes = 0;
43
+ for (const item of cache.values()) {
44
+ estimatedBytes += item.content.length * 2; // Rough estimate
45
+ }
46
+ return {
47
+ count: cache.size,
48
+ estimatedBytes,
49
+ };
50
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { findPassages } from './find.js';
3
+
4
+ describe('find', () => {
5
+ it('should find passages with context windows', () => {
6
+ const text = "The quick brown fox jumps over the lazy dog. The quick brown fox is fast.";
7
+ const queries = ["fox"];
8
+ const results = findPassages(text, queries, { windowChars: 10 });
9
+
10
+ expect(results.length).toBeGreaterThan(0);
11
+ const r0 = results[0];
12
+ if (!r0) throw new Error("Result missing");
13
+ expect(r0.match).toBe("fox");
14
+ expect(r0.passage).toContain("fox");
15
+ // Should have about 10 chars around it
16
+ expect(r0.passage.length).toBeLessThanOrEqual(23); // "fox" + 10 before + 10 after
17
+ });
18
+ });
@@ -0,0 +1,37 @@
1
+ export interface PassageResult {
2
+ passage: string;
3
+ offset: number;
4
+ match: string;
5
+ }
6
+
7
+ export function findPassages(
8
+ text: string,
9
+ queries: string[],
10
+ options?: { mode?: "case-insensitive" | "exact" | "fuzzy"; windowChars?: number }
11
+ ): PassageResult[] {
12
+ const mode = options?.mode ?? "case-insensitive";
13
+ const windowChars = options?.windowChars ?? 200;
14
+ const results: PassageResult[] = [];
15
+
16
+ const validQueries = queries.filter(q => q && q.trim());
17
+
18
+ for (const query of validQueries) {
19
+ let index = -1;
20
+ const searchIn = mode === "case-insensitive" ? text.toLowerCase() : text;
21
+ const searchFor = mode === "case-insensitive" ? query.toLowerCase() : query;
22
+
23
+ while ((index = searchIn.indexOf(searchFor, index + 1)) !== -1) {
24
+ const start = Math.max(0, index - windowChars);
25
+ const end = Math.min(text.length, index + searchFor.length + windowChars);
26
+ results.push({
27
+ passage: text.substring(start, end),
28
+ offset: index,
29
+ match: text.substring(index, index + searchFor.length),
30
+ });
31
+ if (results.length >= 20) break;
32
+ }
33
+ if (results.length >= 20) break;
34
+ }
35
+
36
+ return results;
37
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { registerTools } from './tools.js';
3
+
4
+ vi.mock('./config.js', () => ({
5
+ loadConfig: () => ({}),
6
+ }));
7
+
8
+ vi.mock('./providers/registry.js', () => ({
9
+ executeSearch: async () => ({ provider: 'test', results: [{ title: 'Test', url: 'http://test.com' }] }),
10
+ }));
11
+
12
+ vi.mock('./extractors/pipeline.js', () => ({
13
+ extractContent: async () => ({ title: 'Test', url: 'http://test.com', markdown: 'test content', wordCount: 1, status: 200, extractor: 'test' }),
14
+ }));
15
+
16
+ vi.mock('./storage/cache.js', () => ({
17
+ storeResponse: () => 'resp_123',
18
+ getResponse: () => ({ id: 'resp_123', type: 'search', content: 'cached content', timestamp: 123 }),
19
+ }));
20
+
21
+ vi.mock('./storage/find.js', () => ({
22
+ findPassages: () => ['match1'],
23
+ }));
24
+
25
+ describe('tools execution', () => {
26
+ const mockTools: any = {};
27
+ const mockPi = {
28
+ registerTool: (tool: any) => {
29
+ mockTools[tool.name] = tool;
30
+ },
31
+ };
32
+ registerTools(mockPi as any);
33
+
34
+ it('web_search executes successfully', async () => {
35
+ const result = await mockTools.web_search.execute('id', { query: 'test' }, {} as any, () => {}, {} as any);
36
+ expect(result.details.responseId).toBe('resp_123');
37
+ });
38
+
39
+ it('fetch_content executes successfully', async () => {
40
+ const result = await mockTools.fetch_content.execute('id', { url: 'http://test.com' }, {} as any, () => {}, {} as any);
41
+ expect(result.details.url).toBe('http://test.com');
42
+ });
43
+
44
+ it('get_search_content executes successfully', async () => {
45
+ const result = await mockTools.get_search_content.execute('id', { responseId: 'resp_123' }, {} as any, () => {}, {} as any);
46
+ expect(result.details.content).toBe('cached content');
47
+ });
48
+ });
package/src/tools.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig } from "./config.js";
4
+ import { executeSearch } from "./providers/registry.js";
5
+ import { extractContent } from "./extractors/pipeline.js";
6
+ import { storeResponse, getResponse } from "./storage/cache.js";
7
+ import { findPassages } from "./storage/find.js";
8
+ import {
9
+ renderWebSearchCall, renderWebSearchResult,
10
+ renderFetchContentCall, renderFetchContentResult,
11
+ renderGetSearchContentCall, renderGetSearchContentResult
12
+ } from "./renderer.js";
13
+
14
+ export function registerTools(pi: ExtensionAPI) {
15
+ pi.registerTool({
16
+ name: "web_search",
17
+ label: "Web Search",
18
+ description: "Search the web",
19
+ parameters: Type.Object({
20
+ query: Type.Optional(Type.String()),
21
+ queries: Type.Optional(Type.Array(Type.String())),
22
+ numResults: Type.Optional(Type.Number()),
23
+ provider: Type.Optional(Type.String()),
24
+ recencyFilter: Type.Optional(Type.String()),
25
+ domainFilter: Type.Optional(Type.Array(Type.String())),
26
+ proxy: Type.Optional(Type.String()),
27
+ }),
28
+ renderCall: renderWebSearchCall,
29
+ renderResult: renderWebSearchResult,
30
+ execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
31
+ const config = loadConfig();
32
+ const queries = params.queries ?? (params.query ? [params.query] : []);
33
+ const proxy = params.proxy ?? config.proxy;
34
+ const { provider, results } = await executeSearch(queries, {
35
+ ...(params.numResults !== undefined && { numResults: params.numResults }),
36
+ ...(params.recencyFilter !== undefined && { recencyFilter: params.recencyFilter }),
37
+ ...(params.domainFilter !== undefined && { domainFilter: params.domainFilter }),
38
+ ...(proxy !== undefined && { proxy }),
39
+ signal
40
+ }, config);
41
+
42
+ const summary = results.map(r => `[${r.title}](${r.url})`).join('\n');
43
+ const responseId = storeResponse({
44
+ type: "search",
45
+ content: summary,
46
+ metadata: { provider, results },
47
+ });
48
+
49
+ return {
50
+ content: [{ type: "text", text: summary }],
51
+ details: { responseId, provider, resultCount: results.length, results }
52
+ };
53
+ },
54
+ });
55
+
56
+ pi.registerTool({
57
+ name: "fetch_content",
58
+ label: "Fetch Content",
59
+ description: "Fetch web content",
60
+ parameters: Type.Object({
61
+ url: Type.String(),
62
+ mode: Type.Optional(Type.Union([Type.Literal("readable"), Type.Literal("raw"), Type.Literal("answer")])),
63
+ prompt: Type.Optional(Type.String()),
64
+ proxy: Type.Optional(Type.String()),
65
+ }),
66
+ renderCall: renderFetchContentCall,
67
+ renderResult: renderFetchContentResult,
68
+ execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
69
+ const config = loadConfig();
70
+ const proxy = params.proxy ?? config.proxy;
71
+ const doc = await extractContent(params.url, params.mode ?? "readable", {
72
+ ...(params.prompt !== undefined && { prompt: params.prompt }),
73
+ ...(proxy !== undefined && { proxy }),
74
+ signal: signal ?? undefined
75
+ });
76
+ const responseId = storeResponse({
77
+ type: "fetch",
78
+ content: doc.markdown,
79
+ metadata: { url: doc.url, title: doc.title, wordCount: doc.wordCount, status: doc.status, extractor: doc.extractor },
80
+ });
81
+
82
+ return {
83
+ content: [{ type: "text", text: doc.markdown }],
84
+ details: { responseId, url: doc.url, title: doc.title, wordCount: doc.wordCount, status: doc.status, extractor: doc.extractor, snippet: doc.markdown.slice(0, 500) }
85
+ };
86
+ },
87
+ });
88
+
89
+ pi.registerTool({
90
+ name: "get_search_content",
91
+ label: "Get Stored Content",
92
+ description: "Get cached search content",
93
+ parameters: Type.Object({
94
+ responseId: Type.String(),
95
+ findText: Type.Optional(Type.String()),
96
+ offset: Type.Optional(Type.Number()),
97
+ limit: Type.Optional(Type.Number()),
98
+ }),
99
+ renderCall: renderGetSearchContentCall,
100
+ renderResult: renderGetSearchContentResult,
101
+ execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
102
+ const item = getResponse(params.responseId);
103
+ if (!item) throw new Error("Response not found");
104
+
105
+ if (params.findText) {
106
+ const matches = findPassages(item.content, [params.findText]);
107
+ return { content: [{ type: "text", text: JSON.stringify(matches) }], details: { matches } };
108
+ }
109
+
110
+ if (params.limit === 0) return { content: [{ type: "text", text: "" }], details: { content: "" } };
111
+
112
+ const content = item.content.slice(params.offset ?? 0, params.limit ? (params.offset ?? 0) + params.limit : undefined);
113
+ return { content: [{ type: "text", text: content }], details: { content } };
114
+ },
115
+ });
116
+ }