decorated-pi 0.2.1 → 0.3.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/README.md +67 -59
- package/extensions/file-times.ts +66 -0
- package/extensions/index.ts +13 -7
- package/extensions/io.ts +406 -0
- package/extensions/lsp/tools.ts +59 -1
- package/extensions/{extend-model.ts → model-integration.ts} +127 -4
- package/extensions/patch.ts +624 -0
- package/extensions/providers/qianfan-coding.ts +1 -1
- package/extensions/{safety.ts → safety/detect.ts} +202 -247
- package/extensions/safety/index.ts +194 -0
- package/extensions/settings.ts +33 -0
- package/extensions/slash.ts +111 -9
- package/extensions/smart-at.ts +339 -111
- package/package.json +2 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety — Pi 集成层
|
|
3
|
+
*
|
|
4
|
+
* - Command Guard: 拦截危险 bash 命令
|
|
5
|
+
* - Redirect Guard: bash 覆盖写入提示确认
|
|
6
|
+
* - Protected Paths: write/edit/patch/read 保护路径提示确认
|
|
7
|
+
* - Write Guard: 覆盖非空文件禁止 write (提示使用 patch)
|
|
8
|
+
* - Secret Redact: API Key / Token 自动掩码
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ExtensionAPI,
|
|
13
|
+
ExtensionContext,
|
|
14
|
+
ToolResultEvent,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
checkProtectedPath,
|
|
20
|
+
collectBashDangers,
|
|
21
|
+
formatBashDangers,
|
|
22
|
+
detectSecrets,
|
|
23
|
+
maskSecret,
|
|
24
|
+
} from "./detect.js";
|
|
25
|
+
|
|
26
|
+
type ToolTextContent = Extract<NonNullable<ToolResultEvent["content"]>[number], { type: "text" }>;
|
|
27
|
+
|
|
28
|
+
function summarizeCommand(command: string, maxLength = 48): string {
|
|
29
|
+
const singleLine = command.replace(/\s+/g, " ").trim();
|
|
30
|
+
if (singleLine.length <= maxLength) return singleLine;
|
|
31
|
+
return `${singleLine.slice(0, maxLength - 1)}…`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatRedactionContext(event: ToolResultEvent): string {
|
|
35
|
+
if (event.toolName === "read") {
|
|
36
|
+
const filePath = (event.input as any)?.path ?? (event.input as any)?.file ?? (event.input as any)?.file_path;
|
|
37
|
+
return filePath ? `read ${filePath}` : "read";
|
|
38
|
+
}
|
|
39
|
+
if (event.toolName === "bash") {
|
|
40
|
+
const command = (event.input as any)?.command;
|
|
41
|
+
return typeof command === "string" && command.trim().length > 0
|
|
42
|
+
? `bash ${summarizeCommand(command)}`
|
|
43
|
+
: "bash";
|
|
44
|
+
}
|
|
45
|
+
return event.toolName;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Setup ──────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export function setupSafety(pi: ExtensionAPI) {
|
|
51
|
+
// ── Command Guard + Protected Paths + Write Guard (tool_call) ─────────
|
|
52
|
+
|
|
53
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
54
|
+
|
|
55
|
+
// Gate 1: 危险命令 + 覆盖写入 + 读取保护路径
|
|
56
|
+
if (event.toolName === "bash") {
|
|
57
|
+
const command = (event.input as { command?: string }).command;
|
|
58
|
+
if (command) {
|
|
59
|
+
const dangers = collectBashDangers(command, ctx.cwd);
|
|
60
|
+
if (dangers.length > 0) {
|
|
61
|
+
const message = formatBashDangers(dangers)!;
|
|
62
|
+
if (!ctx.hasUI) {
|
|
63
|
+
return { block: true, reason: `⚠ ${message} (non-interactive)` };
|
|
64
|
+
}
|
|
65
|
+
const choice = await ctx.ui.select(
|
|
66
|
+
`⚠️ ${message}\n\nAllow execution?`,
|
|
67
|
+
["Block", "Allow once"],
|
|
68
|
+
);
|
|
69
|
+
if (!choice || choice === "Block") {
|
|
70
|
+
return { block: true, reason: `⚠ ${message}` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Gate 2: write/edit/patch 写入保护路径
|
|
77
|
+
if (event.toolName === "write" || event.toolName === "edit" || event.toolName === "patch") {
|
|
78
|
+
// For write/edit, path is a single field; for patch, check all patches[].path
|
|
79
|
+
const filePaths: string[] = event.toolName === "patch"
|
|
80
|
+
? (event.input as any).patches?.filter((p: any) => p?.path).map((p: any) => p.path) ?? []
|
|
81
|
+
: [(event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path].filter(Boolean);
|
|
82
|
+
for (const filePath of filePaths) {
|
|
83
|
+
const danger = checkProtectedPath(filePath);
|
|
84
|
+
if (danger) {
|
|
85
|
+
if (!ctx.hasUI) {
|
|
86
|
+
return { block: true, reason: `🔒 ${danger}\nmay contain sensitive information` };
|
|
87
|
+
}
|
|
88
|
+
const choice = await ctx.ui.select(
|
|
89
|
+
`🔒 ${danger}\nmay contain sensitive information\n\nProceed?`,
|
|
90
|
+
["Block", "Allow once"],
|
|
91
|
+
);
|
|
92
|
+
if (!choice || choice === "Block") {
|
|
93
|
+
return { block: true, reason: `🔒 ${danger}\nmay contain sensitive information` };
|
|
94
|
+
}
|
|
95
|
+
break; // User approved — skip remaining paths
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Gate 3: 写保护(已有内容的文件禁止 write,直接返回信息给 agent)
|
|
101
|
+
if (event.toolName === "write") {
|
|
102
|
+
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
103
|
+
if (filePath) {
|
|
104
|
+
try {
|
|
105
|
+
const abs = resolve(ctx.cwd, filePath);
|
|
106
|
+
if (fs.existsSync(abs) && fs.readFileSync(abs, "utf8").length > 0) {
|
|
107
|
+
return { block: true, reason: "Overwriting a non-empty file is dangerous, use the patch tool instead!" };
|
|
108
|
+
}
|
|
109
|
+
} catch { /* file doesn't exist */ }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Gate 4: read 工具读取保护路径(bash 读取已在 Gate 1 处理)
|
|
114
|
+
if (event.toolName === "read") {
|
|
115
|
+
const filePath = (event.input as any).path ?? (event.input as any).file ?? (event.input as any).file_path;
|
|
116
|
+
if (filePath) {
|
|
117
|
+
const danger = checkProtectedPath(filePath);
|
|
118
|
+
if (danger) {
|
|
119
|
+
if (!ctx.hasUI) {
|
|
120
|
+
return { block: true, reason: `🔒 Reading protected file: ${danger}\nmay contain sensitive information` };
|
|
121
|
+
}
|
|
122
|
+
const choice = await ctx.ui.select(
|
|
123
|
+
`🔒 Reading protected file: ${danger}\nmay contain sensitive information\n\nProceed?`,
|
|
124
|
+
["Block", "Allow once"],
|
|
125
|
+
);
|
|
126
|
+
if (!choice || choice === "Block") {
|
|
127
|
+
return { block: true, reason: `🔒 Reading protected file: ${danger}\nmay contain sensitive information` };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── Secret Redact (tool_result) ────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
const handleToolResult = async (
|
|
137
|
+
event: ToolResultEvent,
|
|
138
|
+
ctx: ExtensionContext,
|
|
139
|
+
): Promise<{ content?: NonNullable<ToolResultEvent["content"]> } | void> => {
|
|
140
|
+
if (!event.content || !Array.isArray(event.content)) return;
|
|
141
|
+
|
|
142
|
+
// Scan read + bash tool output. Skip write/edit/patch because they mainly
|
|
143
|
+
// produce diffs or generated file bodies, which are handled elsewhere and are
|
|
144
|
+
// more prone to noisy false positives.
|
|
145
|
+
if (event.toolName !== "read" && event.toolName !== "bash") return;
|
|
146
|
+
|
|
147
|
+
const textParts: Array<{ index: number; text: string; item: ToolTextContent }> = [];
|
|
148
|
+
for (let i = 0; i < event.content.length; i++) {
|
|
149
|
+
const item = event.content[i];
|
|
150
|
+
if (item.type === "text" && typeof item.text === "string" && item.text.length > 0) {
|
|
151
|
+
textParts.push({ index: i, text: item.text, item });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (textParts.length === 0) return;
|
|
155
|
+
|
|
156
|
+
let totalCount = 0;
|
|
157
|
+
const counts: Record<"pattern" | "regex" | "entropy", number> = {
|
|
158
|
+
pattern: 0,
|
|
159
|
+
regex: 0,
|
|
160
|
+
entropy: 0,
|
|
161
|
+
};
|
|
162
|
+
const newContent = [...event.content];
|
|
163
|
+
|
|
164
|
+
const filePath = (event.input as any)?.path ?? (event.input as any)?.file ?? (event.input as any)?.file_path;
|
|
165
|
+
|
|
166
|
+
for (const { index, text, item } of textParts) {
|
|
167
|
+
const matches = detectSecrets(text, { filePath });
|
|
168
|
+
if (matches.length === 0) continue;
|
|
169
|
+
|
|
170
|
+
totalCount += matches.length;
|
|
171
|
+
let redacted = text;
|
|
172
|
+
for (const { start, end, source } of matches) {
|
|
173
|
+
counts[source] += 1;
|
|
174
|
+
const original = redacted.slice(start, end);
|
|
175
|
+
redacted = redacted.slice(0, start) + maskSecret(original, source) + redacted.slice(end);
|
|
176
|
+
}
|
|
177
|
+
const updatedItem: ToolTextContent = { ...item, text: redacted };
|
|
178
|
+
newContent[index] = updatedItem;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (totalCount === 0) return;
|
|
182
|
+
const label = totalCount === 1 ? "1 secret" : `${totalCount} secrets`;
|
|
183
|
+
const breakdown: string[] = [];
|
|
184
|
+
if (counts.pattern > 0) breakdown.push(`*:pattern=${counts.pattern}`);
|
|
185
|
+
if (counts.regex > 0) breakdown.push(`#:regex=${counts.regex}`);
|
|
186
|
+
if (counts.entropy > 0) breakdown.push(`?:entropy=${counts.entropy}`);
|
|
187
|
+
const suffix = breakdown.length > 0 ? ` · ${breakdown.join(" ")}` : "";
|
|
188
|
+
const contextLabel = formatRedactionContext(event);
|
|
189
|
+
ctx.ui.notify(`🔒 [${contextLabel}] Redacted ${label}${suffix}`, "warning");
|
|
190
|
+
return { content: newContent };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
pi.on("tool_result", handleToolResult);
|
|
194
|
+
}
|
package/extensions/settings.ts
CHANGED
|
@@ -25,10 +25,18 @@ export interface ProviderCache {
|
|
|
25
25
|
models: ProviderModelEntry[];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface ModuleSettings {
|
|
29
|
+
safety?: boolean;
|
|
30
|
+
lsp?: boolean;
|
|
31
|
+
"smart-at"?: boolean;
|
|
32
|
+
patch?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
export interface DecoratedPiConfig {
|
|
29
36
|
imageModelKey?: string | null;
|
|
30
37
|
compactModelKey?: string | null;
|
|
31
38
|
providers?: Record<string, ProviderCache>;
|
|
39
|
+
modules?: ModuleSettings;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
export function loadConfig(): DecoratedPiConfig {
|
|
@@ -97,3 +105,28 @@ export function setImageModelKey(key: string | null) {
|
|
|
97
105
|
export function setCompactModelKey(key: string | null) {
|
|
98
106
|
saveConfig({ compactModelKey: key });
|
|
99
107
|
}
|
|
108
|
+
|
|
109
|
+
// ─── Module Switches ──────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const DEFAULT_MODULES: Required<ModuleSettings> = {
|
|
112
|
+
safety: true,
|
|
113
|
+
lsp: true,
|
|
114
|
+
"smart-at": true,
|
|
115
|
+
patch: true,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export function isModuleEnabled(name: keyof ModuleSettings): boolean {
|
|
119
|
+
const modules = loadConfig().modules ?? {};
|
|
120
|
+
return modules[name] ?? DEFAULT_MODULES[name] ?? true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function setModuleEnabled(name: keyof ModuleSettings, enabled: boolean) {
|
|
124
|
+
const modules = { ...loadConfig().modules };
|
|
125
|
+
modules[name] = enabled;
|
|
126
|
+
saveConfig({ modules });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getAllModuleSettings(): Required<ModuleSettings> {
|
|
130
|
+
const modules = loadConfig().modules ?? {};
|
|
131
|
+
return { ...DEFAULT_MODULES, ...modules };
|
|
132
|
+
}
|
package/extensions/slash.ts
CHANGED
|
@@ -1,17 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Slash — 所有扩展命令
|
|
3
3
|
*
|
|
4
|
-
* /
|
|
5
|
-
* /
|
|
4
|
+
* /dp-model → 模型选择器 (TAB 切换 Image/Compact)
|
|
5
|
+
* /dp-settings → 模块开关 (patch / safety / lsp / smart-at)
|
|
6
|
+
* /retry → 中断后继续
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { ModelPickerComponent } from "./
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext, Theme as PiTheme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { ModelPickerComponent } from "./model-integration.js";
|
|
11
|
+
import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "./settings.js";
|
|
12
|
+
import { Container, SettingsList, type TUI, type SettingsListTheme, type Component } from "@earendil-works/pi-tui";
|
|
10
13
|
|
|
11
|
-
// ───
|
|
14
|
+
// ─── Border component (matches native DynamicBorder) ────────────────────────
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
class DynamicBorder implements Component {
|
|
17
|
+
private colorFn: (str: string) => string;
|
|
18
|
+
|
|
19
|
+
constructor(theme: PiTheme) {
|
|
20
|
+
this.colorFn = (str: string) => theme.fg("border", str);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
invalidate() {}
|
|
24
|
+
|
|
25
|
+
render(width: number): string[] {
|
|
26
|
+
return [this.colorFn("─".repeat(Math.max(1, width)))];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── SettingsList Theme (matches native getSettingsListTheme) ───────────────
|
|
31
|
+
|
|
32
|
+
function getSettingsListTheme(theme: PiTheme): SettingsListTheme {
|
|
33
|
+
return {
|
|
34
|
+
label: (text: string, selected: boolean) => selected ? theme.fg("accent", text) : text,
|
|
35
|
+
value: (text: string, selected: boolean) => selected ? theme.fg("accent", text) : theme.fg("muted", text),
|
|
36
|
+
description: (text: string) => theme.fg("dim", text),
|
|
37
|
+
cursor: theme.fg("accent", "→ "),
|
|
38
|
+
hint: (text: string) => theme.fg("dim", text),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── /dp-model ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function setupDpModelCommand(pi: ExtensionAPI) {
|
|
45
|
+
pi.registerCommand("dp-model", {
|
|
15
46
|
description: "Configure image and compact models",
|
|
16
47
|
handler: async (_args, ctx) => {
|
|
17
48
|
if (ctx.hasUI) {
|
|
@@ -21,7 +52,77 @@ function setupExtendModelCommand(pi: ExtensionAPI) {
|
|
|
21
52
|
);
|
|
22
53
|
return;
|
|
23
54
|
}
|
|
24
|
-
ctx.ui.notify("
|
|
55
|
+
ctx.ui.notify("dp-model requires interactive mode.", "warning");
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── /dp-settings ──────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const MODULE_LABELS: Record<keyof ModuleSettings, string> = {
|
|
63
|
+
patch: "Patch Tool",
|
|
64
|
+
safety: "Safety Layer",
|
|
65
|
+
lsp: "LSP Tools",
|
|
66
|
+
"smart-at": "Smart @ Search",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const MODULE_DESCS: Record<keyof ModuleSettings, string> = {
|
|
70
|
+
patch: "Replace edit/write with patch tool (old_str/new_str replacement + overwrite)",
|
|
71
|
+
safety: "Command guard, protected paths, read guard, secret redaction",
|
|
72
|
+
lsp: "Language server diagnostics, hover, definition, references, symbols, rename",
|
|
73
|
+
"smart-at": "Project-aware file search replacing default autocomplete",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
class ModuleSettingsComponent extends Container {
|
|
77
|
+
private settingsList: SettingsList;
|
|
78
|
+
|
|
79
|
+
constructor(tui: TUI, theme: PiTheme, onDone: () => void) {
|
|
80
|
+
super();
|
|
81
|
+
const modules = getAllModuleSettings();
|
|
82
|
+
const keys = Object.keys(MODULE_LABELS) as (keyof ModuleSettings)[];
|
|
83
|
+
|
|
84
|
+
const items = keys.map(k => ({
|
|
85
|
+
id: k,
|
|
86
|
+
label: MODULE_LABELS[k],
|
|
87
|
+
description: MODULE_DESCS[k],
|
|
88
|
+
currentValue: modules[k] ? "on" : "off",
|
|
89
|
+
values: ["on", "off"],
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
this.addChild(new DynamicBorder(theme));
|
|
93
|
+
|
|
94
|
+
this.settingsList = new SettingsList(
|
|
95
|
+
items, 10, getSettingsListTheme(theme),
|
|
96
|
+
(id: string, newValue: string) => {
|
|
97
|
+
setModuleEnabled(id as keyof ModuleSettings, newValue === "on");
|
|
98
|
+
tui.requestRender();
|
|
99
|
+
},
|
|
100
|
+
() => onDone(),
|
|
101
|
+
{ enableSearch: true },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
this.addChild(this.settingsList);
|
|
105
|
+
this.addChild(new DynamicBorder(theme));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
handleInput(data: string) {
|
|
109
|
+
this.settingsList.handleInput(data);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function setupDpSettingsCommand(pi: ExtensionAPI) {
|
|
114
|
+
pi.registerCommand("dp-settings", {
|
|
115
|
+
description: "Toggle decorated-pi modules on/off",
|
|
116
|
+
handler: async (_args, ctx) => {
|
|
117
|
+
if (ctx.hasUI) {
|
|
118
|
+
await ctx.ui.custom<void>(
|
|
119
|
+
(tui, theme, _kb, done) =>
|
|
120
|
+
new ModuleSettingsComponent(tui, theme, () => done(undefined))
|
|
121
|
+
);
|
|
122
|
+
ctx.ui.notify("Module settings updated. /reload to apply.", "info");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
ctx.ui.notify("dp-settings requires interactive mode.", "warning");
|
|
25
126
|
},
|
|
26
127
|
});
|
|
27
128
|
}
|
|
@@ -62,6 +163,7 @@ function setupRetryCommand(pi: ExtensionAPI) {
|
|
|
62
163
|
// ─── 入口 ───────────────────────────────────────────────────────────────────
|
|
63
164
|
|
|
64
165
|
export function setupSlash(pi: ExtensionAPI) {
|
|
65
|
-
|
|
166
|
+
setupDpModelCommand(pi);
|
|
167
|
+
setupDpSettingsCommand(pi);
|
|
66
168
|
setupRetryCommand(pi);
|
|
67
169
|
}
|