@trim21/personal-pi-extensions 0.1.612 → 0.1.613
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 +1 -1
- package/src/aft/config.ts +9 -3
- package/src/aft/index.ts +6 -2
- package/src/aft/logger.ts +9 -3
- package/src/aft/tools.ts +3 -1
- package/src/bwrap/approval-rules.ts +42 -14
- package/src/bwrap/approval-suggest.ts +12 -4
- package/src/bwrap/core.ts +39 -13
- package/src/bwrap/dcg-scan.ts +16 -6
- package/src/bwrap/holder.ts +3 -1
- package/src/bwrap/mihomo-config.ts +18 -6
- package/src/bwrap/network-stack.ts +30 -10
- package/src/bwrap/runtime.ts +30 -10
- package/src/claude-code/common.ts +6 -2
- package/src/claude-code/edit-utils.ts +3 -1
- package/src/claude-code/files.ts +40 -14
- package/src/claude-code/glob.ts +12 -4
- package/src/claude-code/grep.ts +41 -14
- package/src/claude-code/session-tools.ts +18 -6
- package/src/claude-code/shell.ts +9 -3
- package/src/gh/base.ts +72 -24
- package/src/gh/tools/download-release-assets.ts +12 -4
- package/src/gh/tools/list-releases.ts +3 -1
- package/src/gh/tools/list-workflow-runs.ts +9 -3
- package/src/gh/tools/read-ci-logs.ts +21 -7
- package/src/gh/tools/read-repo.ts +3 -1
- package/src/lib/cli-args.ts +6 -2
- package/src/lib/cli.ts +48 -16
- package/src/lib/file-reads.ts +24 -8
- package/src/lib/github.ts +35 -12
- package/src/lib/lsp/adapter.ts +6 -2
- package/src/lib/lsp/bin.ts +12 -4
- package/src/lib/lsp/client.ts +205 -69
- package/src/lib/lsp/diagnostic.ts +6 -2
- package/src/lib/lsp/inspect-tool.ts +25 -9
- package/src/lib/lsp/lsp.ts +138 -46
- package/src/lib/lsp/rename-tool.ts +11 -4
- package/src/lib/lsp/rename.ts +25 -8
- package/src/lib/lsp/server-config.ts +41 -14
- package/src/lib/lsp/watcher.ts +51 -17
- package/src/lib/path.ts +18 -6
- package/src/lib/proc.ts +3 -1
- package/src/lib/proxy.ts +9 -3
- package/src/lib/request-policy.ts +3 -1
- package/src/lib/seq-state.ts +3 -1
- package/src/lib/ui.ts +23 -8
- package/src/lib/write-guard.ts +21 -7
- package/src/openai-cost/config.ts +3 -1
- package/src/openai-cost/cost.ts +54 -18
- package/src/openai-cost/index.ts +3 -1
- package/src/openai-cost/provider.ts +6 -2
- package/src/opencode/bash.ts +9 -3
- package/src/opencode/edit-engine.ts +24 -8
- package/src/opencode/files.ts +30 -10
- package/src/opencode/glob.ts +3 -1
- package/src/opencode/grep.ts +12 -4
- package/src/opencode/question.ts +3 -1
- package/src/opencode/ripgrep.ts +18 -6
- package/src/opencode/todo.ts +3 -1
- package/src/session-name.ts +39 -13
- package/src/spawn-agent-agents.ts +18 -6
- package/src/spawn-agent.ts +53 -18
- package/src/system-prompt/index.ts +9 -3
- package/src/talk/core.ts +143 -48
- package/src/talk/format.ts +12 -4
- package/src/talk/group.ts +9 -3
- package/src/talk/index.ts +30 -10
- package/src/talk/mailbox.ts +30 -10
- package/src/talk/policy.ts +6 -2
- package/src/talk/registry.ts +33 -11
- package/src/talk/storage.ts +3 -1
- package/src/vision-agent.ts +41 -14
- package/src/web/config.ts +3 -1
- package/src/web/fetch.ts +75 -25
- package/src/web/search.ts +30 -10
package/src/talk/registry.ts
CHANGED
|
@@ -81,7 +81,9 @@ export function deriveAddr(cwd: string, agentId: string): string {
|
|
|
81
81
|
|
|
82
82
|
/** Validate a talk address before it becomes a storage key. */
|
|
83
83
|
export function assertAddress(addr: string): void {
|
|
84
|
-
if (!ADDRESS_PATTERN.test(addr))
|
|
84
|
+
if (!ADDRESS_PATTERN.test(addr)) {
|
|
85
|
+
throw new TypeError(`Invalid talk address: ${addr}`);
|
|
86
|
+
}
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
// ── Storage namespaces ───────────────────────────────────────────────────
|
|
@@ -111,7 +113,9 @@ export async function writeRecord(storage: TalkStorage, record: AgentRecord): Pr
|
|
|
111
113
|
|
|
112
114
|
export async function readRecord(storage: TalkStorage, addr: string): Promise<AgentRecord | null> {
|
|
113
115
|
const raw = await storage.readJson(RECORDS_NS, recordKey(addr));
|
|
114
|
-
if (Value.Check(AgentRecordSchema, raw))
|
|
116
|
+
if (Value.Check(AgentRecordSchema, raw)) {
|
|
117
|
+
return raw;
|
|
118
|
+
}
|
|
115
119
|
// Migrate legacy records in place of the `sessionId` → `agentId` rename.
|
|
116
120
|
if (Value.Check(LegacyAgentRecordSchema, raw)) {
|
|
117
121
|
// Value.Check 是类型守卫,raw 已收窄成 legacy 记录的形状,不需要再断言。
|
|
@@ -126,9 +130,13 @@ export async function listRecords(storage: TalkStorage): Promise<AgentRecord[]>
|
|
|
126
130
|
const out: AgentRecord[] = [];
|
|
127
131
|
for (const key of await storage.listKeys(RECORDS_NS)) {
|
|
128
132
|
const addr = key.slice(0, -".json".length);
|
|
129
|
-
if (!ADDRESS_PATTERN.test(addr))
|
|
133
|
+
if (!ADDRESS_PATTERN.test(addr)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
130
136
|
const record = await readRecord(storage, addr);
|
|
131
|
-
if (record)
|
|
137
|
+
if (record) {
|
|
138
|
+
out.push(record);
|
|
139
|
+
}
|
|
132
140
|
}
|
|
133
141
|
return out.toSorted((a, b) => a.startedAt - b.startedAt);
|
|
134
142
|
}
|
|
@@ -160,22 +168,30 @@ export function readStartTime(pid: number): number | undefined {
|
|
|
160
168
|
* unreadable — we fall back to the bare pid check.
|
|
161
169
|
*/
|
|
162
170
|
function pidAlive(pid: number, pidStart?: number): boolean {
|
|
163
|
-
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
171
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
164
174
|
try {
|
|
165
175
|
process.kill(pid, 0);
|
|
166
176
|
} catch (error) {
|
|
167
177
|
// EPERM means the process exists but isn't ours — still alive
|
|
168
178
|
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
169
179
|
}
|
|
170
|
-
if (pidStart === undefined)
|
|
180
|
+
if (pidStart === undefined) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
171
183
|
const start = readStartTime(pid);
|
|
172
184
|
// 读不到启动时间时保守认为「pid 还活着」,只有两边都读到且不同才判定被复用
|
|
173
185
|
return start === undefined || start === pidStart;
|
|
174
186
|
}
|
|
175
187
|
|
|
176
188
|
export function presenceOf(record: AgentRecord): Presence {
|
|
177
|
-
if (record.offline)
|
|
178
|
-
|
|
189
|
+
if (record.offline) {
|
|
190
|
+
return "offline";
|
|
191
|
+
}
|
|
192
|
+
if (!pidAlive(record.pid, record.pidStart)) {
|
|
193
|
+
return "offline";
|
|
194
|
+
}
|
|
179
195
|
return "live";
|
|
180
196
|
}
|
|
181
197
|
|
|
@@ -196,12 +212,18 @@ export async function sweep(storage: TalkStorage, now: number = Date.now()): Pro
|
|
|
196
212
|
for (const record of await listRecords(storage)) {
|
|
197
213
|
// Without a heartbeat, lastSeenAt only tracks the last event, so an idle
|
|
198
214
|
// live agent would look long-quiet — never reap a live process.
|
|
199
|
-
if (pidAlive(record.pid, record.pidStart))
|
|
215
|
+
if (pidAlive(record.pid, record.pidStart)) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
200
218
|
const quietFor = now - record.lastSeenAt;
|
|
201
|
-
if (quietFor < SWEEP_OFFLINE_GRACE_MS)
|
|
219
|
+
if (quietFor < SWEEP_OFFLINE_GRACE_MS) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
202
222
|
const hasMail =
|
|
203
223
|
(await storage.hasKeys(inboxNs(record.addr))) || (await storage.hasKeys(asksNs(record.addr)));
|
|
204
|
-
if (hasMail && quietFor < SWEEP_MAIL_KEEP_MS)
|
|
224
|
+
if (hasMail && quietFor < SWEEP_MAIL_KEEP_MS) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
205
227
|
await storage.removeNamespace(inboxNs(record.addr));
|
|
206
228
|
await storage.removeNamespace(asksNs(record.addr));
|
|
207
229
|
await storage.removeKey(RECORDS_NS, recordKey(record.addr));
|
package/src/talk/storage.ts
CHANGED
|
@@ -100,7 +100,9 @@ export class SqliteTalkStorage implements TalkStorage {
|
|
|
100
100
|
const row = this.db
|
|
101
101
|
.prepare("SELECT value FROM talk_kv WHERE namespace = ? AND key = ?")
|
|
102
102
|
.get(namespace, key) as { value: string } | undefined;
|
|
103
|
-
if (row === undefined)
|
|
103
|
+
if (row === undefined) {
|
|
104
|
+
return Promise.resolve(null);
|
|
105
|
+
}
|
|
104
106
|
try {
|
|
105
107
|
return Promise.resolve(JSON.parse(row.value) as unknown);
|
|
106
108
|
} catch {
|
package/src/vision-agent.ts
CHANGED
|
@@ -120,7 +120,9 @@ export function resolveImagePaths(params: { path?: string | string[] }): string[
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
function toTrimmedList(value: string | string[] | undefined): string[] {
|
|
123
|
-
if (value === undefined)
|
|
123
|
+
if (value === undefined) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
124
126
|
const list = Array.isArray(value) ? value : [value];
|
|
125
127
|
return list.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
126
128
|
}
|
|
@@ -143,9 +145,13 @@ export function loadVisionConfig(settingsPath = SETTINGS_PATH): VisionConfigSett
|
|
|
143
145
|
} catch {
|
|
144
146
|
return undefined;
|
|
145
147
|
}
|
|
146
|
-
if (!Value.Check(visionSettingsSchema, parsed))
|
|
148
|
+
if (!Value.Check(visionSettingsSchema, parsed)) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
147
151
|
const config = parsed.visionConfig;
|
|
148
|
-
if (config === undefined)
|
|
152
|
+
if (config === undefined) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
149
155
|
const provider = config.provider?.trim() || undefined;
|
|
150
156
|
const defaultProvider = parsed.defaultProvider?.trim() || undefined;
|
|
151
157
|
return {
|
|
@@ -210,9 +216,15 @@ export function buildPendantMarkdown(params: {
|
|
|
210
216
|
|
|
211
217
|
/** 按文件头识别真实图片类型,识别不出返回 undefined */
|
|
212
218
|
function sniffMime(buf: Buffer): string | undefined {
|
|
213
|
-
if (buf.length >= 8 && buf.readUInt32BE(0) === 0x89504e47)
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
if (buf.length >= 8 && buf.readUInt32BE(0) === 0x89504e47) {
|
|
220
|
+
return "image/png";
|
|
221
|
+
}
|
|
222
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
223
|
+
return "image/jpeg";
|
|
224
|
+
}
|
|
225
|
+
if (buf.length >= 6 && buf.toString("ascii", 0, 4) === "GIF8") {
|
|
226
|
+
return "image/gif";
|
|
227
|
+
}
|
|
216
228
|
if (
|
|
217
229
|
buf.length >= 12 &&
|
|
218
230
|
buf.toString("ascii", 0, 4) === "RIFF" &&
|
|
@@ -220,14 +232,18 @@ function sniffMime(buf: Buffer): string | undefined {
|
|
|
220
232
|
) {
|
|
221
233
|
return "image/webp";
|
|
222
234
|
}
|
|
223
|
-
if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d)
|
|
235
|
+
if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d) {
|
|
236
|
+
return "image/bmp";
|
|
237
|
+
}
|
|
224
238
|
return undefined;
|
|
225
239
|
}
|
|
226
240
|
|
|
227
241
|
/** 读取图片文件 → base64 + mimeType + 展示名 */
|
|
228
242
|
function loadImageBytes(path: string): { base64: string; mimeType: string; label: string } {
|
|
229
243
|
const ext = extname(path).toLowerCase();
|
|
230
|
-
if (!IMAGE_EXTS.has(ext))
|
|
244
|
+
if (!IMAGE_EXTS.has(ext)) {
|
|
245
|
+
throw new Error(`不支持的文件格式: ${ext || "(无扩展名)"}`);
|
|
246
|
+
}
|
|
231
247
|
|
|
232
248
|
let stat;
|
|
233
249
|
try {
|
|
@@ -238,8 +254,12 @@ function loadImageBytes(path: string): { base64: string; mimeType: string; label
|
|
|
238
254
|
{ cause: error },
|
|
239
255
|
);
|
|
240
256
|
}
|
|
241
|
-
if (!stat.isFile())
|
|
242
|
-
|
|
257
|
+
if (!stat.isFile()) {
|
|
258
|
+
throw new Error(`不是普通文件: ${path}`);
|
|
259
|
+
}
|
|
260
|
+
if (stat.size === 0) {
|
|
261
|
+
throw new Error(`文件为空: ${path}`);
|
|
262
|
+
}
|
|
243
263
|
if (stat.size > MAX_IMAGE_BYTES) {
|
|
244
264
|
throw new Error(`图片过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),上限 10MB`);
|
|
245
265
|
}
|
|
@@ -293,7 +313,9 @@ export async function callVision(
|
|
|
293
313
|
);
|
|
294
314
|
} catch (error) {
|
|
295
315
|
// 用户主动取消(signal abort)不是失败,转成 VisionAbortError 由调用方处理
|
|
296
|
-
if (error instanceof Error && error.name === "AbortError")
|
|
316
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
317
|
+
throw new VisionAbortError();
|
|
318
|
+
}
|
|
297
319
|
throw error;
|
|
298
320
|
}
|
|
299
321
|
const text = contentText(result.content).trim();
|
|
@@ -325,11 +347,14 @@ export default function visionAgent(pi: ExtensionAPI) {
|
|
|
325
347
|
const hasTool = active.includes(TOOL_NAME);
|
|
326
348
|
if (hasTool && isMultimodal(model)) {
|
|
327
349
|
pi.setActiveTools(active.filter((t) => t !== TOOL_NAME));
|
|
328
|
-
if (notify)
|
|
350
|
+
if (notify) {
|
|
351
|
+
notify(`主模型 ${model?.id ?? "?"} 支持视觉 → 已隐藏 ${TOOL_NAME},图片原生透传`);
|
|
352
|
+
}
|
|
329
353
|
} else if (!hasTool && !isMultimodal(model)) {
|
|
330
354
|
pi.setActiveTools([...active, TOOL_NAME]);
|
|
331
|
-
if (notify)
|
|
355
|
+
if (notify) {
|
|
332
356
|
notify(`主模型 ${model?.id ?? "?"} 不支持视觉 → 已启用 ${TOOL_NAME},由视觉模型代理识别`);
|
|
357
|
+
}
|
|
333
358
|
}
|
|
334
359
|
}
|
|
335
360
|
|
|
@@ -347,7 +372,9 @@ export default function visionAgent(pi: ExtensionAPI) {
|
|
|
347
372
|
// baseUrl/apiKey 由 pi 的模型注册表在调用时解析(execute 里 find),
|
|
348
373
|
// 配置好 settings.json / models.json 后重新加载(/reload)即可生效。
|
|
349
374
|
const visionConfig = loadVisionConfig();
|
|
350
|
-
if (!visionConfig?.model)
|
|
375
|
+
if (!visionConfig?.model) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
351
378
|
|
|
352
379
|
pi.registerTool({
|
|
353
380
|
name: TOOL_NAME,
|
package/src/web/config.ts
CHANGED
|
@@ -20,7 +20,9 @@ export function webSearchConfigPath(): string {
|
|
|
20
20
|
/** ~/.pi/web-search.json 的 search1apiApiKey,或 SEARCH1API_KEY 环境变量。 */
|
|
21
21
|
export async function loadSearch1ApiKey(path = webSearchConfigPath()): Promise<string | undefined> {
|
|
22
22
|
const envKey = process.env.SEARCH1API_KEY;
|
|
23
|
-
if (envKey)
|
|
23
|
+
if (envKey) {
|
|
24
|
+
return envKey;
|
|
25
|
+
}
|
|
24
26
|
try {
|
|
25
27
|
const raw = await readFile(path, "utf8");
|
|
26
28
|
const parsed = Value.Parse(webSearchSchema, JSON.parse(raw));
|
package/src/web/fetch.ts
CHANGED
|
@@ -38,33 +38,65 @@ const MAX_MARKDOWN_BYTES = 100 * 1024;
|
|
|
38
38
|
|
|
39
39
|
function isPrivateIpv4(ip: string): boolean {
|
|
40
40
|
const parts = ip.split(".").map(Number);
|
|
41
|
-
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p)))
|
|
41
|
+
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
42
44
|
const [a, b, c] = parts;
|
|
43
|
-
if (a === 0)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (a ===
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (a ===
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
if (a === 0) {
|
|
46
|
+
return true; // 0.0.0.0/8
|
|
47
|
+
}
|
|
48
|
+
if (a === 10) {
|
|
49
|
+
return true; // 10.0.0.0/8
|
|
50
|
+
}
|
|
51
|
+
if (a === 127) {
|
|
52
|
+
return true; // 127.0.0.0/8 loopback
|
|
53
|
+
}
|
|
54
|
+
if (a === 169 && b === 254) {
|
|
55
|
+
return true; // 169.254.0.0/16 link-local
|
|
56
|
+
}
|
|
57
|
+
if (a === 172 && b >= 16 && b <= 31) {
|
|
58
|
+
return true; // 172.16.0.0/12
|
|
59
|
+
}
|
|
60
|
+
if (a === 192 && b === 168) {
|
|
61
|
+
return true; // 192.168.0.0/16
|
|
62
|
+
}
|
|
63
|
+
if (a === 100 && b >= 64 && b <= 127) {
|
|
64
|
+
return true; // 100.64.0.0/10 CGNAT
|
|
65
|
+
}
|
|
66
|
+
if (a === 198 && (b === 18 || b === 19)) {
|
|
67
|
+
return true; // 198.18.0.0/15 benchmarking
|
|
68
|
+
}
|
|
69
|
+
if (a === 192 && b === 0 && c === 0) {
|
|
70
|
+
return true; // 192.0.0.0/24
|
|
71
|
+
}
|
|
52
72
|
return a >= 224; // 224.0.0.0/3 multicast + reserved
|
|
53
73
|
}
|
|
54
74
|
|
|
55
75
|
function isPrivateIpv6(ip: string): boolean {
|
|
56
76
|
const lower = ip.toLowerCase();
|
|
57
|
-
if (lower === "::" || lower === "::1")
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (lower.startsWith("
|
|
77
|
+
if (lower === "::" || lower === "::1") {
|
|
78
|
+
return true; // unspecified / loopback
|
|
79
|
+
}
|
|
80
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) {
|
|
81
|
+
return true; // fc00::/7 ULA
|
|
82
|
+
}
|
|
83
|
+
if (/^fe[89ab]/.test(lower)) {
|
|
84
|
+
return true; // fe80::/10 link-local
|
|
85
|
+
}
|
|
86
|
+
if (lower.startsWith("::ffff:")) {
|
|
87
|
+
return isPrivateIpv4(lower.slice(7));
|
|
88
|
+
}
|
|
61
89
|
return false;
|
|
62
90
|
}
|
|
63
91
|
|
|
64
92
|
export function isPrivateAddress(ip: string): boolean {
|
|
65
93
|
const version = isIP(ip);
|
|
66
|
-
if (version === 4)
|
|
67
|
-
|
|
94
|
+
if (version === 4) {
|
|
95
|
+
return isPrivateIpv4(ip);
|
|
96
|
+
}
|
|
97
|
+
if (version === 6) {
|
|
98
|
+
return isPrivateIpv6(ip);
|
|
99
|
+
}
|
|
68
100
|
return true; // 非 IP 一律拒绝
|
|
69
101
|
}
|
|
70
102
|
|
|
@@ -181,7 +213,9 @@ export async function fetchPage(url: string, signal?: AbortSignal): Promise<Fetc
|
|
|
181
213
|
const decoder = new TextDecoder();
|
|
182
214
|
for (;;) {
|
|
183
215
|
const chunk = (await reader.read()) as { done: boolean; value: Uint8Array };
|
|
184
|
-
if (chunk.done)
|
|
216
|
+
if (chunk.done) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
185
219
|
body += decoder.decode(chunk.value, { stream: true });
|
|
186
220
|
if (Buffer.byteLength(body, "utf8") > MAX_BYTES) {
|
|
187
221
|
throw new Error(`页面过大,上限 ${MAX_BYTES} bytes`);
|
|
@@ -222,7 +256,9 @@ export async function saveUrlToFile(
|
|
|
222
256
|
const reader = response.body.getReader();
|
|
223
257
|
for (;;) {
|
|
224
258
|
const chunk = (await reader.read()) as { done: boolean; value: Uint8Array };
|
|
225
|
-
if (chunk.done)
|
|
259
|
+
if (chunk.done) {
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
226
262
|
bytes += chunk.value.byteLength;
|
|
227
263
|
if (bytes > MAX_FILE_BYTES) {
|
|
228
264
|
throw new Error(`文件过大,上限 ${MAX_FILE_BYTES} bytes`);
|
|
@@ -248,10 +284,18 @@ export async function saveUrlToFile(
|
|
|
248
284
|
/** 按 mime 主体分类响应;html 走 readability,其余文本类原样返回 */
|
|
249
285
|
function classifyContentType(contentType: string): "html" | "text" | null {
|
|
250
286
|
const mime = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
251
|
-
if (mime === "text/html" || mime === "application/xhtml+xml")
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (mime
|
|
287
|
+
if (mime === "text/html" || mime === "application/xhtml+xml") {
|
|
288
|
+
return "html";
|
|
289
|
+
}
|
|
290
|
+
if (mime.startsWith("text/")) {
|
|
291
|
+
return "text";
|
|
292
|
+
}
|
|
293
|
+
if (mime === "application/json" || mime.endsWith("+json")) {
|
|
294
|
+
return "text";
|
|
295
|
+
}
|
|
296
|
+
if (mime === "application/xml" || mime.endsWith("+xml")) {
|
|
297
|
+
return "text";
|
|
298
|
+
}
|
|
255
299
|
return null;
|
|
256
300
|
}
|
|
257
301
|
|
|
@@ -272,7 +316,9 @@ interface ParsedDocument {
|
|
|
272
316
|
*/
|
|
273
317
|
function unshadowReactStreaming(document: ParsedDocument): void {
|
|
274
318
|
for (const el of document.querySelectorAll("[hidden]")) {
|
|
275
|
-
if (/^S:\d+$/.test(el.id ?? ""))
|
|
319
|
+
if (/^S:\d+$/.test(el.id ?? "")) {
|
|
320
|
+
el.removeAttribute("hidden");
|
|
321
|
+
}
|
|
276
322
|
}
|
|
277
323
|
}
|
|
278
324
|
|
|
@@ -285,7 +331,9 @@ export function extractMarkdown(html: string, sourceUrl: string): FetchedPage {
|
|
|
285
331
|
// linkedom document 的兼容方法,cast 桥接即可
|
|
286
332
|
const article = new Readability(parsed.document).parse();
|
|
287
333
|
let title = article?.title ?? document.title?.trim() ?? sourceUrl;
|
|
288
|
-
if (typeof title !== "string" || title.length === 0)
|
|
334
|
+
if (typeof title !== "string" || title.length === 0) {
|
|
335
|
+
title = sourceUrl;
|
|
336
|
+
}
|
|
289
337
|
let body = article?.content;
|
|
290
338
|
if (!body || body.length === 0) {
|
|
291
339
|
body = document.body?.textContent ?? "";
|
|
@@ -303,7 +351,9 @@ export function extractMarkdown(html: string, sourceUrl: string): FetchedPage {
|
|
|
303
351
|
}
|
|
304
352
|
|
|
305
353
|
function truncateMarkdown(text: string): { text: string; truncated: boolean } {
|
|
306
|
-
if (Buffer.byteLength(text, "utf8") <= MAX_MARKDOWN_BYTES)
|
|
354
|
+
if (Buffer.byteLength(text, "utf8") <= MAX_MARKDOWN_BYTES) {
|
|
355
|
+
return { text, truncated: false };
|
|
356
|
+
}
|
|
307
357
|
const bytes = Buffer.from(text, "utf8");
|
|
308
358
|
const sliced = bytes.subarray(0, MAX_MARKDOWN_BYTES).toString("utf8");
|
|
309
359
|
const cut = sliced.lastIndexOf("\n", sliced.length - 1);
|
package/src/web/search.ts
CHANGED
|
@@ -51,13 +51,17 @@ export interface SearchOptions {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function clampNumResults(value: number | undefined): number {
|
|
54
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
54
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
55
|
+
return 5;
|
|
56
|
+
}
|
|
55
57
|
return Math.max(1, Math.min(Math.floor(value), 50));
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
function normalizeDomain(raw: string): string | undefined {
|
|
59
61
|
const input = raw.trim().toLowerCase().replace(/^-/, "").trim();
|
|
60
|
-
if (!input)
|
|
62
|
+
if (!input) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
61
65
|
try {
|
|
62
66
|
const host = input.includes("://")
|
|
63
67
|
? new URL(input).hostname
|
|
@@ -76,7 +80,9 @@ function splitDomainFilter(domainFilter: string[] | undefined): {
|
|
|
76
80
|
const excludeSites: string[] = [];
|
|
77
81
|
for (const raw of domainFilter ?? []) {
|
|
78
82
|
const domain = normalizeDomain(raw);
|
|
79
|
-
if (!domain)
|
|
83
|
+
if (!domain) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
80
86
|
(raw.trimStart().startsWith("-") ? excludeSites : includeSites).push(domain);
|
|
81
87
|
}
|
|
82
88
|
return { includeSites, excludeSites };
|
|
@@ -90,10 +96,18 @@ export function buildSearchBody(query: string, options: SearchOptions): Record<s
|
|
|
90
96
|
max_results: numResults,
|
|
91
97
|
crawl_results: options.includeContent ? Math.min(numResults, 5) : 0,
|
|
92
98
|
};
|
|
93
|
-
if (options.recencyFilter)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
99
|
+
if (options.recencyFilter) {
|
|
100
|
+
body.time_range = options.recencyFilter;
|
|
101
|
+
}
|
|
102
|
+
if (options.searchService) {
|
|
103
|
+
body.search_service = options.searchService;
|
|
104
|
+
}
|
|
105
|
+
if (includeSites.length > 0) {
|
|
106
|
+
body.include_sites = includeSites;
|
|
107
|
+
}
|
|
108
|
+
if (excludeSites.length > 0) {
|
|
109
|
+
body.exclude_sites = excludeSites;
|
|
110
|
+
}
|
|
97
111
|
return body;
|
|
98
112
|
}
|
|
99
113
|
|
|
@@ -108,14 +122,18 @@ function mapHits(
|
|
|
108
122
|
const hits: SearchHit[] = [];
|
|
109
123
|
for (const item of results) {
|
|
110
124
|
const url = item.link?.trim();
|
|
111
|
-
if (!url)
|
|
125
|
+
if (!url) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
112
128
|
const hit: SearchHit = {
|
|
113
129
|
title: item.title?.trim() || url,
|
|
114
130
|
url,
|
|
115
131
|
snippet: item.snippet?.replaceAll(/\s+/g, " ").trim() || "",
|
|
116
132
|
};
|
|
117
133
|
const content = item.content?.trim();
|
|
118
|
-
if (content)
|
|
134
|
+
if (content) {
|
|
135
|
+
hit.content = content;
|
|
136
|
+
}
|
|
119
137
|
hits.push(hit);
|
|
120
138
|
}
|
|
121
139
|
return hits;
|
|
@@ -232,7 +250,9 @@ export default function webSearchTool(pi: ExtensionAPI): void {
|
|
|
232
250
|
const hits: SearchHit[] = [];
|
|
233
251
|
for (const result of results) {
|
|
234
252
|
for (const hit of result.hits) {
|
|
235
|
-
if (seen.has(hit.url))
|
|
253
|
+
if (seen.has(hit.url)) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
236
256
|
seen.add(hit.url);
|
|
237
257
|
hits.push(hit);
|
|
238
258
|
}
|