decorated-pi 0.7.0 → 0.7.2
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 +38 -12
- package/hooks/inject-agents-md.ts +20 -12
- package/hooks/mcp.ts +61 -64
- package/hooks/pi-tool-filter.ts +2 -1
- package/hooks/rtk.ts +3 -2
- package/hooks/skeleton.ts +46 -18
- package/hooks/smart-at.ts +122 -264
- package/hooks/wakatime.ts +3 -2
- package/index.ts +117 -3
- package/package.json +9 -4
- package/settings.ts +5 -3
- package/skills/pi-docs/SKILL.md +13 -0
- package/tools/lsp/tools.ts +0 -1
- package/tools/mcp/builtin/codegraph.ts +1 -1
- package/tools/mcp/client.ts +1 -1
- package/tools/patch/core.ts +17 -1
- package/tools/patch/index.ts +338 -225
package/hooks/smart-at.ts
CHANGED
|
@@ -1,318 +1,176 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* smart-at — high-speed file search autocomplete.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Uses @ff-labs/fff-node (FFF) for cross-platform fuzzy file search.
|
|
5
|
+
* FFF maintains an in-memory index, scores files by fuzzy match +
|
|
6
|
+
* frecency + git status, and returns ranked results. We create one
|
|
7
|
+
* FileFinder per session and query it directly for every @ prefix.
|
|
8
|
+
*
|
|
9
|
+
* KNOWN FFF LIMITATION (v0.9.4):
|
|
10
|
+
* FFF only returns directories that directly contain files. Intermediate
|
|
11
|
+
* folders that only hold subdirectories (e.g. product/module/apmanage/ where
|
|
12
|
+
* all files live in product/module/apmanage/deep/) will not appear in
|
|
13
|
+
* mixedSearch/directorySearch results, even though the files underneath do.
|
|
14
|
+
* We intentionally do NOT synthesize these directories on the TypeScript side
|
|
15
|
+
* to avoid extra complexity/cost; the fix belongs upstream.
|
|
7
16
|
*/
|
|
8
17
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { FileFinder, type MixedItem } from "@ff-labs/fff-node";
|
|
19
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
20
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
14
21
|
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
interface FileCandidate {
|
|
20
|
-
path: string;
|
|
21
|
-
name: string;
|
|
22
|
-
isDir: boolean;
|
|
23
|
-
tier: PenaltyTier;
|
|
24
|
-
penalty: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// ─── Hard-exclude & penalty rules ────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
const HARD_EXCLUDE_DIRS = new Set(["node_modules", ".git", ".pnpm", ".svn"]);
|
|
30
|
-
|
|
31
|
-
const BAD_DIRS = new Set(["build", "dist", "coverage", "out", "target"]);
|
|
32
|
-
|
|
33
|
-
const EXT_PENALTY: Record<string, number> = {
|
|
34
|
-
o: -150, obj: -150, a: -150, so: -150, dll: -150, exe: -150,
|
|
35
|
-
wasm: -150, class: -120, pyc: -120,
|
|
36
|
-
bmp: -100, png: -100, jpg: -100, gif: -100, ico: -100, svg: -80,
|
|
37
|
-
mp3: -100, wav: -100, mp4: -100, avi: -100,
|
|
38
|
-
pdf: -100, zip: -100, tar: -100, gz: -100,
|
|
39
|
-
lock: -80,
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// ─── Penalty pre-compute ─────────────────────────────────────────────────
|
|
22
|
+
// Characters that, when preceding an "@", allow us to start a smart-at
|
|
23
|
+
// completion.
|
|
24
|
+
const AT_BOUNDARY = new Set<string>([" ", "\t", "(", "["]);
|
|
43
25
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const ext = (!isDir && name.includes(".")) ? (name.split(".").pop()?.toLowerCase() || "") : "";
|
|
48
|
-
const depth = parts.length;
|
|
49
|
-
const dirSegments = isDir ? parts : parts.slice(0, -1);
|
|
50
|
-
|
|
51
|
-
let tier: PenaltyTier = 0;
|
|
52
|
-
let tierPenalty = 0;
|
|
53
|
-
|
|
54
|
-
if (gitIgnored) { tier = 1; tierPenalty = -400; }
|
|
55
|
-
else if (dirSegments.some(d => d.startsWith(".") || d.startsWith("__"))) { tier = 2; tierPenalty = -300; }
|
|
56
|
-
else if (dirSegments.some(d => BAD_DIRS.has(d))) { tier = 3; tierPenalty = -200; }
|
|
57
|
-
else if (!isDir && (EXT_PENALTY[ext] ?? 0) < 0) { tier = 4; tierPenalty = EXT_PENALTY[ext]!; }
|
|
58
|
-
|
|
59
|
-
const basePenalty = -(depth * 30) - name.length;
|
|
60
|
-
return { tier, penalty: tierPenalty + basePenalty };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function computePenalty(filePath: string, isDir: boolean, gitIgnored: boolean): number {
|
|
64
|
-
return computePenaltyMeta(filePath, isDir, gitIgnored).penalty;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ─── Candidate collection ────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
const SPAWN_OPTS = { timeout: 5000, encoding: "utf-8" as const, maxBuffer: 10 * 1024 * 1024 };
|
|
70
|
-
|
|
71
|
-
function isGitWorkTree(cwd: string): boolean {
|
|
72
|
-
return spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { ...SPAWN_OPTS, cwd }).status === 0;
|
|
73
|
-
}
|
|
26
|
+
const AUTOCOMPLETE_LIMIT = 20;
|
|
27
|
+
const FFF_SUPERSET = 100;
|
|
28
|
+
const WIDGET_FOOTER = "\x1b[2mpowered by fff\x1b[0m";
|
|
74
29
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
SPAWN_OPTS,
|
|
80
|
-
);
|
|
81
|
-
return result.status === 0;
|
|
30
|
+
interface AutocompleteItem {
|
|
31
|
+
value: string;
|
|
32
|
+
label: string;
|
|
33
|
+
description?: string;
|
|
82
34
|
}
|
|
83
35
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
return false;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const opts = { ...SPAWN_OPTS, cwd };
|
|
95
|
-
const isGit = isGitWorkTree(cwd);
|
|
96
|
-
|
|
97
|
-
if (isGit) {
|
|
98
|
-
const r1 = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], opts);
|
|
99
|
-
const visibleFiles = (r1.status === 0 && r1.stdout)
|
|
100
|
-
? r1.stdout.trim().split("\n").filter(Boolean) : [];
|
|
101
|
-
|
|
102
|
-
const r2 = spawnSync("git", ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--no-empty-directory"], opts);
|
|
103
|
-
const ignoredDirs = new Set<string>();
|
|
104
|
-
const ignoredFiles: string[] = [];
|
|
105
|
-
if (r2.status === 0 && r2.stdout) {
|
|
106
|
-
for (const raw of r2.stdout.trim().split("\n").filter(Boolean)) {
|
|
107
|
-
const entry = raw.replace(/^\.\//, "");
|
|
108
|
-
if (isChildOfHardExclude(entry)) continue;
|
|
109
|
-
if (entry.endsWith("/")) ignoredDirs.add(entry.replace(/\/$/, ""));
|
|
110
|
-
else ignoredFiles.push(entry);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const dirSet = new Set<string>();
|
|
115
|
-
for (const f of visibleFiles) {
|
|
116
|
-
if (isChildOfHardExclude(f)) continue;
|
|
117
|
-
const name = f.split("/").pop() || f;
|
|
118
|
-
const meta = computePenaltyMeta(f, false, false);
|
|
119
|
-
candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
|
|
120
|
-
const parts = f.split("/");
|
|
121
|
-
let current = "";
|
|
122
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
123
|
-
current = current ? `${current}/${parts[i]}` : parts[i]!;
|
|
124
|
-
dirSet.add(current);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
for (const f of ignoredFiles) {
|
|
128
|
-
const name = f.split("/").pop() || f;
|
|
129
|
-
const meta = computePenaltyMeta(f, false, true);
|
|
130
|
-
candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
|
|
131
|
-
}
|
|
132
|
-
for (const d of dirSet) {
|
|
133
|
-
const name = d.split("/").pop() || d;
|
|
134
|
-
const meta = computePenaltyMeta(d, true, false);
|
|
135
|
-
candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
136
|
-
}
|
|
137
|
-
for (const d of ignoredDirs) {
|
|
138
|
-
const name = d.split("/").pop() || d;
|
|
139
|
-
const meta = computePenaltyMeta(d, true, true);
|
|
140
|
-
candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
141
|
-
}
|
|
142
|
-
for (const hd of HARD_EXCLUDE_DIRS) {
|
|
143
|
-
if (!existsSync(join(opts.cwd, hd))) continue;
|
|
144
|
-
const meta = computePenaltyMeta(hd, true, false);
|
|
145
|
-
candidates.push({ path: hd + "/", name: hd, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
146
|
-
}
|
|
147
|
-
} else {
|
|
148
|
-
const rel = (s: string) => {
|
|
149
|
-
let r = s.startsWith(cwd + "/") ? s.slice(cwd.length + 1) : s;
|
|
150
|
-
return r.startsWith("./") ? r.slice(2) : r;
|
|
151
|
-
};
|
|
152
|
-
const fdExcludes = [...HARD_EXCLUDE_DIRS].flatMap(d => ["--exclude", d]);
|
|
153
|
-
const fdOpts = { ...SPAWN_OPTS, cwd: undefined as string | undefined };
|
|
154
|
-
|
|
155
|
-
const r1 = spawnSync("fd", ["--type", "f", "--hidden", "--no-ignore", ...fdExcludes, ".", cwd], fdOpts);
|
|
156
|
-
if (r1.status === 0 && r1.stdout) {
|
|
157
|
-
for (const raw of r1.stdout.trim().split("\n").filter(Boolean)) {
|
|
158
|
-
const f = rel(raw);
|
|
159
|
-
const name = f.split("/").pop() || f;
|
|
160
|
-
const meta = computePenaltyMeta(f, false, false);
|
|
161
|
-
candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
const r2 = spawnSync("fd", ["--type", "d", "--hidden", "--no-ignore", ...fdExcludes, ".", cwd], fdOpts);
|
|
165
|
-
if (r2.status === 0 && r2.stdout) {
|
|
166
|
-
for (const raw of r2.stdout.trim().split("\n").filter(Boolean)) {
|
|
167
|
-
const d = rel(raw).replace(/\/$/, "");
|
|
168
|
-
const name = d.split("/").pop() || d;
|
|
169
|
-
const meta = computePenaltyMeta(d, true, false);
|
|
170
|
-
candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
for (const hd of HARD_EXCLUDE_DIRS) {
|
|
174
|
-
if (!existsSync(join(cwd, hd))) continue;
|
|
175
|
-
const meta = computePenaltyMeta(hd, true, false);
|
|
176
|
-
candidates.push({ path: hd + "/", name: hd, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
36
|
+
function atPrefix(text: string): string | null {
|
|
37
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
38
|
+
if (text[i] !== "@") continue;
|
|
39
|
+
const b = text[i - 1];
|
|
40
|
+
if (i === 0 || AT_BOUNDARY.has(b)) {
|
|
41
|
+
return text.slice(i);
|
|
177
42
|
}
|
|
43
|
+
return null;
|
|
178
44
|
}
|
|
179
|
-
return
|
|
45
|
+
return null;
|
|
180
46
|
}
|
|
181
47
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
function fuzzyScore(text: string, query: string): number {
|
|
185
|
-
let qi = 0, firstMatch = -1, lastMatch = -1;
|
|
186
|
-
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
|
|
187
|
-
if (text[ti] === query[qi]) {
|
|
188
|
-
if (firstMatch < 0) firstMatch = ti;
|
|
189
|
-
lastMatch = ti;
|
|
190
|
-
qi++;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
if (qi < query.length) return 0;
|
|
194
|
-
const span = lastMatch - firstMatch + 1;
|
|
195
|
-
return Math.max(10, 200 - span * 3 - text.length);
|
|
48
|
+
function isGitIgnored(item: MixedItem): boolean {
|
|
49
|
+
return item.type === "file" && item.item.gitStatus === "ignored";
|
|
196
50
|
}
|
|
197
51
|
|
|
198
|
-
function
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
else if (name.includes(query)) s = 600;
|
|
209
|
-
else if (filePath.includes(query)) s = 300;
|
|
210
|
-
else s = fuzzyScore(name, query);
|
|
211
|
-
|
|
212
|
-
if (!s) return 0;
|
|
213
|
-
if (isDir) s += 100;
|
|
214
|
-
if (inDir) s += 50;
|
|
215
|
-
return s;
|
|
52
|
+
function toAutocompleteItem(item: MixedItem): AutocompleteItem {
|
|
53
|
+
const path = item.item.relativePath;
|
|
54
|
+
const label = item.type === "file"
|
|
55
|
+
? item.item.fileName
|
|
56
|
+
: path.replace(/\/+$/, "").split("/").pop() || path;
|
|
57
|
+
return {
|
|
58
|
+
value: "@" + path,
|
|
59
|
+
label,
|
|
60
|
+
description: path,
|
|
61
|
+
};
|
|
216
62
|
}
|
|
217
63
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const visible = candidates.filter(c => c.tier === 0 || c.tier === 3 || c.tier === 4);
|
|
221
|
-
return visible
|
|
222
|
-
.sort((a, b) => {
|
|
223
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
224
|
-
return b.penalty - a.penalty || a.path.localeCompare(b.path);
|
|
225
|
-
})
|
|
226
|
-
.slice(0, 20)
|
|
227
|
-
.map(c => c.path);
|
|
228
|
-
}
|
|
229
|
-
const tokens = query.split(/\s+/).filter(Boolean);
|
|
230
|
-
if (tokens.length === 1) {
|
|
231
|
-
const scored = candidates
|
|
232
|
-
.map(c => ({ path: c.path, total: computeMatchScore(c, tokens[0]!) + c.penalty, matchScore: computeMatchScore(c, tokens[0]!) }))
|
|
233
|
-
.filter(x => x.matchScore > 0);
|
|
234
|
-
return scored
|
|
235
|
-
.sort((a, b) => b.total - a.total || a.path.localeCompare(b.path))
|
|
236
|
-
.slice(0, 20)
|
|
237
|
-
.map(x => x.path);
|
|
238
|
-
}
|
|
239
|
-
const seen = new Set<string>();
|
|
240
|
-
for (const t of tokens) {
|
|
241
|
-
const scored = candidates
|
|
242
|
-
.map(c => ({ path: c.path, total: computeMatchScore(c, t) + c.penalty, matchScore: computeMatchScore(c, t) }))
|
|
243
|
-
.filter(x => x.matchScore > 0)
|
|
244
|
-
.sort((a, b) => b.total - a.total)
|
|
245
|
-
.slice(0, 20);
|
|
246
|
-
for (const { path } of scored) seen.add(path);
|
|
247
|
-
}
|
|
248
|
-
return [...seen].slice(0, 20);
|
|
64
|
+
interface BuiltResult {
|
|
65
|
+
items: AutocompleteItem[];
|
|
249
66
|
}
|
|
250
67
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
68
|
+
/** Take FFF's ranked results, drop git-ignored files, and for non-empty
|
|
69
|
+
* queries keep only paths that contain the query as a substring. This
|
|
70
|
+
* stops FFF's loose fuzzy matching from ranking every file in a directory
|
|
71
|
+
* that happens to share a few letters (e.g. "mm/" for query "mmc"). */
|
|
72
|
+
function buildResult(items: MixedItem[], lowerQuery: string): BuiltResult | null {
|
|
73
|
+
const filtered = items
|
|
74
|
+
.filter((it) => !isGitIgnored(it))
|
|
75
|
+
.filter(
|
|
76
|
+
(it) =>
|
|
77
|
+
!lowerQuery || it.item.relativePath.toLowerCase().includes(lowerQuery),
|
|
78
|
+
)
|
|
79
|
+
.slice(0, AUTOCOMPLETE_LIMIT)
|
|
80
|
+
.map(toAutocompleteItem);
|
|
81
|
+
return filtered.length ? { items: filtered } : null;
|
|
259
82
|
}
|
|
260
83
|
|
|
261
|
-
export const __smartAtTest = {
|
|
262
|
-
|
|
263
|
-
|
|
84
|
+
export const __smartAtTest = { atPrefix };
|
|
85
|
+
|
|
86
|
+
/** Active FileFinder for the current session; freed in session_shutdown
|
|
87
|
+
* to avoid leaking FFF's native handle + LMDB mmap regions. */
|
|
88
|
+
let currentFinder: FileFinder | null = null;
|
|
264
89
|
|
|
265
90
|
export const smartAtModule: Module = {
|
|
266
91
|
name: "smart-at",
|
|
267
92
|
hooks: {
|
|
268
93
|
session_start: [
|
|
269
|
-
(_event: any, ctx: ExtensionContext) => {
|
|
94
|
+
async (_event: any, ctx: ExtensionContext) => {
|
|
270
95
|
const cwd = String(ctx.cwd || "").trim();
|
|
271
|
-
|
|
96
|
+
const created = FileFinder.create({ basePath: cwd || "." });
|
|
97
|
+
if (!created.ok) {
|
|
98
|
+
// FFF not available on this platform; silently skip.
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const finder = created.value;
|
|
103
|
+
currentFinder = finder;
|
|
272
104
|
|
|
273
|
-
|
|
274
|
-
|
|
105
|
+
// Start the scan in the background. We don't wait for it here so
|
|
106
|
+
// session_start returns immediately; the provider queries the
|
|
107
|
+
// (possibly partial) index and FFF returns whatever it has indexed
|
|
108
|
+
// so far. Full results appear as the scan progresses.
|
|
109
|
+
void finder.waitForScan(60_000);
|
|
275
110
|
|
|
276
111
|
ctx.ui.addAutocompleteProvider((orig: any) => ({
|
|
277
|
-
getSuggestions: (
|
|
112
|
+
getSuggestions: (
|
|
113
|
+
lines: string[],
|
|
114
|
+
cl: number,
|
|
115
|
+
cc: number,
|
|
116
|
+
opts?: { signal?: AbortSignal },
|
|
117
|
+
) => {
|
|
278
118
|
const prefix = atPrefix((lines[cl] || "").slice(0, cc));
|
|
279
119
|
if (!prefix) {
|
|
280
|
-
clearCache();
|
|
281
120
|
ctx.ui.setWidget("smart-at", undefined);
|
|
282
121
|
return orig.getSuggestions(lines, cl, cc, opts);
|
|
283
122
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (!results.length) {
|
|
123
|
+
|
|
124
|
+
if (currentFinder !== finder || finder.isDestroyed) {
|
|
287
125
|
ctx.ui.setWidget("smart-at", undefined);
|
|
288
|
-
return
|
|
126
|
+
return orig.getSuggestions(lines, cl, cc, opts);
|
|
289
127
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
128
|
+
|
|
129
|
+
// Respect upstream cancellation so fast typing doesn't pile up
|
|
130
|
+
// expensive FFF queries.
|
|
131
|
+
if (opts?.signal?.aborted) {
|
|
132
|
+
return orig.getSuggestions(lines, cl, cc, opts);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const query = prefix.slice(1);
|
|
136
|
+
const lowerQuery = query.toLowerCase();
|
|
137
|
+
const r = finder.mixedSearch(lowerQuery, {
|
|
138
|
+
pageSize: lowerQuery ? FFF_SUPERSET : AUTOCOMPLETE_LIMIT,
|
|
298
139
|
});
|
|
140
|
+
if (!r.ok) {
|
|
141
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const result = buildResult(r.value.items, lowerQuery);
|
|
146
|
+
if (!result) {
|
|
147
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
ctx.ui.setWidget("smart-at", [WIDGET_FOOTER]);
|
|
152
|
+
return Promise.resolve({ ...result, prefix });
|
|
299
153
|
},
|
|
300
154
|
applyCompletion: (...args: any[]) => {
|
|
301
|
-
clearCache();
|
|
302
155
|
ctx.ui.setWidget("smart-at", undefined);
|
|
303
156
|
return orig.applyCompletion.apply(orig, args);
|
|
304
157
|
},
|
|
305
|
-
shouldTriggerFileCompletion:
|
|
158
|
+
shouldTriggerFileCompletion:
|
|
159
|
+
orig.shouldTriggerFileCompletion?.bind(orig),
|
|
306
160
|
}));
|
|
307
161
|
},
|
|
308
162
|
],
|
|
163
|
+
session_shutdown: [
|
|
164
|
+
(_event: any, _ctx: ExtensionContext) => {
|
|
165
|
+
if (currentFinder && !currentFinder.isDestroyed) {
|
|
166
|
+
currentFinder.destroy();
|
|
167
|
+
}
|
|
168
|
+
currentFinder = null;
|
|
169
|
+
},
|
|
170
|
+
],
|
|
309
171
|
},
|
|
310
172
|
};
|
|
311
173
|
|
|
312
174
|
export function setupSmartAt(sk: Skeleton): void {
|
|
313
|
-
sk.declareDependency({
|
|
314
|
-
label: "fd",
|
|
315
|
-
check: () => commandExists("fd") || isGitWorkTree(process.cwd()),
|
|
316
|
-
});
|
|
317
175
|
sk.register(smartAtModule);
|
|
318
176
|
}
|
package/hooks/wakatime.ts
CHANGED
|
@@ -342,12 +342,13 @@ export function setupWakatimeWithApiKey(
|
|
|
342
342
|
export function setupWakatime(sk: Skeleton, pi: ExtensionAPI): void {
|
|
343
343
|
const apiKey = readWakatimeCfgApiKey();
|
|
344
344
|
const cliPath = findWakatimeCli();
|
|
345
|
-
sk.declareDependency({
|
|
345
|
+
const ready = sk.declareDependency({
|
|
346
346
|
label: "wakatime-cli",
|
|
347
|
+
module: "wakatime",
|
|
347
348
|
check: () => findWakatimeCli() !== null,
|
|
348
349
|
hint: "Install wakatime-cli to track coding activity.",
|
|
349
350
|
});
|
|
350
|
-
if (!apiKey || !cliPath) return;
|
|
351
|
+
if (!apiKey || !cliPath || !ready) return;
|
|
351
352
|
|
|
352
353
|
const sendHeartbeat: HeartbeatSender = (hb, cwd) => sendHeartbeatViaCli(hb, apiKey, cliPath, cwd);
|
|
353
354
|
|
package/index.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
19
20
|
|
|
20
21
|
import { createSkeleton } from "./hooks/skeleton.js";
|
|
21
22
|
|
|
@@ -36,8 +37,9 @@ import { setupRtk } from "./hooks/rtk.js";
|
|
|
36
37
|
import { registerPatchTool } from "./tools/patch/index.js";
|
|
37
38
|
import { registerLspTools } from "./tools/lsp/tools.js";
|
|
38
39
|
import { LspServerManager } from "./tools/lsp/manager.js";
|
|
40
|
+
import { collectLspDependencyStatuses } from "./tools/lsp/servers.js";
|
|
39
41
|
import { registerAskTool } from "./tools/ask/index.js";
|
|
40
|
-
import { resolveMcpConfigs, migrateLegacyGlobalMcpConfig } from "./tools/mcp/config.js";
|
|
42
|
+
import { resolveMcpConfigs, migrateLegacyGlobalMcpConfig, collectMcpDependencyStatuses } from "./tools/mcp/config.js";
|
|
41
43
|
import { ensureMcpServerReady } from "./hooks/mcp.js";
|
|
42
44
|
|
|
43
45
|
import { registerDpModelCommand } from "./commands/dp-model.js";
|
|
@@ -65,6 +67,71 @@ const BASE_GUIDANCE = [
|
|
|
65
67
|
"- CAUTION: Do not perform write operations in the following directories unless explicitly instructed: `node_modules`, `venv`, `env`, `__pycache__`, `.git` or any other hidden directories.",
|
|
66
68
|
].join("\n");
|
|
67
69
|
|
|
70
|
+
/** Remove the injected Pi documentation block from the base system prompt.
|
|
71
|
+
* Matches a line containing "Pi documentation" and deletes it plus all
|
|
72
|
+
* following non-empty lines, stopping at the first blank line. */
|
|
73
|
+
export function stripPiDocsBlock(prompt: string): string {
|
|
74
|
+
const lines = prompt.split("\n");
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
let i = 0;
|
|
77
|
+
while (i < lines.length) {
|
|
78
|
+
const line = lines[i];
|
|
79
|
+
if (line.includes("Pi documentation")) {
|
|
80
|
+
i++;
|
|
81
|
+
while (i < lines.length && lines[i].trim() !== "") i++;
|
|
82
|
+
// Drop the terminating blank line as well so we don't leave orphan whitespace.
|
|
83
|
+
if (i < lines.length && lines[i].trim() === "") i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
out.push(line);
|
|
87
|
+
i++;
|
|
88
|
+
}
|
|
89
|
+
return out.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Sort the <available_skills> block in the system prompt by skill name.
|
|
93
|
+
* Pi core appends extension-provided skills after user/project skills and does
|
|
94
|
+
* not sort the XML; this makes the final prompt stable and cache-friendly. */
|
|
95
|
+
export function sortSkillsInSystemPrompt(prompt: string): string {
|
|
96
|
+
const startMarker = "\n<available_skills>";
|
|
97
|
+
const endMarker = "</available_skills>";
|
|
98
|
+
const startIdx = prompt.indexOf(startMarker);
|
|
99
|
+
if (startIdx === -1) return prompt;
|
|
100
|
+
const endIdx = prompt.indexOf(endMarker, startIdx);
|
|
101
|
+
if (endIdx === -1) return prompt;
|
|
102
|
+
|
|
103
|
+
const before = prompt.slice(0, startIdx + startMarker.length);
|
|
104
|
+
const after = prompt.slice(endIdx);
|
|
105
|
+
const inner = prompt.slice(startIdx + startMarker.length, endIdx);
|
|
106
|
+
|
|
107
|
+
const chunks: string[][] = [];
|
|
108
|
+
let current: string[] = [];
|
|
109
|
+
for (const line of inner.split("\n")) {
|
|
110
|
+
const trimmed = line.trim();
|
|
111
|
+
if (trimmed === "<skill>") {
|
|
112
|
+
current = [line];
|
|
113
|
+
} else if (trimmed === "</skill>") {
|
|
114
|
+
current.push(line);
|
|
115
|
+
chunks.push(current);
|
|
116
|
+
current = [];
|
|
117
|
+
} else if (current.length > 0) {
|
|
118
|
+
current.push(line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const nameOf = (chunk: string[]) => {
|
|
123
|
+
const line = chunk.find((l) => l.trim().startsWith("<name>"));
|
|
124
|
+
if (!line) return "";
|
|
125
|
+
const t = line.trim();
|
|
126
|
+
return t.slice(6, t.indexOf("</name>"));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
chunks.sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
|
|
130
|
+
|
|
131
|
+
const sortedInner = "\n" + chunks.map((chunk) => chunk.join("\n")).join("\n") + "\n";
|
|
132
|
+
return before + sortedInner + after;
|
|
133
|
+
}
|
|
134
|
+
|
|
68
135
|
/** Build the list of guideline strings to inject, in prompt order.
|
|
69
136
|
* Always-on rules first, then per-module guidelines. REDACT_GUIDANCE is
|
|
70
137
|
* gated by the secretRedaction module; CodeGraph guidance follows the MCP
|
|
@@ -80,6 +147,26 @@ function buildGuidelines(): string[] {
|
|
|
80
147
|
return out;
|
|
81
148
|
}
|
|
82
149
|
|
|
150
|
+
function canRegisterMcpServer(config: { name: string; command?: string }, deps: Array<{ module: string; state: string }>): boolean {
|
|
151
|
+
if (!config.command) return true;
|
|
152
|
+
const dep = deps.find((d) => d.module === `mcp:${config.name}`);
|
|
153
|
+
return dep ? dep.state === "ok" : true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Absolute path to the plugin's builtin skills directory.
|
|
157
|
+
* Used by `resources_discover` so the skill travels with the plugin
|
|
158
|
+
* regardless of which project pi is running in. */
|
|
159
|
+
export function getBuiltinSkillPaths(): string[] {
|
|
160
|
+
return [fileURLToPath(new URL("./skills", import.meta.url))];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Register the plugin's builtin skill paths with Pi core. */
|
|
164
|
+
function installBuiltinSkills(pi: ExtensionAPI): void {
|
|
165
|
+
pi.on("resources_discover", async (_event: any) => ({
|
|
166
|
+
skillPaths: getBuiltinSkillPaths(),
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
83
170
|
/** Install a single before_agent_start handler that appends every
|
|
84
171
|
* guideline in order, stripping the volatile "Current date: …" line
|
|
85
172
|
* for cache stability. Idempotent — re-injection is a no-op via marker. */
|
|
@@ -90,7 +177,9 @@ function installGuidelines(pi: ExtensionAPI): void {
|
|
|
90
177
|
|
|
91
178
|
pi.on("before_agent_start", async (event: any) => {
|
|
92
179
|
if (!event.systemPrompt) return undefined;
|
|
93
|
-
let prompt: string = event.systemPrompt
|
|
180
|
+
let prompt: string = stripPiDocsBlock(event.systemPrompt);
|
|
181
|
+
prompt = sortSkillsInSystemPrompt(prompt);
|
|
182
|
+
prompt = prompt.replace(/\nCurrent date: \d{4}-\d{2}-\d{2}/, "");
|
|
94
183
|
if (prompt.includes(marker)) return undefined; // already injected this turn
|
|
95
184
|
return { systemPrompt: `${prompt}\n\n${joined}` };
|
|
96
185
|
});
|
|
@@ -130,7 +219,19 @@ export default async function (pi: ExtensionAPI) {
|
|
|
130
219
|
|
|
131
220
|
// ── Tools (conditional on module switches) ────────────────────────────
|
|
132
221
|
if (isModuleEnabled("patchOverrideEdit")) registerPatchTool(pi);
|
|
133
|
-
if (isModuleEnabled("lsp"))
|
|
222
|
+
if (isModuleEnabled("lsp")) {
|
|
223
|
+
const lspDeps = collectLspDependencyStatuses(process.cwd());
|
|
224
|
+
if (lspDeps.some((d) => d.state === "ok")) {
|
|
225
|
+
registerLspTools(pi, new LspServerManager());
|
|
226
|
+
}
|
|
227
|
+
for (const dep of lspDeps) {
|
|
228
|
+
sk.declareDependency({
|
|
229
|
+
label: `lsp:${dep.label}`,
|
|
230
|
+
module: `lsp:${dep.label}`,
|
|
231
|
+
check: () => collectLspDependencyStatuses(process.cwd()).some((s) => s.label === dep.label && s.state === "ok"),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
134
235
|
if (isModuleEnabled("ask")) registerAskTool(pi);
|
|
135
236
|
|
|
136
237
|
// MCP: hook, tools, and /mcp command are gated together. Disabling the
|
|
@@ -142,16 +243,29 @@ export default async function (pi: ExtensionAPI) {
|
|
|
142
243
|
// explicitly here so `loadGlobalMcpConfigs` stays pure.
|
|
143
244
|
migrateLegacyGlobalMcpConfig();
|
|
144
245
|
sk.register(mcpModule);
|
|
246
|
+
const mcpDeps = collectMcpDependencyStatuses(process.cwd());
|
|
247
|
+
for (const dep of mcpDeps) {
|
|
248
|
+
sk.declareDependency({
|
|
249
|
+
label: dep.module,
|
|
250
|
+
module: dep.module,
|
|
251
|
+
check: () => collectMcpDependencyStatuses(process.cwd()).some((s) => s.module === dep.module && s.state === "ok"),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
145
254
|
const configs = resolveMcpConfigs(process.cwd()).filter(s => s.enabled);
|
|
146
255
|
// Per-server readiness: cache hit → register from cache (fast).
|
|
147
256
|
// Cache miss → connect synchronously, write cache, then register
|
|
148
257
|
// live tools. This blocks startup only for cache-miss servers.
|
|
258
|
+
// Skip servers whose binary is missing (dependency not met).
|
|
149
259
|
for (const config of configs) {
|
|
260
|
+
if (!canRegisterMcpServer(config, mcpDeps)) continue;
|
|
150
261
|
await ensureMcpServerReady(pi, config, process.cwd());
|
|
151
262
|
}
|
|
152
263
|
registerMcpStatusCommand(pi);
|
|
153
264
|
}
|
|
154
265
|
|
|
266
|
+
// ── Builtin skills (travel with the plugin in every project) ─────────────
|
|
267
|
+
installBuiltinSkills(pi);
|
|
268
|
+
|
|
155
269
|
// ── System-prompt guidelines (single handler, array order = prompt order) ──
|
|
156
270
|
installGuidelines(pi);
|
|
157
271
|
|