pi-code 1.0.4 → 1.0.6
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/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +417 -41
- package/extensions/context-imports.ts +446 -61
- package/extensions/hooks.ts +473 -73
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +423 -66
- package/extensions/internal/html-markdown.ts +71 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +177 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +138 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +100 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +579 -30
- package/extensions/memory.ts +158 -35
- package/extensions/notify.ts +76 -4
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +100 -5
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +310 -31
- package/extensions/web.ts +93 -15
- package/package.json +1 -1
package/extensions/web.ts
CHANGED
|
@@ -12,6 +12,8 @@ import type { LookupFunction } from 'node:net'
|
|
|
12
12
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
13
13
|
import { Type } from 'typebox'
|
|
14
14
|
|
|
15
|
+
import { htmlToMarkdown } from './internal/html-markdown.js'
|
|
16
|
+
import { completeText } from './internal/model-complete.js'
|
|
15
17
|
import { capForContext } from './internal/output-guard.js'
|
|
16
18
|
import { httpFetch } from './internal/web-transport.js'
|
|
17
19
|
|
|
@@ -57,6 +59,26 @@ export function resolveResultUrl(href: string): string {
|
|
|
57
59
|
return href.startsWith('//') ? `https:${href}` : href
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/** Keep results whose host matches an allowed domain (or is not blocked). A domain
|
|
63
|
+
* matches the host itself or any subdomain of it, as Claude's domain scoping does. */
|
|
64
|
+
export function filterByDomain(results: SearchResult[], allowed: string[] | undefined, blocked: string[] | undefined): SearchResult[] {
|
|
65
|
+
const hostOf = (url: string): string => {
|
|
66
|
+
try {
|
|
67
|
+
return new URL(url).hostname.toLowerCase()
|
|
68
|
+
} catch {
|
|
69
|
+
return ''
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const matches = (host: string, domain: string): boolean => {
|
|
73
|
+
const d = domain.toLowerCase().replace(/^\.+/, '')
|
|
74
|
+
return host === d || host.endsWith(`.${d}`)
|
|
75
|
+
}
|
|
76
|
+
let out = results
|
|
77
|
+
if (allowed && allowed.length > 0) out = out.filter((r) => allowed.some((d) => matches(hostOf(r.url), d)))
|
|
78
|
+
else if (blocked && blocked.length > 0) out = out.filter((r) => !blocked.some((d) => matches(hostOf(r.url), d)))
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
60
82
|
export function parseSearchResults(html: string, limit: number): SearchResult[] {
|
|
61
83
|
const results: SearchResult[] = []
|
|
62
84
|
const anchorPattern = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g
|
|
@@ -76,15 +98,8 @@ export function parseSearchResults(html: string, limit: number): SearchResult[]
|
|
|
76
98
|
return results
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
82
|
-
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
83
|
-
.replace(/<(br|\/p|\/div|\/h[1-6]|\/li|\/tr)[^>]*>/gi, '\n')
|
|
84
|
-
const text = decodeEntities(withoutBlocks.replace(/<[^<>]*>/g, ' '))
|
|
85
|
-
.replace(/[ \t]+/g, ' ')
|
|
86
|
-
.replace(/\n\s+/g, '\n')
|
|
87
|
-
.trim()
|
|
101
|
+
/** Cap a converted body at the fetch budget, naming what was dropped. */
|
|
102
|
+
function capFetchChars(text: string): string {
|
|
88
103
|
return text.length > MAX_FETCH_CHARS ? `${text.slice(0, MAX_FETCH_CHARS)}\n[truncated ${text.length - MAX_FETCH_CHARS} chars]` : text
|
|
89
104
|
}
|
|
90
105
|
|
|
@@ -214,7 +229,46 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
|
|
|
214
229
|
throw new Error(`too many redirects for ${rawUrl}`)
|
|
215
230
|
}
|
|
216
231
|
|
|
232
|
+
/** Claude documents a 15-minute per-URL cache for WebFetch. */
|
|
233
|
+
const FETCH_CACHE_TTL_MS = 15 * 60 * 1000
|
|
234
|
+
const FETCH_CACHE_MAX_ENTRIES = 50
|
|
235
|
+
|
|
236
|
+
type FetchCache = Map<string, { expires: number; body: string }>
|
|
237
|
+
|
|
238
|
+
/** Store a freshly fetched body. Drop expired entries first, then evict the oldest
|
|
239
|
+
* live one if still full; deleting before set keeps Map insertion order a true
|
|
240
|
+
* recency order, so a refreshed URL moves to the newest slot instead of keeping its
|
|
241
|
+
* stale one. Only a delivered body reaches here, so a thrown fetch retries next call. */
|
|
242
|
+
function rememberFetch(cache: FetchCache, url: string, body: string, now: number): void {
|
|
243
|
+
cache.delete(url)
|
|
244
|
+
for (const [key, entry] of cache) {
|
|
245
|
+
if (entry.expires <= now) cache.delete(key)
|
|
246
|
+
}
|
|
247
|
+
if (cache.size >= FETCH_CACHE_MAX_ENTRIES) {
|
|
248
|
+
const oldest = cache.keys().next().value
|
|
249
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
250
|
+
}
|
|
251
|
+
cache.set(url, { expires: now + FETCH_CACHE_TTL_MS, body })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Claude's WebFetch runs the prompt over the page with a fast model and returns
|
|
255
|
+
* that answer, not the raw page. Best-effort: any failure (no model, provider error)
|
|
256
|
+
* yields null so the caller falls back to the markdown. */
|
|
257
|
+
async function answerFromPage(model: Parameters<typeof completeText>[0], prompt: string, url: string, body: string, signal?: AbortSignal): Promise<string | null> {
|
|
258
|
+
try {
|
|
259
|
+
const answer = await completeText(model, `${prompt}\n\nAnswer using only the page content below, fetched from ${url}:\n\n${body}`, {
|
|
260
|
+
system: 'You extract and answer questions from a web page. Answer only from the provided content, concisely. If the content does not contain the answer, say so.',
|
|
261
|
+
maxTokens: 1024,
|
|
262
|
+
signal,
|
|
263
|
+
})
|
|
264
|
+
return answer || null
|
|
265
|
+
} catch {
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
217
270
|
export default function webExtension(pi: ExtensionAPI) {
|
|
271
|
+
const fetchCache = new Map<string, { expires: number; body: string }>()
|
|
218
272
|
pi.registerTool({
|
|
219
273
|
name: 'web_search',
|
|
220
274
|
label: 'Web search',
|
|
@@ -222,10 +276,14 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
222
276
|
parameters: Type.Object({
|
|
223
277
|
query: Type.String({ description: 'Search query' }),
|
|
224
278
|
count: Type.Optional(Type.Number({ description: 'Max results (default 5)' })),
|
|
279
|
+
allowed_domains: Type.Optional(Type.Array(Type.String(), { description: 'Only include results from these domains' })),
|
|
280
|
+
blocked_domains: Type.Optional(Type.Array(Type.String(), { description: 'Exclude results from these domains' })),
|
|
225
281
|
}),
|
|
226
282
|
async execute(_id, params) {
|
|
283
|
+
// Claude documents allowed/blocked domains as mutually exclusive; allowed wins.
|
|
227
284
|
const { text } = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query))
|
|
228
|
-
const
|
|
285
|
+
const limit = Math.min(params.count ?? 5, 10)
|
|
286
|
+
const results = filterByDomain(parseSearchResults(text, 10), params.allowed_domains, params.blocked_domains).slice(0, limit)
|
|
229
287
|
if (results.length === 0) {
|
|
230
288
|
return { content: [{ type: 'text' as const, text: 'No results found.' }], details: {} }
|
|
231
289
|
}
|
|
@@ -237,14 +295,34 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
237
295
|
pi.registerTool({
|
|
238
296
|
name: 'web_fetch',
|
|
239
297
|
label: 'Web fetch',
|
|
240
|
-
description: 'Fetch a URL and return its content
|
|
241
|
-
parameters: Type.Object({
|
|
242
|
-
|
|
298
|
+
description: 'Fetch a URL and return its content converted to markdown. Pass `prompt` to get a focused answer extracted from the page instead of the raw content. Responses are cached for 15 minutes per URL.',
|
|
299
|
+
parameters: Type.Object({
|
|
300
|
+
url: Type.String({ description: 'Absolute http(s) URL to fetch' }),
|
|
301
|
+
prompt: Type.Optional(Type.String({ description: 'What to extract or answer from the page; returns the model’s answer instead of the raw markdown' })),
|
|
302
|
+
}),
|
|
303
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
243
304
|
if (!/^https?:\/\//.test(params.url)) {
|
|
244
305
|
return { content: [{ type: 'text' as const, text: 'Only http(s) URLs are supported.' }], details: {} }
|
|
245
306
|
}
|
|
246
|
-
const
|
|
247
|
-
|
|
307
|
+
const now = Date.now()
|
|
308
|
+
// The cache holds the raw markdown, so a second fetch with a different prompt
|
|
309
|
+
// still reuses it: retrieval and the optional prompt step are separate.
|
|
310
|
+
const cached = fetchCache.get(params.url)
|
|
311
|
+
let body: string
|
|
312
|
+
if (cached && cached.expires > now) {
|
|
313
|
+
body = cached.body
|
|
314
|
+
} else {
|
|
315
|
+
const { text, contentType } = await fetchText(params.url)
|
|
316
|
+
body = capFetchChars(contentType.includes('html') ? htmlToMarkdown(text) : text)
|
|
317
|
+
rememberFetch(fetchCache, params.url, body, now)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Best-effort prompt-over-page: a failure returns null, so web_fetch always
|
|
321
|
+
// falls back to the raw markdown and returns something.
|
|
322
|
+
if (params.prompt && ctx?.model) {
|
|
323
|
+
const answer = await answerFromPage(ctx.model, params.prompt, params.url, body, signal)
|
|
324
|
+
if (answer) return { content: [{ type: 'text' as const, text: answer }], details: {} }
|
|
325
|
+
}
|
|
248
326
|
// The char cap alone admits thousands of short lines; pi's tool-output budget
|
|
249
327
|
// bounds lines too, which the shared guard enforces.
|
|
250
328
|
return { content: [{ type: 'text' as const, text: capForContext(body) || '(empty response)' }], details: {} }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"pi",
|