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