decorated-pi 0.6.0 → 0.7.1

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/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 (增量同步元数据) ─────────────────────────────────────────────
@@ -0,0 +1,93 @@
1
+ /**
2
+ * ask — ask the user one or more questions and return their answers.
3
+ *
4
+ * Supports free text, single-choice, and multiple-choice questions.
5
+ * Uses a custom TUI component so multiple questions can be presented
6
+ * together and navigated with Tab / Shift+Tab.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "typebox";
11
+ import { AskComponent, type AskAnswer, type AskQuestion } from "../../ui/ask.js";
12
+
13
+ const askQuestionSchema = Type.Object({
14
+ id: Type.String({ description: "Unique identifier for this question in the result." }),
15
+ type: Type.Union(
16
+ [Type.Literal("text"), Type.Literal("single"), Type.Literal("multi")],
17
+ { description: "text = free input, single = one option, multi = many options" },
18
+ ),
19
+ question: Type.String({ description: "Question text shown to the user." }),
20
+ options: Type.Optional(Type.Array(Type.String(), { description: "Options for single or multi choice." })),
21
+ default: Type.Optional(Type.String({ description: "Default answer. For multi, comma-separated values." })),
22
+ allowCustom: Type.Optional(Type.Boolean({
23
+ description: "For single/multi: append an \"Other\" row that toggles into a free-text input. Lets the user enter a custom answer not in the preset list.",
24
+ })),
25
+ });
26
+
27
+ function formatAnswer(q: AskQuestion, value: string | string[]): string {
28
+ if (q.type === "text") return value as string;
29
+ if (q.type === "single") return value as string;
30
+ return (value as string[]).join(", ");
31
+ }
32
+
33
+ export function formatAnswers(questions: AskQuestion[], answers: { id: string; value: string | string[] }[]): string {
34
+ const lines: string[] = [];
35
+ for (const q of questions) {
36
+ const a = answers.find((x) => x.id === q.id);
37
+ if (!a) continue;
38
+ lines.push(`${q.question}: ${formatAnswer(q, a.value)}`);
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+
43
+ export function registerAskTool(pi: ExtensionAPI): void {
44
+ pi.registerTool({
45
+ name: "ask",
46
+ label: "Ask user",
47
+ description: "Ask the user one or more questions and return their answers. Supports free text, single choice, and multiple choice. Use when you need clarification or a decision before continuing.",
48
+ promptSnippet: "Ask the user one or more clarifying questions",
49
+ promptGuidelines: [
50
+ "Before acting on a prompt, ensure you fully understand the user's intent — if ambiguous, ask clarifying questions using the ask tool.",
51
+ ],
52
+ parameters: Type.Object({
53
+ questions: Type.Array(askQuestionSchema, { minItems: 1, description: "Questions to ask. User navigates between them with Tab / Shift+Tab." }),
54
+ }),
55
+ execute: async (_id, params, _signal, _update, ctx) => {
56
+ if (!ctx.hasUI) {
57
+ return {
58
+ content: [{ type: "text", text: "ask tool requires an interactive UI session." }],
59
+ isError: true,
60
+ details: {},
61
+ };
62
+ }
63
+
64
+ // Validate options for single/multi.
65
+ for (const q of params.questions) {
66
+ if ((q.type === "single" || q.type === "multi") && (!q.options || q.options.length === 0)) {
67
+ return {
68
+ content: [{ type: "text", text: `Question "${q.id}" (${q.type}) requires at least one option.` }],
69
+ isError: true,
70
+ details: {},
71
+ };
72
+ }
73
+ }
74
+
75
+ const answers = await ctx.ui.custom<AskAnswer[] | undefined>(
76
+ (tui, theme, _kb, done) => new AskComponent(tui, theme, params.questions, done),
77
+ );
78
+
79
+ if (!answers) {
80
+ return {
81
+ content: [{ type: "text", text: "User cancelled the questions." }],
82
+ isError: false,
83
+ details: { cancelled: true },
84
+ };
85
+ }
86
+
87
+ return {
88
+ content: [{ type: "text", text: formatAnswers(params.questions, answers) }],
89
+ details: { answers },
90
+ };
91
+ },
92
+ });
93
+ }
@@ -1,45 +1,21 @@
1
1
  /**
2
- * codegraph builtin MCP server — config + system-prompt guidance.
2
+ * codegraph builtin MCP server — config + project artefact check.
3
3
  *
4
- * Injected by `index.ts` `buildGuidelines()` when the codegraph
5
- * module is enabled (see `isModuleEnabled("codegraph")` in settings).
4
+ * Enabled state is controlled like any other MCP server: through the
5
+ * MCP config (global `~/.pi/agent/mcp.json` under `mcpServers`, or
6
+ * project `.pi/agent/mcp.json`) or via the `/mcp` command. There is no
7
+ * separate /dp-settings toggle; codegraph is just one MCP server.
6
8
  */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
