decorated-pi 0.1.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/LICENSE +21 -0
- package/README.md +218 -0
- package/extensions/extend-model.ts +410 -0
- package/extensions/guidance.ts +21 -0
- package/extensions/index.ts +24 -0
- package/extensions/lsp/client.ts +525 -0
- package/extensions/lsp/env.ts +12 -0
- package/extensions/lsp/format.ts +349 -0
- package/extensions/lsp/index.ts +14 -0
- package/extensions/lsp/prompt.ts +39 -0
- package/extensions/lsp/server-manager.ts +303 -0
- package/extensions/lsp/servers.ts +229 -0
- package/extensions/lsp/tools.ts +530 -0
- package/extensions/lsp/trust.ts +39 -0
- package/extensions/safety.ts +370 -0
- package/extensions/session-title.ts +40 -0
- package/extensions/settings.ts +62 -0
- package/extensions/slash.ts +67 -0
- package/extensions/smart-at.ts +220 -0
- package/extensions/subdir-agents.ts +121 -0
- package/index.ts +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart @ - 高速文件搜索自动补全
|
|
3
|
+
*
|
|
4
|
+
* 【重要】applyCompletion / shouldTriggerFileCompletion 必须用 .bind(orig)
|
|
5
|
+
* 不能用箭头函数!Pi editor 会做原型检查,新函数导致扩展崩溃。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from "child_process";
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
// ═══════════════════════════════════════════════════════════
|
|
12
|
+
// 文件列表(缓存 10s,maxBuffer 防 ENOBUFS)
|
|
13
|
+
// ═══════════════════════════════════════════════════════════
|
|
14
|
+
|
|
15
|
+
let cachedDirs: string[] = [];
|
|
16
|
+
let cachedFiles: string[] = [];
|
|
17
|
+
let cacheTime = 0;
|
|
18
|
+
let cacheCwd = "";
|
|
19
|
+
|
|
20
|
+
function getFileAndDirList(cwd: string): { dirs: string[]; files: string[] } {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
if (cacheCwd === cwd && now - cacheTime < 10000) return { dirs: cachedDirs, files: cachedFiles };
|
|
23
|
+
|
|
24
|
+
let dirs: string[] = [];
|
|
25
|
+
let files: string[] = [];
|
|
26
|
+
const opts = { timeout: 5000, encoding: "utf-8" as const, maxBuffer: 10 * 1024 * 1024, cwd };
|
|
27
|
+
|
|
28
|
+
// 去掉 cwd 前缀和 ./ 前缀,转相对路径
|
|
29
|
+
const rel = (s: string) => {
|
|
30
|
+
let r = s.startsWith(cwd + "/") ? s.slice(cwd.length + 1) : s;
|
|
31
|
+
return r.startsWith("./") ? r.slice(2) : r;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// 1. git ls-files (returns relative paths)
|
|
35
|
+
const git = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], opts);
|
|
36
|
+
if (git.status === 0) {
|
|
37
|
+
const r = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], opts);
|
|
38
|
+
if (r.status === 0 && r.stdout) files = r.stdout.trim().split("\n").filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. fd (returns absolute paths when given absolute path)
|
|
42
|
+
if (!files.length) {
|
|
43
|
+
const fdFiles = spawnSync("fd", ["--type", "f", "--hidden", ".", cwd], { ...opts, cwd: undefined });
|
|
44
|
+
if (fdFiles.status === 0 && fdFiles.stdout) files = fdFiles.stdout.trim().split("\n").filter(Boolean).map(rel);
|
|
45
|
+
}
|
|
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
|
+
|
|
49
|
+
cachedDirs = dirs;
|
|
50
|
+
cachedFiles = files;
|
|
51
|
+
cacheTime = Date.now();
|
|
52
|
+
cacheCwd = cwd;
|
|
53
|
+
return { dirs, files };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ═══════════════════════════════════════════════════════════
|
|
57
|
+
// 评分
|
|
58
|
+
// ═══════════════════════════════════════════════════════════
|
|
59
|
+
|
|
60
|
+
const EXT_PENALTY: Record<string, number> = {
|
|
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
|
+
// 真模糊匹配(字符可以不连续)
|
|
72
|
+
function fuzzyScore(text: string, query: string): number {
|
|
73
|
+
const t = text.toLowerCase(), q = query.toLowerCase();
|
|
74
|
+
let qi = 0, firstMatch = -1, lastMatch = -1;
|
|
75
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
76
|
+
if (t[ti] === q[qi]) {
|
|
77
|
+
if (firstMatch < 0) firstMatch = ti;
|
|
78
|
+
lastMatch = ti;
|
|
79
|
+
qi++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (qi < q.length) return 0;
|
|
83
|
+
const span = lastMatch - firstMatch + 1; // 匹配跨度
|
|
84
|
+
const totalLen = t.length;
|
|
85
|
+
// 跨度小 + 文件名短 = 高分
|
|
86
|
+
return Math.max(10, 200 - span * 3 - totalLen);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function scoreFile(file: string, query: string, isDir = false): number {
|
|
90
|
+
const cleaned = isDir ? file.replace(/\/$/, "") : file;
|
|
91
|
+
const parts = cleaned.split("/");
|
|
92
|
+
const name = parts[parts.length - 1] || cleaned;
|
|
93
|
+
const stem = name.replace(/\.[^.]+$/, "");
|
|
94
|
+
const ext = name.includes(".") ? name.split(".").pop()?.toLowerCase() || "" : "";
|
|
95
|
+
const q = query.toLowerCase();
|
|
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));
|
|
100
|
+
|
|
101
|
+
let s = 0;
|
|
102
|
+
if (sl === q) s = isDir ? 980 : 950;
|
|
103
|
+
else if (nl.startsWith(q + ".") || nl.startsWith(q + "_") || nl.startsWith(q + "/")) s = 900;
|
|
104
|
+
else if (nl.startsWith(q)) s = 800;
|
|
105
|
+
else if (nl.includes(q)) s = 500;
|
|
106
|
+
else if (file.toLowerCase().includes(q)) s = 100;
|
|
107
|
+
else {
|
|
108
|
+
// 模糊匹配仅限文件名(全路径太松)
|
|
109
|
+
s = fuzzyScore(nl, q);
|
|
110
|
+
}
|
|
111
|
+
if (!s) return 0;
|
|
112
|
+
|
|
113
|
+
// 目录加成(+500,确保匹配目录排第一)
|
|
114
|
+
if (isDir) s += 500;
|
|
115
|
+
// 扩展名奖惩
|
|
116
|
+
if (!isDir) s += EXT_PENALTY[ext] ?? 0;
|
|
117
|
+
// 隐藏目录 / 缓存目录 / __pycache__ 类目录 降权
|
|
118
|
+
const inBadDir = parts.some((d) => d.startsWith(".") || d.startsWith("__") || BAD_DIRS.includes(d));
|
|
119
|
+
if (inBadDir) s -= 200;
|
|
120
|
+
if (inDir) s += 300;
|
|
121
|
+
|
|
122
|
+
return s * 3 - name.length - depth * 2;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ═══════════════════════════════════════════════════════════
|
|
126
|
+
// 搜索
|
|
127
|
+
// ═══════════════════════════════════════════════════════════
|
|
128
|
+
|
|
129
|
+
function smartSearch(dirs: string[], files: string[], query: string): string[] {
|
|
130
|
+
if (!query) {
|
|
131
|
+
// 无查询:排除隐藏目录和隐藏目录下的所有文件
|
|
132
|
+
const isHidden = (p: string) => p.split("/").some((s) => s.startsWith(".") || s.startsWith("__"));
|
|
133
|
+
return [
|
|
134
|
+
...dirs.filter((d) => !isHidden(d)).slice(0, 10),
|
|
135
|
+
...files.filter((f) => !isHidden(f)).slice(0, 10),
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
139
|
+
|
|
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);
|
|
145
|
+
|
|
146
|
+
if (tokens.length === 1) {
|
|
147
|
+
return scored
|
|
148
|
+
.sort((a, b) => b.s - a.s || a.path.localeCompare(b.path))
|
|
149
|
+
.slice(0, 20)
|
|
150
|
+
.map((x) => x.path);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 多词 OR
|
|
154
|
+
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
|
+
for (const t of tokens) {
|
|
160
|
+
for (const { path: p } of all
|
|
161
|
+
.map(({ path, isDir }) => ({ path, s: scoreFile(path, t, isDir) }))
|
|
162
|
+
.filter((x) => x.s > 0)
|
|
163
|
+
.sort((a, b) => b.s - a.s)
|
|
164
|
+
.slice(0, 20)) {
|
|
165
|
+
seen.add(p);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return [...seen].slice(0, 20);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ═══════════════════════════════════════════════════════════
|
|
172
|
+
// @ 前缀
|
|
173
|
+
// ═══════════════════════════════════════════════════════════
|
|
174
|
+
|
|
175
|
+
function atPrefix(text: string): string | null {
|
|
176
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
177
|
+
if (text[i] !== "@") continue;
|
|
178
|
+
const b = text[i - 1];
|
|
179
|
+
if (i === 0 || b === " " || b === "\t" || b === "(" || b === "[") return text.slice(i);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ═══════════════════════════════════════════════════════════
|
|
186
|
+
// 入口
|
|
187
|
+
// ═══════════════════════════════════════════════════════════
|
|
188
|
+
|
|
189
|
+
export function setupSmartAt(pi: ExtensionAPI) {
|
|
190
|
+
pi.on("session_start", (_e: any, ctx: any) => {
|
|
191
|
+
const cwd = String(ctx.cwd || "").trim();
|
|
192
|
+
|
|
193
|
+
ctx.ui.addAutocompleteProvider((orig: any) => ({
|
|
194
|
+
getSuggestions: (lines: any, cl: any, cc: any, opts: any) => {
|
|
195
|
+
const prefix = atPrefix((lines[cl] || "").slice(0, cc));
|
|
196
|
+
if (!prefix) {
|
|
197
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
198
|
+
return orig.getSuggestions(lines, cl, cc, opts);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { dirs, files } = getFileAndDirList(cwd);
|
|
202
|
+
const results = smartSearch(dirs, files, prefix.slice(1));
|
|
203
|
+
ctx.ui.setWidget("smart-at", ["[2mpowered by decorated-pi[0m"]);
|
|
204
|
+
|
|
205
|
+
if (!results.length) return null;
|
|
206
|
+
return Promise.resolve({
|
|
207
|
+
items: results.map((f: string) => ({
|
|
208
|
+
value: "@" + f,
|
|
209
|
+
label: f.replace(/\/$/, "").split("/").pop() || f,
|
|
210
|
+
description: f,
|
|
211
|
+
})),
|
|
212
|
+
prefix,
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
// ⚠️ 必须 .bind(orig)
|
|
216
|
+
applyCompletion: orig.applyCompletion.bind(orig),
|
|
217
|
+
shouldTriggerFileCompletion: orig.shouldTriggerFileCompletion?.bind(orig),
|
|
218
|
+
}));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subdir Agents — 动态加载子目录的 AGENTS.md
|
|
3
|
+
*
|
|
4
|
+
* 当 agent 读取或编辑子目录中的文件时,自动发现该目录及父目录中的 AGENTS.md/CLAUDE.md,
|
|
5
|
+
* 将其内容注入到 tool result 中。
|
|
6
|
+
*
|
|
7
|
+
* 状态通过 pi.appendEntry() 持久化到 session JSONL 文件中,resume 时自动恢复。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { dirname, resolve, relative, join } from "node:path";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
const CUSTOM_TYPE = "decorated-pi.subdir-agents";
|
|
15
|
+
const AGENTS_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
|
|
16
|
+
|
|
17
|
+
const discovered = new Set<string>();
|
|
18
|
+
const pendingPaths = new Map<string, string>();
|
|
19
|
+
let sessionCwd = process.cwd();
|
|
20
|
+
|
|
21
|
+
function restoreFromSession(ctx: { cwd: string; sessionManager: { getEntries: () => Array<{ type: string; customType?: string; data?: unknown }> } }) {
|
|
22
|
+
discovered.clear();
|
|
23
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
24
|
+
if (entry.type === "custom" && entry.customType === CUSTOM_TYPE) {
|
|
25
|
+
const paths = entry.data as string[] | undefined;
|
|
26
|
+
if (paths) {
|
|
27
|
+
for (const p of paths) {
|
|
28
|
+
discovered.add(resolve(ctx.cwd, p));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findNewAgents(filePath: string, cwd: string): Array<{ path: string; content: string }> {
|
|
36
|
+
const resolvedCwd = resolve(cwd);
|
|
37
|
+
let dir = dirname(resolve(filePath));
|
|
38
|
+
const results: Array<{ path: string; content: string }> = [];
|
|
39
|
+
|
|
40
|
+
while (true) {
|
|
41
|
+
const rel = relative(resolvedCwd, dir);
|
|
42
|
+
if (rel === "" || rel.startsWith("..")) break;
|
|
43
|
+
|
|
44
|
+
for (const name of AGENTS_NAMES) {
|
|
45
|
+
const agentsPath = join(dir, name);
|
|
46
|
+
if (existsSync(agentsPath) && !discovered.has(agentsPath)) {
|
|
47
|
+
try {
|
|
48
|
+
const content = readFileSync(agentsPath, "utf-8");
|
|
49
|
+
discovered.add(agentsPath);
|
|
50
|
+
results.push({
|
|
51
|
+
path: relative(cwd, agentsPath),
|
|
52
|
+
content,
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parent = dirname(dir);
|
|
61
|
+
if (parent === dir) break;
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return results.reverse();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function setupSubdirAgents(pi: ExtensionAPI) {
|
|
69
|
+
pi.on("session_start", (_event, ctx) => {
|
|
70
|
+
sessionCwd = ctx.cwd;
|
|
71
|
+
restoreFromSession(ctx);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
pi.on("tool_call", (event) => {
|
|
75
|
+
if (event.toolName === "read" || event.toolName === "edit") {
|
|
76
|
+
const path = (event.input as { path?: string }).path;
|
|
77
|
+
if (path) {
|
|
78
|
+
pendingPaths.set(event.toolCallId, path);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pi.on("tool_result", (event, ctx) => {
|
|
84
|
+
const path = pendingPaths.get(event.toolCallId);
|
|
85
|
+
pendingPaths.delete(event.toolCallId);
|
|
86
|
+
|
|
87
|
+
if (!path || !event.content || !Array.isArray(event.content)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cwd = ctx.cwd ?? sessionCwd;
|
|
92
|
+
const agents = findNewAgents(path, cwd);
|
|
93
|
+
if (agents.length === 0) return;
|
|
94
|
+
|
|
95
|
+
const injections = agents
|
|
96
|
+
.map((a) => `[Directory Context: ${a.path}]\n${a.content}`)
|
|
97
|
+
.join("\n\n---\n\n");
|
|
98
|
+
|
|
99
|
+
const names = agents.map((a) => a.path).join(", ");
|
|
100
|
+
const label = agents.length === 1 ? "AGENTS.md" : "AGENTS.md files";
|
|
101
|
+
ctx.ui.notify(`📋 Loaded ${label}: ${names}`, "info");
|
|
102
|
+
|
|
103
|
+
const absolutePaths = agents.map((a) => resolve(cwd, a.path));
|
|
104
|
+
const relativePaths = absolutePaths.map((p) => relative(cwd, p));
|
|
105
|
+
pi.appendEntry(CUSTOM_TYPE, relativePaths);
|
|
106
|
+
|
|
107
|
+
const newContent = [...event.content];
|
|
108
|
+
newContent.push({
|
|
109
|
+
type: "text",
|
|
110
|
+
text: `\n\n${injections}`,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return { content: newContent };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
pi.on("session_shutdown", () => {
|
|
117
|
+
discovered.clear();
|
|
118
|
+
pendingPaths.clear();
|
|
119
|
+
sessionCwd = process.cwd();
|
|
120
|
+
});
|
|
121
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./extensions/index";
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "decorated-pi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Essential utilities for pi: safety gates, secret redaction, smart @ completion, dynamic AGENTS loading, image fallback, and LSP tools",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"safety",
|
|
10
|
+
"security",
|
|
11
|
+
"lsp",
|
|
12
|
+
"language-server",
|
|
13
|
+
"autocomplete",
|
|
14
|
+
"secretlint"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/lcwecker/decorated-pi.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/lcwecker/decorated-pi#readme",
|
|
22
|
+
"bugs": "https://github.com/lcwecker/decorated-pi/issues",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@secretlint/node": "^13.0.0",
|
|
25
|
+
"@secretlint/secretlint-rule-azure": "^13.0.0",
|
|
26
|
+
"@secretlint/secretlint-rule-preset-recommend": "^13.0.0",
|
|
27
|
+
"@secretlint/secretlint-rule-secp256k1-privatekey": "^13.0.0",
|
|
28
|
+
"@spences10/pi-child-env": "0.1.4",
|
|
29
|
+
"@spences10/pi-project-trust": "0.0.6",
|
|
30
|
+
"openai": "^6.37.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
34
|
+
"@earendil-works/pi-tui": "*",
|
|
35
|
+
"typebox": "*"
|
|
36
|
+
},
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./extensions/index.ts"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|