@trim21/personal-pi-extensions 0.1.605 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.605",
3
+ "version": "0.1.611",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/aft/bridge.ts CHANGED
@@ -26,12 +26,9 @@ import { type AftLogger, createAftLogger } from "./logger.js";
26
26
 
27
27
  export { findBinary } from "@cortexkit/aft-bridge";
28
28
 
29
- /** Pi 会话 ID:Rust 侧用它做 session 作用域(undo/checkpoint),感知工具可留空。 */
30
- export function resolveSessionId(extCtx: ExtensionContext): string | undefined {
31
- const manager = (extCtx as unknown as { sessionManager?: { getSessionId?: () => string } })
32
- .sessionManager;
33
- const id = manager?.getSessionId?.();
34
- return typeof id === "string" && id.length > 0 ? id : undefined;
29
+ /** Pi 会话 ID:Rust 侧用它做 session 作用域(undo/checkpoint)。 */
30
+ export function resolveSessionId(extCtx: ExtensionContext): string {
31
+ return extCtx.sessionManager.getSessionId();
35
32
  }
36
33
 
37
34
  export interface AftPool {
@@ -140,18 +137,20 @@ export async function createAftPool(
140
137
  *
141
138
  * `signal`:宿主取消信号,透传给 bridge 的 abortSignal——standalone transport
142
139
  * 会在 abort 时向 Rust 发 cancel_request(subc route 忽略,靠 route 关闭取消)。
140
+ *
141
+ * `sessionId` 由调用方从自己的 context 解析(工具走 resolveSessionId(ctx)),
142
+ * 独立 CLI 没有 session,传 undefined。
143
143
  */
144
144
  export async function callAftTool(
145
145
  bridge: AftProjectTransport,
146
146
  command: string,
147
147
  rawArgs: Record<string, unknown>,
148
- extCtx: ExtensionContext,
148
+ sessionId: string | undefined,
149
149
  options?: BridgeRequestOptions & { preview?: boolean },
150
150
  softCodes?: ReadonlySet<string>,
151
151
  signal?: AbortSignal,
152
152
  ): Promise<{ text: string; response: Record<string, unknown> }> {
153
153
  const timeoutMs = timeoutForCommand(command);
154
- const sessionId = resolveSessionId(extCtx);
155
154
  const sendOptions = {
156
155
  ...(timeoutMs !== undefined && { timeoutMs }),
157
156
  ...options,
@@ -168,14 +167,14 @@ export async function callAftTool(
168
167
  if (softCodes?.has(code)) {
169
168
  return {
170
169
  text: response.text || response.message || "",
171
- response: response as unknown as Record<string, unknown>,
170
+ response,
172
171
  };
173
172
  }
174
173
  throw new Error(response.text || response.message || `${command} failed`);
175
174
  }
176
175
  return {
177
176
  text: typeof response.text === "string" ? response.text : "",
178
- response: response as unknown as Record<string, unknown>,
177
+ response,
179
178
  };
180
179
  }
181
180
 
package/src/aft/tools.ts CHANGED
@@ -25,7 +25,12 @@ import { Value } from "typebox/value";
25
25
 
26
26
  import { formatDisplayPath, formatSubtitlePath, resolvePathArg } from "../lib/path.js";
27
27
  import { type ToolPendant } from "../lib/pendant.js";
28
- import { type AftState, callAftTool, SEMANTIC_INDEX_WAIT_TIMEOUT_MS } from "./bridge.js";
28
+ import {
29
+ type AftState,
30
+ callAftTool,
31
+ resolveSessionId,
32
+ SEMANTIC_INDEX_WAIT_TIMEOUT_MS,
33
+ } from "./bridge.js";
29
34
 
30
35
  /** 工具使用指南,以 markdown 形式维护,读起来像文档。 */
31
36
  const OUTLINE_PROMPT = readFileSync(
@@ -157,7 +162,7 @@ export function registerOutlineTool(pi: ExtensionAPI, ctx: AftToolContext): void
157
162
  bridgeFor(ctx),
158
163
  "outline",
159
164
  rawArgs,
160
- extCtx,
165
+ resolveSessionId(extCtx),
161
166
  undefined,
162
167
  undefined,
163
168
  signal,
@@ -236,7 +241,7 @@ export function registerZoomTool(pi: ExtensionAPI, ctx: AftToolContext): void {
236
241
  bridgeFor(ctx),
237
242
  "zoom",
238
243
  rawArgs,
239
- extCtx,
244
+ resolveSessionId(extCtx),
240
245
  undefined,
241
246
  undefined,
242
247
  signal,
@@ -343,7 +348,7 @@ export async function callCallgraphWithBuildRetry(
343
348
  bridge,
344
349
  "callgraph",
345
350
  rawArgs,
346
- extCtx,
351
+ resolveSessionId(extCtx),
347
352
  undefined,
348
353
  CALLGRAPH_SOFT_CODES,
349
354
  signal,
@@ -422,18 +427,25 @@ export function registerCallgraphTool(pi: ExtensionAPI, ctx: AftToolContext): vo
422
427
 
423
428
  /** `BinaryBridge.subscribeStatus` 的能力探测类型:AftProjectTransport 接口上未暴露。 */
424
429
  interface StatusSubscribableBridge {
425
- subscribeStatus?(listener: (snapshot: StatusSnapshot) => void): () => void;
430
+ subscribeStatus(listener: (snapshot: StatusSnapshot) => void): () => void;
431
+ }
432
+
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";
426
441
  }
427
442
 
428
- function subscribeBridgeStatus(
443
+ export function subscribeBridgeStatus(
429
444
  bridge: AftProjectTransport,
430
445
  listener: (snapshot: StatusSnapshot) => void,
431
446
  ): (() => void) | undefined {
432
- const subscribable = bridge as AftProjectTransport & StatusSubscribableBridge;
433
- if (typeof subscribable.subscribeStatus !== "function") {
434
- return undefined;
435
- }
436
- return subscribable.subscribeStatus(listener);
447
+ if (!isStatusSubscribable(bridge)) return undefined;
448
+ return bridge.subscribeStatus(listener);
437
449
  }
438
450
 
439
451
  /**
@@ -637,7 +649,7 @@ export function registerSearchTool(pi: ExtensionAPI, ctx: AftToolContext): void
637
649
  bridge,
638
650
  "search",
639
651
  rawArgs,
640
- extCtx,
652
+ resolveSessionId(extCtx),
641
653
  {
642
654
  // 默认 search 传输超时仅 60s,会早于索引等待(600s)触发;覆盖为等待
643
655
  // 上限 + 常规执行预算。超时只说明响应被挤掉而非 bridge 挂死,保留
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+))? 可选:无端口时 match[2] 在运行时是 undefined
85
- const portPart = match[2] as string | undefined;
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(":");
@@ -329,8 +329,7 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
329
329
  // Node 没有公开 API 拿它(stdio[3].fd 恒为 undefined,只能读 _handle,且仅在
330
330
  // 子进程存活期间有效);pipe 要跨两个子进程共享(holder 持写端、slirp4netns
331
331
  // 持读端),所以必须把父进程侧的 fd 重新 dup 给 slirp4netns。
332
- const exitReadFd = (holder.stdio[3] as unknown as { _handle?: { fd?: number } } | null)?._handle
333
- ?.fd;
332
+ const exitReadFd = (holder.stdio[3] as { _handle?: { fd?: number } } | null)?._handle?.fd;
334
333
  if (typeof exitReadFd !== "number") {
335
334
  throw new TypeError("Failed to resolve exit-fd from holder stdio");
336
335
  }
@@ -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
- const existing = Array.isArray(config.approvalRules)
834
- ? (config.approvalRules as ApprovalRule[])
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 { Static, TObject } from "typebox";
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: FlagSchema;
82
+ schema: TSchema;
92
83
  }
93
84
 
94
- function kindOf(key: string, schema: FlagSchema): FlagKind {
95
- switch (schema["~kind"]) {
96
- case "Boolean": {
97
- return "boolean";
98
- }
99
- case "String": {
100
- return "string";
101
- }
102
- case "Number":
103
- case "Integer": {
104
- return "number";
105
- }
106
- case "Union": {
107
- return "enum";
108
- }
109
- default: {
110
- throw new TypeError(
111
- `Unsupported flag type for '${key}': ${schema["~kind"] ?? schema.type ?? "unknown"} ` +
112
- "(use Type.Boolean/String/Number/Integer or a string literal union)",
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: FlagSchema): string[] | undefined {
120
- const anyOf = schema.anyOf;
121
- if (!anyOf) return undefined;
122
- const values = anyOf.map((s) => s.const).filter((c) => typeof c === "string");
123
- return values.length === anyOf.length ? values : undefined;
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, rawSchema] of Object.entries(spec.flags.properties)) {
130
- const schema = rawSchema as unknown 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["~optional"] !== true && schema.default === undefined,
140
+ required: !isOptionalFlag(schema) && flagDefault(schema) === undefined,
137
141
  placeholder: m?.valuePlaceholder ?? `<${key}>`,
138
- description: m?.description ?? schema.description ?? "",
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
- if (f.kind === "enum" && typeof rawFlags[f.key] === "string") {
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(rawFlags[f.key] as string)) {
300
+ if (values && !values.includes(raw)) {
296
301
  return errorResult(
297
302
  spec,
298
- `Invalid value for '--${f.key}': '${String(rawFlags[f.key])}' (expected one of: ${values.join(", ")})`,
303
+ `Invalid value for '--${f.key}': '${raw}' (expected one of: ${values.join(", ")})`,
299
304
  );
300
305
  }
301
306
  }
302
- if (f.kind === "number" && typeof rawFlags[f.key] === "string") {
303
- const n = Number(rawFlags[f.key]);
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}': '${String(rawFlags[f.key])}'`);
310
+ return errorResult(spec, `Invalid value for '--${f.key}': '${raw}'`);
306
311
  }
307
312
  rawFlags[f.key] = n;
308
313
  }
@@ -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
- for await (const chunk of createReadStream(filePath)) {
43
- hash.update(chunk as Buffer);
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 as { reads?: unknown } | undefined;
154
- if (!details?.reads) continue;
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
- /** REST /search/issues response shape we consume (item is an issue/pr union). */
29
- interface RawSearchItem {
30
- number: number;
31
- state: string;
32
- title: string;
33
- html_url: string;
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: string;
36
- user: { login: string } | null;
37
- labels: { name: string }[];
38
- milestone: { title: string } | null;
39
- assignees: { login: string }[];
40
- comments: number;
41
- created_at: string;
42
- updated_at: string;
43
- closed_at: string | null;
44
- pull_request: { merged_at: string | null } | null;
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: RawSearchItem): string {
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: RawSearchItem): SearchHit {
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" : (raw.state as "open" | "closed"),
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((l) => l.name),
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 as unknown as RawSearchItem));
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);
@@ -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 泛型关联:query 为 "hover" 时返回 hover 内容,否则返回
374
- * 位置列表。实现内部用 cast 建立关联(TS 无法验证分支与泛型的对应关系)。
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<Q extends LspInspectQuery>(request: {
431
- file: string;
432
- cwd: string;
433
- line: number;
434
- character: number;
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<Q extends LspInspectQuery>(request: {
998
- file: string;
999
- cwd: string;
1000
- line: number;
1001
- character: number;
1002
- query: Q;
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 } as LspInspectResult<Q>;
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
- } as LspInspectResult<Q>;
1043
+ };
1038
1044
  } catch (error) {
1039
1045
  // 调用方取消不是"服务器失败":直接向上抛,别折叠成 all-servers-failed
1040
1046
  request.options?.signal?.throwIfAborted();
@@ -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
+ }
@@ -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 (typeof json !== "object" || json === null || (json as { type?: unknown }).type !== "match") {
78
+ if (!isRecord(json) || json.type !== "match") {
78
79
  return undefined;
79
80
  }
80
81
  const record = parseWithSchema(rawMatchSchema, json);
@@ -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 (typeof entry !== "object" || entry === null) continue;
105
- const { type, customType, data } = entry as {
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
- const recorded = (data as { agentId?: unknown } | undefined)?.agentId;
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;
@@ -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
- const record = value as { from?: unknown } | null;
53
- const from = record?.from;
54
- if (typeof from !== "object" || from === null) return null;
55
- const fromRecord = from as Record<string, unknown>;
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
- ...(value as object),
59
- from: { ...fromRecord, agentId: fromRecord.sessionId },
58
+ ...value,
59
+ from: { ...from, agentId: from.sessionId },
60
60
  };
61
61
  return Value.Check(LetterSchema, migrated) ? migrated : null;
62
62
  }
@@ -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
- const { sessionId, ...rest } = raw as { sessionId: string } & Record<string, unknown>;
118
- return { ...rest, agentId: sessionId } as AgentRecord;
117
+ // Value.Check 是类型守卫,raw 已收窄成 legacy 记录的形状,不需要再断言。
118
+ const { sessionId, ...rest } = raw;
119
+ return { ...rest, agentId: sessionId };
119
120
  }
120
121
  return null;
121
122
  }
@@ -92,7 +92,7 @@ export class SqliteTalkStorage implements TalkStorage {
92
92
  listKeys(namespace: string): Promise<string[]> {
93
93
  const rows = this.db
94
94
  .prepare("SELECT key FROM talk_kv WHERE namespace = ? ORDER BY key")
95
- .all(namespace) as unknown as { key: string }[];
95
+ .all(namespace) as { key: string }[];
96
96
  return Promise.resolve(rows.map((r) => r.key));
97
97
  }
98
98
 
@@ -142,7 +142,7 @@ export class SqliteTalkStorage implements TalkStorage {
142
142
  readLog(logName: string): Promise<string[]> {
143
143
  const rows = this.db
144
144
  .prepare("SELECT line FROM talk_log WHERE name = ? ORDER BY seq")
145
- .all(logName) as unknown as { line: string }[];
145
+ .all(logName) as { line: string }[];
146
146
  return Promise.resolve(rows.map((r) => r.line));
147
147
  }
148
148
  }
@@ -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
- const parsed: unknown = JSON.parse(raw);
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
  /**