decorated-pi 0.5.4 → 0.6.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/.codegraph/daemon.pid +6 -0
- package/AGENTS.md +92 -0
- package/README.md +53 -64
- package/commands/dp-model.ts +23 -0
- package/commands/dp-settings.ts +23 -0
- package/commands/mcp-status.ts +62 -0
- package/commands/retry.ts +19 -0
- package/commands/usage.ts +544 -0
- package/hooks/compaction.ts +204 -0
- package/hooks/externalize.ts +70 -0
- package/hooks/image-vision.ts +132 -0
- package/hooks/inject-agents-md.ts +164 -0
- package/hooks/mcp.ts +340 -0
- package/hooks/normalize-codeblocks.ts +88 -0
- package/hooks/pi-tool-filter.ts +28 -0
- package/{extensions/safety → hooks/redact}/types.ts +1 -8
- package/hooks/redact.ts +104 -0
- package/{extensions → hooks}/rtk.ts +92 -115
- package/hooks/session-title.ts +54 -0
- package/hooks/skeleton.ts +212 -0
- package/hooks/smart-at.ts +318 -0
- package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
- package/{extensions → hooks}/wakatime.ts +120 -122
- package/index.ts +155 -1
- package/package.json +5 -4
- package/{extensions/settings.ts → settings.ts} +32 -0
- package/{extensions → tools}/lsp/client.ts +0 -25
- package/{extensions → tools}/lsp/format.ts +2 -99
- package/{extensions → tools}/lsp/index.ts +1 -1
- package/{extensions → tools}/lsp/servers.ts +1 -1
- package/{extensions → tools}/lsp/tools.ts +1 -66
- package/{extensions → tools}/lsp/types.ts +0 -11
- package/tools/mcp/builtin/codegraph.ts +45 -0
- package/tools/mcp/builtin/context7.ts +10 -0
- package/tools/mcp/builtin/exa.ts +10 -0
- package/tools/mcp/builtin/index.ts +19 -0
- package/tools/mcp/cache.ts +124 -0
- package/{extensions → tools}/mcp/client.ts +2 -2
- package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
- package/tools/mcp/index.ts +44 -0
- package/tools/mcp/tool-definition.ts +112 -0
- package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
- package/{extensions/io.ts → tools/patch/index.ts} +29 -205
- package/tsconfig.json +10 -1
- package/ui/mcp-status.ts +202 -0
- package/ui/model-picker.ts +162 -0
- package/ui/module-settings.ts +83 -0
- package/ui/usage.ts +396 -0
- package/extensions/index.ts +0 -154
- package/extensions/io-tool-output.ts +0 -33
- package/extensions/mcp/index.ts +0 -435
- package/extensions/model-integration.ts +0 -531
- package/extensions/providers/ark-coding.ts +0 -75
- package/extensions/providers/index.ts +0 -9
- package/extensions/providers/ollama-cloud.ts +0 -101
- package/extensions/providers/qianfan-coding.ts +0 -71
- package/extensions/safety/index.ts +0 -102
- package/extensions/session-title.ts +0 -40
- package/extensions/slash.ts +0 -458
- package/extensions/smart-at.ts +0 -481
- package/extensions/subdir-agents.ts +0 -151
- /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
- /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
- /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
- /package/{extensions → tools}/lsp/env.ts +0 -0
- /package/{extensions → tools}/lsp/manager.ts +0 -0
- /package/{extensions → tools}/lsp/prompt.ts +0 -0
- /package/{extensions → tools}/lsp/protocol.ts +0 -0
package/hooks/mcp.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp — MCP connection lifecycle (session_start → connect, session_shutdown → disconnect).
|
|
3
|
+
*
|
|
4
|
+
* Tool registration is in tools/mcp/; this hook only manages the
|
|
5
|
+
* activeConnections array and cache persistence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { McpConnection } from "../tools/mcp/client.js";
|
|
10
|
+
import { resolveMcpConfigs, type McpServerConfig } from "../tools/mcp/config.js";
|
|
11
|
+
import { loadMcpCache, loadScopedMcpCache, updateServerCache, cleanupStaleCache, type McpToolCache } from "../tools/mcp/cache.js";
|
|
12
|
+
import { buildMcpTool } from "../tools/mcp/tool-definition.js";
|
|
13
|
+
import type { Module, Skeleton } from "./skeleton.js";
|
|
14
|
+
|
|
15
|
+
let activeConnections: McpConnection[] = [];
|
|
16
|
+
let allServers = new Map<string, any>();
|
|
17
|
+
let cachedConfigs: McpServerConfig[] = [];
|
|
18
|
+
let cachedCwd = "";
|
|
19
|
+
|
|
20
|
+
interface ServerStatus {
|
|
21
|
+
name: string;
|
|
22
|
+
url: string;
|
|
23
|
+
source: string;
|
|
24
|
+
state: "connecting" | "connected" | "failed" | "disabled" | "waiting_reload";
|
|
25
|
+
toolCount: number;
|
|
26
|
+
tools: Array<{ name: string; description?: string; inputSchema?: Record<string, unknown> }>;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function cacheScopeForSource(source: string): "global" | "project" {
|
|
31
|
+
return source === "project" ? "project" : "global";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function connectAll(
|
|
35
|
+
configs: McpServerConfig[],
|
|
36
|
+
registry: any,
|
|
37
|
+
ctx?: any,
|
|
38
|
+
): Promise<{ schemaChanges: string[]; hasNewServer: boolean }> {
|
|
39
|
+
const cache = loadMcpCache(cachedCwd);
|
|
40
|
+
const schemaChanges: string[] = [];
|
|
41
|
+
|
|
42
|
+
// IMPORTANT: do NOT reassign `allServers`. After /reload, multiple
|
|
43
|
+
// connectAll calls can race — if the new one reassigns, the old
|
|
44
|
+
// in-flight connections' `allServers.set("connected")` updates a
|
|
45
|
+
// detached Map that the UI never reads. Mutate in place instead so
|
|
46
|
+
// every caller's updates are visible.
|
|
47
|
+
allServers.clear();
|
|
48
|
+
for (const s of configs) {
|
|
49
|
+
allServers.set(s.name, { name: s.name, url: s.url ?? s.command ?? "(unknown)", source: s.source, state: "connecting", toolCount: 0, tools: [] });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Watchdog: the inner Promise.race in McpConnection.connect() has a
|
|
53
|
+
// 30s setTimeout, but in some environments (MCP SDK fetch hanging,
|
|
54
|
+
// event-loop pressure) that timer never fires and the connection
|
|
55
|
+
// promise never settles. After 35s we force-mark any still-connecting
|
|
56
|
+
// server as failed so the UI doesn't get stuck on "connecting" forever,
|
|
57
|
+
// and we make connectAll() return so a subsequent /reload can start a
|
|
58
|
+
// fresh connection attempt without racing a zombie Promise.all.
|
|
59
|
+
let watchdogFired = false;
|
|
60
|
+
const watchdog = new Promise<void>((resolve) => {
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
watchdogFired = true;
|
|
63
|
+
for (const config of configs) {
|
|
64
|
+
const server = allServers.get(config.name);
|
|
65
|
+
if (server && server.state === "connecting") {
|
|
66
|
+
server.state = "failed";
|
|
67
|
+
server.error = "Watchdog: connection did not settle within 35s (inner timeout missed)";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
resolve();
|
|
71
|
+
}, 35_000);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Connect SERIALLY, not via Promise.all. Three concurrent
|
|
75
|
+
// conn.connect() calls in a Promise.all fan-out reliably hang in the
|
|
76
|
+
// user's environment (likely the MCP SDK's HTTP transport is sensitive
|
|
77
|
+
// to concurrent DNS / connection-pool state). refreshServerCache
|
|
78
|
+
// (single, sequential) works fine — and pressing 'r' on a hung
|
|
79
|
+
// server in the UI also recovers it, which is what pointed us here.
|
|
80
|
+
let hasNewServer = false;
|
|
81
|
+
for (const server of configs) {
|
|
82
|
+
if (watchdogFired) break; // remaining servers are already failed
|
|
83
|
+
|
|
84
|
+
const conn = new McpConnection(server.name, server);
|
|
85
|
+
conn.source = server.source;
|
|
86
|
+
try {
|
|
87
|
+
await conn.connect(30_000);
|
|
88
|
+
if (watchdogFired) {
|
|
89
|
+
try { await conn.disconnect(); } catch { /* ignore */ }
|
|
90
|
+
return { schemaChanges, hasNewServer };
|
|
91
|
+
}
|
|
92
|
+
activeConnections.push(conn);
|
|
93
|
+
const actualTools = conn.tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }));
|
|
94
|
+
const cachedEntry = cache?.servers[server.name];
|
|
95
|
+
if (cachedEntry && cachedEntry.tools.length > 0) {
|
|
96
|
+
const cachedToolNames = new Set(cachedEntry.tools.map(t => t.name));
|
|
97
|
+
const added = actualTools.filter(t => !cachedToolNames.has(t.name));
|
|
98
|
+
const removed = cachedEntry.tools.filter(t => !cachedToolNames.has(t.name));
|
|
99
|
+
const changed = actualTools.filter(t => {
|
|
100
|
+
const cached = cachedEntry.tools.find(ct => ct.name === t.name);
|
|
101
|
+
return cached && JSON.stringify(cached.inputSchema) !== JSON.stringify(t.inputSchema);
|
|
102
|
+
});
|
|
103
|
+
if (added.length > 0 || removed.length > 0 || changed.length > 0) {
|
|
104
|
+
const parts: string[] = [];
|
|
105
|
+
if (added.length) parts.push(`${added.length} added`);
|
|
106
|
+
if (removed.length) parts.push(`${removed.length} removed`);
|
|
107
|
+
if (changed.length) parts.push(`${changed.length} changed`);
|
|
108
|
+
schemaChanges.push(`${server.name} (${parts.join(', ')})`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
allServers.set(server.name, {
|
|
112
|
+
name: server.name, url: server.url ?? server.command ?? "(unknown)", source: server.source,
|
|
113
|
+
state: "connected", toolCount: conn.tools.length, tools: actualTools,
|
|
114
|
+
});
|
|
115
|
+
const tools: McpToolCache[] = conn.tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }));
|
|
116
|
+
updateServerCache(server.name, { tools, cachedAt: Date.now() }, cacheScopeForSource(server.source), cachedCwd || undefined);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (watchdogFired) return { schemaChanges, hasNewServer };
|
|
119
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
120
|
+
allServers.set(server.name, {
|
|
121
|
+
name: server.name, url: server.url ?? server.command ?? "(unknown)", source: server.source,
|
|
122
|
+
state: "failed", toolCount: 0, tools: [], error: msg,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Watchdog is no longer raced with the work — the for-loop above is
|
|
128
|
+
// already sequential and bounded by each conn.connect()'s own 30s
|
|
129
|
+
// timeout. We still keep a 35s ceiling for any single server just in
|
|
130
|
+
// case the inner timeout ever stops firing again.
|
|
131
|
+
void watchdog;
|
|
132
|
+
|
|
133
|
+
for (const server of configs) {
|
|
134
|
+
const cachedEntry = cache?.servers[server.name];
|
|
135
|
+
if (!cachedEntry || cachedEntry.tools.length === 0) { hasNewServer = true; break; }
|
|
136
|
+
}
|
|
137
|
+
return { schemaChanges, hasNewServer };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getMcpStatus(): ServerStatus[] {
|
|
141
|
+
const cache = loadMcpCache(cachedCwd);
|
|
142
|
+
const result: ServerStatus[] = [];
|
|
143
|
+
for (const config of cachedConfigs) {
|
|
144
|
+
const connected = allServers.get(config.name);
|
|
145
|
+
if (connected) { result.push(connected); continue; }
|
|
146
|
+
const cachedEntry = cache?.servers[config.name];
|
|
147
|
+
// Not in allServers: either disabled at startup (connectAll skipped it)
|
|
148
|
+
// or was toggled enabled after startup and needs /reload.
|
|
149
|
+
result.push({
|
|
150
|
+
name: config.name, url: config.url ?? config.command ?? "(unknown)", source: config.source,
|
|
151
|
+
state: config.enabled ? "waiting_reload" : "disabled",
|
|
152
|
+
toolCount: cachedEntry?.tools.length ?? 0, tools: cachedEntry?.tools ?? [],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function updateConfigEnabled(serverName: string, enabled: boolean): Promise<void> {
|
|
159
|
+
const config = cachedConfigs.find(c => c.name === serverName);
|
|
160
|
+
if (config) config.enabled = enabled;
|
|
161
|
+
|
|
162
|
+
if (!enabled) {
|
|
163
|
+
// Tear down the live connection immediately so teardownMcp() on
|
|
164
|
+
// /reload doesn't have to deal with a zombie conn.
|
|
165
|
+
const conn = activeConnections.find(c => c.serverName === serverName);
|
|
166
|
+
if (conn) {
|
|
167
|
+
try { await conn.disconnect(); } catch { /* ignore */ }
|
|
168
|
+
activeConnections = activeConnections.filter(c => c.serverName !== serverName);
|
|
169
|
+
}
|
|
170
|
+
// Mark the server as disabled in allServers (or add it if absent).
|
|
171
|
+
allServers.set(serverName, {
|
|
172
|
+
name: serverName,
|
|
173
|
+
url: config?.url ?? config?.command ?? "(unknown)",
|
|
174
|
+
source: config?.source ?? "unknown",
|
|
175
|
+
state: "disabled",
|
|
176
|
+
toolCount: 0,
|
|
177
|
+
tools: [],
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Re-enabling at runtime: the config is now enabled but tools aren't
|
|
183
|
+
// registered until the next session_start. Mark it as waiting_reload
|
|
184
|
+
// so the user sees exactly why the server isn't usable yet.
|
|
185
|
+
allServers.set(serverName, {
|
|
186
|
+
name: serverName,
|
|
187
|
+
url: config?.url ?? config?.command ?? "(unknown)",
|
|
188
|
+
source: config?.source ?? "unknown",
|
|
189
|
+
state: "waiting reload",
|
|
190
|
+
toolCount: 0,
|
|
191
|
+
tools: [],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function refreshServerCache(serverName: string, registry: any): Promise<{ ok: boolean; error?: string }> {
|
|
196
|
+
const config = resolveMcpConfigs(cachedCwd).find(s => s.name === serverName);
|
|
197
|
+
if (!config) return { ok: false, error: `Server "${serverName}" not found in config.` };
|
|
198
|
+
const existing = activeConnections.find(c => c.serverName === serverName);
|
|
199
|
+
if (existing) {
|
|
200
|
+
try { await existing.disconnect(); } catch { /* ignore */ }
|
|
201
|
+
activeConnections = activeConnections.filter(c => c.serverName !== serverName);
|
|
202
|
+
}
|
|
203
|
+
const conn = new McpConnection(config.name, config);
|
|
204
|
+
conn.source = config.source;
|
|
205
|
+
try {
|
|
206
|
+
await conn.connect(30_000);
|
|
207
|
+
activeConnections.push(conn);
|
|
208
|
+
allServers.set(config.name, {
|
|
209
|
+
name: config.name, url: config.url ?? config.command ?? "(unknown)", source: config.source,
|
|
210
|
+
state: "connected", toolCount: conn.tools.length,
|
|
211
|
+
tools: conn.tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
212
|
+
});
|
|
213
|
+
const tools: McpToolCache[] = conn.tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }));
|
|
214
|
+
updateServerCache(config.name, { tools, cachedAt: Date.now() }, cacheScopeForSource(config.source), cachedCwd || undefined);
|
|
215
|
+
return { ok: true };
|
|
216
|
+
} catch (err) {
|
|
217
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
218
|
+
allServers.set(config.name, { name: config.name, url: config.url ?? config.command ?? "(unknown)", source: config.source, state: "failed", toolCount: 0, tools: [], error: msg });
|
|
219
|
+
return { ok: false, error: msg };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function getActiveMcpConnections(): McpConnection[] {
|
|
224
|
+
return activeConnections;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function getCachedMcpConfigs(): McpServerConfig[] {
|
|
228
|
+
return cachedConfigs;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Ensure one MCP server is ready and its tools are registered with pi.
|
|
233
|
+
*
|
|
234
|
+
* Behavior:
|
|
235
|
+
* - Cache hit → register tools from cache immediately (fast path).
|
|
236
|
+
* - Cache miss → connect synchronously, write the cache, then
|
|
237
|
+
* register the live tools.
|
|
238
|
+
* - Connection failure + no cache → no tools registered (per design).
|
|
239
|
+
*
|
|
240
|
+
* Side effect: pushes a McpConnection into `activeConnections` so
|
|
241
|
+
* later `execute` calls (via buildMcpTool's findConnection callback)
|
|
242
|
+
* can find it.
|
|
243
|
+
*/
|
|
244
|
+
export async function ensureMcpServerReady(pi: ExtensionAPI, config: McpServerConfig, cwd?: string): Promise<void> {
|
|
245
|
+
const scope = cacheScopeForSource(config.source);
|
|
246
|
+
const cache = loadScopedMcpCache(scope, cwd);
|
|
247
|
+
const entry = cache?.servers[config.name];
|
|
248
|
+
const findConnection = (name: string) => activeConnections.find(c => c.serverName === name);
|
|
249
|
+
|
|
250
|
+
if (entry && entry.tools.length > 0) {
|
|
251
|
+
for (const t of entry.tools) {
|
|
252
|
+
try {
|
|
253
|
+
pi.registerTool(buildMcpTool(config, t, findConnection) as any);
|
|
254
|
+
} catch { /* duplicate name; previous registration still in effect */ }
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (activeConnections.find(c => c.serverName === config.name)) return;
|
|
260
|
+
|
|
261
|
+
const conn = new McpConnection(config.name, config);
|
|
262
|
+
conn.source = config.source;
|
|
263
|
+
try {
|
|
264
|
+
await conn.connect(30_000);
|
|
265
|
+
activeConnections.push(conn);
|
|
266
|
+
const tools: McpToolCache[] = conn.tools.map(t => ({
|
|
267
|
+
name: t.name,
|
|
268
|
+
description: t.description,
|
|
269
|
+
inputSchema: t.inputSchema,
|
|
270
|
+
}));
|
|
271
|
+
updateServerCache(config.name, { tools, cachedAt: Date.now() }, scope, cwd);
|
|
272
|
+
for (const t of tools) {
|
|
273
|
+
try {
|
|
274
|
+
pi.registerTool(buildMcpTool(config, t, findConnection) as any);
|
|
275
|
+
} catch { /* duplicate name */ }
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
279
|
+
allServers.set(config.name, {
|
|
280
|
+
name: config.name,
|
|
281
|
+
url: config.url ?? config.command ?? "(unknown)",
|
|
282
|
+
source: config.source,
|
|
283
|
+
state: "failed",
|
|
284
|
+
toolCount: 0,
|
|
285
|
+
tools: [],
|
|
286
|
+
error: `No cache and initial connect failed: ${msg}`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export const mcpModule: Module = {
|
|
292
|
+
name: "mcp",
|
|
293
|
+
hooks: {
|
|
294
|
+
session_start: [
|
|
295
|
+
async (_event, ctx) => {
|
|
296
|
+
await teardownMcp();
|
|
297
|
+
cachedCwd = ctx.cwd;
|
|
298
|
+
const configs = resolveMcpConfigs(ctx.cwd).sort((a, b) => a.name.localeCompare(b.name));
|
|
299
|
+
cachedConfigs = configs;
|
|
300
|
+
if (configs.length === 0) return;
|
|
301
|
+
cleanupStaleCache(configs, cachedCwd);
|
|
302
|
+
const enabledConfigs = configs.filter(s => s.enabled);
|
|
303
|
+
|
|
304
|
+
// connectAll only needs to handle servers that weren't already
|
|
305
|
+
// connected by index.ts's ensureMcpServerReady. We don't
|
|
306
|
+
// teardown first: the initial-sync connections are still
|
|
307
|
+
// healthy and we don't want to disconnect + reconnect on
|
|
308
|
+
// every session_start.
|
|
309
|
+
const toConnect = enabledConfigs.filter(s => !activeConnections.find(c => c.serverName === s.name));
|
|
310
|
+
if (toConnect.length === 0) return;
|
|
311
|
+
|
|
312
|
+
// connectAll runs in the background so the watchdog tests
|
|
313
|
+
// that mock hung connections don't block session_start.
|
|
314
|
+
void connectAll(toConnect, ctx.modelRegistry, ctx).then(({ schemaChanges }) => {
|
|
315
|
+
if (schemaChanges.length > 0 && ctx.hasUI) {
|
|
316
|
+
ctx.ui.notify(`mcp schema changed! please '/reload'`, "warning");
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
session_shutdown: [
|
|
322
|
+
async () => { await teardownMcp(); },
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export async function teardownMcp(): Promise<void> {
|
|
328
|
+
await Promise.all(
|
|
329
|
+
activeConnections.map(async (conn) => {
|
|
330
|
+
try { await conn.disconnect(); } catch { /* ignore */ }
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
activeConnections = [];
|
|
334
|
+
allServers.clear();
|
|
335
|
+
cachedConfigs = [];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function setupMcp(sk: Skeleton, _pi: ExtensionAPI): void {
|
|
339
|
+
sk.register(mcpModule);
|
|
340
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* normalize-codeblocks — fix markdown code block rendering in TUI.
|
|
3
|
+
*
|
|
4
|
+
* pi-tui's Markdown renderer doesn't expand tabs or handle CRLF in code
|
|
5
|
+
* blocks, causing garbled output (empty lines, misaligned content).
|
|
6
|
+
*
|
|
7
|
+
* This hook intercepts tool_result events and normalizes fenced code
|
|
8
|
+
* blocks (``` or ~~~):
|
|
9
|
+
* - CRLF → LF (entire text)
|
|
10
|
+
* - tabs → 4 spaces (inside code blocks only)
|
|
11
|
+
* - strip trailing whitespace (inside code blocks only)
|
|
12
|
+
*
|
|
13
|
+
* Content outside code blocks is left untouched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Module } from "./skeleton.js";
|
|
17
|
+
|
|
18
|
+
/** Normalize CRLF line endings to LF. */
|
|
19
|
+
function normalizeLineEndings(text: string): string {
|
|
20
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Normalize content inside markdown fenced code blocks.
|
|
24
|
+
* - \t → 4 spaces
|
|
25
|
+
* - strip trailing whitespace per line
|
|
26
|
+
* Leaves everything outside code blocks untouched. */
|
|
27
|
+
function normalizeCodeBlockContent(text: string): string {
|
|
28
|
+
const lines = text.split("\n");
|
|
29
|
+
let inCodeBlock = false;
|
|
30
|
+
let fenceChar = "";
|
|
31
|
+
let fenceLen = 0;
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < lines.length; i++) {
|
|
34
|
+
const line = lines[i];
|
|
35
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})/);
|
|
36
|
+
|
|
37
|
+
if (!inCodeBlock) {
|
|
38
|
+
if (fenceMatch) {
|
|
39
|
+
inCodeBlock = true;
|
|
40
|
+
fenceChar = fenceMatch[1][0];
|
|
41
|
+
fenceLen = fenceMatch[1].length;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
// Check for closing fence (same char, length >= opening)
|
|
45
|
+
if (fenceMatch && fenceMatch[1][0] === fenceChar && fenceMatch[1].length >= fenceLen) {
|
|
46
|
+
inCodeBlock = false;
|
|
47
|
+
} else {
|
|
48
|
+
// Inside code block: normalize
|
|
49
|
+
lines[i] = line.replace(/\t/g, " ").replace(/[ \t]+$/, "");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalize a tool result's text content.
|
|
58
|
+
* Returns modified content array, or undefined if no changes. */
|
|
59
|
+
export function normalizeToolResultContent(content: any[]): any[] | undefined {
|
|
60
|
+
let changed = false;
|
|
61
|
+
const newContent = content.map((block) => {
|
|
62
|
+
if (block.type !== "text" || typeof block.text !== "string") return block;
|
|
63
|
+
// First normalize line endings (entire text)
|
|
64
|
+
let text = normalizeLineEndings(block.text);
|
|
65
|
+
// Then normalize code block content
|
|
66
|
+
text = normalizeCodeBlockContent(text);
|
|
67
|
+
if (text !== block.text) {
|
|
68
|
+
changed = true;
|
|
69
|
+
return { ...block, text };
|
|
70
|
+
}
|
|
71
|
+
return block;
|
|
72
|
+
});
|
|
73
|
+
return changed ? newContent : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const normalizeCodeblocksModule: Module = {
|
|
77
|
+
name: "normalize-codeblocks",
|
|
78
|
+
hooks: {
|
|
79
|
+
tool_result: [
|
|
80
|
+
(event) => {
|
|
81
|
+
if (!Array.isArray(event.content)) return undefined;
|
|
82
|
+
const newContent = normalizeToolResultContent(event.content);
|
|
83
|
+
if (!newContent) return undefined;
|
|
84
|
+
return { ...event, content: newContent };
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-tool-filter — unregister pi native tools that are replaced by our extensions.
|
|
3
|
+
*
|
|
4
|
+
* edit → replaced by patch
|
|
5
|
+
* grep → replaced by bash
|
|
6
|
+
* find → replaced by bash
|
|
7
|
+
* ls → replaced by bash
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Module, Skeleton } from "./skeleton.js";
|
|
11
|
+
|
|
12
|
+
const TOOLS_TO_DROP = new Set(["edit", "grep", "find", "ls"]);
|
|
13
|
+
|
|
14
|
+
export const piToolFilterModule: Module = {
|
|
15
|
+
name: "pi-tool-filter",
|
|
16
|
+
hooks: {
|
|
17
|
+
session_start: [
|
|
18
|
+
(_event, ctx, pi) => {
|
|
19
|
+
const active = pi.getActiveTools();
|
|
20
|
+
pi.setActiveTools(active.filter((t) => !TOOLS_TO_DROP.has(t)));
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function setupPiToolFilter(sk: Skeleton): void {
|
|
27
|
+
sk.register(piToolFilterModule);
|
|
28
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Redact — shared types and constants
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
// ─── Match types ──────────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
5
|
export type SecretMatchSource = "pattern" | "regex" | "entropy";
|
|
8
6
|
|
|
9
7
|
export interface SecretMatch {
|
|
@@ -19,7 +17,6 @@ export interface SecretPattern {
|
|
|
19
17
|
pattern: RegExp;
|
|
20
18
|
minLength: number;
|
|
21
19
|
allowsSpaces: boolean;
|
|
22
|
-
/** If true, skip safe-pattern exclusion (unambiguous prefix) */
|
|
23
20
|
highConfidence: boolean;
|
|
24
21
|
}
|
|
25
22
|
|
|
@@ -27,8 +24,6 @@ export interface DetectSecretsOptions {
|
|
|
27
24
|
filePath?: string;
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
// ─── Internal types ──────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
27
|
export interface ConfigStringEntry {
|
|
33
28
|
key: string;
|
|
34
29
|
normalizedKey: string;
|
|
@@ -37,8 +32,6 @@ export interface ConfigStringEntry {
|
|
|
37
32
|
end: number;
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
// ─── Constants ────────────────────────────────────────────────────────────
|
|
41
|
-
|
|
42
35
|
export const MIN_SCAN_LENGTH = 10;
|
|
43
36
|
export const CONFIG_VALUE_MIN_LENGTH = 32;
|
|
44
37
|
export const CONFIG_FILE_EXTENSIONS = new Set([
|
package/hooks/redact.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redact — secret redaction on read/bash tool_result.
|
|
3
|
+
*
|
|
4
|
+
* Detects API keys, tokens, passwords in tool output and replaces them with
|
|
5
|
+
* mask characters before the result enters the model context. Notifies the
|
|
6
|
+
* user via ctx.ui.notify with the count of redactions.
|
|
7
|
+
*
|
|
8
|
+
* Implementation is split into helper modules under hooks/redact/ to keep
|
|
9
|
+
* this file focused on the hook itself.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { Module, Skeleton } from "./skeleton.js";
|
|
14
|
+
import { detectSecrets, maskSecret } from "./redact/detect.js";
|
|
15
|
+
|
|
16
|
+
type TextContent = { type: "text"; text: string };
|
|
17
|
+
|
|
18
|
+
function summarizeCommand(command: string, maxLength = 48): string {
|
|
19
|
+
const singleLine = command.replace(/\s+/g, " ").trim();
|
|
20
|
+
if (singleLine.length <= maxLength) return singleLine;
|
|
21
|
+
return `${singleLine.slice(0, maxLength - 1)}…`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatRedactionContext(toolName: string, input: any): string {
|
|
25
|
+
if (toolName === "read") {
|
|
26
|
+
const filePath = input?.path ?? input?.file ?? input?.file_path;
|
|
27
|
+
return filePath ? `read ${filePath}` : "read";
|
|
28
|
+
}
|
|
29
|
+
if (toolName === "bash") {
|
|
30
|
+
const command = input?.command;
|
|
31
|
+
return typeof command === "string" && command.trim().length > 0
|
|
32
|
+
? `bash ${summarizeCommand(command)}` : "bash";
|
|
33
|
+
}
|
|
34
|
+
return toolName;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const redactModule: Module = {
|
|
38
|
+
name: "redact",
|
|
39
|
+
hooks: {
|
|
40
|
+
tool_result: [
|
|
41
|
+
(event, ctx) => {
|
|
42
|
+
// Patch failures emit error text that may include surrounding
|
|
43
|
+
// file content as a "did you mean" diagnostic. That path leaks
|
|
44
|
+
// secrets if the file is a config / .env / source with API keys.
|
|
45
|
+
// The success path returns the constant "Success" (no secrets)
|
|
46
|
+
// but we let it through anyway so the filter is uniform.
|
|
47
|
+
if (event.toolName !== "read" && event.toolName !== "bash" && event.toolName !== "patch") return;
|
|
48
|
+
if (!event.content || !Array.isArray(event.content)) return;
|
|
49
|
+
|
|
50
|
+
const textParts: Array<{ index: number; item: TextContent; text: string }> = [];
|
|
51
|
+
for (let i = 0; i < event.content.length; i++) {
|
|
52
|
+
const item = event.content[i];
|
|
53
|
+
if (item.type === "text" && typeof item.text === "string" && item.text.length > 0) {
|
|
54
|
+
textParts.push({ index: i, item: item as TextContent, text: item.text });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (textParts.length === 0) return;
|
|
58
|
+
|
|
59
|
+
let totalCount = 0;
|
|
60
|
+
const counts: Record<"pattern" | "regex" | "entropy", number> = { pattern: 0, regex: 0, entropy: 0 };
|
|
61
|
+
const newContent = [...event.content];
|
|
62
|
+
const filePath = (event.input as any)?.path ?? (event.input as any)?.file ?? (event.input as any)?.file_path;
|
|
63
|
+
|
|
64
|
+
for (const { index, text, item } of textParts) {
|
|
65
|
+
const matches = detectSecrets(text, { filePath });
|
|
66
|
+
if (matches.length === 0) continue;
|
|
67
|
+
totalCount += matches.length;
|
|
68
|
+
let redacted = text;
|
|
69
|
+
for (const { start, end, source } of matches.sort((a, b) => b.start - a.start)) {
|
|
70
|
+
counts[source] += 1;
|
|
71
|
+
const original = redacted.slice(start, end);
|
|
72
|
+
redacted = redacted.slice(0, start) + maskSecret(original, source) + redacted.slice(end);
|
|
73
|
+
}
|
|
74
|
+
newContent[index] = { ...item, text: redacted };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (totalCount === 0) return;
|
|
78
|
+
const label = totalCount === 1 ? "1 secret" : `${totalCount} secrets`;
|
|
79
|
+
const breakdown: string[] = [];
|
|
80
|
+
if (counts.pattern > 0) breakdown.push(`*:pattern=${counts.pattern}`);
|
|
81
|
+
if (counts.regex > 0) breakdown.push(`#:regex=${counts.regex}`);
|
|
82
|
+
if (counts.entropy > 0) breakdown.push(`?:entropy=${counts.entropy}`);
|
|
83
|
+
const suffix = breakdown.length > 0 ? ` · ${breakdown.join(" ")}` : "";
|
|
84
|
+
const contextLabel = formatRedactionContext(event.toolName, event.input);
|
|
85
|
+
if (ctx.hasUI) ctx.ui.notify(`🔒 [${contextLabel}] Redacted ${label}${suffix}`, "warning");
|
|
86
|
+
return { ...event, content: newContent };
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function setupRedact(sk: Skeleton): void {
|
|
93
|
+
sk.register(redactModule);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* System-prompt guidance for the redact hook — tells the LLM that
|
|
98
|
+
* masked values are real redactions (not "sk-xxx" literal strings),
|
|
99
|
+
* so it shouldn't try to read or reconstruct them.
|
|
100
|
+
*/
|
|
101
|
+
export const REDACT_GUIDANCE = [
|
|
102
|
+
"### Secret Masking, redacted values are real redactions",
|
|
103
|
+
"- When you see masked secret values (e.g. `sk-***...***` where `*`, `#`, or `?` are mask characters), the real value has been redacted by the system. Do not attempt to read or guess it. If you need the secret, use tools like `jq` or `grep` to extract it from the original source file.",
|
|
104
|
+
].join("\n");
|