decorated-pi 0.6.0 → 0.7.1
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 +45 -15
- package/commands/dp-settings.ts +6 -1
- package/hooks/mcp.ts +52 -0
- package/hooks/session-title.ts +25 -1
- package/hooks/smart-at.ts +114 -264
- package/index.ts +33 -22
- package/package.json +13 -5
- package/settings.ts +170 -31
- package/tools/ask/index.ts +93 -0
- package/tools/mcp/builtin/codegraph.ts +10 -34
- package/tools/mcp/builtin/index.ts +2 -2
- package/tools/mcp/config.ts +93 -21
- package/tools/patch/core.ts +7 -1
- package/tools/patch/index.ts +3 -2
- package/ui/ask.ts +443 -0
- package/ui/module-settings.ts +131 -26
- package/ui/usage.ts +4 -4
- package/.codegraph/daemon.pid +0 -6
- package/AGENTS.md +0 -92
package/hooks/smart-at.ts
CHANGED
|
@@ -1,318 +1,168 @@
|
|
|
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.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { FileFinder, type MixedItem } from "@ff-labs/fff-node";
|
|
11
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
12
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
14
13
|
|
|
15
|
-
//
|
|
14
|
+
// Characters that, when preceding an "@", allow us to start a smart-at
|
|
15
|
+
// completion.
|
|
16
|
+
const AT_BOUNDARY = new Set<string>([" ", "\t", "(", "["]);
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
const AUTOCOMPLETE_LIMIT = 20;
|
|
19
|
+
const FFF_SUPERSET = 100;
|
|
20
|
+
const WIDGET_FOOTER = "\x1b[2mpowered by fff\x1b[0m";
|
|
18
21
|
|
|
19
|
-
interface
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
tier: PenaltyTier;
|
|
24
|
-
penalty: number;
|
|
22
|
+
interface AutocompleteItem {
|
|
23
|
+
value: string;
|
|
24
|
+
label: string;
|
|
25
|
+
description?: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 ─────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
function computePenaltyMeta(filePath: string, isDir: boolean, gitIgnored: boolean): { tier: PenaltyTier; penalty: number } {
|
|
45
|
-
const parts = filePath.replace(/\/$/, "").split("/");
|
|
46
|
-
const name = parts[parts.length - 1] || filePath;
|
|
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
|
-
}
|
|
74
|
-
|
|
75
|
-
function commandExists(command: string): boolean {
|
|
76
|
-
const result = spawnSync(
|
|
77
|
-
process.platform === "win32" ? "where" : (process.env.SHELL || "sh"),
|
|
78
|
-
process.platform === "win32" ? [command] : ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`],
|
|
79
|
-
SPAWN_OPTS,
|
|
80
|
-
);
|
|
81
|
-
return result.status === 0;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function collectCandidates(cwd: string): FileCandidate[] {
|
|
85
|
-
const candidates: FileCandidate[] = [];
|
|
86
|
-
const isChildOfHardExclude = (p: string): boolean => {
|
|
87
|
-
const parts = p.replace(/\/$/, "").split("/");
|
|
88
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
89
|
-
if (HARD_EXCLUDE_DIRS.has(parts[i]!)) return true;
|
|
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 });
|
|
28
|
+
function atPrefix(text: string): string | null {
|
|
29
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
30
|
+
if (text[i] !== "@") continue;
|
|
31
|
+
const b = text[i - 1];
|
|
32
|
+
if (i === 0 || AT_BOUNDARY.has(b)) {
|
|
33
|
+
return text.slice(i);
|
|
177
34
|
}
|
|
35
|
+
return null;
|
|
178
36
|
}
|
|
179
|
-
return
|
|
37
|
+
return null;
|
|
180
38
|
}
|
|
181
39
|
|
|
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);
|
|
40
|
+
function isGitIgnored(item: MixedItem): boolean {
|
|
41
|
+
return item.type === "file" && item.item.gitStatus === "ignored";
|
|
196
42
|
}
|
|
197
43
|
|
|
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;
|
|
44
|
+
function toAutocompleteItem(item: MixedItem): AutocompleteItem {
|
|
45
|
+
const path = item.item.relativePath;
|
|
46
|
+
const label = item.type === "file"
|
|
47
|
+
? item.item.fileName
|
|
48
|
+
: path.replace(/\/+$/, "").split("/").pop() || path;
|
|
49
|
+
return {
|
|
50
|
+
value: "@" + path,
|
|
51
|
+
label,
|
|
52
|
+
description: path,
|
|
53
|
+
};
|
|
216
54
|
}
|
|
217
55
|
|
|
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);
|
|
56
|
+
interface BuiltResult {
|
|
57
|
+
items: AutocompleteItem[];
|
|
249
58
|
}
|
|
250
59
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
60
|
+
/** Take FFF's ranked results, drop git-ignored files, and for non-empty
|
|
61
|
+
* queries keep only paths that contain the query as a substring. This
|
|
62
|
+
* stops FFF's loose fuzzy matching from ranking every file in a directory
|
|
63
|
+
* that happens to share a few letters (e.g. "mm/" for query "mmc"). */
|
|
64
|
+
function buildResult(items: MixedItem[], lowerQuery: string): BuiltResult | null {
|
|
65
|
+
const filtered = items
|
|
66
|
+
.filter((it) => !isGitIgnored(it))
|
|
67
|
+
.filter(
|
|
68
|
+
(it) =>
|
|
69
|
+
!lowerQuery || it.item.relativePath.toLowerCase().includes(lowerQuery),
|
|
70
|
+
)
|
|
71
|
+
.slice(0, AUTOCOMPLETE_LIMIT)
|
|
72
|
+
.map(toAutocompleteItem);
|
|
73
|
+
return filtered.length ? { items: filtered } : null;
|
|
259
74
|
}
|
|
260
75
|
|
|
261
|
-
export const __smartAtTest = {
|
|
262
|
-
|
|
263
|
-
|
|
76
|
+
export const __smartAtTest = { atPrefix };
|
|
77
|
+
|
|
78
|
+
/** Active FileFinder for the current session; freed in session_shutdown
|
|
79
|
+
* to avoid leaking FFF's native handle + LMDB mmap regions. */
|
|
80
|
+
let currentFinder: FileFinder | null = null;
|
|
264
81
|
|
|
265
82
|
export const smartAtModule: Module = {
|
|
266
83
|
name: "smart-at",
|
|
267
84
|
hooks: {
|
|
268
85
|
session_start: [
|
|
269
|
-
(_event: any, ctx: ExtensionContext) => {
|
|
86
|
+
async (_event: any, ctx: ExtensionContext) => {
|
|
270
87
|
const cwd = String(ctx.cwd || "").trim();
|
|
271
|
-
|
|
88
|
+
const created = FileFinder.create({ basePath: cwd || "." });
|
|
89
|
+
if (!created.ok) {
|
|
90
|
+
// FFF not available on this platform; silently skip.
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const finder = created.value;
|
|
95
|
+
currentFinder = finder;
|
|
272
96
|
|
|
273
|
-
|
|
274
|
-
|
|
97
|
+
// Start the scan in the background. We don't wait for it here so
|
|
98
|
+
// session_start returns immediately; the provider queries the
|
|
99
|
+
// (possibly partial) index and FFF returns whatever it has indexed
|
|
100
|
+
// so far. Full results appear as the scan progresses.
|
|
101
|
+
void finder.waitForScan(60_000);
|
|
275
102
|
|
|
276
103
|
ctx.ui.addAutocompleteProvider((orig: any) => ({
|
|
277
|
-
getSuggestions: (
|
|
104
|
+
getSuggestions: (
|
|
105
|
+
lines: string[],
|
|
106
|
+
cl: number,
|
|
107
|
+
cc: number,
|
|
108
|
+
opts?: { signal?: AbortSignal },
|
|
109
|
+
) => {
|
|
278
110
|
const prefix = atPrefix((lines[cl] || "").slice(0, cc));
|
|
279
111
|
if (!prefix) {
|
|
280
|
-
clearCache();
|
|
281
112
|
ctx.ui.setWidget("smart-at", undefined);
|
|
282
113
|
return orig.getSuggestions(lines, cl, cc, opts);
|
|
283
114
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (!results.length) {
|
|
115
|
+
|
|
116
|
+
if (currentFinder !== finder || finder.isDestroyed) {
|
|
287
117
|
ctx.ui.setWidget("smart-at", undefined);
|
|
288
|
-
return
|
|
118
|
+
return orig.getSuggestions(lines, cl, cc, opts);
|
|
289
119
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
120
|
+
|
|
121
|
+
// Respect upstream cancellation so fast typing doesn't pile up
|
|
122
|
+
// expensive FFF queries.
|
|
123
|
+
if (opts?.signal?.aborted) {
|
|
124
|
+
return orig.getSuggestions(lines, cl, cc, opts);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const query = prefix.slice(1);
|
|
128
|
+
const lowerQuery = query.toLowerCase();
|
|
129
|
+
const r = finder.mixedSearch(lowerQuery, {
|
|
130
|
+
pageSize: lowerQuery ? FFF_SUPERSET : AUTOCOMPLETE_LIMIT,
|
|
298
131
|
});
|
|
132
|
+
if (!r.ok) {
|
|
133
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const result = buildResult(r.value.items, lowerQuery);
|
|
138
|
+
if (!result) {
|
|
139
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ctx.ui.setWidget("smart-at", [WIDGET_FOOTER]);
|
|
144
|
+
return Promise.resolve({ ...result, prefix });
|
|
299
145
|
},
|
|
300
146
|
applyCompletion: (...args: any[]) => {
|
|
301
|
-
clearCache();
|
|
302
147
|
ctx.ui.setWidget("smart-at", undefined);
|
|
303
148
|
return orig.applyCompletion.apply(orig, args);
|
|
304
149
|
},
|
|
305
|
-
shouldTriggerFileCompletion:
|
|
150
|
+
shouldTriggerFileCompletion:
|
|
151
|
+
orig.shouldTriggerFileCompletion?.bind(orig),
|
|
306
152
|
}));
|
|
307
153
|
},
|
|
308
154
|
],
|
|
155
|
+
session_shutdown: [
|
|
156
|
+
(_event: any, _ctx: ExtensionContext) => {
|
|
157
|
+
if (currentFinder && !currentFinder.isDestroyed) {
|
|
158
|
+
currentFinder.destroy();
|
|
159
|
+
}
|
|
160
|
+
currentFinder = null;
|
|
161
|
+
},
|
|
162
|
+
],
|
|
309
163
|
},
|
|
310
164
|
};
|
|
311
165
|
|
|
312
166
|
export function setupSmartAt(sk: Skeleton): void {
|
|
313
|
-
sk.declareDependency({
|
|
314
|
-
label: "fd",
|
|
315
|
-
check: () => commandExists("fd") || isGitWorkTree(process.cwd()),
|
|
316
|
-
});
|
|
317
167
|
sk.register(smartAtModule);
|
|
318
168
|
}
|
package/index.ts
CHANGED
|
@@ -36,9 +36,9 @@ import { setupRtk } from "./hooks/rtk.js";
|
|
|
36
36
|
import { registerPatchTool } from "./tools/patch/index.js";
|
|
37
37
|
import { registerLspTools } from "./tools/lsp/tools.js";
|
|
38
38
|
import { LspServerManager } from "./tools/lsp/manager.js";
|
|
39
|
-
import {
|
|
39
|
+
import { registerAskTool } from "./tools/ask/index.js";
|
|
40
|
+
import { resolveMcpConfigs, migrateLegacyGlobalMcpConfig } from "./tools/mcp/config.js";
|
|
40
41
|
import { ensureMcpServerReady } from "./hooks/mcp.js";
|
|
41
|
-
import { CODEGRAPH_GUIDANCE } from "./tools/mcp/builtin/codegraph.js";
|
|
42
42
|
|
|
43
43
|
import { registerDpModelCommand } from "./commands/dp-model.js";
|
|
44
44
|
import { registerDpSettingsCommand } from "./commands/dp-settings.js";
|
|
@@ -46,7 +46,7 @@ import { registerMcpStatusCommand } from "./commands/mcp-status.js";
|
|
|
46
46
|
import { registerRetryCommand } from "./commands/retry.js";
|
|
47
47
|
import { registerUsageCommand } from "./commands/usage.js";
|
|
48
48
|
|
|
49
|
-
import { isModuleEnabled } from "./settings.js";
|
|
49
|
+
import { captureModuleSnapshot, isModuleEnabled } from "./settings.js";
|
|
50
50
|
|
|
51
51
|
// ─── System-prompt guidelines (hard-coded base, per-module imports) ────────
|
|
52
52
|
//
|
|
@@ -57,7 +57,7 @@ const BASE_GUIDANCE = [
|
|
|
57
57
|
"## Decorated Pi Guidance",
|
|
58
58
|
"",
|
|
59
59
|
"### Workflow, how to approach tasks",
|
|
60
|
-
"- Before acting on a prompt
|
|
60
|
+
"- Before acting on a prompt, do sufficient research on the existing state — read files, search, investigate — and only proceed once you have a clear picture.",
|
|
61
61
|
"- Exercise caution when performing any **write** operations, especially when you are in a research or exploration phase.",
|
|
62
62
|
"- Before modifying code, match the user's existing code style (naming, formatting, patterns). Do not re-modify lines the user has manually edited since your last change.",
|
|
63
63
|
"",
|
|
@@ -66,16 +66,17 @@ const BASE_GUIDANCE = [
|
|
|
66
66
|
].join("\n");
|
|
67
67
|
|
|
68
68
|
/** Build the list of guideline strings to inject, in prompt order.
|
|
69
|
-
* Always-on rules first, then per-module guidelines
|
|
70
|
-
*
|
|
71
|
-
*
|
|
69
|
+
* Always-on rules first, then per-module guidelines. REDACT_GUIDANCE is
|
|
70
|
+
* gated by the secretRedaction module; CodeGraph guidance follows the MCP
|
|
71
|
+
* server switch chain (mcp module on → codegraph server on → guidance
|
|
72
|
+
* injected). The mcp module check lives in `resolveMcpConfigs`, so this
|
|
73
|
+
* code only needs to look at the server's own `enabled` flag. */
|
|
72
74
|
function buildGuidelines(): string[] {
|
|
73
75
|
const out: string[] = [
|
|
74
76
|
BASE_GUIDANCE,
|
|
75
|
-
|
|
76
|
-
INJECT_AGENTS_MD_GUIDANCE, // from hooks/inject-agents-md.ts — always on (smart-at module)
|
|
77
|
+
INJECT_AGENTS_MD_GUIDANCE, // from hooks/inject-agents-md.ts — always on
|
|
77
78
|
];
|
|
78
|
-
if (isModuleEnabled("
|
|
79
|
+
if (isModuleEnabled("secretRedaction")) out.push(REDACT_GUIDANCE);
|
|
79
80
|
return out;
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -96,13 +97,18 @@ function installGuidelines(pi: ExtensionAPI): void {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
export default async function (pi: ExtensionAPI) {
|
|
100
|
+
// Snapshot the module settings that pi is about to load. /dp-settings
|
|
101
|
+
// compares against this to avoid prompting for reload when the user
|
|
102
|
+
// has only returned the settings to the currently-loaded state.
|
|
103
|
+
captureModuleSnapshot();
|
|
104
|
+
|
|
99
105
|
// ── Skeleton (hooks) ───────────────────────────────────────────────────
|
|
100
106
|
const sk = createSkeleton();
|
|
101
107
|
|
|
102
108
|
// Order matters for tool_result compose chain:
|
|
103
109
|
// 1. redact → normalize-codeblocks → externalize → track-mtime → inject-agents-md → image-vision → wakatime
|
|
104
110
|
// The first module registered for a given event runs first (compose chain).
|
|
105
|
-
setupRedact(sk);
|
|
111
|
+
if (isModuleEnabled("secretRedaction")) setupRedact(sk);
|
|
106
112
|
sk.register(normalizeCodeblocksModule);
|
|
107
113
|
sk.register(externalizeModule);
|
|
108
114
|
sk.register(trackMtimeModule);
|
|
@@ -114,22 +120,27 @@ export default async function (pi: ExtensionAPI) {
|
|
|
114
120
|
// anything else inspects the tool list.
|
|
115
121
|
sk.register(piToolFilterModule);
|
|
116
122
|
sk.register(sessionTitleModule);
|
|
117
|
-
sk.register(smartAtModule);
|
|
118
|
-
sk.register(wakatimeModule);
|
|
123
|
+
if (isModuleEnabled("atOverride")) sk.register(smartAtModule);
|
|
124
|
+
if (isModuleEnabled("wakatime")) sk.register(wakatimeModule);
|
|
119
125
|
|
|
120
126
|
// Compaction + RTK (these also install their own pi.on via setup<>()).
|
|
121
127
|
setupCompaction(sk);
|
|
122
|
-
setupRtk(sk, pi);
|
|
123
|
-
setupWakatime(sk, pi);
|
|
128
|
+
if (isModuleEnabled("rtk")) setupRtk(sk, pi);
|
|
129
|
+
if (isModuleEnabled("wakatime")) setupWakatime(sk, pi);
|
|
124
130
|
|
|
125
131
|
// ── Tools (conditional on module switches) ────────────────────────────
|
|
126
|
-
if (isModuleEnabled("
|
|
132
|
+
if (isModuleEnabled("patchOverrideEdit")) registerPatchTool(pi);
|
|
127
133
|
if (isModuleEnabled("lsp")) registerLspTools(pi, new LspServerManager());
|
|
134
|
+
if (isModuleEnabled("ask")) registerAskTool(pi);
|
|
128
135
|
|
|
129
|
-
// MCP: hook
|
|
130
|
-
// means no session_start handler runs, no tools register,
|
|
131
|
-
// background connections are attempted.
|
|
136
|
+
// MCP: hook, tools, and /mcp command are gated together. Disabling the
|
|
137
|
+
// module means no session_start handler runs, no tools register, no
|
|
138
|
+
// /mcp command is available, and no background connections are attempted.
|
|
132
139
|
if (isModuleEnabled("mcp")) {
|
|
140
|
+
// One-time migration: legacy global MCP configs in
|
|
141
|
+
// ~/.pi/agent/decorated-pi.json move to ~/.pi/agent/mcp.json. Run
|
|
142
|
+
// explicitly here so `loadGlobalMcpConfigs` stays pure.
|
|
143
|
+
migrateLegacyGlobalMcpConfig();
|
|
133
144
|
sk.register(mcpModule);
|
|
134
145
|
const configs = resolveMcpConfigs(process.cwd()).filter(s => s.enabled);
|
|
135
146
|
// Per-server readiness: cache hit → register from cache (fast).
|
|
@@ -138,6 +149,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
138
149
|
for (const config of configs) {
|
|
139
150
|
await ensureMcpServerReady(pi, config, process.cwd());
|
|
140
151
|
}
|
|
152
|
+
registerMcpStatusCommand(pi);
|
|
141
153
|
}
|
|
142
154
|
|
|
143
155
|
// ── System-prompt guidelines (single handler, array order = prompt order) ──
|
|
@@ -146,9 +158,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
146
158
|
// ── Commands ──────────────────────────────────────────────────────────
|
|
147
159
|
registerDpModelCommand(pi);
|
|
148
160
|
registerDpSettingsCommand(pi);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
registerUsageCommand(pi);
|
|
161
|
+
if (isModuleEnabled("retry")) registerRetryCommand(pi);
|
|
162
|
+
if (isModuleEnabled("usage")) registerUsageCommand(pi);
|
|
152
163
|
|
|
153
164
|
// ── Install skeleton (last) ────────────────────────────────────────────
|
|
154
165
|
sk.install(pi);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decorated-pi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "decorated-pi is a practical enhancement pack for pi coding agent — token-efficient workflow, cache-friendly design, and smarter tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
"mcp",
|
|
11
11
|
"security",
|
|
12
12
|
"lsp",
|
|
13
|
-
"
|
|
14
|
-
"secret-
|
|
13
|
+
"lsp-client",
|
|
14
|
+
"secret-redaction",
|
|
15
|
+
"codegraph",
|
|
16
|
+
"fff",
|
|
17
|
+
"smart-at",
|
|
18
|
+
"fuzzy-search",
|
|
19
|
+
"file-finder"
|
|
15
20
|
],
|
|
16
21
|
"license": "MIT",
|
|
17
22
|
"repository": {
|
|
@@ -21,6 +26,7 @@
|
|
|
21
26
|
"homepage": "https://github.com/lcwecker/decorated-pi#readme",
|
|
22
27
|
"bugs": "https://github.com/lcwecker/decorated-pi/issues",
|
|
23
28
|
"dependencies": {
|
|
29
|
+
"@ff-labs/fff-node": "^0.9.4",
|
|
24
30
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
31
|
"file-type": "^21.3.4",
|
|
26
32
|
"openai": "^6.37.0"
|
|
@@ -38,13 +44,15 @@
|
|
|
38
44
|
},
|
|
39
45
|
"devDependencies": {
|
|
40
46
|
"@types/node": "^22.15.3",
|
|
47
|
+
"@vitest/coverage-v8": "4.1.8",
|
|
41
48
|
"depcheck": "^1.4.7",
|
|
42
49
|
"publint": "^0.3.21",
|
|
43
|
-
"vitest": "
|
|
50
|
+
"vitest": "4.1.8"
|
|
44
51
|
},
|
|
45
52
|
"scripts": {
|
|
46
53
|
"prepack": "depcheck --fail-on-missing",
|
|
47
|
-
"test": "vitest run",
|
|
54
|
+
"test": "vitest run --coverage",
|
|
55
|
+
"bench": "vitest bench --run",
|
|
48
56
|
"typecheck": "tsc --noEmit",
|
|
49
57
|
"prepublishOnly": "npm install --no-audit --no-fund && npm test && npm run typecheck && depcheck --fail-on-missing && publint"
|
|
50
58
|
}
|