@trim21/personal-pi-extensions 0.1.608 → 0.1.611
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/tools.ts +14 -7
- package/src/bwrap/core.ts +12 -0
- package/src/bwrap/mihomo-config.ts +2 -2
- package/src/bwrap/runtime.ts +3 -3
- package/src/lib/cli.ts +54 -49
- package/src/lib/file-reads.ts +9 -4
- package/src/lib/github.ts +37 -22
- package/src/lib/lsp/lsp.ts +26 -20
- package/src/lib/lsp/rename.ts +3 -7
- package/src/lib/lsp/server-config.ts +1 -5
- package/src/lib/narrow.ts +15 -0
- package/src/opencode/grep.ts +2 -1
- package/src/session-name.ts +3 -7
- package/src/talk/core.ts +5 -7
- package/src/talk/mailbox.ts +7 -7
- package/src/talk/registry.ts +3 -2
- package/src/vision-agent.ts +27 -16
package/package.json
CHANGED
package/src/aft/tools.ts
CHANGED
|
@@ -427,18 +427,25 @@ export function registerCallgraphTool(pi: ExtensionAPI, ctx: AftToolContext): vo
|
|
|
427
427
|
|
|
428
428
|
/** `BinaryBridge.subscribeStatus` 的能力探测类型:AftProjectTransport 接口上未暴露。 */
|
|
429
429
|
interface StatusSubscribableBridge {
|
|
430
|
-
subscribeStatus
|
|
430
|
+
subscribeStatus(listener: (snapshot: StatusSnapshot) => void): () => void;
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
-
|
|
433
|
+
/**
|
|
434
|
+
* 运行时能力探测:`AftProjectTransport` 接口没声明 subscribeStatus,但实际实现
|
|
435
|
+
* (BinaryBridge)有。用 in 收窄做真检查,而不是断言成「一定支持」。
|
|
436
|
+
*/
|
|
437
|
+
export function isStatusSubscribable(
|
|
438
|
+
bridge: AftProjectTransport,
|
|
439
|
+
): bridge is AftProjectTransport & StatusSubscribableBridge {
|
|
440
|
+
return "subscribeStatus" in bridge && typeof bridge.subscribeStatus === "function";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function subscribeBridgeStatus(
|
|
434
444
|
bridge: AftProjectTransport,
|
|
435
445
|
listener: (snapshot: StatusSnapshot) => void,
|
|
436
446
|
): (() => void) | undefined {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
return undefined;
|
|
440
|
-
}
|
|
441
|
-
return subscribable.subscribeStatus(listener);
|
|
447
|
+
if (!isStatusSubscribable(bridge)) return undefined;
|
|
448
|
+
return bridge.subscribeStatus(listener);
|
|
442
449
|
}
|
|
443
450
|
|
|
444
451
|
/**
|
package/src/bwrap/core.ts
CHANGED
|
@@ -31,6 +31,18 @@ export const NETWORK_MODES = ["block", "limited", "allow-all"] as const;
|
|
|
31
31
|
|
|
32
32
|
export type NetworkMode = (typeof NETWORK_MODES)[number];
|
|
33
33
|
|
|
34
|
+
// 模式集合按 string 装:外部输入(CLI 参数)是 string,用它收窄成字面量联合。
|
|
35
|
+
const FS_MODE_SET: ReadonlySet<string> = new Set(FS_MODES);
|
|
36
|
+
const NETWORK_MODE_SET: ReadonlySet<string> = new Set(NETWORK_MODES);
|
|
37
|
+
|
|
38
|
+
export function isFsMode(value: unknown): value is FsMode {
|
|
39
|
+
return typeof value === "string" && FS_MODE_SET.has(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isNetworkMode(value: unknown): value is NetworkMode {
|
|
43
|
+
return typeof value === "string" && NETWORK_MODE_SET.has(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
const fsConfigProperties = {
|
|
35
47
|
mode: StringEnum(FS_MODES),
|
|
36
48
|
writablePaths: Type.Array(Type.String()),
|
|
@@ -81,8 +81,8 @@ function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
|
81
81
|
const match = /^\[(.+)\](?::(\d+))?$/.exec(entry);
|
|
82
82
|
if (match?.[1] === undefined) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
83
83
|
host = match[1];
|
|
84
|
-
// 端口组 (?::(\d+))? 可选:无端口时
|
|
85
|
-
const portPart = match
|
|
84
|
+
// 端口组 (?::(\d+))? 可选:无端口时 at(2) 是 undefined
|
|
85
|
+
const portPart = match.at(2);
|
|
86
86
|
port = portPart === undefined ? undefined : parsePort(portPart);
|
|
87
87
|
} else {
|
|
88
88
|
const colon = entry.lastIndexOf(":");
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { type TObject, Type } from "typebox";
|
|
|
17
17
|
|
|
18
18
|
import { type CommandSpec, parseCommand } from "../lib/cli.js";
|
|
19
19
|
import { fenceCodeBlock } from "../lib/markdown.js";
|
|
20
|
+
import { isUnknownArray } from "../lib/narrow.js";
|
|
20
21
|
import { formatDisplayPath } from "../lib/path.js";
|
|
21
22
|
import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
|
|
22
23
|
import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
|
|
@@ -830,9 +831,8 @@ export class BwrapRuntime {
|
|
|
830
831
|
if (existsSync(project)) {
|
|
831
832
|
config = JSON.parse(readFileSync(project, "utf8")) as Record<string, unknown>;
|
|
832
833
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
: [];
|
|
834
|
+
// 既有规则按不透明值原样保留(形状不认识也不丢),只追加本次允许的规则。
|
|
835
|
+
const existing = isUnknownArray(config.approvalRules) ? config.approvalRules : [];
|
|
836
836
|
config.approvalRules = [...existing, ...newRules];
|
|
837
837
|
await mkdir(dirname(project), { recursive: true });
|
|
838
838
|
await writeFile(project, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
package/src/lib/cli.ts
CHANGED
|
@@ -32,10 +32,11 @@
|
|
|
32
32
|
* the result so handlers can display it in chat instead of writing stdout.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import type
|
|
35
|
+
import { IsKind, type Static, type TObject, type TSchema } from "typebox";
|
|
36
36
|
import { Value } from "typebox/value";
|
|
37
37
|
|
|
38
38
|
import { shlexSplit } from "./cli-args.js";
|
|
39
|
+
import { isRecord, isUnknownArray } from "./narrow.js";
|
|
39
40
|
|
|
40
41
|
/** Per-flag CLI metadata on top of the typebox schema. */
|
|
41
42
|
export interface FlagMeta {
|
|
@@ -69,16 +70,6 @@ export type CommandResult<TFlags extends TObject> =
|
|
|
69
70
|
| { kind: "help"; text: string }
|
|
70
71
|
| { kind: "error"; text: string };
|
|
71
72
|
|
|
72
|
-
/** Runtime view of a flag schema (typebox's `TSchema` is empty at the type level). */
|
|
73
|
-
interface FlagSchema {
|
|
74
|
-
"~kind"?: string;
|
|
75
|
-
"~optional"?: boolean;
|
|
76
|
-
type?: string;
|
|
77
|
-
default?: unknown;
|
|
78
|
-
description?: string;
|
|
79
|
-
anyOf?: { type?: string; const?: unknown }[];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
73
|
type FlagKind = "boolean" | "string" | "number" | "enum";
|
|
83
74
|
|
|
84
75
|
interface FlagInfo {
|
|
@@ -88,54 +79,67 @@ interface FlagInfo {
|
|
|
88
79
|
required: boolean;
|
|
89
80
|
placeholder: string;
|
|
90
81
|
description: string;
|
|
91
|
-
schema:
|
|
82
|
+
schema: TSchema;
|
|
92
83
|
}
|
|
93
84
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
85
|
+
/** schema 的种类名,仅用于报错文案。 */
|
|
86
|
+
function schemaKindLabel(schema: TSchema): string {
|
|
87
|
+
if ("~kind" in schema && typeof schema["~kind"] === "string") return schema["~kind"];
|
|
88
|
+
if ("type" in schema && typeof schema.type === "string") return schema.type;
|
|
89
|
+
return "unknown";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function kindOf(key: string, schema: TSchema): FlagKind {
|
|
93
|
+
if (IsKind(schema, "Boolean")) return "boolean";
|
|
94
|
+
if (IsKind(schema, "String")) return "string";
|
|
95
|
+
if (IsKind(schema, "Number") || IsKind(schema, "Integer")) return "number";
|
|
96
|
+
if (IsKind(schema, "Union")) return "enum";
|
|
97
|
+
throw new TypeError(
|
|
98
|
+
`Unsupported flag type for '${key}': ${schemaKindLabel(schema)} ` +
|
|
99
|
+
"(use Type.Boolean/String/Number/Integer or a string literal union)",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Type.Optional 的 `~optional` 标记:缺省即「不带该 flag 时不报错」。 */
|
|
104
|
+
function isOptionalFlag(schema: TSchema): boolean {
|
|
105
|
+
return "~optional" in schema && schema["~optional"] === true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** schema 声明的 default:有 default 的 flag 缺省时由 typebox 补值,不算 required。 */
|
|
109
|
+
function flagDefault(schema: TSchema): unknown {
|
|
110
|
+
return "default" in schema ? schema.default : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function flagDescription(schema: TSchema): string {
|
|
114
|
+
return "description" in schema && typeof schema.description === "string"
|
|
115
|
+
? schema.description
|
|
116
|
+
: "";
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
/** Allowed values for a string-literal union flag, or undefined for mixed unions. */
|
|
119
|
-
function enumValues(schema:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
120
|
+
function enumValues(schema: TSchema): string[] | undefined {
|
|
121
|
+
if (!("anyOf" in schema) || !isUnknownArray(schema.anyOf)) return undefined;
|
|
122
|
+
const values: string[] = [];
|
|
123
|
+
for (const variant of schema.anyOf) {
|
|
124
|
+
if (!isRecord(variant) || typeof variant.const !== "string") return undefined;
|
|
125
|
+
values.push(variant.const);
|
|
126
|
+
}
|
|
127
|
+
return values;
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
function buildFlagInfos<TFlags extends TObject>(spec: CommandSpec<TFlags>): FlagInfo[] {
|
|
131
|
+
// flagMeta 的键随 flags 泛型变化(映射类型),运行时按字符串键索引需在此收敛一次。
|
|
127
132
|
const meta = spec.flagMeta as Record<string, FlagMeta> | undefined;
|
|
128
133
|
const infos: FlagInfo[] = [];
|
|
129
|
-
for (const [key,
|
|
130
|
-
const schema = rawSchema as FlagSchema;
|
|
134
|
+
for (const [key, schema] of Object.entries(spec.flags.properties)) {
|
|
131
135
|
const m = meta?.[key];
|
|
132
136
|
infos.push({
|
|
133
137
|
key,
|
|
134
138
|
kind: kindOf(key, schema),
|
|
135
139
|
short: m?.short,
|
|
136
|
-
required: schema
|
|
140
|
+
required: !isOptionalFlag(schema) && flagDefault(schema) === undefined,
|
|
137
141
|
placeholder: m?.valuePlaceholder ?? `<${key}>`,
|
|
138
|
-
description: m?.description ?? schema
|
|
142
|
+
description: m?.description ?? flagDescription(schema),
|
|
139
143
|
schema,
|
|
140
144
|
});
|
|
141
145
|
}
|
|
@@ -290,19 +294,20 @@ export function parseCommand<TFlags extends TObject>(
|
|
|
290
294
|
}
|
|
291
295
|
|
|
292
296
|
for (const f of flags) {
|
|
293
|
-
|
|
297
|
+
const raw = rawFlags[f.key];
|
|
298
|
+
if (typeof raw === "string" && f.kind === "enum") {
|
|
294
299
|
const values = enumValues(f.schema);
|
|
295
|
-
if (values && !values.includes(
|
|
300
|
+
if (values && !values.includes(raw)) {
|
|
296
301
|
return errorResult(
|
|
297
302
|
spec,
|
|
298
|
-
`Invalid value for '--${f.key}': '${
|
|
303
|
+
`Invalid value for '--${f.key}': '${raw}' (expected one of: ${values.join(", ")})`,
|
|
299
304
|
);
|
|
300
305
|
}
|
|
301
306
|
}
|
|
302
|
-
if (
|
|
303
|
-
const n = Number(
|
|
307
|
+
if (typeof raw === "string" && f.kind === "number") {
|
|
308
|
+
const n = Number(raw);
|
|
304
309
|
if (Number.isNaN(n)) {
|
|
305
|
-
return errorResult(spec, `Invalid value for '--${f.key}': '${
|
|
310
|
+
return errorResult(spec, `Invalid value for '--${f.key}': '${raw}'`);
|
|
306
311
|
}
|
|
307
312
|
rawFlags[f.key] = n;
|
|
308
313
|
}
|
package/src/lib/file-reads.ts
CHANGED
|
@@ -16,6 +16,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
16
16
|
import { type Static, Type } from "typebox";
|
|
17
17
|
import { Value } from "typebox/value";
|
|
18
18
|
|
|
19
|
+
import { isRecord } from "./narrow.js";
|
|
20
|
+
|
|
19
21
|
const fileSnapshotSchema = Type.Object({
|
|
20
22
|
digest: Type.String(),
|
|
21
23
|
textEditable: Type.Boolean(),
|
|
@@ -39,8 +41,11 @@ export function snapshotOf(content: Uint8Array | string, textEditable = true): F
|
|
|
39
41
|
/** 整文件指纹:流式读取,避免为记账把大文件整个读进内存。 */
|
|
40
42
|
export async function fileDigest(filePath: string): Promise<string> {
|
|
41
43
|
const hash = createHash("sha256");
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
// createReadStream 的 async iterator 在 @types/node 里是 any:显式声明成 Buffer 流,
|
|
45
|
+
// 而不是在 update 处断言。
|
|
46
|
+
const stream: AsyncIterable<Buffer> = createReadStream(filePath);
|
|
47
|
+
for await (const chunk of stream) {
|
|
48
|
+
hash.update(chunk);
|
|
44
49
|
}
|
|
45
50
|
return hash.digest("hex");
|
|
46
51
|
}
|
|
@@ -150,8 +155,8 @@ export function restoreReads(
|
|
|
150
155
|
for (const entry of sessionManager.getBranch()) {
|
|
151
156
|
if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
|
|
152
157
|
if (!toolNames.has(entry.message.toolName)) continue;
|
|
153
|
-
const details = entry.message.details
|
|
154
|
-
if (!details
|
|
158
|
+
const details = entry.message.details;
|
|
159
|
+
if (!isRecord(details) || !details.reads) continue;
|
|
155
160
|
for (const [filePath, snapshot] of deserializeReads(details.reads)) {
|
|
156
161
|
state.reads.set(filePath, snapshot);
|
|
157
162
|
}
|
package/src/lib/github.ts
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
12
|
|
|
13
13
|
import { Octokit } from "octokit";
|
|
14
|
+
import { type Static, Type } from "typebox";
|
|
15
|
+
|
|
16
|
+
import { parseWithSchema } from "./parse-with-schema.js";
|
|
14
17
|
|
|
15
18
|
export type SearchKind = "issue" | "pr";
|
|
16
19
|
|
|
@@ -25,24 +28,36 @@ export interface SearchParams {
|
|
|
25
28
|
limit?: number;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
/**
|
|
32
|
+
* REST /search/issues 里本客户端消费的 item 形状(issue / PR 联合)。只声明真正
|
|
33
|
+
* 读取的字段,其余键忽略;未声明的字段类型不符时按「响应形状变了」处理,报错而
|
|
34
|
+
* 不是把 undefined 混进结果。labels 兼容字符串与 {name} 两种形态(openapi 里是
|
|
35
|
+
* 联合类型)。
|
|
36
|
+
*/
|
|
37
|
+
const searchItemSchema = Type.Object({
|
|
38
|
+
number: Type.Number(),
|
|
39
|
+
state: Type.Union([Type.Literal("open"), Type.Literal("closed")]),
|
|
40
|
+
title: Type.String(),
|
|
41
|
+
html_url: Type.String(),
|
|
34
42
|
/** The search API exposes the repo as a URL, not as an object. */
|
|
35
|
-
repository_url:
|
|
36
|
-
user: { login:
|
|
37
|
-
labels:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
repository_url: Type.String(),
|
|
44
|
+
user: Type.Union([Type.Object({ login: Type.String() }), Type.Null()]),
|
|
45
|
+
labels: Type.Array(
|
|
46
|
+
Type.Union([Type.String(), Type.Object({ name: Type.Optional(Type.String()) })]),
|
|
47
|
+
),
|
|
48
|
+
milestone: Type.Union([Type.Object({ title: Type.Optional(Type.String()) }), Type.Null()]),
|
|
49
|
+
assignees: Type.Array(Type.Object({ login: Type.String() })),
|
|
50
|
+
comments: Type.Number(),
|
|
51
|
+
created_at: Type.String(),
|
|
52
|
+
updated_at: Type.String(),
|
|
53
|
+
closed_at: Type.Union([Type.String(), Type.Null()]),
|
|
54
|
+
pull_request: Type.Union([
|
|
55
|
+
Type.Object({ merged_at: Type.Optional(Type.Union([Type.String(), Type.Null()])) }),
|
|
56
|
+
Type.Null(),
|
|
57
|
+
]),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type SearchItem = Static<typeof searchItemSchema>;
|
|
46
61
|
|
|
47
62
|
export interface SearchHit {
|
|
48
63
|
number: number;
|
|
@@ -162,21 +177,21 @@ function toDate(iso: string | null | undefined): string {
|
|
|
162
177
|
const REPO_URL_RE = /\/repos\/([^/]+\/[^/]+)$/;
|
|
163
178
|
|
|
164
179
|
/** repository_url looks like https://api.github.com/repos/OWNER/REPO */
|
|
165
|
-
function repoName(raw:
|
|
180
|
+
function repoName(raw: SearchItem): string {
|
|
166
181
|
const match = REPO_URL_RE.exec(raw.repository_url);
|
|
167
182
|
return match?.[1] ?? "";
|
|
168
183
|
}
|
|
169
184
|
|
|
170
|
-
function normalize(raw:
|
|
185
|
+
function normalize(raw: SearchItem): SearchHit {
|
|
171
186
|
const mergedAt = raw.pull_request?.merged_at ?? "";
|
|
172
187
|
return {
|
|
173
188
|
number: raw.number,
|
|
174
|
-
state: mergedAt ? "merged" :
|
|
189
|
+
state: mergedAt ? "merged" : raw.state,
|
|
175
190
|
title: raw.title,
|
|
176
191
|
url: raw.html_url,
|
|
177
192
|
repo: repoName(raw),
|
|
178
193
|
author: raw.user?.login ?? "",
|
|
179
|
-
labels: raw.labels.map((
|
|
194
|
+
labels: raw.labels.map((label) => (typeof label === "string" ? label : (label.name ?? ""))),
|
|
180
195
|
milestone: raw.milestone?.title ?? "",
|
|
181
196
|
assignees: raw.assignees.map((a) => a.login),
|
|
182
197
|
comments: raw.comments,
|
|
@@ -320,7 +335,7 @@ export function createGithubSearch(options: GithubClientOptions = {}): GithubSea
|
|
|
320
335
|
q,
|
|
321
336
|
per_page: limit,
|
|
322
337
|
});
|
|
323
|
-
return data.items.map((item) => normalize(item
|
|
338
|
+
return data.items.map((item) => normalize(parseWithSchema(searchItemSchema, item)));
|
|
324
339
|
} catch (error) {
|
|
325
340
|
const status = (error as { status?: number }).status;
|
|
326
341
|
const message = (error as { message?: string }).message ?? String(error);
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -370,13 +370,24 @@ export type StatusRenderer = (text: string | undefined) => void;
|
|
|
370
370
|
export type LspInspectQuery = "definition" | "references" | "hover";
|
|
371
371
|
|
|
372
372
|
/**
|
|
373
|
-
* 返回类型与 query
|
|
374
|
-
*
|
|
373
|
+
* 返回类型与 query 关联:query 为 "hover" 时返回 hover 内容,否则返回位置列表。
|
|
374
|
+
* 关联由 `inspect` 的重载签名建立(泛型参数下 TS 无法验证分支与返回体的对应关系,
|
|
375
|
+
* 所以接口按 query 分重载,实现体只需返回这个联合)。
|
|
375
376
|
*/
|
|
376
377
|
export type LspInspectResult<Q extends LspInspectQuery = LspInspectQuery> = Q extends "hover"
|
|
377
378
|
? { serverID: string; query: "hover"; hover: Hover | null }
|
|
378
379
|
: { serverID: string; query: "definition" | "references"; locations: InspectLocation[] };
|
|
379
380
|
|
|
381
|
+
/** 只读符号查询的入参:query 决定返回体(见 LspInspectResult)。line / character 为 0-based。 */
|
|
382
|
+
export interface LspInspectRequest<Q extends LspInspectQuery = LspInspectQuery> {
|
|
383
|
+
file: string;
|
|
384
|
+
cwd: string;
|
|
385
|
+
line: number;
|
|
386
|
+
character: number;
|
|
387
|
+
query: Q;
|
|
388
|
+
options?: LspRequestOptions;
|
|
389
|
+
}
|
|
390
|
+
|
|
380
391
|
export interface LspRequestOptions {
|
|
381
392
|
notify?: ExtensionUIContext["notify"];
|
|
382
393
|
/** 中止时提前结束诊断等待(已中止时直接跳过诊断)。 */
|
|
@@ -427,14 +438,11 @@ export interface LspService {
|
|
|
427
438
|
* 的服务器,按配置顺序取第一个成功结果;服务器不支持该方法(MethodNotFound)
|
|
428
439
|
* 时跳过并尝试下一个,全部不支持时抛聚合错误。line / character 为 0-based。
|
|
429
440
|
*/
|
|
430
|
-
inspect
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
query: Q;
|
|
436
|
-
options?: LspRequestOptions;
|
|
437
|
-
}): Promise<LspInspectResult<Q>>;
|
|
441
|
+
inspect(request: LspInspectRequest<"hover">): Promise<LspInspectResult<"hover">>;
|
|
442
|
+
inspect(
|
|
443
|
+
request: LspInspectRequest<"definition" | "references">,
|
|
444
|
+
): Promise<LspInspectResult<"definition" | "references">>;
|
|
445
|
+
inspect(request: LspInspectRequest): Promise<LspInspectResult>;
|
|
438
446
|
shutdownAll(): Promise<void>;
|
|
439
447
|
/** 停止全部服务器并禁用 LSP:之后工具调用不再 spawn,直到 start/reload。 */
|
|
440
448
|
stop(): Promise<void>;
|
|
@@ -994,14 +1002,12 @@ export function createLspService(
|
|
|
994
1002
|
}
|
|
995
1003
|
|
|
996
1004
|
/** 只读符号查询:与 rename 同款多服务器策略,但 MethodNotFound 是"跳过"而非失败。 */
|
|
997
|
-
async function inspect
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
options?: LspRequestOptions;
|
|
1004
|
-
}): Promise<LspInspectResult<Q>> {
|
|
1005
|
+
async function inspect(request: LspInspectRequest<"hover">): Promise<LspInspectResult<"hover">>;
|
|
1006
|
+
async function inspect(
|
|
1007
|
+
request: LspInspectRequest<"definition" | "references">,
|
|
1008
|
+
): Promise<LspInspectResult<"definition" | "references">>;
|
|
1009
|
+
async function inspect(request: LspInspectRequest): Promise<LspInspectResult>;
|
|
1010
|
+
async function inspect(request: LspInspectRequest): Promise<LspInspectResult> {
|
|
1005
1011
|
const clients = await getClients(
|
|
1006
1012
|
request.file,
|
|
1007
1013
|
request.cwd,
|
|
@@ -1024,7 +1030,7 @@ export function createLspService(
|
|
|
1024
1030
|
try {
|
|
1025
1031
|
if (request.query === "hover") {
|
|
1026
1032
|
const hover = await client.hover(position);
|
|
1027
|
-
return { serverID: client.serverID, query: "hover", hover }
|
|
1033
|
+
return { serverID: client.serverID, query: "hover", hover };
|
|
1028
1034
|
}
|
|
1029
1035
|
const locations =
|
|
1030
1036
|
request.query === "definition"
|
|
@@ -1034,7 +1040,7 @@ export function createLspService(
|
|
|
1034
1040
|
serverID: client.serverID,
|
|
1035
1041
|
query: request.query,
|
|
1036
1042
|
locations,
|
|
1037
|
-
}
|
|
1043
|
+
};
|
|
1038
1044
|
} catch (error) {
|
|
1039
1045
|
// 调用方取消不是"服务器失败":直接向上抛,别折叠成 all-servers-failed
|
|
1040
1046
|
request.options?.signal?.throwIfAborted();
|
package/src/lib/lsp/rename.ts
CHANGED
|
@@ -17,6 +17,8 @@ import { fileURLToPath } from "node:url";
|
|
|
17
17
|
|
|
18
18
|
import type { TextEdit, WorkspaceEdit } from "vscode-languageserver-types";
|
|
19
19
|
|
|
20
|
+
import { isRecord } from "../narrow.js";
|
|
21
|
+
|
|
20
22
|
/** 单个文件展开后的编辑结果。 */
|
|
21
23
|
export interface AppliedFileEdit {
|
|
22
24
|
readonly path: string;
|
|
@@ -38,13 +40,7 @@ interface RangeLike {
|
|
|
38
40
|
|
|
39
41
|
/** 编辑联合里 SnippetTextEdit 没有 newText,应用层不支持且无法静默处理。 */
|
|
40
42
|
function isRangeLike(edit: unknown): edit is RangeLike {
|
|
41
|
-
return (
|
|
42
|
-
typeof edit === "object" &&
|
|
43
|
-
edit !== null &&
|
|
44
|
-
"range" in edit &&
|
|
45
|
-
"newText" in edit &&
|
|
46
|
-
typeof (edit as { newText: unknown }).newText === "string"
|
|
47
|
-
);
|
|
43
|
+
return isRecord(edit) && "range" in edit && "newText" in edit && typeof edit.newText === "string";
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
/** file:// URI → 规范化本地路径;非 file scheme 是服务器的意外行为,直接报错。 */
|
|
@@ -25,6 +25,7 @@ import { minimatch } from "minimatch";
|
|
|
25
25
|
import { type Static, Type } from "typebox";
|
|
26
26
|
import { Value } from "typebox/value";
|
|
27
27
|
|
|
28
|
+
import { isRecord } from "../narrow.js";
|
|
28
29
|
import { type LspServerAdapter, type LspServerHandle, type ServerKind } from "./adapter.js";
|
|
29
30
|
import { exists, findBinaryInWorkspace, which } from "./bin.js";
|
|
30
31
|
import { spawnProcess } from "./launch.js";
|
|
@@ -185,11 +186,6 @@ async function runConfigCommand(argv: string[], options: ConfigCommandOptions):
|
|
|
185
186
|
/** 命令 stdout 即 initializationOptions:JSON 对象,经 typebox 校验后作为配置值。 */
|
|
186
187
|
const initializationOptionsOutputSchema = Type.Record(Type.String(), Type.Unknown());
|
|
187
188
|
|
|
188
|
-
/** 纯对象判定(数组与 null 不算),用于深合并与命令输出校验。 */
|
|
189
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
190
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
189
|
/** 深合并 initializationOptions:两侧都是纯对象时逐层递归,其余类型整体覆盖(override 优先)。 */
|
|
194
190
|
function mergeInitializationOptions(
|
|
195
191
|
base: Record<string, unknown> | undefined,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unknown 收窄守卫:外部数据(JSON、SQLite 行、子进程输出、宿主抛出的错误)
|
|
3
|
+
* 在类型层是 unknown,用守卫显式收窄,避免用 `as` 断言把未检查的值伪装成
|
|
4
|
+
* 已知类型。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** 纯对象判定(数组与 null 不算)。 */
|
|
8
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 数组判定。`Array.isArray` 单独用会把 unknown 收成 any[],元素仍是未检查的。 */
|
|
13
|
+
export function isUnknownArray(value: unknown): value is unknown[] {
|
|
14
|
+
return Array.isArray(value);
|
|
15
|
+
}
|
package/src/opencode/grep.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
|
|
22
|
+
import { isRecord } from "../lib/narrow.js";
|
|
22
23
|
import { parseWithSchema } from "../lib/parse-with-schema.js";
|
|
23
24
|
import { didYouMean } from "./files.js";
|
|
24
25
|
import { RIPGREP_RESULT_LIMIT, runRipgrep } from "./ripgrep.js";
|
|
@@ -74,7 +75,7 @@ export function parseGrepRecord(line: string): GrepMatch | undefined {
|
|
|
74
75
|
} catch (error) {
|
|
75
76
|
throw new Error("Invalid ripgrep JSON output", { cause: error });
|
|
76
77
|
}
|
|
77
|
-
if (
|
|
78
|
+
if (!isRecord(json) || json.type !== "match") {
|
|
78
79
|
return undefined;
|
|
79
80
|
}
|
|
80
81
|
const record = parseWithSchema(rawMatchSchema, json);
|
package/src/session-name.ts
CHANGED
|
@@ -36,6 +36,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
36
36
|
import { type Static, Type } from "typebox";
|
|
37
37
|
import { Value } from "typebox/value";
|
|
38
38
|
|
|
39
|
+
import { isRecord } from "./lib/narrow.js";
|
|
40
|
+
|
|
39
41
|
// ── constants ────────────────────────────────────────────────────────────────
|
|
40
42
|
|
|
41
43
|
/** ~/.pi/agent/settings.json:sessionName 配置所在文件 */
|
|
@@ -153,13 +155,7 @@ function messageText(content: unknown): string {
|
|
|
153
155
|
return content
|
|
154
156
|
.map((part: unknown) => {
|
|
155
157
|
if (typeof part === "string") return part;
|
|
156
|
-
if (
|
|
157
|
-
part &&
|
|
158
|
-
typeof part === "object" &&
|
|
159
|
-
typeof (part as Record<string, unknown>).text === "string"
|
|
160
|
-
) {
|
|
161
|
-
return (part as Record<string, unknown>).text as string;
|
|
162
|
-
}
|
|
158
|
+
if (isRecord(part) && typeof part.text === "string") return part.text;
|
|
163
159
|
return "";
|
|
164
160
|
})
|
|
165
161
|
.join("")
|
package/src/talk/core.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* poll — so a swallowed sendMessage error no longer destroys the letter.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { isRecord } from "../lib/narrow.js";
|
|
17
18
|
import { age, formatListing, refusalUnknown, shortAddr } from "./format.js";
|
|
18
19
|
import {
|
|
19
20
|
deleteGroup,
|
|
@@ -101,14 +102,11 @@ export const TALK_JOIN_ENTRY_TYPE = "talk:join";
|
|
|
101
102
|
export function restoreTalkAgentId(branchEntries: readonly unknown[]): string | undefined {
|
|
102
103
|
let agentId: string | undefined;
|
|
103
104
|
for (const entry of branchEntries) {
|
|
104
|
-
if (
|
|
105
|
-
const { type, customType, data } = entry
|
|
106
|
-
type?: unknown;
|
|
107
|
-
customType?: unknown;
|
|
108
|
-
data?: unknown;
|
|
109
|
-
};
|
|
105
|
+
if (!isRecord(entry)) continue;
|
|
106
|
+
const { type, customType, data } = entry;
|
|
110
107
|
if (type !== "custom" || customType !== TALK_JOIN_ENTRY_TYPE) continue;
|
|
111
|
-
|
|
108
|
+
if (!isRecord(data)) continue;
|
|
109
|
+
const recorded = data.agentId;
|
|
112
110
|
if (typeof recorded === "string" && recorded.length > 0) agentId = recorded;
|
|
113
111
|
}
|
|
114
112
|
return agentId;
|
package/src/talk/mailbox.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { randomUUID } from "node:crypto";
|
|
|
18
18
|
import { type Static, Type } from "typebox";
|
|
19
19
|
import { Value } from "typebox/value";
|
|
20
20
|
|
|
21
|
+
import { isRecord } from "../lib/narrow.js";
|
|
21
22
|
import { asksNs, assertAddress, inboxNs } from "./registry.js";
|
|
22
23
|
import type { TalkStorage } from "./storage.js";
|
|
23
24
|
|
|
@@ -49,14 +50,13 @@ export type LetterKind = Letter["kind"];
|
|
|
49
50
|
*/
|
|
50
51
|
export function normalizeLetter(value: unknown): Letter | null {
|
|
51
52
|
if (Value.Check(LetterSchema, value)) return value;
|
|
52
|
-
|
|
53
|
-
const from =
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
if (typeof fromRecord.sessionId !== "string") return null;
|
|
53
|
+
if (!isRecord(value)) return null;
|
|
54
|
+
const from = value.from;
|
|
55
|
+
if (!isRecord(from)) return null;
|
|
56
|
+
if (typeof from.sessionId !== "string") return null;
|
|
57
57
|
const migrated = {
|
|
58
|
-
...
|
|
59
|
-
from: { ...
|
|
58
|
+
...value,
|
|
59
|
+
from: { ...from, agentId: from.sessionId },
|
|
60
60
|
};
|
|
61
61
|
return Value.Check(LetterSchema, migrated) ? migrated : null;
|
|
62
62
|
}
|
package/src/talk/registry.ts
CHANGED
|
@@ -114,8 +114,9 @@ export async function readRecord(storage: TalkStorage, addr: string): Promise<Ag
|
|
|
114
114
|
if (Value.Check(AgentRecordSchema, raw)) return raw;
|
|
115
115
|
// Migrate legacy records in place of the `sessionId` → `agentId` rename.
|
|
116
116
|
if (Value.Check(LegacyAgentRecordSchema, raw)) {
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Value.Check 是类型守卫,raw 已收窄成 legacy 记录的形状,不需要再断言。
|
|
118
|
+
const { sessionId, ...rest } = raw;
|
|
119
|
+
return { ...rest, agentId: sessionId };
|
|
119
120
|
}
|
|
120
121
|
return null;
|
|
121
122
|
}
|
package/src/vision-agent.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "@earendil-works/pi-ai";
|
|
34
34
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
35
35
|
import { Type } from "typebox";
|
|
36
|
+
import { Value } from "typebox/value";
|
|
36
37
|
|
|
37
38
|
import { type ToolPendant } from "./lib/pendant.js";
|
|
38
39
|
|
|
@@ -83,6 +84,21 @@ export interface VisionConfigSettings {
|
|
|
83
84
|
model?: string;
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* ~/.pi/agent/settings.json 里本扩展读取的字段:默认 provider 与视觉模型配置。
|
|
89
|
+
* 未声明的键(packages / defaultModel 等 pi 自己的配置)忽略;声明了的字段类型
|
|
90
|
+
* 不符则整份配置视为不可用,退回「未配置」而不是猜。
|
|
91
|
+
*/
|
|
92
|
+
const visionSettingsSchema = Type.Object({
|
|
93
|
+
defaultProvider: Type.Optional(Type.String()),
|
|
94
|
+
visionConfig: Type.Optional(
|
|
95
|
+
Type.Object({
|
|
96
|
+
provider: Type.Optional(Type.String()),
|
|
97
|
+
model: Type.Optional(Type.String()),
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
});
|
|
101
|
+
|
|
86
102
|
/**
|
|
87
103
|
* 视觉识别所需的模型注册表操作:扩展传 ctx.modelRegistry,测试传 mock。
|
|
88
104
|
* 结构化类型(duck typing),只声明用到的两个方法。
|
|
@@ -121,26 +137,21 @@ export function loadVisionConfig(settingsPath = SETTINGS_PATH): VisionConfigSett
|
|
|
121
137
|
} catch {
|
|
122
138
|
return undefined;
|
|
123
139
|
}
|
|
140
|
+
let parsed: unknown;
|
|
124
141
|
try {
|
|
125
|
-
|
|
126
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
127
|
-
const settings = parsed as Record<string, unknown>;
|
|
128
|
-
const vc = settings.visionConfig;
|
|
129
|
-
if (!vc || typeof vc !== "object" || Array.isArray(vc)) return undefined;
|
|
130
|
-
const config = vc as Record<string, unknown>;
|
|
131
|
-
const provider =
|
|
132
|
-
typeof config.provider === "string" ? config.provider.trim() || undefined : undefined;
|
|
133
|
-
const defaultProvider =
|
|
134
|
-
typeof settings.defaultProvider === "string"
|
|
135
|
-
? settings.defaultProvider.trim() || undefined
|
|
136
|
-
: undefined;
|
|
137
|
-
return {
|
|
138
|
-
provider: provider ?? defaultProvider,
|
|
139
|
-
model: typeof config.model === "string" ? config.model.trim() || undefined : undefined,
|
|
140
|
-
};
|
|
142
|
+
parsed = JSON.parse(raw);
|
|
141
143
|
} catch {
|
|
142
144
|
return undefined;
|
|
143
145
|
}
|
|
146
|
+
if (!Value.Check(visionSettingsSchema, parsed)) return undefined;
|
|
147
|
+
const config = parsed.visionConfig;
|
|
148
|
+
if (config === undefined) return undefined;
|
|
149
|
+
const provider = config.provider?.trim() || undefined;
|
|
150
|
+
const defaultProvider = parsed.defaultProvider?.trim() || undefined;
|
|
151
|
+
return {
|
|
152
|
+
provider: provider ?? defaultProvider,
|
|
153
|
+
model: config.model?.trim() || undefined,
|
|
154
|
+
};
|
|
144
155
|
}
|
|
145
156
|
|
|
146
157
|
/**
|