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.
Files changed (68) hide show
  1. package/.codegraph/daemon.pid +6 -0
  2. package/AGENTS.md +92 -0
  3. package/README.md +53 -64
  4. package/commands/dp-model.ts +23 -0
  5. package/commands/dp-settings.ts +23 -0
  6. package/commands/mcp-status.ts +62 -0
  7. package/commands/retry.ts +19 -0
  8. package/commands/usage.ts +544 -0
  9. package/hooks/compaction.ts +204 -0
  10. package/hooks/externalize.ts +70 -0
  11. package/hooks/image-vision.ts +132 -0
  12. package/hooks/inject-agents-md.ts +164 -0
  13. package/hooks/mcp.ts +340 -0
  14. package/hooks/normalize-codeblocks.ts +88 -0
  15. package/hooks/pi-tool-filter.ts +28 -0
  16. package/{extensions/safety → hooks/redact}/types.ts +1 -8
  17. package/hooks/redact.ts +104 -0
  18. package/{extensions → hooks}/rtk.ts +92 -115
  19. package/hooks/session-title.ts +54 -0
  20. package/hooks/skeleton.ts +212 -0
  21. package/hooks/smart-at.ts +318 -0
  22. package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
  23. package/{extensions → hooks}/wakatime.ts +120 -122
  24. package/index.ts +155 -1
  25. package/package.json +5 -4
  26. package/{extensions/settings.ts → settings.ts} +32 -0
  27. package/{extensions → tools}/lsp/client.ts +0 -25
  28. package/{extensions → tools}/lsp/format.ts +2 -99
  29. package/{extensions → tools}/lsp/index.ts +1 -1
  30. package/{extensions → tools}/lsp/servers.ts +1 -1
  31. package/{extensions → tools}/lsp/tools.ts +1 -66
  32. package/{extensions → tools}/lsp/types.ts +0 -11
  33. package/tools/mcp/builtin/codegraph.ts +45 -0
  34. package/tools/mcp/builtin/context7.ts +10 -0
  35. package/tools/mcp/builtin/exa.ts +10 -0
  36. package/tools/mcp/builtin/index.ts +19 -0
  37. package/tools/mcp/cache.ts +124 -0
  38. package/{extensions → tools}/mcp/client.ts +2 -2
  39. package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
  40. package/tools/mcp/index.ts +44 -0
  41. package/tools/mcp/tool-definition.ts +112 -0
  42. package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
  43. package/{extensions/io.ts → tools/patch/index.ts} +29 -205
  44. package/tsconfig.json +10 -1
  45. package/ui/mcp-status.ts +202 -0
  46. package/ui/model-picker.ts +162 -0
  47. package/ui/module-settings.ts +83 -0
  48. package/ui/usage.ts +396 -0
  49. package/extensions/index.ts +0 -154
  50. package/extensions/io-tool-output.ts +0 -33
  51. package/extensions/mcp/index.ts +0 -435
  52. package/extensions/model-integration.ts +0 -531
  53. package/extensions/providers/ark-coding.ts +0 -75
  54. package/extensions/providers/index.ts +0 -9
  55. package/extensions/providers/ollama-cloud.ts +0 -101
  56. package/extensions/providers/qianfan-coding.ts +0 -71
  57. package/extensions/safety/index.ts +0 -102
  58. package/extensions/session-title.ts +0 -40
  59. package/extensions/slash.ts +0 -458
  60. package/extensions/smart-at.ts +0 -481
  61. package/extensions/subdir-agents.ts +0 -151
  62. /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
  63. /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
  64. /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
  65. /package/{extensions → tools}/lsp/env.ts +0 -0
  66. /package/{extensions → tools}/lsp/manager.ts +0 -0
  67. /package/{extensions → tools}/lsp/prompt.ts +0 -0
  68. /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
- * Safety Detection — shared types and constants
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([
@@ -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");