@trim21/personal-pi-extensions 0.0.335 → 0.0.336

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.335",
3
+ "version": "0.0.336",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -37,6 +37,7 @@
37
37
  "@earendil-works/pi-coding-agent": "^0.84.1",
38
38
  "@eslint/js": "10.0.1",
39
39
  "@types/node": "^24.13.3",
40
+ "@types/turndown": "^5.0.6",
40
41
  "@typescript/native": "npm:typescript@^7.0.2",
41
42
  "@vitest/coverage-v8": "^4.1.10",
42
43
  "eslint": "^10.8.1",
@@ -65,7 +66,8 @@
65
66
  "src/gh-readonly.ts",
66
67
  "src/spawn-agent.ts",
67
68
  "src/system-prompt/index.ts",
68
- "src/talk/index.ts"
69
+ "src/talk/index.ts",
70
+ "src/web/index.ts"
69
71
  ],
70
72
  "skills": [
71
73
  "src/talk/skills"
@@ -86,9 +88,12 @@
86
88
  },
87
89
  "dependencies": {
88
90
  "@cortexkit/aft-bridge": "0.51.2",
91
+ "@mozilla/readability": "^0.6.0",
89
92
  "@vscode/tree-sitter-wasm": "^0.3.1",
90
93
  "jsonc-parser": "^3.3.1",
94
+ "linkedom": "^0.16.0",
91
95
  "minimatch": "^10.2.6",
96
+ "turndown": "^7.2.0",
92
97
  "vscode-jsonrpc": "^9.0.1",
93
98
  "vscode-languageserver-types": "^3.18.0",
94
99
  "web-tree-sitter": "^0.26.12"
@@ -0,0 +1,31 @@
1
+ /**
2
+ * web 扩展的本地配置读取:Search1API key。
3
+ * 路径可注入,便于测试。
4
+ */
5
+ import { readFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ import { Type } from "typebox";
10
+ import { Value } from "typebox/value";
11
+
12
+ const webSearchSchema = Type.Object({
13
+ search1apiApiKey: Type.Optional(Type.String()),
14
+ });
15
+
16
+ export function webSearchConfigPath(): string {
17
+ return join(homedir(), ".pi", "web-search.json");
18
+ }
19
+
20
+ /** ~/.pi/web-search.json 的 search1apiApiKey,或 SEARCH1API_KEY 环境变量。 */
21
+ export async function loadSearch1ApiKey(path = webSearchConfigPath()): Promise<string | undefined> {
22
+ const envKey = process.env.SEARCH1API_KEY;
23
+ if (envKey) return envKey;
24
+ try {
25
+ const raw = await readFile(path, "utf8");
26
+ const parsed = Value.Parse(webSearchSchema, JSON.parse(raw));
27
+ return parsed.search1apiApiKey?.trim() || undefined;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * web_fetch:抓取 URL 并提取正文为 markdown。
3
+ *
4
+ * SSRF 防护:DNS 预解析 + 拒绝私有/保留地址 + 每跳重定向重新校验,
5
+ * 防止把 agent 变成内网探测口。正文提取用 readability 主内容算法。
6
+ */
7
+ import { lookup } from "node:dns/promises";
8
+ import { isIP } from "node:net";
9
+
10
+ import { Readability } from "@mozilla/readability";
11
+ import { parseHTML } from "linkedom";
12
+ import TurndownService from "turndown";
13
+
14
+ const MAX_REDIRECTS = 5;
15
+ const TIMEOUT_MS = 30_000;
16
+ const MAX_BYTES = 5 * 1024 * 1024;
17
+ const MIN_USEFUL_CONTENT = 200;
18
+
19
+ function isPrivateIpv4(ip: string): boolean {
20
+ const parts = ip.split(".").map(Number);
21
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return false;
22
+ const [a, b, c] = parts;
23
+ if (a === 0) return true; // 0.0.0.0/8
24
+ if (a === 10) return true; // 10.0.0.0/8
25
+ if (a === 127) return true; // 127.0.0.0/8 loopback
26
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
27
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
28
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
29
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
30
+ if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
31
+ if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24
32
+ return a >= 224; // 224.0.0.0/3 multicast + reserved
33
+ }
34
+
35
+ function isPrivateIpv6(ip: string): boolean {
36
+ const lower = ip.toLowerCase();
37
+ if (lower === "::" || lower === "::1") return true; // unspecified / loopback
38
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7 ULA
39
+ if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 link-local
40
+ if (lower.startsWith("::ffff:")) return isPrivateIpv4(lower.slice(7));
41
+ return false;
42
+ }
43
+
44
+ export function isPrivateAddress(ip: string): boolean {
45
+ const version = isIP(ip);
46
+ if (version === 4) return isPrivateIpv4(ip);
47
+ if (version === 6) return isPrivateIpv6(ip);
48
+ return true; // 非 IP 一律拒绝
49
+ }
50
+
51
+ /** 解析 hostname 的全部地址,任一私有即拒绝,返回解析结果 */
52
+ export async function assertPublicHostname(
53
+ hostname: string,
54
+ lookupFn: (hostname: string) => Promise<{ address: string }[]> = (h) => lookup(h, { all: true }),
55
+ ): Promise<void> {
56
+ let addresses: { address: string }[];
57
+ try {
58
+ addresses = await lookupFn(hostname);
59
+ } catch (error) {
60
+ throw new Error(
61
+ `域名解析失败 ${hostname}: ${error instanceof Error ? error.message : String(error)}`,
62
+ { cause: error },
63
+ );
64
+ }
65
+ const blocked = addresses.find(({ address }) => isPrivateAddress(address));
66
+ if (blocked) {
67
+ throw new Error(`拒绝访问内网地址 ${hostname} (${blocked.address})`);
68
+ }
69
+ }
70
+
71
+ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
72
+ const timeout = AbortSignal.timeout(ms);
73
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
74
+ }
75
+
76
+ interface FetchedPage {
77
+ url: string;
78
+ title: string;
79
+ markdown: string;
80
+ }
81
+
82
+ /** 手动跟随重定向,每跳重新做 SSRF 校验(防 DNS rebinding 简化处理) */
83
+ async function fetchWithRedirects(
84
+ url: URL,
85
+ signal: AbortSignal | undefined,
86
+ fetchFn: typeof fetch = fetch,
87
+ ): Promise<Response> {
88
+ let current = url;
89
+ for (let redirects = 0; ; redirects++) {
90
+ await assertPublicHostname(current.hostname);
91
+ const response = await fetchFn(current, {
92
+ redirect: "manual",
93
+ signal: withTimeout(signal, TIMEOUT_MS),
94
+ headers: { "user-agent": "Mozilla/5.0 (compatible; pi-web-fetch/1.0)" },
95
+ });
96
+ const location = response.headers.get("location");
97
+ if (location && response.status >= 300 && response.status < 400) {
98
+ if (redirects >= MAX_REDIRECTS) {
99
+ throw new Error(`重定向次数超过上限 (${MAX_REDIRECTS})`);
100
+ }
101
+ current = new URL(location, current);
102
+ if (current.protocol !== "http:" && current.protocol !== "https:") {
103
+ throw new Error(`不支持的协议: ${current.protocol}`);
104
+ }
105
+ continue;
106
+ }
107
+ return response;
108
+ }
109
+ }
110
+
111
+ export async function fetchPage(url: string, signal?: AbortSignal): Promise<FetchedPage> {
112
+ let target: URL;
113
+ try {
114
+ target = new URL(url);
115
+ } catch {
116
+ throw new Error(`无效的 URL: ${url}`);
117
+ }
118
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
119
+ throw new Error(`只支持 http/https,收到: ${target.protocol}`);
120
+ }
121
+
122
+ const response = await fetchWithRedirects(target, signal);
123
+ if (!response.ok) {
124
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
125
+ }
126
+ const contentType = response.headers.get("content-type") ?? "";
127
+ if (!contentType.includes("text/html") && !contentType.includes("text/plain")) {
128
+ throw new Error(`不支持的内容类型: ${contentType || "unknown"}`);
129
+ }
130
+
131
+ const declaredLength = Number(response.headers.get("content-length") ?? "0");
132
+ if (declaredLength > MAX_BYTES) {
133
+ throw new Error(`页面过大 (${declaredLength} bytes),上限 ${MAX_BYTES}`);
134
+ }
135
+
136
+ let html = "";
137
+ if (response.body) {
138
+ const reader = response.body.getReader();
139
+ const decoder = new TextDecoder();
140
+ for (;;) {
141
+ const chunk = (await reader.read()) as { done: boolean; value: Uint8Array };
142
+ if (chunk.done) break;
143
+ html += decoder.decode(chunk.value, { stream: true });
144
+ if (Buffer.byteLength(html, "utf8") > MAX_BYTES) {
145
+ throw new Error(`页面过大,上限 ${MAX_BYTES} bytes`);
146
+ }
147
+ }
148
+ html += decoder.decode();
149
+ }
150
+
151
+ return extractMarkdown(html, response.url);
152
+ }
153
+
154
+ /** 提取用到的 document 最小接口(linkedom 类型是 any,显式标注避免 unsafe) */
155
+ interface ParsedDocument {
156
+ title: string | null;
157
+ body: { textContent: string | null } | null;
158
+ }
159
+
160
+ /** 从 HTML 提取标题 + 正文 markdown(readability 主内容 → turndown) */
161
+ export function extractMarkdown(html: string, sourceUrl: string): FetchedPage {
162
+ const parsed = parseHTML(html) as { document: ParsedDocument };
163
+ const document = parsed.document;
164
+ // tsconfig 无 DOM lib;Readability 构造参数声明为 DOM Document,运行时只用到
165
+ // linkedom document 的兼容方法,cast 桥接即可
166
+ const article = new Readability(parsed.document).parse();
167
+ let title = article?.title ?? document.title?.trim() ?? sourceUrl;
168
+ if (typeof title !== "string" || title.length === 0) title = sourceUrl;
169
+ let body = article?.content;
170
+ if (!body || body.length === 0) {
171
+ body = document.body?.textContent ?? "";
172
+ }
173
+ if (typeof body !== "string" || body.trim().length < MIN_USEFUL_CONTENT) {
174
+ throw new Error("页面没有可提取的正文内容");
175
+ }
176
+ const markdown = new TurndownService({
177
+ headingStyle: "atx",
178
+ codeBlockStyle: "fenced",
179
+ })
180
+ .turndown(body)
181
+ .trim();
182
+ return { url: sourceUrl, title, markdown };
183
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * web 扩展:自研 web_search + web_fetch,替代 pi-web-access。
3
+ *
4
+ * - web_search:Search1API 搜索(key 读 ~/.pi/web-search.json 的
5
+ * search1apiApiKey 或 SEARCH1API_KEY),直接返回整理后的结构化结果。
6
+ * - web_fetch:抓取 URL,SSRF 防护 + readability 提取正文为 markdown。
7
+ */
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "typebox";
10
+
11
+ import { loadSearch1ApiKey } from "./config.js";
12
+ import { fetchPage } from "./fetch.js";
13
+ import { type SearchHit, searchWeb } from "./search.js";
14
+
15
+ const MAX_MARKDOWN_BYTES = 100 * 1024;
16
+
17
+ function truncateMarkdown(text: string): { text: string; truncated: boolean } {
18
+ if (Buffer.byteLength(text, "utf8") <= MAX_MARKDOWN_BYTES) return { text, truncated: false };
19
+ const bytes = Buffer.from(text, "utf8");
20
+ const sliced = bytes.subarray(0, MAX_MARKDOWN_BYTES).toString("utf8");
21
+ const cut = sliced.lastIndexOf("\n", sliced.length - 1);
22
+ return { text: (cut > 0 ? sliced.slice(0, cut) : sliced) + "\n…(已截断)", truncated: true };
23
+ }
24
+
25
+ export default function webTools(pi: ExtensionAPI) {
26
+ pi.registerTool({
27
+ name: "web_search",
28
+ label: "Web Search",
29
+ description:
30
+ "Search the web via Search1API and return structured results " +
31
+ "(title/url/snippet, optionally inline content).",
32
+ promptSnippet: "Search the web",
33
+ parameters: Type.Object({
34
+ query: Type.Optional(
35
+ Type.String({ description: "Search query (mutually exclusive with queries)" }),
36
+ ),
37
+ queries: Type.Optional(
38
+ Type.Array(Type.String(), { description: "Multiple queries searched in sequence" }),
39
+ ),
40
+ numResults: Type.Optional(
41
+ Type.Number({ description: "Results per query (default: 5, max: 50)" }),
42
+ ),
43
+ recencyFilter: Type.Optional(
44
+ Type.String({ description: "Filter by recency: day, week, month, year" }),
45
+ ),
46
+ domainFilter: Type.Optional(
47
+ Type.Array(Type.String(), { description: "Limit to domains; prefix with - to exclude" }),
48
+ ),
49
+ searchService: Type.Optional(
50
+ Type.String({
51
+ description:
52
+ "Search engine: google, bing, duckduckgo, github, arxiv, reddit, youtube, etc.",
53
+ }),
54
+ ),
55
+ includeContent: Type.Optional(
56
+ Type.Boolean({ description: "Inline-fetch content for the top results (up to 5)" }),
57
+ ),
58
+ }),
59
+ async execute(_id, params, signal, onUpdate) {
60
+ try {
61
+ const queries = [...(params.query ? [params.query] : []), ...(params.queries ?? [])]
62
+ .map((q) => q.trim())
63
+ .filter(Boolean)
64
+ .slice(0, 4);
65
+ if (queries.length === 0) {
66
+ return {
67
+ isError: true,
68
+ content: [{ type: "text", text: "需要提供 query 或 queries(至少一个搜索词)。" }],
69
+ details: { error: "no query" },
70
+ };
71
+ }
72
+
73
+ const apiKey = await loadSearch1ApiKey();
74
+ if (!apiKey) {
75
+ return {
76
+ isError: true,
77
+ content: [
78
+ {
79
+ type: "text",
80
+ text: "未找到 Search1API key:请在 ~/.pi/web-search.json 配置 search1apiApiKey,或设置 SEARCH1API_KEY 环境变量。",
81
+ },
82
+ ],
83
+ details: { error: "search1api key not configured" },
84
+ };
85
+ }
86
+
87
+ onUpdate?.({
88
+ content: [{ type: "text", text: `正在搜索: ${queries.join(" / ")}` }],
89
+ details: {},
90
+ });
91
+
92
+ const common = {
93
+ numResults: params.numResults,
94
+ recencyFilter: params.recencyFilter,
95
+ domainFilter: params.domainFilter,
96
+ searchService: params.searchService,
97
+ includeContent: params.includeContent,
98
+ signal,
99
+ };
100
+ const results = await Promise.all(queries.map((query) => searchWeb(query, apiKey, common)));
101
+
102
+ // 按 URL 去重合并
103
+ const seen = new Set<string>();
104
+ const hits: SearchHit[] = [];
105
+ for (const result of results) {
106
+ for (const hit of result.hits) {
107
+ if (seen.has(hit.url)) continue;
108
+ seen.add(hit.url);
109
+ hits.push(hit);
110
+ }
111
+ }
112
+ if (hits.length === 0) {
113
+ return {
114
+ content: [{ type: "text", text: "没有找到结果。" }],
115
+ details: { query: queries, count: 0 },
116
+ };
117
+ }
118
+
119
+ return {
120
+ content: [{ type: "text", text: JSON.stringify(hits, null, 2) }],
121
+ details: {
122
+ query: queries,
123
+ provider: "search1api",
124
+ count: hits.length,
125
+ results: hits,
126
+ },
127
+ };
128
+ } catch (error) {
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ return {
131
+ isError: true,
132
+ content: [{ type: "text", text: `搜索失败: ${message}` }],
133
+ details: { error: message },
134
+ };
135
+ }
136
+ },
137
+ });
138
+
139
+ pi.registerTool({
140
+ name: "web_fetch",
141
+ label: "Web Fetch",
142
+ description:
143
+ "Fetch a URL and extract the main content as markdown. SSRF-protected: refuses " +
144
+ "private/internal addresses. Only http/https HTML pages are supported.",
145
+ promptSnippet: "Fetch a web page and extract its content",
146
+ parameters: Type.Object({
147
+ url: Type.String({ description: "The URL to fetch" }),
148
+ }),
149
+ async execute(_id, params, signal) {
150
+ try {
151
+ const page = await fetchPage(params.url, signal);
152
+ const { text, truncated } = truncateMarkdown(page.markdown);
153
+ const details: Record<string, unknown> = {
154
+ url: page.url,
155
+ title: page.title,
156
+ bytes: Buffer.byteLength(page.markdown, "utf8"),
157
+ truncated,
158
+ };
159
+ return {
160
+ content: [{ type: "text", text }],
161
+ details,
162
+ };
163
+ } catch (error) {
164
+ const message = error instanceof Error ? error.message : String(error);
165
+ const details: Record<string, unknown> = { error: message, url: params.url };
166
+ return {
167
+ isError: true,
168
+ content: [{ type: "text", text: `抓取失败: ${message}` }],
169
+ details,
170
+ };
171
+ }
172
+ },
173
+ });
174
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Search1API 搜索:请求构造、响应解析与结果整理。
3
+ *
4
+ * 搜索响应不做 AI 预消化,直接返回整理后的结构化结果(title/url/snippet,
5
+ * crawl_results 开启时含内联正文)——模型自己读,零额外模型调用。
6
+ */
7
+ import { Type } from "typebox";
8
+ import { Value } from "typebox/value";
9
+
10
+ const SEARCH_URL = "https://api.search1api.com/search";
11
+ const TIMEOUT_MS = 60_000;
12
+
13
+ const hitSchema = Type.Object({
14
+ title: Type.Union([Type.String(), Type.Null()]),
15
+ link: Type.Union([Type.String(), Type.Null()]),
16
+ snippet: Type.Union([Type.String(), Type.Null()]),
17
+ content: Type.Optional(Type.Union([Type.String(), Type.Null()])),
18
+ });
19
+
20
+ const searchResponseSchema = Type.Object(
21
+ { results: Type.Array(hitSchema) },
22
+ { additionalProperties: true },
23
+ );
24
+
25
+ export interface SearchHit {
26
+ title: string;
27
+ url: string;
28
+ snippet: string;
29
+ content?: string;
30
+ }
31
+
32
+ export interface SearchResult {
33
+ query: string;
34
+ hits: SearchHit[];
35
+ }
36
+
37
+ export interface SearchOptions {
38
+ numResults?: number;
39
+ recencyFilter?: string;
40
+ domainFilter?: string[];
41
+ searchService?: string;
42
+ /** 内联抓取前几个结果的正文(crawl_results),可省去后续 web_fetch */
43
+ includeContent?: boolean;
44
+ signal?: AbortSignal;
45
+ }
46
+
47
+ function clampNumResults(value: number | undefined): number {
48
+ if (typeof value !== "number" || !Number.isFinite(value)) return 5;
49
+ return Math.max(1, Math.min(Math.floor(value), 50));
50
+ }
51
+
52
+ function normalizeDomain(raw: string): string | undefined {
53
+ const input = raw.trim().toLowerCase().replace(/^-/, "").trim();
54
+ if (!input) return undefined;
55
+ try {
56
+ const host = input.includes("://")
57
+ ? new URL(input).hostname
58
+ : new URL(`https://${input}`).hostname;
59
+ return host.replaceAll(/^\.+|\.+$/g, "") || undefined;
60
+ } catch {
61
+ return input.split("/", 1)[0]?.split(":", 1)[0] || undefined;
62
+ }
63
+ }
64
+
65
+ function splitDomainFilter(domainFilter: string[] | undefined): {
66
+ includeSites: string[];
67
+ excludeSites: string[];
68
+ } {
69
+ const includeSites: string[] = [];
70
+ const excludeSites: string[] = [];
71
+ for (const raw of domainFilter ?? []) {
72
+ const domain = normalizeDomain(raw);
73
+ if (!domain) continue;
74
+ (raw.trimStart().startsWith("-") ? excludeSites : includeSites).push(domain);
75
+ }
76
+ return { includeSites, excludeSites };
77
+ }
78
+
79
+ export function buildSearchBody(query: string, options: SearchOptions): Record<string, unknown> {
80
+ const numResults = clampNumResults(options.numResults);
81
+ const { includeSites, excludeSites } = splitDomainFilter(options.domainFilter);
82
+ const body: Record<string, unknown> = {
83
+ query,
84
+ max_results: numResults,
85
+ crawl_results: options.includeContent ? Math.min(numResults, 5) : 0,
86
+ };
87
+ if (options.recencyFilter) body.time_range = options.recencyFilter;
88
+ if (options.searchService) body.search_service = options.searchService;
89
+ if (includeSites.length > 0) body.include_sites = includeSites;
90
+ if (excludeSites.length > 0) body.exclude_sites = excludeSites;
91
+ return body;
92
+ }
93
+
94
+ function mapHits(
95
+ results: {
96
+ title: string | null;
97
+ link: string | null;
98
+ snippet: string | null;
99
+ content?: string | null;
100
+ }[],
101
+ ): SearchHit[] {
102
+ const hits: SearchHit[] = [];
103
+ for (const item of results) {
104
+ const url = item.link?.trim();
105
+ if (!url) continue;
106
+ const hit: SearchHit = {
107
+ title: item.title?.trim() || url,
108
+ url,
109
+ snippet: item.snippet?.replaceAll(/\s+/g, " ").trim() || "",
110
+ };
111
+ const content = item.content?.trim();
112
+ if (content) hit.content = content;
113
+ hits.push(hit);
114
+ }
115
+ return hits;
116
+ }
117
+
118
+ /** 合并调用方 signal 与超时;调用方未传时仍有超时兜底 */
119
+ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
120
+ const timeout = AbortSignal.timeout(ms);
121
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
122
+ }
123
+
124
+ export async function searchWeb(
125
+ query: string,
126
+ apiKey: string,
127
+ options: SearchOptions = {},
128
+ ): Promise<SearchResult> {
129
+ const response = await fetch(SEARCH_URL, {
130
+ method: "POST",
131
+ headers: {
132
+ Authorization: `Bearer ${apiKey}`,
133
+ "Content-Type": "application/json",
134
+ },
135
+ body: JSON.stringify(buildSearchBody(query, options)),
136
+ signal: withTimeout(options.signal, TIMEOUT_MS),
137
+ });
138
+
139
+ const raw = await response.text();
140
+ if (!response.ok) {
141
+ throw new Error(`Search1API error ${response.status}: ${raw.slice(0, 300)}`);
142
+ }
143
+ const parsed = Value.Parse(searchResponseSchema, JSON.parse(raw));
144
+ return { query, hits: mapHits(parsed.results) };
145
+ }