@trim21/personal-pi-extensions 0.1.611 → 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.
Files changed (75) hide show
  1. package/package.json +2 -2
  2. package/src/aft/config.ts +9 -3
  3. package/src/aft/index.ts +6 -2
  4. package/src/aft/logger.ts +9 -3
  5. package/src/aft/tools.ts +5 -3
  6. package/src/bwrap/approval-rules.ts +51 -18
  7. package/src/bwrap/approval-suggest.ts +12 -4
  8. package/src/bwrap/core.ts +39 -13
  9. package/src/bwrap/dcg-scan.ts +16 -6
  10. package/src/bwrap/holder.ts +3 -1
  11. package/src/bwrap/mihomo-config.ts +18 -6
  12. package/src/bwrap/network-stack.ts +30 -10
  13. package/src/bwrap/runtime.ts +36 -14
  14. package/src/claude-code/common.ts +6 -2
  15. package/src/claude-code/edit-utils.ts +3 -1
  16. package/src/claude-code/files.ts +40 -14
  17. package/src/claude-code/glob.ts +12 -4
  18. package/src/claude-code/grep.ts +41 -14
  19. package/src/claude-code/session-tools.ts +18 -6
  20. package/src/claude-code/shell.ts +9 -3
  21. package/src/gh/base.ts +72 -24
  22. package/src/gh/tools/download-release-assets.ts +12 -4
  23. package/src/gh/tools/list-releases.ts +3 -1
  24. package/src/gh/tools/list-workflow-runs.ts +9 -3
  25. package/src/gh/tools/read-ci-logs.ts +21 -7
  26. package/src/gh/tools/read-repo.ts +3 -1
  27. package/src/lib/cli-args.ts +6 -2
  28. package/src/lib/cli.ts +55 -22
  29. package/src/lib/file-reads.ts +24 -8
  30. package/src/lib/github.ts +35 -12
  31. package/src/lib/lsp/adapter.ts +6 -2
  32. package/src/lib/lsp/bin.ts +12 -4
  33. package/src/lib/lsp/client.ts +205 -69
  34. package/src/lib/lsp/diagnostic.ts +6 -2
  35. package/src/lib/lsp/inspect-tool.ts +25 -9
  36. package/src/lib/lsp/lsp.ts +154 -58
  37. package/src/lib/lsp/rename-tool.ts +11 -4
  38. package/src/lib/lsp/rename.ts +25 -8
  39. package/src/lib/lsp/server-config.ts +41 -14
  40. package/src/lib/lsp/watcher.ts +51 -17
  41. package/src/lib/path.ts +18 -6
  42. package/src/lib/proc.ts +3 -1
  43. package/src/lib/proxy.ts +9 -3
  44. package/src/lib/request-policy.ts +3 -1
  45. package/src/lib/seq-state.ts +3 -1
  46. package/src/lib/ui.ts +23 -8
  47. package/src/lib/write-guard.ts +21 -7
  48. package/src/openai-cost/config.ts +3 -1
  49. package/src/openai-cost/cost.ts +54 -18
  50. package/src/openai-cost/index.ts +3 -1
  51. package/src/openai-cost/provider.ts +6 -2
  52. package/src/opencode/bash.ts +9 -3
  53. package/src/opencode/edit-engine.ts +63 -41
  54. package/src/opencode/files.ts +30 -10
  55. package/src/opencode/glob.ts +3 -1
  56. package/src/opencode/grep.ts +12 -4
  57. package/src/opencode/question.ts +3 -1
  58. package/src/opencode/ripgrep.ts +23 -9
  59. package/src/opencode/todo.ts +3 -1
  60. package/src/session-name.ts +40 -16
  61. package/src/spawn-agent-agents.ts +18 -6
  62. package/src/spawn-agent.ts +58 -21
  63. package/src/system-prompt/index.ts +9 -3
  64. package/src/talk/core.ts +143 -48
  65. package/src/talk/format.ts +12 -4
  66. package/src/talk/group.ts +9 -3
  67. package/src/talk/index.ts +30 -10
  68. package/src/talk/mailbox.ts +30 -10
  69. package/src/talk/policy.ts +10 -5
  70. package/src/talk/registry.ts +35 -12
  71. package/src/talk/storage.ts +3 -1
  72. package/src/vision-agent.ts +41 -14
  73. package/src/web/config.ts +3 -1
  74. package/src/web/fetch.ts +75 -25
  75. package/src/web/search.ts +30 -10