7
11
  import type { McpServerConfig } from "../config.js";
8
- import { isCodegraphModuleEnabled } from "../../../settings.js";
9
12
 
10
13
  export const CODEGRAPH_BUILTIN: Omit<McpServerConfig, "source"> = {
11
14
  name: "codegraph",
12
15
  command: "codegraph",
13
16
  args: ["serve", "--mcp"],
14
- enabled: false, // overridden by isCodegraphModuleEnabled() at resolve time
17
+ enabled: false,
15
18
  description:
16
- "Local code knowledge graph (colbymchenry/codegraph). Enable via /dp-settings.",
19
+ "Local code knowledge graph (colbymchenry/codegraph). Enable via /mcp.",
20
+ canUseInProject: (cwd: string) => fs.existsSync(path.join(cwd, ".codegraph")),
17
21
  };
18
-
19
- /** Predicate for `resolveMcpConfigs` to gate the codegraph server. */
20
- export function codegraphEnabled(): boolean {
21
- return isCodegraphModuleEnabled();
22
- }
23
-
24
- export const CODEGRAPH_GUIDANCE = [
25
- "### CodeGraph, code source map",
26
- "- This project's `codegraph_*` MCP tools are enabled. The graph is a pre-built index; grep/glob/Read of source code is repeating work the index already did.",
27
- "",
28
- "#### When to reach for it",
29
- '- Starting any task that touches code → `codegraph_explore("how does X work")` or `codegraph_files`',
30
- "- Looking for where a symbol is defined → `codegraph_search <name>`",
31
- "- Reading a function's body → `codegraph_node <name>` (or `codegraph_explore`)",
32
- "- Tracing call flow → `codegraph_callers` / `codegraph_callees`",
33
- "- Assessing refactor risk → `codegraph_impact <name>`",
34
- "",
35
- "#### Do NOT do this",
36
- "- `ls`, `find`, `grep -rn`, `rg` to discover symbols → use `codegraph_search`",
37
- "- `read` of an entire file to find a function → use `codegraph_explore` first",
38
- '- Reading 3+ files to understand a module → use `codegraph_explore("how does X work")`',
39
- "- `bash` with `cat`, `head`, `sed` to view source → use `codegraph_node` or `read` (single file only)",
40
- "",
41
- "#### If it errors",
42
- '- "Project not initialized" → ask the user to run `codegraph init -i` in their terminal',
43
- "- Empty results → fall back to grep/Read (the index is best-effort, not authoritative)",
44
- "- Tool timeout → `codegraph_status` to check; if indexer is dead, fall back",
45
- ].join("\n");
@@ -5,11 +5,11 @@
5
5
  import type { McpServerConfig } from "../config.js";
6
6
  import { CONTEXT7_BUILTIN } from "./context7.js";
7
7
  import { EXA_BUILTIN } from "./exa.js";
