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.
Files changed (35) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +417 -41
  4. package/extensions/context-imports.ts +446 -61
  5. package/extensions/hooks.ts +473 -73
  6. package/extensions/init.ts +81 -0
  7. package/extensions/internal/agent-run.ts +42 -0
  8. package/extensions/internal/bash-rules.ts +27 -0
  9. package/extensions/internal/command-file.ts +423 -66
  10. package/extensions/internal/html-markdown.ts +71 -0
  11. package/extensions/internal/instruction-events.ts +70 -0
  12. package/extensions/internal/managed-settings.ts +38 -0
  13. package/extensions/internal/mcp-call.ts +28 -0
  14. package/extensions/internal/mcp-oauth.ts +177 -0
  15. package/extensions/internal/model-complete.ts +68 -0
  16. package/extensions/internal/path-rules.ts +80 -0
  17. package/extensions/internal/plugins.ts +138 -0
  18. package/extensions/internal/project-approval.ts +2 -3
  19. package/extensions/internal/project-root.ts +78 -0
  20. package/extensions/internal/shell-split.ts +65 -0
  21. package/extensions/internal/strip-comments.ts +100 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +579 -30
  24. package/extensions/memory.ts +158 -35
  25. package/extensions/notify.ts +76 -4
  26. package/extensions/output-styles.ts +34 -6
  27. package/extensions/plan-mode/utils.ts +3 -57
  28. package/extensions/question.ts +2 -2
  29. package/extensions/skills.ts +11 -1
  30. package/extensions/status-line.ts +100 -5
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +310 -31
  34. package/extensions/web.ts +93 -15
  35. 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
- export function htmlToText(html: string): string {
80
- const withoutBlocks = html
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 results = parseSearchResults(text, Math.min(params.count ?? 5, 10))
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 as readable text (HTML is stripped).',
241
- parameters: Type.Object({ url: Type.String({ description: 'Absolute http(s) URL to fetch' }) }),
242
- async execute(_id, params) {
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 { text, contentType } = await fetchText(params.url)
247
- const body = contentType.includes('html') ? htmlToText(text) : text.slice(0, MAX_FETCH_CHARS)
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.4",
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",