package/src/talk/index.ts CHANGED
@@ -86,7 +86,9 @@ function readTalkSettings(): { dbPath?: string; deliver?: "steer" | "queue" } {
86
86
  const raw = fs.readFileSync(path.join(getAgentDir(), "settings.json"), "utf8");
87
87
  const parsed = JSON.parse(raw) as { talk?: { db_path?: unknown; deliver?: unknown } };
88
88
  const talk = parsed.talk;
89
- if (!talk) return {};
89
+ if (!talk) {
90
+ return {};
91
+ }
90
92
  const dbPath = typeof talk.db_path === "string" ? talk.db_path : undefined;
91
93
  const deliver = talk.deliver === "steer" || talk.deliver === "queue" ? talk.deliver : undefined;
92
94
  return { dbPath, deliver };
@@ -151,14 +153,18 @@ export default function talk(pi: ExtensionAPI) {
151
153
  });
152
154
 
153
155
  function requireInit(): string | undefined {
154
- if (!core.selfAddr) return "Talk is not initialized (no session_start yet).";
156
+ if (!core.selfAddr) {
157
+ return "Talk is not initialized (no session_start yet).";
158
+ }
155
159
  return undefined;
156
160
  }
157
161
 
158
162
  /** Refresh the talk footer/status-bar text: "alias@group", or "@group" with no explicit alias. */
