pi-code 0.1.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,195 @@
1
+ /**
2
+ * Web Extension
3
+ *
4
+ * Key-free web access: web_search scrapes the DuckDuckGo HTML endpoint (no
5
+ * API key, no account) and web_fetch retrieves a URL as readable text.
6
+ * Honors the local-only setup: no cloud accounts, plain HTTPS to public web.
7
+ */
8
+
9
+ import { lookup } from 'node:dns/promises'
10
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
11
+ import { Type } from 'typebox'
12
+
13
+ const SEARCH_ENDPOINT = 'https://html.duckduckgo.com/html/?q='
14
+ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) pi-code-web/0.1'
15
+ const MAX_FETCH_CHARS = 30_000
16
+ // Hard cap on raw bytes read before any parsing, so a huge or hostile page can't
17
+ // exhaust memory or feed megabytes into the HTML regexes. Output is capped again
18
+ // at MAX_FETCH_CHARS, so real pages rarely lose text.
19
+ const MAX_RAW_CHARS = 200_000
20
+ const FETCH_TIMEOUT_MS = 20_000
21
+
22
+ export interface SearchResult {
23
+ title: string
24
+ url: string
25
+ snippet: string
26
+ }
27
+
28
+ const ENTITIES: Record<string, string> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#x27;': "'", '&#39;': "'", '&nbsp;': ' ' }
29
+
30
+ export function decodeEntities(text: string): string {
31
+ return text.replace(/&(?:amp|lt|gt|quot|#x27|#39|nbsp);/g, (m) => ENTITIES[m] ?? m)
32
+ }
33
+
34
+ export function stripTags(html: string): string {
35
+ return decodeEntities(html.replace(/<[^>]*>/g, ''))
36
+ .replace(/\s+/g, ' ')
37
+ .trim()
38
+ }
39
+
40
+ /** Resolve DuckDuckGo's redirect links (/l/?uddg=<encoded>) to the target URL. */
41
+ export function resolveResultUrl(href: string): string {
42
+ const match = href.match(/[?&]uddg=([^&]+)/)
43
+ if (match) {
44
+ try {
45
+ return decodeURIComponent(match[1])
46
+ } catch {
47
+ return href
48
+ }
49
+ }
50
+ return href.startsWith('//') ? `https:${href}` : href
51
+ }
52
+
53
+ export function parseSearchResults(html: string, limit: number): SearchResult[] {
54
+ const results: SearchResult[] = []
55
+ const anchorPattern = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g
56
+ const snippetPattern = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g
57
+ const snippets = [...html.matchAll(snippetPattern)].map((m) => stripTags(m[1]))
58
+ let index = 0
59
+ for (const match of html.matchAll(anchorPattern)) {
60
+ if (results.length >= limit) break
61
+ const url = resolveResultUrl(match[1])
62
+ const title = stripTags(match[2])
63
+ if (!title || url.includes('duckduckgo.com/y.js')) continue
64
+ results.push({ title, url, snippet: snippets[index] ?? '' })
65
+ index++
66
+ }
67
+ return results
68
+ }
69
+
70
+ export function htmlToText(html: string): string {
71
+ const withoutBlocks = html
72
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
73
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
74
+ .replace(/<(br|\/p|\/div|\/h[1-6]|\/li|\/tr)[^>]*>/gi, '\n')
75
+ const text = decodeEntities(withoutBlocks.replace(/<[^>]*>/g, ' '))
76
+ .replace(/[ \t]+/g, ' ')
77
+ .replace(/\n\s+/g, '\n')
78
+ .trim()
79
+ return text.length > MAX_FETCH_CHARS ? `${text.slice(0, MAX_FETCH_CHARS)}\n[truncated ${text.length - MAX_FETCH_CHARS} chars]` : text
80
+ }
81
+
82
+ /** SSRF guard: true for loopback, RFC1918, link-local, CGNAT, and private IPv6 ranges. Fails closed on unparseable input. */
83
+ export function isPrivateAddress(ip: string): boolean {
84
+ const addr = ip
85
+ .toLowerCase()
86
+ .replace(/^\[|\]$/g, '')
87
+ .split('%')[0]
88
+ if (addr.includes(':')) {
89
+ // IPv4-mapped (::ffff:...) and NAT64 (64:ff9b::...) forms embed an IPv4 in the low 32 bits,
90
+ // in either dotted-decimal or hex; the WHATWG URL parser normalizes literals to the hex form.
91
+ const dotted = addr.match(/^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/)
92
+ if (dotted) return isPrivateAddress(dotted[1])
93
+ const hex = addr.match(/^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
94
+ if (hex) {
95
+ const hi = Number.parseInt(hex[1], 16)
96
+ const lo = Number.parseInt(hex[2], 16)
97
+ return isPrivateAddress(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`)
98
+ }
99
+ if (addr === '::1' || addr === '::') return true
100
+ if (/^fe[89ab]/.test(addr)) return true // link-local fe80::/10
101
+ if (/^f[cd]/.test(addr)) return true // unique local fc00::/7
102
+ return false
103
+ }
104
+ const parts = addr.split('.').map(Number)
105
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return true
106
+ const [a, b] = parts
107
+ if (a === 0 || a === 10 || a === 127) return true
108
+ if (a === 169 && b === 254) return true
109
+ if (a === 172 && b >= 16 && b <= 31) return true
110
+ if (a === 192 && b === 168) return true
111
+ if (a === 100 && b >= 64 && b <= 127) return true
112
+ return false
113
+ }
114
+
115
+ async function assertPublicHost(url: URL): Promise<void> {
116
+ const host = url.hostname.replace(/^\[|\]$/g, '')
117
+ const addresses = await lookup(host, { all: true, verbatim: true })
118
+ for (const { address } of addresses) {
119
+ if (isPrivateAddress(address)) throw new Error(`refusing to fetch private/internal address for ${url.hostname} (${address})`)
120
+ }
121
+ }
122
+
123
+ const MAX_REDIRECTS = 5
124
+
125
+ /** Read a response body up to MAX_RAW_CHARS, then stop the download. Bounds memory and parsing cost. */
126
+ async function readCapped(response: Response): Promise<string> {
127
+ const reader = response.body?.getReader()
128
+ if (!reader) return (await response.text()).slice(0, MAX_RAW_CHARS)
129
+ const decoder = new TextDecoder()
130
+ let text = ''
131
+ while (text.length < MAX_RAW_CHARS) {
132
+ const { done, value } = await reader.read()
133
+ if (done) break
134
+ if (value) text += decoder.decode(value, { stream: true })
135
+ }
136
+ await reader.cancel().catch(() => {})
137
+ return text.slice(0, MAX_RAW_CHARS)
138
+ }
139
+
140
+ async function fetchText(rawUrl: string): Promise<{ text: string; contentType: string }> {
141
+ let url = new URL(rawUrl)
142
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
143
+ await assertPublicHost(url)
144
+ const response = await fetch(url, {
145
+ headers: { 'User-Agent': USER_AGENT },
146
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
147
+ redirect: 'manual',
148
+ })
149
+ if (response.status >= 300 && response.status < 400) {
150
+ const location = response.headers.get('location')
151
+ if (!location) throw new Error(`redirect without location from ${url.hostname}`)
152
+ url = new URL(location, url)
153
+ continue
154
+ }
155
+ if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`)
156
+ return { text: await readCapped(response), contentType: response.headers.get('content-type') ?? '' }
157
+ }
158
+ throw new Error(`too many redirects for ${rawUrl}`)
159
+ }
160
+
161
+ export default function webExtension(pi: ExtensionAPI) {
162
+ pi.registerTool({
163
+ name: 'web_search',
164
+ label: 'Web search',
165
+ description: 'Search the web (DuckDuckGo). Returns titles, URLs, and snippets. Use web_fetch to read a result in full.',
166
+ parameters: Type.Object({
167
+ query: Type.String({ description: 'Search query' }),
168
+ count: Type.Optional(Type.Number({ description: 'Max results (default 5)' })),
169
+ }),
170
+ async execute(_id, params) {
171
+ const { text } = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query))
172
+ const results = parseSearchResults(text, Math.min(params.count ?? 5, 10))
173
+ if (results.length === 0) {
174
+ return { content: [{ type: 'text' as const, text: 'No results found.' }], details: {} }
175
+ }
176
+ const formatted = results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`).join('\n')
177
+ return { content: [{ type: 'text' as const, text: formatted }], details: {} }
178
+ },
179
+ })
180
+
181
+ pi.registerTool({
182
+ name: 'web_fetch',
183
+ label: 'Web fetch',
184
+ description: 'Fetch a URL and return its content as readable text (HTML is stripped).',
185
+ parameters: Type.Object({ url: Type.String({ description: 'Absolute http(s) URL to fetch' }) }),
186
+ async execute(_id, params) {
187
+ if (!/^https?:\/\//.test(params.url)) {
188
+ return { content: [{ type: 'text' as const, text: 'Only http(s) URLs are supported.' }], details: {} }
189
+ }
190
+ const { text, contentType } = await fetchText(params.url)
191
+ const body = contentType.includes('html') ? htmlToText(text) : text.slice(0, MAX_FETCH_CHARS)
192
+ return { content: [{ type: 'text' as const, text: body || '(empty response)' }], details: {} }
193
+ },
194
+ })
195
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "pi-code",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "license": "MIT",
9
+ "type": "module",
10
+ "files": [
11
+ "extensions"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/ilovepixelart/pi-code.git"
16
+ },
17
+ "homepage": "https://github.com/ilovepixelart/pi-code#readme",
18
+ "bugs": "https://github.com/ilovepixelart/pi-code/issues",
19
+ "pi": {
20
+ "extensions": [
21
+ "./extensions"
22
+ ]
23
+ },
24
+ "scripts": {
25
+ "biome": "npx @biomejs/biome check",
26
+ "biome:fix": "npx @biomejs/biome check --write .",
27
+ "type:check": "tsc --noEmit",
28
+ "test": "vitest run --coverage",
29
+ "check": "npm run biome && npm run type:check && npm run test",
30
+ "prepublishOnly": "npm run check"
31
+ },
32
+ "engines": {
33
+ "node": ">=22.19"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "provenance": true
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.0.0",
41
+ "typebox": "^1.3.6"
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-ai": "*",
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "@earendil-works/pi-tui": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@biomejs/biome": "^2.5.4",
50
+ "@earendil-works/pi-agent-core": "^0.80.10",
51
+ "@earendil-works/pi-ai": "^0.80.10",
52
+ "@earendil-works/pi-coding-agent": "^0.80.10",
53
+ "@earendil-works/pi-tui": "^0.80.10",
54
+ "@types/node": "^26.1.1",
55
+ "@vitest/coverage-v8": "^4.1.10",
56
+ "typescript": "^7.0.2",
57
+ "vitest": "^4.1.10"
58
+ }
59
+ }