decorated-pi 0.7.2 → 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 +18 -2
- package/commands/dp-settings.ts +3 -3
- package/hooks/compaction.ts +115 -85
- package/hooks/externalize.ts +48 -35
- package/hooks/rtk.ts +24 -97
- package/hooks/skeleton.ts +53 -18
- package/hooks/smart-at.ts +51 -7
- package/hooks/wakatime.ts +17 -30
- package/index.ts +15 -11
- package/package.json +1 -1
- package/settings.ts +159 -1
- package/tools/ask/index.ts +0 -3
- package/tools/lsp/servers.ts +31 -15
- package/tools/mcp/config.ts +34 -15
- 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/skeleton.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { isDontBother } from "../settings.js";
|
|
14
15
|
|
|
15
16
|
// ─── Event union ───────────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -42,13 +43,24 @@ export type ComposeHandler<E extends HookEvent> = (
|
|
|
42
43
|
pi: ExtensionAPI,
|
|
43
44
|
) => any | Promise<any>;
|
|
44
45
|
|
|
46
|
+
/** Result: each handler sees the original event; the last non-undefined
|
|
47
|
+
* return value wins. Matches runner.emit()'s behavior for `session_before_*`
|
|
48
|
+
* events, where the runner collects a single `{ cancel?, compaction? }`
|
|
49
|
+
* result from all extensions. Use for events whose contract is "the
|
|
50
|
+
* extension either overrides or steps aside". */
|
|
51
|
+
export type ResultHandler<E extends HookEvent> = (
|
|
52
|
+
event: any,
|
|
53
|
+
ctx: ExtensionContext,
|
|
54
|
+
pi: ExtensionAPI,
|
|
55
|
+
) => any | Promise<any>;
|
|
56
|
+
|
|
45
57
|
export interface Module {
|
|
46
58
|
readonly name: string;
|
|
47
59
|
readonly hooks: {
|
|
48
60
|
session_start?: ParallelHandler<"session_start">[];
|
|
49
61
|
session_shutdown?: ParallelHandler<"session_shutdown">[];
|
|
50
62
|
session_compact?: ParallelHandler<"session_compact">[];
|
|
51
|
-
session_before_compact?:
|
|
63
|
+
session_before_compact?: ResultHandler<"session_before_compact">[];
|
|
52
64
|
before_agent_start?: ComposeHandler<"before_agent_start">[];
|
|
53
65
|
agent_start?: ParallelHandler<"agent_start">[];
|
|
54
66
|
agent_end?: ParallelHandler<"agent_end">[];
|
|
@@ -61,10 +73,8 @@ export interface Module {
|
|
|
61
73
|
// ─── Declarations ──────────────────────────────────────────────────────────
|
|
62
74
|
|
|
63
75
|
export interface Dependency {
|
|
64
|
-
|
|
65
|
-
check: () => boolean;
|
|
76
|
+
name: string;
|
|
66
77
|
hint?: string;
|
|
67
|
-
/** Display/source tag for inspection and notifications. */
|
|
68
78
|
module?: string;
|
|
69
79
|
}
|
|
70
80
|
|
|
@@ -75,9 +85,12 @@ export interface Dependency {
|
|
|
75
85
|
* uses `Dependency.check` directly rather than collecting statuses. */
|
|
76
86
|
export interface DependencyStatus {
|
|
77
87
|
module: string;
|
|
88
|
+
/** Binary/config key, not necessarily the resolved absolute path. */
|
|
78
89
|
label: string;
|
|
79
90
|
state: "ok" | "missing";
|
|
80
91
|
detail: string;
|
|
92
|
+
/** Resolved executable path when state is ok. */
|
|
93
|
+
path?: string;
|
|
81
94
|
}
|
|
82
95
|
|
|
83
96
|
// ─── Skeleton ──────────────────────────────────────────────────────────────
|
|
@@ -88,10 +101,23 @@ const COMPOSE_EVENTS = new Set<HookEvent>([
|
|
|
88
101
|
"tool_result",
|
|
89
102
|
]);
|
|
90
103
|
|
|
104
|
+
/** Events whose handler return value is propagated to the extension runner
|
|
105
|
+
* (no chaining — each handler sees the original event, last non-undefined
|
|
106
|
+
* return wins). Required for `session_before_compact`, whose contract is
|
|
107
|
+
* `{ cancel?, compaction? }`; without this, hooks can't override pi's
|
|
108
|
+
* default compaction. */
|
|
109
|
+
const RESULT_EVENTS = new Set<HookEvent>([
|
|
110
|
+
"session_before_compact",
|
|
111
|
+
]);
|
|
112
|
+
|
|
91
113
|
export interface Skeleton {
|
|
92
114
|
register(module: Module): void;
|
|
93
115
|
/** Returns whether the dependency check passed right now. */
|
|
94
|
-
|
|
116
|
+
/** Declare that a binary dependency is missing. Module calls this
|
|
117
|
+
* after its own which() lookup failed. Skeleton dedupes by name,
|
|
118
|
+
* honors `dependencies[name].dontBother`, and shows a single
|
|
119
|
+
* "run /dp-settings" notification on session_start. */
|
|
120
|
+
declareMissing(dep: Omit<Dependency, "module"> & { module?: string }): void;
|
|
95
121
|
install(pi: ExtensionAPI): void;
|
|
96
122
|
inspect(): Inspection;
|
|
97
123
|
}
|
|
@@ -99,7 +125,7 @@ export interface Skeleton {
|
|
|
99
125
|
export interface Inspection {
|
|
100
126
|
modules: string[];
|
|
101
127
|
events: Record<string, Array<{ module: string; order: number }>>;
|
|
102
|
-
dependencies: Array<{
|
|
128
|
+
dependencies: Array<{ name: string; module?: string; hint?: string }>;
|
|
103
129
|
}
|
|
104
130
|
|
|
105
131
|
export function createSkeleton(): Skeleton {
|
|
@@ -125,13 +151,10 @@ export function createSkeleton(): Skeleton {
|
|
|
125
151
|
collect(mod);
|
|
126
152
|
},
|
|
127
153
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
} catch {
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
154
|
+
declareMissing(dep) {
|
|
155
|
+
// Dedupe by name — multiple modules may depend on the same binary.
|
|
156
|
+
if (dependencies.some((d) => d.name === dep.name)) return;
|
|
157
|
+
dependencies.push(dep as Dependency);
|
|
135
158
|
},
|
|
136
159
|
|
|
137
160
|
install(pi) {
|
|
@@ -147,6 +170,15 @@ export function createSkeleton(): Skeleton {
|
|
|
147
170
|
}
|
|
148
171
|
return current === event ? undefined : current;
|
|
149
172
|
});
|
|
173
|
+
} else if (RESULT_EVENTS.has(event)) {
|
|
174
|
+
pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
|
|
175
|
+
let result;
|
|
176
|
+
for (const { handler } of handlers) {
|
|
177
|
+
const r = await handler(event, ctx, pi);
|
|
178
|
+
if (r !== undefined) result = r;
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
});
|
|
150
182
|
} else {
|
|
151
183
|
pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
|
|
152
184
|
for (const { handler } of handlers) await handler(event, ctx, pi);
|
|
@@ -165,13 +197,16 @@ export function createSkeleton(): Skeleton {
|
|
|
165
197
|
dependencyNotifyTimer = undefined;
|
|
166
198
|
const missing: string[] = [];
|
|
167
199
|
for (const dep of dependencies) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
200
|
+
// dontBother flag silences the notification per-binary.
|
|
201
|
+
if (isDontBother(dep.name)) continue;
|
|
202
|
+
missing.push(dep.name);
|
|
171
203
|
}
|
|
172
204
|
if (missing.length) {
|
|
173
205
|
try {
|
|
174
|
-
ctx.ui.notify(
|
|
206
|
+
ctx.ui.notify(
|
|
207
|
+
`[decorated-pi] Some dependencies are missing (${missing.length}). Run /dp-settings → Dependencies to configure.`,
|
|
208
|
+
"info",
|
|
209
|
+
);
|
|
175
210
|
} catch {
|
|
176
211
|
// Extension context may be stale if another reload/session switch happened.
|
|
177
212
|
}
|
|
@@ -207,7 +242,7 @@ export function createSkeleton(): Skeleton {
|
|
|
207
242
|
return {
|
|
208
243
|
modules: modules.map((m) => m.name),
|
|
209
244
|
events,
|
|
210
|
-
dependencies: dependencies.map((d) => ({
|
|
245
|
+
dependencies: dependencies.map((d) => ({ name: d.name, module: d.module, hint: d.hint })),
|
|
211
246
|
};
|
|
212
247
|
},
|
|
213
248
|
};
|
package/hooks/smart-at.ts
CHANGED
|
@@ -93,7 +93,15 @@ export const smartAtModule: Module = {
|
|
|
93
93
|
session_start: [
|
|
94
94
|
async (_event: any, ctx: ExtensionContext) => {
|
|
95
95
|
const cwd = String(ctx.cwd || "").trim();
|
|
96
|
-
|
|
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
|
+
});
|
|
97
105
|
if (!created.ok) {
|
|
98
106
|
// FFF not available on this platform; silently skip.
|
|
99
107
|
return;
|
|
@@ -102,11 +110,18 @@ export const smartAtModule: Module = {
|
|
|
102
110
|
const finder = created.value;
|
|
103
111
|
currentFinder = finder;
|
|
104
112
|
|
|
113
|
+
let scanWidgetVisible = false;
|
|
114
|
+
|
|
105
115
|
// Start the scan in the background. We don't wait for it here so
|
|
106
|
-
// session_start returns immediately
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
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
|
+
});
|
|
110
125
|
|
|
111
126
|
ctx.ui.addAutocompleteProvider((orig: any) => ({
|
|
112
127
|
getSuggestions: (
|
|
@@ -142,18 +157,47 @@ export const smartAtModule: Module = {
|
|
|
142
157
|
return null;
|
|
143
158
|
}
|
|
144
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
|
+
|
|
145
181
|
const result = buildResult(r.value.items, lowerQuery);
|
|
146
182
|
if (!result) {
|
|
147
183
|
ctx.ui.setWidget("smart-at", undefined);
|
|
148
184
|
return null;
|
|
149
185
|
}
|
|
150
186
|
|
|
187
|
+
scanWidgetVisible = false;
|
|
151
188
|
ctx.ui.setWidget("smart-at", [WIDGET_FOOTER]);
|
|
152
189
|
return Promise.resolve({ ...result, prefix });
|
|
153
190
|
},
|
|
154
|
-
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;
|
|
155
199
|
ctx.ui.setWidget("smart-at", undefined);
|
|
156
|
-
return orig.applyCompletion
|
|
200
|
+
return orig.applyCompletion(lines, cl, cc, item, prefix);
|
|
157
201
|
},
|
|
158
202
|
shouldTriggerFileCompletion:
|
|
159
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,13 +328,14 @@ 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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
331
|
+
if (!cliPath) {
|
|
332
|
+
sk.declareMissing({
|
|
333
|
+
name: "wakatime-cli",
|
|
334
|
+
module: "wakatime",
|
|
335
|
+
hint: "Install wakatime-cli to track coding activity.",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (!apiKey || !cliPath) return;
|
|
352
339
|
|
|
353
340
|
const sendHeartbeat: HeartbeatSender = (hb, cwd) => sendHeartbeatViaCli(hb, apiKey, cliPath, cwd);
|
|
354
341
|
|
package/index.ts
CHANGED
|
@@ -214,7 +214,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
214
214
|
|
|
215
215
|
// Compaction + RTK (these also install their own pi.on via setup<>()).
|
|
216
216
|
setupCompaction(sk);
|
|
217
|
-
if (isModuleEnabled("rtk")) setupRtk(sk
|
|
217
|
+
if (isModuleEnabled("rtk")) setupRtk(sk);
|
|
218
218
|
if (isModuleEnabled("wakatime")) setupWakatime(sk, pi);
|
|
219
219
|
|
|
220
220
|
// ── Tools (conditional on module switches) ────────────────────────────
|
|
@@ -225,11 +225,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
225
225
|
registerLspTools(pi, new LspServerManager());
|
|
226
226
|
}
|
|
227
227
|
for (const dep of lspDeps) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
if (dep.state !== "ok") {
|
|
229
|
+
sk.declareMissing({
|
|
230
|
+
name: dep.label,
|
|
231
|
+
module: "lsp",
|
|
232
|
+
hint: dep.detail,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
233
235
|
}
|
|
234
236
|
}
|
|
235
237
|
if (isModuleEnabled("ask")) registerAskTool(pi);
|
|
@@ -245,11 +247,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
245
247
|
sk.register(mcpModule);
|
|
246
248
|
const mcpDeps = collectMcpDependencyStatuses(process.cwd());
|
|
247
249
|
for (const dep of mcpDeps) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|
|
253
257
|
}
|
|
254
258
|
const configs = resolveMcpConfigs(process.cwd()).filter(s => s.enabled);
|
|
255
259
|
// Per-server readiness: cache hit → register from cache (fast).
|
package/package.json
CHANGED
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,15 +70,46 @@ 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)) {
|
|
@@ -99,6 +131,11 @@ export function saveConfig(config: Partial<DecoratedPiConfig>) {
|
|
|
99
131
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...config }, null, 2), "utf-8");
|
|
100
132
|
}
|
|
101
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
|
+
|
|
102
139
|
// ─── 辅助 ──────────────────────────────────────────────────────────────────
|
|
103
140
|
|
|
104
141
|
export function formatModelKey(m: Model<any>): string {
|
|
@@ -165,6 +202,77 @@ export function getCompactModelKey(): string | null {
|
|
|
165
202
|
return loadConfig().compactModelKey ?? null;
|
|
166
203
|
}
|
|
167
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
|
+
|
|
168
276
|
// ─── Setter ─────────────────────────────────────────────────────────────────
|
|
169
277
|
|
|
170
278
|
export function setImageModelKey(key: string | null) {
|
|
@@ -175,6 +283,47 @@ export function setCompactModelKey(key: string | null) {
|
|
|
175
283
|
saveConfig({ compactModelKey: key });
|
|
176
284
|
}
|
|
177
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
|
+
|
|
178
327
|
// ─── Module Switches ──────────────────────────────────────────────────────────
|
|
179
328
|
|
|
180
329
|
const DEFAULT_MODULES: Required<ModuleSettings> = {
|
|
@@ -321,14 +470,23 @@ export function getAllModuleSettings(): Required<ModuleSettings> {
|
|
|
321
470
|
* reload is actually necessary.
|
|
322
471
|
*/
|
|
323
472
|
let loadedModuleSnapshot: Required<ModuleSettings> | null = null;
|
|
473
|
+
let loadedDependenciesSnapshot: Record<string, DependencySettings> | null = null;
|
|
324
474
|
|
|
325
475
|
export function captureModuleSnapshot(): void {
|
|
326
476
|
loadedModuleSnapshot = getAllModuleSettings();
|
|
477
|
+
loadedDependenciesSnapshot = loadConfig().dependencies ?? {};
|
|
327
478
|
}
|
|
328
479
|
|
|
329
480
|
export function moduleSnapshotChanged(): boolean {
|
|
330
481
|
if (!loadedModuleSnapshot) return true;
|
|
331
|
-
|
|
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;
|
|
332
490
|
}
|
|
333
491
|
|
|
334
492
|
// ─── Usage index (增量同步元数据) ─────────────────────────────────────────────
|
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 {
|