159
163
  function refreshGroupStatus(ui: ExtensionUIContext): void {
160
164
  void (async () => {
161
- if (!self) return;
165
+ if (!self) {
166
+ return;
167
+ }
162
168
  try {
163
169
  const text = await core.groupStatus();
164
170
  ui.setStatus("talk", text && ui.theme.fg("accent", text));
@@ -205,7 +211,9 @@ export default function talk(pi: ExtensionAPI) {
205
211
  pi.on("session_info_changed", () => {
206
212
  // A name set explicitly via `--name` wins over pi's session title;
207
213
  // otherwise follow pi's session name.
208
- if (self) core.setAgentName(explicitName ?? pi.getSessionName() ?? self.name);
214
+ if (self) {
215
+ core.setAgentName(explicitName ?? pi.getSessionName() ?? self.name);
216
+ }
209
217
  });
210
218
  pi.on("session_shutdown", () => {
211
219
  void core.stop();
@@ -223,7 +231,9 @@ export default function talk(pi: ExtensionAPI) {
223
231
  }),
224
232
  async execute(_toolCallId, params) {
225
233
  const initError = requireInit();
226
- if (initError) return toolResult(initError);
234
+ if (initError) {
235
+ return toolResult(initError);
236
+ }
227
237
  return toolResult(params.cwd ? await core.listCwd(params.cwd) : await core.list());
228
238
  },
229
239
  });
@@ -243,7 +253,9 @@ export default function talk(pi: ExtensionAPI) {
243
253
  }),
244
254
  async execute(_toolCallId, params, signal) {
245
255
  const initError = requireInit();
246
- if (initError) return toolResult(initError);
256
+ if (initError) {
257
+ return toolResult(initError);
258
+ }
247
259
  return toolResult(
248
260
  await core.ask(
249
261
  params.to,
@@ -267,7 +279,9 @@ export default function talk(pi: ExtensionAPI) {
267
279
  }),
268
280
  async execute(_toolCallId, params) {
269
281
  const initError = requireInit();
270
- if (initError) return toolResult(initError);
282
+ if (initError) {
283
+ return toolResult(initError);
284
+ }
271
285
  return toolResult(await core.send(params.to, params.message));
272
286
  },
273
287
  });
@@ -417,7 +431,9 @@ export default function talk(pi: ExtensionAPI) {
417
431
  ctx,
418
432
  async (parsed) => {
419
433
  const agentName = parsed.flags.name?.trim() || undefined;
420
- if (agentName !== undefined) explicitName = agentName;
434
+ if (agentName !== undefined) {
435
+ explicitName = agentName;
436
+ }
421
437
  return core.groupJoin(parsed.args[0], agentName);
422
438
  },
423
439
  { sendToContext: true },
@@ -433,7 +449,9 @@ export default function talk(pi: ExtensionAPI) {
433
449
  ctx,
434
450
  async (parsed) => {
435
451
  const agentName = parsed.flags.name?.trim() || undefined;
436
- if (agentName !== undefined) explicitName = agentName;
452
+ if (agentName !== undefined) {
453
+ explicitName = agentName;
454
+ }
437
455
  return core.groupJoinLast(agentName);
438
456
  },
439
457
  { sendToContext: true },
@@ -496,7 +514,9 @@ export default function talk(pi: ExtensionAPI) {
496
514
  for (const type of [LIST_TYPE, NOTIFY_TYPE]) {
497
515
  pi.registerEntryRenderer<string>(type, (entry, _options, theme) => {
498
516
  const text = typeof entry.data === "string" ? entry.data : "";
499
- if (!text) return;
517
+ if (!text) {
518
+ return;
519
+ }
500
520
  return {
501
521
  render: (width: number) =>
502
522
  truncateToVisualLines(text, Infinity, width, 1).visualLines.map((line) =>
@@ -49,11 +49,19 @@ export type LetterKind = Letter["kind"];
49
49
  * unchanged; anything else returns null.
50
50
  */
51
51
  export function normalizeLetter(value: unknown): Letter | null {
52
- if (Value.Check(LetterSchema, value)) return value;
53
- if (!isRecord(value)) return null;
52
+ if (Value.Check(LetterSchema, value)) {
53
+ return value;
54
+ }
55
+ if (!isRecord(value)) {
56
+ return null;
57
+ }
54
58
  const from = value.from;
55
- if (!isRecord(from)) return null;
56
- if (typeof from.sessionId !== "string") return null;
59
+ if (!isRecord(from)) {
60
+ return null;
61
+ }
62
+ if (typeof from.sessionId !== "string") {
63
+ return null;
64
+ }
57
65
  const migrated = {
58
66
  ...value,
59
67
  from: { ...from, agentId: from.sessionId },
@@ -98,7 +106,9 @@ export function newMessageId(): string {
98
106
  }
99
107
 
100
108
  function assertMessageId(id: string): void {
101
- if (!MESSAGE_ID_PATTERN.test(id)) throw new TypeError(`Invalid talk message id: ${id}`);
109
+ if (!MESSAGE_ID_PATTERN.test(id)) {
110
+ throw new TypeError(`Invalid talk message id: ${id}`);
111
+ }
102
112
  }
103
113
 
104
114
  /** The storage key for a letter: `<ts>-<id>.json`, sorted oldest-first. */
@@ -112,7 +122,9 @@ export function letterFileName(letter: Letter): string {
112
122
 
113
123
  /** Structural validation plus filename safety (id/timestamp shape). */
114
124
  export function isValidLetter(value: unknown): value is Letter {
115
- if (!Value.Check(LetterSchema, value)) return false;
125
+ if (!Value.Check(LetterSchema, value)) {
126
+ return false;
127
+ }
116
128
  try {
117
129
  letterFileName(value);
118
130
  return true;
@@ -153,7 +165,9 @@ export async function listInbox(storage: TalkStorage, addr: string): Promise<Inb
153
165
  for (const fileName of await storage.listKeys(inboxNs(addr))) {
154
166
  const raw = await storage.readJson(inboxNs(addr), fileName);
155
167
  const letter = normalizeLetter(raw);
156
- if (letter && isValidLetter(letter)) out.push({ fileName, letter });
168
+ if (letter && isValidLetter(letter)) {
169
+ out.push({ fileName, letter });
170
+ }
157
171
  // corrupt letters are skipped; the caller may remove them separately
158
172
  }
159
173
  return out; // keys are already sorted by listKeys
@@ -194,7 +208,9 @@ export async function awaitReceipt(
194
208
  while (Date.now() < deadline) {
195
209
  const inbox = await listInbox(storage, toAddr);
196
210
  const stillThere = inbox.some((i) => i.letter.id === letter.id);
197
- if (!stillThere) return "delivered";
211
+ if (!stillThere) {
212
+ return "delivered";
213
+ }
198
214
  await sleep(100);
199
215
  }
200
216
  const inbox = await listInbox(storage, toAddr);
@@ -236,7 +252,9 @@ export async function clearAsk(storage: TalkStorage, addr: string, askId: string
236
252
  export async function outgoingAskIds(storage: TalkStorage, addr: string): Promise<string[]> {
237
253
  const out: string[] = [];
238
254
  for (const key of await storage.listKeys(asksNs(addr))) {
239
- if (!key.startsWith("out-") || !key.endsWith(".json")) continue;
255
+ if (!key.startsWith("out-") || !key.endsWith(".json")) {
256
+ continue;
257
+ }
240
258
  out.push(key.slice("out-".length, -".json".length));
241
259
  }
242
260
  return out;
@@ -271,7 +289,9 @@ export async function readAudit(storage: TalkStorage, limit = 50): Promise<Audit
271
289
  for (const line of await storage.readLog(AUDIT_LOG)) {
272
290
  try {
273
291
  const parsed: unknown = JSON.parse(line);
274
- if (Value.Check(AuditRecordSchema, parsed)) out.push(parsed);
292
+ if (Value.Check(AuditRecordSchema, parsed)) {
293
+ out.push(parsed);
294
+ }
275
295
  } catch {
276
296
  // skip corrupt line — append-only, never fatal
277
297
  }
@@ -53,7 +53,9 @@ export class OutboundPolicy {
53
53
  };
54
54
  }
55
55
  const windowStart = now - RATE_LIMIT_WINDOW_MS;
56
- while (this.sentAt.length > 0 && this.sentAt[0] < windowStart) this.sentAt.shift();
56
+ while (this.sentAt.length > 0 && this.sentAt[0] < windowStart) {
57
+ this.sentAt.shift();
58
+ }
57
59
  if (this.sentAt.length >= RATE_LIMIT_MAX) {
58
60
  return {
59
61
  ok: false,
@@ -68,10 +70,13 @@ export class OutboundPolicy {
68
70
  this.sentAt.push(this.now());
69
71
  this.recentBodies.set(`${body}\u0000${target ?? ""}`, this.now());
70
72
  // bound the dedupe map
71
- if (this.recentBodies.size > 200) {
72
- const cutoff = this.now() - DEDUPE_WINDOW_MS;
73
- for (const [key, ts] of this.recentBodies) {
74
- if (ts < cutoff) this.recentBodies.delete(key);
73
+ if (this.recentBodies.size <= 200) {
74
+ return;
75
+ }
76
+ const cutoff = this.now() - DEDUPE_WINDOW_MS;
77
+ for (const [key, ts] of this.recentBodies) {
78
+ if (ts < cutoff) {
79
+ this.recentBodies.delete(key);
75
80
  }
76
81
  }
77
82
  }
@@ -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)) throw new TypeError(`Invalid talk address: ${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)) return 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)) continue;
133
+ if (!ADDRESS_PATTERN.test(addr)) {
134
+ continue;
135
+ }
130
136
  const record = await readRecord(storage, addr);
131
- if (record) out.push(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,21 +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) return false;
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) return true;
180
+ if (pidStart === undefined) {
181
+ return true;
182
+ }
171
183
  const start = readStartTime(pid);
172
- return start === undefined ? true : start === pidStart;
184
+ // 读不到启动时间时保守认为「pid 还活着」,只有两边都读到且不同才判定被复用
185
+ return start === undefined || start === pidStart;
173
186
  }
174
187
 
175
188
  export function presenceOf(record: AgentRecord): Presence {
176
- if (record.offline) return "offline";
177
- if (!pidAlive(record.pid, record.pidStart)) return "offline";
189
+ if (record.offline) {
190
+ return "offline";
191
+ }
192
+ if (!pidAlive(record.pid, record.pidStart)) {
193
+ return "offline";
194
+ }
178
195
  return "live";
179
196
  }
180
197
 
@@ -195,12 +212,18 @@ export async function sweep(storage: TalkStorage, now: number = Date.now()): Pro
195
212
  for (const record of await listRecords(storage)) {
196
213
  // Without a heartbeat, lastSeenAt only tracks the last event, so an idle
197
214
  // live agent would look long-quiet — never reap a live process.
198
- if (pidAlive(record.pid, record.pidStart)) continue;
215
+ if (pidAlive(record.pid, record.pidStart)) {
216
+ continue;
217
+ }
199
218
  const quietFor = now - record.lastSeenAt;
200
- if (quietFor < SWEEP_OFFLINE_GRACE_MS) continue;
219
+ if (quietFor < SWEEP_OFFLINE_GRACE_MS) {
220
+ continue;
221
+ }
201
222
  const hasMail =
202
223
  (await storage.hasKeys(inboxNs(record.addr))) || (await storage.hasKeys(asksNs(record.addr)));
203
- if (hasMail && quietFor < SWEEP_MAIL_KEEP_MS) continue;
224
+ if (hasMail && quietFor < SWEEP_MAIL_KEEP_MS) {
225
+ continue;
226
+ }
204
227
  await storage.removeNamespace(inboxNs(record.addr));
205
228
  await storage.removeNamespace(asksNs(record.addr));
206
229
  await storage.removeKey(RECORDS_NS, recordKey(record.addr));
@@ -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) return Promise.resolve(null);
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 {
@@ -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) return [];
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)) return undefined;
148
+ if (!Value.Check(visionSettingsSchema, parsed)) {
149
+ return undefined;
150
+ }
147
151
  const config = parsed.visionConfig;
148
- if (config === undefined) return 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) return "image/png";
214
- if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
215
- if (buf.length >= 6 && buf.toString("ascii", 0, 4) === "GIF8") return "image/gif";
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) return "image/bmp";
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)) throw new Error(`不支持的文件格式: ${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()) throw new Error(`不是普通文件: ${path}`);
242
- if (stat.size === 0) throw new Error(`文件为空: ${path}`);
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") throw new VisionAbortError();
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) notify(`主模型 ${model?.id ?? "?"} 支持视觉 → 已隐藏 ${TOOL_NAME},图片原生透传`);
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) return;
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) return 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))) return false;
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) return true; // 0.0.0.0/8
44
- if (a === 10) return true; // 10.0.0.0/8
45
- if (a === 127) return true; // 127.0.0.0/8 loopback
46
- if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
47
- if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
48
- if (a === 192 && b === 168) return true; // 192.168.0.0/16
49
- if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
50
- if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
51
- if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24
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") return true; // unspecified / loopback
58
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7 ULA
59
- if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 link-local
60
- if (lower.startsWith("::ffff:")) return isPrivateIpv4(lower.slice(7));
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) return isPrivateIpv4(ip);
67
- if (version === 6) return isPrivateIpv6(ip);
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) break;
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) break;
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") return "html";
252
- if (mime.startsWith("text/")) return "text";
253
- if (mime === "application/json" || mime.endsWith("+json")) return "text";
254
- if (mime === "application/xml" || mime.endsWith("+xml")) return "text";
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 ?? "")) el.removeAttribute("hidden");
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) title = sourceUrl;
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) return { text, truncated: false };
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);