decorated-pi 0.2.2 → 0.4.0
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/README.md +82 -74
- package/extensions/file-times.ts +124 -0
- package/extensions/guidance.ts +5 -3
- package/extensions/index.ts +6 -2
- package/extensions/io.ts +587 -0
- package/extensions/lsp/client.ts +181 -428
- package/extensions/lsp/env.ts +45 -12
- package/extensions/lsp/format.ts +102 -237
- package/extensions/lsp/index.ts +8 -11
- package/extensions/lsp/manager.ts +249 -0
- package/extensions/lsp/prompt.ts +3 -42
- package/extensions/lsp/protocol.ts +219 -0
- package/extensions/lsp/servers.ts +80 -160
- package/extensions/lsp/tools.ts +175 -510
- package/extensions/lsp/types.ts +42 -0
- package/extensions/mcp/builtin.ts +126 -0
- package/extensions/mcp/client.ts +106 -0
- package/extensions/mcp/index.ts +123 -0
- package/extensions/{extend-model.ts → model-integration.ts} +127 -4
- package/extensions/patch.ts +842 -0
- package/extensions/providers/ark-coding.ts +2 -0
- package/extensions/safety/detect.ts +78 -707
- package/extensions/safety/entropy.ts +226 -0
- package/extensions/safety/index.ts +44 -97
- package/extensions/safety/patterns.ts +155 -0
- package/extensions/safety/types.ts +50 -0
- package/extensions/settings.ts +10 -0
- package/extensions/slash.ts +165 -9
- package/extensions/smart-at.ts +339 -111
- package/extensions/subdir-agents.ts +43 -13
- package/package.json +3 -4
- package/tsconfig.json +16 -0
- package/extensions/lsp/server-manager.ts +0 -309
- package/extensions/lsp/trust.ts +0 -45
package/extensions/smart-at.ts
CHANGED
|
@@ -1,175 +1,381 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Smart @ - 高速文件搜索自动补全
|
|
3
3
|
*
|
|
4
|
+
* 设计:
|
|
5
|
+
* 1. 缓存生命周期绑定用户交互: 触发 @ 时收集+缓存, 选择/取消时清除
|
|
6
|
+
* 2. 收集时预计算路径惩罚(路径固有,跟 query 无关)
|
|
7
|
+
* 3. 惩罚分级消费: 每个文件只命中最高级别的一档惩罚,不叠加
|
|
8
|
+
* 4. 搜索时只算匹配分, 总分 = 匹配分 + 惩罚分
|
|
9
|
+
*
|
|
10
|
+
* 候选收集:
|
|
11
|
+
* - Git 仓库: git ls-files 列出文件, 从文件路径推导目录
|
|
12
|
+
* - 非 Git 仓库: fd 列出文件和目录
|
|
13
|
+
*
|
|
14
|
+
* 惩罚来源:
|
|
15
|
+
* - Git 仓库: .gitignore 规则(动态) + 静态规则
|
|
16
|
+
* - 非 Git 仓库: 仅静态规则
|
|
17
|
+
*
|
|
18
|
+
* 惩罚分级(从高到低,首次命中即消费,不叠加):
|
|
19
|
+
* Tier 1 (-400): 匹配 .gitignore 规则(仅 git 仓库)
|
|
20
|
+
* Tier 2 (-300): 在 .* 或 __* 目录下
|
|
21
|
+
* Tier 3 (-200): 在已知噪音目录下(build/dist/coverage 等)
|
|
22
|
+
* Tier 4 (-150~-80): 坏扩展名(二进制/编译产物/媒体文件)
|
|
23
|
+
* Base (always): -depth*30 - name.length
|
|
24
|
+
*
|
|
25
|
+
* 匹配:
|
|
26
|
+
* - 大小写敏感
|
|
27
|
+
* - 目录优先
|
|
28
|
+
*
|
|
4
29
|
* 【重要】applyCompletion / shouldTriggerFileCompletion 必须用 .bind(orig)
|
|
5
|
-
* 不能用箭头函数!Pi editor 会做原型检查,新函数导致扩展崩溃。
|
|
30
|
+
* 不能用箭头函数! Pi editor 会做原型检查,新函数导致扩展崩溃。
|
|
6
31
|
*/
|
|
7
32
|
|
|
8
33
|
import { spawnSync } from "child_process";
|
|
34
|
+
import { existsSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
9
36
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
37
|
|
|
11
38
|
// ═══════════════════════════════════════════════════════════
|
|
12
|
-
//
|
|
39
|
+
// 类型
|
|
40
|
+
// ═══════════════════════════════════════════════════════════
|
|
41
|
+
|
|
42
|
+
type PenaltyTier = 0 | 1 | 2 | 3 | 4;
|
|
43
|
+
|
|
44
|
+
interface FileCandidate {
|
|
45
|
+
path: string; // 相对路径 "extensions/lsp/tools.ts"
|
|
46
|
+
name: string; // 文件名 "tools.ts"
|
|
47
|
+
isDir: boolean;
|
|
48
|
+
tier: PenaltyTier; // 语义分级,用于空查询过滤
|
|
49
|
+
penalty: number; // 预计算: tierPenalty + basePenalty
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ═══════════════════════════════════════════════════════════
|
|
53
|
+
// 硬排除 & 惩罚规则
|
|
13
54
|
// ═══════════════════════════════════════════════════════════
|
|
14
55
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
56
|
+
/** 硬排除: 不枚举其子文件/子目录,但目录本身保留为候选 */
|
|
57
|
+
const HARD_EXCLUDE_DIRS = new Set(["node_modules", ".git", ".pnpm", ".svn"]);
|
|
58
|
+
|
|
59
|
+
/** Tier 3: 已知噪音目录(不以 . 开头,但通常是构建产物) */
|
|
60
|
+
const BAD_DIRS = new Set(["build", "dist", "coverage", "out", "target"]);
|
|
61
|
+
|
|
62
|
+
/** Tier 4: 扩展名惩罚(值为负数) */
|
|
63
|
+
const EXT_PENALTY: Record<string, number> = {
|
|
64
|
+
o: -150, obj: -150, a: -150, so: -150, dll: -150, exe: -150,
|
|
65
|
+
wasm: -150, class: -120, pyc: -120,
|
|
66
|
+
bmp: -100, png: -100, jpg: -100, gif: -100, ico: -100, svg: -80,
|
|
67
|
+
mp3: -100, wav: -100, mp4: -100, avi: -100,
|
|
68
|
+
pdf: -100, zip: -100, tar: -100, gz: -100,
|
|
69
|
+
lock: -80,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ═══════════════════════════════════════════════════════════
|
|
73
|
+
// 惩罚预计算(分级消费,首次命中即停)
|
|
74
|
+
// ═══════════════════════════════════════════════════════════
|
|
75
|
+
|
|
76
|
+
interface PenaltyMeta {
|
|
77
|
+
tier: PenaltyTier;
|
|
78
|
+
penalty: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function computePenaltyMeta(filePath: string, isDir: boolean, gitIgnored: boolean): PenaltyMeta {
|
|
82
|
+
const parts = filePath.replace(/\/$/, "").split("/");
|
|
83
|
+
const name = parts[parts.length - 1] || filePath;
|
|
84
|
+
const ext = (!isDir && name.includes(".")) ? (name.split(".").pop()?.toLowerCase() || "") : "";
|
|
85
|
+
const depth = parts.length;
|
|
19
86
|
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
87
|
+
// 文件: 检查父目录段; 目录: 检查所有段(含自身)
|
|
88
|
+
const dirSegments = isDir ? parts : parts.slice(0, -1);
|
|
89
|
+
|
|
90
|
+
let tier: PenaltyTier = 0;
|
|
91
|
+
let tierPenalty = 0;
|
|
92
|
+
|
|
93
|
+
if (gitIgnored) {
|
|
94
|
+
tier = 1;
|
|
95
|
+
tierPenalty = -400;
|
|
96
|
+
} else if (dirSegments.some(d => d.startsWith(".") || d.startsWith("__"))) {
|
|
97
|
+
tier = 2;
|
|
98
|
+
tierPenalty = -300;
|
|
99
|
+
} else if (dirSegments.some(d => BAD_DIRS.has(d))) {
|
|
100
|
+
tier = 3;
|
|
101
|
+
tierPenalty = -200;
|
|
102
|
+
} else if (!isDir && (EXT_PENALTY[ext] ?? 0) < 0) {
|
|
103
|
+
tier = 4;
|
|
104
|
+
tierPenalty = EXT_PENALTY[ext]!;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const basePenalty = -(depth * 30) - name.length;
|
|
108
|
+
return { tier, penalty: tierPenalty + basePenalty };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function computePenalty(filePath: string, isDir: boolean, gitIgnored: boolean): number {
|
|
112
|
+
return computePenaltyMeta(filePath, isDir, gitIgnored).penalty;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ═══════════════════════════════════════════════════════════
|
|
116
|
+
// 候选收集
|
|
117
|
+
// ═══════════════════════════════════════════════════════════
|
|
118
|
+
|
|
119
|
+
const SPAWN_OPTS = { timeout: 5000, encoding: "utf-8" as const, maxBuffer: 10 * 1024 * 1024 };
|
|
120
|
+
|
|
121
|
+
function collectCandidates(cwd: string): FileCandidate[] {
|
|
122
|
+
const candidates: FileCandidate[] = [];
|
|
123
|
+
|
|
124
|
+
// 判断路径是否为硬排除目录的子项(不含硬排除目录本身)
|
|
125
|
+
const isChildOfHardExclude = (p: string): boolean => {
|
|
126
|
+
const parts = p.replace(/\/$/, "").split("/");
|
|
127
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
128
|
+
if (HARD_EXCLUDE_DIRS.has(parts[i]!)) return true;
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const opts = { ...SPAWN_OPTS, cwd };
|
|
134
|
+
|
|
135
|
+
// ── 检测是否 git 仓库 ──
|
|
136
|
+
const isGit = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], opts).status === 0;
|
|
137
|
+
|
|
138
|
+
if (isGit) {
|
|
139
|
+
collectGit(candidates, opts, isChildOfHardExclude);
|
|
140
|
+
} else {
|
|
141
|
+
collectFd(candidates, cwd, isChildOfHardExclude);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return candidates;
|
|
145
|
+
}
|
|
23
146
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
147
|
+
/** Git 仓库: git ls-files 列出文件, 从文件路径推导目录 */
|
|
148
|
+
function collectGit(
|
|
149
|
+
candidates: FileCandidate[],
|
|
150
|
+
opts: { timeout: number; encoding: "utf-8"; maxBuffer: number; cwd: string },
|
|
151
|
+
isChildOfHardExclude: (p: string) => boolean,
|
|
152
|
+
) {
|
|
153
|
+
// 可见文件(tracked + untracked 非 ignored)
|
|
154
|
+
const r1 = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], opts);
|
|
155
|
+
const visibleFiles = (r1.status === 0 && r1.stdout)
|
|
156
|
+
? r1.stdout.trim().split("\n").filter(Boolean)
|
|
157
|
+
: [];
|
|
27
158
|
|
|
28
|
-
//
|
|
159
|
+
// ignored 项(目录优先聚合)
|
|
160
|
+
const r2 = spawnSync(
|
|
161
|
+
"git",
|
|
162
|
+
["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--no-empty-directory"],
|
|
163
|
+
opts,
|
|
164
|
+
);
|
|
165
|
+
const ignoredDirs = new Set<string>();
|
|
166
|
+
const ignoredFiles: string[] = [];
|
|
167
|
+
if (r2.status === 0 && r2.stdout) {
|
|
168
|
+
for (const raw of r2.stdout.trim().split("\n").filter(Boolean)) {
|
|
169
|
+
const entry = raw.replace(/^\.\//, "");
|
|
170
|
+
if (isChildOfHardExclude(entry)) continue;
|
|
171
|
+
if (entry.endsWith("/")) {
|
|
172
|
+
ignoredDirs.add(entry.replace(/\/$/, ""));
|
|
173
|
+
} else {
|
|
174
|
+
ignoredFiles.push(entry);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const hasIgnoredAncestor = (p: string): boolean => {
|
|
180
|
+
const parts = p.split("/");
|
|
181
|
+
let current = "";
|
|
182
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
183
|
+
current = current ? `${current}/${parts[i]}` : parts[i]!;
|
|
184
|
+
if (ignoredDirs.has(current)) return true;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const ignoredFileSet = new Set(ignoredFiles);
|
|
190
|
+
|
|
191
|
+
// ── 可见文件 → 候选 ──
|
|
192
|
+
const dirSet = new Set<string>();
|
|
193
|
+
for (const f of visibleFiles) {
|
|
194
|
+
if (isChildOfHardExclude(f)) continue;
|
|
195
|
+
const name = f.split("/").pop() || f;
|
|
196
|
+
const meta = computePenaltyMeta(f, false, false);
|
|
197
|
+
candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
|
|
198
|
+
// 推导目录
|
|
199
|
+
const parts = f.split("/");
|
|
200
|
+
let current = "";
|
|
201
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
202
|
+
current = current ? `${current}/${parts[i]}` : parts[i]!;
|
|
203
|
+
dirSet.add(current);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── ignored 文件 → 候选 ──
|
|
208
|
+
for (const f of ignoredFiles) {
|
|
209
|
+
const name = f.split("/").pop() || f;
|
|
210
|
+
const meta = computePenaltyMeta(f, false, true);
|
|
211
|
+
candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── 推导的可见目录 → 候选 ──
|
|
215
|
+
for (const d of dirSet) {
|
|
216
|
+
const name = d.split("/").pop() || d;
|
|
217
|
+
const meta = computePenaltyMeta(d, true, false);
|
|
218
|
+
candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── ignored 目录 → 候选 ──
|
|
222
|
+
for (const d of ignoredDirs) {
|
|
223
|
+
const name = d.split("/").pop() || d;
|
|
224
|
+
const meta = computePenaltyMeta(d, true, true);
|
|
225
|
+
candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── 硬排除目录本身 → 候选(仅存在时加入) ──
|
|
229
|
+
for (const hd of HARD_EXCLUDE_DIRS) {
|
|
230
|
+
if (!existsSync(join(opts.cwd, hd))) continue;
|
|
231
|
+
const meta = computePenaltyMeta(hd, true, false);
|
|
232
|
+
candidates.push({ path: hd + "/", name: hd, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 非 Git 仓库: fd 列出文件和目录, 仅用静态规则惩罚 */
|
|
237
|
+
function collectFd(
|
|
238
|
+
candidates: FileCandidate[],
|
|
239
|
+
cwd: string,
|
|
240
|
+
isChildOfHardExclude: (p: string) => boolean,
|
|
241
|
+
) {
|
|
29
242
|
const rel = (s: string) => {
|
|
30
243
|
let r = s.startsWith(cwd + "/") ? s.slice(cwd.length + 1) : s;
|
|
31
244
|
return r.startsWith("./") ? r.slice(2) : r;
|
|
32
245
|
};
|
|
33
246
|
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
247
|
+
const fdExcludes = [...HARD_EXCLUDE_DIRS].flatMap(d => ["--exclude", d]);
|
|
248
|
+
const fdOpts = { ...SPAWN_OPTS, cwd: undefined as string | undefined };
|
|
249
|
+
|
|
250
|
+
// 文件(排除硬排除目录的子文件)
|
|
251
|
+
const r1 = spawnSync("fd", ["--type", "f", "--hidden", "--no-ignore", ...fdExcludes, ".", cwd], fdOpts);
|
|
252
|
+
if (r1.status === 0 && r1.stdout) {
|
|
253
|
+
for (const raw of r1.stdout.trim().split("\n").filter(Boolean)) {
|
|
254
|
+
const f = rel(raw);
|
|
255
|
+
const name = f.split("/").pop() || f;
|
|
256
|
+
const meta = computePenaltyMeta(f, false, false);
|
|
257
|
+
candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
|
|
258
|
+
}
|
|
39
259
|
}
|
|
40
260
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
261
|
+
// 目录(排除硬排除目录的子目录)
|
|
262
|
+
const r2 = spawnSync("fd", ["--type", "d", "--hidden", "--no-ignore", ...fdExcludes, ".", cwd], fdOpts);
|
|
263
|
+
if (r2.status === 0 && r2.stdout) {
|
|
264
|
+
for (const raw of r2.stdout.trim().split("\n").filter(Boolean)) {
|
|
265
|
+
const d = rel(raw).replace(/\/$/, "");
|
|
266
|
+
const name = d.split("/").pop() || d;
|
|
267
|
+
const meta = computePenaltyMeta(d, true, false);
|
|
268
|
+
candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
269
|
+
}
|
|
45
270
|
}
|
|
46
|
-
const fdDirs = spawnSync("fd", ["--type", "d", "--hidden", ".", cwd], { ...opts, cwd: undefined });
|
|
47
|
-
if (fdDirs.status === 0 && fdDirs.stdout) dirs = fdDirs.stdout.trim().split("\n").filter(Boolean).map(rel);
|
|
48
271
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
272
|
+
// 硬排除目录本身 → 候选(仅存在时加入)
|
|
273
|
+
for (const hd of HARD_EXCLUDE_DIRS) {
|
|
274
|
+
if (!existsSync(join(cwd, hd))) continue;
|
|
275
|
+
const meta = computePenaltyMeta(hd, true, false);
|
|
276
|
+
candidates.push({ path: hd + "/", name: hd, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
277
|
+
}
|
|
54
278
|
}
|
|
55
279
|
|
|
56
280
|
// ═══════════════════════════════════════════════════════════
|
|
57
|
-
//
|
|
281
|
+
// 匹配评分(query 相关,大小写敏感)
|
|
58
282
|
// ═══════════════════════════════════════════════════════════
|
|
59
283
|
|
|
60
|
-
|
|
61
|
-
o: -500, obj: -500, a: -500, so: -500, dll: -500, exe: -500,
|
|
62
|
-
wasm: -500, class: -400, pyc: -400,
|
|
63
|
-
bmp: -200, png: -200, jpg: -200, gif: -200, ico: -200, svg: -100,
|
|
64
|
-
mp3: -200, wav: -200, mp4: -200, avi: -200,
|
|
65
|
-
pdf: -200, zip: -200, tar: -200, gz: -200,
|
|
66
|
-
lock: -100, json: 0, yml: 0, yaml: 0, toml: 0,
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const BAD_DIRS = ["node_modules", ".obj", "build", "dist"];
|
|
70
|
-
|
|
71
|
-
// 真模糊匹配(字符可以不连续)
|
|
284
|
+
/** 模糊匹配(大小写敏感,字符可以不连续) */
|
|
72
285
|
function fuzzyScore(text: string, query: string): number {
|
|
73
|
-
const t = text.toLowerCase(), q = query.toLowerCase();
|
|
74
286
|
let qi = 0, firstMatch = -1, lastMatch = -1;
|
|
75
|
-
for (let ti = 0; ti <
|
|
76
|
-
if (
|
|
287
|
+
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
|
|
288
|
+
if (text[ti] === query[qi]) {
|
|
77
289
|
if (firstMatch < 0) firstMatch = ti;
|
|
78
290
|
lastMatch = ti;
|
|
79
291
|
qi++;
|
|
80
292
|
}
|
|
81
293
|
}
|
|
82
|
-
if (qi <
|
|
83
|
-
const span = lastMatch - firstMatch + 1;
|
|
84
|
-
|
|
85
|
-
// 跨度小 + 文件名短 = 高分
|
|
86
|
-
return Math.max(10, 200 - span * 3 - totalLen);
|
|
294
|
+
if (qi < query.length) return 0;
|
|
295
|
+
const span = lastMatch - firstMatch + 1;
|
|
296
|
+
return Math.max(10, 200 - span * 3 - text.length);
|
|
87
297
|
}
|
|
88
298
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
const name = parts[parts.length - 1] || cleaned;
|
|
299
|
+
/** 计算单个候选对 query 的匹配分(大小写敏感) */
|
|
300
|
+
function computeMatchScore(candidate: FileCandidate, query: string): number {
|
|
301
|
+
const { path: filePath, name, isDir } = candidate;
|
|
93
302
|
const stem = name.replace(/\.[^.]+$/, "");
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const nl = name.toLowerCase();
|
|
97
|
-
const sl = stem.toLowerCase();
|
|
98
|
-
const depth = parts.length;
|
|
99
|
-
const inDir = parts.slice(0, -1).some((d) => d.toLowerCase().includes(q));
|
|
303
|
+
const parts = filePath.replace(/\/$/, "").split("/");
|
|
304
|
+
const inDir = parts.slice(0, -1).some(d => d.includes(query));
|
|
100
305
|
|
|
101
306
|
let s = 0;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
else if (
|
|
106
|
-
else if (
|
|
107
|
-
else
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
307
|
+
|
|
308
|
+
// 大小写敏感匹配
|
|
309
|
+
if (stem === query) s = isDir ? 1500 : 1050;
|
|
310
|
+
else if (name.startsWith(query + ".") || name.startsWith(query + "_")) s = 1000;
|
|
311
|
+
else if (name.startsWith(query)) s = 900;
|
|
312
|
+
else if (name.includes(query)) s = 600;
|
|
313
|
+
else if (filePath.includes(query)) s = 300;
|
|
314
|
+
else s = fuzzyScore(name, query);
|
|
315
|
+
|
|
111
316
|
if (!s) return 0;
|
|
112
317
|
|
|
113
|
-
//
|
|
114
|
-
if (isDir) s +=
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const inBadDir = parts.some((d) => d.startsWith(".") || d.startsWith("__") || BAD_DIRS.includes(d));
|
|
119
|
-
if (inBadDir) s -= 200;
|
|
120
|
-
if (inDir) s += 300;
|
|
318
|
+
// 目录轻微加成(tiebreaker,不碾压深度)
|
|
319
|
+
if (isDir) s += 100;
|
|
320
|
+
|
|
321
|
+
// 父目录命中加成
|
|
322
|
+
if (inDir) s += 50;
|
|
121
323
|
|
|
122
|
-
return s
|
|
324
|
+
return s;
|
|
123
325
|
}
|
|
124
326
|
|
|
125
327
|
// ═══════════════════════════════════════════════════════════
|
|
126
|
-
// 搜索
|
|
328
|
+
// 搜索(匹配分 + 惩罚分 = 总分排序)
|
|
127
329
|
// ═══════════════════════════════════════════════════════════
|
|
128
330
|
|
|
129
|
-
function smartSearch(
|
|
331
|
+
function smartSearch(candidates: FileCandidate[], query: string): string[] {
|
|
332
|
+
// 空查询: 按 tier 语义过滤, 隐藏 Tier 1/2
|
|
130
333
|
if (!query) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
334
|
+
const visible = candidates.filter(c => c.tier === 0 || c.tier === 3 || c.tier === 4);
|
|
335
|
+
return visible
|
|
336
|
+
.sort((a, b) => {
|
|
337
|
+
// 目录优先
|
|
338
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
339
|
+
return b.penalty - a.penalty || a.path.localeCompare(b.path);
|
|
340
|
+
})
|
|
341
|
+
.slice(0, 20)
|
|
342
|
+
.map(c => c.path);
|
|
137
343
|
}
|
|
138
|
-
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
139
344
|
|
|
140
|
-
|
|
141
|
-
const scored = [
|
|
142
|
-
...dirs.map((d) => ({ path: d.endsWith("/") ? d : d + "/", s: scoreFile(d, tokens[0]!, true) })),
|
|
143
|
-
...files.map((f) => ({ path: f, s: scoreFile(f, tokens[0]!, false) })),
|
|
144
|
-
].filter((x) => x.s > 0);
|
|
345
|
+
const tokens = query.split(/\s+/).filter(Boolean);
|
|
145
346
|
|
|
146
347
|
if (tokens.length === 1) {
|
|
348
|
+
const scored = candidates
|
|
349
|
+
.map(c => {
|
|
350
|
+
const matchScore = computeMatchScore(c, tokens[0]!);
|
|
351
|
+
return { path: c.path, total: matchScore + c.penalty, matchScore };
|
|
352
|
+
})
|
|
353
|
+
.filter(x => x.matchScore > 0);
|
|
354
|
+
|
|
147
355
|
return scored
|
|
148
|
-
.sort((a, b) => b.
|
|
356
|
+
.sort((a, b) => b.total - a.total || a.path.localeCompare(b.path))
|
|
149
357
|
.slice(0, 20)
|
|
150
|
-
.map(
|
|
358
|
+
.map(x => x.path);
|
|
151
359
|
}
|
|
152
360
|
|
|
153
|
-
//
|
|
361
|
+
// 多词: 各 token 独立搜索取 union
|
|
154
362
|
const seen = new Set<string>();
|
|
155
|
-
const all = [
|
|
156
|
-
...dirs.map((d) => ({ path: d.endsWith("/") ? d : d + "/", isDir: true })),
|
|
157
|
-
...files.map((f) => ({ path: f, isDir: false })),
|
|
158
|
-
];
|
|
159
363
|
for (const t of tokens) {
|
|
160
|
-
|
|
161
|
-
.map(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
364
|
+
const scored = candidates
|
|
365
|
+
.map(c => {
|
|
366
|
+
const matchScore = computeMatchScore(c, t);
|
|
367
|
+
return { path: c.path, total: matchScore + c.penalty, matchScore };
|
|
368
|
+
})
|
|
369
|
+
.filter(x => x.matchScore > 0)
|
|
370
|
+
.sort((a, b) => b.total - a.total)
|
|
371
|
+
.slice(0, 20);
|
|
372
|
+
for (const { path } of scored) seen.add(path);
|
|
167
373
|
}
|
|
168
374
|
return [...seen].slice(0, 20);
|
|
169
375
|
}
|
|
170
376
|
|
|
171
377
|
// ═══════════════════════════════════════════════════════════
|
|
172
|
-
// @
|
|
378
|
+
// @ 前缀检测
|
|
173
379
|
// ═══════════════════════════════════════════════════════════
|
|
174
380
|
|
|
175
381
|
function atPrefix(text: string): string | null {
|
|
@@ -182,6 +388,16 @@ function atPrefix(text: string): string | null {
|
|
|
182
388
|
return null;
|
|
183
389
|
}
|
|
184
390
|
|
|
391
|
+
// 测试导出: 仅暴露纯逻辑
|
|
392
|
+
export const __smartAtTest = {
|
|
393
|
+
computePenaltyMeta,
|
|
394
|
+
computePenalty,
|
|
395
|
+
fuzzyScore,
|
|
396
|
+
computeMatchScore,
|
|
397
|
+
smartSearch,
|
|
398
|
+
atPrefix,
|
|
399
|
+
};
|
|
400
|
+
|
|
185
401
|
// ═══════════════════════════════════════════════════════════
|
|
186
402
|
// 入口
|
|
187
403
|
// ═══════════════════════════════════════════════════════════
|
|
@@ -190,23 +406,35 @@ export function setupSmartAt(pi: ExtensionAPI) {
|
|
|
190
406
|
pi.on("session_start", (_e: any, ctx: any) => {
|
|
191
407
|
const cwd = String(ctx.cwd || "").trim();
|
|
192
408
|
|
|
409
|
+
let cache: FileCandidate[] | null = null;
|
|
410
|
+
|
|
411
|
+
function getOrBuildCache(): FileCandidate[] {
|
|
412
|
+
if (!cache) cache = collectCandidates(cwd);
|
|
413
|
+
return cache;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function clearCache() {
|
|
417
|
+
cache = null;
|
|
418
|
+
}
|
|
419
|
+
|
|
193
420
|
ctx.ui.addAutocompleteProvider((orig: any) => ({
|
|
194
421
|
getSuggestions: (lines: any, cl: any, cc: any, opts: any) => {
|
|
195
422
|
const prefix = atPrefix((lines[cl] || "").slice(0, cc));
|
|
196
423
|
if (!prefix) {
|
|
424
|
+
clearCache();
|
|
197
425
|
ctx.ui.setWidget("smart-at", undefined);
|
|
198
426
|
return orig.getSuggestions(lines, cl, cc, opts);
|
|
199
427
|
}
|
|
200
428
|
|
|
201
|
-
const
|
|
202
|
-
const results = smartSearch(
|
|
429
|
+
const candidates = getOrBuildCache();
|
|
430
|
+
const results = smartSearch(candidates, prefix.slice(1));
|
|
203
431
|
|
|
204
432
|
if (!results.length) {
|
|
205
433
|
ctx.ui.setWidget("smart-at", undefined);
|
|
206
434
|
return null;
|
|
207
435
|
}
|
|
208
436
|
|
|
209
|
-
ctx.ui.setWidget("smart-at", ["
|
|
437
|
+
ctx.ui.setWidget("smart-at", ["\x1b[2mpowered by decorated-pi\x1b[0m"]);
|
|
210
438
|
return Promise.resolve({
|
|
211
439
|
items: results.map((f: string) => ({
|
|
212
440
|
value: "@" + f,
|
|
@@ -216,8 +444,8 @@ export function setupSmartAt(pi: ExtensionAPI) {
|
|
|
216
444
|
prefix,
|
|
217
445
|
});
|
|
218
446
|
},
|
|
219
|
-
// ⚠️ 必须 .bind(orig)
|
|
220
447
|
applyCompletion: (...args: any[]) => {
|
|
448
|
+
clearCache();
|
|
221
449
|
ctx.ui.setWidget("smart-at", undefined);
|
|
222
450
|
return orig.applyCompletion.apply(orig, args);
|
|
223
451
|
},
|
|
@@ -4,29 +4,49 @@
|
|
|
4
4
|
* 当 agent 读取或编辑子目录中的文件时,自动发现该目录及父目录中的 AGENTS.md/CLAUDE.md,
|
|
5
5
|
* 将其内容注入到 tool result 中。
|
|
6
6
|
*
|
|
7
|
-
* 状态通过 pi.appendEntry() 持久化到 session JSONL 文件中,resume
|
|
7
|
+
* 状态通过 pi.appendEntry() 持久化到 session JSONL 文件中,resume / reload 时恢复。
|
|
8
|
+
* compaction 视为上下文边界:仅恢复当前 branch 上最后一个 compaction 之后的已加载状态。
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { dirname, resolve, relative, join } from "node:path";
|
|
12
|
+
import { dirname, resolve, relative, join, normalize } from "node:path";
|
|
12
13
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
14
|
|
|
14
15
|
const CUSTOM_TYPE = "decorated-pi.subdir-agents";
|
|
15
16
|
const AGENTS_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
|
|
16
17
|
|
|
18
|
+
interface SessionLikeEntry {
|
|
19
|
+
type: string;
|
|
20
|
+
customType?: string;
|
|
21
|
+
data?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
const discovered = new Set<string>();
|
|
18
25
|
const pendingPaths = new Map<string, string>();
|
|
19
26
|
let sessionCwd = process.cwd();
|
|
20
27
|
|
|
21
|
-
function
|
|
28
|
+
function normalizeAbsPath(cwd: string, p: string): string {
|
|
29
|
+
return normalize(resolve(cwd, p));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lastCompactionIndex(entries: SessionLikeEntry[]): number {
|
|
33
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
34
|
+
if (entries[i]?.type === "compaction") return i;
|
|
35
|
+
}
|
|
36
|
+
return -1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function restoreFromBranch(ctx: { cwd: string; sessionManager: { getBranch: () => Array<SessionLikeEntry> } }) {
|
|
22
40
|
discovered.clear();
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
const branch = ctx.sessionManager.getBranch();
|
|
42
|
+
const start = lastCompactionIndex(branch) + 1;
|
|
43
|
+
for (const entry of branch.slice(start)) {
|
|
44
|
+
if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue;
|
|
45
|
+
const paths = entry.data as string[] | undefined;
|
|
46
|
+
if (!Array.isArray(paths)) continue;
|
|
47
|
+
for (const p of paths) {
|
|
48
|
+
if (typeof p === "string" && p.trim()) {
|
|
49
|
+
discovered.add(normalizeAbsPath(ctx.cwd, p));
|
|
30
50
|
}
|
|
31
51
|
}
|
|
32
52
|
}
|
|
@@ -34,7 +54,7 @@ function restoreFromSession(ctx: { cwd: string; sessionManager: { getEntries: ()
|
|
|
34
54
|
|
|
35
55
|
function findNewAgents(filePath: string, cwd: string): Array<{ path: string; content: string }> {
|
|
36
56
|
const resolvedCwd = resolve(cwd);
|
|
37
|
-
let dir = dirname(resolve(filePath));
|
|
57
|
+
let dir = dirname(resolve(cwd, filePath));
|
|
38
58
|
const results: Array<{ path: string; content: string }> = [];
|
|
39
59
|
|
|
40
60
|
while (true) {
|
|
@@ -42,7 +62,7 @@ function findNewAgents(filePath: string, cwd: string): Array<{ path: string; con
|
|
|
42
62
|
if (rel === "" || rel.startsWith("..")) break;
|
|
43
63
|
|
|
44
64
|
for (const name of AGENTS_NAMES) {
|
|
45
|
-
const agentsPath = join(dir, name);
|
|
65
|
+
const agentsPath = normalize(join(dir, name));
|
|
46
66
|
if (existsSync(agentsPath) && !discovered.has(agentsPath)) {
|
|
47
67
|
try {
|
|
48
68
|
const content = readFileSync(agentsPath, "utf-8");
|
|
@@ -65,10 +85,20 @@ function findNewAgents(filePath: string, cwd: string): Array<{ path: string; con
|
|
|
65
85
|
return results.reverse();
|
|
66
86
|
}
|
|
67
87
|
|
|
88
|
+
export const __subdirAgentsTest = {
|
|
89
|
+
restoreFromBranch,
|
|
90
|
+
findNewAgents,
|
|
91
|
+
};
|
|
92
|
+
|
|
68
93
|
export function setupSubdirAgents(pi: ExtensionAPI) {
|
|
69
94
|
pi.on("session_start", (_event, ctx) => {
|
|
70
95
|
sessionCwd = ctx.cwd;
|
|
71
|
-
|
|
96
|
+
restoreFromBranch(ctx);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
pi.on("session_compact", () => {
|
|
100
|
+
discovered.clear();
|
|
101
|
+
pendingPaths.clear();
|
|
72
102
|
});
|
|
73
103
|
|
|
74
104
|
pi.on("tool_call", (event) => {
|