@meowlynxsea/koi 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 +34 -0
- package/NOTICE +35 -0
- package/README.md +15 -0
- package/bin/koi +12 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/main.js +489918 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/package.json +51 -0
- package/src/agent/check-permissions.ts +239 -0
- package/src/agent/hooks/message-utils.ts +305 -0
- package/src/agent/hooks/types.ts +32 -0
- package/src/agent/hooks.ts +1560 -0
- package/src/agent/mode.ts +163 -0
- package/src/agent/monitor-registry.ts +308 -0
- package/src/agent/permission-ui.ts +71 -0
- package/src/agent/plan-ui.ts +74 -0
- package/src/agent/question-ui.ts +58 -0
- package/src/agent/session-fork.ts +299 -0
- package/src/agent/session-snapshots.ts +216 -0
- package/src/agent/session-store.ts +649 -0
- package/src/agent/session-tasks.ts +305 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +176 -0
- package/src/agent/subagent.ts +194 -0
- package/src/agent/tool-orchestration.ts +55 -0
- package/src/agent/tools.ts +8 -0
- package/src/cli/args.ts +6 -0
- package/src/cli/commands.ts +5 -0
- package/src/commands/skills/index.ts +23 -0
- package/src/config/models.ts +6 -0
- package/src/config/settings.ts +392 -0
- package/src/main.tsx +64 -0
- package/src/services/mcp/client.ts +194 -0
- package/src/services/mcp/config.ts +232 -0
- package/src/services/mcp/connection-manager.ts +258 -0
- package/src/services/mcp/index.ts +80 -0
- package/src/services/mcp/mcp-commands.ts +114 -0
- package/src/services/mcp/stdio-transport.ts +246 -0
- package/src/services/mcp/types.ts +155 -0
- package/src/skills/SkillsMenu.tsx +370 -0
- package/src/skills/bundled/batch.ts +106 -0
- package/src/skills/bundled/debug.ts +86 -0
- package/src/skills/bundled/loremIpsum.ts +101 -0
- package/src/skills/bundled/remember.ts +97 -0
- package/src/skills/bundled/simplify.ts +100 -0
- package/src/skills/bundled/skillify.ts +123 -0
- package/src/skills/bundled/stuck.ts +101 -0
- package/src/skills/bundled/updateConfig.ts +228 -0
- package/src/skills/bundled.ts +46 -0
- package/src/skills/frontmatter.ts +179 -0
- package/src/skills/index.ts +87 -0
- package/src/skills/invoke.ts +231 -0
- package/src/skills/loader.ts +710 -0
- package/src/skills/substitution.ts +169 -0
- package/src/skills/types.ts +201 -0
- package/src/tools/agent.ts +143 -0
- package/src/tools/ask-user-question.ts +46 -0
- package/src/tools/bash.ts +148 -0
- package/src/tools/edit.ts +164 -0
- package/src/tools/glob.ts +102 -0
- package/src/tools/grep.ts +248 -0
- package/src/tools/index.ts +73 -0
- package/src/tools/list-mcp-resources.ts +74 -0
- package/src/tools/ls.ts +85 -0
- package/src/tools/mcp.ts +76 -0
- package/src/tools/monitor.ts +159 -0
- package/src/tools/plan-mode.ts +134 -0
- package/src/tools/read-mcp-resource.ts +79 -0
- package/src/tools/read.ts +137 -0
- package/src/tools/skill.ts +176 -0
- package/src/tools/task.ts +349 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/webfetch-domains.ts +239 -0
- package/src/tools/webfetch.ts +533 -0
- package/src/tools/write.ts +101 -0
- package/src/tui/app.tsx +1178 -0
- package/src/tui/components/chat-panel.tsx +1071 -0
- package/src/tui/components/command-panel.tsx +261 -0
- package/src/tui/components/confirm-modal.tsx +135 -0
- package/src/tui/components/connect-modal.tsx +435 -0
- package/src/tui/components/connecting-modal.tsx +167 -0
- package/src/tui/components/edit-pending-modal.tsx +103 -0
- package/src/tui/components/exit-modal.tsx +131 -0
- package/src/tui/components/fork-modal.tsx +377 -0
- package/src/tui/components/image-preview-modal.tsx +141 -0
- package/src/tui/components/image-utils.ts +128 -0
- package/src/tui/components/info-bar.tsx +103 -0
- package/src/tui/components/input-box.tsx +352 -0
- package/src/tui/components/mcp/MCPSettings.tsx +386 -0
- package/src/tui/components/mcp/index.ts +7 -0
- package/src/tui/components/model-modal.tsx +310 -0
- package/src/tui/components/pending-area.tsx +88 -0
- package/src/tui/components/rename-modal.tsx +119 -0
- package/src/tui/components/session-modal.tsx +233 -0
- package/src/tui/components/side-bar.tsx +349 -0
- package/src/tui/components/tool-output.ts +6 -0
- package/src/tui/hooks/user-prompt-history.ts +114 -0
- package/src/tui/theme.ts +63 -0
- package/src/types/commands.ts +80 -0
- package/src/types/cross-spawn.d.ts +24 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebFetchTool — 网页抓取与 AI 摘要
|
|
3
|
+
*
|
|
4
|
+
* 抓取目标 URL 的内容,通过 turndown 将 HTML 转为 Markdown,
|
|
5
|
+
* 再调用辅助模型根据用户 prompt 进行摘要/分析。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import axios, { type AxiosResponse } from "axios";
|
|
10
|
+
import TurndownService from "turndown";
|
|
11
|
+
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import type { TextContent } from "@mariozechner/pi-ai";
|
|
13
|
+
import { completeSimple } from "@mariozechner/pi-ai";
|
|
14
|
+
import { checkPermission } from "../agent/check-permissions.js";
|
|
15
|
+
import { requestPermission } from "../agent/permission-ui.js";
|
|
16
|
+
import { getCurrentPiModel, getPiModelRegistry } from "../config/settings.js";
|
|
17
|
+
import { isPreapprovedDomain, isDangerousHost } from "./webfetch-domains.js";
|
|
18
|
+
import type { ToolResultWithError } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/* ───────── TypeBox Schema ───────── */
|
|
21
|
+
|
|
22
|
+
export const webfetchSchema = Type.Object({
|
|
23
|
+
url: Type.String({ description: "目标 URL(必须合法)" }),
|
|
24
|
+
prompt: Type.String({ description: "对抓取内容执行的问题/指令" }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type WebFetchToolInput = {
|
|
28
|
+
url: string;
|
|
29
|
+
prompt: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Safe Cache Operations
|
|
34
|
+
*
|
|
35
|
+
* CacheError wraps cache failures so the caller can decide whether to fall back to a fresh fetch
|
|
36
|
+
* instead of silently swallowing the error or crashing the agent loop.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
class CacheError extends Error {}
|
|
40
|
+
|
|
41
|
+
function safeCacheGet<T>(cache: { get(key: string): T | undefined }, key: string): T | undefined {
|
|
42
|
+
try {
|
|
43
|
+
return cache.get(key);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new CacheError(`Cache get failed for ${key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeCacheSet(cache: { set(key: string, value: unknown): void }, key: string, value: unknown): void {
|
|
50
|
+
try {
|
|
51
|
+
cache.set(key, value);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new CacheError(`Cache set failed for ${key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* LRU Cache Implementations
|
|
59
|
+
*
|
|
60
|
+
* SizeBoundedLRUCache: byte-capped + TTL + entry-capped. Used for page HTML/Markdown content.
|
|
61
|
+
* SimpleLRUCache: entry-capped + TTL only. Used for lightweight domain safety checks.
|
|
62
|
+
*
|
|
63
|
+
* Both use Map iteration order as the LRU queue (set/delete/re-set moves the key to the end).
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/** 带容量(字节)与 TTL 的 LRU 字符串缓存 */
|
|
67
|
+
class SizeBoundedLRUCache {
|
|
68
|
+
private cache = new Map<
|
|
69
|
+
string,
|
|
70
|
+
{ value: string; size: number; expiresAt: number }
|
|
71
|
+
>();
|
|
72
|
+
private currentSize = 0;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private maxSizeBytes: number,
|
|
76
|
+
private ttlMs: number,
|
|
77
|
+
private maxEntries: number
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
get(key: string): string | undefined {
|
|
81
|
+
const entry = this.cache.get(key);
|
|
82
|
+
if (!entry) return undefined;
|
|
83
|
+
|
|
84
|
+
if (Date.now() > entry.expiresAt) {
|
|
85
|
+
this.currentSize -= entry.size;
|
|
86
|
+
this.cache.delete(key);
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.cache.delete(key);
|
|
91
|
+
this.cache.set(key, entry);
|
|
92
|
+
return entry.value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
set(key: string, value: string): void {
|
|
96
|
+
const size = Buffer.byteLength(value, "utf8");
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
|
|
99
|
+
// 清理过期条目
|
|
100
|
+
for (const [k, v] of this.cache) {
|
|
101
|
+
if (now > v.expiresAt) {
|
|
102
|
+
this.currentSize -= v.size;
|
|
103
|
+
this.cache.delete(k);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 淘汰最旧条目
|
|
108
|
+
while (
|
|
109
|
+
(this.currentSize + size > this.maxSizeBytes || this.cache.size >= this.maxEntries) &&
|
|
110
|
+
this.cache.size > 0
|
|
111
|
+
) {
|
|
112
|
+
const firstKey = this.cache.keys().next().value as string;
|
|
113
|
+
const firstEntry = this.cache.get(firstKey)!;
|
|
114
|
+
this.currentSize -= firstEntry.size;
|
|
115
|
+
this.cache.delete(firstKey);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const existing = this.cache.get(key);
|
|
119
|
+
if (existing) {
|
|
120
|
+
this.currentSize -= existing.size;
|
|
121
|
+
this.cache.delete(key);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.cache.set(key, { value, size, expiresAt: now + this.ttlMs });
|
|
125
|
+
this.currentSize += size;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 简单条目数上限 LRU 缓存 */
|
|
130
|
+
class SimpleLRUCache<K, V> {
|
|
131
|
+
private cache = new Map<K, { value: V; expiresAt: number }>();
|
|
132
|
+
|
|
133
|
+
constructor(private maxSize: number, private ttlMs: number) {}
|
|
134
|
+
|
|
135
|
+
get(key: K): V | undefined {
|
|
136
|
+
const entry = this.cache.get(key);
|
|
137
|
+
if (!entry) return undefined;
|
|
138
|
+
if (Date.now() > entry.expiresAt) {
|
|
139
|
+
this.cache.delete(key);
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
this.cache.delete(key);
|
|
143
|
+
this.cache.set(key, entry);
|
|
144
|
+
return entry.value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
set(key: K, value: V): void {
|
|
148
|
+
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
|
149
|
+
const firstKey = this.cache.keys().next().value as K;
|
|
150
|
+
this.cache.delete(firstKey);
|
|
151
|
+
}
|
|
152
|
+
this.cache.delete(key);
|
|
153
|
+
this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* 页面缓存:15 min TTL,50 MB 上限,256 条目上限 */
|
|
158
|
+
const pageCache = new SizeBoundedLRUCache(50 * 1024 * 1024, 15 * 60 * 1000, 256);
|
|
159
|
+
|
|
160
|
+
/* 域名预检缓存:5 min TTL,128 条目 */
|
|
161
|
+
const domainCheckCache = new SimpleLRUCache<string, boolean>(128, 5 * 60 * 1000);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Domain Safety
|
|
165
|
+
*
|
|
166
|
+
* Checks URL against the dangerous-host list and preapproved-domain list.
|
|
167
|
+
* Results are cached in domainCheckCache to avoid re-parsing the same URL repeatedly
|
|
168
|
+
* within a single agent turn.
|
|
169
|
+
*/
|
|
170
|
+
|
|
171
|
+
function checkDomainSafety(url: string): { safe: boolean; reason?: string } {
|
|
172
|
+
try {
|
|
173
|
+
const cached = safeCacheGet(domainCheckCache, url);
|
|
174
|
+
if (cached !== undefined) {
|
|
175
|
+
return cached ? { safe: true } : { safe: false, reason: "域名被标记为危险(缓存)" };
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// 缓存读取失败继续执行
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const parsed = new URL(url);
|
|
183
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
184
|
+
|
|
185
|
+
if (isDangerousHost(hostname)) {
|
|
186
|
+
safeCacheSet(domainCheckCache, url, false);
|
|
187
|
+
return { safe: false, reason: "本地/IP/内网地址被禁止访问" };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
safeCacheSet(domainCheckCache, url, true);
|
|
191
|
+
return { safe: true };
|
|
192
|
+
} catch {
|
|
193
|
+
safeCacheSet(domainCheckCache, url, false);
|
|
194
|
+
return { safe: false, reason: "无效 URL" };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Redirect Safety Policy
|
|
200
|
+
*
|
|
201
|
+
* Rules:
|
|
202
|
+
* 1. Allow http→https upgrades; deny downgrades or protocol switches.
|
|
203
|
+
* 2. Port must remain identical.
|
|
204
|
+
* 3. Reject URLs containing embedded credentials (user:pass@host).
|
|
205
|
+
* 4. Allow same-origin or www-prefix-only host changes.
|
|
206
|
+
*/
|
|
207
|
+
|
|
208
|
+
function isSafeRedirect(currentUrl: URL, redirectUrl: URL): boolean {
|
|
209
|
+
// 协议:允许 http→https,禁止 https→http,禁止其他协议跳变
|
|
210
|
+
if (currentUrl.protocol === "https:" && redirectUrl.protocol === "http:") {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
if (
|
|
214
|
+
currentUrl.protocol !== redirectUrl.protocol &&
|
|
215
|
+
!(currentUrl.protocol === "http:" && redirectUrl.protocol === "https:")
|
|
216
|
+
) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 端口必须一致
|
|
221
|
+
const defaultPort = (protocol: string) => (protocol === "https:" ? "443" : "80");
|
|
222
|
+
if ((currentUrl.port || defaultPort(currentUrl.protocol)) !== (redirectUrl.port || defaultPort(redirectUrl.protocol))) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 禁止 URL 中包含 username/password
|
|
227
|
+
if (redirectUrl.username || redirectUrl.password) return false;
|
|
228
|
+
|
|
229
|
+
// 域名:同源或仅 www. 前缀变化
|
|
230
|
+
const currentHost = currentUrl.hostname.toLowerCase();
|
|
231
|
+
const redirectHost = redirectUrl.hostname.toLowerCase();
|
|
232
|
+
if (currentHost === redirectHost) return true;
|
|
233
|
+
if (currentHost === `www.${redirectHost}` || redirectHost === `www.${currentHost}`) return true;
|
|
234
|
+
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* HTTP Fetch with Safe Redirects
|
|
240
|
+
*
|
|
241
|
+
* Manually follows redirects (axios maxRedirects: 0) so we can enforce isSafeRedirect
|
|
242
|
+
* on every hop. Auto-upgrades http→https on the initial URL.
|
|
243
|
+
*/
|
|
244
|
+
|
|
245
|
+
async function fetchWithRedirects(
|
|
246
|
+
initialUrl: string,
|
|
247
|
+
maxRedirects = 10
|
|
248
|
+
): Promise<AxiosResponse<string>> {
|
|
249
|
+
let currentUrl = initialUrl.startsWith("http://")
|
|
250
|
+
? initialUrl.replace("http://", "https://")
|
|
251
|
+
: initialUrl;
|
|
252
|
+
|
|
253
|
+
let redirects = 0;
|
|
254
|
+
while (redirects <= maxRedirects) {
|
|
255
|
+
const response = await axios.get<string>(currentUrl, {
|
|
256
|
+
timeout: 60_000,
|
|
257
|
+
maxContentLength: 10 * 1024 * 1024,
|
|
258
|
+
responseType: "text",
|
|
259
|
+
maxRedirects: 0,
|
|
260
|
+
validateStatus: (status) => status < 400 || (status >= 300 && status < 400),
|
|
261
|
+
headers: {
|
|
262
|
+
"User-Agent": "Mozilla/5.0 (compatible; KoiBot/1.0; +https://koi.dev)",
|
|
263
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
if (response.status >= 200 && response.status < 300) {
|
|
268
|
+
return response;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (response.status >= 300 && response.status < 400) {
|
|
272
|
+
const location = response.headers["location"] as unknown;
|
|
273
|
+
if (typeof location !== "string") {
|
|
274
|
+
throw new Error(`重定向响应缺少 Location 头 (HTTP ${response.status})`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const nextUrl = new URL(location, currentUrl);
|
|
278
|
+
if (!isSafeRedirect(new URL(currentUrl), nextUrl)) {
|
|
279
|
+
throw new Error(`不安全的重定向被阻止: ${currentUrl} → ${nextUrl.href}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
currentUrl = nextUrl.href;
|
|
283
|
+
redirects++;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
throw new Error(`重定向次数超过最大限制 (${maxRedirects})`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Shared TurndownService instance: avoids re-creating regex-heavy parsers on every fetch. */
|
|
294
|
+
|
|
295
|
+
const turndownService = new TurndownService({
|
|
296
|
+
headingStyle: "atx",
|
|
297
|
+
codeBlockStyle: "fenced",
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Content Fetch
|
|
302
|
+
*
|
|
303
|
+
* Checks pageCache first; on miss performs the HTTP request, converts HTML→Markdown,
|
|
304
|
+
* stores the result in the cache, and returns the content.
|
|
305
|
+
* Cache failures are caught and re-thrown as CacheError so executeWebFetch can retry.
|
|
306
|
+
*/
|
|
307
|
+
|
|
308
|
+
async function fetchPageContent(url: string): Promise<{ content: string; contentType: string }> {
|
|
309
|
+
try {
|
|
310
|
+
const cached = safeCacheGet(pageCache, url);
|
|
311
|
+
if (cached !== undefined) {
|
|
312
|
+
return { content: cached, contentType: "text/html" };
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
// 缓存读取失败继续抓取
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const response = await fetchWithRedirects(url);
|
|
319
|
+
const contentType = String(response.headers["content-type"] || "application/octet-stream");
|
|
320
|
+
const body: string = response.data;
|
|
321
|
+
const content = contentType.includes("text/html") ? turndownService.turndown(body) : body;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
safeCacheSet(pageCache, url, content);
|
|
325
|
+
} catch {
|
|
326
|
+
// 缓存写入失败不影响结果
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { content, contentType };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* AI Summarization
|
|
334
|
+
*
|
|
335
|
+
* Sends the truncated page content + user prompt to the current Pi model.
|
|
336
|
+
* Preapproved domains get a plain system prompt; non-preapproved domains include
|
|
337
|
+
* a copyright/disclaimer notice to reduce hallucinated attribution.
|
|
338
|
+
*/
|
|
339
|
+
|
|
340
|
+
async function summarizeWithAI(content: string, prompt: string, url: string): Promise<string> {
|
|
341
|
+
const model = getCurrentPiModel();
|
|
342
|
+
if (!model) {
|
|
343
|
+
throw new Error("未配置 AI 模型,无法处理抓取内容");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const registry = getPiModelRegistry();
|
|
347
|
+
const auth = await registry.getApiKeyAndHeaders(model);
|
|
348
|
+
if (!auth.ok) {
|
|
349
|
+
throw new Error(`无法获取模型认证信息: ${auth.error}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const isPreapproved = isPreapprovedDomain(url);
|
|
353
|
+
const systemPrompt = isPreapproved
|
|
354
|
+
? "你是一个网页内容分析助手。请根据用户提供的网页内容和问题,给出准确、简洁的回答。"
|
|
355
|
+
: "你是一个网页内容分析助手。请根据用户提供的网页内容和问题,给出准确、简洁的回答。注意:该内容来自第三方网站,请适当引用并注意版权问题。";
|
|
356
|
+
|
|
357
|
+
const userPrompt = `网页内容:\n\n${content}\n\n用户问题/指令:${prompt}`;
|
|
358
|
+
|
|
359
|
+
const result = await completeSimple(
|
|
360
|
+
model,
|
|
361
|
+
{
|
|
362
|
+
systemPrompt,
|
|
363
|
+
messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
apiKey: auth.apiKey,
|
|
367
|
+
headers: auth.headers,
|
|
368
|
+
maxTokens: 4000,
|
|
369
|
+
timeoutMs: 60_000,
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
if (result.stopReason === "error" || result.stopReason === "aborted") {
|
|
374
|
+
throw new Error(`辅助模型处理失败: ${result.errorMessage || "未知错误"}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return result.content
|
|
378
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
379
|
+
.map((block) => block.text)
|
|
380
|
+
.join("");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Main Entry Point
|
|
385
|
+
*
|
|
386
|
+
* Pipeline: domain safety → fetch content → truncate → AI summarize → return.
|
|
387
|
+
* On cache failure we re-fetch uncached; all other errors bubble up to the tool layer.
|
|
388
|
+
*/
|
|
389
|
+
|
|
390
|
+
export async function executeWebFetch(params: WebFetchToolInput): Promise<{
|
|
391
|
+
content: TextContent[];
|
|
392
|
+
details: {
|
|
393
|
+
url: string;
|
|
394
|
+
contentType: string;
|
|
395
|
+
charCount: number;
|
|
396
|
+
truncated: boolean;
|
|
397
|
+
cached: boolean;
|
|
398
|
+
};
|
|
399
|
+
}> {
|
|
400
|
+
const { url, prompt } = params;
|
|
401
|
+
|
|
402
|
+
// 域名预检
|
|
403
|
+
const safety = checkDomainSafety(url);
|
|
404
|
+
if (!safety.safe) {
|
|
405
|
+
throw new Error(`域名预检失败: ${safety.reason}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 抓取内容
|
|
409
|
+
let fromCache = false;
|
|
410
|
+
let rawContent: string;
|
|
411
|
+
let contentType: string;
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const page = await fetchPageContent(url);
|
|
415
|
+
rawContent = page.content;
|
|
416
|
+
contentType = page.contentType;
|
|
417
|
+
fromCache = true;
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (err instanceof CacheError) {
|
|
420
|
+
// 缓存出错,重新抓取
|
|
421
|
+
const page = await fetchPageContent(url);
|
|
422
|
+
rawContent = page.content;
|
|
423
|
+
contentType = page.contentType;
|
|
424
|
+
fromCache = false;
|
|
425
|
+
} else {
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// 截断
|
|
431
|
+
const MAX_CHARS = 100_000;
|
|
432
|
+
const truncated = rawContent.length > MAX_CHARS;
|
|
433
|
+
const truncatedContent = truncated ? rawContent.slice(0, MAX_CHARS) : rawContent;
|
|
434
|
+
|
|
435
|
+
// AI 处理
|
|
436
|
+
const answer = await summarizeWithAI(truncatedContent, prompt, url);
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
content: [{ type: "text", text: answer }],
|
|
440
|
+
details: {
|
|
441
|
+
url,
|
|
442
|
+
contentType,
|
|
443
|
+
charCount: truncatedContent.length,
|
|
444
|
+
truncated,
|
|
445
|
+
cached: fromCache,
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Factory functions for permission-denied tool results (keeps execute() readable). */
|
|
451
|
+
|
|
452
|
+
function buildDeniedResult(params: WebFetchToolInput, reason: string): ToolResultWithError<{
|
|
453
|
+
url: string;
|
|
454
|
+
contentType: string;
|
|
455
|
+
charCount: number;
|
|
456
|
+
truncated: boolean;
|
|
457
|
+
cached: boolean;
|
|
458
|
+
}> {
|
|
459
|
+
return {
|
|
460
|
+
content: [{ type: "text", text: `Permission denied: ${reason}` }],
|
|
461
|
+
details: { url: params.url, contentType: "", charCount: 0, truncated: false, cached: false },
|
|
462
|
+
isError: true,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function buildUserDeniedResult(params: WebFetchToolInput): ToolResultWithError<{
|
|
467
|
+
url: string;
|
|
468
|
+
contentType: string;
|
|
469
|
+
charCount: number;
|
|
470
|
+
truncated: boolean;
|
|
471
|
+
cached: boolean;
|
|
472
|
+
}> {
|
|
473
|
+
return {
|
|
474
|
+
content: [{ type: "text", text: "User denied permission to fetch the URL." }],
|
|
475
|
+
details: { url: params.url, contentType: "", charCount: 0, truncated: false, cached: false },
|
|
476
|
+
isError: true,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* ToolDefinition Factory
|
|
482
|
+
*
|
|
483
|
+
* Registers the webfetch tool with the Pi agent runtime.
|
|
484
|
+
* Permission flow: deny → return error immediately; ask → show modal → proceed or return error.
|
|
485
|
+
*/
|
|
486
|
+
|
|
487
|
+
export function createWebFetchToolDefinition(
|
|
488
|
+
_cwd: string
|
|
489
|
+
): ToolDefinition<
|
|
490
|
+
typeof webfetchSchema,
|
|
491
|
+
{
|
|
492
|
+
url: string;
|
|
493
|
+
contentType: string;
|
|
494
|
+
charCount: number;
|
|
495
|
+
truncated: boolean;
|
|
496
|
+
cached: boolean;
|
|
497
|
+
}
|
|
498
|
+
> {
|
|
499
|
+
return {
|
|
500
|
+
name: "webfetch",
|
|
501
|
+
label: "WebFetch",
|
|
502
|
+
description:
|
|
503
|
+
"抓取网页内容并通过 AI 进行摘要/分析。\n\n" +
|
|
504
|
+
"支持 HTML 自动转 Markdown,自动处理重定向,内置域名安全策略。\n" +
|
|
505
|
+
"对于预批准的技术文档域名直接通过;非预批准域名会附加引用/版权提示。",
|
|
506
|
+
promptSnippet: "WebFetch: 抓取网页并通过 AI 分析内容",
|
|
507
|
+
promptGuidelines: [
|
|
508
|
+
"url 必须是合法的 HTTP/HTTPS URL",
|
|
509
|
+
"prompt 参数用于指导 AI 如何处理抓取后的内容",
|
|
510
|
+
"工具会自动将 HTML 转为 Markdown,并截断到 100,000 字符",
|
|
511
|
+
"非预批准域名的结果会附加版权/引用注意事项",
|
|
512
|
+
],
|
|
513
|
+
parameters: webfetchSchema,
|
|
514
|
+
executionMode: "parallel",
|
|
515
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
516
|
+
const perm = checkPermission("webfetch", params);
|
|
517
|
+
if (perm.decision === "deny") {
|
|
518
|
+
return buildDeniedResult(params, perm.reason ?? "webfetch operation blocked");
|
|
519
|
+
}
|
|
520
|
+
if (perm.decision === "ask") {
|
|
521
|
+
const allowed = await requestPermission({
|
|
522
|
+
toolName: "webfetch",
|
|
523
|
+
args: params,
|
|
524
|
+
reason: perm.reason ?? "Confirm web fetch",
|
|
525
|
+
});
|
|
526
|
+
if (!allowed) {
|
|
527
|
+
return buildUserDeniedResult(params);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return await executeWebFetch(params);
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileWriteTool — Full file write
|
|
3
|
+
*
|
|
4
|
+
* Overwrites existing files or creates new ones.
|
|
5
|
+
* Forces LF line endings on write.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { mkdirSync, writeFileSync, existsSync } from "fs";
|
|
10
|
+
import { dirname, resolve } from "path";
|
|
11
|
+
import { structuredPatch } from "diff";
|
|
12
|
+
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import type { TextContent } from "@mariozechner/pi-ai";
|
|
14
|
+
import { checkPermission } from "../agent/check-permissions.js";
|
|
15
|
+
import { requestPermission } from "../agent/permission-ui.js";
|
|
16
|
+
import { withWriteLock } from "../agent/tool-orchestration.js";
|
|
17
|
+
import type { ToolResultWithError } from "./types.js";
|
|
18
|
+
|
|
19
|
+
export const writeSchema = Type.Object({
|
|
20
|
+
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
|
|
21
|
+
content: Type.String({ description: "Content to write to the file" }),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type WriteToolInput = {
|
|
25
|
+
path: string;
|
|
26
|
+
content: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function generateFullDiff(filePath: string, newContent: string): string {
|
|
30
|
+
const patch = structuredPatch(filePath, filePath, "", newContent, "", "", { context: 99999 });
|
|
31
|
+
if (!patch || patch.hunks.length === 0) return "";
|
|
32
|
+
let result = `--- ${filePath}\n+++ ${filePath}\n`;
|
|
33
|
+
for (const hunk of patch.hunks) {
|
|
34
|
+
result += `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n`;
|
|
35
|
+
for (const line of hunk.lines) {
|
|
36
|
+
result += line + "\n";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function executeWrite(params: WriteToolInput): Promise<{ content: TextContent[]; details: { bytesWritten: number; diff: string } }> {
|
|
43
|
+
const filePath = resolve(params.path);
|
|
44
|
+
const dir = dirname(filePath);
|
|
45
|
+
|
|
46
|
+
if (!existsSync(dir)) {
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Force LF line endings
|
|
51
|
+
const normalized = params.content.replace(/\r\n/g, "\n");
|
|
52
|
+
writeFileSync(filePath, normalized, "utf-8");
|
|
53
|
+
|
|
54
|
+
const diff = generateFullDiff(filePath, normalized);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: "text", text: `File written: ${params.path}` }],
|
|
58
|
+
details: { bytesWritten: Buffer.byteLength(normalized, "utf-8"), diff },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createWriteToolDefinition(_cwd: string): ToolDefinition<typeof writeSchema, { bytesWritten: number; diff: string }> {
|
|
63
|
+
return {
|
|
64
|
+
name: "write",
|
|
65
|
+
label: "Write",
|
|
66
|
+
description:
|
|
67
|
+
"Writes a file to the local filesystem.\n\n" +
|
|
68
|
+
"- This tool will overwrite the existing file if there is one at the provided path.\n" +
|
|
69
|
+
"- If this is an existing file, you MUST use the Read tool first.\n" +
|
|
70
|
+
"- Prefer the Edit tool for modifying existing files — it only sends the diff.\n" +
|
|
71
|
+
"- Only use this tool to create new files or for complete rewrites.",
|
|
72
|
+
promptSnippet: "Write: create or overwrite a file (prefer Edit for partial changes)",
|
|
73
|
+
parameters: writeSchema,
|
|
74
|
+
executionMode: "parallel",
|
|
75
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
76
|
+
return withWriteLock(async () => {
|
|
77
|
+
const perm = checkPermission("write", params);
|
|
78
|
+
if (perm.decision === "deny") {
|
|
79
|
+
const result: ToolResultWithError<{ bytesWritten: number; diff: string }> = {
|
|
80
|
+
content: [{ type: "text", text: `Permission denied: ${perm.reason ?? "write operation blocked"}` }],
|
|
81
|
+
details: { bytesWritten: 0, diff: "" },
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
if (perm.decision === "ask") {
|
|
87
|
+
const allowed = await requestPermission({ toolName: "write", args: params, reason: perm.reason ?? "Confirm file write" });
|
|
88
|
+
if (!allowed) {
|
|
89
|
+
const result: ToolResultWithError<{ bytesWritten: number; diff: string }> = {
|
|
90
|
+
content: [{ type: "text", text: "User denied permission to write file." }],
|
|
91
|
+
details: { bytesWritten: 0, diff: "" },
|
|
92
|
+
isError: true,
|
|
93
|
+
};
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return await executeWrite(params);
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|