@trim21/personal-pi-extensions 0.1.608 → 0.1.612
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/tools.ts +16 -9
- package/src/bwrap/approval-rules.ts +9 -4
- package/src/bwrap/core.ts +12 -0
- package/src/bwrap/mihomo-config.ts +2 -2
- package/src/bwrap/runtime.ts +9 -7
- package/src/lib/cli.ts +58 -52
- package/src/lib/file-reads.ts +9 -4
- package/src/lib/github.ts +37 -22
- package/src/lib/lsp/lsp.ts +42 -32
- 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/edit-engine.ts +39 -33
- package/src/opencode/grep.ts +2 -1
- package/src/opencode/ripgrep.ts +5 -3
- package/src/session-name.ts +7 -13
- package/src/spawn-agent.ts +5 -3
- package/src/talk/core.ts +5 -7
- package/src/talk/mailbox.ts +7 -7
- package/src/talk/policy.ts +6 -5
- package/src/talk/registry.ts +5 -3
- package/src/vision-agent.ts +27 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.612",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"eslint-plugin-import-x": "^4.17.1",
|
|
53
53
|
"eslint-plugin-promise": "7.3.0",
|
|
54
54
|
"eslint-plugin-simple-import-sort": "14.0.0",
|
|
55
|
-
"eslint-plugin-unicorn": "
|
|
55
|
+
"eslint-plugin-unicorn": "76.0.0",
|
|
56
56
|
"eslint-plugin-unused-imports": "4.4.1",
|
|
57
57
|
"husky": "^9.1.7",
|
|
58
58
|
"jiti": "^2.7.0",
|
package/src/aft/tools.ts
CHANGED
|
@@ -152,7 +152,7 @@ export function registerOutlineTool(pi: ExtensionAPI, ctx: AftToolContext): void
|
|
|
152
152
|
}
|
|
153
153
|
const rawArgs = compactArgs({
|
|
154
154
|
target: filesMode ? target : resolved,
|
|
155
|
-
files: filesMode
|
|
155
|
+
files: filesMode || undefined,
|
|
156
156
|
includeTests: params.includeTests,
|
|
157
157
|
});
|
|
158
158
|
|
|
@@ -232,7 +232,7 @@ export function registerZoomTool(pi: ExtensionAPI, ctx: AftToolContext): void {
|
|
|
232
232
|
1,
|
|
233
233
|
Number.MAX_SAFE_INTEGER,
|
|
234
234
|
),
|
|
235
|
-
callgraph: coerceBoolean(params.callgraph)
|
|
235
|
+
callgraph: coerceBoolean(params.callgraph) || undefined,
|
|
236
236
|
});
|
|
237
237
|
|
|
238
238
|
const subtitle = buildZoomSubtitle(extCtx.cwd, params);
|
|
@@ -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
|
/**
|
|
@@ -140,10 +140,15 @@ function collectCommand(node: Node, all: Node[]): BashCommand | undefined {
|
|
|
140
140
|
// 自身且每次返回新 wrapper,`===` 比较失效会无限递归。
|
|
141
141
|
const nested: BashCommand[] = [];
|
|
142
142
|
for (const descendant of all) {
|
|
143
|
-
if (descendant === node)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
if (descendant === node) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (descendant.startIndex < node.startIndex || descendant.endIndex > node.endIndex) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const inner = collectCommand(descendant, all);
|
|
150
|
+
if (inner) {
|
|
151
|
+
nested.push(inner);
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
154
|
return { name, args, raw: node.text, nested };
|
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";
|
|
@@ -208,11 +209,13 @@ class BashOutput {
|
|
|
208
209
|
this.tailBytes -= this.tail[0].length;
|
|
209
210
|
this.tail.shift();
|
|
210
211
|
}
|
|
211
|
-
if (this.tailBytes
|
|
212
|
-
|
|
213
|
-
this.tail[0] = this.tail[0].subarray(this.tailBytes - BASH_TAIL_LIMIT_BYTES);
|
|
214
|
-
this.tailBytes = BASH_TAIL_LIMIT_BYTES;
|
|
212
|
+
if (this.tailBytes <= BASH_TAIL_LIMIT_BYTES || this.tail.length !== 1) {
|
|
213
|
+
return;
|
|
215
214
|
}
|
|
215
|
+
|
|
216
|
+
// 单个 chunk 超过上限:截掉头部,只保留尾部
|
|
217
|
+
this.tail[0] = this.tail[0].subarray(this.tailBytes - BASH_TAIL_LIMIT_BYTES);
|
|
218
|
+
this.tailBytes = BASH_TAIL_LIMIT_BYTES;
|
|
216
219
|
}
|
|
217
220
|
|
|
218
221
|
close(): Promise<void> {
|
|
@@ -830,9 +833,8 @@ export class BwrapRuntime {
|
|
|
830
833
|
if (existsSync(project)) {
|
|
831
834
|
config = JSON.parse(readFileSync(project, "utf8")) as Record<string, unknown>;
|
|
832
835
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
: [];
|
|
836
|
+
// 既有规则按不透明值原样保留(形状不认识也不丢),只追加本次允许的规则。
|
|
837
|
+
const existing = isUnknownArray(config.approvalRules) ? config.approvalRules : [];
|
|
836
838
|
config.approvalRules = [...existing, ...newRules];
|
|
837
839
|
await mkdir(dirname(project), { recursive: true });
|
|
838
840
|
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,22 +294,24 @@ 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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
307
|
+
if (typeof raw !== "string" || f.kind !== "number") {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const n = Number(raw);
|
|
311
|
+
if (Number.isNaN(n)) {
|
|
312
|
+
return errorResult(spec, `Invalid value for '--${f.key}': '${raw}'`);
|
|
308
313
|
}
|
|
314
|
+
rawFlags[f.key] = n;
|
|
309
315
|
}
|
|
310
316
|
|
|
311
317
|
const { min, max } = spec.arity ?? {};
|
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
|
@@ -274,12 +274,13 @@ function validateConfig(config: ResolvedLspConfig, adapters?: LspServerAdapter[]
|
|
|
274
274
|
adapters ? adapters.map((adapter) => adapter.id) : Object.keys(config.servers),
|
|
275
275
|
);
|
|
276
276
|
const unknown = config.enabled === undefined ? [] : [...config.enabled.difference(available)];
|
|
277
|
-
if (unknown.length
|
|
278
|
-
|
|
279
|
-
throw new Error(
|
|
280
|
-
`lsp.json: unknown server id in enabled: ${unknown.join(", ")} (available: ${list})`,
|
|
281
|
-
);
|
|
277
|
+
if (unknown.length === 0) {
|
|
278
|
+
return;
|
|
282
279
|
}
|
|
280
|
+
const list = [...available].toSorted().join(", ") || "none";
|
|
281
|
+
throw new Error(
|
|
282
|
+
`lsp.json: unknown server id in enabled: ${unknown.join(", ")} (available: ${list})`,
|
|
283
|
+
);
|
|
283
284
|
}
|
|
284
285
|
|
|
285
286
|
/** 合并 watch 段:全局为基底、本地逐字段覆盖;ignore 取并集去重(全局在前)。两边都未配置时返回 undefined,调用方据此省略 watch 键。 */
|
|
@@ -370,13 +371,24 @@ export type StatusRenderer = (text: string | undefined) => void;
|
|
|
370
371
|
export type LspInspectQuery = "definition" | "references" | "hover";
|
|
371
372
|
|
|
372
373
|
/**
|
|
373
|
-
* 返回类型与 query
|
|
374
|
-
*
|
|
374
|
+
* 返回类型与 query 关联:query 为 "hover" 时返回 hover 内容,否则返回位置列表。
|
|
375
|
+
* 关联由 `inspect` 的重载签名建立(泛型参数下 TS 无法验证分支与返回体的对应关系,
|
|
376
|
+
* 所以接口按 query 分重载,实现体只需返回这个联合)。
|
|
375
377
|
*/
|
|
376
378
|
export type LspInspectResult<Q extends LspInspectQuery = LspInspectQuery> = Q extends "hover"
|
|
377
379
|
? { serverID: string; query: "hover"; hover: Hover | null }
|
|
378
380
|
: { serverID: string; query: "definition" | "references"; locations: InspectLocation[] };
|
|
379
381
|
|
|
382
|
+
/** 只读符号查询的入参:query 决定返回体(见 LspInspectResult)。line / character 为 0-based。 */
|
|
383
|
+
export interface LspInspectRequest<Q extends LspInspectQuery = LspInspectQuery> {
|
|
384
|
+
file: string;
|
|
385
|
+
cwd: string;
|
|
386
|
+
line: number;
|
|
387
|
+
character: number;
|
|
388
|
+
query: Q;
|
|
389
|
+
options?: LspRequestOptions;
|
|
390
|
+
}
|
|
391
|
+
|
|
380
392
|
export interface LspRequestOptions {
|
|
381
393
|
notify?: ExtensionUIContext["notify"];
|
|
382
394
|
/** 中止时提前结束诊断等待(已中止时直接跳过诊断)。 */
|
|
@@ -427,14 +439,11 @@ export interface LspService {
|
|
|
427
439
|
* 的服务器,按配置顺序取第一个成功结果;服务器不支持该方法(MethodNotFound)
|
|
428
440
|
* 时跳过并尝试下一个,全部不支持时抛聚合错误。line / character 为 0-based。
|
|
429
441
|
*/
|
|
430
|
-
inspect
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
query: Q;
|
|
436
|
-
options?: LspRequestOptions;
|
|
437
|
-
}): Promise<LspInspectResult<Q>>;
|
|
442
|
+
inspect(request: LspInspectRequest<"hover">): Promise<LspInspectResult<"hover">>;
|
|
443
|
+
inspect(
|
|
444
|
+
request: LspInspectRequest<"definition" | "references">,
|
|
445
|
+
): Promise<LspInspectResult<"definition" | "references">>;
|
|
446
|
+
inspect(request: LspInspectRequest): Promise<LspInspectResult>;
|
|
438
447
|
shutdownAll(): Promise<void>;
|
|
439
448
|
/** 停止全部服务器并禁用 LSP:之后工具调用不再 spawn,直到 start/reload。 */
|
|
440
449
|
stop(): Promise<void>;
|
|
@@ -756,14 +765,17 @@ export function createLspService(
|
|
|
756
765
|
updateStatusText();
|
|
757
766
|
const reporter = sessionNotify ?? notify;
|
|
758
767
|
const lastNotified = state.brokenNotifiedAt.get(key);
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
`LSP server "${serverID}" failed to start for ${root}: ${cause}. ` +
|
|
763
|
-
`Fix the issue or run /lsp-reload ${serverID} to retry now.`,
|
|
764
|
-
"error",
|
|
765
|
-
);
|
|
768
|
+
const due = lastNotified === undefined || now - lastNotified >= notifyIntervalMs;
|
|
769
|
+
if (!reporter || !due) {
|
|
770
|
+
return;
|
|
766
771
|
}
|
|
772
|
+
|
|
773
|
+
state.brokenNotifiedAt.set(key, now);
|
|
774
|
+
reporter(
|
|
775
|
+
`LSP server "${serverID}" failed to start for ${root}: ${cause}. ` +
|
|
776
|
+
`Fix the issue or run /lsp-reload ${serverID} to retry now.`,
|
|
777
|
+
"error",
|
|
778
|
+
);
|
|
767
779
|
}
|
|
768
780
|
|
|
769
781
|
/**
|
|
@@ -994,14 +1006,12 @@ export function createLspService(
|
|
|
994
1006
|
}
|
|
995
1007
|
|
|
996
1008
|
/** 只读符号查询:与 rename 同款多服务器策略,但 MethodNotFound 是"跳过"而非失败。 */
|
|
997
|
-
async function inspect
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
options?: LspRequestOptions;
|
|
1004
|
-
}): Promise<LspInspectResult<Q>> {
|
|
1009
|
+
async function inspect(request: LspInspectRequest<"hover">): Promise<LspInspectResult<"hover">>;
|
|
1010
|
+
async function inspect(
|
|
1011
|
+
request: LspInspectRequest<"definition" | "references">,
|
|
1012
|
+
): Promise<LspInspectResult<"definition" | "references">>;
|
|
1013
|
+
async function inspect(request: LspInspectRequest): Promise<LspInspectResult>;
|
|
1014
|
+
async function inspect(request: LspInspectRequest): Promise<LspInspectResult> {
|
|
1005
1015
|
const clients = await getClients(
|
|
1006
1016
|
request.file,
|
|
1007
1017
|
request.cwd,
|
|
@@ -1024,7 +1034,7 @@ export function createLspService(
|
|
|
1024
1034
|
try {
|
|
1025
1035
|
if (request.query === "hover") {
|
|
1026
1036
|
const hover = await client.hover(position);
|
|
1027
|
-
return { serverID: client.serverID, query: "hover", hover }
|
|
1037
|
+
return { serverID: client.serverID, query: "hover", hover };
|
|
1028
1038
|
}
|
|
1029
1039
|
const locations =
|
|
1030
1040
|
request.query === "definition"
|
|
@@ -1034,7 +1044,7 @@ export function createLspService(
|
|
|
1034
1044
|
serverID: client.serverID,
|
|
1035
1045
|
query: request.query,
|
|
1036
1046
|
locations,
|
|
1037
|
-
}
|
|
1047
|
+
};
|
|
1038
1048
|
} catch (error) {
|
|
1039
1049
|
// 调用方取消不是"服务器失败":直接向上抛,别折叠成 all-servers-failed
|
|
1040
1050
|
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
|
+
}
|
|
@@ -119,20 +119,21 @@ const LineTrimmedReplacer: Replacer = function* (content, find) {
|
|
|
119
119
|
break;
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
if (matches) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
122
|
+
if (!matches) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let matchStartIndex = 0;
|
|
126
|
+
for (let k = 0; k < i; k++) {
|
|
127
|
+
matchStartIndex += originalLines[k].length + 1;
|
|
128
|
+
}
|
|
129
|
+
let matchEndIndex = matchStartIndex;
|
|
130
|
+
for (let k = 0; k < searchLines.length; k++) {
|
|
131
|
+
matchEndIndex += originalLines[i + k].length;
|
|
132
|
+
if (k < searchLines.length - 1) {
|
|
133
|
+
matchEndIndex += 1;
|
|
133
134
|
}
|
|
134
|
-
yield content.slice(matchStartIndex, matchEndIndex);
|
|
135
135
|
}
|
|
136
|
+
yield content.slice(matchStartIndex, matchEndIndex);
|
|
136
137
|
}
|
|
137
138
|
};
|
|
138
139
|
|
|
@@ -230,26 +231,29 @@ const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
230
231
|
} else {
|
|
231
232
|
similarity = 1;
|
|
232
233
|
}
|
|
233
|
-
if (similarity
|
|
234
|
-
|
|
235
|
-
bestMatch = candidate;
|
|
234
|
+
if (similarity <= maxSimilarity) {
|
|
235
|
+
continue;
|
|
236
236
|
}
|
|
237
|
+
|
|
238
|
+
maxSimilarity = similarity;
|
|
239
|
+
bestMatch = candidate;
|
|
237
240
|
}
|
|
238
|
-
if (bestMatch
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
241
|
+
if (!bestMatch || maxSimilarity < MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const { startLine, endLine } = bestMatch;
|
|
245
|
+
let matchStartIndex = 0;
|
|
246
|
+
for (let k = 0; k < startLine; k++) {
|
|
247
|
+
matchStartIndex += originalLines[k].length + 1;
|
|
248
|
+
}
|
|
249
|
+
let matchEndIndex = matchStartIndex;
|
|
250
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
251
|
+
matchEndIndex += originalLines[k].length;
|
|
252
|
+
if (k < endLine) {
|
|
253
|
+
matchEndIndex += 1;
|
|
250
254
|
}
|
|
251
|
-
yield content.slice(matchStartIndex, matchEndIndex);
|
|
252
255
|
}
|
|
256
|
+
yield content.slice(matchStartIndex, matchEndIndex);
|
|
253
257
|
};
|
|
254
258
|
|
|
255
259
|
function normalizeWhitespace(text: string): string {
|
|
@@ -370,11 +374,13 @@ const ContextAwareReplacer: Replacer = function* (content, find) {
|
|
|
370
374
|
for (let k = 1; k < blockLines.length - 1; k++) {
|
|
371
375
|
const blockLine = blockLines[k].trim();
|
|
372
376
|
const findLine = findLines[k].trim();
|
|
373
|
-
if (blockLine.length
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
377
|
+
if (blockLine.length === 0 && findLine.length === 0) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
totalNonEmptyLines++;
|
|
382
|
+
if (blockLine === findLine) {
|
|
383
|
+
matchingLines++;
|
|
378
384
|
}
|
|
379
385
|
}
|
|
380
386
|
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
|
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/opencode/ripgrep.ts
CHANGED
|
@@ -88,10 +88,12 @@ export function runRipgrep<R>(
|
|
|
88
88
|
}
|
|
89
89
|
if (item === undefined) return;
|
|
90
90
|
items.push(item);
|
|
91
|
-
if (items.length
|
|
92
|
-
|
|
93
|
-
stop();
|
|
91
|
+
if (items.length <= options.limit) {
|
|
92
|
+
return;
|
|
94
93
|
}
|
|
94
|
+
|
|
95
|
+
state.truncated = true;
|
|
96
|
+
stop();
|
|
95
97
|
};
|
|
96
98
|
|
|
97
99
|
child.stderr.setEncoding("utf8");
|
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("")
|
|
@@ -175,12 +171,10 @@ export function extractFirstUserPrompt(
|
|
|
175
171
|
currentPrompt: string,
|
|
176
172
|
): string | undefined {
|
|
177
173
|
const trimmed = currentPrompt.trim();
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (text) return text;
|
|
183
|
-
}
|
|
174
|
+
for (const entry of branch) {
|
|
175
|
+
if (entry.type !== "message") continue;
|
|
176
|
+
const text = messageText(entry.message?.content);
|
|
177
|
+
if (text) return text;
|
|
184
178
|
}
|
|
185
179
|
return trimmed || undefined;
|
|
186
180
|
}
|
package/src/spawn-agent.ts
CHANGED
|
@@ -286,10 +286,12 @@ export function overrideExtensionPaths(tools: string[]): string[] {
|
|
|
286
286
|
const paths: string[] = [];
|
|
287
287
|
for (const tool of tools) {
|
|
288
288
|
const ext = TOOL_EXTENSION_OVERRIDES[tool];
|
|
289
|
-
if (ext
|
|
290
|
-
|
|
291
|
-
paths.push(extensionPath(ext));
|
|
289
|
+
if (!ext || loaded.has(ext)) {
|
|
290
|
+
continue;
|
|
292
291
|
}
|
|
292
|
+
|
|
293
|
+
loaded.add(ext);
|
|
294
|
+
paths.push(extensionPath(ext));
|
|
293
295
|
}
|
|
294
296
|
return paths;
|
|
295
297
|
}
|
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/policy.ts
CHANGED
|
@@ -68,11 +68,12 @@ export class OutboundPolicy {
|
|
|
68
68
|
this.sentAt.push(this.now());
|
|
69
69
|
this.recentBodies.set(`${body}\u0000${target ?? ""}`, this.now());
|
|
70
70
|
// bound the dedupe map
|
|
71
|
-
if (this.recentBodies.size
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
if (this.recentBodies.size <= 200) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const cutoff = this.now() - DEDUPE_WINDOW_MS;
|
|
75
|
+
for (const [key, ts] of this.recentBodies) {
|
|
76
|
+
if (ts < cutoff) this.recentBodies.delete(key);
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
}
|
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
|
}
|
|
@@ -168,7 +169,8 @@ function pidAlive(pid: number, pidStart?: number): boolean {
|
|
|
168
169
|
}
|
|
169
170
|
if (pidStart === undefined) return true;
|
|
170
171
|
const start = readStartTime(pid);
|
|
171
|
-
|
|
172
|
+
// 读不到启动时间时保守认为「pid 还活着」,只有两边都读到且不同才判定被复用
|
|
173
|
+
return start === undefined || start === pidStart;
|
|
172
174
|
}
|
|
173
175
|
|
|
174
176
|
export function presenceOf(record: AgentRecord): Presence {
|
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
|
/**
|