8
- import { CODEGRAPH_BUILTIN, CODEGRAPH_GUIDANCE } from "./codegraph.js";
8
+ import { CODEGRAPH_BUILTIN } from "./codegraph.js";
9
9
 
10
10
  export { CONTEXT7_BUILTIN } from "./context7.js";
11
11
  export { EXA_BUILTIN } from "./exa.js";
12
- export { CODEGRAPH_BUILTIN, CODEGRAPH_GUIDANCE, codegraphEnabled } from "./codegraph.js";
12
+ export { CODEGRAPH_BUILTIN } from "./codegraph.js";
13
13
 
14
14
  /** All builtin servers — flat list for `resolveMcpConfigs` to merge. */
15
15
  export const BUILTIN_MCP_SERVERS: Omit<McpServerConfig, "source">[] = [
@@ -5,16 +5,20 @@
5
5
  * hit sets defaults; later sources can override specific fields.
6
6
  *
7
7
  * Persistence locations:
8
- * - global: `~/.pi/agent/decorated-pi.json` (under `mcpServers`)
8
+ * - global: `~/.pi/agent/mcp.json` (under `mcpServers`)
9
9
  * - project: `<cwd>/.pi/agent/mcp.json` (under `mcpServers`)
10
+ *
11
+ * Legacy: global MCP servers used to live in `~/.pi/agent/decorated-pi.json`
12
+ * under `mcpServers`. `loadGlobalMcpConfigs()` automatically migrates that
13
+ * data to `~/.pi/agent/mcp.json` on first read.
10
14
  */
11
15
  import * as fs from "node:fs";
12
16
  import * as os from "node:os";
13
17
  import * as path from "node:path";
14
18
  import { spawnSync } from "node:child_process";
15
- import { loadConfig } from "../../settings.js";
19
+ import { isModuleEnabled } from "../../settings.js";
16
20
  import type { DependencyStatus } from "../../hooks/skeleton.js";
17
- import { BUILTIN_MCP_SERVERS, codegraphEnabled } from "./builtin/index.js";
21
+ import { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
18
22
 
19
23
  export { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
20
24
 
@@ -27,6 +31,12 @@ export interface McpServerConfig {
27
31
  description?: string;
28
32
  enabled: boolean;
29
33
  source: "builtin" | "global" | "project";
34
+ /** Optional predicate: return false if this server cannot be used in the given project. */
35
+ canUseInProject?: (cwd: string) => boolean;
36
+ }
37
+
38
+ function globalMcpJsonPath(): string {
39
+ return path.join(os.homedir(), ".pi", "agent", "mcp.json");
30
40
  }
31
41
 
32
42
  function readMcpJson(filePath: string): Record<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string>; enabled?: boolean; description?: string }> | null {
@@ -41,6 +51,64 @@ function readMcpJson(filePath: string): Record<string, { url?: string; command?:
41
51
  }
42
52
  }
43
53
 
54
+ /**
55
+ * One-time migration: older versions stored global MCP servers in
56
+ * `~/.pi/agent/decorated-pi.json` under `mcpServers`. Move that data to
57
+ * the dedicated `~/.pi/agent/mcp.json` file so global and project configs
58
+ * are symmetric.
59
+ *
60
+ * Rules:
61
+ * - Only runs when the legacy file has at least one `mcpServers` entry.
62
+ * - For each legacy server name, if `mcp.json` already has an entry with
63
+ * the same name, the legacy entry is skipped (new file wins).
64
+ * - Idempotent: after running once, the legacy file no longer has
65
+ * `mcpServers`, so a second call is a no-op.
66
+ */
67
+ export function migrateLegacyGlobalMcpConfig(): void {
68
+ const legacyPath = path.join(os.homedir(), ".pi", "agent", "decorated-pi.json");
69
+ const newPath = globalMcpJsonPath();
70
+
71
+ let legacy: Record<string, any> | null = null;
72
+ try {
73
+ legacy = JSON.parse(fs.readFileSync(legacyPath, "utf-8"));
74
+ } catch {
75
+ return;
76
+ }
77
+ if (!legacy || !legacy.mcpServers || typeof legacy.mcpServers !== "object") return;
78
+ if (Object.keys(legacy.mcpServers).length === 0) return;
79
+
80
+ // Load existing new file (if any). If it is corrupt, leave everything
81
+ // alone — safer than clobbering.
82
+ let newConfig: Record<string, any> = { mcpServers: {} };
83
+ if (fs.existsSync(newPath)) {
84
+ try {
85
+ const parsed = JSON.parse(fs.readFileSync(newPath, "utf-8"));
86
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
87
+ newConfig = parsed;
88
+ if (!newConfig.mcpServers || typeof newConfig.mcpServers !== "object") {
89
+ newConfig.mcpServers = {};
90
+ }
91
+ }
92
+ } catch {
93
+ return;
94
+ }
95
+ }
96
+
97
+ // Merge: only add legacy entries that don't already exist in the new file.
98
+ for (const [name, entry] of Object.entries(legacy.mcpServers)) {
99
+ if (!(name in newConfig.mcpServers)) {
100
+ newConfig.mcpServers[name] = entry;
101
+ }
102
+ }
103
+
104
+ const dir = path.dirname(newPath);
105
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
106
+ fs.writeFileSync(newPath, JSON.stringify(newConfig, null, 2) + "\n", "utf-8");
107
+
108
+ delete legacy.mcpServers;
109
+ fs.writeFileSync(legacyPath, JSON.stringify(legacy, null, 2) + "\n", "utf-8");
110
+ }
111
+
44
112
  /** Load project-level MCP configs from cwd only. */
45
113
  export function loadProjectMcpConfigs(cwd: string): McpServerConfig[] {
46
114
  const configs: McpServerConfig[] = [];
@@ -69,18 +137,18 @@ export function loadProjectMcpConfigs(cwd: string): McpServerConfig[] {
69
137
  return configs;
70
138
  }
71
139
 
72
- /** Load global MCP configs from ~/.pi/agent/decorated-pi.json. */
140
+ /** Load global MCP configs from ~/.pi/agent/mcp.json. */
73
141
  export function loadGlobalMcpConfigs(): McpServerConfig[] {
74
- const config = loadConfig();
75
- if (!config.mcpServers) return [];
142
+ const servers = readMcpJson(globalMcpJsonPath());
143
+ if (!servers) return [];
76
144
 
77
- return Object.entries(config.mcpServers).map(([name, entry]) => ({
145
+ return Object.entries(servers).map(([name, entry]) => ({
78
146
  name,
79
147
  url: entry.url,
80
148
  command: entry.command,
81
149
  args: entry.args,
82
150
  env: entry.env,
83
- description: (entry as any).description as string | undefined,
151
+ description: entry.description,
84
152
  enabled: entry.enabled !== false,
85
153
  source: "global" as const,
86
154
  }));
@@ -96,16 +164,17 @@ export function isSseUrl(url: string): boolean {
96
164
  * Later sources override earlier ones for the same server name.
97
165
  */
98
166
  export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
167
+ // The mcp module switch is the master switch. When it is off, no MCP
168
+ // server config is considered active — this keeps the chain simple:
169
+ // mcp off → all servers off → no codegraph guidance.
170
+ if (!isModuleEnabled("mcp")) return [];
171
+
99
172
  const byName = new Map<string, McpServerConfig>();
100
173
 
101
- // Builtin (lowest priority). The codegraph entry's `enabled` flag is
102
- // computed at resolve time from the dp-settings module toggle.
174
+ // Builtin (lowest priority). Use the builtin's own `enabled` default.
175
+ // Servers can be enabled via the `/mcp` command or by editing mcp.json.
103
176
  for (const s of BUILTIN_MCP_SERVERS) {
104
- if (s.name === "codegraph") {
105
- byName.set(s.name, { ...s, enabled: codegraphEnabled(), source: "builtin" });
106
- } else {
107
- byName.set(s.name, { ...s, source: "builtin" });
108
- }
177
+ byName.set(s.name, { ...s, source: "builtin" });
109
178
  }
110
179
 
111
180
  // Global — preserve url/command/description from builtin if not overridden
@@ -121,6 +190,7 @@ export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
121
190
  env: s.env ?? existing.env,
122
191
  description: s.description ?? existing.description,
123
192
  source: "global",
193
+ canUseInProject: s.canUseInProject ?? existing.canUseInProject,
124
194
  });
125
195
  } else {
126
196
  byName.set(s.name, s);
@@ -140,6 +210,7 @@ export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
140
210
  env: s.env ?? existing.env,
141
211
  description: s.description ?? existing.description,
142
212
  source: "project",
213
+ canUseInProject: s.canUseInProject ?? existing.canUseInProject,
143
214
  });
144
215
  } else {
145
216
  byName.set(s.name, s);
@@ -201,13 +272,14 @@ export function toggleMcpServerEnabled(
201
272
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
202
273
  fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
203
274
  } else {
204
- const config = loadConfig();
205
- config.mcpServers = config.mcpServers || {};
206
- config.mcpServers[serverName] = { ...(config.mcpServers[serverName] || {}), enabled };
207
- const configPath = path.join(os.homedir(), ".pi/agent/decorated-pi.json");
208
- const dir = path.dirname(configPath);
275
+ const filePath = globalMcpJsonPath();
276
+ const raw = readMcpJsonSafe(filePath) || { mcpServers: {} };
277
+ const servers = raw.mcpServers ?? {};
278
+ servers[serverName] = { ...(servers[serverName] || {}), enabled };
279
+ raw.mcpServers = servers;
280
+ const dir = path.dirname(filePath);
209
281
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
210
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
282
+ fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
211
283
  }
212
284
  return true;
213
285
  } catch {
@@ -1076,12 +1076,18 @@ interface LineRange {
1076
1076
  endLine: number;
1077
1077
  }
1078
1078
 
1079
- /** Build line offset table: offsets[i] = character offset of line i+1 (1-based) */
1079
+ /** Build line offset table: offsets[i] = character offset of line i+1 (1-based).
1080
+ * If the content does not end with a newline, the final line has no
1081
+ * trailing marker; push an extra offset at content.length so callers
1082
+ * like lineAtOffset / extractLineRange handle the last line correctly. */
1080
1083
  function buildLineOffsets(content: string): number[] {
1081
1084
  const offsets = [0];
1082
1085
  for (let i = 0; i < content.length; i++) {
1083
1086
  if (content[i] === "\n") offsets.push(i + 1);
1084
1087
  }
1088
+ if (content.length > 0 && content[content.length - 1] !== "\n") {
1089
+ offsets.push(content.length);
1090
+ }
1085
1091
  return offsets;
1086
1092
  }
1087
1093
 
@@ -215,8 +215,9 @@ export function registerPatchTool(pi: ExtensionAPI): void {
215
215
  ].join("\n"),
216
216
  promptSnippet: "Edits a file using exact string replacement, with anchor support.",
217
217
  promptGuidelines: [
218
- "Always prefer modifying files with patch tool over bash commands or python scripts.",
219
- "To prevent hallucinations: 1. Keep each edit batch 5 changes; 2. Process remaining revisions in sequential steps",
218
+ "The patch tool is the preferred way to modify files use it over the write tool (full overwrite) or the bash tool (sed/echo).",
219
+ "When editing an existing file, use patch instead of write to avoid overwriting changes made since the file was last read.",
220
+ "To prevent hallucinations: 1. Keep each edit batch ≤ 5 changes; 2. Process remaining revisions in sequential steps.",
220
221
  "On repeated failures: read the file first to confirm information accuracy.",
221
222
  ],
222
223
  parameters: PatchSchema,