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.
- package/LICENSE +22 -0
- package/README.md +51 -0
- package/extensions/claude-rules.ts +136 -0
- package/extensions/commands.ts +45 -0
- package/extensions/context-imports.ts +118 -0
- package/extensions/git-checkpoint.ts +199 -0
- package/extensions/hooks.ts +178 -0
- package/extensions/mcp.ts +268 -0
- package/extensions/memory.ts +127 -0
- package/extensions/notify.ts +52 -0
- package/extensions/output-styles.ts +159 -0
- package/extensions/plan-mode/README.md +65 -0
- package/extensions/plan-mode/index.ts +352 -0
- package/extensions/plan-mode/utils.ts +175 -0
- package/extensions/question.ts +258 -0
- package/extensions/skills.ts +41 -0
- package/extensions/status-line.ts +59 -0
- package/extensions/subagent/README.md +172 -0
- package/extensions/subagent/agents/planner.md +37 -0
- package/extensions/subagent/agents/reviewer.md +35 -0
- package/extensions/subagent/agents/scout.md +50 -0
- package/extensions/subagent/agents/worker.md +24 -0
- package/extensions/subagent/agents.ts +147 -0
- package/extensions/subagent/background.ts +89 -0
- package/extensions/subagent/index.ts +953 -0
- package/extensions/subagent/prompts/implement-and-review.md +10 -0
- package/extensions/subagent/prompts/implement.md +10 -0
- package/extensions/subagent/prompts/scout-and-plan.md +9 -0
- package/extensions/todo.ts +504 -0
- package/extensions/web.ts +195 -0
- package/package.json +59 -0
|
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ' ': ' ' }
|
|
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
|
+
}
|