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 +19 -10
- package/commands/dp-settings.ts +6 -1
- package/hooks/mcp.ts +52 -0
- package/hooks/session-title.ts +25 -1
- package/index.ts +33 -22
- package/package.json +4 -3
- 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/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
|
@@ -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 {
|