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,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety — 安全防护模块
|
|
3
|
+
*
|
|
4
|
+
* - Command Guard: 拦截危险 bash 命令与 shell 覆盖写入(枚举式)
|
|
5
|
+
* - Protected Paths: 禁止写入敏感路径
|
|
6
|
+
* - Write Guard: 覆盖非空文件前确认
|
|
7
|
+
* - Secret Redact: API Key / Token 自动掩码
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ExtensionAPI,
|
|
12
|
+
ExtensionContext,
|
|
13
|
+
ToolResultEvent,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
// ─── 危险命令枚举 ──────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const DANGEROUS_COMMANDS: [string, string[]][] = [
|
|
21
|
+
["rm", []],
|
|
22
|
+
["sudo", []],
|
|
23
|
+
["svn", ["commit", "revert"]],
|
|
24
|
+
["git", ["reset", "restore", "clean", "push", "revert"]],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const SAFE_REDIRECT_TARGETS = new Set([
|
|
28
|
+
"/dev/null",
|
|
29
|
+
"/dev/stdout",
|
|
30
|
+
"/dev/stderr",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const SHELL_SEGMENT_BREAKS = new Set(["|", "&&", "||", ";"]);
|
|
34
|
+
const SHELL_REDIRECT_OPERATORS = new Set([">", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>"]);
|
|
35
|
+
|
|
36
|
+
function tokenizeShell(command: string): string[] {
|
|
37
|
+
const tokens: string[] = [];
|
|
38
|
+
let current = "";
|
|
39
|
+
let quote: "'" | '"' | null = null;
|
|
40
|
+
|
|
41
|
+
const pushCurrent = () => {
|
|
42
|
+
if (current.length > 0) {
|
|
43
|
+
tokens.push(current);
|
|
44
|
+
current = "";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < command.length; i++) {
|
|
49
|
+
const ch = command[i]!;
|
|
50
|
+
|
|
51
|
+
if (quote) {
|
|
52
|
+
if (ch === quote) {
|
|
53
|
+
quote = null;
|
|
54
|
+
} else if (ch === "\\" && quote === '"' && i + 1 < command.length) {
|
|
55
|
+
current += command[i + 1]!;
|
|
56
|
+
i += 1;
|
|
57
|
+
} else {
|
|
58
|
+
current += ch;
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (ch === "'" || ch === '"') {
|
|
64
|
+
quote = ch;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (/\s/.test(ch)) {
|
|
69
|
+
pushCurrent();
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (ch === ";") {
|
|
74
|
+
pushCurrent();
|
|
75
|
+
tokens.push(";");
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (ch === "|" || ch === "&") {
|
|
80
|
+
if (i + 1 < command.length && command[i + 1] === ch) {
|
|
81
|
+
pushCurrent();
|
|
82
|
+
tokens.push(ch + ch);
|
|
83
|
+
i += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (ch === "|") {
|
|
87
|
+
pushCurrent();
|
|
88
|
+
tokens.push("|");
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (ch === ">") {
|
|
94
|
+
let op = ">";
|
|
95
|
+
if (i + 1 < command.length && command[i + 1] === ">") {
|
|
96
|
+
op = ">>";
|
|
97
|
+
i += 1;
|
|
98
|
+
}
|
|
99
|
+
if (current === "&" || /^\d+$/.test(current)) {
|
|
100
|
+
op = current + op;
|
|
101
|
+
current = "";
|
|
102
|
+
} else {
|
|
103
|
+
pushCurrent();
|
|
104
|
+
}
|
|
105
|
+
tokens.push(op);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
current += ch;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
pushCurrent();
|
|
113
|
+
return tokens;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isExistingRegularFile(target: string, cwd: string): boolean {
|
|
117
|
+
if (!target || SAFE_REDIRECT_TARGETS.has(target)) return false;
|
|
118
|
+
try {
|
|
119
|
+
return fs.statSync(resolve(cwd, target)).isFile();
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function collectDangerousReasons(command: string, cwd: string): string[] {
|
|
126
|
+
const tokens = tokenizeShell(command);
|
|
127
|
+
const reasons: string[] = [];
|
|
128
|
+
const seen = new Set<string>();
|
|
129
|
+
|
|
130
|
+
const addReason = (reason: string) => {
|
|
131
|
+
if (seen.has(reason)) return;
|
|
132
|
+
seen.add(reason);
|
|
133
|
+
reasons.push(reason);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
137
|
+
const token = tokens[i]!;
|
|
138
|
+
if (SHELL_SEGMENT_BREAKS.has(token)) continue;
|
|
139
|
+
|
|
140
|
+
for (const [cmd, subs] of DANGEROUS_COMMANDS) {
|
|
141
|
+
const name = token.split("/").pop() ?? token;
|
|
142
|
+
if (name !== cmd && name !== `${cmd}.exe`) continue;
|
|
143
|
+
if (subs.length === 0) {
|
|
144
|
+
addReason(`"${cmd}" is a dangerous command`);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
const next = tokens[i + 1];
|
|
148
|
+
if (next && subs.includes(next)) {
|
|
149
|
+
addReason(`"${cmd} ${next}" is a dangerous command`);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (SHELL_REDIRECT_OPERATORS.has(token)) {
|
|
155
|
+
const target = tokens[i + 1];
|
|
156
|
+
if (target && isExistingRegularFile(target, cwd)) {
|
|
157
|
+
addReason(`shell redirection would write to existing file "${target}"`);
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const name = token.split("/").pop() ?? token;
|
|
163
|
+
if (name !== "tee" && name !== "tee.exe") continue;
|
|
164
|
+
|
|
165
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
166
|
+
const next = tokens[j]!;
|
|
167
|
+
if (SHELL_SEGMENT_BREAKS.has(next)) break;
|
|
168
|
+
if (next === "-a" || next === "--append") continue;
|
|
169
|
+
if (next.startsWith("-")) continue;
|
|
170
|
+
if (isExistingRegularFile(next, cwd)) {
|
|
171
|
+
addReason(`"tee" would write to existing file "${next}"`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return reasons;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function formatDangerousReasons(reasons: string[]): string | null {
|
|
180
|
+
if (reasons.length === 0) return null;
|
|
181
|
+
if (reasons.length === 1) return reasons[0]!;
|
|
182
|
+
return `dangerous operations detected:\n- ${reasons.join("\n- ")}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function checkDangerous(command: string, cwd: string): string | null {
|
|
186
|
+
return formatDangerousReasons(collectDangerousReasons(command, cwd));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Protected Paths ────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
const PROTECTED_PATH_SEGMENTS = [
|
|
192
|
+
".env", ".git/", "node_modules/", ".ssh/",
|
|
193
|
+
".gnupg/", ".aws/", "secrets/", ".docker/",
|
|
194
|
+
];
|
|
195
|
+
const PROTECTED_EXTENSIONS = [".pem", ".key", ".p12", ".pfx", ".keystore"];
|
|
196
|
+
const PROTECTED_FILENAMES = [
|
|
197
|
+
"id_rsa", "id_ed25519", "id_ecdsa",
|
|
198
|
+
"authorized_keys", "known_hosts",
|
|
199
|
+
".env.local", ".env.production",
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
function checkProtectedPath(filePath: string): string | null {
|
|
203
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
204
|
+
const filename = normalized.split("/").pop() ?? "";
|
|
205
|
+
for (const seg of PROTECTED_PATH_SEGMENTS) {
|
|
206
|
+
if (normalized.includes(seg)) return `path contains "${seg}"`;
|
|
207
|
+
}
|
|
208
|
+
for (const ext of PROTECTED_EXTENSIONS) {
|
|
209
|
+
if (normalized.endsWith(ext)) return `file extension "${ext}"`;
|
|
210
|
+
}
|
|
211
|
+
for (const name of PROTECTED_FILENAMES) {
|
|
212
|
+
if (filename === name) return `protected file "${name}"`;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Secret Redact ──────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
import { createEngine } from "@secretlint/node";
|
|
220
|
+
|
|
221
|
+
type SecretLintEngine = Awaited<ReturnType<typeof createEngine>>;
|
|
222
|
+
type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number], { type: "text" }>;
|
|
223
|
+
|
|
224
|
+
let engine: SecretLintEngine | null = null;
|
|
225
|
+
|
|
226
|
+
function maskSecret(text: string): string {
|
|
227
|
+
if (text.length <= 8) return "********";
|
|
228
|
+
return text.slice(0, 4) + "********" + text.slice(-4);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function ensureEngine(): Promise<SecretLintEngine> {
|
|
232
|
+
if (!engine) {
|
|
233
|
+
engine = await createEngine({
|
|
234
|
+
formatter: "json",
|
|
235
|
+
color: false,
|
|
236
|
+
maskSecrets: false,
|
|
237
|
+
configFileJSON: {
|
|
238
|
+
rules: [
|
|
239
|
+
{ id: "@secretlint/secretlint-rule-preset-recommend" },
|
|
240
|
+
{ id: "@secretlint/secretlint-rule-azure" },
|
|
241
|
+
{ id: "@secretlint/secretlint-rule-secp256k1-privatekey" },
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return engine;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function extractRanges(jsonOutput: string): Array<{ start: number; end: number }> {
|
|
250
|
+
try {
|
|
251
|
+
const reports = JSON.parse(jsonOutput) as Array<{
|
|
252
|
+
messages: Array<{ range: [number, number]; ruleId: string }>;
|
|
253
|
+
}>;
|
|
254
|
+
const ranges: Array<{ start: number; end: number }> = [];
|
|
255
|
+
for (const report of reports) {
|
|
256
|
+
for (const msg of report.messages) {
|
|
257
|
+
ranges.push({ start: msg.range[0], end: msg.range[1] });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const unique = new Map<string, { start: number; end: number }>();
|
|
261
|
+
for (const r of ranges) unique.set(`${r.start}-${r.end}`, r);
|
|
262
|
+
return [...unique.values()].sort((a, b) => b.start - a.start);
|
|
263
|
+
} catch { return []; }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── Setup ──────────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
export function setupSafety(pi: ExtensionAPI) {
|
|
269
|
+
// ── Command Guard + Protected Paths + Write Guard (tool_call) ─────────
|
|
270
|
+
|
|
271
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
272
|
+
|
|
273
|
+
// Gate 1: 危险命令
|
|
274
|
+
if (event.toolName === "bash") {
|
|
275
|
+
const command = (event.input as { command?: string }).command;
|
|
276
|
+
if (command) {
|
|
277
|
+
const danger = checkDangerous(command, ctx.cwd);
|
|
278
|
+
if (danger) {
|
|
279
|
+
if (!ctx.hasUI) {
|
|
280
|
+
return { block: true, reason: `⛔ ${danger} (non-interactive)` };
|
|
281
|
+
}
|
|
282
|
+
const choice = await ctx.ui.select(
|
|
283
|
+
`⚠️ ${danger}\n\nAllow execution?`,
|
|
284
|
+
["Block", "Allow once"],
|
|
285
|
+
);
|
|
286
|
+
if (!choice || choice === "Block") {
|
|
287
|
+
return { block: true, reason: `⛔ ${danger}` };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Gate 2: 保护路径
|
|
294
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
295
|
+
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
296
|
+
if (filePath) {
|
|
297
|
+
const danger = checkProtectedPath(filePath);
|
|
298
|
+
if (danger) {
|
|
299
|
+
if (!ctx.hasUI) {
|
|
300
|
+
return { block: true, reason: `🔐 ${danger}` };
|
|
301
|
+
}
|
|
302
|
+
const choice = await ctx.ui.select(
|
|
303
|
+
`🔐 ${danger}\n\nProceed?`,
|
|
304
|
+
["Block", "Allow once"],
|
|
305
|
+
);
|
|
306
|
+
if (!choice || choice === "Block") {
|
|
307
|
+
return { block: true, reason: `🔐 ${danger}` };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Gate 3: 写保护(已有内容的文件禁止 write,直接返回信息给 agent)
|
|
314
|
+
if (event.toolName === "write") {
|
|
315
|
+
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
316
|
+
if (filePath) {
|
|
317
|
+
try {
|
|
318
|
+
const abs = resolve(ctx.cwd, filePath);
|
|
319
|
+
if (fs.existsSync(abs) && fs.readFileSync(abs, "utf8").length > 0) {
|
|
320
|
+
return { block: true, reason: "Overwriting a non-empty file is dangerous, use the edit tool instead!" };
|
|
321
|
+
}
|
|
322
|
+
} catch { /* file doesn't exist */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ── Secret Redact (tool_result) ────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
const handleToolResult = async (
|
|
330
|
+
event: ToolResultEvent,
|
|
331
|
+
ctx: ExtensionContext,
|
|
332
|
+
): Promise<{ content?: NonNullable<ToolResultEvent["content"]> } | void> => {
|
|
333
|
+
if (!event.content || !Array.isArray(event.content)) return;
|
|
334
|
+
|
|
335
|
+
const textParts: Array<{ index: number; text: string; item: ToolTextContent }> = [];
|
|
336
|
+
for (let i = 0; i < event.content.length; i++) {
|
|
337
|
+
const item = event.content[i];
|
|
338
|
+
if (item.type === "text" && typeof item.text === "string" && item.text.length > 0) {
|
|
339
|
+
textParts.push({ index: i, text: item.text, item });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (textParts.length === 0) return;
|
|
343
|
+
|
|
344
|
+
const eng = await ensureEngine();
|
|
345
|
+
let totalCount = 0;
|
|
346
|
+
const newContent = [...event.content];
|
|
347
|
+
|
|
348
|
+
for (const { index, text, item } of textParts) {
|
|
349
|
+
const result = await eng.executeOnContent({ content: text, filePath: "tool-output.txt" });
|
|
350
|
+
const ranges = extractRanges(result.output);
|
|
351
|
+
if (ranges.length === 0) continue;
|
|
352
|
+
|
|
353
|
+
totalCount += ranges.length;
|
|
354
|
+
let redacted = text;
|
|
355
|
+
for (const { start, end } of ranges) {
|
|
356
|
+
const original = redacted.slice(start, end);
|
|
357
|
+
redacted = redacted.slice(0, start) + maskSecret(original) + redacted.slice(end);
|
|
358
|
+
}
|
|
359
|
+
const updatedItem: ToolTextContent = { ...item, text: redacted };
|
|
360
|
+
newContent[index] = updatedItem;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (totalCount === 0) return;
|
|
364
|
+
const label = totalCount === 1 ? "1 secret" : `${totalCount} secrets`;
|
|
365
|
+
ctx.ui.notify(`🔐 Redacted ${label} in ${event.toolName} output`, "warning");
|
|
366
|
+
return { content: newContent };
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
pi.on("tool_result", handleToolResult);
|
|
370
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Title — 自动从首条消息设置 session name
|
|
3
|
+
*
|
|
4
|
+
* Pi 在 resume 列表已经用 firstMessage 显示,但 footer 不显示。
|
|
5
|
+
* 这个模块从 session entries 读取首条用户消息,设为 session name,
|
|
6
|
+
* 让 footer 行1 和 terminal title 也能显示。
|
|
7
|
+
* 用户手动 /rename 后不再覆盖。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
function extractFirstMessage(entries: Array<{ type: string; message?: { role: string; content?: unknown } }>): string | undefined {
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (entry.type !== "message" || !entry.message || entry.message.role !== "user") continue;
|
|
15
|
+
|
|
16
|
+
const content = entry.message.content;
|
|
17
|
+
if (typeof content === "string" && content.trim()) {
|
|
18
|
+
return content.trim();
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(content)) {
|
|
21
|
+
for (const part of content) {
|
|
22
|
+
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
23
|
+
return part.text.trim();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setupSessionTitle(pi: ExtensionAPI) {
|
|
32
|
+
pi.on("session_start", (_event, ctx) => {
|
|
33
|
+
if (ctx.sessionManager.getSessionName()) return;
|
|
34
|
+
|
|
35
|
+
const title = extractFirstMessage(ctx.sessionManager.getBranch());
|
|
36
|
+
if (title) {
|
|
37
|
+
pi.setSessionName(title);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings — 配置读写(唯一写文件)
|
|
3
|
+
*
|
|
4
|
+
* 所有其他模块只通过此文件访问 decorated-pi.json
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
10
|
+
|
|
11
|
+
const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions");
|
|
12
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "decorated-pi.json");
|
|
13
|
+
|
|
14
|
+
export interface DecoratedPiConfig {
|
|
15
|
+
imageModelKey?: string | null;
|
|
16
|
+
compactModelKey?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function loadConfig(): DecoratedPiConfig {
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(CONFIG_FILE)) return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
22
|
+
} catch {}
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function saveConfig(config: Partial<DecoratedPiConfig>) {
|
|
27
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
28
|
+
const current = loadConfig();
|
|
29
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...config }, null, 2), "utf-8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── 辅助 ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export function formatModelKey(m: Model<any>): string {
|
|
35
|
+
return `${m.provider}/${m.id}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseModelKey(key: string): { provider: string; modelId: string } | null {
|
|
39
|
+
const i = key.indexOf("/");
|
|
40
|
+
if (i === -1) return null;
|
|
41
|
+
return { provider: key.slice(0, i), modelId: key.slice(i + 1) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Getter ─────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function getImageModelKey(): string | null {
|
|
47
|
+
return loadConfig().imageModelKey ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getCompactModelKey(): string | null {
|
|
51
|
+
return loadConfig().compactModelKey ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Setter ─────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export function setImageModelKey(key: string | null) {
|
|
57
|
+
saveConfig({ imageModelKey: key });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function setCompactModelKey(key: string | null) {
|
|
61
|
+
saveConfig({ compactModelKey: key });
|
|
62
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash — 所有扩展命令
|
|
3
|
+
*
|
|
4
|
+
* /extend-model → 模型选择器 (TAB 切换 Image/Compact)
|
|
5
|
+
* /retry → 中断后继续
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { ModelPickerComponent } from "./extend-model.js";
|
|
10
|
+
|
|
11
|
+
// ─── /extend-model ─────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function setupExtendModelCommand(pi: ExtensionAPI) {
|
|
14
|
+
pi.registerCommand("extend-model", {
|
|
15
|
+
description: "Configure image and compact models",
|
|
16
|
+
handler: async (_args, ctx) => {
|
|
17
|
+
if (ctx.hasUI) {
|
|
18
|
+
await ctx.ui.custom<void>(
|
|
19
|
+
(tui, theme, _kb, done) =>
|
|
20
|
+
new ModelPickerComponent(tui, theme, ctx.modelRegistry, () => done(undefined))
|
|
21
|
+
);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
ctx.ui.notify("extend-model requires interactive mode.", "warning");
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── /retry ────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function setupRetryCommand(pi: ExtensionAPI) {
|
|
32
|
+
let shouldInjectRetryNote = false;
|
|
33
|
+
let retryInProgress = false;
|
|
34
|
+
|
|
35
|
+
pi.registerCommand("retry", {
|
|
36
|
+
description: "Continue after interruption",
|
|
37
|
+
handler: async (_args, ctx) => {
|
|
38
|
+
if (retryInProgress) {
|
|
39
|
+
ctx.ui.notify("Retry is already in progress", "warning");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!ctx.isIdle()) ctx.abort();
|
|
43
|
+
|
|
44
|
+
retryInProgress = true;
|
|
45
|
+
shouldInjectRetryNote = true;
|
|
46
|
+
pi.sendMessage(
|
|
47
|
+
{ customType: "retry-trigger", content: "Continue.", display: false },
|
|
48
|
+
{ triggerTurn: true }
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
pi.on("before_agent_start", async (event) => {
|
|
54
|
+
if (!shouldInjectRetryNote) return;
|
|
55
|
+
shouldInjectRetryNote = false;
|
|
56
|
+
return { systemPrompt: event.systemPrompt + "\n\nThe previous turn was interrupted by the system." };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
pi.on("agent_start", () => { retryInProgress = false; });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── 入口 ───────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export function setupSlash(pi: ExtensionAPI) {
|
|
65
|
+
setupExtendModelCommand(pi);
|
|
66
|
+
setupRetryCommand(pi);
|
|
67
|
+
}
|