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/README.md +45 -15
- package/commands/dp-settings.ts +6 -1
- package/hooks/mcp.ts +52 -0
- package/hooks/session-title.ts +25 -1
- package/hooks/smart-at.ts +114 -264
- package/index.ts +33 -22
- package/package.json +13 -5
- package/settings.ts +170 -31
- package/tools/ask/index.ts +93 -0
- package/tools/mcp/builtin/codegraph.ts +10 -34
- package/tools/mcp/builtin/index.ts +2 -2
- package/tools/mcp/config.ts +93 -21
- package/tools/patch/core.ts +7 -1
- package/tools/patch/index.ts +3 -2
- package/ui/ask.ts +443 -0
- package/ui/module-settings.ts +131 -26
- package/ui/usage.ts +4 -4
- package/.codegraph/daemon.pid +0 -6
- package/AGENTS.md +0 -92
package/settings.ts
CHANGED
|
@@ -35,14 +35,32 @@ export interface McpServerEntry {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export interface ModuleSettings {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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))
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
171
|
-
const
|
|
172
|
-
|
|
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 {
|
|
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
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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
|
-
|
|
190
|
-
|
|
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 +
|
|
2
|
+
* codegraph builtin MCP server — config + project artefact check.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
17
|
+
enabled: false,
|
|
15
18
|
description:
|
|
16
|
-
"Local code knowledge graph (colbymchenry/codegraph). Enable via /
|
|
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
|
|
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
|
|
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">[] = [
|
package/tools/mcp/config.ts
CHANGED
|
@@ -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/
|
|
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 {
|
|
19
|
+
import { isModuleEnabled } from "../../settings.js";
|
|
16
20
|
import type { DependencyStatus } from "../../hooks/skeleton.js";
|
|
17
|
-
import { BUILTIN_MCP_SERVERS
|
|
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/
|
|
140
|
+
/** Load global MCP configs from ~/.pi/agent/mcp.json. */
|
|
73
141
|
export function loadGlobalMcpConfigs(): McpServerConfig[] {
|
|
74
|
-
const
|
|
75
|
-
if (!
|
|
142
|
+
const servers = readMcpJson(globalMcpJsonPath());
|
|
143
|
+
if (!servers) return [];
|
|
76
144
|
|
|
77
|
-
return Object.entries(
|
|
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:
|
|
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).
|
|
102
|
-
//
|
|
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
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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(
|
|
282
|
+
fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
|
|
211
283
|
}
|
|
212
284
|
return true;
|
|
213
285
|
} catch {
|
package/tools/patch/core.ts
CHANGED
|
@@ -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
|
|
package/tools/patch/index.ts
CHANGED
|
@@ -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
|
-
"
|
|
219
|
-
"
|
|
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,
|