@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.
- package/package.json +2 -2
- 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 +5 -3
- package/src/bwrap/approval-rules.ts +51 -18
- 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 +36 -14
- 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 +55 -22
- 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 +154 -58
- 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 +63 -41
- 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 +23 -9
- package/src/opencode/todo.ts +3 -1
- package/src/session-name.ts +40 -16
- package/src/spawn-agent-agents.ts +18 -6
- package/src/spawn-agent.ts +58 -21
- 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 +10 -5
- package/src/talk/registry.ts +35 -12
- 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/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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
517
|
+
if (!text) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
500
520
|
return {
|
|
501
521
|
render: (width: number) =>
|
|
502
522
|
truncateToVisualLines(text, Infinity, width, 1).visualLines.map((line) =>
|
package/src/talk/mailbox.ts
CHANGED
|
@@ -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))
|
|
53
|
-
|
|
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))
|
|
56
|
-
|
|
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))
|
|
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))
|
|
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))
|
|
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)
|
|
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"))
|
|
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))
|
|
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
|
}
|
package/src/talk/policy.ts
CHANGED
|
@@ -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)
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
}
|
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,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)
|
|
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 还活着」,只有两边都读到且不同才判定被复用
|
|
185
|
+
return start === undefined || start === pidStart;
|
|
173
186
|
}
|
|
174
187
|
|
|
175
188
|
export function presenceOf(record: AgentRecord): Presence {
|
|
176
|
-
if (record.offline)
|
|
177
|
-
|
|
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))
|
|
215
|
+
if (pidAlive(record.pid, record.pidStart)) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
199
218
|
const quietFor = now - record.lastSeenAt;
|
|
200
|
-
if (quietFor < SWEEP_OFFLINE_GRACE_MS)
|
|
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)
|
|
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));
|
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);
|