decorated-pi 0.6.0 → 0.7.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 CHANGED
@@ -14,7 +14,7 @@ pi install /path/to/decorated-pi
14
14
 
15
15
  ### 1. Token Efficiency
16
16
 
17
- Multiple layers of token savings that compound across every session.
17
+ Multiple layers of token savings that compound across every session. **All integrated CLI tools only require installing their respective CLIs — zero config**.
18
18
 
19
19
  **RTK** — integrates [RTK](https://github.com/rtk-ai/rtk) to compress bash output into structured summaries, so the LLM never sees raw noise.
20
20
 
@@ -73,7 +73,7 @@ Zero-config MCP client with built-in servers:
73
73
  | Exa | `exa_*` | `https://mcp.exa.ai/mcp` |
74
74
  | codegraph | `codegraph_*` | bundled binary |
75
75
 
76
- **Custom servers** in `.pi/agent/mcp.json` (project) or `~/.pi/agent/decorated-pi.json` (global). Project overrides global. Tool prompts and schemas are cached locally so MCP tools are available immediately on startup — no manual `/reload` required even on first install.
76
+ **Custom servers** in `.pi/agent/mcp.json` (project) or `~/.pi/agent/mcp.json` (global). Project overrides global. Tool prompts and schemas are cached locally so MCP tools are available immediately on startup.
77
77
 
78
78
  ```json
79
79
  {
@@ -118,8 +118,8 @@ Example redaction on a `read` / `bash` output:
118
118
  ### 5. Other
119
119
 
120
120
  - `/usage` — token stats with cache‑hit rate, per‑model breakdown (Session / Today / This Week / This Month / All Time)
121
- - Progressive context — supports subdirectory `AGENTS.md` / `CLAUDE.md` discovery and injection
122
121
  - `/retry` — continue after interruption
122
+ - Progressive context — supports subdirectory `AGENTS.md` / `CLAUDE.md` discovery and injection
123
123
  - **WakaTime** — coding activity tracking via [WakaTime](https://wakatime.com)
124
124
 
125
125
  ## Configuration
@@ -129,13 +129,22 @@ Runtime settings in `~/.pi/agent/decorated-pi.json`. Modules can be toggled via
129
129
  ```json
130
130
  {
131
131
  "modules": {
132
- "patch": true,
133
- "safety": true,
134
- "rtk": true,
135
- "lsp": true,
136
- "smart-at": true,
137
- "mcp": true,
138
- "wakatime": true
132
+ "tools": {
133
+ "patchOverrideEdit": true,
134
+ "ask": true,
135
+ "lsp": true,
136
+ "mcp": true
137
+ },
138
+ "hooks": {
139
+ "secretRedaction": true,
140
+ "rtk": true,
141
+ "wakatime": true
142
+ },
143
+ "commands": {
144
+ "atOverride": true,
145
+ "retry": true,
146
+ "usage": true
147
+ }
139
148
  }
140
149
  }
141
150
  ```
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { moduleSnapshotChanged } from "../settings.js";
6
7
  import { ModuleSettingsComponent } from "../ui/module-settings.js";
7
8
 
8
9
  export function registerDpSettingsCommand(pi: ExtensionAPI): void {
@@ -14,7 +15,11 @@ export function registerDpSettingsCommand(pi: ExtensionAPI): void {
14
15
  (tui, theme, _kb, done) =>
15
16
  new ModuleSettingsComponent(tui, theme, () => done(undefined))
16
17
  );
17
- ctx.ui.notify("Module settings updated. /reload to apply.", "warning");
18
+ // Only prompt for reload when the effective settings differ from
19
+ // the snapshot taken when pi loaded the extension.
20
+ if (moduleSnapshotChanged()) {
21
+ ctx.ui.notify("Module settings updated. /reload to apply.", "warning");
22
+ }
18
23
  return;
19
24
  }
20
25
  ctx.ui.notify("dp-settings requires interactive mode.", "warning");
package/hooks/mcp.ts CHANGED
@@ -31,6 +31,11 @@ function cacheScopeForSource(source: string): "global" | "project" {
31
31
  return source === "project" ? "project" : "global";
32
32
  }
33
33
 
34
+ function canUseServer(config: McpServerConfig, cwd?: string): boolean {
35
+ if (!config.canUseInProject) return true;
36
+ return config.canUseInProject(cwd ?? process.cwd());
37
+ }
38
+
34
39
  async function connectAll(
35
40
  configs: McpServerConfig[],
36
41
  registry: any,
@@ -81,6 +86,15 @@ async function connectAll(
81
86
  for (const server of configs) {
82
87
  if (watchdogFired) break; // remaining servers are already failed
83
88
 
89
+ if (!canUseServer(server, cachedCwd || undefined)) {
90
+ allServers.set(server.name, {
91
+ name: server.name, url: server.url ?? server.command ?? "(unknown)", source: server.source,
92
+ state: "failed", toolCount: 0, tools: [],
93
+ error: "Project is missing required artefacts for this server",
94
+ });
95
+ continue;
96
+ }
97
+
84
98
  const conn = new McpConnection(server.name, server);
85
99
  conn.source = server.source;
86
100
  try {
@@ -89,6 +103,18 @@ async function connectAll(
89
103
  try { await conn.disconnect(); } catch { /* ignore */ }
90
104
  return { schemaChanges, hasNewServer };
91
105
  }
106
+ if (conn.tools.length === 0) {
107
+ // Server connected but advertises no tools. Treat this as a
108
+ // failed state and do NOT write an empty cache, otherwise a
109
+ // subsequent /reload will keep using the empty cache forever.
110
+ try { await conn.disconnect(); } catch { /* ignore */ }
111
+ allServers.set(server.name, {
112
+ name: server.name, url: server.url ?? server.command ?? "(unknown)", source: server.source,
113
+ state: "failed", toolCount: 0, tools: [],
114
+ error: "Server connected but returned no tools (e.g. codegraph without a .codegraph index)",
115
+ });
116
+ continue;
117
+ }
92
118
  activeConnections.push(conn);
93
119
  const actualTools = conn.tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }));
94
120
  const cachedEntry = cache?.servers[server.name];
@@ -195,6 +221,9 @@ export async function updateConfigEnabled(serverName: string, enabled: boolean):
195
221
  export async function refreshServerCache(serverName: string, registry: any): Promise<{ ok: boolean; error?: string }> {
196
222
  const config = resolveMcpConfigs(cachedCwd).find(s => s.name === serverName);
197
223
  if (!config) return { ok: false, error: `Server "${serverName}" not found in config.` };
224
+ if (!canUseServer(config, cachedCwd || undefined)) {
225
+ return { ok: false, error: "Project is missing required artefacts for this server" };
226
+ }
198
227
  const existing = activeConnections.find(c => c.serverName === serverName);
199
228
  if (existing) {
200
229
  try { await existing.disconnect(); } catch { /* ignore */ }
@@ -204,6 +233,12 @@ export async function refreshServerCache(serverName: string, registry: any): Pro
204
233
  conn.source = config.source;
205
234
  try {
206
235
  await conn.connect(30_000);
236
+ if (conn.tools.length === 0) {
237
+ try { await conn.disconnect(); } catch { /* ignore */ }
238
+ const error = "Server connected but returned no tools (e.g. codegraph without a .codegraph index)";
239
+ allServers.set(config.name, { name: config.name, url: config.url ?? config.command ?? "(unknown)", source: config.source, state: "failed", toolCount: 0, tools: [], error });
240
+ return { ok: false, error };
241
+ }
207
242
  activeConnections.push(conn);
208
243
  allServers.set(config.name, {
209
244
  name: config.name, url: config.url ?? config.command ?? "(unknown)", source: config.source,
@@ -242,6 +277,8 @@ export function getCachedMcpConfigs(): McpServerConfig[] {
242
277
  * can find it.
243
278
  */
244
279
  export async function ensureMcpServerReady(pi: ExtensionAPI, config: McpServerConfig, cwd?: string): Promise<void> {
280
+ if (!canUseServer(config, cwd)) return;
281
+
245
282
  const scope = cacheScopeForSource(config.source);
246
283
  const cache = loadScopedMcpCache(scope, cwd);
247
284
  const entry = cache?.servers[config.name];
@@ -262,6 +299,21 @@ export async function ensureMcpServerReady(pi: ExtensionAPI, config: McpServerCo
262
299
  conn.source = config.source;
263
300
  try {
264
301
  await conn.connect(30_000);
302
+ if (conn.tools.length === 0) {
303
+ // Empty tool list means the server is not useful. Disconnect and
304
+ // leave cache untouched so the next /reload retries the connection.
305
+ try { await conn.disconnect(); } catch { /* ignore */ }
306
+ allServers.set(config.name, {
307
+ name: config.name,
308
+ url: config.url ?? config.command ?? "(unknown)",
309
+ source: config.source,
310
+ state: "failed",
311
+ toolCount: 0,
312
+ tools: [],
313
+ error: "Server connected but returned no tools (e.g. codegraph without a .codegraph index)",
314
+ });
315
+ return;
316
+ }
265
317
  activeConnections.push(conn);
266
318
  const tools: McpToolCache[] = conn.tools.map(t => ({
267
319
  name: t.name,
@@ -40,13 +40,37 @@ export function extractFirstMessage(entries: SessionEntryLike[]): string | undef
40
40
  return undefined;
41
41
  }
42
42
 
43
+ function normalizeTitle(text: string): string | undefined {
44
+ const nl = text.indexOf("\n");
45
+ const oneLine = (nl === -1 ? text : text.slice(0, nl)).trim();
46
+ if (!oneLine) return undefined;
47
+ if (oneLine.length <= MAX_SESSION_TITLE_LENGTH) return oneLine;
48
+ return oneLine.slice(0, MAX_SESSION_TITLE_LENGTH - 1) + "…";
49
+ }
50
+
51
+ function trySetSessionName(ctx: any, pi: ExtensionAPI): void {
52
+ if (ctx.sessionManager.getSessionName()) return;
53
+ const title = extractFirstMessage(ctx.sessionManager.getBranch());
54
+ if (title) (pi as any).setSessionName(title);
55
+ }
56
+
43
57
  export const sessionTitleModule: Module = {
44
58
  name: "session-title",
45
59
  hooks: {
46
60
  session_start: [
47
61
  (_event, ctx, pi) => {
62
+ trySetSessionName(ctx, pi);
63
+ },
64
+ ],
65
+ input: [
66
+ (event, ctx, pi) => {
67
+ // Skip if already named, not a fresh interactive message,
68
+ // or stream steering/follow-up messages.
48
69
  if (ctx.sessionManager.getSessionName()) return;
49
- const title = extractFirstMessage(ctx.sessionManager.getBranch());
70
+ if (event.source === "extension") return;
71
+ if (event.streamingBehavior) return;
72
+ const text = typeof event.text === "string" ? event.text.trim() : "";
73
+ const title = normalizeTitle(text);
50
74
  if (title) (pi as any).setSessionName(title);
51
75
  },
52
76
  ],
package/index.ts CHANGED
@@ -36,9 +36,9 @@ import { setupRtk } from "./hooks/rtk.js";
36
36
  import { registerPatchTool } from "./tools/patch/index.js";
37
37
  import { registerLspTools } from "./tools/lsp/tools.js";
38
38
  import { LspServerManager } from "./tools/lsp/manager.js";
39
- import { resolveMcpConfigs } from "./tools/mcp/config.js";
39
+ import { registerAskTool } from "./tools/ask/index.js";
40
+ import { resolveMcpConfigs, migrateLegacyGlobalMcpConfig } from "./tools/mcp/config.js";
40
41
  import { ensureMcpServerReady } from "./hooks/mcp.js";
41
- import { CODEGRAPH_GUIDANCE } from "./tools/mcp/builtin/codegraph.js";
42
42
 
43
43
  import { registerDpModelCommand } from "./commands/dp-model.js";
44
44
  import { registerDpSettingsCommand } from "./commands/dp-settings.js";
@@ -46,7 +46,7 @@ import { registerMcpStatusCommand } from "./commands/mcp-status.js";
46
46
  import { registerRetryCommand } from "./commands/retry.js";
47
47
  import { registerUsageCommand } from "./commands/usage.js";
48
48
 
49
- import { isModuleEnabled } from "./settings.js";
49
+ import { captureModuleSnapshot, isModuleEnabled } from "./settings.js";
50
50
 
51
51
  // ─── System-prompt guidelines (hard-coded base, per-module imports) ────────
52
52
  //
@@ -57,7 +57,7 @@ const BASE_GUIDANCE = [
57
57
  "## Decorated Pi Guidance",
58
58
  "",
59
59
  "### Workflow, how to approach tasks",
60
- "- Before acting on a prompt: 1. ensure you fully understand the user's intent — if ambiguous, ask clarifying questions; 2. have researched the existing state — read files, search, investigate. Proceed only when both are clear.",
60
+ "- Before acting on a prompt, do sufficient research on the existing state — read files, search, investigate and only proceed once you have a clear picture.",
61
61
  "- Exercise caution when performing any **write** operations, especially when you are in a research or exploration phase.",
62
62
  "- Before modifying code, match the user's existing code style (naming, formatting, patterns). Do not re-modify lines the user has manually edited since your last change.",
63
63
  "",
@@ -66,16 +66,17 @@ const BASE_GUIDANCE = [
66
66
  ].join("\n");
67
67
 
68
68
  /** Build the list of guideline strings to inject, in prompt order.
69
- * Always-on rules first, then per-module guidelines (skipping disabled
70
- * modules). Add new per-module guidelines by importing the constant
71
- * here and pushing it inside an `isModuleEnabled(...)` guard. */
69
+ * Always-on rules first, then per-module guidelines. REDACT_GUIDANCE is
70
+ * gated by the secretRedaction module; CodeGraph guidance follows the MCP
71
+ * server switch chain (mcp module on codegraph server on → guidance
72
+ * injected). The mcp module check lives in `resolveMcpConfigs`, so this
73
+ * code only needs to look at the server's own `enabled` flag. */
72
74
  function buildGuidelines(): string[] {
73
75
  const out: string[] = [
74
76
  BASE_GUIDANCE,
75
- REDACT_GUIDANCE, // from hooks/redact.ts — always on (safety module)
76
- INJECT_AGENTS_MD_GUIDANCE, // from hooks/inject-agents-md.ts — always on (smart-at module)
77
+ INJECT_AGENTS_MD_GUIDANCE, // from hooks/inject-agents-md.ts — always on
77
78
  ];
78
- if (isModuleEnabled("codegraph")) out.push(CODEGRAPH_GUIDANCE);
79
+ if (isModuleEnabled("secretRedaction")) out.push(REDACT_GUIDANCE);
79
80
  return out;
80
81
  }
81
82
 
@@ -96,13 +97,18 @@ function installGuidelines(pi: ExtensionAPI): void {
96
97
  }
97
98
 
98
99
  export default async function (pi: ExtensionAPI) {
100
+ // Snapshot the module settings that pi is about to load. /dp-settings
101
+ // compares against this to avoid prompting for reload when the user
102
+ // has only returned the settings to the currently-loaded state.
103
+ captureModuleSnapshot();
104
+
99
105
  // ── Skeleton (hooks) ───────────────────────────────────────────────────
100
106
  const sk = createSkeleton();
101
107
 
102
108
  // Order matters for tool_result compose chain:
103
109
  // 1. redact → normalize-codeblocks → externalize → track-mtime → inject-agents-md → image-vision → wakatime
104
110
  // The first module registered for a given event runs first (compose chain).
105
- setupRedact(sk);
111
+ if (isModuleEnabled("secretRedaction")) setupRedact(sk);
106
112
  sk.register(normalizeCodeblocksModule);
107
113
  sk.register(externalizeModule);
108
114
  sk.register(trackMtimeModule);
@@ -114,22 +120,27 @@ export default async function (pi: ExtensionAPI) {
114
120
  // anything else inspects the tool list.
115
121
  sk.register(piToolFilterModule);
116
122
  sk.register(sessionTitleModule);
117
- sk.register(smartAtModule);
118
- sk.register(wakatimeModule);
123
+ if (isModuleEnabled("atOverride")) sk.register(smartAtModule);
124
+ if (isModuleEnabled("wakatime")) sk.register(wakatimeModule);
119
125
 
120
126
  // Compaction + RTK (these also install their own pi.on via setup<>()).
121
127
  setupCompaction(sk);
122
- setupRtk(sk, pi);
123
- setupWakatime(sk, pi);
128
+ if (isModuleEnabled("rtk")) setupRtk(sk, pi);
129
+ if (isModuleEnabled("wakatime")) setupWakatime(sk, pi);
124
130
 
125
131
  // ── Tools (conditional on module switches) ────────────────────────────
126
- if (isModuleEnabled("patch")) registerPatchTool(pi);
132
+ if (isModuleEnabled("patchOverrideEdit")) registerPatchTool(pi);
127
133
  if (isModuleEnabled("lsp")) registerLspTools(pi, new LspServerManager());
134
+ if (isModuleEnabled("ask")) registerAskTool(pi);
128
135
 
129
- // MCP: hook AND tool are gated together. Disabling the module
130
- // means no session_start handler runs, no tools register, and no
131
- // background connections are attempted.
136
+ // MCP: hook, tools, and /mcp command are gated together. Disabling the
137
+ // module means no session_start handler runs, no tools register, no
138
+ // /mcp command is available, and no background connections are attempted.
132
139
  if (isModuleEnabled("mcp")) {
140
+ // One-time migration: legacy global MCP configs in
141
+ // ~/.pi/agent/decorated-pi.json move to ~/.pi/agent/mcp.json. Run
142
+ // explicitly here so `loadGlobalMcpConfigs` stays pure.
143
+ migrateLegacyGlobalMcpConfig();
133
144
  sk.register(mcpModule);
134
145
  const configs = resolveMcpConfigs(process.cwd()).filter(s => s.enabled);
135
146
  // Per-server readiness: cache hit → register from cache (fast).
@@ -138,6 +149,7 @@ export default async function (pi: ExtensionAPI) {
138
149
  for (const config of configs) {
139
150
  await ensureMcpServerReady(pi, config, process.cwd());
140
151
  }
152
+ registerMcpStatusCommand(pi);
141
153
  }
142
154
 
143
155
  // ── System-prompt guidelines (single handler, array order = prompt order) ──
@@ -146,9 +158,8 @@ export default async function (pi: ExtensionAPI) {
146
158
  // ── Commands ──────────────────────────────────────────────────────────
147
159
  registerDpModelCommand(pi);
148
160
  registerDpSettingsCommand(pi);
149
- registerMcpStatusCommand(pi);
150
- registerRetryCommand(pi);
151
- registerUsageCommand(pi);
161
+ if (isModuleEnabled("retry")) registerRetryCommand(pi);
162
+ if (isModuleEnabled("usage")) registerUsageCommand(pi);
152
163
 
153
164
  // ── Install skeleton (last) ────────────────────────────────────────────
154
165
  sk.install(pi);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "decorated-pi is a practical enhancement pack for pi coding agent — token-efficient workflow, cache-friendly design, and smarter tools.",
5
5
  "keywords": [
6
6
  "pi",
@@ -10,8 +10,9 @@
10
10
  "mcp",
11
11
  "security",
12
12
  "lsp",
13
- "language-server",
14
- "secret-detection"
13
+ "lsp-client",
14
+ "secret-redaction",
15
+ "codegraph"
15
16
  ],
16
17
  "license": "MIT",
17
18
  "repository": {
package/settings.ts CHANGED
@@ -35,14 +35,32 @@ export interface McpServerEntry {
35
35
  }
36
36
 
37
37
  export interface ModuleSettings {
38
- safety?: boolean;
39
- lsp?: boolean;
40
- "smart-at"?: boolean;
41
- patch?: boolean;
42
- mcp?: boolean;
43
- wakatime?: boolean;
44
- "rtk"?: boolean;
45
- codegraph?: boolean;
38
+ tools?: {
39
+ /** Replace Pi native edit/write with the patch tool. */
40
+ patchOverrideEdit?: boolean;
41
+ /** Interactive ask tool for user clarification (blocks loop until answered). */
42
+ ask?: boolean;
43
+ /** Language server diagnostics, hover, definition, references, symbols, rename. */
44
+ lsp?: boolean;
45
+ /** MCP client with builtin servers (context7, exa, codegraph). */
46
+ mcp?: boolean;
47
+ };
48
+ hooks?: {
49
+ /** Redact secrets from read/bash output before model context. */
50
+ secretRedaction?: boolean;
51
+ /** Rewrite bash through system RTK when available. */
52
+ "rtk"?: boolean;
53
+ /** Send coding activity heartbeats to WakaTime. */
54
+ wakatime?: boolean;
55
+ };
56
+ commands?: {
57
+ /** Project-aware file search replacing default autocomplete. */
58
+ atOverride?: boolean;
59
+ /** /retry command to continue after interruption. */
60
+ retry?: boolean;
61
+ /** /usage command for token stats. */
62
+ usage?: boolean;
63
+ };
46
64
  }
47
65
 
48
66
  export interface UsageIndexEntry {
@@ -62,7 +80,13 @@ export interface DecoratedPiConfig {
62
80
 
63
81
  export function loadConfig(): DecoratedPiConfig {
64
82
  try {
65
- if (fs.existsSync(CONFIG_FILE)) return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
83
+ if (fs.existsSync(CONFIG_FILE)) {
84
+ const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")) as DecoratedPiConfig;
85
+ if (migrateModuleSettings(config)) {
86
+ saveConfig(config);
87
+ }
88
+ return config;
89
+ }
66
90
  } catch {}
67
91
  return {};
68
92
  }
@@ -152,42 +176,157 @@ export function setCompactModelKey(key: string | null) {
152
176
  // ─── Module Switches ──────────────────────────────────────────────────────────
153
177
 
154
178
  const DEFAULT_MODULES: Required<ModuleSettings> = {
155
- safety: true,
156
- lsp: true,
157
- "smart-at": true,
158
- patch: true,
159
- mcp: true,
160
- wakatime: true,
161
- "rtk": true,
162
- codegraph: false, // opt-in: heavy binary, not everyone wants it
179
+ tools: {
180
+ patchOverrideEdit: true,
181
+ ask: true,
182
+ lsp: true,
183
+ mcp: true,
184
+ },
185
+ hooks: {
186
+ secretRedaction: true,
187
+ "rtk": true,
188
+ wakatime: true,
189
+ },
190
+ commands: {
191
+ atOverride: true,
192
+ retry: true,
193
+ usage: true,
194
+ },
163
195
  };
164
196
 
165
- export function isModuleEnabled(name: keyof ModuleSettings): boolean {
197
+ /** Maps every module name to its category. Used by isModuleEnabled/setModuleEnabled. */
198
+ const MODULE_TO_CATEGORY: Record<string, keyof ModuleSettings> = {
199
+ patchOverrideEdit: "tools",
200
+ ask: "tools",
201
+ lsp: "tools",
202
+ mcp: "tools",
203
+ secretRedaction: "hooks",
204
+ "rtk": "hooks",
205
+ wakatime: "hooks",
206
+ atOverride: "commands",
207
+ retry: "commands",
208
+ usage: "commands",
209
+ };
210
+
211
+ /** Legacy flat module keys that were renamed. Applied before flat→nested migration. */
212
+ const LEGACY_MODULE_KEYS: Record<string, string> = {
213
+ patch: "patchOverrideEdit",
214
+ safety: "secretRedaction",
215
+ };
216
+
217
+ /**
218
+ * Migrate module settings to the nested tools/hooks/commands layout.
219
+ * Handles three legacy shapes:
220
+ * 1. Flat config with current names (patchOverrideEdit, secretRedaction, ...)
221
+ * 2. Flat config with old names (patch, safety)
222
+ * 3. Nested config with old inner names (tools.patch, hooks.safety)
223
+ * Already-correct nested configs are left untouched.
224
+ */
225
+ function migrateModuleSettings(config: DecoratedPiConfig): boolean {
226
+ if (!config.modules) return false;
227
+
228
+ let migrated = false;
229
+ const result: ModuleSettings = {};
230
+
231
+ // Start from any already-nested values.
232
+ if (config.modules.tools) result.tools = { ...config.modules.tools };
233
+ if (config.modules.hooks) result.hooks = { ...config.modules.hooks };
234
+ if (config.modules.commands) result.commands = { ...config.modules.commands };
235
+
236
+ // Rename legacy keys inside already-nested categories.
237
+ function renameInCategory(
238
+ category: keyof ModuleSettings,
239
+ oldKey: string,
240
+ newKey: string,
241
+ ) {
242
+ const cat = result[category] as Record<string, boolean> | undefined;
243
+ if (cat && oldKey in cat && !(newKey in cat)) {
244
+ cat[newKey] = cat[oldKey];
245
+ delete cat[oldKey];
246
+ migrated = true;
247
+ }
248
+ }
249
+ renameInCategory("tools", "patch", "patchOverrideEdit");
250
+ renameInCategory("hooks", "safety", "secretRedaction");
251
+ renameInCategory("commands", "smart-at", "atOverride");
252
+
253
+ // Migrate flat keys into nested categories.
254
+ const flatMapping: Record<string, [keyof ModuleSettings, string]> = {
255
+ patchOverrideEdit: ["tools", "patchOverrideEdit"],
256
+ ask: ["tools", "ask"],
257
+ lsp: ["tools", "lsp"],
258
+ mcp: ["tools", "mcp"],
259
+ secretRedaction: ["hooks", "secretRedaction"],
260
+ "rtk": ["hooks", "rtk"],
261
+ wakatime: ["hooks", "wakatime"],
262
+ atOverride: ["commands", "atOverride"],
263
+ retry: ["commands", "retry"],
264
+ usage: ["commands", "usage"],
265
+ patch: ["tools", "patchOverrideEdit"],
266
+ safety: ["hooks", "secretRedaction"],
267
+ "smart-at": ["commands", "atOverride"],
268
+ };
269
+
270
+ for (const [key, value] of Object.entries(config.modules)) {
271
+ if (key === "tools" || key === "hooks" || key === "commands") continue;
272
+ const mapping = flatMapping[key];
273
+ if (!mapping) continue;
274
+ const [category, newKey] = mapping;
275
+ if (!result[category]) result[category] = {} as any;
276
+ const cat = result[category] as Record<string, boolean>;
277
+ if (!(newKey in cat)) {
278
+ cat[newKey] = value as boolean;
279
+ }
280
+ migrated = true;
281
+ }
282
+
283
+ if (!migrated) return false;
284
+ config.modules = result;
285
+ return true;
286
+ }
287
+
288
+ export function isModuleEnabled(name: string): boolean {
289
+ const category = MODULE_TO_CATEGORY[name];
290
+ if (!category) return true;
166
291
  const modules = loadConfig().modules ?? {};
167
- return modules[name] ?? DEFAULT_MODULES[name] ?? true;
292
+ const cat = modules[category] as Record<string, boolean> | undefined;
293
+ const defaults = DEFAULT_MODULES[category] as Record<string, boolean>;
294
+ if (cat && name in cat) return cat[name];
295
+ return defaults[name] ?? true;
168
296
  }
169
297
 
170
- export function setModuleEnabled(name: keyof ModuleSettings, enabled: boolean) {
171
- const modules = { ...loadConfig().modules };
172
- modules[name] = enabled;
298
+ export function setModuleEnabled(name: string, enabled: boolean) {
299
+ const category = MODULE_TO_CATEGORY[name];
300
+ if (!category) return;
301
+ const modules: ModuleSettings = { ...loadConfig().modules };
302
+ modules[category] = { ...(modules[category] ?? {}), [name]: enabled } as any;
173
303
  saveConfig({ modules });
174
304
  }
175
305
 
176
306
  export function getAllModuleSettings(): Required<ModuleSettings> {
177
307
  const modules = loadConfig().modules ?? {};
178
- return { ...DEFAULT_MODULES, ...modules };
308
+ return {
309
+ tools: { ...DEFAULT_MODULES.tools, ...modules.tools },
310
+ hooks: { ...DEFAULT_MODULES.hooks, ...modules.hooks },
311
+ commands: { ...DEFAULT_MODULES.commands, ...modules.commands },
312
+ };
179
313
  }
180
314
 
181
- // ─── Codegraph (drives the builtin MCP server's auto-enable) ───────────────
182
-
183
315
  /**
184
- * True when the user has enabled the codegraph module via /dp-settings.
185
- * Default is OFF (opt-in) `modules.codegraph` defaults to `false`.
186
- * When true, the builtin MCP server is enabled and the CodeGraph
187
- * guidance section is injected into the system prompt.
316
+ * Snapshot of the module settings that pi is currently running with.
317
+ * Captured once when the extension loads; /dp-settings compares the
318
+ * current effective settings against this snapshot to decide whether a
319
+ * reload is actually necessary.
188
320
  */
189
- export function isCodegraphModuleEnabled(): boolean {
190
- return isModuleEnabled("codegraph");
321
+ let loadedModuleSnapshot: Required<ModuleSettings> | null = null;
322
+
323
+ export function captureModuleSnapshot(): void {
324
+ loadedModuleSnapshot = getAllModuleSettings();
325
+ }
326
+
327
+ export function moduleSnapshotChanged(): boolean {
328
+ if (!loadedModuleSnapshot) return true;
329
+ return JSON.stringify(loadedModuleSnapshot) !== JSON.stringify(getAllModuleSettings());
191
330
  }
192
331
 
193
332
  // ─── Usage index (增量同步元数据) ─────────────────────────────────────────────