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/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
- const created = FileFinder.create({ basePath: cwd || "." });
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; the provider queries the
99
- // (possibly partial) index and FFF returns whatever it has indexed
100
- // so far. Full results appear as the scan progresses.
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: (...args: any[]) => {
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.apply(orig, args);
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, execFileSync } from "node:child_process";
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
- try {
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 exists = options.exists ?? fs.existsSync;
148
- const fromPath = probePath();
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
- sk.declareDependency({
346
- label: "wakatime-cli",
347
- check: () => findWakatimeCli() !== null,
348
- hint: "Install wakatime-cli to track coding activity.",
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.replace(/\nCurrent date: \d{4}-\d{2}-\d{2}/, "");
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, pi);
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")) registerLspTools(pi, new LspServerManager());
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.1",
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
- "smart-at",
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
- if (migrateModuleSettings(config)) {
86
- saveConfig(config);
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
- return JSON.stringify(loadedModuleSnapshot) !== JSON.stringify(getAllModuleSettings());
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)
@@ -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 {