decorated-pi 0.7.1 → 0.7.3
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 +29 -8
- package/commands/dp-settings.ts +3 -3
- package/hooks/compaction.ts +115 -85
- package/hooks/externalize.ts +48 -35
- package/hooks/inject-agents-md.ts +20 -12
- package/hooks/mcp.ts +61 -64
- package/hooks/pi-tool-filter.ts +2 -1
- package/hooks/rtk.ts +24 -96
- package/hooks/skeleton.ts +86 -23
- package/hooks/smart-at.ts +59 -7
- package/hooks/wakatime.ts +16 -28
- package/index.ts +122 -4
- package/package.json +2 -4
- package/settings.ts +164 -4
- package/skills/pi-docs/SKILL.md +13 -0
- package/tools/ask/index.ts +0 -3
- package/tools/lsp/servers.ts +31 -15
- package/tools/lsp/tools.ts +0 -1
- package/tools/mcp/builtin/codegraph.ts +1 -1
- package/tools/mcp/client.ts +1 -1
- package/tools/mcp/config.ts +34 -15
- package/tools/patch/core.ts +10 -0
- package/tools/patch/index.ts +338 -226
- package/tsconfig.json +1 -0
- package/ui/ask.ts +72 -22
- package/ui/module-settings.ts +166 -16
- package/utils/which.ts +109 -0
package/hooks/smart-at.ts
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
* FFF maintains an in-memory index, scores files by fuzzy match +
|
|
6
6
|
* frecency + git status, and returns ranked results. We create one
|
|
7
7
|
* FileFinder per session and query it directly for every @ prefix.
|
|
8
|
+
*
|
|
9
|
+
* KNOWN FFF LIMITATION (v0.9.4):
|
|
10
|
+
* FFF only returns directories that directly contain files. Intermediate
|
|
11
|
+
* folders that only hold subdirectories (e.g. product/module/apmanage/ where
|
|
12
|
+
* all files live in product/module/apmanage/deep/) will not appear in
|
|
13
|
+
* mixedSearch/directorySearch results, even though the files underneath do.
|
|
14
|
+
* We intentionally do NOT synthesize these directories on the TypeScript side
|
|
15
|
+
* to avoid extra complexity/cost; the fix belongs upstream.
|
|
8
16
|
*/
|
|
9
17
|
|
|
10
18
|
import { FileFinder, type MixedItem } from "@ff-labs/fff-node";
|
|
@@ -85,7 +93,15 @@ export const smartAtModule: Module = {
|
|
|
85
93
|
session_start: [
|
|
86
94
|
async (_event: any, ctx: ExtensionContext) => {
|
|
87
95
|
const cwd = String(ctx.cwd || "").trim();
|
|
88
|
-
|
|
96
|
+
// Always opt in to home/root scanning. These flags are opt-in guards
|
|
97
|
+
// in FFF — when cwd is a normal project, they're no-ops; when cwd
|
|
98
|
+
// IS $HOME or /, they let FFF index it. Without them, create() fails
|
|
99
|
+
// outright when cwd is a home/root, leaving the user without @-search.
|
|
100
|
+
const created = FileFinder.create({
|
|
101
|
+
basePath: cwd || ".",
|
|
102
|
+
enableHomeDirScanning: true,
|
|
103
|
+
enableFsRootScanning: true,
|
|
104
|
+
});
|
|
89
105
|
if (!created.ok) {
|
|
90
106
|
// FFF not available on this platform; silently skip.
|
|
91
107
|
return;
|
|
@@ -94,11 +110,18 @@ export const smartAtModule: Module = {
|
|
|
94
110
|
const finder = created.value;
|
|
95
111
|
currentFinder = finder;
|
|
96
112
|
|
|
113
|
+
let scanWidgetVisible = false;
|
|
114
|
+
|
|
97
115
|
// Start the scan in the background. We don't wait for it here so
|
|
98
|
-
// session_start returns immediately
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
void finder.waitForScan(60_000)
|
|
116
|
+
// session_start returns immediately. If a scanning status was shown,
|
|
117
|
+
// clear it when the scan finishes even if no new autocomplete request
|
|
118
|
+
// is triggered afterwards.
|
|
119
|
+
void finder.waitForScan(60_000).then(() => {
|
|
120
|
+
if (currentFinder === finder && !finder.isDestroyed && scanWidgetVisible) {
|
|
121
|
+
scanWidgetVisible = false;
|
|
122
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
102
125
|
|
|
103
126
|
ctx.ui.addAutocompleteProvider((orig: any) => ({
|
|
104
127
|
getSuggestions: (
|
|
@@ -134,18 +157,47 @@ export const smartAtModule: Module = {
|
|
|
134
157
|
return null;
|
|
135
158
|
}
|
|
136
159
|
|
|
160
|
+
// 0 items during the initial scan means FFF is not ready yet.
|
|
161
|
+
// Autocomplete has no non-selectable dropdown state: returning an
|
|
162
|
+
// item would force SelectList to render a selectable "→ ..." row.
|
|
163
|
+
// So use a static below-editor widget while scanning, and clear it
|
|
164
|
+
// once the scan completes (see waitForScan above). After scanning,
|
|
165
|
+
// 0 items just means "no match".
|
|
166
|
+
if (r.value.items.length === 0) {
|
|
167
|
+
if (finder.isScanning()) {
|
|
168
|
+
scanWidgetVisible = true;
|
|
169
|
+
ctx.ui.setWidget(
|
|
170
|
+
"smart-at",
|
|
171
|
+
["⏳ scanning… (indexing files, please wait)"],
|
|
172
|
+
{ placement: "belowEditor" },
|
|
173
|
+
);
|
|
174
|
+
} else {
|
|
175
|
+
scanWidgetVisible = false;
|
|
176
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
137
181
|
const result = buildResult(r.value.items, lowerQuery);
|
|
138
182
|
if (!result) {
|
|
139
183
|
ctx.ui.setWidget("smart-at", undefined);
|
|
140
184
|
return null;
|
|
141
185
|
}
|
|
142
186
|
|
|
187
|
+
scanWidgetVisible = false;
|
|
143
188
|
ctx.ui.setWidget("smart-at", [WIDGET_FOOTER]);
|
|
144
189
|
return Promise.resolve({ ...result, prefix });
|
|
145
190
|
},
|
|
146
|
-
applyCompletion: (
|
|
191
|
+
applyCompletion: (
|
|
192
|
+
lines: string[],
|
|
193
|
+
cl: number,
|
|
194
|
+
cc: number,
|
|
195
|
+
item: { value: string; label: string },
|
|
196
|
+
prefix: string,
|
|
197
|
+
) => {
|
|
198
|
+
scanWidgetVisible = false;
|
|
147
199
|
ctx.ui.setWidget("smart-at", undefined);
|
|
148
|
-
return orig.applyCompletion
|
|
200
|
+
return orig.applyCompletion(lines, cl, cc, item, prefix);
|
|
149
201
|
},
|
|
150
202
|
shouldTriggerFileCompletion:
|
|
151
203
|
orig.shouldTriggerFileCompletion?.bind(orig),
|
package/hooks/wakatime.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { execFile
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
+
import { resolveDependency } from "../settings.js";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
15
16
|
|
|
@@ -122,36 +123,21 @@ export function buildPluginString(version = PACKAGE_VERSION): string {
|
|
|
122
123
|
return `pi/${version} pi/${version}`;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
export function wakatimeDependencyExtendPath(): string[] {
|
|
127
|
+
return [path.join(os.homedir(), ".wakatime")];
|
|
128
|
+
}
|
|
129
|
+
|
|
125
130
|
function findWakatimeCliOnPath(): string | null {
|
|
126
|
-
|
|
127
|
-
if (process.platform === "win32") {
|
|
128
|
-
const output = execFileSync("where", ["wakatime-cli"], { encoding: "utf-8" }).trim();
|
|
129
|
-
const first = output.split(/\r?\n/)[0]?.trim();
|
|
130
|
-
return first ? path.resolve(first) : null;
|
|
131
|
-
}
|
|
132
|
-
const shell = process.env.SHELL || "sh";
|
|
133
|
-
const output = execFileSync(shell, ["-lc", "command -v wakatime-cli"], { encoding: "utf-8" }).trim();
|
|
134
|
-
return output ? path.resolve(output) : null;
|
|
135
|
-
} catch {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
131
|
+
return resolveDependency("wakatime-cli", { extendPath: wakatimeDependencyExtendPath() });
|
|
138
132
|
}
|
|
139
133
|
|
|
140
134
|
export function findWakatimeCli(options: {
|
|
141
135
|
probePath?: () => string | null;
|
|
142
|
-
exists?: (candidate: string) => boolean;
|
|
143
|
-
fallbackPath?: string;
|
|
144
136
|
} = {}): string | null {
|
|
145
137
|
if (cachedWakatimeCliPath !== undefined) return cachedWakatimeCliPath;
|
|
146
138
|
const probePath = options.probePath ?? findWakatimeCliOnPath;
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
if (fromPath) {
|
|
150
|
-
cachedWakatimeCliPath = path.resolve(fromPath);
|
|
151
|
-
return cachedWakatimeCliPath;
|
|
152
|
-
}
|
|
153
|
-
const fallback = path.resolve(options.fallbackPath ?? WAKATIME_CLI_FALLBACK);
|
|
154
|
-
cachedWakatimeCliPath = exists(fallback) ? fallback : null;
|
|
139
|
+
const found = probePath();
|
|
140
|
+
cachedWakatimeCliPath = found ? path.resolve(found) : null;
|
|
155
141
|
return cachedWakatimeCliPath;
|
|
156
142
|
}
|
|
157
143
|
|
|
@@ -342,11 +328,13 @@ export function setupWakatimeWithApiKey(
|
|
|
342
328
|
export function setupWakatime(sk: Skeleton, pi: ExtensionAPI): void {
|
|
343
329
|
const apiKey = readWakatimeCfgApiKey();
|
|
344
330
|
const cliPath = findWakatimeCli();
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
331
|
+
if (!cliPath) {
|
|
332
|
+
sk.declareMissing({
|
|
333
|
+
name: "wakatime-cli",
|
|
334
|
+
module: "wakatime",
|
|
335
|
+
hint: "Install wakatime-cli to track coding activity.",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
350
338
|
if (!apiKey || !cliPath) return;
|
|
351
339
|
|
|
352
340
|
const sendHeartbeat: HeartbeatSender = (hb, cwd) => sendHeartbeatViaCli(hb, apiKey, cliPath, cwd);
|
package/index.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
19
20
|
|
|
20
21
|
import { createSkeleton } from "./hooks/skeleton.js";
|
|
21
22
|
|
|
@@ -36,8 +37,9 @@ import { setupRtk } from "./hooks/rtk.js";
|
|
|
36
37
|
import { registerPatchTool } from "./tools/patch/index.js";
|
|
37
38
|
import { registerLspTools } from "./tools/lsp/tools.js";
|
|
38
39
|
import { LspServerManager } from "./tools/lsp/manager.js";
|
|
40
|
+
import { collectLspDependencyStatuses } from "./tools/lsp/servers.js";
|
|
39
41
|
import { registerAskTool } from "./tools/ask/index.js";
|
|
40
|
-
import { resolveMcpConfigs, migrateLegacyGlobalMcpConfig } from "./tools/mcp/config.js";
|
|
42
|
+
import { resolveMcpConfigs, migrateLegacyGlobalMcpConfig, collectMcpDependencyStatuses } from "./tools/mcp/config.js";
|
|
41
43
|
import { ensureMcpServerReady } from "./hooks/mcp.js";
|
|
42
44
|
|
|
43
45
|
import { registerDpModelCommand } from "./commands/dp-model.js";
|
|
@@ -65,6 +67,71 @@ const BASE_GUIDANCE = [
|
|
|
65
67
|
"- CAUTION: Do not perform write operations in the following directories unless explicitly instructed: `node_modules`, `venv`, `env`, `__pycache__`, `.git` or any other hidden directories.",
|
|
66
68
|
].join("\n");
|
|
67
69
|
|
|
70
|
+
/** Remove the injected Pi documentation block from the base system prompt.
|
|
71
|
+
* Matches a line containing "Pi documentation" and deletes it plus all
|
|
72
|
+
* following non-empty lines, stopping at the first blank line. */
|
|
73
|
+
export function stripPiDocsBlock(prompt: string): string {
|
|
74
|
+
const lines = prompt.split("\n");
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
let i = 0;
|
|
77
|
+
while (i < lines.length) {
|
|
78
|
+
const line = lines[i];
|
|
79
|
+
if (line.includes("Pi documentation")) {
|
|
80
|
+
i++;
|
|
81
|
+
while (i < lines.length && lines[i].trim() !== "") i++;
|
|
82
|
+
// Drop the terminating blank line as well so we don't leave orphan whitespace.
|
|
83
|
+
if (i < lines.length && lines[i].trim() === "") i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
out.push(line);
|
|
87
|
+
i++;
|
|
88
|
+
}
|
|
89
|
+
return out.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Sort the <available_skills> block in the system prompt by skill name.
|
|
93
|
+
* Pi core appends extension-provided skills after user/project skills and does
|
|
94
|
+
* not sort the XML; this makes the final prompt stable and cache-friendly. */
|
|
95
|
+
export function sortSkillsInSystemPrompt(prompt: string): string {
|
|
96
|
+
const startMarker = "\n<available_skills>";
|
|
97
|
+
const endMarker = "</available_skills>";
|
|
98
|
+
const startIdx = prompt.indexOf(startMarker);
|
|
99
|
+
if (startIdx === -1) return prompt;
|
|
100
|
+
const endIdx = prompt.indexOf(endMarker, startIdx);
|
|
101
|
+
if (endIdx === -1) return prompt;
|
|
102
|
+
|
|
103
|
+
const before = prompt.slice(0, startIdx + startMarker.length);
|
|
104
|
+
const after = prompt.slice(endIdx);
|
|
105
|
+
const inner = prompt.slice(startIdx + startMarker.length, endIdx);
|
|
106
|
+
|
|
107
|
+
const chunks: string[][] = [];
|
|
108
|
+
let current: string[] = [];
|
|
109
|
+
for (const line of inner.split("\n")) {
|
|
110
|
+
const trimmed = line.trim();
|
|
111
|
+
if (trimmed === "<skill>") {
|
|
112
|
+
current = [line];
|
|
113
|
+
} else if (trimmed === "</skill>") {
|
|
114
|
+
current.push(line);
|
|
115
|
+
chunks.push(current);
|
|
116
|
+
current = [];
|
|
117
|
+
} else if (current.length > 0) {
|
|
118
|
+
current.push(line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const nameOf = (chunk: string[]) => {
|
|
123
|
+
const line = chunk.find((l) => l.trim().startsWith("<name>"));
|
|
124
|
+
if (!line) return "";
|
|
125
|
+
const t = line.trim();
|
|
126
|
+
return t.slice(6, t.indexOf("</name>"));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
chunks.sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
|
|
130
|
+
|
|
131
|
+
const sortedInner = "\n" + chunks.map((chunk) => chunk.join("\n")).join("\n") + "\n";
|
|
132
|
+
return before + sortedInner + after;
|
|
133
|
+
}
|
|
134
|
+
|
|
68
135
|
/** Build the list of guideline strings to inject, in prompt order.
|
|
69
136
|
* Always-on rules first, then per-module guidelines. REDACT_GUIDANCE is
|
|
70
137
|
* gated by the secretRedaction module; CodeGraph guidance follows the MCP
|
|
@@ -80,6 +147,26 @@ function buildGuidelines(): string[] {
|
|
|
80
147
|
return out;
|
|
81
148
|
}
|
|
82
149
|
|
|
150
|
+
function canRegisterMcpServer(config: { name: string; command?: string }, deps: Array<{ module: string; state: string }>): boolean {
|
|
151
|
+
if (!config.command) return true;
|
|
152
|
+
const dep = deps.find((d) => d.module === `mcp:${config.name}`);
|
|
153
|
+
return dep ? dep.state === "ok" : true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Absolute path to the plugin's builtin skills directory.
|
|
157
|
+
* Used by `resources_discover` so the skill travels with the plugin
|
|
158
|
+
* regardless of which project pi is running in. */
|
|
159
|
+
export function getBuiltinSkillPaths(): string[] {
|
|
160
|
+
return [fileURLToPath(new URL("./skills", import.meta.url))];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Register the plugin's builtin skill paths with Pi core. */
|
|
164
|
+
function installBuiltinSkills(pi: ExtensionAPI): void {
|
|
165
|
+
pi.on("resources_discover", async (_event: any) => ({
|
|
166
|
+
skillPaths: getBuiltinSkillPaths(),
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
83
170
|
/** Install a single before_agent_start handler that appends every
|
|
84
171
|
* guideline in order, stripping the volatile "Current date: …" line
|
|
85
172
|
* for cache stability. Idempotent — re-injection is a no-op via marker. */
|
|
@@ -90,7 +177,9 @@ function installGuidelines(pi: ExtensionAPI): void {
|
|
|
90
177
|
|
|
91
178
|
pi.on("before_agent_start", async (event: any) => {
|
|
92
179
|
if (!event.systemPrompt) return undefined;
|
|
93
|
-
let prompt: string = event.systemPrompt
|
|
180
|
+
let prompt: string = stripPiDocsBlock(event.systemPrompt);
|
|
181
|
+
prompt = sortSkillsInSystemPrompt(prompt);
|
|
182
|
+
prompt = prompt.replace(/\nCurrent date: \d{4}-\d{2}-\d{2}/, "");
|
|
94
183
|
if (prompt.includes(marker)) return undefined; // already injected this turn
|
|
95
184
|
return { systemPrompt: `${prompt}\n\n${joined}` };
|
|
96
185
|
});
|
|
@@ -125,12 +214,26 @@ export default async function (pi: ExtensionAPI) {
|
|
|
125
214
|
|
|
126
215
|
// Compaction + RTK (these also install their own pi.on via setup<>()).
|
|
127
216
|
setupCompaction(sk);
|
|
128
|
-
if (isModuleEnabled("rtk")) setupRtk(sk
|
|
217
|
+
if (isModuleEnabled("rtk")) setupRtk(sk);
|
|
129
218
|
if (isModuleEnabled("wakatime")) setupWakatime(sk, pi);
|
|
130
219
|
|
|
131
220
|
// ── Tools (conditional on module switches) ────────────────────────────
|
|
132
221
|
if (isModuleEnabled("patchOverrideEdit")) registerPatchTool(pi);
|
|
133
|
-
if (isModuleEnabled("lsp"))
|
|
222
|
+
if (isModuleEnabled("lsp")) {
|
|
223
|
+
const lspDeps = collectLspDependencyStatuses(process.cwd());
|
|
224
|
+
if (lspDeps.some((d) => d.state === "ok")) {
|
|
225
|
+
registerLspTools(pi, new LspServerManager());
|
|
226
|
+
}
|
|
227
|
+
for (const dep of lspDeps) {
|
|
228
|
+
if (dep.state !== "ok") {
|
|
229
|
+
sk.declareMissing({
|
|
230
|
+
name: dep.label,
|
|
231
|
+
module: "lsp",
|
|
232
|
+
hint: dep.detail,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
134
237
|
if (isModuleEnabled("ask")) registerAskTool(pi);
|
|
135
238
|
|
|
136
239
|
// MCP: hook, tools, and /mcp command are gated together. Disabling the
|
|
@@ -142,16 +245,31 @@ export default async function (pi: ExtensionAPI) {
|
|
|
142
245
|
// explicitly here so `loadGlobalMcpConfigs` stays pure.
|
|
143
246
|
migrateLegacyGlobalMcpConfig();
|
|
144
247
|
sk.register(mcpModule);
|
|
248
|
+
const mcpDeps = collectMcpDependencyStatuses(process.cwd());
|
|
249
|
+
for (const dep of mcpDeps) {
|
|
250
|
+
if (dep.state !== "ok") {
|
|
251
|
+
sk.declareMissing({
|
|
252
|
+
name: dep.label, // binary name (e.g. "codegraph")
|
|
253
|
+
module: "mcp",
|
|
254
|
+
hint: dep.detail,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
145
258
|
const configs = resolveMcpConfigs(process.cwd()).filter(s => s.enabled);
|
|
146
259
|
// Per-server readiness: cache hit → register from cache (fast).
|
|
147
260
|
// Cache miss → connect synchronously, write cache, then register
|
|
148
261
|
// live tools. This blocks startup only for cache-miss servers.
|
|
262
|
+
// Skip servers whose binary is missing (dependency not met).
|
|
149
263
|
for (const config of configs) {
|
|
264
|
+
if (!canRegisterMcpServer(config, mcpDeps)) continue;
|
|
150
265
|
await ensureMcpServerReady(pi, config, process.cwd());
|
|
151
266
|
}
|
|
152
267
|
registerMcpStatusCommand(pi);
|
|
153
268
|
}
|
|
154
269
|
|
|
270
|
+
// ── Builtin skills (travel with the plugin in every project) ─────────────
|
|
271
|
+
installBuiltinSkills(pi);
|
|
272
|
+
|
|
155
273
|
// ── System-prompt guidelines (single handler, array order = prompt order) ──
|
|
156
274
|
installGuidelines(pi);
|
|
157
275
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decorated-pi",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
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",
|
|
@@ -14,9 +14,7 @@
|
|
|
14
14
|
"secret-redaction",
|
|
15
15
|
"codegraph",
|
|
16
16
|
"fff",
|
|
17
|
-
"
|
|
18
|
-
"fuzzy-search",
|
|
19
|
-
"file-finder"
|
|
17
|
+
"fuzzy-search"
|
|
20
18
|
],
|
|
21
19
|
"license": "MIT",
|
|
22
20
|
"repository": {
|
package/settings.ts
CHANGED
|
@@ -7,6 +7,7 @@ import * as fs from "node:fs";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import type { Model } from "@earendil-works/pi-ai";
|
|
10
|
+
import { which } from "./utils/which.js";
|
|
10
11
|
|
|
11
12
|
const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
12
13
|
const CONFIG_FILE = path.join(CONFIG_DIR, "decorated-pi.json");
|
|
@@ -69,22 +70,55 @@ export interface UsageIndexEntry {
|
|
|
69
70
|
mtime: number;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/** Per-binary dependency override.
|
|
74
|
+
*
|
|
75
|
+
* Keyed by binary name (e.g. "rtk", "wakatime-cli", "gopls",
|
|
76
|
+
* "codegraph"). `path` injects an extra search location into which();
|
|
77
|
+
* `dontBother` silences missing-dependency notifications for this binary. */
|
|
78
|
+
export interface DependencySettings {
|
|
79
|
+
/** Absolute path to the binary (file) or a directory to search. Injected
|
|
80
|
+
* into which()'s extendPath, so it's tried before $PATH. */
|
|
81
|
+
path?: string;
|
|
82
|
+
/** When true, skeleton's missing-dependency notification skips this
|
|
83
|
+
* binary. Use for binaries the user doesn't care about. */
|
|
84
|
+
dontBother?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DependencyView extends DependencySettings {
|
|
88
|
+
/** Runtime-only shadow value: what the resolver actually found.
|
|
89
|
+
* Not persisted to decorated-pi.json and not written by /dp-settings. */
|
|
90
|
+
resolvedPath?: string;
|
|
91
|
+
/** Runtime-only shadow value: whether the resolver checked/found it. */
|
|
92
|
+
resolvedState?: "ok" | "missing";
|
|
93
|
+
}
|
|
94
|
+
|
|
72
95
|
export interface DecoratedPiConfig {
|
|
73
96
|
imageModelKey?: string | null;
|
|
74
97
|
compactModelKey?: string | null;
|
|
98
|
+
dependencies?: Record<string, DependencySettings>;
|
|
75
99
|
providers?: Record<string, ProviderCache>;
|
|
76
100
|
modules?: ModuleSettings;
|
|
77
101
|
mcpServers?: Record<string, McpServerEntry>;
|
|
78
102
|
usageIndex?: Record<string, UsageIndexEntry>;
|
|
79
103
|
}
|
|
80
104
|
|
|
105
|
+
/** Runtime view = real config plus in-memory shadow fields. The shadow has
|
|
106
|
+
* the same top-level shape as config, but may enrich selected leaves with
|
|
107
|
+
* runtime-only data. It is never persisted and /dp-settings writes only the
|
|
108
|
+
* real config through setter functions. */
|
|
109
|
+
export interface DecoratedPiConfigView extends Omit<DecoratedPiConfig, "dependencies"> {
|
|
110
|
+
dependencies?: Record<string, DependencyView>;
|
|
111
|
+
}
|
|
112
|
+
|
|
81
113
|
export function loadConfig(): DecoratedPiConfig {
|
|
82
114
|
try {
|
|
83
115
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
84
116
|
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")) as DecoratedPiConfig;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
// Mutate in place. Do NOT call saveConfig here — saveConfig calls
|
|
118
|
+
// loadConfig first, which would re-trigger the migration (and the
|
|
119
|
+
// file on disk hasn't been written yet), causing deep recursion.
|
|
120
|
+
// The migrated shape is persisted on the next explicit saveConfig.
|
|
121
|
+
migrateModuleSettings(config);
|
|
88
122
|
return config;
|
|
89
123
|
}
|
|
90
124
|
} catch {}
|
|
@@ -97,6 +131,11 @@ export function saveConfig(config: Partial<DecoratedPiConfig>) {
|
|
|
97
131
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...config }, null, 2), "utf-8");
|
|
98
132
|
}
|
|
99
133
|
|
|
134
|
+
// Runtime-only shadow for the whole config. It mirrors DecoratedPiConfig's
|
|
135
|
+
// top-level shape but is never persisted; runtime modules update it, and
|
|
136
|
+
// /dp-settings reads the merged view for display only.
|
|
137
|
+
const configShadow: DecoratedPiConfigView = {};
|
|
138
|
+
|
|
100
139
|
// ─── 辅助 ──────────────────────────────────────────────────────────────────
|
|
101
140
|
|
|
102
141
|
export function formatModelKey(m: Model<any>): string {
|
|
@@ -163,6 +202,77 @@ export function getCompactModelKey(): string | null {
|
|
|
163
202
|
return loadConfig().compactModelKey ?? null;
|
|
164
203
|
}
|
|
165
204
|
|
|
205
|
+
/** Look up a binary's configured path override. Returns null when not set. */
|
|
206
|
+
export function getDependencyPath(name: string): string | null {
|
|
207
|
+
return loadConfig().dependencies?.[name]?.path ?? null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Whether missing-dependency notifications are silenced for this binary. */
|
|
211
|
+
export function isDontBother(name: string): boolean {
|
|
212
|
+
return loadConfig().dependencies?.[name]?.dontBother === true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function getConfigView(): DecoratedPiConfigView {
|
|
216
|
+
const real = loadConfig();
|
|
217
|
+
const dependencyNames = new Set([
|
|
218
|
+
...Object.keys(real.dependencies ?? {}),
|
|
219
|
+
...Object.keys(configShadow.dependencies ?? {}),
|
|
220
|
+
]);
|
|
221
|
+
const dependencies: Record<string, DependencyView> = {};
|
|
222
|
+
for (const name of dependencyNames) {
|
|
223
|
+
dependencies[name] = {
|
|
224
|
+
...(real.dependencies?.[name] ?? {}),
|
|
225
|
+
...(configShadow.dependencies?.[name] ?? {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
...real,
|
|
230
|
+
...configShadow,
|
|
231
|
+
dependencies: Object.keys(dependencies).length ? dependencies : undefined,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function getDependencyView(name: string): DependencyView {
|
|
236
|
+
return getConfigView().dependencies?.[name] ?? {};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function listDependencyViewNames(extraNames: string[] = []): string[] {
|
|
240
|
+
return [...new Set([
|
|
241
|
+
...extraNames,
|
|
242
|
+
...Object.keys(getConfigView().dependencies ?? {}),
|
|
243
|
+
])].sort();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function recordDependencyResolution(name: string, resolvedPath: string | null): void {
|
|
247
|
+
if (!configShadow.dependencies) configShadow.dependencies = {};
|
|
248
|
+
configShadow.dependencies[name] = {
|
|
249
|
+
...(configShadow.dependencies[name] ?? {}),
|
|
250
|
+
...(resolvedPath
|
|
251
|
+
? { resolvedState: "ok" as const, resolvedPath }
|
|
252
|
+
: { resolvedState: "missing" as const, resolvedPath: undefined }),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Resolve a binary using dependency config plus caller-specific search
|
|
257
|
+
* locations. Modules call this at startup/runtime; it records a shadow
|
|
258
|
+
* view for /dp-settings but does not persist anything.
|
|
259
|
+
*
|
|
260
|
+
* Order:
|
|
261
|
+
* 1. dependencies[name].path (file or directory)
|
|
262
|
+
* 2. opts.extendPath entries (file or directory)
|
|
263
|
+
* 3. $PATH
|
|
264
|
+
*/
|
|
265
|
+
export function resolveDependency(name: string, opts?: { extendPath?: string[] }): string | null {
|
|
266
|
+
const override = getDependencyPath(name);
|
|
267
|
+
const extendPath = [
|
|
268
|
+
...(override ? [override] : []),
|
|
269
|
+
...(opts?.extendPath ?? []),
|
|
270
|
+
];
|
|
271
|
+
const resolved = which(name, { extendPath });
|
|
272
|
+
recordDependencyResolution(name, resolved);
|
|
273
|
+
return resolved;
|
|
274
|
+
}
|
|
275
|
+
|
|
166
276
|
// ─── Setter ─────────────────────────────────────────────────────────────────
|
|
167
277
|
|
|
168
278
|
export function setImageModelKey(key: string | null) {
|
|
@@ -173,6 +283,47 @@ export function setCompactModelKey(key: string | null) {
|
|
|
173
283
|
saveConfig({ compactModelKey: key });
|
|
174
284
|
}
|
|
175
285
|
|
|
286
|
+
/** Set or clear a binary's path override. Pass null to remove the path.
|
|
287
|
+
* Preserves any dontBother flag on the same entry (精准修改不覆盖). */
|
|
288
|
+
export function setDependencyPath(name: string, path: string | null) {
|
|
289
|
+
const current = loadConfig();
|
|
290
|
+
const deps = { ...(current.dependencies ?? {}) };
|
|
291
|
+
const existing = deps[name] ?? {};
|
|
292
|
+
if (path === null) {
|
|
293
|
+
const { path: _drop, ...rest } = existing;
|
|
294
|
+
if (Object.keys(rest).length === 0) {
|
|
295
|
+
delete deps[name];
|
|
296
|
+
} else {
|
|
297
|
+
deps[name] = rest;
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
deps[name] = { ...existing, path };
|
|
301
|
+
}
|
|
302
|
+
// Path edits are config writes; invalidate runtime-only resolution for
|
|
303
|
+
// this binary so /dp-settings never shows a stale pre-edit shadow path.
|
|
304
|
+
if (configShadow.dependencies?.[name]) delete configShadow.dependencies[name];
|
|
305
|
+
saveConfig({ dependencies: deps });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Set or clear a binary's dontBother flag. Pass false to re-enable
|
|
309
|
+
* missing-dependency notifications. Preserves any path override. */
|
|
310
|
+
export function setDontBother(name: string, dontBother: boolean) {
|
|
311
|
+
const current = loadConfig();
|
|
312
|
+
const deps = { ...(current.dependencies ?? {}) };
|
|
313
|
+
const existing = deps[name] ?? {};
|
|
314
|
+
if (dontBother) {
|
|
315
|
+
deps[name] = { ...existing, dontBother: true };
|
|
316
|
+
} else {
|
|
317
|
+
const { dontBother: _drop, ...rest } = existing;
|
|
318
|
+
if (Object.keys(rest).length === 0) {
|
|
319
|
+
delete deps[name];
|
|
320
|
+
} else {
|
|
321
|
+
deps[name] = rest;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
saveConfig({ dependencies: deps });
|
|
325
|
+
}
|
|
326
|
+
|
|
176
327
|
// ─── Module Switches ──────────────────────────────────────────────────────────
|
|
177
328
|
|
|
178
329
|
const DEFAULT_MODULES: Required<ModuleSettings> = {
|
|
@@ -319,14 +470,23 @@ export function getAllModuleSettings(): Required<ModuleSettings> {
|
|
|
319
470
|
* reload is actually necessary.
|
|
320
471
|
*/
|
|
321
472
|
let loadedModuleSnapshot: Required<ModuleSettings> | null = null;
|
|
473
|
+
let loadedDependenciesSnapshot: Record<string, DependencySettings> | null = null;
|
|
322
474
|
|
|
323
475
|
export function captureModuleSnapshot(): void {
|
|
324
476
|
loadedModuleSnapshot = getAllModuleSettings();
|
|
477
|
+
loadedDependenciesSnapshot = loadConfig().dependencies ?? {};
|
|
325
478
|
}
|
|
326
479
|
|
|
327
480
|
export function moduleSnapshotChanged(): boolean {
|
|
328
481
|
if (!loadedModuleSnapshot) return true;
|
|
329
|
-
|
|
482
|
+
if (JSON.stringify(loadedModuleSnapshot) !== JSON.stringify(getAllModuleSettings())) return true;
|
|
483
|
+
// Dependencies changes don't strictly require reload (which() reads the
|
|
484
|
+
// config file on every call), but prompt for consistency — the user
|
|
485
|
+
// might be mid-session and expect the new path to be picked up by hooks
|
|
486
|
+
// that captured the binary path at startup (rtk/wakatime).
|
|
487
|
+
const currentDeps = loadConfig().dependencies ?? {};
|
|
488
|
+
if (JSON.stringify(loadedDependenciesSnapshot) !== JSON.stringify(currentDeps)) return true;
|
|
489
|
+
return false;
|
|
330
490
|
}
|
|
331
491
|
|
|
332
492
|
// ─── Usage index (增量同步元数据) ─────────────────────────────────────────────
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-docs
|
|
3
|
+
description: pi docs resources
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
|
|
7
|
+
- Main documentation: /home/liuchang/.local/share/fnm/node-versions/v24.14.0/installation/lib/node_modules/@earendil-works/pi-coding-agent/README.md
|
|
8
|
+
- Additional docs: /home/liuchang/.local/share/fnm/node-versions/v24.14.0/installation/lib/node_modules/@earendil-works/pi-coding-agent/docs
|
|
9
|
+
- Examples: /home/liuchang/.local/share/fnm/node-versions/v24.14.0/installation/lib/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)
|
|
10
|
+
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
|
|
11
|
+
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)
|
|
12
|
+
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
|
|
13
|
+
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)
|
package/tools/ask/index.ts
CHANGED
|
@@ -19,9 +19,6 @@ const askQuestionSchema = Type.Object({
|
|
|
19
19
|
question: Type.String({ description: "Question text shown to the user." }),
|
|
20
20
|
options: Type.Optional(Type.Array(Type.String(), { description: "Options for single or multi choice." })),
|
|
21
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
22
|
});
|
|
26
23
|
|
|
27
24
|
function formatAnswer(q: AskQuestion, value: string | string[]): string {
|